From 18a7a2fc765b29bd05d2621c112a045d93a431bb Mon Sep 17 00:00:00 2001 From: alex Date: Fri, 14 Nov 2025 12:17:53 +0100 Subject: [PATCH 01/18] Adding bubbles support Signed-off-by: alex --- app/src/main/AndroidManifest.xml | 7 + .../com/nextcloud/talk/chat/BubbleActivity.kt | 72 ++++++ .../com/nextcloud/talk/chat/ChatActivity.kt | 195 +++++++++++++- .../ConversationInfoActivity.kt | 76 +++++- .../nextcloud/talk/jobs/NotificationWorker.kt | 238 ++++++++++++++++-- .../talk/settings/SettingsActivity.kt | 78 +++++- .../nextcloud/talk/utils/NotificationUtils.kt | 229 ++++++++++++++--- .../nextcloud/talk/utils/bundle/BundleKeys.kt | 2 + .../utils/preferences/AppPreferences.java | 8 + .../utils/preferences/AppPreferencesImpl.kt | 26 ++ .../res/layout/activity_conversation_info.xml | 1 + app/src/main/res/layout/activity_settings.xml | 72 ++++++ .../res/layout/item_notification_settings.xml | 40 +++ app/src/main/res/menu/menu_conversation.xml | 16 ++ app/src/main/res/values/strings.xml | 11 + 15 files changed, 1007 insertions(+), 64 deletions(-) create mode 100644 app/src/main/java/com/nextcloud/talk/chat/BubbleActivity.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ca34e3f2921..abe1c642970 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -168,6 +168,13 @@ android:name=".chat.ChatActivity" android:theme="@style/AppTheme" /> + + + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.chat + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import com.nextcloud.talk.R +import com.nextcloud.talk.utils.bundle.BundleKeys + +class BubbleActivity : ChatActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + supportActionBar?.setDisplayHomeAsUpEnabled(false) + supportActionBar?.setDisplayShowHomeEnabled(false) + } + + override fun onPrepareOptionsMenu(menu: android.view.Menu): Boolean { + super.onPrepareOptionsMenu(menu) + + menu.findItem(R.id.create_conversation_bubble)?.isVisible = false + menu.findItem(R.id.open_conversation_in_app)?.isVisible = true + + return true + } + + override fun onOptionsItemSelected(item: android.view.MenuItem): Boolean { + return when (item.itemId) { + R.id.open_conversation_in_app -> { + openInMainApp() + true + } + else -> super.onOptionsItemSelected(item) + } + } + + private fun openInMainApp() { + val intent = Intent(this, ChatActivity::class.java).apply { + putExtras(this@BubbleActivity.intent) + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP + } + startActivity(intent) + moveTaskToBack(false) + } + + override fun onBackPressed() { + moveTaskToBack(false) + } + + @Deprecated("Deprecated in Java") + override fun onSupportNavigateUp(): Boolean { + moveTaskToBack(false) + return true + } + + companion object { + fun newIntent(context: Context, roomToken: String, conversationName: String?): Intent { + return Intent(context, BubbleActivity::class.java).apply { + putExtra(BundleKeys.KEY_ROOM_TOKEN, roomToken) + conversationName?.let { putExtra(BundleKeys.KEY_CONVERSATION_NAME, it) } + action = Intent.ACTION_VIEW + flags = Intent.FLAG_ACTIVITY_NEW_DOCUMENT or Intent.FLAG_ACTIVITY_MULTIPLE_TASK + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt index f38bab5c772..0a28e1cfd72 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt @@ -1,6 +1,7 @@ /* * Nextcloud Talk - Android Client * + * SPDX-FileCopyrightText: 2025 Alexandre Wery * SPDX-FileCopyrightText: 2024 Christian Reiner * SPDX-FileCopyrightText: 2024 Parneet Singh * SPDX-FileCopyrightText: 2024 Giacomo Pacini @@ -163,6 +164,7 @@ import com.nextcloud.talk.models.json.threads.ThreadInfo import com.nextcloud.talk.polls.ui.PollCreateDialogFragment import com.nextcloud.talk.remotefilebrowser.activities.RemoteFileBrowserActivity import com.nextcloud.talk.shareditems.activities.SharedItemsActivity +import com.nextcloud.talk.settings.SettingsActivity import com.nextcloud.talk.signaling.SignalingMessageReceiver import com.nextcloud.talk.signaling.SignalingMessageSender import com.nextcloud.talk.threadsoverview.ThreadsOverviewActivity @@ -214,6 +216,7 @@ import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_THREAD_ID import com.nextcloud.talk.utils.permissions.PlatformPermissionUtil import com.nextcloud.talk.utils.rx.DisposableSet import com.nextcloud.talk.utils.singletons.ApplicationWideCurrentRoomHolder +import com.nextcloud.talk.utils.preferences.preferencestorage.DatabaseStorageModule import com.nextcloud.talk.webrtc.WebSocketConnectionHelper import com.nextcloud.talk.webrtc.WebSocketInstance import com.otaliastudios.autocomplete.Autocomplete @@ -252,7 +255,7 @@ import kotlin.math.roundToInt @Suppress("TooManyFunctions") @AutoInjector(NextcloudTalkApplication::class) -class ChatActivity : +open class ChatActivity : BaseActivity(), MessagesListAdapter.OnLoadMoreListener, MessagesListAdapter.Formatter, @@ -2665,24 +2668,33 @@ class ChatActivity : ) } - private fun showConversationInfoScreen() { + private fun showConversationInfoScreen(focusBubbleSwitch: Boolean = false) { val bundle = Bundle() bundle.putString(KEY_ROOM_TOKEN, roomToken) bundle.putBoolean(BundleKeys.KEY_ROOM_ONE_TO_ONE, isOneToOneConversation()) + if (focusBubbleSwitch) { + bundle.putBoolean(BundleKeys.KEY_FOCUS_CONVERSATION_BUBBLE, true) + } val intent = Intent(this, ConversationInfoActivity::class.java) intent.putExtras(bundle) startActivity(intent) } + private fun openBubbleSettings() { + val intent = Intent(this, SettingsActivity::class.java) + intent.putExtra(BundleKeys.KEY_FOCUS_BUBBLE_SETTINGS, true) + startActivity(intent) + } + private fun validSessionId(): Boolean = currentConversation != null && sessionIdAfterRoomJoined?.isNotEmpty() == true && sessionIdAfterRoomJoined != "0" @Suppress("Detekt.TooGenericExceptionCaught") - private fun cancelNotificationsForCurrentConversation() { + protected open fun cancelNotificationsForCurrentConversation() { if (conversationUser != null) { if (!TextUtils.isEmpty(roomToken)) { try { @@ -3271,6 +3283,10 @@ class ChatActivity : showThreadsItem.isVisible = !isChatThread() && hasSpreedFeatureCapability(spreedCapabilities, SpreedFeatures.THREADS) + val createBubbleItem = menu.findItem(R.id.create_conversation_bubble) + createBubbleItem.isVisible = android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.R && + !isChatThread() + if (CapabilitiesUtil.isAbleToCall(spreedCapabilities) && !isChatThread()) { conversationVoiceCallMenuItem = menu.findItem(R.id.conversation_voice_call) conversationVideoMenuItem = menu.findItem(R.id.conversation_video_call) @@ -3363,9 +3379,181 @@ class ChatActivity : true } + R.id.create_conversation_bubble -> { + createConversationBubble() + true + } + else -> super.onOptionsItemSelected(item) } + private fun createConversationBubble() { + lifecycleScope.launch { + if (!appPreferences.areBubblesEnabled()) { + Toast.makeText( + this@ChatActivity, + getString(R.string.nc_conversation_notification_bubble_disabled), + Toast.LENGTH_SHORT + ).show() + openBubbleSettings() + return@launch + } + + if (!appPreferences.areBubblesForced()) { + val conversationAllowsBubbles = isConversationBubbleEnabled() + if (!conversationAllowsBubbles) { + Toast.makeText( + this@ChatActivity, + getString(R.string.nc_conversation_notification_bubble_enable_conversation), + Toast.LENGTH_SHORT + ).show() + showConversationInfoScreen(focusBubbleSwitch = true) + return@launch + } + } + + try { + val shortcutId = "conversation_$roomToken" + val conversationName = currentConversation?.displayName ?: getString(R.string.nc_app_name) + + val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as android.app.NotificationManager + val notificationId = NotificationUtils.calculateCRC32(roomToken).toInt() + + notificationManager.cancel(notificationId) + androidx.core.content.pm.ShortcutManagerCompat.removeDynamicShortcuts(this@ChatActivity, listOf(shortcutId)) + + // Load conversation avatar on background thread + val avatarIcon = withContext(Dispatchers.IO) { + try { + var avatarUrl = if (isOneToOneConversation()) { + ApiUtils.getUrlForAvatar( + conversationUser!!.baseUrl!!, + currentConversation!!.name, + true + ) + } else { + ApiUtils.getUrlForConversationAvatar( + ApiUtils.API_V1, + conversationUser!!.baseUrl!!, + roomToken + ) + } + + if (DisplayUtils.isDarkModeOn(this@ChatActivity)) { + avatarUrl = "$avatarUrl/dark" + } + + NotificationUtils.loadAvatarSyncForBubble(avatarUrl, this@ChatActivity, credentials) + } catch (e: Exception) { + Log.e(TAG, "Error loading bubble avatar", e) + null + } + } + + val icon = avatarIcon ?: androidx.core.graphics.drawable.IconCompat.createWithResource( + this@ChatActivity, + R.drawable.ic_logo + ) + + val person = androidx.core.app.Person.Builder() + .setName(conversationName) + .setKey(shortcutId) + .setImportant(true) + .setIcon(icon) + .build() + + // Use the same request code calculation as NotificationWorker + val bubbleRequestCode = NotificationUtils.calculateCRC32("bubble_$roomToken").toInt() + + val bubbleIntent = android.app.PendingIntent.getActivity( + this@ChatActivity, + bubbleRequestCode, + BubbleActivity.newIntent(this@ChatActivity, roomToken, conversationName), + android.app.PendingIntent.FLAG_UPDATE_CURRENT or android.app.PendingIntent.FLAG_MUTABLE + ) + + val shortcut = androidx.core.content.pm.ShortcutInfoCompat.Builder(this@ChatActivity, shortcutId) + .setShortLabel(conversationName) + .setLongLabel(conversationName) + .setIcon(icon) + .setIntent(Intent(Intent.ACTION_DEFAULT)) + .setLongLived(true) + .setPerson(person) + .setCategories(setOf(android.app.Notification.CATEGORY_MESSAGE)) + .setLocusId(androidx.core.content.LocusIdCompat(shortcutId)) + .build() + + androidx.core.content.pm.ShortcutManagerCompat.pushDynamicShortcut(this@ChatActivity, shortcut) + + val bubbleData = androidx.core.app.NotificationCompat.BubbleMetadata.Builder( + bubbleIntent, + icon + ) + .setDesiredHeight(600) + .setAutoExpandBubble(false) + .setSuppressNotification(true) + .build() + + val messagingStyle = androidx.core.app.NotificationCompat.MessagingStyle(person) + .setConversationTitle(conversationName) + + // Create extras bundle to protect bubble from deletion + val notificationExtras = bundleOf( + BundleKeys.KEY_ROOM_TOKEN to roomToken, + BundleKeys.KEY_NOTIFICATION_RESTRICT_DELETION to true, + BundleKeys.KEY_INTERNAL_USER_ID to conversationUser!!.id!! + ) + + val channelId = NotificationUtils.NotificationChannels.NOTIFICATION_CHANNEL_MESSAGES_V4.name + val notification = androidx.core.app.NotificationCompat.Builder(this@ChatActivity, channelId) + .setContentTitle(conversationName) + .setSmallIcon(R.drawable.ic_notification) + .setCategory(androidx.core.app.NotificationCompat.CATEGORY_MESSAGE) + .setShortcutId(shortcutId) + .setLocusId(androidx.core.content.LocusIdCompat(shortcutId)) + .addPerson(person) + .setStyle(messagingStyle) + .setBubbleMetadata(bubbleData) + .setContentIntent(bubbleIntent) + .setAutoCancel(true) + .setOngoing(false) + .setOnlyAlertOnce(true) + .setExtras(notificationExtras) + .build() + + // Check if notification channel supports bubbles and recreate if needed + val channel = notificationManager.getNotificationChannel(channelId) + + if (channel == null || !channel.canBubble()) { + NotificationUtils.registerNotificationChannels( + applicationContext, + appPreferences!! + ) + } + + // Use the same notification ID calculation as NotificationWorker + // Show notification with bubble + notificationManager.notify(notificationId, notification) + + } catch (e: Exception) { + Log.e(TAG, "Error creating bubble", e) + Toast.makeText(this@ChatActivity, R.string.nc_common_error_sorry, Toast.LENGTH_SHORT).show() + } + } + } + + private suspend fun isConversationBubbleEnabled(): Boolean { + val user = conversationUser ?: return false + return withContext(Dispatchers.IO) { + try { + DatabaseStorageModule(user, roomToken).getBoolean(BUBBLE_SWITCH_KEY, false) + } catch (e: Exception) { + Log.e(TAG, "Failed to read conversation bubble preference", e) + false + } + } + } + @Suppress("Detekt.LongMethod") private fun showThreadNotificationMenu() { fun setThreadNotificationLevel(level: Int) { @@ -4593,6 +4781,7 @@ class ChatActivity : private const val CURRENT_AUDIO_POSITION_KEY = "CURRENT_AUDIO_POSITION" private const val CURRENT_AUDIO_WAS_PLAYING_KEY = "CURRENT_AUDIO_PLAYING" private const val RESUME_AUDIO_TAG = "RESUME_AUDIO_TAG" + private const val BUBBLE_SWITCH_KEY = "bubble_switch" private const val FIVE_MINUTES_IN_SECONDS: Long = 300 private const val ROOM_TYPE_ONE_TO_ONE = "1" private const val ACTOR_TYPE = "users" diff --git a/app/src/main/java/com/nextcloud/talk/conversationinfo/ConversationInfoActivity.kt b/app/src/main/java/com/nextcloud/talk/conversationinfo/ConversationInfoActivity.kt index 347ae60fc32..6824588969e 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationinfo/ConversationInfoActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationinfo/ConversationInfoActivity.kt @@ -12,6 +12,7 @@ package com.nextcloud.talk.conversationinfo import android.annotation.SuppressLint import android.content.Intent +import android.os.Build import android.os.Bundle import android.text.TextUtils import android.util.Log @@ -90,6 +91,8 @@ import com.nextcloud.talk.utils.CapabilitiesUtil.hasSpreedFeatureCapability import com.nextcloud.talk.utils.ConversationUtils import com.nextcloud.talk.utils.DateConstants import com.nextcloud.talk.utils.DateUtils +import com.nextcloud.talk.utils.DrawableUtils +import com.nextcloud.talk.utils.NotificationUtils import com.nextcloud.talk.utils.ShareUtils import com.nextcloud.talk.utils.SpreedFeatures import com.nextcloud.talk.utils.bundle.BundleKeys @@ -144,6 +147,7 @@ class ConversationInfoActivity : private var databaseStorageModule: DatabaseStorageModule? = null private var conversation: ConversationModel? = null + private var focusBubbleSwitch: Boolean = false private var adapter: FlexibleAdapter? = null private var userItems: MutableList = ArrayList() @@ -200,6 +204,7 @@ class ConversationInfoActivity : conversationToken = intent.getStringExtra(KEY_ROOM_TOKEN)!! hasAvatarSpacing = intent.getBooleanExtra(BundleKeys.KEY_ROOM_ONE_TO_ONE, false) + focusBubbleSwitch = intent.getBooleanExtra(BundleKeys.KEY_FOCUS_CONVERSATION_BUBBLE, false) credentials = ApiUtils.getCredentials(conversationUser.username, conversationUser.token)!! } @@ -570,7 +575,8 @@ class ConversationInfoActivity : binding.guestAccessView.passwordProtectionSwitch, binding.recordingConsentView.recordingConsentForConversationSwitch, binding.lockConversationSwitch, - binding.notificationSettingsView.sensitiveConversationSwitch + binding.notificationSettingsView.sensitiveConversationSwitch, + binding.notificationSettingsView.bubbleSwitch ).forEach(viewThemeUtils.talk::colorSwitch) } } @@ -1873,6 +1879,13 @@ class ConversationInfoActivity : } private fun setUpNotificationSettings(module: DatabaseStorageModule) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + binding.notificationSettingsView.notificationSettingsBubble.visibility = VISIBLE + configureConversationBubbleSetting(module) + } else { + binding.notificationSettingsView.notificationSettingsBubble.visibility = GONE + } + binding.notificationSettingsView.notificationSettingsCallNotifications.setOnClickListener { val isChecked = binding.notificationSettingsView.callNotificationsSwitch.isChecked binding.notificationSettingsView.callNotificationsSwitch.isChecked = !isChecked @@ -1899,6 +1912,66 @@ class ConversationInfoActivity : } } + private fun configureConversationBubbleSetting(module: DatabaseStorageModule) { + val bubbleRow = binding.notificationSettingsView.notificationSettingsBubble + val bubbleSwitch = binding.notificationSettingsView.bubbleSwitch + val bubbleSummary = binding.notificationSettingsView.notificationSettingsBubbleSummary + + val globalBubblesEnabled = appPreferences.areBubblesEnabled() + val forceAllBubbles = appPreferences.areBubblesForced() + val storedPreference = module.getBoolean(BUBBLE_SWITCH_KEY, false) + + val effectiveState = when { + !globalBubblesEnabled -> false + forceAllBubbles -> true + else -> storedPreference + } + bubbleSwitch.isChecked = effectiveState + + val rowIsInteractive = globalBubblesEnabled && !forceAllBubbles + bubbleRow.isEnabled = rowIsInteractive + bubbleSwitch.isEnabled = rowIsInteractive + bubbleRow.alpha = if (rowIsInteractive) 1f else LOW_EMPHASIS_OPACITY + + val summaryText = when { + !globalBubblesEnabled -> R.string.nc_conversation_notification_bubble_disabled + forceAllBubbles -> R.string.nc_conversation_notification_bubble_forced + else -> R.string.nc_conversation_notification_bubble_desc + } + bubbleSummary.setText(summaryText) + + bubbleRow.setOnClickListener { + if (!rowIsInteractive) { + return@setOnClickListener + } + val newValue = !bubbleSwitch.isChecked + bubbleSwitch.isChecked = newValue + lifecycleScope.launch { + module.saveBoolean(BUBBLE_SWITCH_KEY, newValue) + } + if (!newValue) { + NotificationUtils.dismissBubbleForRoom(this@ConversationInfoActivity, conversationUser, conversationToken) + } + } + + if (focusBubbleSwitch) { + focusBubbleSwitch = false + highlightBubbleRow(bubbleRow) + } + } + + private fun highlightBubbleRow(target: View) { + binding.conversationInfoScroll.post { + val scrollViewLocation = IntArray(2) + val targetLocation = IntArray(2) + binding.conversationInfoScroll.getLocationOnScreen(scrollViewLocation) + target.getLocationOnScreen(targetLocation) + val offset = targetLocation[1] - scrollViewLocation[1] + binding.conversationInfoScroll.smoothScrollBy(0, offset) + target.background?.let { DrawableUtils.blinkDrawable(it) } + } + } + companion object { private val TAG = ConversationInfoActivity::class.java.simpleName private const val NOTIFICATION_LEVEL_ALWAYS: Int = 1 @@ -1910,6 +1983,7 @@ class ConversationInfoActivity : private const val DEMOTE_OR_PROMOTE = 1 private const val REMOVE_FROM_CONVERSATION = 2 private const val BAN_FROM_CONVERSATION = 3 + private const val BUBBLE_SWITCH_KEY = "bubble_switch" } /** diff --git a/app/src/main/java/com/nextcloud/talk/jobs/NotificationWorker.kt b/app/src/main/java/com/nextcloud/talk/jobs/NotificationWorker.kt index 75f67286e3e..65461e97f69 100644 --- a/app/src/main/java/com/nextcloud/talk/jobs/NotificationWorker.kt +++ b/app/src/main/java/com/nextcloud/talk/jobs/NotificationWorker.kt @@ -64,10 +64,12 @@ import com.nextcloud.talk.receivers.ShareRecordingToChatReceiver import com.nextcloud.talk.users.UserManager import com.nextcloud.talk.utils.ApiUtils import com.nextcloud.talk.utils.ConversationUtils +import com.nextcloud.talk.utils.DisplayUtils import com.nextcloud.talk.utils.NotificationUtils import com.nextcloud.talk.utils.NotificationUtils.cancelAllNotificationsForAccount import com.nextcloud.talk.utils.NotificationUtils.cancelNotification import com.nextcloud.talk.utils.NotificationUtils.findNotificationForRoom +import com.nextcloud.talk.utils.UserIdUtils import com.nextcloud.talk.utils.NotificationUtils.getCallRingtoneUri import com.nextcloud.talk.utils.NotificationUtils.loadAvatarSync import com.nextcloud.talk.utils.ParticipantPermissions @@ -102,7 +104,6 @@ import java.security.NoSuchAlgorithmException import java.security.PrivateKey import java.util.concurrent.TimeUnit import java.util.function.Consumer -import java.util.zip.CRC32 import javax.crypto.Cipher import javax.crypto.NoSuchPaddingException import javax.inject.Inject @@ -509,7 +510,10 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor contentText = EmojiCompat.get().process(pushMessage.text) } - val autoCancelOnClick = TYPE_RECORDING != pushMessage.type + // Bubbles need the notification to stay alive + val autoCancelOnClick = TYPE_RECORDING != pushMessage.type && + TYPE_CHAT != pushMessage.type && + TYPE_REMINDER != pushMessage.type val notificationBuilder = createNotificationBuilder( @@ -533,16 +537,87 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor // NOTE - systemNotificationId is an internal ID used on the device only. // It is NOT the same as the notification ID used in communication with the server. + // Use a consistent ID based on the room token to avoid duplicate bubbles val systemNotificationId: Int = - activeStatusBarNotification?.id ?: calculateCRC32(System.currentTimeMillis().toString()).toInt() + activeStatusBarNotification?.id ?: NotificationUtils.calculateCRC32(pushMessage.id!!).toInt() - if (TYPE_CHAT == pushMessage.type || TYPE_REMINDER == pushMessage.type) { + if ((TYPE_CHAT == pushMessage.type || TYPE_REMINDER == pushMessage.type) && + pushMessage.notificationUser != null + ) { notificationBuilder.setOnlyAlertOnce(false) - if (pushMessage.notificationUser != null) { - styleChatNotification(notificationBuilder, activeStatusBarNotification) - addReplyAction(notificationBuilder, systemNotificationId) - addMarkAsReadAction(notificationBuilder, systemNotificationId) + prepareChatNotification(notificationBuilder, activeStatusBarNotification) + val shortcutId = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + "conversation_${pushMessage.id}" + } else { + null + } + val roomToken = pushMessage.id + val bubbleAllowed = roomToken?.let { shouldBubble(it) } ?: false + var effectiveShortcutId = if (bubbleAllowed) shortcutId else null + // Only add bubble metadata if there's no existing notification + // If one exists, the bubble metadata will be preserved + if (activeStatusBarNotification == null) { + if (bubbleAllowed) { + addBubbleMetadata(notificationBuilder, false) + } + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + if (!bubbleAllowed) { + notificationBuilder.setBubbleMetadata(null) + } else { + // Preserve existing bubble metadata + val existingBubble = activeStatusBarNotification.notification.bubbleMetadata + if (existingBubble != null) { + val compatBubble = NotificationCompat.BubbleMetadata.fromPlatform(existingBubble) + if (compatBubble != null) { + val preservedBubbleBuilder = when { + !compatBubble.shortcutId.isNullOrEmpty() -> + NotificationCompat.BubbleMetadata.Builder(compatBubble.shortcutId!!) + + compatBubble.intent != null && compatBubble.icon != null -> + NotificationCompat.BubbleMetadata.Builder( + compatBubble.intent!!, + compatBubble.icon!! + ) + + else -> null + } + if (preservedBubbleBuilder == null) { + addBubbleMetadata(notificationBuilder, existingBubble.isNotificationSuppressed) + } else { + compatBubble.deleteIntent?.let { preservedBubbleBuilder.setDeleteIntent(it) } + if (compatBubble.desiredHeight > 0) { + preservedBubbleBuilder.setDesiredHeight(compatBubble.desiredHeight) + } + if (compatBubble.desiredHeightResId != 0) { + preservedBubbleBuilder.setDesiredHeightResId(compatBubble.desiredHeightResId) + } + preservedBubbleBuilder + .setAutoExpandBubble(existingBubble.autoExpandBubble) + .setSuppressNotification(false) + val preservedMetadata = preservedBubbleBuilder.build() + notificationBuilder.setBubbleMetadata(preservedMetadata) + + val existingShortcut = compatBubble.shortcutId + if (!existingShortcut.isNullOrEmpty()) { + effectiveShortcutId = existingShortcut + } + } + } else { + addBubbleMetadata(notificationBuilder, existingBubble.isNotificationSuppressed) + } + } else { + addBubbleMetadata(notificationBuilder, false) + } + } } + if (!effectiveShortcutId.isNullOrEmpty()) { + val ensuredShortcutId = effectiveShortcutId!! + val locusId = androidx.core.content.LocusIdCompat(ensuredShortcutId) + notificationBuilder.setShortcutId(ensuredShortcutId) + notificationBuilder.setLocusId(locusId) + } + addReplyAction(notificationBuilder, systemNotificationId) + addMarkAsReadAction(notificationBuilder, systemNotificationId) } if (TYPE_RECORDING == pushMessage.type && ncNotification != null) { @@ -560,7 +635,10 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor pendingIntent: PendingIntent?, autoCancelOnClick: Boolean ): NotificationCompat.Builder { - val notificationBuilder = NotificationCompat.Builder(context!!, "1") + val notificationBuilder = NotificationCompat.Builder( + context!!, + NotificationUtils.NotificationChannels.NOTIFICATION_CHANNEL_MESSAGES_V4.name + ) .setPriority(NotificationCompat.PRIORITY_HIGH) .setCategory(category) .setSmallIcon(R.drawable.ic_notification) @@ -571,6 +649,8 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor .setShowWhen(true) .setContentIntent(pendingIntent) .setAutoCancel(autoCancelOnClick) + .setOngoing(!autoCancelOnClick) + .setOnlyAlertOnce(true) .setColor(context!!.resources.getColor(R.color.colorPrimary, null)) val notificationInfoBundle = Bundle() @@ -579,7 +659,8 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor notificationInfoBundle.putString(KEY_ROOM_TOKEN, pushMessage.id) notificationInfoBundle.putLong(KEY_NOTIFICATION_ID, pushMessage.notificationId!!) - if (pushMessage.type == TYPE_RECORDING) { + // Protect bubble notifications from being canceled + if (pushMessage.type == TYPE_RECORDING || pushMessage.type == TYPE_CHAT || pushMessage.type == TYPE_REMINDER) { notificationInfoBundle.putBoolean(KEY_NOTIFICATION_RESTRICT_DELETION, true) } @@ -600,7 +681,7 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor notificationBuilder.setContentIntent(pendingIntent) val groupName = signatureVerification.user!!.id.toString() + "@" + pushMessage.id - notificationBuilder.setGroup(calculateCRC32(groupName).toString()) + notificationBuilder.setGroup(NotificationUtils.calculateCRC32(groupName).toString()) return notificationBuilder } @@ -636,14 +717,7 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor } return largeIcon } - - private fun calculateCRC32(s: String): Long { - val crc32 = CRC32() - crc32.update(s.toByteArray()) - return crc32.value - } - - private fun styleChatNotification( + private fun prepareChatNotification( notificationBuilder: NotificationCompat.Builder, activeStatusBarNotification: StatusBarNotification? ) { @@ -659,6 +733,7 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor val person = Person.Builder() .setKey(signatureVerification.user!!.id.toString() + "@" + notificationUser.id) .setName(EmojiCompat.get().process(notificationUser.name!!)) + .setImportant(true) .setBot("bot" == userType) if ("user" == userType || "guest" == userType) { @@ -674,7 +749,127 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor } person.setIcon(loadAvatarSync(avatarUrl, context!!)) } - notificationBuilder.setStyle(getStyle(person.build(), style)) + + val personBuilt = person.build() + notificationBuilder.setStyle(getStyle(personBuilt, style)) + notificationBuilder.addPerson(personBuilt) + } + + private fun addBubbleMetadata( + notificationBuilder: NotificationCompat.Builder, + suppressNotification: Boolean + ) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { + return // Bubbles require API 30+ (Android 11) + } + + try { + val roomToken = pushMessage.id ?: return + if (!shouldBubble(roomToken)) { + return + } + val conversationName = pushMessage.subject + val shortcutId = "conversation_$roomToken" + + val bubbleIcon = resolveBubbleIcon(roomToken) ?: run { + val fallbackBitmap = getLargeIcon() ?: return + androidx.core.graphics.drawable.IconCompat.createWithBitmap(fallbackBitmap) + } + + val person = androidx.core.app.Person.Builder() + .setName(conversationName ?: context!!.getString(R.string.nc_app_name)) + .setKey(shortcutId) + .setImportant(true) + .setIcon(bubbleIcon) + .build() + + val shortcut = androidx.core.content.pm.ShortcutInfoCompat.Builder(context!!, shortcutId) + .setShortLabel(conversationName ?: context!!.getString(R.string.nc_app_name)) + .setLongLabel(conversationName ?: context!!.getString(R.string.nc_app_name)) + .setIcon(bubbleIcon) + .setIntent(Intent(Intent.ACTION_DEFAULT)) + .setLongLived(true) + .setPerson(person) + .setCategories(setOf(Notification.CATEGORY_MESSAGE)) + .setLocusId(androidx.core.content.LocusIdCompat(shortcutId)) + .build() + + androidx.core.content.pm.ShortcutManagerCompat.pushDynamicShortcut(context!!, shortcut) + + val bubbleRequestCode = NotificationUtils.calculateCRC32("bubble_$roomToken").toInt() + val bubbleIntent = android.app.PendingIntent.getActivity( + context, + bubbleRequestCode, + com.nextcloud.talk.chat.BubbleActivity.newIntent(context!!, roomToken, conversationName), + android.app.PendingIntent.FLAG_UPDATE_CURRENT or android.app.PendingIntent.FLAG_MUTABLE + ) + + val bubbleData = androidx.core.app.NotificationCompat.BubbleMetadata.Builder( + bubbleIntent, + bubbleIcon + ) + .setDesiredHeight(600) + .setAutoExpandBubble(false) + .setSuppressNotification(suppressNotification) + .build() + + notificationBuilder.setBubbleMetadata(bubbleData) + notificationBuilder.setShortcutId(shortcutId) + notificationBuilder.setLocusId(androidx.core.content.LocusIdCompat(shortcutId)) + + } catch (e: Exception) { + android.util.Log.e(TAG, "Error adding bubble metadata", e) + } + } + + private fun shouldBubble(roomToken: String): Boolean { + if (!appPreferences.areBubblesEnabled()) { + return false + } + if (appPreferences.areBubblesForced()) { + return true + } + val user = signatureVerification.user ?: return false + val accountId = UserIdUtils.getIdForUser(user) + return arbitraryStorageManager + ?.getStorageSetting(accountId, BUBBLE_SWITCH_KEY, roomToken) + ?.map { storage -> storage.value?.toBoolean() ?: false } + ?.blockingGet(false) ?: false + } + + private fun resolveBubbleIcon(roomToken: String): androidx.core.graphics.drawable.IconCompat? { + val ctx = context ?: return null + val baseUrl = signatureVerification.user?.baseUrl ?: return null + val isDarkMode = DisplayUtils.isDarkModeOn(ctx) + + var conversationAvatarUrl = ApiUtils.getUrlForConversationAvatar(ApiUtils.API_V1, baseUrl, roomToken) + if (isDarkMode) { + conversationAvatarUrl += "/dark" + } + + NotificationUtils.loadAvatarSyncForBubble(conversationAvatarUrl, ctx, credentials)?.let { + return it + } + + if (conversationType.equals("one2one", ignoreCase = true)) { + val notificationUser = pushMessage.notificationUser + val userType = notificationUser?.type + val userAvatarUrl = when { + notificationUser == null || notificationUser.id.isNullOrEmpty() -> null + userType.equals("guest", ignoreCase = true) -> + ApiUtils.getUrlForGuestAvatar(baseUrl, notificationUser.name, true) + isDarkMode -> ApiUtils.getUrlForAvatarDarkTheme(baseUrl, notificationUser.id, true) + else -> ApiUtils.getUrlForAvatar(baseUrl, notificationUser.id, true) + } + + if (userAvatarUrl != null) { + NotificationUtils.loadAvatarSyncForBubble(userAvatarUrl, ctx, credentials)?.let { + return it + } + } + } + + return null } private fun buildIntentForAction(cls: Class<*>, systemNotificationId: Int, messageId: Int): PendingIntent { @@ -1036,6 +1231,7 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor private const val TIMER_START = 1 private const val TIMER_COUNT = 12 private const val TIMER_DELAY: Long = 5 - private const val LINEBREAK: String = "\n" + private const val LINEBREAK: String = "\n" + private const val BUBBLE_SWITCH_KEY = "bubble_switch" } } diff --git a/app/src/main/java/com/nextcloud/talk/settings/SettingsActivity.kt b/app/src/main/java/com/nextcloud/talk/settings/SettingsActivity.kt index 6b7c8a826f2..6a1766a5cc0 100644 --- a/app/src/main/java/com/nextcloud/talk/settings/SettingsActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/settings/SettingsActivity.kt @@ -83,6 +83,7 @@ import com.nextcloud.talk.utils.NotificationUtils.getCallRingtoneUri import com.nextcloud.talk.utils.NotificationUtils.getMessageRingtoneUri import com.nextcloud.talk.utils.SecurityUtils import com.nextcloud.talk.utils.SpreedFeatures +import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_FOCUS_BUBBLE_SETTINGS import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_SCROLL_TO_NOTIFICATION_CATEGORY import com.nextcloud.talk.utils.permissions.PlatformPermissionUtil import com.nextcloud.talk.utils.power.PowerManagerUtils @@ -142,6 +143,7 @@ class SettingsActivity : private var profileQueryDisposable: Disposable? = null private var dbQueryDisposable: Disposable? = null private var openedByNotificationWarning: Boolean = false + private var focusBubbleSettings: Boolean = false private var isOnline: MutableState = mutableStateOf(false) @SuppressLint("StringFormatInvalid") @@ -196,6 +198,7 @@ class SettingsActivity : private fun handleIntent(intent: Intent) { val extras: Bundle? = intent.extras openedByNotificationWarning = extras?.getBoolean(KEY_SCROLL_TO_NOTIFICATION_CATEGORY) ?: false + focusBubbleSettings = extras?.getBoolean(KEY_FOCUS_BUBBLE_SETTINGS) ?: false } override fun onResume() { @@ -247,20 +250,33 @@ class SettingsActivity : themeTitles() themeSwitchPreferences() - if (openedByNotificationWarning) { - scrollToNotificationCategory() + when { + focusBubbleSettings -> scrollToBubbleSettings() + openedByNotificationWarning -> scrollToNotificationCategory() } } @Suppress("MagicNumber") private fun scrollToNotificationCategory() { + scrollToView(binding.settingsNotificationsCategory) + } + + private fun scrollToBubbleSettings() { + focusBubbleSettings = false + scrollToView(binding.settingsBubbles, blinkBackground = true) + } + + private fun scrollToView(targetView: View, blinkBackground: Boolean = false) { binding.scrollView.post { val scrollViewLocation = IntArray(2) val targetLocation = IntArray(2) binding.scrollView.getLocationOnScreen(scrollViewLocation) - binding.settingsNotificationsCategory.getLocationOnScreen(targetLocation) + targetView.getLocationOnScreen(targetLocation) val offset = targetLocation[1] - scrollViewLocation[1] binding.scrollView.scrollBy(0, offset) + if (blinkBackground) { + targetView.background?.let { DrawableUtils.blinkDrawable(it) } + } } } @@ -311,6 +327,7 @@ class SettingsActivity : setupNotificationSoundsSettings() setupNotificationPermissionSettings() setupServerNotificationAppCheck() + setupBubbleSettings() } @SuppressLint("StringFormatInvalid") @@ -404,6 +421,57 @@ class SettingsActivity : } } + private fun setupBubbleSettings() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { + binding.settingsBubbles.visibility = View.GONE + binding.settingsBubblesForce.visibility = View.GONE + return + } + + binding.settingsBubbles.visibility = View.VISIBLE + binding.settingsBubblesForce.visibility = View.VISIBLE + + val bubblesEnabled = appPreferences.areBubblesEnabled() + binding.settingsBubblesSwitch.isChecked = bubblesEnabled + binding.settingsBubblesForceSwitch.isChecked = appPreferences.areBubblesForced() + + updateBubbleForceRowState(bubblesEnabled) + + binding.settingsBubbles.setOnClickListener { + val newValue = !binding.settingsBubblesSwitch.isChecked + binding.settingsBubblesSwitch.isChecked = newValue + appPreferences.setBubblesEnabled(newValue) + updateBubbleForceRowState(newValue) + + // Dismiss all active bubbles when disabling globally + if (!newValue) { + currentUser?.let { user -> + NotificationUtils.dismissAllBubbles(this, user) + } + } + } + + binding.settingsBubblesForce.setOnClickListener { + if (!binding.settingsBubblesSwitch.isChecked) { + return@setOnClickListener + } + val newValue = !binding.settingsBubblesForceSwitch.isChecked + binding.settingsBubblesForceSwitch.isChecked = newValue + appPreferences.setBubblesForced(newValue) + + // When disabling "force all", dismiss bubbles without explicit per-conversation settings + if (!newValue) { + NotificationUtils.dismissBubblesWithoutExplicitSettings(this, currentUserProvider.currentUser.blockingGet()) + } + } + } + + private fun updateBubbleForceRowState(globalEnabled: Boolean) { + binding.settingsBubblesForce.isEnabled = globalEnabled + binding.settingsBubblesForce.alpha = if (globalEnabled) ENABLED_ALPHA else DISABLED_ALPHA + binding.settingsBubblesForceSwitch.isEnabled = globalEnabled + } + private fun setupNotificationSoundsSettings() { if (NotificationUtils.isCallsNotificationChannelEnabled(this)) { val callRingtoneUri = getCallRingtoneUri(context, (appPreferences)) @@ -777,7 +845,9 @@ class SettingsActivity : settingsPhoneBookIntegrationSwitch, settingsReadPrivacySwitch, settingsTypingStatusSwitch, - settingsProxyUseCredentialsSwitch + settingsProxyUseCredentialsSwitch, + settingsBubblesSwitch, + settingsBubblesForceSwitch ).forEach(viewThemeUtils.talk::colorSwitch) } } diff --git a/app/src/main/java/com/nextcloud/talk/utils/NotificationUtils.kt b/app/src/main/java/com/nextcloud/talk/utils/NotificationUtils.kt index 4023b8083eb..e8d449701ae 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/NotificationUtils.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/NotificationUtils.kt @@ -11,17 +11,30 @@ import android.app.Notification import android.app.NotificationChannel import android.app.NotificationManager import android.content.Context +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.PorterDuff +import android.graphics.PorterDuffXfermode +import android.graphics.Rect import android.graphics.drawable.BitmapDrawable +import android.graphics.drawable.Drawable import android.media.AudioAttributes import android.net.Uri import android.service.notification.StatusBarNotification import android.text.TextUtils import android.util.Log +import androidx.core.content.ContextCompat +import androidx.core.content.pm.ShortcutManagerCompat import androidx.core.graphics.drawable.IconCompat +import androidx.core.graphics.drawable.toBitmap import androidx.core.net.toUri import coil.executeBlocking import coil.imageLoader import coil.request.ImageRequest +import coil.size.Precision +import coil.size.Scale import coil.transform.CircleCropTransformation import com.bluelinelabs.logansquare.LoganSquare import com.nextcloud.talk.BuildConfig @@ -31,11 +44,19 @@ import com.nextcloud.talk.models.RingtoneSettings import com.nextcloud.talk.utils.bundle.BundleKeys import com.nextcloud.talk.utils.preferences.AppPreferences import java.io.IOException +import java.util.concurrent.ConcurrentHashMap +import java.util.zip.CRC32 +import kotlin.math.max +import kotlin.math.min +import kotlin.math.roundToInt @Suppress("TooManyFunctions") object NotificationUtils { const val TAG = "NotificationUtils" + private const val BUBBLE_ICON_SIZE_DP = 96 + private const val BUBBLE_ICON_CONTENT_RATIO = 0.68f + private val bubbleIconCache = ConcurrentHashMap() enum class NotificationChannels { NOTIFICATION_CHANNEL_MESSAGES_V4, @@ -62,27 +83,33 @@ object NotificationUtils { audioAttributes: AudioAttributes? ) { val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + val isMessagesChannel = notificationChannel.id == NotificationChannels.NOTIFICATION_CHANNEL_MESSAGES_V4.name + val shouldSupportBubbles = android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.R && isMessagesChannel + + val existingChannel = notificationManager.getNotificationChannel(notificationChannel.id) + val needsRecreation = shouldSupportBubbles && existingChannel != null && !existingChannel.canBubble() + + if (existingChannel == null || needsRecreation) { + if (needsRecreation) { + notificationManager.deleteNotificationChannel(notificationChannel.id) + } - if ( - notificationManager.getNotificationChannel(notificationChannel.id) == null - ) { val importance = if (notificationChannel.isImportant) { NotificationManager.IMPORTANCE_HIGH } else { NotificationManager.IMPORTANCE_LOW } - val channel = NotificationChannel( - notificationChannel.id, - notificationChannel.name, - importance - ) - - channel.description = notificationChannel.description - channel.enableLights(true) - channel.lightColor = R.color.colorPrimary - channel.setSound(sound, audioAttributes) - channel.setBypassDnd(false) + val channel = NotificationChannel(notificationChannel.id, notificationChannel.name, importance).apply { + description = notificationChannel.description + enableLights(true) + lightColor = R.color.colorPrimary + setSound(sound, audioAttributes) + setBypassDnd(false) + if (shouldSupportBubbles) { + setAllowBubbles(true) + } + } notificationManager.createNotificationChannel(channel) } @@ -212,7 +239,13 @@ object NotificationUtils { fun cancelNotification(context: Context?, conversationUser: User, notificationId: Long?) { scanNotifications(context, conversationUser) { notificationManager, statusBarNotification, notification -> if (notificationId == notification.extras.getLong(BundleKeys.KEY_NOTIFICATION_ID)) { - notificationManager.cancel(statusBarNotification.id) + if (notification.extras.getBoolean(BundleKeys.KEY_NOTIFICATION_RESTRICT_DELETION)) { + if (BuildConfig.DEBUG) { + Log.d(TAG, "Skip cancelling protected notification ${statusBarNotification.id}") + } + } else { + notificationManager.cancel(statusBarNotification.id) + } } } } @@ -240,36 +273,58 @@ object NotificationUtils { } } - fun isNotificationVisible(context: Context?, notificationId: Int): Boolean { - var isVisible = false + private fun dismissBubbles( + context: Context?, + conversationUser: User, + predicate: (String) -> Boolean + ) { + if (context == null) return - val notificationManager = context!!.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - val notifications = notificationManager.activeNotifications - for (notification in notifications) { - if (notification.id == notificationId) { - isVisible = true - break + val shortcutsToRemove = mutableListOf() + + scanNotifications(context, conversationUser) { notificationManager, statusBarNotification, notification -> + val roomToken = notification.extras.getString(BundleKeys.KEY_ROOM_TOKEN) + if (roomToken != null && predicate(roomToken)) { + notificationManager.cancel(statusBarNotification.id) + shortcutsToRemove.add("conversation_$roomToken") } } - return isVisible - } - fun isCallsNotificationChannelEnabled(context: Context): Boolean { - val channel = getNotificationChannel(context, NotificationChannels.NOTIFICATION_CHANNEL_CALLS_V4.name) - if (channel != null) { - return isNotificationChannelEnabled(channel) + if (shortcutsToRemove.isNotEmpty()) { + ShortcutManagerCompat.removeDynamicShortcuts(context, shortcutsToRemove) } - return false } - fun isMessagesNotificationChannelEnabled(context: Context): Boolean { - val channel = getNotificationChannel(context, NotificationChannels.NOTIFICATION_CHANNEL_MESSAGES_V4.name) - if (channel != null) { - return isNotificationChannelEnabled(channel) + fun dismissBubbleForRoom(context: Context?, conversationUser: User, roomTokenOrId: String) { + dismissBubbles(context, conversationUser) { it == roomTokenOrId } + } + + fun dismissAllBubbles(context: Context?, conversationUser: User) { + dismissBubbles(context, conversationUser) { true } + } + + fun dismissBubblesWithoutExplicitSettings(context: Context?, conversationUser: User) { + dismissBubbles(context, conversationUser) { roomToken -> + !com.nextcloud.talk.utils.preferences.preferencestorage.DatabaseStorageModule( + conversationUser, + roomToken + ).getBoolean("bubble_switch", false) } - return false } + fun isNotificationVisible(context: Context?, notificationId: Int): Boolean { + val notificationManager = context!!.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + return notificationManager.activeNotifications.any { it.id == notificationId } + } + + fun isCallsNotificationChannelEnabled(context: Context): Boolean = + getNotificationChannel(context, NotificationChannels.NOTIFICATION_CHANNEL_CALLS_V4.name) + ?.let { isNotificationChannelEnabled(it) } ?: false + + fun isMessagesNotificationChannelEnabled(context: Context): Boolean = + getNotificationChannel(context, NotificationChannels.NOTIFICATION_CHANNEL_MESSAGES_V4.name) + ?.let { isNotificationChannelEnabled(it) } ?: false + private fun isNotificationChannelEnabled(channel: NotificationChannel): Boolean = channel.importance != NotificationManager.IMPORTANCE_NONE @@ -338,9 +393,113 @@ object NotificationUtils { .build() context.imageLoader.executeBlocking(request) + + return avatarIcon + } + fun loadAvatarSyncForBubble(url: String?, context: Context, credentials: String?): IconCompat? { + if (url.isNullOrEmpty()) { + Log.w(TAG, "Avatar URL is null or empty for bubble") + return null + } + + bubbleIconCache[url]?.let { return it } + + var avatarIcon: IconCompat? = null + val bubbleSizePx = context.bubbleIconSizePx() + + val requestBuilder = ImageRequest.Builder(context) + .data(url) + .placeholder(R.drawable.account_circle_96dp) + .size(bubbleSizePx * 4, bubbleSizePx * 4) + .precision(Precision.EXACT) + .scale(Scale.FIT) + .allowHardware(false) + .bitmapConfig(Bitmap.Config.ARGB_8888) + + if (!credentials.isNullOrEmpty()) { + requestBuilder.addHeader("Authorization", credentials) + } + + val request = requestBuilder.target( + onSuccess = { result -> + avatarIcon = IconCompat.createWithAdaptiveBitmap( + result.toBubbleBitmap(bubbleSizePx, BUBBLE_ICON_CONTENT_RATIO) + ) + }, + onError = { error -> + (error ?: ContextCompat.getDrawable(context, R.drawable.account_circle_96dp))?.let { + avatarIcon = IconCompat.createWithAdaptiveBitmap( + it.toBubbleBitmap(bubbleSizePx, BUBBLE_ICON_CONTENT_RATIO) + ) + } + } + ) + .build() + + context.imageLoader.executeBlocking(request) + + avatarIcon?.let { bubbleIconCache[url] = it } return avatarIcon } + private data class Channel(val id: String, val name: String, val description: String, val isImportant: Boolean) + + private fun Context.bubbleIconSizePx(): Int = + (BUBBLE_ICON_SIZE_DP * resources.displayMetrics.density).roundToInt().coerceAtLeast(1) + + private fun Drawable.toBubbleBitmap(size: Int, contentRatio: Float): Bitmap { + val safeRatio = contentRatio.coerceIn(0.5f, 1f) + val drawable = this.constantState?.newDrawable()?.mutate() ?: this.mutate() + + val sourceWidth = max(1, if (drawable.intrinsicWidth > 0) drawable.intrinsicWidth else size) + val sourceHeight = max(1, if (drawable.intrinsicHeight > 0) drawable.intrinsicHeight else size) + val sourceBitmap = drawable.toBitmap(sourceWidth, sourceHeight, Bitmap.Config.ARGB_8888) + + val minDimension = min(sourceWidth, sourceHeight) + val cropX = (sourceWidth - minDimension) / 2 + val cropY = (sourceHeight - minDimension) / 2 + val squareBitmap = Bitmap.createBitmap(sourceBitmap, cropX, cropY, minDimension, minDimension) + if (squareBitmap != sourceBitmap) { + sourceBitmap.recycle() + } + + val resultBitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888) + val canvas = Canvas(resultBitmap) + val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + isFilterBitmap = true + isDither = true + } + + canvas.drawARGB(0, 0, 0, 0) + paint.color = Color.BLACK + canvas.drawCircle(size / 2f, size / 2f, size / 2f, paint) + + paint.xfermode = PorterDuffXfermode(PorterDuff.Mode.SRC_IN) + val targetDiameter = (size * safeRatio).roundToInt().coerceAtLeast(1) + val destRect = Rect( + ((size - targetDiameter) / 2f).roundToInt(), + ((size - targetDiameter) / 2f).roundToInt(), + ((size + targetDiameter) / 2f).roundToInt(), + ((size + targetDiameter) / 2f).roundToInt() + ) + canvas.drawBitmap(squareBitmap, null, destRect, paint) + paint.xfermode = null + + if (!squareBitmap.isRecycled) { + squareBitmap.recycle() + } + + return resultBitmap + } + + /** + * Calculate CRC32 hash for a string, commonly used for generating notification IDs + */ + fun calculateCRC32(s: String): Long { + val crc32 = CRC32() + crc32.update(s.toByteArray()) + return crc32.value + } } diff --git a/app/src/main/java/com/nextcloud/talk/utils/bundle/BundleKeys.kt b/app/src/main/java/com/nextcloud/talk/utils/bundle/BundleKeys.kt index d31b7c6e388..4d0385043e1 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/bundle/BundleKeys.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/bundle/BundleKeys.kt @@ -80,9 +80,11 @@ object BundleKeys { const val KEY_CREDENTIALS: String = "KEY_CREDENTIALS" const val KEY_FIELD_MAP: String = "KEY_FIELD_MAP" const val KEY_CHAT_URL: String = "KEY_CHAT_URL" + const val KEY_FOCUS_BUBBLE_SETTINGS: String = "KEY_FOCUS_BUBBLE_SETTINGS" const val KEY_SCROLL_TO_NOTIFICATION_CATEGORY: String = "KEY_SCROLL_TO_NOTIFICATION_CATEGORY" const val KEY_FOCUS_INPUT: String = "KEY_FOCUS_INPUT" const val KEY_THREAD_ID = "KEY_THREAD_ID" const val KEY_FROM_QR: String = "KEY_FROM_QR" const val KEY_OPENED_VIA_NOTIFICATION: String = "KEY_OPENED_VIA_NOTIFICATION" + const val KEY_FOCUS_CONVERSATION_BUBBLE: String = "KEY_FOCUS_CONVERSATION_BUBBLE" } diff --git a/app/src/main/java/com/nextcloud/talk/utils/preferences/AppPreferences.java b/app/src/main/java/com/nextcloud/talk/utils/preferences/AppPreferences.java index e68e1291d45..5a90868a175 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/preferences/AppPreferences.java +++ b/app/src/main/java/com/nextcloud/talk/utils/preferences/AppPreferences.java @@ -104,6 +104,14 @@ public interface AppPreferences { void removeNotificationChannelUpgradeToV3(); + boolean areBubblesEnabled(); + + void setBubblesEnabled(boolean value); + + boolean areBubblesForced(); + + void setBubblesForced(boolean value); + boolean getIsScreenSecured(); void setScreenSecurity(boolean value); diff --git a/app/src/main/java/com/nextcloud/talk/utils/preferences/AppPreferencesImpl.kt b/app/src/main/java/com/nextcloud/talk/utils/preferences/AppPreferencesImpl.kt index 61d4b79850e..aa5f39f2e1b 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/preferences/AppPreferencesImpl.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/preferences/AppPreferencesImpl.kt @@ -269,6 +269,30 @@ class AppPreferencesImpl(val context: Context) : AppPreferences { setNotificationChannelIsUpgradedToV3(false) } + override fun areBubblesEnabled(): Boolean = + runBlocking { + async { readBoolean(BUBBLES_ENABLED, true).first() } + }.getCompleted() + + override fun setBubblesEnabled(value: Boolean) = + runBlocking { + async { + writeBoolean(BUBBLES_ENABLED, value) + } + } + + override fun areBubblesForced(): Boolean = + runBlocking { + async { readBoolean(BUBBLES_FORCE_ALL).first() } + }.getCompleted() + + override fun setBubblesForced(value: Boolean) = + runBlocking { + async { + writeBoolean(BUBBLES_FORCE_ALL, value) + } + } + override fun getIsScreenSecured(): Boolean = runBlocking { async { readBoolean(SCREEN_SECURITY).first() } @@ -621,6 +645,8 @@ class AppPreferencesImpl(val context: Context) : AppPreferences { const val MESSAGE_RINGTONE = "message_ringtone" const val NOTIFY_UPGRADE_V2 = "notification_channels_upgrade_to_v2" const val NOTIFY_UPGRADE_V3 = "notification_channels_upgrade_to_v3" + const val BUBBLES_ENABLED = "bubbles_enabled" + const val BUBBLES_FORCE_ALL = "bubbles_force_all" const val SCREEN_SECURITY = "screen_security" const val SCREEN_LOCK = "screen_lock" const val INCOGNITO_KEYBOARD = "incognito_keyboard" diff --git a/app/src/main/res/layout/activity_conversation_info.xml b/app/src/main/res/layout/activity_conversation_info.xml index 2663a8d8809..f7de1e505de 100644 --- a/app/src/main/res/layout/activity_conversation_info.xml +++ b/app/src/main/res/layout/activity_conversation_info.xml @@ -50,6 +50,7 @@ tools:visibility="gone" /> diff --git a/app/src/main/res/layout/activity_settings.xml b/app/src/main/res/layout/activity_settings.xml index a3191b81575..dcd76ab85f1 100644 --- a/app/src/main/res/layout/activity_settings.xml +++ b/app/src/main/res/layout/activity_settings.xml @@ -336,6 +336,78 @@ android:textSize="@dimen/supporting_text_text_size"/> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5c15d936afc..cd8002604db 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -355,6 +355,15 @@ How to translate with transifex: Notify when mentioned Never notify Call notifications + Bubble + Open new messages from this conversation as floating bubbles. + Overridden by general bubble settings. + Enable bubbles in general settings to adjust per conversation. + Enable bubbles for this conversation in the conversation info. + Bubbles + Allow Talk notifications to appear as floating bubbles. + All conversations can bubble + Override individual conversation bubble settings. Sensitive conversation Message preview will be disabled in conversation list and notifications Important conversation @@ -432,6 +441,8 @@ How to translate with transifex: Video call Event conversation menu Conversation info + Create bubble + Open in app Unread messages %1$s sent a GIF. %1$s sent an audio. From 57c2d7fbe9ae3ebc96280b90ce9afab95fda7c9c Mon Sep 17 00:00:00 2001 From: alex Date: Fri, 14 Nov 2025 18:04:06 +0100 Subject: [PATCH 02/18] fix ktlintCheck detekt testGplayDebugUnitTest Signed-off-by: alex --- .../com/nextcloud/talk/chat/BubbleActivity.kt | 30 +++++---- .../com/nextcloud/talk/chat/ChatActivity.kt | 60 +++++++++++------- .../ConversationInfoActivity.kt | 6 +- .../nextcloud/talk/jobs/NotificationWorker.kt | 37 ++++++----- .../talk/settings/SettingsActivity.kt | 9 ++- .../nextcloud/talk/utils/NotificationUtils.kt | 61 +++++++++---------- .../utils/preferences/AppPreferencesImpl.kt | 4 +- 7 files changed, 112 insertions(+), 95 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/chat/BubbleActivity.kt b/app/src/main/java/com/nextcloud/talk/chat/BubbleActivity.kt index aafccbdb3ef..30ba28561ef 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/BubbleActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/BubbleActivity.kt @@ -14,32 +14,31 @@ import com.nextcloud.talk.R import com.nextcloud.talk.utils.bundle.BundleKeys class BubbleActivity : ChatActivity() { - + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) supportActionBar?.setDisplayHomeAsUpEnabled(false) supportActionBar?.setDisplayShowHomeEnabled(false) } - + override fun onPrepareOptionsMenu(menu: android.view.Menu): Boolean { super.onPrepareOptionsMenu(menu) - + menu.findItem(R.id.create_conversation_bubble)?.isVisible = false menu.findItem(R.id.open_conversation_in_app)?.isVisible = true - + return true } - - override fun onOptionsItemSelected(item: android.view.MenuItem): Boolean { - return when (item.itemId) { + + override fun onOptionsItemSelected(item: android.view.MenuItem): Boolean = + when (item.itemId) { R.id.open_conversation_in_app -> { openInMainApp() true } else -> super.onOptionsItemSelected(item) } - } - + private fun openInMainApp() { val intent = Intent(this, ChatActivity::class.java).apply { putExtras(this@BubbleActivity.intent) @@ -48,25 +47,24 @@ class BubbleActivity : ChatActivity() { startActivity(intent) moveTaskToBack(false) } - + override fun onBackPressed() { moveTaskToBack(false) } - + @Deprecated("Deprecated in Java") override fun onSupportNavigateUp(): Boolean { moveTaskToBack(false) return true } - + companion object { - fun newIntent(context: Context, roomToken: String, conversationName: String?): Intent { - return Intent(context, BubbleActivity::class.java).apply { + fun newIntent(context: Context, roomToken: String, conversationName: String?): Intent = + Intent(context, BubbleActivity::class.java).apply { putExtra(BundleKeys.KEY_ROOM_TOKEN, roomToken) conversationName?.let { putExtra(BundleKeys.KEY_CONVERSATION_NAME, it) } action = Intent.ACTION_VIEW flags = Intent.FLAG_ACTIVITY_NEW_DOCUMENT or Intent.FLAG_ACTIVITY_MULTIPLE_TASK } - } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt index 0a28e1cfd72..13e44b7e9ce 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt @@ -3329,8 +3329,8 @@ open class ChatActivity : hasSpreedFeatureCapability(spreedCapabilities, SpreedFeatures.THREADS) val threadNotificationIcon = when (conversationThreadInfo?.attendee?.notificationLevel) { - 1 -> R.drawable.outline_notifications_active_24 - 3 -> R.drawable.ic_baseline_notifications_off_24 + NOTIFICATION_LEVEL_DEFAULT -> R.drawable.outline_notifications_active_24 + NOTIFICATION_LEVEL_NEVER -> R.drawable.ic_baseline_notifications_off_24 else -> R.drawable.baseline_notifications_24 } threadNotificationItem.icon = ContextCompat.getDrawable(context, threadNotificationIcon) @@ -3416,12 +3416,17 @@ open class ChatActivity : val shortcutId = "conversation_$roomToken" val conversationName = currentConversation?.displayName ?: getString(R.string.nc_app_name) - val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as android.app.NotificationManager + val notificationManager = getSystemService( + Context.NOTIFICATION_SERVICE + ) as android.app.NotificationManager val notificationId = NotificationUtils.calculateCRC32(roomToken).toInt() notificationManager.cancel(notificationId) - androidx.core.content.pm.ShortcutManagerCompat.removeDynamicShortcuts(this@ChatActivity, listOf(shortcutId)) - + androidx.core.content.pm.ShortcutManagerCompat.removeDynamicShortcuts( + this@ChatActivity, + listOf(shortcutId) + ) + // Load conversation avatar on background thread val avatarIcon = withContext(Dispatchers.IO) { try { @@ -3438,23 +3443,26 @@ open class ChatActivity : roomToken ) } - + if (DisplayUtils.isDarkModeOn(this@ChatActivity)) { avatarUrl = "$avatarUrl/dark" } - + NotificationUtils.loadAvatarSyncForBubble(avatarUrl, this@ChatActivity, credentials) - } catch (e: Exception) { - Log.e(TAG, "Error loading bubble avatar", e) + } catch (e: IOException) { + Log.e(TAG, "Error loading bubble avatar: IO error", e) + null + } catch (e: IllegalArgumentException) { + Log.e(TAG, "Error loading bubble avatar: Invalid argument", e) null } } - + val icon = avatarIcon ?: androidx.core.graphics.drawable.IconCompat.createWithResource( this@ChatActivity, R.drawable.ic_logo ) - + val person = androidx.core.app.Person.Builder() .setName(conversationName) .setKey(shortcutId) @@ -3464,7 +3472,7 @@ open class ChatActivity : // Use the same request code calculation as NotificationWorker val bubbleRequestCode = NotificationUtils.calculateCRC32("bubble_$roomToken").toInt() - + val bubbleIntent = android.app.PendingIntent.getActivity( this@ChatActivity, bubbleRequestCode, @@ -3489,7 +3497,7 @@ open class ChatActivity : bubbleIntent, icon ) - .setDesiredHeight(600) + .setDesiredHeight(BUBBLE_DESIRED_HEIGHT_PX) .setAutoExpandBubble(false) .setSuppressNotification(true) .build() @@ -3523,7 +3531,7 @@ open class ChatActivity : // Check if notification channel supports bubbles and recreate if needed val channel = notificationManager.getNotificationChannel(channelId) - + if (channel == null || !channel.canBubble()) { NotificationUtils.registerNotificationChannels( applicationContext, @@ -3534,9 +3542,11 @@ open class ChatActivity : // Use the same notification ID calculation as NotificationWorker // Show notification with bubble notificationManager.notify(notificationId, notification) - - } catch (e: Exception) { - Log.e(TAG, "Error creating bubble", e) + } catch (e: SecurityException) { + Log.e(TAG, "Error creating bubble: Permission denied", e) + Toast.makeText(this@ChatActivity, R.string.nc_common_error_sorry, Toast.LENGTH_SHORT).show() + } catch (e: IllegalArgumentException) { + Log.e(TAG, "Error creating bubble: Invalid argument", e) Toast.makeText(this@ChatActivity, R.string.nc_common_error_sorry, Toast.LENGTH_SHORT).show() } } @@ -3547,8 +3557,11 @@ open class ChatActivity : return withContext(Dispatchers.IO) { try { DatabaseStorageModule(user, roomToken).getBoolean(BUBBLE_SWITCH_KEY, false) - } catch (e: Exception) { - Log.e(TAG, "Failed to read conversation bubble preference", e) + } catch (e: IOException) { + Log.e(TAG, "Failed to read conversation bubble preference: IO error", e) + false + } catch (e: IllegalStateException) { + Log.e(TAG, "Failed to read conversation bubble preference: Invalid state", e) false } } @@ -3608,7 +3621,7 @@ open class ChatActivity : subtitle = null, icon = R.drawable.ic_baseline_notifications_off_24, onClick = { - setThreadNotificationLevel(3) + setThreadNotificationLevel(NOTIFICATION_LEVEL_NEVER) } ) ) @@ -4279,8 +4292,8 @@ open class ChatActivity : displayName = currentConversation?.displayName ?: "" ) showSnackBar(roomToken) - } catch (e: Exception) { - Log.w(TAG, "File corresponding to the uri does not exist $shareUri", e) + } catch (e: IOException) { + Log.w(TAG, "File corresponding to the uri does not exist: IO error $shareUri", e) downloadFileToCache(message, false) { uploadFile( fileUri = shareUri.toString(), @@ -4766,6 +4779,9 @@ open class ChatActivity : private const val HTTP_FORBIDDEN = 403 private const val HTTP_NOT_FOUND = 404 private const val MESSAGE_PULL_LIMIT = 100 + private const val NOTIFICATION_LEVEL_DEFAULT = 1 + private const val NOTIFICATION_LEVEL_NEVER = 3 + private const val BUBBLE_DESIRED_HEIGHT_PX = 600 private const val INVITE_LENGTH = 6 private const val ACTOR_LENGTH = 6 private const val CHUNK_SIZE: Int = 10 diff --git a/app/src/main/java/com/nextcloud/talk/conversationinfo/ConversationInfoActivity.kt b/app/src/main/java/com/nextcloud/talk/conversationinfo/ConversationInfoActivity.kt index 6824588969e..37f847a054e 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationinfo/ConversationInfoActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationinfo/ConversationInfoActivity.kt @@ -1950,7 +1950,11 @@ class ConversationInfoActivity : module.saveBoolean(BUBBLE_SWITCH_KEY, newValue) } if (!newValue) { - NotificationUtils.dismissBubbleForRoom(this@ConversationInfoActivity, conversationUser, conversationToken) + NotificationUtils.dismissBubbleForRoom( + this@ConversationInfoActivity, + conversationUser, + conversationToken + ) } } diff --git a/app/src/main/java/com/nextcloud/talk/jobs/NotificationWorker.kt b/app/src/main/java/com/nextcloud/talk/jobs/NotificationWorker.kt index 65461e97f69..f8079f2e3ce 100644 --- a/app/src/main/java/com/nextcloud/talk/jobs/NotificationWorker.kt +++ b/app/src/main/java/com/nextcloud/talk/jobs/NotificationWorker.kt @@ -511,9 +511,9 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor } // Bubbles need the notification to stay alive - val autoCancelOnClick = TYPE_RECORDING != pushMessage.type && - TYPE_CHAT != pushMessage.type && - TYPE_REMINDER != pushMessage.type + val autoCancelOnClick = TYPE_RECORDING != pushMessage.type && + TYPE_CHAT != pushMessage.type && + TYPE_REMINDER != pushMessage.type val notificationBuilder = createNotificationBuilder( @@ -749,16 +749,13 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor } person.setIcon(loadAvatarSync(avatarUrl, context!!)) } - + val personBuilt = person.build() notificationBuilder.setStyle(getStyle(personBuilt, style)) notificationBuilder.addPerson(personBuilt) } - private fun addBubbleMetadata( - notificationBuilder: NotificationCompat.Builder, - suppressNotification: Boolean - ) { + private fun addBubbleMetadata(notificationBuilder: NotificationCompat.Builder, suppressNotification: Boolean) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { return // Bubbles require API 30+ (Android 11) } @@ -770,7 +767,7 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor } val conversationName = pushMessage.subject val shortcutId = "conversation_$roomToken" - + val bubbleIcon = resolveBubbleIcon(roomToken) ?: run { val fallbackBitmap = getLargeIcon() ?: return androidx.core.graphics.drawable.IconCompat.createWithBitmap(fallbackBitmap) @@ -793,9 +790,9 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor .setCategories(setOf(Notification.CATEGORY_MESSAGE)) .setLocusId(androidx.core.content.LocusIdCompat(shortcutId)) .build() - + androidx.core.content.pm.ShortcutManagerCompat.pushDynamicShortcut(context!!, shortcut) - + val bubbleRequestCode = NotificationUtils.calculateCRC32("bubble_$roomToken").toInt() val bubbleIntent = android.app.PendingIntent.getActivity( context, @@ -803,22 +800,23 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor com.nextcloud.talk.chat.BubbleActivity.newIntent(context!!, roomToken, conversationName), android.app.PendingIntent.FLAG_UPDATE_CURRENT or android.app.PendingIntent.FLAG_MUTABLE ) - + val bubbleData = androidx.core.app.NotificationCompat.BubbleMetadata.Builder( bubbleIntent, bubbleIcon ) - .setDesiredHeight(600) + .setDesiredHeight(BUBBLE_DESIRED_HEIGHT_PX) .setAutoExpandBubble(false) .setSuppressNotification(suppressNotification) .build() - + notificationBuilder.setBubbleMetadata(bubbleData) notificationBuilder.setShortcutId(shortcutId) notificationBuilder.setLocusId(androidx.core.content.LocusIdCompat(shortcutId)) - - } catch (e: Exception) { - android.util.Log.e(TAG, "Error adding bubble metadata", e) + } catch (e: IllegalArgumentException) { + android.util.Log.e(TAG, "Error adding bubble metadata: Invalid argument", e) + } catch (e: IllegalStateException) { + android.util.Log.e(TAG, "Error adding bubble metadata: Invalid state", e) } } @@ -1231,7 +1229,8 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor private const val TIMER_START = 1 private const val TIMER_COUNT = 12 private const val TIMER_DELAY: Long = 5 - private const val LINEBREAK: String = "\n" - private const val BUBBLE_SWITCH_KEY = "bubble_switch" + private const val LINEBREAK: String = "\n" + private const val BUBBLE_SWITCH_KEY = "bubble_switch" + private const val BUBBLE_DESIRED_HEIGHT_PX = 600 } } diff --git a/app/src/main/java/com/nextcloud/talk/settings/SettingsActivity.kt b/app/src/main/java/com/nextcloud/talk/settings/SettingsActivity.kt index 6a1766a5cc0..2fba3d04c2c 100644 --- a/app/src/main/java/com/nextcloud/talk/settings/SettingsActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/settings/SettingsActivity.kt @@ -442,7 +442,7 @@ class SettingsActivity : binding.settingsBubblesSwitch.isChecked = newValue appPreferences.setBubblesEnabled(newValue) updateBubbleForceRowState(newValue) - + // Dismiss all active bubbles when disabling globally if (!newValue) { currentUser?.let { user -> @@ -458,10 +458,13 @@ class SettingsActivity : val newValue = !binding.settingsBubblesForceSwitch.isChecked binding.settingsBubblesForceSwitch.isChecked = newValue appPreferences.setBubblesForced(newValue) - + // When disabling "force all", dismiss bubbles without explicit per-conversation settings if (!newValue) { - NotificationUtils.dismissBubblesWithoutExplicitSettings(this, currentUserProvider.currentUser.blockingGet()) + NotificationUtils.dismissBubblesWithoutExplicitSettings( + this, + currentUserProvider.currentUser.blockingGet() + ) } } } diff --git a/app/src/main/java/com/nextcloud/talk/utils/NotificationUtils.kt b/app/src/main/java/com/nextcloud/talk/utils/NotificationUtils.kt index e8d449701ae..032cd75414d 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/NotificationUtils.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/NotificationUtils.kt @@ -56,6 +56,8 @@ object NotificationUtils { const val TAG = "NotificationUtils" private const val BUBBLE_ICON_SIZE_DP = 96 private const val BUBBLE_ICON_CONTENT_RATIO = 0.68f + private const val BUBBLE_SIZE_MULTIPLIER = 4 + private const val MIN_BUBBLE_CONTENT_RATIO = 0.5f private val bubbleIconCache = ConcurrentHashMap() enum class NotificationChannels { @@ -84,7 +86,8 @@ object NotificationUtils { ) { val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager val isMessagesChannel = notificationChannel.id == NotificationChannels.NOTIFICATION_CHANNEL_MESSAGES_V4.name - val shouldSupportBubbles = android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.R && isMessagesChannel + val shouldSupportBubbles = + android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.R && isMessagesChannel val existingChannel = notificationManager.getNotificationChannel(notificationChannel.id) val needsRecreation = shouldSupportBubbles && existingChannel != null && !existingChannel.canBubble() @@ -273,15 +276,11 @@ object NotificationUtils { } } - private fun dismissBubbles( - context: Context?, - conversationUser: User, - predicate: (String) -> Boolean - ) { + private fun dismissBubbles(context: Context?, conversationUser: User, predicate: (String) -> Boolean) { if (context == null) return val shortcutsToRemove = mutableListOf() - + scanNotifications(context, conversationUser) { notificationManager, statusBarNotification, notification -> val roomToken = notification.extras.getString(BundleKeys.KEY_ROOM_TOKEN) if (roomToken != null && predicate(roomToken)) { @@ -393,7 +392,7 @@ object NotificationUtils { .build() context.imageLoader.executeBlocking(request) - + return avatarIcon } @@ -403,25 +402,24 @@ object NotificationUtils { return null } - bubbleIconCache[url]?.let { return it } - - var avatarIcon: IconCompat? = null - val bubbleSizePx = context.bubbleIconSizePx() + return bubbleIconCache[url] ?: run { + var avatarIcon: IconCompat? = null + val bubbleSizePx = context.bubbleIconSizePx() + + val requestBuilder = ImageRequest.Builder(context) + .data(url) + .placeholder(R.drawable.account_circle_96dp) + .size(bubbleSizePx * BUBBLE_SIZE_MULTIPLIER, bubbleSizePx * BUBBLE_SIZE_MULTIPLIER) + .precision(Precision.EXACT) + .scale(Scale.FIT) + .allowHardware(false) + .bitmapConfig(Bitmap.Config.ARGB_8888) + + if (!credentials.isNullOrEmpty()) { + requestBuilder.addHeader("Authorization", credentials) + } - val requestBuilder = ImageRequest.Builder(context) - .data(url) - .placeholder(R.drawable.account_circle_96dp) - .size(bubbleSizePx * 4, bubbleSizePx * 4) - .precision(Precision.EXACT) - .scale(Scale.FIT) - .allowHardware(false) - .bitmapConfig(Bitmap.Config.ARGB_8888) - - if (!credentials.isNullOrEmpty()) { - requestBuilder.addHeader("Authorization", credentials) - } - - val request = requestBuilder.target( + val request = requestBuilder.target( onSuccess = { result -> avatarIcon = IconCompat.createWithAdaptiveBitmap( result.toBubbleBitmap(bubbleSizePx, BUBBLE_ICON_CONTENT_RATIO) @@ -435,22 +433,21 @@ object NotificationUtils { } } ) - .build() + .build() - context.imageLoader.executeBlocking(request) + context.imageLoader.executeBlocking(request) - avatarIcon?.let { bubbleIconCache[url] = it } - return avatarIcon + avatarIcon?.also { bubbleIconCache[url] = it } + } } - private data class Channel(val id: String, val name: String, val description: String, val isImportant: Boolean) private fun Context.bubbleIconSizePx(): Int = (BUBBLE_ICON_SIZE_DP * resources.displayMetrics.density).roundToInt().coerceAtLeast(1) private fun Drawable.toBubbleBitmap(size: Int, contentRatio: Float): Bitmap { - val safeRatio = contentRatio.coerceIn(0.5f, 1f) + val safeRatio = contentRatio.coerceIn(MIN_BUBBLE_CONTENT_RATIO, 1f) val drawable = this.constantState?.newDrawable()?.mutate() ?: this.mutate() val sourceWidth = max(1, if (drawable.intrinsicWidth > 0) drawable.intrinsicWidth else size) diff --git a/app/src/main/java/com/nextcloud/talk/utils/preferences/AppPreferencesImpl.kt b/app/src/main/java/com/nextcloud/talk/utils/preferences/AppPreferencesImpl.kt index aa5f39f2e1b..db3a4de2dc8 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/preferences/AppPreferencesImpl.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/preferences/AppPreferencesImpl.kt @@ -645,8 +645,8 @@ class AppPreferencesImpl(val context: Context) : AppPreferences { const val MESSAGE_RINGTONE = "message_ringtone" const val NOTIFY_UPGRADE_V2 = "notification_channels_upgrade_to_v2" const val NOTIFY_UPGRADE_V3 = "notification_channels_upgrade_to_v3" - const val BUBBLES_ENABLED = "bubbles_enabled" - const val BUBBLES_FORCE_ALL = "bubbles_force_all" + const val BUBBLES_ENABLED = "bubbles_enabled" + const val BUBBLES_FORCE_ALL = "bubbles_force_all" const val SCREEN_SECURITY = "screen_security" const val SCREEN_LOCK = "screen_lock" const val INCOGNITO_KEYBOARD = "incognito_keyboard" From 0053b5bfc36221e8f071bc3c4628c05d79414819 Mon Sep 17 00:00:00 2001 From: alex Date: Thu, 27 Nov 2025 02:36:08 +0100 Subject: [PATCH 03/18] remove deprecated onBackPressed and extracted code in function Signed-off-by: alex --- .../com/nextcloud/talk/chat/BubbleActivity.kt | 11 ++++++---- .../nextcloud/talk/jobs/NotificationWorker.kt | 21 +++++++++++++------ 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/chat/BubbleActivity.kt b/app/src/main/java/com/nextcloud/talk/chat/BubbleActivity.kt index 30ba28561ef..52dda1fca37 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/BubbleActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/BubbleActivity.kt @@ -10,6 +10,7 @@ package com.nextcloud.talk.chat import android.content.Context import android.content.Intent import android.os.Bundle +import androidx.activity.OnBackPressedCallback import com.nextcloud.talk.R import com.nextcloud.talk.utils.bundle.BundleKeys @@ -19,6 +20,12 @@ class BubbleActivity : ChatActivity() { super.onCreate(savedInstanceState) supportActionBar?.setDisplayHomeAsUpEnabled(false) supportActionBar?.setDisplayShowHomeEnabled(false) + + onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + moveTaskToBack(false) + } + }) } override fun onPrepareOptionsMenu(menu: android.view.Menu): Boolean { @@ -48,10 +55,6 @@ class BubbleActivity : ChatActivity() { moveTaskToBack(false) } - override fun onBackPressed() { - moveTaskToBack(false) - } - @Deprecated("Deprecated in Java") override fun onSupportNavigateUp(): Boolean { moveTaskToBack(false) diff --git a/app/src/main/java/com/nextcloud/talk/jobs/NotificationWorker.kt b/app/src/main/java/com/nextcloud/talk/jobs/NotificationWorker.kt index f8079f2e3ce..c5d6b446e53 100644 --- a/app/src/main/java/com/nextcloud/talk/jobs/NotificationWorker.kt +++ b/app/src/main/java/com/nextcloud/talk/jobs/NotificationWorker.kt @@ -610,12 +610,7 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor } } } - if (!effectiveShortcutId.isNullOrEmpty()) { - val ensuredShortcutId = effectiveShortcutId!! - val locusId = androidx.core.content.LocusIdCompat(ensuredShortcutId) - notificationBuilder.setShortcutId(ensuredShortcutId) - notificationBuilder.setLocusId(locusId) - } + applyShortcutAndLocus(notificationBuilder, effectiveShortcutId) addReplyAction(notificationBuilder, systemNotificationId) addMarkAsReadAction(notificationBuilder, systemNotificationId) } @@ -641,6 +636,7 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor ) .setPriority(NotificationCompat.PRIORITY_HIGH) .setCategory(category) + .setLargeIcon(getLargeIcon()) .setSmallIcon(R.drawable.ic_notification) .setContentTitle(contentTitle) .setContentText(contentText) @@ -685,6 +681,19 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor return notificationBuilder } + private fun applyShortcutAndLocus( + notificationBuilder: NotificationCompat.Builder, + shortcutId: String? + ) { + if (shortcutId.isNullOrEmpty()) { + return + } + val ensuredShortcutId = shortcutId + val locusId = androidx.core.content.LocusIdCompat(ensuredShortcutId) + notificationBuilder.setShortcutId(ensuredShortcutId) + notificationBuilder.setLocusId(locusId) + } + private fun getLargeIcon(): Bitmap { val largeIcon: Bitmap if (pushMessage.type == TYPE_RECORDING) { From 734a700b790df5ed195dfe95ebe075a3ae0106d4 Mon Sep 17 00:00:00 2001 From: alex Date: Fri, 5 Dec 2025 14:10:35 +0100 Subject: [PATCH 04/18] prevent second app instance and adding talk icon button in bubble mode Signed-off-by: alex --- .../com/nextcloud/talk/chat/BubbleActivity.kt | 33 +++++++++++++++---- .../com/nextcloud/talk/chat/ChatActivity.kt | 21 ++++++++++-- 2 files changed, 46 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/chat/BubbleActivity.kt b/app/src/main/java/com/nextcloud/talk/chat/BubbleActivity.kt index 52dda1fca37..6ab4c469b47 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/BubbleActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/BubbleActivity.kt @@ -12,15 +12,21 @@ import android.content.Intent import android.os.Bundle import androidx.activity.OnBackPressedCallback import com.nextcloud.talk.R +import com.nextcloud.talk.activities.MainActivity import com.nextcloud.talk.utils.bundle.BundleKeys class BubbleActivity : ChatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - supportActionBar?.setDisplayHomeAsUpEnabled(false) - supportActionBar?.setDisplayShowHomeEnabled(false) + supportActionBar?.setDisplayHomeAsUpEnabled(true) + supportActionBar?.setHomeAsUpIndicator(R.drawable.ic_talk) + supportActionBar?.setDisplayShowHomeEnabled(true) + findViewById(R.id.chat_toolbar)?.setNavigationOnClickListener { + openConversationList() + } + onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) { override fun handleOnBackPressed() { moveTaskToBack(false) @@ -43,21 +49,36 @@ class BubbleActivity : ChatActivity() { openInMainApp() true } + android.R.id.home -> { + openConversationList() + true + } else -> super.onOptionsItemSelected(item) } private fun openInMainApp() { - val intent = Intent(this, ChatActivity::class.java).apply { + val intent = Intent(this, MainActivity::class.java).apply { + action = Intent.ACTION_MAIN + addCategory(Intent.CATEGORY_LAUNCHER) putExtras(this@BubbleActivity.intent) - flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP + conversationUser?.id?.let { putExtra(BundleKeys.KEY_INTERNAL_USER_ID, it) } + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP + } + startActivity(intent) + } + + private fun openConversationList() { + val intent = Intent(this, MainActivity::class.java).apply { + action = Intent.ACTION_MAIN + addCategory(Intent.CATEGORY_LAUNCHER) + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP } startActivity(intent) - moveTaskToBack(false) } @Deprecated("Deprecated in Java") override fun onSupportNavigateUp(): Boolean { - moveTaskToBack(false) + openInMainApp() return true } diff --git a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt index 13e44b7e9ce..2dccd11bf28 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt @@ -3480,11 +3480,28 @@ open class ChatActivity : android.app.PendingIntent.FLAG_UPDATE_CURRENT or android.app.PendingIntent.FLAG_MUTABLE ) + val contentIntent = android.app.PendingIntent.getActivity( + this@ChatActivity, + bubbleRequestCode, + Intent(this@ChatActivity, ChatActivity::class.java).apply { + putExtra(BundleKeys.KEY_ROOM_TOKEN, roomToken) + conversationName?.let { putExtra(BundleKeys.KEY_CONVERSATION_NAME, it) } + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP + }, + android.app.PendingIntent.FLAG_UPDATE_CURRENT or android.app.PendingIntent.FLAG_IMMUTABLE + ) + + val shortcutIntent = Intent(this@ChatActivity, ChatActivity::class.java).apply { + action = Intent.ACTION_VIEW + putExtra(BundleKeys.KEY_ROOM_TOKEN, roomToken) + conversationName?.let { putExtra(BundleKeys.KEY_CONVERSATION_NAME, it) } + } + val shortcut = androidx.core.content.pm.ShortcutInfoCompat.Builder(this@ChatActivity, shortcutId) .setShortLabel(conversationName) .setLongLabel(conversationName) .setIcon(icon) - .setIntent(Intent(Intent.ACTION_DEFAULT)) + .setIntent(shortcutIntent) .setLongLived(true) .setPerson(person) .setCategories(setOf(android.app.Notification.CATEGORY_MESSAGE)) @@ -3522,7 +3539,7 @@ open class ChatActivity : .addPerson(person) .setStyle(messagingStyle) .setBubbleMetadata(bubbleData) - .setContentIntent(bubbleIntent) + .setContentIntent(contentIntent) .setAutoCancel(true) .setOngoing(false) .setOnlyAlertOnce(true) From 3cc598eafeec7dc95b8ec87ad7a3534684b7ea25 Mon Sep 17 00:00:00 2001 From: alex Date: Sat, 6 Dec 2025 13:55:33 +0100 Subject: [PATCH 05/18] fix potential issue with room token Signed-off-by: alex --- app/src/main/java/com/nextcloud/talk/jobs/NotificationWorker.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/nextcloud/talk/jobs/NotificationWorker.kt b/app/src/main/java/com/nextcloud/talk/jobs/NotificationWorker.kt index c5d6b446e53..fbfefda5764 100644 --- a/app/src/main/java/com/nextcloud/talk/jobs/NotificationWorker.kt +++ b/app/src/main/java/com/nextcloud/talk/jobs/NotificationWorker.kt @@ -539,7 +539,7 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor // It is NOT the same as the notification ID used in communication with the server. // Use a consistent ID based on the room token to avoid duplicate bubbles val systemNotificationId: Int = - activeStatusBarNotification?.id ?: NotificationUtils.calculateCRC32(pushMessage.id!!).toInt() + activeStatusBarNotification?.id ?: NotificationUtils.calculateCRC32("${signatureVerification.user!!.id}:${pushMessage.id!!}").toInt() if ((TYPE_CHAT == pushMessage.type || TYPE_REMINDER == pushMessage.type) && pushMessage.notificationUser != null From eaf473196cfa5cb2b47ae795d4f413d06c36a721 Mon Sep 17 00:00:00 2001 From: alex Date: Sat, 6 Dec 2025 14:57:05 +0100 Subject: [PATCH 06/18] fix getLargeIcon preventing bubble notification Signed-off-by: alex --- .../java/com/nextcloud/talk/jobs/NotificationWorker.kt | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/jobs/NotificationWorker.kt b/app/src/main/java/com/nextcloud/talk/jobs/NotificationWorker.kt index fbfefda5764..7c7d511bbe5 100644 --- a/app/src/main/java/com/nextcloud/talk/jobs/NotificationWorker.kt +++ b/app/src/main/java/com/nextcloud/talk/jobs/NotificationWorker.kt @@ -701,7 +701,6 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor } else { when (conversationType) { "one2one" -> { - pushMessage.subject = "" largeIcon = ContextCompat.getDrawable(context!!, R.drawable.ic_baseline_person_black_24)?.toBitmap()!! } @@ -774,8 +773,9 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor if (!shouldBubble(roomToken)) { return } - val conversationName = pushMessage.subject + val conversationName = pushMessage.subject?.takeIf { it.isNotBlank() } val shortcutId = "conversation_$roomToken" + val fallbackConversationLabel = conversationName ?: context!!.getString(R.string.nc_app_name) val bubbleIcon = resolveBubbleIcon(roomToken) ?: run { val fallbackBitmap = getLargeIcon() ?: return @@ -783,15 +783,15 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor } val person = androidx.core.app.Person.Builder() - .setName(conversationName ?: context!!.getString(R.string.nc_app_name)) + .setName(fallbackConversationLabel) .setKey(shortcutId) .setImportant(true) .setIcon(bubbleIcon) .build() val shortcut = androidx.core.content.pm.ShortcutInfoCompat.Builder(context!!, shortcutId) - .setShortLabel(conversationName ?: context!!.getString(R.string.nc_app_name)) - .setLongLabel(conversationName ?: context!!.getString(R.string.nc_app_name)) + .setShortLabel(fallbackConversationLabel) + .setLongLabel(fallbackConversationLabel) .setIcon(bubbleIcon) .setIntent(Intent(Intent.ACTION_DEFAULT)) .setLongLived(true) From 63f0818edef8f252fb317e7f2db50abfece1a08f Mon Sep 17 00:00:00 2001 From: alex Date: Sat, 6 Dec 2025 17:03:53 +0100 Subject: [PATCH 07/18] bubbles off by default and lead to android bubble settings if needed when enabled Signed-off-by: alex --- .../talk/settings/SettingsActivity.kt | 180 ++++++++++++++++-- .../utils/preferences/AppPreferencesImpl.kt | 2 +- app/src/main/res/values/strings.xml | 3 + 3 files changed, 171 insertions(+), 14 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/settings/SettingsActivity.kt b/app/src/main/java/com/nextcloud/talk/settings/SettingsActivity.kt index 2fba3d04c2c..6ffe77f2b62 100644 --- a/app/src/main/java/com/nextcloud/talk/settings/SettingsActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/settings/SettingsActivity.kt @@ -15,6 +15,8 @@ import android.animation.Animator import android.animation.AnimatorListenerAdapter import android.annotation.SuppressLint import android.app.KeyguardManager +import android.app.NotificationManager +import android.content.ActivityNotFoundException import android.content.Context import android.content.DialogInterface import android.content.Intent @@ -144,12 +146,16 @@ class SettingsActivity : private var dbQueryDisposable: Disposable? = null private var openedByNotificationWarning: Boolean = false private var focusBubbleSettings: Boolean = false + private var isUpdatingBubbleSwitchState: Boolean = false + private var pendingBubbleEnableAfterSystemChange: Boolean = false private var isOnline: MutableState = mutableStateOf(false) @SuppressLint("StringFormatInvalid") override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) + pendingBubbleEnableAfterSystemChange = + savedInstanceState?.getBoolean(STATE_PENDING_ENABLE_BUBBLES) ?: false networkMonitor.isOnlineLiveData.observe(this) { online -> isOnline.value = online handleNetworkChange(isOnline.value) @@ -201,6 +207,11 @@ class SettingsActivity : focusBubbleSettings = extras?.getBoolean(KEY_FOCUS_BUBBLE_SETTINGS) ?: false } + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + outState.putBoolean(STATE_PENDING_ENABLE_BUBBLES, pendingBubbleEnableAfterSystemChange) + } + override fun onResume() { super.onResume() supportActionBar?.show() @@ -431,24 +442,29 @@ class SettingsActivity : binding.settingsBubbles.visibility = View.VISIBLE binding.settingsBubblesForce.visibility = View.VISIBLE - val bubblesEnabled = appPreferences.areBubblesEnabled() - binding.settingsBubblesSwitch.isChecked = bubblesEnabled + val systemAllowsAllConversations = isSystemBubblePreferenceAll() + updateBubbleSummary(systemAllowsAllConversations) + + var bubblesEnabled = appPreferences.areBubblesEnabled() + if (bubblesEnabled && !systemAllowsAllConversations) { + appPreferences.setBubblesEnabled(false) + bubblesEnabled = false + } + + setGlobalBubbleSwitchState(bubblesEnabled) binding.settingsBubblesForceSwitch.isChecked = appPreferences.areBubblesForced() updateBubbleForceRowState(bubblesEnabled) - binding.settingsBubbles.setOnClickListener { - val newValue = !binding.settingsBubblesSwitch.isChecked - binding.settingsBubblesSwitch.isChecked = newValue - appPreferences.setBubblesEnabled(newValue) - updateBubbleForceRowState(newValue) - - // Dismiss all active bubbles when disabling globally - if (!newValue) { - currentUser?.let { user -> - NotificationUtils.dismissAllBubbles(this, user) - } + binding.settingsBubblesSwitch.setOnCheckedChangeListener { _, isChecked -> + if (isUpdatingBubbleSwitchState) { + return@setOnCheckedChangeListener } + handleGlobalBubblePreferenceChange(isChecked) + } + + binding.settingsBubbles.setOnClickListener { + binding.settingsBubblesSwitch.performClick() } binding.settingsBubblesForce.setOnClickListener { @@ -467,6 +483,8 @@ class SettingsActivity : ) } } + + maybeEnableBubblesAfterSystemChange(systemAllowsAllConversations) } private fun updateBubbleForceRowState(globalEnabled: Boolean) { @@ -475,6 +493,141 @@ class SettingsActivity : binding.settingsBubblesForceSwitch.isEnabled = globalEnabled } + private fun handleGlobalBubblePreferenceChange(enabled: Boolean) { + val systemAllowsAllConversations = isSystemBubblePreferenceAll() + + if (enabled) { + if (!systemAllowsAllConversations) { + pendingBubbleEnableAfterSystemChange = true + showSystemBubblesDisabledFeedback() + updateBubbleSummary(systemAllowsAllConversations) + setGlobalBubbleSwitchState(false) + navigateToSystemBubbleSettings() + return + } + + pendingBubbleEnableAfterSystemChange = false + appPreferences.setBubblesEnabled(true) + updateBubbleForceRowState(true) + updateBubbleSummary(true) + } else { + pendingBubbleEnableAfterSystemChange = false + appPreferences.setBubblesEnabled(false) + updateBubbleForceRowState(false) + updateBubbleSummary(systemAllowsAllConversations) + currentUser?.let { user -> + NotificationUtils.dismissAllBubbles(this, user) + } + } + } + + private fun setGlobalBubbleSwitchState(checked: Boolean) { + isUpdatingBubbleSwitchState = true + binding.settingsBubblesSwitch.isChecked = checked + isUpdatingBubbleSwitchState = false + } + + private fun updateBubbleSummary(systemAllowsAllConversations: Boolean) { + val summaryText = if (systemAllowsAllConversations) { + R.string.nc_notification_settings_bubbles_desc + } else { + R.string.nc_notification_settings_bubbles_system_disabled + } + binding.settingsBubblesSummary.setText(summaryText) + } + + private fun showSystemBubblesDisabledFeedback() { + Toast.makeText( + this, + R.string.nc_notification_settings_bubbles_system_disabled_toast, + Toast.LENGTH_LONG + ).show() + } + + private fun maybeEnableBubblesAfterSystemChange(systemAllowsAllConversations: Boolean) { + if (!pendingBubbleEnableAfterSystemChange || !systemAllowsAllConversations) { + return + } + + pendingBubbleEnableAfterSystemChange = false + appPreferences.setBubblesEnabled(true) + setGlobalBubbleSwitchState(true) + updateBubbleForceRowState(true) + updateBubbleSummary(true) + } + + private fun isSystemBubblePreferenceAll(): Boolean { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { + return false + } + + val notificationManager = getSystemService(NotificationManager::class.java) + return notificationManager?.bubblePreference == NotificationManager.BUBBLE_PREFERENCE_ALL + } + + private fun navigateToSystemBubbleSettings() { + val targetPackage = packageName + val targetUid = applicationInfo?.uid ?: -1 + val candidateIntents = mutableListOf() + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + candidateIntents += Intent(Settings.ACTION_APP_NOTIFICATION_BUBBLE_SETTINGS) + .withNotificationExtras(targetPackage, targetUid) + .apply { + putExtra( + Settings.EXTRA_CHANNEL_ID, + NotificationUtils.NotificationChannels.NOTIFICATION_CHANNEL_MESSAGES_V4.name + ) + } + + candidateIntents += Intent("android.settings.APP_NOTIFICATION_BUBBLE_SETTINGS") + .withNotificationExtras(targetPackage, targetUid) + + val explicitBubbleComponents = listOf( + "com.android.settings.Settings\$AppBubbleNotificationSettingsActivity", + "com.android.settings.Settings\$BubbleNotificationSettingsActivity" + ) + + explicitBubbleComponents.forEach { componentName -> + candidateIntents += Intent(Intent.ACTION_MAIN) + .withNotificationExtras(targetPackage, targetUid) + .setClassName("com.android.settings", componentName) + } + } + + candidateIntents += Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS) + .withNotificationExtras(targetPackage, targetUid) + + candidateIntents += Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) + .withNotificationExtras(targetPackage, targetUid) + + candidateIntents.firstOrNull { launchIntentSafely(it) } ?: Toast.makeText( + this, + R.string.nc_notification_settings_bubbles_open_failed, + Toast.LENGTH_LONG + ).show() + } + + private fun Intent.withNotificationExtras(targetPackage: String, targetUid: Int): Intent { + data = Uri.fromParts("package", targetPackage, null) + putExtra(Settings.EXTRA_APP_PACKAGE, targetPackage) + putExtra("app_uid", targetUid) + return this + } + + private fun launchIntentSafely(intent: Intent): Boolean { + return try { + if (intent.resolveActivity(packageManager) != null) { + startActivity(intent) + true + } else { + false + } + } catch (activityNotFoundException: ActivityNotFoundException) { + false + } + } + private fun setupNotificationSoundsSettings() { if (NotificationUtils.isCallsNotificationChannelEnabled(this)) { val callRingtoneUri = getCallRingtoneUri(context, (appPreferences)) @@ -1547,6 +1700,7 @@ class SettingsActivity : private const val DISABLED_ALPHA: Float = 0.38f private const val ENABLED_ALPHA: Float = 1.0f private const val LINEBREAK = "\n" + private const val STATE_PENDING_ENABLE_BUBBLES = "statePendingEnableBubbles" const val HTTP_CODE_OK: Int = 200 const val HTTP_ERROR_CODE_BAD_REQUEST: Int = 400 const val NO_NOTIFICATION_REMINDER_WANTED = 0L diff --git a/app/src/main/java/com/nextcloud/talk/utils/preferences/AppPreferencesImpl.kt b/app/src/main/java/com/nextcloud/talk/utils/preferences/AppPreferencesImpl.kt index db3a4de2dc8..8afecc535cd 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/preferences/AppPreferencesImpl.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/preferences/AppPreferencesImpl.kt @@ -271,7 +271,7 @@ class AppPreferencesImpl(val context: Context) : AppPreferences { override fun areBubblesEnabled(): Boolean = runBlocking { - async { readBoolean(BUBBLES_ENABLED, true).first() } + async { readBoolean(BUBBLES_ENABLED, false).first() } }.getCompleted() override fun setBubblesEnabled(value: Boolean) = diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index cd8002604db..f980ea2003a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -362,8 +362,11 @@ How to translate with transifex: Enable bubbles for this conversation in the conversation info. Bubbles Allow Talk notifications to appear as floating bubbles. + Enable “All conversations can bubble” in Android notification settings to allow bubbles. All conversations can bubble Override individual conversation bubble settings. + Unable to open Android bubble settings. Please enable bubbles for Talk from system notification settings. + Turn on “All conversations can bubble” in Android notification settings first. Sensitive conversation Message preview will be disabled in conversation list and notifications Important conversation From 7c463cbed4745cf6c998e209f19433eea2839868 Mon Sep 17 00:00:00 2001 From: alex Date: Sun, 28 Dec 2025 15:41:27 +0100 Subject: [PATCH 08/18] codacy warnings Signed-off-by: alex --- .../com/nextcloud/talk/chat/BubbleActivity.kt | 1 - .../nextcloud/talk/jobs/NotificationWorker.kt | 107 ++++++++++-------- 2 files changed, 61 insertions(+), 47 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/chat/BubbleActivity.kt b/app/src/main/java/com/nextcloud/talk/chat/BubbleActivity.kt index 6ab4c469b47..1c9e8fc498e 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/BubbleActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/BubbleActivity.kt @@ -22,7 +22,6 @@ class BubbleActivity : ChatActivity() { supportActionBar?.setDisplayHomeAsUpEnabled(true) supportActionBar?.setHomeAsUpIndicator(R.drawable.ic_talk) supportActionBar?.setDisplayShowHomeEnabled(true) - findViewById(R.id.chat_toolbar)?.setNavigationOnClickListener { openConversationList() } diff --git a/app/src/main/java/com/nextcloud/talk/jobs/NotificationWorker.kt b/app/src/main/java/com/nextcloud/talk/jobs/NotificationWorker.kt index 7c7d511bbe5..cf552cc43b3 100644 --- a/app/src/main/java/com/nextcloud/talk/jobs/NotificationWorker.kt +++ b/app/src/main/java/com/nextcloud/talk/jobs/NotificationWorker.kt @@ -538,8 +538,10 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor // NOTE - systemNotificationId is an internal ID used on the device only. // It is NOT the same as the notification ID used in communication with the server. // Use a consistent ID based on the room token to avoid duplicate bubbles - val systemNotificationId: Int = - activeStatusBarNotification?.id ?: NotificationUtils.calculateCRC32("${signatureVerification.user!!.id}:${pushMessage.id!!}").toInt() + val systemNotificationId: Int = activeStatusBarNotification?.id + ?: NotificationUtils.calculateCRC32( + "${signatureVerification.user!!.id}:${pushMessage.id!!}" + ).toInt() if ((TYPE_CHAT == pushMessage.type || TYPE_REMINDER == pushMessage.type) && pushMessage.notificationUser != null @@ -764,21 +766,24 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor } private fun addBubbleMetadata(notificationBuilder: NotificationCompat.Builder, suppressNotification: Boolean) { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { - return // Bubbles require API 30+ (Android 11) + val roomToken = pushMessage.id + val shouldAbort = Build.VERSION.SDK_INT < Build.VERSION_CODES.R || + roomToken.isNullOrEmpty() || + roomToken?.let { !shouldBubble(it) } == true + + if (shouldAbort) { + return } + val ensuredRoomToken = roomToken!! + try { - val roomToken = pushMessage.id ?: return - if (!shouldBubble(roomToken)) { - return - } val conversationName = pushMessage.subject?.takeIf { it.isNotBlank() } - val shortcutId = "conversation_$roomToken" + val shortcutId = "conversation_$ensuredRoomToken" val fallbackConversationLabel = conversationName ?: context!!.getString(R.string.nc_app_name) - val bubbleIcon = resolveBubbleIcon(roomToken) ?: run { - val fallbackBitmap = getLargeIcon() ?: return + val bubbleIcon = resolveBubbleIcon(ensuredRoomToken) ?: run { + val fallbackBitmap = getLargeIcon() androidx.core.graphics.drawable.IconCompat.createWithBitmap(fallbackBitmap) } @@ -802,11 +807,11 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor androidx.core.content.pm.ShortcutManagerCompat.pushDynamicShortcut(context!!, shortcut) - val bubbleRequestCode = NotificationUtils.calculateCRC32("bubble_$roomToken").toInt() + val bubbleRequestCode = NotificationUtils.calculateCRC32("bubble_$ensuredRoomToken").toInt() val bubbleIntent = android.app.PendingIntent.getActivity( context, bubbleRequestCode, - com.nextcloud.talk.chat.BubbleActivity.newIntent(context!!, roomToken, conversationName), + com.nextcloud.talk.chat.BubbleActivity.newIntent(context!!, ensuredRoomToken, conversationName), android.app.PendingIntent.FLAG_UPDATE_CURRENT or android.app.PendingIntent.FLAG_MUTABLE ) @@ -830,53 +835,63 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor } private fun shouldBubble(roomToken: String): Boolean { - if (!appPreferences.areBubblesEnabled()) { - return false - } - if (appPreferences.areBubblesForced()) { - return true + val user = signatureVerification.user + + return when { + !appPreferences.areBubblesEnabled() -> false + appPreferences.areBubblesForced() -> true + user == null -> false + else -> { + val accountId = UserIdUtils.getIdForUser(user) + arbitraryStorageManager + ?.getStorageSetting(accountId, BUBBLE_SWITCH_KEY, roomToken) + ?.map { storage -> storage.value?.toBoolean() ?: false } + ?.blockingGet(false) ?: false + } } - val user = signatureVerification.user ?: return false - val accountId = UserIdUtils.getIdForUser(user) - return arbitraryStorageManager - ?.getStorageSetting(accountId, BUBBLE_SWITCH_KEY, roomToken) - ?.map { storage -> storage.value?.toBoolean() ?: false } - ?.blockingGet(false) ?: false } private fun resolveBubbleIcon(roomToken: String): androidx.core.graphics.drawable.IconCompat? { - val ctx = context ?: return null - val baseUrl = signatureVerification.user?.baseUrl ?: return null - val isDarkMode = DisplayUtils.isDarkModeOn(ctx) + val ctx = context + val baseUrl = signatureVerification.user?.baseUrl + if (ctx == null || baseUrl.isNullOrEmpty()) { + return null + } + val isDarkMode = DisplayUtils.isDarkModeOn(ctx) var conversationAvatarUrl = ApiUtils.getUrlForConversationAvatar(ApiUtils.API_V1, baseUrl, roomToken) if (isDarkMode) { conversationAvatarUrl += "/dark" } - NotificationUtils.loadAvatarSyncForBubble(conversationAvatarUrl, ctx, credentials)?.let { - return it - } + val conversationIcon = NotificationUtils.loadAvatarSyncForBubble(conversationAvatarUrl, ctx, credentials) + val resolvedIcon = conversationIcon ?: resolveOneToOneBubbleIcon(ctx, baseUrl, isDarkMode) - if (conversationType.equals("one2one", ignoreCase = true)) { - val notificationUser = pushMessage.notificationUser - val userType = notificationUser?.type - val userAvatarUrl = when { - notificationUser == null || notificationUser.id.isNullOrEmpty() -> null - userType.equals("guest", ignoreCase = true) -> - ApiUtils.getUrlForGuestAvatar(baseUrl, notificationUser.name, true) - isDarkMode -> ApiUtils.getUrlForAvatarDarkTheme(baseUrl, notificationUser.id, true) - else -> ApiUtils.getUrlForAvatar(baseUrl, notificationUser.id, true) - } + return resolvedIcon + } - if (userAvatarUrl != null) { - NotificationUtils.loadAvatarSyncForBubble(userAvatarUrl, ctx, credentials)?.let { - return it - } - } + private fun resolveOneToOneBubbleIcon( + ctx: Context, + baseUrl: String, + isDarkMode: Boolean + ): androidx.core.graphics.drawable.IconCompat? { + if (!conversationType.equals("one2one", ignoreCase = true)) { + return null } - return null + val notificationUser = pushMessage.notificationUser + val userType = notificationUser?.type + val userAvatarUrl = when { + notificationUser == null || notificationUser.id.isNullOrEmpty() -> null + userType.equals("guest", ignoreCase = true) -> + ApiUtils.getUrlForGuestAvatar(baseUrl, notificationUser.name, true) + isDarkMode -> ApiUtils.getUrlForAvatarDarkTheme(baseUrl, notificationUser.id, true) + else -> ApiUtils.getUrlForAvatar(baseUrl, notificationUser.id, true) + } + + return userAvatarUrl?.let { + NotificationUtils.loadAvatarSyncForBubble(it, ctx, credentials) + } } private fun buildIntentForAction(cls: Class<*>, systemNotificationId: Int, messageId: Int): PendingIntent { From 4e23c56313d7f02b6213fed87c67946792083689 Mon Sep 17 00:00:00 2001 From: alex Date: Sun, 28 Dec 2025 15:55:15 +0100 Subject: [PATCH 09/18] systemNotificationId fix Signed-off-by: alex --- app/src/main/java/com/nextcloud/talk/jobs/NotificationWorker.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/nextcloud/talk/jobs/NotificationWorker.kt b/app/src/main/java/com/nextcloud/talk/jobs/NotificationWorker.kt index cf552cc43b3..ae0b2d0c0f9 100644 --- a/app/src/main/java/com/nextcloud/talk/jobs/NotificationWorker.kt +++ b/app/src/main/java/com/nextcloud/talk/jobs/NotificationWorker.kt @@ -540,7 +540,7 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor // Use a consistent ID based on the room token to avoid duplicate bubbles val systemNotificationId: Int = activeStatusBarNotification?.id ?: NotificationUtils.calculateCRC32( - "${signatureVerification.user!!.id}:${pushMessage.id!!}" + System.currentTimeMillis().toString() ).toInt() if ((TYPE_CHAT == pushMessage.type || TYPE_REMINDER == pushMessage.type) && From d11c76182b13b5a624c1ad14322e9db3ea7fa90a Mon Sep 17 00:00:00 2001 From: alex Date: Sun, 28 Dec 2025 16:03:36 +0100 Subject: [PATCH 10/18] Fix 2 bubbles Signed-off-by: alex --- app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt index 2dccd11bf28..3df723e2edd 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt @@ -3419,7 +3419,13 @@ open class ChatActivity : val notificationManager = getSystemService( Context.NOTIFICATION_SERVICE ) as android.app.NotificationManager - val notificationId = NotificationUtils.calculateCRC32(roomToken).toInt() + val existingNotification = NotificationUtils.findNotificationForRoom( + this@ChatActivity, + conversationUser!!, + roomToken + ) + val notificationId = existingNotification?.id + ?: NotificationUtils.calculateCRC32(roomToken).toInt() notificationManager.cancel(notificationId) androidx.core.content.pm.ShortcutManagerCompat.removeDynamicShortcuts( From 605af97bf22b7583a15f50b7d9069f4bf4e55ec7 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Thu, 8 Jan 2026 15:52:08 +0100 Subject: [PATCH 11/18] fix user provider usage Signed-off-by: Marcel Hibbe --- .../main/java/com/nextcloud/talk/settings/SettingsActivity.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/nextcloud/talk/settings/SettingsActivity.kt b/app/src/main/java/com/nextcloud/talk/settings/SettingsActivity.kt index c1d07c67fb8..8c41ea4b670 100644 --- a/app/src/main/java/com/nextcloud/talk/settings/SettingsActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/settings/SettingsActivity.kt @@ -479,7 +479,7 @@ class SettingsActivity : if (!newValue) { NotificationUtils.dismissBubblesWithoutExplicitSettings( this, - currentUserProvider.currentUser.blockingGet() + currentUserProviderOld.currentUser.blockingGet() ) } } From 6a5a728ae5034864bbbc2df9cedfffcd31a2dbde Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Thu, 8 Jan 2026 16:31:14 +0100 Subject: [PATCH 12/18] change logic to not delete bubbles instead to use KEY_NOTIFICATION_RESTRICT_DELETION, always check if notification is actually bubbled in cancelNotification Without this commit, bubbled notifications would remain open when the chat was opened in normal mode. With this commit, bubbled notifications will be removed when opening the chat in normal mode. Signed-off-by: Marcel Hibbe --- .../com/nextcloud/talk/chat/ChatActivity.kt | 9 ++++----- .../nextcloud/talk/jobs/NotificationWorker.kt | 3 +-- .../nextcloud/talk/utils/NotificationUtils.kt | 17 +++++++++-------- 3 files changed, 14 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt index 35a340a3513..5dc011ae584 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt @@ -2708,8 +2708,9 @@ open class ChatActivity : @Suppress("Detekt.TooGenericExceptionCaught") protected open fun cancelNotificationsForCurrentConversation() { + val isBubbleMode = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && isLaunchedFromBubble if (conversationUser != null) { - if (!TextUtils.isEmpty(roomToken)) { + if (!TextUtils.isEmpty(roomToken) && !isBubbleMode) { try { NotificationUtils.cancelExistingNotificationsForRoom( applicationContext, @@ -3541,11 +3542,9 @@ open class ChatActivity : val messagingStyle = androidx.core.app.NotificationCompat.MessagingStyle(person) .setConversationTitle(conversationName) - // Create extras bundle to protect bubble from deletion val notificationExtras = bundleOf( - BundleKeys.KEY_ROOM_TOKEN to roomToken, - BundleKeys.KEY_NOTIFICATION_RESTRICT_DELETION to true, - BundleKeys.KEY_INTERNAL_USER_ID to conversationUser!!.id!! + KEY_ROOM_TOKEN to roomToken, + KEY_INTERNAL_USER_ID to conversationUser!!.id!! ) val channelId = NotificationUtils.NotificationChannels.NOTIFICATION_CHANNEL_MESSAGES_V4.name diff --git a/app/src/main/java/com/nextcloud/talk/jobs/NotificationWorker.kt b/app/src/main/java/com/nextcloud/talk/jobs/NotificationWorker.kt index ae0b2d0c0f9..2d3a355c4f4 100644 --- a/app/src/main/java/com/nextcloud/talk/jobs/NotificationWorker.kt +++ b/app/src/main/java/com/nextcloud/talk/jobs/NotificationWorker.kt @@ -657,8 +657,7 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor notificationInfoBundle.putString(KEY_ROOM_TOKEN, pushMessage.id) notificationInfoBundle.putLong(KEY_NOTIFICATION_ID, pushMessage.notificationId!!) - // Protect bubble notifications from being canceled - if (pushMessage.type == TYPE_RECORDING || pushMessage.type == TYPE_CHAT || pushMessage.type == TYPE_REMINDER) { + if (pushMessage.type == TYPE_RECORDING) { notificationInfoBundle.putBoolean(KEY_NOTIFICATION_RESTRICT_DELETION, true) } diff --git a/app/src/main/java/com/nextcloud/talk/utils/NotificationUtils.kt b/app/src/main/java/com/nextcloud/talk/utils/NotificationUtils.kt index 032cd75414d..320ae73fa95 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/NotificationUtils.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/NotificationUtils.kt @@ -22,6 +22,7 @@ import android.graphics.drawable.BitmapDrawable import android.graphics.drawable.Drawable import android.media.AudioAttributes import android.net.Uri +import android.os.Build import android.service.notification.StatusBarNotification import android.text.TextUtils import android.util.Log @@ -241,14 +242,14 @@ object NotificationUtils { fun cancelNotification(context: Context?, conversationUser: User, notificationId: Long?) { scanNotifications(context, conversationUser) { notificationManager, statusBarNotification, notification -> - if (notificationId == notification.extras.getLong(BundleKeys.KEY_NOTIFICATION_ID)) { - if (notification.extras.getBoolean(BundleKeys.KEY_NOTIFICATION_RESTRICT_DELETION)) { - if (BuildConfig.DEBUG) { - Log.d(TAG, "Skip cancelling protected notification ${statusBarNotification.id}") - } - } else { - notificationManager.cancel(statusBarNotification.id) - } + val matchesId = notificationId == notification.extras.getLong(BundleKeys.KEY_NOTIFICATION_ID) + + val isBubble = + Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && + (notification.flags and Notification.FLAG_BUBBLE) != 0 + + if (matchesId && !isBubble) { + notificationManager.cancel(statusBarNotification.id) } } } From 03418697704ef364ede2436ada8ad95713fb609f Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Thu, 8 Jan 2026 18:35:08 +0100 Subject: [PATCH 13/18] fix to enable system bubble when they are disabled but "create bubble" is chosen for a conversation before this commit, only systemAllowsAllConversations was checked. This was not enough but also system wide enabling of bubbles must be checked + minor refactoring Signed-off-by: Marcel Hibbe --- .../com/nextcloud/talk/chat/ChatActivity.kt | 14 ++++++-- .../talk/settings/SettingsActivity.kt | 27 ++++++---------- .../nextcloud/talk/utils/NotificationUtils.kt | 32 +++++++++++++++++-- 3 files changed, 51 insertions(+), 22 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt index 5dc011ae584..40c50aa0a15 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt @@ -3333,6 +3333,8 @@ open class ChatActivity : menu.removeItem(R.id.conversation_voice_call) } + menu.findItem(R.id.create_conversation_bubble)?.isVisible = NotificationUtils.deviceSupportsBubbles + handleThreadNotificationIcon(menu.findItem(R.id.thread_notifications)) } return true @@ -3402,8 +3404,15 @@ open class ChatActivity : } private fun createConversationBubble() { + if (!NotificationUtils.deviceSupportsBubbles) { + Log.e(TAG, "createConversationBubble was called but device doesnt support it. It should not be possible " + + "to get here via UI!") + return + } + lifecycleScope.launch { - if (!appPreferences.areBubblesEnabled()) { + if (!appPreferences.areBubblesEnabled() || !NotificationUtils.areSystemBubblesEnabled(context)) { + // Do not replace with snackbar as it needs to survive screen change Toast.makeText( this@ChatActivity, getString(R.string.nc_conversation_notification_bubble_disabled), @@ -3416,6 +3425,7 @@ open class ChatActivity : if (!appPreferences.areBubblesForced()) { val conversationAllowsBubbles = isConversationBubbleEnabled() if (!conversationAllowsBubbles) { + // Do not replace with snackbar as it needs to survive screen change Toast.makeText( this@ChatActivity, getString(R.string.nc_conversation_notification_bubble_enable_conversation), @@ -3567,7 +3577,7 @@ open class ChatActivity : // Check if notification channel supports bubbles and recreate if needed val channel = notificationManager.getNotificationChannel(channelId) - if (channel == null || !channel.canBubble()) { + if (channel == null || NotificationUtils.deviceSupportsBubbles && !channel.canBubble()) { NotificationUtils.registerNotificationChannels( applicationContext, appPreferences!! diff --git a/app/src/main/java/com/nextcloud/talk/settings/SettingsActivity.kt b/app/src/main/java/com/nextcloud/talk/settings/SettingsActivity.kt index 8c41ea4b670..98008e84c28 100644 --- a/app/src/main/java/com/nextcloud/talk/settings/SettingsActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/settings/SettingsActivity.kt @@ -15,7 +15,6 @@ import android.animation.Animator import android.animation.AnimatorListenerAdapter import android.annotation.SuppressLint import android.app.KeyguardManager -import android.app.NotificationManager import android.content.ActivityNotFoundException import android.content.Context import android.content.DialogInterface @@ -442,19 +441,20 @@ class SettingsActivity : binding.settingsBubbles.visibility = View.VISIBLE binding.settingsBubblesForce.visibility = View.VISIBLE - val systemAllowsAllConversations = isSystemBubblePreferenceAll() + val systemAllowsAllConversations = NotificationUtils.isSystemBubblePreferenceAll(context) + val systemBubblesEnabled = NotificationUtils.areSystemBubblesEnabled(context) updateBubbleSummary(systemAllowsAllConversations) - var bubblesEnabled = appPreferences.areBubblesEnabled() - if (bubblesEnabled && !systemAllowsAllConversations) { + var appBubblesEnabled = appPreferences.areBubblesEnabled() + if (appBubblesEnabled && (!systemAllowsAllConversations || !systemBubblesEnabled)) { appPreferences.setBubblesEnabled(false) - bubblesEnabled = false + appBubblesEnabled = false } - setGlobalBubbleSwitchState(bubblesEnabled) + setGlobalBubbleSwitchState(appBubblesEnabled) binding.settingsBubblesForceSwitch.isChecked = appPreferences.areBubblesForced() - updateBubbleForceRowState(bubblesEnabled) + updateBubbleForceRowState(appBubblesEnabled) binding.settingsBubblesSwitch.setOnCheckedChangeListener { _, isChecked -> if (isUpdatingBubbleSwitchState) { @@ -494,10 +494,10 @@ class SettingsActivity : } private fun handleGlobalBubblePreferenceChange(enabled: Boolean) { - val systemAllowsAllConversations = isSystemBubblePreferenceAll() + val systemAllowsAllConversations = NotificationUtils.isSystemBubblePreferenceAll(context) if (enabled) { - if (!systemAllowsAllConversations) { + if (!systemAllowsAllConversations || !NotificationUtils.areSystemBubblesEnabled(context)) { pendingBubbleEnableAfterSystemChange = true showSystemBubblesDisabledFeedback() updateBubbleSummary(systemAllowsAllConversations) @@ -556,15 +556,6 @@ class SettingsActivity : updateBubbleSummary(true) } - private fun isSystemBubblePreferenceAll(): Boolean { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { - return false - } - - val notificationManager = getSystemService(NotificationManager::class.java) - return notificationManager?.bubblePreference == NotificationManager.BUBBLE_PREFERENCE_ALL - } - private fun navigateToSystemBubbleSettings() { val targetPackage = packageName val targetUid = applicationInfo?.uid ?: -1 diff --git a/app/src/main/java/com/nextcloud/talk/utils/NotificationUtils.kt b/app/src/main/java/com/nextcloud/talk/utils/NotificationUtils.kt index 320ae73fa95..ac32e9d6835 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/NotificationUtils.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/NotificationUtils.kt @@ -23,6 +23,7 @@ import android.graphics.drawable.Drawable import android.media.AudioAttributes import android.net.Uri import android.os.Build +import android.provider.Settings import android.service.notification.StatusBarNotification import android.text.TextUtils import android.util.Log @@ -79,6 +80,34 @@ object NotificationUtils { const val KEY_UPLOAD_GROUP = "com.nextcloud.talk.utils.KEY_UPLOAD_GROUP" const val GROUP_SUMMARY_NOTIFICATION_ID = -1 + val deviceSupportsBubbles = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q + + fun areSystemBubblesEnabled(context: Context): Boolean { + if (!deviceSupportsBubbles) { + return false + } + + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + Settings.Secure.getInt( + context.contentResolver, + "notification_bubbles", + 1 + ) == 1 + } else { + // Android 10 (Q) — bubbles always enabled + true + } + } + + fun isSystemBubblePreferenceAll(context: Context): Boolean { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { + return false + } + + val notificationManager = context.getSystemService(NotificationManager::class.java) + return notificationManager?.bubblePreference == NotificationManager.BUBBLE_PREFERENCE_ALL + } + private fun createNotificationChannel( context: Context, notificationChannel: Channel, @@ -87,8 +116,7 @@ object NotificationUtils { ) { val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager val isMessagesChannel = notificationChannel.id == NotificationChannels.NOTIFICATION_CHANNEL_MESSAGES_V4.name - val shouldSupportBubbles = - android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.R && isMessagesChannel + val shouldSupportBubbles = deviceSupportsBubbles && isMessagesChannel val existingChannel = notificationManager.getNotificationChannel(notificationChannel.id) val needsRecreation = shouldSupportBubbles && existingChannel != null && !existingChannel.canBubble() From 304da57495d76c7572345c14cc49294709ca6177 Mon Sep 17 00:00:00 2001 From: alex Date: Sat, 10 Jan 2026 15:34:51 +0100 Subject: [PATCH 14/18] Remove outdated comment Signed-off-by: alex --- app/src/main/java/com/nextcloud/talk/jobs/NotificationWorker.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/app/src/main/java/com/nextcloud/talk/jobs/NotificationWorker.kt b/app/src/main/java/com/nextcloud/talk/jobs/NotificationWorker.kt index 2d3a355c4f4..1a90798127d 100644 --- a/app/src/main/java/com/nextcloud/talk/jobs/NotificationWorker.kt +++ b/app/src/main/java/com/nextcloud/talk/jobs/NotificationWorker.kt @@ -537,7 +537,6 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor // NOTE - systemNotificationId is an internal ID used on the device only. // It is NOT the same as the notification ID used in communication with the server. - // Use a consistent ID based on the room token to avoid duplicate bubbles val systemNotificationId: Int = activeStatusBarNotification?.id ?: NotificationUtils.calculateCRC32( System.currentTimeMillis().toString() From 374c37891df22a6cc389ae89bb3a0894449523bb Mon Sep 17 00:00:00 2001 From: alex Date: Mon, 12 Jan 2026 11:51:59 +0100 Subject: [PATCH 15/18] Fix build Signed-off-by: alex --- .../talk/models/json/push/DecryptedPushMessage.kt | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/models/json/push/DecryptedPushMessage.kt b/app/src/main/java/com/nextcloud/talk/models/json/push/DecryptedPushMessage.kt index e3bb3cc74ec..0fcef928376 100644 --- a/app/src/main/java/com/nextcloud/talk/models/json/push/DecryptedPushMessage.kt +++ b/app/src/main/java/com/nextcloud/talk/models/json/push/DecryptedPushMessage.kt @@ -32,7 +32,7 @@ data class DecryptedPushMessage( var notificationId: Long?, @JsonField(name = ["nids"]) - var notificationIds: LongArray?, + var notificationIds: List?, @JsonField(name = ["delete"]) var delete: Boolean, @@ -70,12 +70,7 @@ data class DecryptedPushMessage( if (subject != other.subject) return false if (id != other.id) return false if (notificationId != other.notificationId) return false - if (notificationIds != null) { - if (other.notificationIds == null) return false - if (!notificationIds.contentEquals(other.notificationIds)) return false - } else if (other.notificationIds != null) { - return false - } + if (notificationIds != other.notificationIds) return false if (delete != other.delete) return false if (deleteAll != other.deleteAll) return false if (deleteMultiple != other.deleteMultiple) return false @@ -93,7 +88,7 @@ data class DecryptedPushMessage( result = 31 * result + (subject?.hashCode() ?: 0) result = 31 * result + (id?.hashCode() ?: 0) result = 31 * result + (notificationId?.hashCode() ?: 0) - result = 31 * result + (notificationIds?.contentHashCode() ?: 0) + result = 31 * result + (notificationIds?.hashCode() ?: 0) result = 31 * result + (delete?.hashCode() ?: 0) result = 31 * result + (deleteAll?.hashCode() ?: 0) result = 31 * result + (deleteMultiple?.hashCode() ?: 0) From 8ab2945a8f78a5237c7af9220a74dcdf45c03def Mon Sep 17 00:00:00 2001 From: alex Date: Mon, 12 Jan 2026 12:56:21 +0100 Subject: [PATCH 16/18] Extracted bubble logic Bubble logic extracted from ChatActivity to NotificationUtils Signed-off-by: alex --- .../com/nextcloud/talk/chat/ChatActivity.kt | 169 ++--------------- .../nextcloud/talk/utils/NotificationUtils.kt | 179 ++++++++++++++++++ 2 files changed, 190 insertions(+), 158 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt index 40c50aa0a15..3e422e62622 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt @@ -3436,164 +3436,18 @@ open class ChatActivity : } } - try { - val shortcutId = "conversation_$roomToken" - val conversationName = currentConversation?.displayName ?: getString(R.string.nc_app_name) - - val notificationManager = getSystemService( - Context.NOTIFICATION_SERVICE - ) as android.app.NotificationManager - val existingNotification = NotificationUtils.findNotificationForRoom( - this@ChatActivity, - conversationUser!!, - roomToken - ) - val notificationId = existingNotification?.id - ?: NotificationUtils.calculateCRC32(roomToken).toInt() - - notificationManager.cancel(notificationId) - androidx.core.content.pm.ShortcutManagerCompat.removeDynamicShortcuts( - this@ChatActivity, - listOf(shortcutId) - ) - - // Load conversation avatar on background thread - val avatarIcon = withContext(Dispatchers.IO) { - try { - var avatarUrl = if (isOneToOneConversation()) { - ApiUtils.getUrlForAvatar( - conversationUser!!.baseUrl!!, - currentConversation!!.name, - true - ) - } else { - ApiUtils.getUrlForConversationAvatar( - ApiUtils.API_V1, - conversationUser!!.baseUrl!!, - roomToken - ) - } - - if (DisplayUtils.isDarkModeOn(this@ChatActivity)) { - avatarUrl = "$avatarUrl/dark" - } - - NotificationUtils.loadAvatarSyncForBubble(avatarUrl, this@ChatActivity, credentials) - } catch (e: IOException) { - Log.e(TAG, "Error loading bubble avatar: IO error", e) - null - } catch (e: IllegalArgumentException) { - Log.e(TAG, "Error loading bubble avatar: Invalid argument", e) - null - } - } + val conversationName = currentConversation?.displayName ?: getString(R.string.nc_app_name) - val icon = avatarIcon ?: androidx.core.graphics.drawable.IconCompat.createWithResource( - this@ChatActivity, - R.drawable.ic_logo - ) - - val person = androidx.core.app.Person.Builder() - .setName(conversationName) - .setKey(shortcutId) - .setImportant(true) - .setIcon(icon) - .build() - - // Use the same request code calculation as NotificationWorker - val bubbleRequestCode = NotificationUtils.calculateCRC32("bubble_$roomToken").toInt() - - val bubbleIntent = android.app.PendingIntent.getActivity( - this@ChatActivity, - bubbleRequestCode, - BubbleActivity.newIntent(this@ChatActivity, roomToken, conversationName), - android.app.PendingIntent.FLAG_UPDATE_CURRENT or android.app.PendingIntent.FLAG_MUTABLE - ) - - val contentIntent = android.app.PendingIntent.getActivity( - this@ChatActivity, - bubbleRequestCode, - Intent(this@ChatActivity, ChatActivity::class.java).apply { - putExtra(BundleKeys.KEY_ROOM_TOKEN, roomToken) - conversationName?.let { putExtra(BundleKeys.KEY_CONVERSATION_NAME, it) } - flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP - }, - android.app.PendingIntent.FLAG_UPDATE_CURRENT or android.app.PendingIntent.FLAG_IMMUTABLE - ) - - val shortcutIntent = Intent(this@ChatActivity, ChatActivity::class.java).apply { - action = Intent.ACTION_VIEW - putExtra(BundleKeys.KEY_ROOM_TOKEN, roomToken) - conversationName?.let { putExtra(BundleKeys.KEY_CONVERSATION_NAME, it) } - } - - val shortcut = androidx.core.content.pm.ShortcutInfoCompat.Builder(this@ChatActivity, shortcutId) - .setShortLabel(conversationName) - .setLongLabel(conversationName) - .setIcon(icon) - .setIntent(shortcutIntent) - .setLongLived(true) - .setPerson(person) - .setCategories(setOf(android.app.Notification.CATEGORY_MESSAGE)) - .setLocusId(androidx.core.content.LocusIdCompat(shortcutId)) - .build() - - androidx.core.content.pm.ShortcutManagerCompat.pushDynamicShortcut(this@ChatActivity, shortcut) - - val bubbleData = androidx.core.app.NotificationCompat.BubbleMetadata.Builder( - bubbleIntent, - icon - ) - .setDesiredHeight(BUBBLE_DESIRED_HEIGHT_PX) - .setAutoExpandBubble(false) - .setSuppressNotification(true) - .build() - - val messagingStyle = androidx.core.app.NotificationCompat.MessagingStyle(person) - .setConversationTitle(conversationName) - - val notificationExtras = bundleOf( - KEY_ROOM_TOKEN to roomToken, - KEY_INTERNAL_USER_ID to conversationUser!!.id!! - ) - - val channelId = NotificationUtils.NotificationChannels.NOTIFICATION_CHANNEL_MESSAGES_V4.name - val notification = androidx.core.app.NotificationCompat.Builder(this@ChatActivity, channelId) - .setContentTitle(conversationName) - .setSmallIcon(R.drawable.ic_notification) - .setCategory(androidx.core.app.NotificationCompat.CATEGORY_MESSAGE) - .setShortcutId(shortcutId) - .setLocusId(androidx.core.content.LocusIdCompat(shortcutId)) - .addPerson(person) - .setStyle(messagingStyle) - .setBubbleMetadata(bubbleData) - .setContentIntent(contentIntent) - .setAutoCancel(true) - .setOngoing(false) - .setOnlyAlertOnce(true) - .setExtras(notificationExtras) - .build() - - // Check if notification channel supports bubbles and recreate if needed - val channel = notificationManager.getNotificationChannel(channelId) - - if (channel == null || NotificationUtils.deviceSupportsBubbles && !channel.canBubble()) { - NotificationUtils.registerNotificationChannels( - applicationContext, - appPreferences!! - ) - } - - // Use the same notification ID calculation as NotificationWorker - // Show notification with bubble - notificationManager.notify(notificationId, notification) - } catch (e: SecurityException) { - Log.e(TAG, "Error creating bubble: Permission denied", e) - Toast.makeText(this@ChatActivity, R.string.nc_common_error_sorry, Toast.LENGTH_SHORT).show() - } catch (e: IllegalArgumentException) { - Log.e(TAG, "Error creating bubble: Invalid argument", e) - Toast.makeText(this@ChatActivity, R.string.nc_common_error_sorry, Toast.LENGTH_SHORT).show() - } + NotificationUtils.createConversationBubble( + context = this@ChatActivity, + roomToken = roomToken, + conversationRemoteId = currentConversation!!.name, + conversationName = conversationName, + conversationUser = conversationUser!!, + isOneToOneConversation = isOneToOneConversation(), + credentials = credentials, + appPreferences = appPreferences!! + ) } } @@ -4828,7 +4682,6 @@ open class ChatActivity : private const val MESSAGE_PULL_LIMIT = 100 private const val NOTIFICATION_LEVEL_DEFAULT = 1 private const val NOTIFICATION_LEVEL_NEVER = 3 - private const val BUBBLE_DESIRED_HEIGHT_PX = 600 private const val INVITE_LENGTH = 6 private const val ACTOR_LENGTH = 6 private const val CHUNK_SIZE: Int = 10 diff --git a/app/src/main/java/com/nextcloud/talk/utils/NotificationUtils.kt b/app/src/main/java/com/nextcloud/talk/utils/NotificationUtils.kt index ac32e9d6835..111c1984405 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/NotificationUtils.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/NotificationUtils.kt @@ -11,6 +11,7 @@ import android.app.Notification import android.app.NotificationChannel import android.app.NotificationManager import android.content.Context +import android.content.Intent import android.graphics.Bitmap import android.graphics.Canvas import android.graphics.Color @@ -32,6 +33,7 @@ import androidx.core.content.pm.ShortcutManagerCompat import androidx.core.graphics.drawable.IconCompat import androidx.core.graphics.drawable.toBitmap import androidx.core.net.toUri +import androidx.core.os.bundleOf import coil.executeBlocking import coil.imageLoader import coil.request.ImageRequest @@ -41,10 +43,16 @@ import coil.transform.CircleCropTransformation import com.bluelinelabs.logansquare.LoganSquare import com.nextcloud.talk.BuildConfig import com.nextcloud.talk.R +import com.nextcloud.talk.chat.BubbleActivity +import com.nextcloud.talk.chat.ChatActivity import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.models.RingtoneSettings +import com.nextcloud.talk.utils.ApiUtils +import com.nextcloud.talk.utils.DisplayUtils import com.nextcloud.talk.utils.bundle.BundleKeys import com.nextcloud.talk.utils.preferences.AppPreferences +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import java.io.IOException import java.util.concurrent.ConcurrentHashMap import java.util.zip.CRC32 @@ -57,6 +65,7 @@ object NotificationUtils { const val TAG = "NotificationUtils" private const val BUBBLE_ICON_SIZE_DP = 96 + const val BUBBLE_DESIRED_HEIGHT_PX = 600 private const val BUBBLE_ICON_CONTENT_RATIO = 0.68f private const val BUBBLE_SIZE_MULTIPLIER = 4 private const val MIN_BUBBLE_CONTENT_RATIO = 0.5f @@ -520,6 +529,176 @@ object NotificationUtils { return resultBitmap } + suspend fun createConversationBubble( + context: Context, + roomToken: String, + conversationRemoteId: String, + conversationName: String?, + conversationUser: User, + isOneToOneConversation: Boolean, + credentials: String?, + appPreferences: AppPreferences + ) { + try { + val shortcutId = "conversation_$roomToken" + val bubbleConversationName = conversationName ?: context.getString(R.string.nc_app_name) + + val notificationManager = context.getSystemService( + Context.NOTIFICATION_SERVICE + ) as android.app.NotificationManager + val existingNotification = findNotificationForRoom( + context, + conversationUser, + roomToken + ) + val notificationId = existingNotification?.id + ?: calculateCRC32(roomToken).toInt() + + notificationManager.cancel(notificationId) + ShortcutManagerCompat.removeDynamicShortcuts( + context, + listOf(shortcutId) + ) + + // Load conversation avatar on background thread + val avatarIcon = withContext(Dispatchers.IO) { + try { + var avatarUrl = if (isOneToOneConversation) { + ApiUtils.getUrlForAvatar( + conversationUser.baseUrl!!, + conversationRemoteId, + true + ) + } else { + ApiUtils.getUrlForConversationAvatar( + ApiUtils.API_V1, + conversationUser.baseUrl!!, + roomToken + ) + } + + if (DisplayUtils.isDarkModeOn(context)) { + avatarUrl = "$avatarUrl/dark" + } + + loadAvatarSyncForBubble(avatarUrl, context, credentials) + } catch (e: IOException) { + Log.e(TAG, "Error loading bubble avatar: IO error", e) + null + } catch (e: IllegalArgumentException) { + Log.e(TAG, "Error loading bubble avatar: Invalid argument", e) + null + } + } + + val icon = avatarIcon ?: androidx.core.graphics.drawable.IconCompat.createWithResource( + context, + R.drawable.ic_logo + ) + + val person = androidx.core.app.Person.Builder() + .setName(bubbleConversationName) + .setKey(shortcutId) + .setImportant(true) + .setIcon(icon) + .build() + + // Use the same request code calculation as NotificationWorker + val bubbleRequestCode = calculateCRC32("bubble_$roomToken").toInt() + + val bubbleIntent = android.app.PendingIntent.getActivity( + context, + bubbleRequestCode, + BubbleActivity.newIntent(context, roomToken, bubbleConversationName), + android.app.PendingIntent.FLAG_UPDATE_CURRENT or android.app.PendingIntent.FLAG_MUTABLE + ) + + val contentIntent = android.app.PendingIntent.getActivity( + context, + bubbleRequestCode, + Intent(context, ChatActivity::class.java).apply { + putExtra(BundleKeys.KEY_ROOM_TOKEN, roomToken) + conversationName?.let { putExtra(BundleKeys.KEY_CONVERSATION_NAME, it) } + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP + }, + android.app.PendingIntent.FLAG_UPDATE_CURRENT or android.app.PendingIntent.FLAG_IMMUTABLE + ) + + val shortcutIntent = Intent(context, ChatActivity::class.java).apply { + action = Intent.ACTION_VIEW + putExtra(BundleKeys.KEY_ROOM_TOKEN, roomToken) + conversationName?.let { putExtra(BundleKeys.KEY_CONVERSATION_NAME, it) } + } + + val shortcut = androidx.core.content.pm.ShortcutInfoCompat.Builder(context, shortcutId) + .setShortLabel(bubbleConversationName) + .setLongLabel(bubbleConversationName) + .setIcon(icon) + .setIntent(shortcutIntent) + .setLongLived(true) + .setPerson(person) + .setCategories(setOf(android.app.Notification.CATEGORY_MESSAGE)) + .setLocusId(androidx.core.content.LocusIdCompat(shortcutId)) + .build() + + ShortcutManagerCompat.pushDynamicShortcut(context, shortcut) + + val bubbleData = androidx.core.app.NotificationCompat.BubbleMetadata.Builder( + bubbleIntent, + icon + ) + .setDesiredHeight(BUBBLE_DESIRED_HEIGHT_PX) + .setAutoExpandBubble(false) + .setSuppressNotification(true) + .build() + + val messagingStyle = androidx.core.app.NotificationCompat.MessagingStyle(person) + .setConversationTitle(bubbleConversationName) + + val notificationExtras = bundleOf( + BundleKeys.KEY_ROOM_TOKEN to roomToken, + BundleKeys.KEY_INTERNAL_USER_ID to conversationUser.id!! + ) + + val channelId = NotificationChannels.NOTIFICATION_CHANNEL_MESSAGES_V4.name + val notification = androidx.core.app.NotificationCompat.Builder(context, channelId) + .setContentTitle(bubbleConversationName) + .setSmallIcon(R.drawable.ic_notification) + .setCategory(androidx.core.app.NotificationCompat.CATEGORY_MESSAGE) + .setShortcutId(shortcutId) + .setLocusId(androidx.core.content.LocusIdCompat(shortcutId)) + .addPerson(person) + .setStyle(messagingStyle) + .setBubbleMetadata(bubbleData) + .setContentIntent(contentIntent) + .setAutoCancel(true) + .setOngoing(false) + .setOnlyAlertOnce(true) + .setExtras(notificationExtras) + .build() + + // Check if notification channel supports bubbles and recreate if needed + val channel = notificationManager.getNotificationChannel(channelId) + + if (channel == null || deviceSupportsBubbles && !channel.canBubble()) { + registerNotificationChannels( + context, + appPreferences + ) + } + + // Use the same notification ID calculation as NotificationWorker + // Show notification with bubble + notificationManager.notify(notificationId, notification) + } catch (e: SecurityException) { + Log.e(TAG, "Error creating bubble: Permission denied", e) + android.widget.Toast.makeText(context, R.string.nc_common_error_sorry, android.widget.Toast.LENGTH_SHORT).show() + } catch (e: IllegalArgumentException) { + Log.e(TAG, "Error creating bubble: Invalid argument", e) + android.widget.Toast.makeText(context, R.string.nc_common_error_sorry, android.widget.Toast.LENGTH_SHORT).show() + } + } + /** * Calculate CRC32 hash for a string, commonly used for generating notification IDs */ From 658d629d018f0948db9089123d8270e5f24eed19 Mon Sep 17 00:00:00 2001 From: alex Date: Mon, 12 Jan 2026 15:33:04 +0100 Subject: [PATCH 17/18] Fix codacy Signed-off-by: alex --- .../com/nextcloud/talk/chat/ChatActivity.kt | 14 +- .../nextcloud/talk/utils/NotificationUtils.kt | 293 +++++++++++------- 2 files changed, 181 insertions(+), 126 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt index 3e422e62622..61165a36793 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt @@ -3440,12 +3440,14 @@ open class ChatActivity : NotificationUtils.createConversationBubble( context = this@ChatActivity, - roomToken = roomToken, - conversationRemoteId = currentConversation!!.name, - conversationName = conversationName, - conversationUser = conversationUser!!, - isOneToOneConversation = isOneToOneConversation(), - credentials = credentials, + bubbleInfo = NotificationUtils.BubbleInfo( + roomToken = roomToken, + conversationRemoteId = currentConversation!!.name, + conversationName = conversationName, + conversationUser = conversationUser!!, + isOneToOneConversation = isOneToOneConversation(), + credentials = credentials + ), appPreferences = appPreferences!! ) } diff --git a/app/src/main/java/com/nextcloud/talk/utils/NotificationUtils.kt b/app/src/main/java/com/nextcloud/talk/utils/NotificationUtils.kt index 111c1984405..a316f94e8d8 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/NotificationUtils.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/NotificationUtils.kt @@ -47,8 +47,6 @@ import com.nextcloud.talk.chat.BubbleActivity import com.nextcloud.talk.chat.ChatActivity import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.models.RingtoneSettings -import com.nextcloud.talk.utils.ApiUtils -import com.nextcloud.talk.utils.DisplayUtils import com.nextcloud.talk.utils.bundle.BundleKeys import com.nextcloud.talk.utils.preferences.AppPreferences import kotlinx.coroutines.Dispatchers @@ -529,30 +527,34 @@ object NotificationUtils { return resultBitmap } + data class BubbleInfo( + val roomToken: String, + val conversationRemoteId: String, + val conversationName: String?, + val conversationUser: User, + val isOneToOneConversation: Boolean, + val credentials: String? + ) + suspend fun createConversationBubble( context: Context, - roomToken: String, - conversationRemoteId: String, - conversationName: String?, - conversationUser: User, - isOneToOneConversation: Boolean, - credentials: String?, + bubbleInfo: BubbleInfo, appPreferences: AppPreferences ) { try { - val shortcutId = "conversation_$roomToken" - val bubbleConversationName = conversationName ?: context.getString(R.string.nc_app_name) + val shortcutId = "conversation_${bubbleInfo.roomToken}" + val bubbleConversationName = bubbleInfo.conversationName ?: context.getString(R.string.nc_app_name) val notificationManager = context.getSystemService( Context.NOTIFICATION_SERVICE ) as android.app.NotificationManager val existingNotification = findNotificationForRoom( context, - conversationUser, - roomToken + bubbleInfo.conversationUser, + bubbleInfo.roomToken ) val notificationId = existingNotification?.id - ?: calculateCRC32(roomToken).toInt() + ?: calculateCRC32(bubbleInfo.roomToken).toInt() notificationManager.cancel(notificationId) ShortcutManagerCompat.removeDynamicShortcuts( @@ -561,123 +563,27 @@ object NotificationUtils { ) // Load conversation avatar on background thread - val avatarIcon = withContext(Dispatchers.IO) { - try { - var avatarUrl = if (isOneToOneConversation) { - ApiUtils.getUrlForAvatar( - conversationUser.baseUrl!!, - conversationRemoteId, - true - ) - } else { - ApiUtils.getUrlForConversationAvatar( - ApiUtils.API_V1, - conversationUser.baseUrl!!, - roomToken - ) - } - - if (DisplayUtils.isDarkModeOn(context)) { - avatarUrl = "$avatarUrl/dark" - } - - loadAvatarSyncForBubble(avatarUrl, context, credentials) - } catch (e: IOException) { - Log.e(TAG, "Error loading bubble avatar: IO error", e) - null - } catch (e: IllegalArgumentException) { - Log.e(TAG, "Error loading bubble avatar: Invalid argument", e) - null - } - } - + val avatarIcon = loadBubbleAvatar(context, bubbleInfo) val icon = avatarIcon ?: androidx.core.graphics.drawable.IconCompat.createWithResource( context, R.drawable.ic_logo ) - val person = androidx.core.app.Person.Builder() - .setName(bubbleConversationName) - .setKey(shortcutId) - .setImportant(true) - .setIcon(icon) - .build() - - // Use the same request code calculation as NotificationWorker - val bubbleRequestCode = calculateCRC32("bubble_$roomToken").toInt() + val person = createBubblePerson(bubbleConversationName, shortcutId, icon) - val bubbleIntent = android.app.PendingIntent.getActivity( - context, - bubbleRequestCode, - BubbleActivity.newIntent(context, roomToken, bubbleConversationName), - android.app.PendingIntent.FLAG_UPDATE_CURRENT or android.app.PendingIntent.FLAG_MUTABLE - ) + pushBubbleShortcut(context, bubbleInfo, bubbleConversationName, shortcutId, icon, person) - val contentIntent = android.app.PendingIntent.getActivity( + val notification = createBubbleNotification( context, - bubbleRequestCode, - Intent(context, ChatActivity::class.java).apply { - putExtra(BundleKeys.KEY_ROOM_TOKEN, roomToken) - conversationName?.let { putExtra(BundleKeys.KEY_CONVERSATION_NAME, it) } - flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP - }, - android.app.PendingIntent.FLAG_UPDATE_CURRENT or android.app.PendingIntent.FLAG_IMMUTABLE - ) - - val shortcutIntent = Intent(context, ChatActivity::class.java).apply { - action = Intent.ACTION_VIEW - putExtra(BundleKeys.KEY_ROOM_TOKEN, roomToken) - conversationName?.let { putExtra(BundleKeys.KEY_CONVERSATION_NAME, it) } - } - - val shortcut = androidx.core.content.pm.ShortcutInfoCompat.Builder(context, shortcutId) - .setShortLabel(bubbleConversationName) - .setLongLabel(bubbleConversationName) - .setIcon(icon) - .setIntent(shortcutIntent) - .setLongLived(true) - .setPerson(person) - .setCategories(setOf(android.app.Notification.CATEGORY_MESSAGE)) - .setLocusId(androidx.core.content.LocusIdCompat(shortcutId)) - .build() - - ShortcutManagerCompat.pushDynamicShortcut(context, shortcut) - - val bubbleData = androidx.core.app.NotificationCompat.BubbleMetadata.Builder( - bubbleIntent, + bubbleInfo, + bubbleConversationName, + shortcutId, + person, icon ) - .setDesiredHeight(BUBBLE_DESIRED_HEIGHT_PX) - .setAutoExpandBubble(false) - .setSuppressNotification(true) - .build() - - val messagingStyle = androidx.core.app.NotificationCompat.MessagingStyle(person) - .setConversationTitle(bubbleConversationName) - - val notificationExtras = bundleOf( - BundleKeys.KEY_ROOM_TOKEN to roomToken, - BundleKeys.KEY_INTERNAL_USER_ID to conversationUser.id!! - ) - - val channelId = NotificationChannels.NOTIFICATION_CHANNEL_MESSAGES_V4.name - val notification = androidx.core.app.NotificationCompat.Builder(context, channelId) - .setContentTitle(bubbleConversationName) - .setSmallIcon(R.drawable.ic_notification) - .setCategory(androidx.core.app.NotificationCompat.CATEGORY_MESSAGE) - .setShortcutId(shortcutId) - .setLocusId(androidx.core.content.LocusIdCompat(shortcutId)) - .addPerson(person) - .setStyle(messagingStyle) - .setBubbleMetadata(bubbleData) - .setContentIntent(contentIntent) - .setAutoCancel(true) - .setOngoing(false) - .setOnlyAlertOnce(true) - .setExtras(notificationExtras) - .build() // Check if notification channel supports bubbles and recreate if needed + val channelId = NotificationChannels.NOTIFICATION_CHANNEL_MESSAGES_V4.name val channel = notificationManager.getNotificationChannel(channelId) if (channel == null || deviceSupportsBubbles && !channel.canBubble()) { @@ -692,13 +598,160 @@ object NotificationUtils { notificationManager.notify(notificationId, notification) } catch (e: SecurityException) { Log.e(TAG, "Error creating bubble: Permission denied", e) - android.widget.Toast.makeText(context, R.string.nc_common_error_sorry, android.widget.Toast.LENGTH_SHORT).show() + showErrorToast(context) } catch (e: IllegalArgumentException) { Log.e(TAG, "Error creating bubble: Invalid argument", e) - android.widget.Toast.makeText(context, R.string.nc_common_error_sorry, android.widget.Toast.LENGTH_SHORT).show() + showErrorToast(context) } } + private suspend fun loadBubbleAvatar(context: Context, bubbleInfo: BubbleInfo): IconCompat? { + return withContext(Dispatchers.IO) { + try { + var avatarUrl = if (bubbleInfo.isOneToOneConversation) { + ApiUtils.getUrlForAvatar( + bubbleInfo.conversationUser.baseUrl!!, + bubbleInfo.conversationRemoteId, + true + ) + } else { + ApiUtils.getUrlForConversationAvatar( + ApiUtils.API_V1, + bubbleInfo.conversationUser.baseUrl!!, + bubbleInfo.roomToken + ) + } + + if (DisplayUtils.isDarkModeOn(context)) { + avatarUrl = "$avatarUrl/dark" + } + + loadAvatarSyncForBubble(avatarUrl, context, bubbleInfo.credentials) + } catch (e: IOException) { + Log.e(TAG, "Error loading bubble avatar: IO error", e) + null + } catch (e: IllegalArgumentException) { + Log.e(TAG, "Error loading bubble avatar: Invalid argument", e) + null + } + } + } + + private fun createBubblePerson( + name: String, + key: String, + icon: IconCompat + ): androidx.core.app.Person { + return androidx.core.app.Person.Builder() + .setName(name) + .setKey(key) + .setImportant(true) + .setIcon(icon) + .build() + } + + private fun pushBubbleShortcut( + context: Context, + bubbleInfo: BubbleInfo, + conversationName: String, + shortcutId: String, + icon: IconCompat, + person: androidx.core.app.Person + ) { + val shortcutIntent = Intent(context, ChatActivity::class.java).apply { + action = Intent.ACTION_VIEW + putExtra(BundleKeys.KEY_ROOM_TOKEN, bubbleInfo.roomToken) + bubbleInfo.conversationName?.let { putExtra(BundleKeys.KEY_CONVERSATION_NAME, it) } + } + + val shortcut = androidx.core.content.pm.ShortcutInfoCompat.Builder(context, shortcutId) + .setShortLabel(conversationName) + .setLongLabel(conversationName) + .setIcon(icon) + .setIntent(shortcutIntent) + .setLongLived(true) + .setPerson(person) + .setCategories(setOf(android.app.Notification.CATEGORY_MESSAGE)) + .setLocusId(androidx.core.content.LocusIdCompat(shortcutId)) + .build() + + ShortcutManagerCompat.pushDynamicShortcut(context, shortcut) + } + + private fun createBubbleNotification( + context: Context, + bubbleInfo: BubbleInfo, + conversationName: String, + shortcutId: String, + person: androidx.core.app.Person, + icon: IconCompat + ): Notification { + // Use the same request code calculation as NotificationWorker + val bubbleRequestCode = calculateCRC32("bubble_${bubbleInfo.roomToken}").toInt() + + val bubbleIntent = android.app.PendingIntent.getActivity( + context, + bubbleRequestCode, + BubbleActivity.newIntent(context, bubbleInfo.roomToken, conversationName), + android.app.PendingIntent.FLAG_UPDATE_CURRENT or android.app.PendingIntent.FLAG_MUTABLE + ) + + val contentIntent = android.app.PendingIntent.getActivity( + context, + bubbleRequestCode, + Intent(context, ChatActivity::class.java).apply { + putExtra(BundleKeys.KEY_ROOM_TOKEN, bubbleInfo.roomToken) + bubbleInfo.conversationName?.let { putExtra(BundleKeys.KEY_CONVERSATION_NAME, it) } + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP + }, + android.app.PendingIntent.FLAG_UPDATE_CURRENT or android.app.PendingIntent.FLAG_IMMUTABLE + ) + + val bubbleData = androidx.core.app.NotificationCompat.BubbleMetadata.Builder( + bubbleIntent, + icon + ) + .setDesiredHeight(BUBBLE_DESIRED_HEIGHT_PX) + .setAutoExpandBubble(false) + .setSuppressNotification(true) + .build() + + val messagingStyle = androidx.core.app.NotificationCompat.MessagingStyle(person) + .setConversationTitle(conversationName) + + val notificationExtras = bundleOf( + BundleKeys.KEY_ROOM_TOKEN to bubbleInfo.roomToken, + BundleKeys.KEY_INTERNAL_USER_ID to bubbleInfo.conversationUser.id!! + ) + + return androidx.core.app.NotificationCompat.Builder( + context, + NotificationChannels.NOTIFICATION_CHANNEL_MESSAGES_V4.name + ) + .setContentTitle(conversationName) + .setSmallIcon(R.drawable.ic_notification) + .setCategory(androidx.core.app.NotificationCompat.CATEGORY_MESSAGE) + .setShortcutId(shortcutId) + .setLocusId(androidx.core.content.LocusIdCompat(shortcutId)) + .addPerson(person) + .setStyle(messagingStyle) + .setBubbleMetadata(bubbleData) + .setContentIntent(contentIntent) + .setAutoCancel(true) + .setOngoing(false) + .setOnlyAlertOnce(true) + .setExtras(notificationExtras) + .build() + } + + private fun showErrorToast(context: Context) { + android.widget.Toast.makeText( + context, + R.string.nc_common_error_sorry, + android.widget.Toast.LENGTH_SHORT + ).show() + } + /** * Calculate CRC32 hash for a string, commonly used for generating notification IDs */ From 93f31f20785923f713ff23e0d4d4f74bf941e1a3 Mon Sep 17 00:00:00 2001 From: alex Date: Mon, 12 Jan 2026 15:51:27 +0100 Subject: [PATCH 18/18] Fix codacy fix Signed-off-by: alex --- .../nextcloud/talk/utils/NotificationUtils.kt | 54 +++++++++---------- 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/utils/NotificationUtils.kt b/app/src/main/java/com/nextcloud/talk/utils/NotificationUtils.kt index a316f94e8d8..8a662bb5289 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/NotificationUtils.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/NotificationUtils.kt @@ -536,6 +536,13 @@ object NotificationUtils { val credentials: String? ) + private data class BubbleNotificationData( + val conversationName: String, + val shortcutId: String, + val icon: IconCompat, + val person: androidx.core.app.Person + ) + suspend fun createConversationBubble( context: Context, bubbleInfo: BubbleInfo, @@ -571,15 +578,14 @@ object NotificationUtils { val person = createBubblePerson(bubbleConversationName, shortcutId, icon) - pushBubbleShortcut(context, bubbleInfo, bubbleConversationName, shortcutId, icon, person) + val bubbleNotificationData = BubbleNotificationData(bubbleConversationName, shortcutId, icon, person) + + pushBubbleShortcut(context, bubbleInfo, bubbleNotificationData) val notification = createBubbleNotification( context, bubbleInfo, - bubbleConversationName, - shortcutId, - person, - icon + bubbleNotificationData ) // Check if notification channel supports bubbles and recreate if needed @@ -653,10 +659,7 @@ object NotificationUtils { private fun pushBubbleShortcut( context: Context, bubbleInfo: BubbleInfo, - conversationName: String, - shortcutId: String, - icon: IconCompat, - person: androidx.core.app.Person + data: BubbleNotificationData ) { val shortcutIntent = Intent(context, ChatActivity::class.java).apply { action = Intent.ACTION_VIEW @@ -664,15 +667,15 @@ object NotificationUtils { bubbleInfo.conversationName?.let { putExtra(BundleKeys.KEY_CONVERSATION_NAME, it) } } - val shortcut = androidx.core.content.pm.ShortcutInfoCompat.Builder(context, shortcutId) - .setShortLabel(conversationName) - .setLongLabel(conversationName) - .setIcon(icon) + val shortcut = androidx.core.content.pm.ShortcutInfoCompat.Builder(context, data.shortcutId) + .setShortLabel(data.conversationName) + .setLongLabel(data.conversationName) + .setIcon(data.icon) .setIntent(shortcutIntent) .setLongLived(true) - .setPerson(person) + .setPerson(data.person) .setCategories(setOf(android.app.Notification.CATEGORY_MESSAGE)) - .setLocusId(androidx.core.content.LocusIdCompat(shortcutId)) + .setLocusId(androidx.core.content.LocusIdCompat(data.shortcutId)) .build() ShortcutManagerCompat.pushDynamicShortcut(context, shortcut) @@ -681,10 +684,7 @@ object NotificationUtils { private fun createBubbleNotification( context: Context, bubbleInfo: BubbleInfo, - conversationName: String, - shortcutId: String, - person: androidx.core.app.Person, - icon: IconCompat + data: BubbleNotificationData ): Notification { // Use the same request code calculation as NotificationWorker val bubbleRequestCode = calculateCRC32("bubble_${bubbleInfo.roomToken}").toInt() @@ -692,7 +692,7 @@ object NotificationUtils { val bubbleIntent = android.app.PendingIntent.getActivity( context, bubbleRequestCode, - BubbleActivity.newIntent(context, bubbleInfo.roomToken, conversationName), + BubbleActivity.newIntent(context, bubbleInfo.roomToken, data.conversationName), android.app.PendingIntent.FLAG_UPDATE_CURRENT or android.app.PendingIntent.FLAG_MUTABLE ) @@ -709,15 +709,15 @@ object NotificationUtils { val bubbleData = androidx.core.app.NotificationCompat.BubbleMetadata.Builder( bubbleIntent, - icon + data.icon ) .setDesiredHeight(BUBBLE_DESIRED_HEIGHT_PX) .setAutoExpandBubble(false) .setSuppressNotification(true) .build() - val messagingStyle = androidx.core.app.NotificationCompat.MessagingStyle(person) - .setConversationTitle(conversationName) + val messagingStyle = androidx.core.app.NotificationCompat.MessagingStyle(data.person) + .setConversationTitle(data.conversationName) val notificationExtras = bundleOf( BundleKeys.KEY_ROOM_TOKEN to bubbleInfo.roomToken, @@ -728,12 +728,12 @@ object NotificationUtils { context, NotificationChannels.NOTIFICATION_CHANNEL_MESSAGES_V4.name ) - .setContentTitle(conversationName) + .setContentTitle(data.conversationName) .setSmallIcon(R.drawable.ic_notification) .setCategory(androidx.core.app.NotificationCompat.CATEGORY_MESSAGE) - .setShortcutId(shortcutId) - .setLocusId(androidx.core.content.LocusIdCompat(shortcutId)) - .addPerson(person) + .setShortcutId(data.shortcutId) + .setLocusId(androidx.core.content.LocusIdCompat(data.shortcutId)) + .addPerson(data.person) .setStyle(messagingStyle) .setBubbleMetadata(bubbleData) .setContentIntent(contentIntent)