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 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(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)
+ }
+ })
+ }
+
+ 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 =
+ when (item.itemId) {
+ R.id.open_conversation_in_app -> {
+ openInMainApp()
+ true
+ }
+ android.R.id.home -> {
+ openConversationList()
+ true
+ }
+ else -> super.onOptionsItemSelected(item)
+ }
+
+ private fun openInMainApp() {
+ val intent = Intent(this, MainActivity::class.java).apply {
+ action = Intent.ACTION_MAIN
+ addCategory(Intent.CATEGORY_LAUNCHER)
+ putExtras(this@BubbleActivity.intent)
+ 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)
+ }
+
+ @Deprecated("Deprecated in Java")
+ override fun onSupportNavigateUp(): Boolean {
+ openInMainApp()
+ return true
+ }
+
+ companion object {
+ 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
+ }
+ }
+}
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 90f20bce462..40c50aa0a15 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,
@@ -2678,26 +2681,36 @@ 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() {
+ 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,
@@ -3284,6 +3297,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)
@@ -3316,6 +3333,8 @@ 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
@@ -3326,8 +3345,8 @@ 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)
@@ -3376,9 +3395,223 @@ class ChatActivity :
true
}
+ R.id.create_conversation_bubble -> {
+ createConversationBubble()
+ true
+ }
+
else -> super.onOptionsItemSelected(item)
}
+ 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() || !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),
+ Toast.LENGTH_SHORT
+ ).show()
+ openBubbleSettings()
+ return@launch
+ }
+
+ 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),
+ 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 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 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()
+ }
+ }
+ }
+
+ 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: 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
+ }
+ }
+ }
+
@Suppress("Detekt.LongMethod")
private fun showThreadNotificationMenu() {
fun setThreadNotificationLevel(level: Int) {
@@ -3433,7 +3666,7 @@ class ChatActivity :
subtitle = null,
icon = R.drawable.ic_baseline_notifications_off_24,
onClick = {
- setThreadNotificationLevel(3)
+ setThreadNotificationLevel(NOTIFICATION_LEVEL_NEVER)
}
)
)
@@ -4106,8 +4339,8 @@ 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(),
@@ -4593,6 +4826,9 @@ 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
@@ -4608,6 +4844,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 2cd4e29b5af..7ffbea259ae 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)
}
}
@@ -1882,6 +1888,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
@@ -1908,6 +1921,70 @@ 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
@@ -1919,6 +1996,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..2d3a355c4f4 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,84 @@ 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.
- val systemNotificationId: Int =
- activeStatusBarNotification?.id ?: calculateCRC32(System.currentTimeMillis().toString()).toInt()
-
- if (TYPE_CHAT == pushMessage.type || TYPE_REMINDER == pushMessage.type) {
+ // Use a consistent ID based on the room token to avoid duplicate bubbles
+ val systemNotificationId: Int = activeStatusBarNotification?.id
+ ?: NotificationUtils.calculateCRC32(
+ System.currentTimeMillis().toString()
+ ).toInt()
+
+ 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)
+ }
+ }
+ }
+ applyShortcutAndLocus(notificationBuilder, effectiveShortcutId)
+ addReplyAction(notificationBuilder, systemNotificationId)
+ addMarkAsReadAction(notificationBuilder, systemNotificationId)
}
if (TYPE_RECORDING == pushMessage.type && ncNotification != null) {
@@ -560,9 +632,13 @@ 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)
+ .setLargeIcon(getLargeIcon())
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(contentTitle)
.setContentText(contentText)
@@ -571,6 +647,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()
@@ -600,10 +678,23 @@ 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
}
+ 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) {
@@ -611,7 +702,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()!!
}
@@ -636,14 +726,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 +742,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 +758,139 @@ 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) {
+ 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 conversationName = pushMessage.subject?.takeIf { it.isNotBlank() }
+ val shortcutId = "conversation_$ensuredRoomToken"
+ val fallbackConversationLabel = conversationName ?: context!!.getString(R.string.nc_app_name)
+
+ val bubbleIcon = resolveBubbleIcon(ensuredRoomToken) ?: run {
+ val fallbackBitmap = getLargeIcon()
+ androidx.core.graphics.drawable.IconCompat.createWithBitmap(fallbackBitmap)
+ }
+
+ val person = androidx.core.app.Person.Builder()
+ .setName(fallbackConversationLabel)
+ .setKey(shortcutId)
+ .setImportant(true)
+ .setIcon(bubbleIcon)
+ .build()
+
+ val shortcut = androidx.core.content.pm.ShortcutInfoCompat.Builder(context!!, shortcutId)
+ .setShortLabel(fallbackConversationLabel)
+ .setLongLabel(fallbackConversationLabel)
+ .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_$ensuredRoomToken").toInt()
+ val bubbleIntent = android.app.PendingIntent.getActivity(
+ context,
+ bubbleRequestCode,
+ com.nextcloud.talk.chat.BubbleActivity.newIntent(context!!, ensuredRoomToken, conversationName),
+ android.app.PendingIntent.FLAG_UPDATE_CURRENT or android.app.PendingIntent.FLAG_MUTABLE
+ )
+
+ val bubbleData = androidx.core.app.NotificationCompat.BubbleMetadata.Builder(
+ bubbleIntent,
+ bubbleIcon
+ )
+ .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: 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)
+ }
+ }
+
+ private fun shouldBubble(roomToken: String): Boolean {
+ 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
+ }
+ }
+ }
+
+ private fun resolveBubbleIcon(roomToken: String): androidx.core.graphics.drawable.IconCompat? {
+ 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"
+ }
+
+ val conversationIcon = NotificationUtils.loadAvatarSyncForBubble(conversationAvatarUrl, ctx, credentials)
+ val resolvedIcon = conversationIcon ?: resolveOneToOneBubbleIcon(ctx, baseUrl, isDarkMode)
+
+ return resolvedIcon
+ }
+
+ private fun resolveOneToOneBubbleIcon(
+ ctx: Context,
+ baseUrl: String,
+ isDarkMode: Boolean
+ ): androidx.core.graphics.drawable.IconCompat? {
+ if (!conversationType.equals("one2one", ignoreCase = true)) {
+ 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 {
@@ -1037,5 +1253,7 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor
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 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 1de5203983a..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,6 +15,7 @@ import android.animation.Animator
import android.animation.AnimatorListenerAdapter
import android.annotation.SuppressLint
import android.app.KeyguardManager
+import android.content.ActivityNotFoundException
import android.content.Context
import android.content.DialogInterface
import android.content.Intent
@@ -83,6 +84,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,12 +144,17 @@ class SettingsActivity :
private var profileQueryDisposable: Disposable? = null
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)
@@ -196,6 +203,12 @@ 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 onSaveInstanceState(outState: Bundle) {
+ super.onSaveInstanceState(outState)
+ outState.putBoolean(STATE_PENDING_ENABLE_BUBBLES, pendingBubbleEnableAfterSystemChange)
}
override fun onResume() {
@@ -247,20 +260,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 +337,7 @@ class SettingsActivity :
setupNotificationSoundsSettings()
setupNotificationPermissionSettings()
setupServerNotificationAppCheck()
+ setupBubbleSettings()
}
@SuppressLint("StringFormatInvalid")
@@ -404,6 +431,194 @@ 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 systemAllowsAllConversations = NotificationUtils.isSystemBubblePreferenceAll(context)
+ val systemBubblesEnabled = NotificationUtils.areSystemBubblesEnabled(context)
+ updateBubbleSummary(systemAllowsAllConversations)
+
+ var appBubblesEnabled = appPreferences.areBubblesEnabled()
+ if (appBubblesEnabled && (!systemAllowsAllConversations || !systemBubblesEnabled)) {
+ appPreferences.setBubblesEnabled(false)
+ appBubblesEnabled = false
+ }
+
+ setGlobalBubbleSwitchState(appBubblesEnabled)
+ binding.settingsBubblesForceSwitch.isChecked = appPreferences.areBubblesForced()
+
+ updateBubbleForceRowState(appBubblesEnabled)
+
+ binding.settingsBubblesSwitch.setOnCheckedChangeListener { _, isChecked ->
+ if (isUpdatingBubbleSwitchState) {
+ return@setOnCheckedChangeListener
+ }
+ handleGlobalBubblePreferenceChange(isChecked)
+ }
+
+ binding.settingsBubbles.setOnClickListener {
+ binding.settingsBubblesSwitch.performClick()
+ }
+
+ 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,
+ currentUserProviderOld.currentUser.blockingGet()
+ )
+ }
+ }
+
+ maybeEnableBubblesAfterSystemChange(systemAllowsAllConversations)
+ }
+
+ 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 handleGlobalBubblePreferenceChange(enabled: Boolean) {
+ val systemAllowsAllConversations = NotificationUtils.isSystemBubblePreferenceAll(context)
+
+ if (enabled) {
+ if (!systemAllowsAllConversations || !NotificationUtils.areSystemBubblesEnabled(context)) {
+ 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 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))
@@ -778,7 +993,9 @@ class SettingsActivity :
settingsPhoneBookIntegrationSwitch,
settingsReadPrivacySwitch,
settingsTypingStatusSwitch,
- settingsProxyUseCredentialsSwitch
+ settingsProxyUseCredentialsSwitch,
+ settingsBubblesSwitch,
+ settingsBubblesForceSwitch
).forEach(viewThemeUtils.talk::colorSwitch)
}
}
@@ -1475,6 +1692,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/NotificationUtils.kt b/app/src/main/java/com/nextcloud/talk/utils/NotificationUtils.kt
index 4023b8083eb..ac32e9d6835 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,32 @@ 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.os.Build
+import android.provider.Settings
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 +46,21 @@ 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 const val BUBBLE_SIZE_MULTIPLIER = 4
+ private const val MIN_BUBBLE_CONTENT_RATIO = 0.5f
+ private val bubbleIconCache = ConcurrentHashMap()
enum class NotificationChannels {
NOTIFICATION_CHANNEL_MESSAGES_V4,
@@ -55,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,
@@ -62,27 +115,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 = deviceSupportsBubbles && 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)
}
@@ -211,7 +270,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)) {
+ 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)
}
}
@@ -240,36 +305,54 @@ 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
@@ -342,5 +425,107 @@ object NotificationUtils {
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
+ }
+
+ 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 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?.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(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)
+ 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..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
@@ -269,6 +269,30 @@ class AppPreferencesImpl(val context: Context) : AppPreferences {
setNotificationChannelIsUpgradedToV3(false)
}
+ override fun areBubblesEnabled(): Boolean =
+ runBlocking {
+ async { readBoolean(BUBBLES_ENABLED, false).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 bc5ad1f74c1..0e4f55d9c9b 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -355,6 +355,18 @@ 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.
+ 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
@@ -432,6 +444,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.