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.