diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 5da4d1fc8da..fa4591b5a80 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -173,6 +173,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 16c71517d36..9e2dad739bb 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
@@ -165,6 +166,7 @@ import com.nextcloud.talk.models.json.signaling.settings.SignalingSettingsOveral
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.settings.SettingsActivity
import com.nextcloud.talk.shareditems.activities.SharedItemsActivity
import com.nextcloud.talk.signaling.SignalingMessageReceiver
import com.nextcloud.talk.signaling.SignalingMessageSender
@@ -217,6 +219,7 @@ import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_START_CALL_AFTER_ROOM_SWIT
import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_SWITCH_TO_ROOM
import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_THREAD_ID
import com.nextcloud.talk.utils.permissions.PlatformPermissionUtil
+import com.nextcloud.talk.utils.preferences.preferencestorage.DatabaseStorageModule
import com.nextcloud.talk.utils.rx.DisposableSet
import com.nextcloud.talk.utils.singletons.ApplicationWideCurrentRoomHolder
import com.nextcloud.talk.webrtc.WebSocketConnectionHelper
@@ -260,7 +263,7 @@ import kotlin.math.roundToInt
@Suppress("TooManyFunctions", "LargeClass", "LongMethod")
@AutoInjector(NextcloudTalkApplication::class)
-class ChatActivity :
+open class ChatActivity :
BaseActivity(),
MessagesListAdapter.OnLoadMoreListener,
MessagesListAdapter.Formatter,
@@ -2814,11 +2817,14 @@ 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 upcomingEvent =
(chatViewModel.upcomingEventViewState.value as? ChatViewModel.UpcomingEventUIState.Success)?.event
@@ -2831,15 +2837,22 @@ class ChatActivity :
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,
@@ -3452,10 +3465,11 @@ class ChatActivity :
showThreadsItem.isVisible = !isChatThread() &&
hasSpreedFeatureCapability(spreedCapabilities, SpreedFeatures.THREADS)
- if (CapabilitiesUtil.isAbleToCall(spreedCapabilities) &&
- !isChatThread() &&
- !ConversationUtils.isNoteToSelfConversation(currentConversation)
- ) {
+ val createBubbleItem = menu.findItem(R.id.create_conversation_bubble)
+ createBubbleItem.isVisible = Build.VERSION.SDK_INT >= 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)
@@ -3487,6 +3501,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
@@ -3497,8 +3513,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)
@@ -3557,6 +3573,11 @@ class ChatActivity :
true
}
+ R.id.create_conversation_bubble -> {
+ createConversationBubble()
+ true
+ }
+
else -> super.onOptionsItemSelected(item)
}
@@ -3637,6 +3658,73 @@ class ChatActivity :
)
}
+ @Suppress("ReturnCount")
+ private fun createConversationBubble() {
+ if (!NotificationUtils.deviceSupportsBubbles) {
+ Log.e(
+ TAG,
+ "createConversationBubble was called but device doesn't support it. It should not be possible " +
+ "to get here via UI!"
+ )
+ return
+ }
+
+ if (!appPreferences.areBubblesEnabled() || !NotificationUtils.areSystemBubblesEnabled(context)) {
+ // Do not replace with snackbar as it needs to survive screen change
+ Toast.makeText(
+ context,
+ getString(R.string.nc_conversation_notification_bubble_disabled),
+ Toast.LENGTH_SHORT
+ ).show()
+ openBubbleSettings()
+ return
+ }
+
+ if (!appPreferences.areBubblesForced() && !isConversationBubbleEnabled()) {
+ // Do not replace with snackbar as it needs to survive screen change
+ Toast.makeText(
+ context,
+ getString(R.string.nc_conversation_notification_bubble_enable_conversation),
+ Toast.LENGTH_SHORT
+ ).show()
+ showConversationInfoScreen(focusBubbleSwitch = true)
+ return
+ }
+
+ val conversationName = currentConversation?.displayName ?: getString(R.string.nc_app_name)
+ currentConversation?.let {
+ val bubbleInfo = NotificationUtils.BubbleInfo(
+ roomToken = roomToken,
+ conversationRemoteId = it.name,
+ conversationName = conversationName,
+ conversationUser = conversationUser,
+ isOneToOneConversation = isOneToOneConversation(),
+ credentials = credentials
+ )
+
+ NotificationUtils.createConversationBubble(
+ context = context,
+ bubbleInfo = bubbleInfo,
+ appPreferences = appPreferences,
+ lifecycleScope
+ )
+ }
+ }
+
+ private fun isConversationBubbleEnabled(): Boolean =
+ runCatching {
+ DatabaseStorageModule(conversationUser, roomToken).getBoolean(BUBBLE_SWITCH_KEY, false)
+ }.onFailure { e ->
+ when (e) {
+ is IOException -> Log.e(TAG, "Failed to read conversation bubble preference: IO error", e)
+ is IllegalStateException -> Log.e(
+ TAG,
+ "Failed to read conversation bubble preference: Invalid state",
+ e
+ )
+ }
+ }.getOrDefault(false)
+
@Suppress("Detekt.LongMethod")
private fun showThreadNotificationMenu() {
fun setThreadNotificationLevel(level: Int) {
@@ -3691,7 +3779,7 @@ class ChatActivity :
subtitle = null,
icon = R.drawable.ic_baseline_notifications_off_24,
onClick = {
- setThreadNotificationLevel(3)
+ setThreadNotificationLevel(NOTIFICATION_LEVEL_NEVER)
}
)
)
@@ -4400,8 +4488,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(),
@@ -4887,6 +4975,8 @@ 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 INVITE_LENGTH = 6
private const val ACTOR_LENGTH = 6
private const val CHUNK_SIZE: Int = 10
@@ -4902,6 +4992,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 f93b9d7de74..a55b6740cc6 100644
--- a/app/src/main/java/com/nextcloud/talk/conversationinfo/ConversationInfoActivity.kt
+++ b/app/src/main/java/com/nextcloud/talk/conversationinfo/ConversationInfoActivity.kt
@@ -8,11 +8,13 @@ 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
import android.view.Menu
import android.view.MenuItem
+import android.view.View
import android.view.View.GONE
import android.view.View.VISIBLE
import androidx.activity.result.ActivityResult
@@ -89,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
@@ -139,6 +143,7 @@ class ConversationInfoActivity : BaseActivity() {
private var databaseStorageModule: DatabaseStorageModule? = null
private var conversation: ConversationModel? = null
+ private var focusBubbleSwitch: Boolean = false
private var participantAdapter: ParticipantItemAdapter? = null
@@ -193,6 +198,7 @@ class ConversationInfoActivity : BaseActivity() {
) { "Missing room token" }
hasAvatarSpacing = intent.getBooleanExtra(BundleKeys.KEY_ROOM_ONE_TO_ONE, false)
+ focusBubbleSwitch = intent.getBooleanExtra(BundleKeys.KEY_FOCUS_CONVERSATION_BUBBLE, false)
val upcomingEvent = intent.getParcelableExtraProvider(BundleKeys.KEY_UPCOMING_EVENT)
if (upcomingEvent != null && (upcomingEvent.summary != null || upcomingEvent.start != null)) {
@@ -283,10 +289,12 @@ class ConversationInfoActivity : BaseActivity() {
Snackbar.LENGTH_LONG
).show()
}
+
is ConversationInfoViewModel.MarkConversationAsSensitiveViewState.Error -> {
Snackbar.make(binding.root, R.string.nc_common_error_sorry, Snackbar.LENGTH_LONG).show()
Log.e(TAG, "failed to mark conversation as insensitive", uiState.exception)
}
+
else -> {
}
}
@@ -303,15 +311,18 @@ class ConversationInfoActivity : BaseActivity() {
Snackbar.LENGTH_LONG
).show()
}
+
is ConversationInfoViewModel.MarkConversationAsInsensitiveViewState.Error -> {
Snackbar.make(binding.root, R.string.nc_common_error_sorry, Snackbar.LENGTH_LONG).show()
Log.e(TAG, "failed to mark conversation as sensitive", uiState.exception)
}
+
else -> {
}
}
}
}
+
private fun initClearChatHistoryObserver() {
viewModel.clearChatHistoryViewState.observe(this) { uiState ->
when (uiState) {
@@ -410,10 +421,12 @@ class ConversationInfoActivity : BaseActivity() {
Snackbar.LENGTH_LONG
).show()
}
+
is ConversationInfoViewModel.MarkConversationAsImportantViewState.Error -> {
Snackbar.make(binding.root, R.string.nc_common_error_sorry, Snackbar.LENGTH_LONG).show()
Log.e(TAG, "failed to mark conversation as important", uiState.exception)
}
+
else -> {
}
}
@@ -430,10 +443,12 @@ class ConversationInfoActivity : BaseActivity() {
Snackbar.LENGTH_LONG
).show()
}
+
is ConversationInfoViewModel.MarkConversationAsUnimportantViewState.Error -> {
Snackbar.make(binding.root, R.string.nc_common_error_sorry, Snackbar.LENGTH_LONG).show()
Log.e(TAG, "failed to mark conversation as unimportant", uiState.exception)
}
+
else -> {
}
}
@@ -448,6 +463,7 @@ class ConversationInfoActivity : BaseActivity() {
is ConversationInfoViewModel.GetRoomErrorState -> {
Snackbar.make(binding.root, R.string.nc_common_error_sorry, Snackbar.LENGTH_LONG).show()
}
+
else -> {}
}
}
@@ -462,10 +478,12 @@ class ConversationInfoActivity : BaseActivity() {
Snackbar.make(binding.root, R.string.nc_common_error_sorry, Snackbar.LENGTH_LONG).show()
}
}
+
is ConversationInfoViewModel.GetProfileErrorState -> {
Log.e(TAG, "Network error occurred getting profile information")
Snackbar.make(binding.root, R.string.nc_common_error_sorry, Snackbar.LENGTH_LONG).show()
}
+
else -> {}
}
}
@@ -573,7 +591,8 @@ class ConversationInfoActivity : BaseActivity() {
binding.guestAccessView.passwordProtectionSwitch,
binding.recordingConsentView.recordingConsentForConversationSwitch,
binding.lockConversationSwitch,
- binding.notificationSettingsView.sensitiveConversationSwitch
+ binding.notificationSettingsView.sensitiveConversationSwitch,
+ binding.notificationSettingsView.bubbleSwitch
).forEach(viewThemeUtils.talk::colorSwitch)
}
}
@@ -1896,6 +1915,13 @@ class ConversationInfoActivity : BaseActivity() {
}
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
@@ -1922,6 +1948,70 @@ class ConversationInfoActivity : BaseActivity() {
}
}
+ 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
@@ -1933,5 +2023,6 @@ class ConversationInfoActivity : BaseActivity() {
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 b705a8f8186..e3a4277f32c 100644
--- a/app/src/main/java/com/nextcloud/talk/jobs/NotificationWorker.kt
+++ b/app/src/main/java/com/nextcloud/talk/jobs/NotificationWorker.kt
@@ -67,8 +67,8 @@ import com.nextcloud.talk.receivers.MarkAsReadReceiver
import com.nextcloud.talk.receivers.ShareRecordingToChatReceiver
import com.nextcloud.talk.users.UserManager
import com.nextcloud.talk.utils.ApiUtils
-import com.nextcloud.talk.utils.DisplayUtils
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
@@ -77,6 +77,7 @@ import com.nextcloud.talk.utils.NotificationUtils.getCallRingtoneUri
import com.nextcloud.talk.utils.NotificationUtils.loadAvatarSync
import com.nextcloud.talk.utils.ParticipantPermissions
import com.nextcloud.talk.utils.PushUtils
+import com.nextcloud.talk.utils.UserIdUtils
import com.nextcloud.talk.utils.bundle.BundleKeys
import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_DISMISS_RECORDING_URL
import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_FROM_NOTIFICATION_START_CALL
@@ -107,7 +108,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
@@ -546,7 +546,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(
@@ -570,20 +573,35 @@ 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) {
- notificationBuilder.setOnlyAlertOnce(false)
- if (pushMessage.notificationUser != null) {
- if (imagePreviewUrl != null) {
- styleImageNotification(notificationBuilder)
- } else {
- styleChatNotification(notificationBuilder, activeStatusBarNotification)
+ val systemNotificationId: Int = activeStatusBarNotification?.id
+ ?: NotificationUtils.calculateCRC32(
+ System.currentTimeMillis().toString()
+ ).toInt()
+
+ if ((TYPE_CHAT == pushMessage.type || TYPE_REMINDER == pushMessage.type) &&
+ pushMessage.notificationUser != null
+ ) {
+ val shortcutId = "conversation_$id"
+ val roomToken = pushMessage.id
+ val bubbleAllowed = roomToken?.let { shouldBubble(it) } ?: false
+ val effectiveShortcutId = if (bubbleAllowed) shortcutId else null
+
+ notificationBuilder
+ .addReplyAction(systemNotificationId)
+ .addMarkAsReadAction(systemNotificationId)
+ .setOnlyAlertOnce(false)
+ .apply {
+ imagePreviewUrl?.let {
+ styleImageNotification(it)
+ } ?: {
+ prepareChatNotification(activeStatusBarNotification)
+ }
}
- addReplyAction(notificationBuilder, systemNotificationId)
- addMarkAsReadAction(notificationBuilder, systemNotificationId)
- }
+ .addBubble(
+ activeStatusBarNotification,
+ effectiveShortcutId,
+ bubbleAllowed
+ )
}
if (TYPE_RECORDING == pushMessage.type && ncNotification != null) {
@@ -593,6 +611,77 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor
sendNotification(systemNotificationId, notificationBuilder.build())
}
+ /**
+ * This only adds a bubble if allowed and valid, else it sets bubble metadata to null
+ */
+ @Suppress("ReturnCount")
+ private fun NotificationCompat.Builder.addBubble(
+ activeStatusBarNotification: StatusBarNotification?,
+ effectiveShortcutId: String?,
+ bubbleAllowed: Boolean
+ ): NotificationCompat.Builder {
+ val previousBubbleExists = (activeStatusBarNotification != null)
+ val versionCheck = (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R)
+
+ if (!versionCheck || !bubbleAllowed) {
+ return this.setBubbleMetadata(null)
+ }
+
+ // Only add bubble metadata if there's no existing notification
+ // If one exists, the bubble metadata will be preserved
+
+ if (!previousBubbleExists) {
+ return this
+ .addBubbleMetadata(false)
+ .applyShortcutAndLocus(effectiveShortcutId)
+ }
+
+ val existingBubble = activeStatusBarNotification.notification.bubbleMetadata
+ val compatBubble = NotificationCompat.BubbleMetadata.fromPlatform(existingBubble)
+ if (compatBubble == null) {
+ Log.e(TAG, "NotificationCompact returns null bubble meta data from non-null active status bar notification")
+ return this.setBubbleMetadata(null) // edge case
+ }
+
+ 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 -> { // edge case
+ return this
+ .addBubbleMetadata(compatBubble.isNotificationSuppressed)
+ .applyShortcutAndLocus(effectiveShortcutId)
+ }
+ }
+
+ compatBubble.deleteIntent?.let { preservedBubbleBuilder.setDeleteIntent(it) }
+
+ if (compatBubble.desiredHeight > 0) {
+ preservedBubbleBuilder.setDesiredHeight(compatBubble.desiredHeight)
+ }
+
+ if (compatBubble.desiredHeightResId != 0) {
+ preservedBubbleBuilder.setDesiredHeightResId(compatBubble.desiredHeightResId)
+ }
+
+ preservedBubbleBuilder
+ .setAutoExpandBubble(compatBubble.autoExpandBubble)
+ .setSuppressNotification(false)
+
+ val preservedMetadata = preservedBubbleBuilder.build()
+ val existingShortcut = compatBubble.shortcutId
+
+ return this
+ .setBubbleMetadata(preservedMetadata)
+ .applyShortcutAndLocus(existingShortcut)
+ }
+
private fun createNotificationBuilder(
category: String,
contentTitle: CharSequence?,
@@ -601,9 +690,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)
@@ -612,6 +705,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()
@@ -641,10 +736,22 @@ 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
}
+ /**
+ * This only sets the LocusId if the shortcutId is not null or empty
+ */
+ private fun NotificationCompat.Builder.applyShortcutAndLocus(shortcutId: String?): NotificationCompat.Builder =
+ if (shortcutId.isNullOrEmpty()) {
+ this
+ } else {
+ val locusId = androidx.core.content.LocusIdCompat(shortcutId)
+ this.setShortcutId(shortcutId)
+ .setLocusId(locusId)
+ }
+
private fun getLargeIcon(): Bitmap {
val largeIcon: Bitmap
if (pushMessage.type == TYPE_RECORDING) {
@@ -652,7 +759,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()!!
}
@@ -678,22 +784,20 @@ 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 styleImageNotification(notificationBuilder: NotificationCompat.Builder) {
- val bitmap = loadImageBitmapSync(imagePreviewUrl!!)
- if (bitmap != null) {
- notificationBuilder
- .setLargeIcon(bitmap)
- .setStyle(
+ /**
+ * This only adds a style and a large icon if the bitmap for the given image url is not null
+ */
+ private fun NotificationCompat.Builder.styleImageNotification(url: String): NotificationCompat.Builder {
+ val bitmap = loadImageBitmapSync(url)
+ return this.apply {
+ if (bitmap != null) {
+ setLargeIcon(bitmap)
+ setStyle(
NotificationCompat.BigPictureStyle()
.bigPicture(bitmap)
.bigLargeIcon(null as Bitmap?)
)
+ }
}
}
@@ -712,11 +816,13 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor
return bitmap
}
- private fun styleChatNotification(
- notificationBuilder: NotificationCompat.Builder,
+ /**
+ * This only adds a style and a person if the push message notification user is not null
+ */
+ private fun NotificationCompat.Builder.prepareChatNotification(
activeStatusBarNotification: StatusBarNotification?
- ) {
- val notificationUser = pushMessage.notificationUser ?: return
+ ): NotificationCompat.Builder {
+ val notificationUser = pushMessage.notificationUser ?: return this
val userType = notificationUser.type
var style: NotificationCompat.MessagingStyle? = null
@@ -725,9 +831,10 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor
activeStatusBarNotification.notification
)
}
- val person = Person.Builder()
+ val personBuilder = 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) {
@@ -742,9 +849,146 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor
} else {
ApiUtils.getUrlForGuestAvatar(baseUrl!!, notificationUser.name, false)
}
- person.setIcon(loadAvatarSync(avatarUrl, context!!))
+ personBuilder.setIcon(loadAvatarSync(avatarUrl, context!!))
+ }
+
+ val person = personBuilder.build()
+
+ return this
+ .setStyle(getStyle(person, style))
+ .addPerson(person)
+ }
+
+ /**
+ * This only adds bubble metadata, locus id, and a shortcut if allowed and valid
+ */
+ private fun NotificationCompat.Builder.addBubbleMetadata(suppress: Boolean): NotificationCompat.Builder =
+ runCatching {
+ val roomToken = pushMessage.id
+ val shouldAbort = Build.VERSION.SDK_INT < Build.VERSION_CODES.R ||
+ roomToken.isNullOrEmpty() ||
+ roomToken.let { !shouldBubble(it) }
+
+ if (shouldAbort) {
+ return@runCatching this
+ }
+
+ 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()
+ androidx.core.graphics.drawable.IconCompat.createWithBitmap(fallbackBitmap)
+ }
+
+ val person = 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_$roomToken").toInt()
+ val bubbleIntent = PendingIntent.getActivity(
+ context,
+ bubbleRequestCode,
+ com.nextcloud.talk.chat.BubbleActivity.newIntent(context!!, roomToken, conversationName),
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
+ )
+
+ val bubbleData = NotificationCompat.BubbleMetadata.Builder(
+ bubbleIntent,
+ bubbleIcon
+ )
+ .setDesiredHeight(BUBBLE_DESIRED_HEIGHT_PX)
+ .setAutoExpandBubble(false)
+ .setSuppressNotification(suppress)
+ .build()
+
+ this.setBubbleMetadata(bubbleData)
+ .setShortcutId(shortcutId)
+ .setLocusId(androidx.core.content.LocusIdCompat(shortcutId))
+ }.onFailure { error ->
+ when (error) {
+ is IllegalArgumentException -> Log.e(TAG, "Error adding bubble metadata: Invalid argument", error)
+ is IllegalStateException -> Log.e(TAG, "Error adding bubble metadata: Invalid state", error)
+ else -> Log.e(TAG, "Error adding bubble metadata: $error")
+ }
+ }.getOrDefault(this)
+
+ 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.getUrlForAvatar(baseUrl, notificationUser.id, true, true)
+ else -> ApiUtils.getUrlForAvatar(baseUrl, notificationUser.id, true)
+ }
+
+ return userAvatarUrl?.let {
+ NotificationUtils.loadAvatarSyncForBubble(it, ctx, credentials)
}
- notificationBuilder.setStyle(getStyle(person.build(), style))
}
private fun buildIntentForAction(cls: Class<*>, systemNotificationId: Int, messageId: Int): PendingIntent {
@@ -765,20 +1009,23 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor
return PendingIntent.getBroadcast(context, systemNotificationId, actualIntent, intentFlag)
}
- private fun addMarkAsReadAction(notificationBuilder: NotificationCompat.Builder, systemNotificationId: Int) {
- if (pushMessage.objectId != null) {
- val messageId: Int = try {
- parseMessageId(pushMessage.objectId!!)
- } catch (nfe: NumberFormatException) {
- Log.e(TAG, "Failed to parse messageId from objectId, skip adding mark-as-read action.", nfe)
- return
+ /**
+ * This only adds an action if the push message objectId is not null
+ */
+ private fun NotificationCompat.Builder.addMarkAsReadAction(systemNotificationId: Int): NotificationCompat.Builder =
+ runCatching {
+ if (pushMessage.objectId == null) {
+ return@runCatching this
}
+ val messageId: Int = parseMessageId(pushMessage.objectId!!)
+
val pendingIntent = buildIntentForAction(
MarkAsReadReceiver::class.java,
systemNotificationId,
messageId
)
+
val markAsReadAction = NotificationCompat.Action.Builder(
R.drawable.ic_mark_chat_read_24px,
context!!.resources.getString(R.string.nc_mark_as_read),
@@ -787,11 +1034,18 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor
.setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_MARK_AS_READ)
.setShowsUserInterface(false)
.build()
- notificationBuilder.addAction(markAsReadAction)
- }
- }
- private fun addReplyAction(notificationBuilder: NotificationCompat.Builder, systemNotificationId: Int) {
+ this.addAction(markAsReadAction)
+ }.onFailure { error ->
+ when (error) {
+ is NumberFormatException -> {
+ Log.e(TAG, "Failed to parse messageId from objectId, skip adding mark-as-read action.", error)
+ }
+ else -> Log.e(TAG, "Error adding mark as read action: $error")
+ }
+ }.getOrDefault(this)
+
+ private fun NotificationCompat.Builder.addReplyAction(systemNotificationId: Int): NotificationCompat.Builder {
val replyLabel = context!!.resources.getString(R.string.nc_reply)
val remoteInput = RemoteInput.Builder(NotificationUtils.KEY_DIRECT_REPLY)
.setLabel(replyLabel)
@@ -808,7 +1062,8 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor
.setAllowGeneratedReplies(true)
.addRemoteInput(remoteInput)
.build()
- notificationBuilder.addAction(replyAction)
+
+ return this.addAction(replyAction)
}
private fun addDismissRecordingAvailableAction(
@@ -1107,5 +1362,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/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)
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 43989283290..b506692989e 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() {
@@ -248,8 +261,9 @@ class SettingsActivity :
themeTitles()
themeSwitchPreferences()
- if (openedByNotificationWarning) {
- scrollToNotificationCategory()
+ when {
+ focusBubbleSettings -> scrollToBubbleSettings()
+ openedByNotificationWarning -> scrollToNotificationCategory()
}
}
@@ -263,13 +277,25 @@ class SettingsActivity :
@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) }
+ }
}
}
@@ -320,6 +346,7 @@ class SettingsActivity :
setupNotificationSoundsSettings()
setupNotificationPermissionSettings()
setupServerNotificationAppCheck()
+ setupBubbleSettings()
}
@SuppressLint("StringFormatInvalid")
@@ -413,6 +440,192 @@ 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(NOTIFICATION_BUBBLE_SETTINGS)
+ .withNotificationExtras(targetPackage, targetUid)
+
+ val explicitBubbleComponents = listOf(APP_NOTIFICATION, BUBBLE_NOTIFICATION)
+
+ explicitBubbleComponents.forEach { componentName ->
+ candidateIntents += Intent(Intent.ACTION_MAIN)
+ .withNotificationExtras(targetPackage, targetUid)
+ .setClassName(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 =
+ runCatching {
+ if (intent.resolveActivity(packageManager) == null) {
+ throw NullPointerException("Intent to resolveActivity was Null!!!") // should not happen
+ }
+
+ startActivity(intent)
+ }.onFailure { error ->
+ when (error) {
+ is ActivityNotFoundException -> Log.e(TAG, "LaunchIntentSafely failed, is this activity real?: $error")
+ else -> Log.e(TAG, "LaunchIntentSafely failed: $error")
+ }
+ }.isSuccess
+
private fun setupNotificationSoundsSettings() {
if (NotificationUtils.isCallsNotificationChannelEnabled(this)) {
val callRingtoneUri = getCallRingtoneUri(context, (appPreferences))
@@ -788,7 +1001,9 @@ class SettingsActivity :
settingsPhoneBookIntegrationSwitch,
settingsReadPrivacySwitch,
settingsTypingStatusSwitch,
- settingsProxyUseCredentialsSwitch
+ settingsProxyUseCredentialsSwitch,
+ settingsBubblesSwitch,
+ settingsBubblesForceSwitch
).forEach(viewThemeUtils.talk::colorSwitch)
}
}
@@ -1098,7 +1313,7 @@ class SettingsActivity :
}
private fun setupScreenLockSetting() {
- val keyguardManager = context.getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager
+ val keyguardManager = context.getSystemService(KEYGUARD_SERVICE) as KeyguardManager
if (keyguardManager.isKeyguardSecure) {
binding.settingsScreenLock.isEnabled = true
binding.settingsScreenLockTimeout.isEnabled = true
@@ -1496,8 +1711,13 @@ 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
+ private const val APP_NOTIFICATION = $$"com.android.settings.Settings$AppBubbleNotificationSettingsActivity"
+ private const val BUBBLE_NOTIFICATION = $$"com.android.settings.Settings$BubbleNotificationSettingsActivity"
+ private const val NOTIFICATION_BUBBLE_SETTINGS = "android.settings.APP_NOTIFICATION_BUBBLE_SETTINGS"
+ private const val ANDROID_SETTINGS = "com.android.settings"
}
}
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..05eea6f52a9 100644
--- a/app/src/main/java/com/nextcloud/talk/utils/NotificationUtils.kt
+++ b/app/src/main/java/com/nextcloud/talk/utils/NotificationUtils.kt
@@ -7,35 +7,78 @@
*/
package com.nextcloud.talk.utils
+import android.annotation.SuppressLint
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
+import android.app.PendingIntent
import android.content.Context
+import android.content.Intent
+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.createBitmap
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
+import coil.size.Precision
+import coil.size.Scale
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.bundle.BundleKeys
import com.nextcloud.talk.utils.preferences.AppPreferences
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.catch
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.flow.flowOn
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.onEach
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
+ 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
+ private val bubbleIconCache = ConcurrentHashMap()
enum class NotificationChannels {
NOTIFICATION_CHANNEL_MESSAGES_V4,
@@ -55,6 +98,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 +133,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 +288,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 +323,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 +443,330 @@ 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 = createBitmap(size, size)
+ 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
+ }
+
+ data class BubbleInfo(
+ val roomToken: String,
+ val conversationRemoteId: String,
+ val conversationName: String?,
+ val conversationUser: User,
+ val isOneToOneConversation: Boolean,
+ val credentials: String?
+ )
+
+ private data class BubbleNotificationData(
+ val conversationName: String,
+ val shortcutId: String,
+ val icon: IconCompat,
+ val person: androidx.core.app.Person
+ )
+
+ /**
+ * If there is an existing conversation bubble, it will be canceled and relaunched
+ */
+ fun createConversationBubble(
+ context: Context,
+ bubbleInfo: BubbleInfo,
+ appPreferences: AppPreferences,
+ scope: CoroutineScope
+ ) = loadBubbleAvatar(context, bubbleInfo)
+ .flowOn(Dispatchers.IO)
+ .map { icon ->
+ icon ?: IconCompat.createWithResource(context, R.drawable.ic_logo)
+ }
+ .onEach { icon ->
+ val shortcutId = "conversation_${bubbleInfo.roomToken}"
+ val bubbleConversationName = bubbleInfo.conversationName ?: context.getString(R.string.nc_app_name)
+
+ val notificationManager = context.getSystemService(
+ Context.NOTIFICATION_SERVICE
+ ) as NotificationManager
+
+ val existingNotification = findNotificationForRoom(
+ context,
+ bubbleInfo.conversationUser,
+ bubbleInfo.roomToken
+ )
+ val notificationId = existingNotification?.id
+ ?: calculateCRC32(bubbleInfo.roomToken).toInt()
+
+ notificationManager.cancel(notificationId)
+ ShortcutManagerCompat.removeDynamicShortcuts(
+ context,
+ listOf(shortcutId)
+ )
+
+ val person = createBubblePerson(bubbleConversationName, shortcutId, icon)
+
+ val bubbleNotificationData = BubbleNotificationData(bubbleConversationName, shortcutId, icon, person)
+
+ pushBubbleShortcut(context, bubbleInfo, bubbleNotificationData)
+
+ val notification = createBubbleNotification(
+ context,
+ bubbleInfo,
+ bubbleNotificationData
+ )
+
+ // 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()) {
+ registerNotificationChannels(
+ context,
+ appPreferences
+ )
+ }
+
+ // Use the same notification ID calculation as NotificationWorker
+ notificationManager.notify(notificationId, notification)
+ }
+ .catch { error ->
+ when (error) {
+ is SecurityException -> Log.e(TAG, "Error creating bubble: Permission denied", error)
+ is IllegalArgumentException -> Log.e(TAG, "Error creating bubble: Invalid argument", error)
+ is CancellationException -> throw error
+ else -> Log.e(TAG, "Error creating bubble: $error")
+ }
+
+ showErrorToast(context)
+ }
+ .launchIn(scope)
+
+ private fun loadBubbleAvatar(context: Context, bubbleInfo: BubbleInfo): Flow =
+ flow {
+ 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"
+ }
+
+ val icon = loadAvatarSyncForBubble(avatarUrl, context, bubbleInfo.credentials)
+ emit(icon)
+ }.catch { error ->
+ when (error) {
+ is IOException -> Log.e(TAG, "Error loading bubble avatar: IO error", error)
+ is IllegalArgumentException -> Log.e(TAG, "Error loading bubble avatar: Invalid argument", error)
+ is CancellationException -> throw error
+ else -> Log.e(TAG, "Error loading bubble avatar: $error")
+ }
+
+ emit(null)
+ }
+
+ private fun createBubblePerson(name: String, key: String, icon: IconCompat): androidx.core.app.Person =
+ androidx.core.app.Person.Builder()
+ .setName(name)
+ .setKey(key)
+ .setImportant(true)
+ .setIcon(icon)
+ .build()
+
+ private fun pushBubbleShortcut(context: Context, bubbleInfo: BubbleInfo, data: BubbleNotificationData) {
+ 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, data.shortcutId)
+ .setShortLabel(data.conversationName)
+ .setLongLabel(data.conversationName)
+ .setIcon(data.icon)
+ .setIntent(shortcutIntent)
+ .setLongLived(true)
+ .setPerson(data.person)
+ .setCategories(setOf(Notification.CATEGORY_MESSAGE))
+ .setLocusId(androidx.core.content.LocusIdCompat(data.shortcutId))
+ .build()
+
+ ShortcutManagerCompat.pushDynamicShortcut(context, shortcut)
+ }
+
+ @SuppressLint("WrongConstant")
+ private fun createBubbleNotification(
+ context: Context,
+ bubbleInfo: BubbleInfo,
+ data: BubbleNotificationData
+ ): Notification {
+ // Use the same request code calculation as NotificationWorker
+ val bubbleRequestCode = calculateCRC32("bubble_${bubbleInfo.roomToken}").toInt()
+
+ val bubbleIntent = PendingIntent.getActivity(
+ context,
+ bubbleRequestCode,
+ BubbleActivity.newIntent(context, bubbleInfo.roomToken, data.conversationName),
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
+ )
+
+ val contentIntent = 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
+ },
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+ )
+
+ val bubbleData = androidx.core.app.NotificationCompat.BubbleMetadata.Builder(
+ bubbleIntent,
+ data.icon
+ )
+ .setDesiredHeight(BUBBLE_DESIRED_HEIGHT_PX)
+ .setAutoExpandBubble(false)
+ .setSuppressNotification(true)
+ .build()
+
+ val messagingStyle = androidx.core.app.NotificationCompat.MessagingStyle(data.person)
+ .setConversationTitle(data.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(data.conversationName)
+ .setSmallIcon(R.drawable.ic_notification)
+ .setCategory(androidx.core.app.NotificationCompat.CATEGORY_MESSAGE)
+ .setShortcutId(data.shortcutId)
+ .setLocusId(androidx.core.content.LocusIdCompat(data.shortcutId))
+ .addPerson(data.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
+ */
+ 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 8b12a483f03..b3cd6036e0c 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,10 +80,12 @@ 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"
const val KEY_UPCOMING_EVENT: String = "KEY_UPCOMING_EVENT"
}
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 8dea169ad58..1b6a472bc02 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
@@ -98,6 +98,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 16de5234d5a..62e11dd60dc 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
@@ -253,6 +253,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() }
@@ -620,6 +644,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 06f2e326764..1fd5fff37ab 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 c1e855a307c..ba1277a8790 100644
--- a/app/src/main/res/layout/activity_settings.xml
+++ b/app/src/main/res/layout/activity_settings.xml
@@ -372,6 +372,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 869ba9ee80c..5d713cc365a 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -376,6 +376,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
@@ -451,6 +463,8 @@ How to translate with transifex:
Event conversation menu
Go to file
Conversation info
+ Create bubble
+ Open in app
Unread messages
%1$s sent a GIF.
%1$s sent an audio.