-
-
Notifications
You must be signed in to change notification settings - Fork 287
Adding bubbles support #5554
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Adding bubbles support #5554
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,72 @@ | ||
| /* | ||
| * Nextcloud Talk - Android Client | ||
| * | ||
| * SPDX-FileCopyrightText: 2025 Alexandre Wery <[email protected]> | ||
| * SPDX-License-Identifier: GPL-3.0-or-later | ||
| */ | ||
|
|
||
| package com.nextcloud.talk.chat | ||
|
|
||
| import android.content.Context | ||
| import android.content.Intent | ||
| import android.os.Bundle | ||
| import com.nextcloud.talk.R | ||
| import com.nextcloud.talk.utils.bundle.BundleKeys | ||
|
|
||
| class BubbleActivity : ChatActivity() { | ||
|
|
||
| override fun onCreate(savedInstanceState: Bundle?) { | ||
| super.onCreate(savedInstanceState) | ||
| supportActionBar?.setDisplayHomeAsUpEnabled(false) | ||
| supportActionBar?.setDisplayShowHomeEnabled(false) | ||
| } | ||
|
|
||
| override fun onPrepareOptionsMenu(menu: android.view.Menu): Boolean { | ||
| super.onPrepareOptionsMenu(menu) | ||
|
|
||
| menu.findItem(R.id.create_conversation_bubble)?.isVisible = false | ||
| menu.findItem(R.id.open_conversation_in_app)?.isVisible = true | ||
|
|
||
| return true | ||
| } | ||
|
|
||
| override fun onOptionsItemSelected(item: android.view.MenuItem): Boolean { | ||
| return when (item.itemId) { | ||
| R.id.open_conversation_in_app -> { | ||
| openInMainApp() | ||
| true | ||
| } | ||
| else -> super.onOptionsItemSelected(item) | ||
| } | ||
| } | ||
|
|
||
| private fun openInMainApp() { | ||
| val intent = Intent(this, ChatActivity::class.java).apply { | ||
| putExtras([email protected]) | ||
| flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP | ||
| } | ||
| startActivity(intent) | ||
| moveTaskToBack(false) | ||
| } | ||
|
|
||
| override fun onBackPressed() { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. as |
||
| moveTaskToBack(false) | ||
| } | ||
|
|
||
| @Deprecated("Deprecated in Java") | ||
| override fun onSupportNavigateUp(): Boolean { | ||
| moveTaskToBack(false) | ||
| return true | ||
| } | ||
|
|
||
| companion object { | ||
| fun newIntent(context: Context, roomToken: String, conversationName: String?): Intent { | ||
| return Intent(context, BubbleActivity::class.java).apply { | ||
| putExtra(BundleKeys.KEY_ROOM_TOKEN, roomToken) | ||
| conversationName?.let { putExtra(BundleKeys.KEY_CONVERSATION_NAME, it) } | ||
| action = Intent.ACTION_VIEW | ||
| flags = Intent.FLAG_ACTIVITY_NEW_DOCUMENT or Intent.FLAG_ACTIVITY_MULTIPLE_TASK | ||
| } | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,6 +1,7 @@ | ||
| /* | ||
| * Nextcloud Talk - Android Client | ||
| * | ||
| * SPDX-FileCopyrightText: 2025 Alexandre Wery <[email protected]> | ||
| * SPDX-FileCopyrightText: 2024 Christian Reiner <[email protected]> | ||
| * SPDX-FileCopyrightText: 2024 Parneet Singh <[email protected]> | ||
| * SPDX-FileCopyrightText: 2024 Giacomo Pacini <[email protected]> | ||
|
|
@@ -163,6 +164,7 @@ | |
| 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.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 @@ | |
|
|
||
| @Suppress("TooManyFunctions") | ||
| @AutoInjector(NextcloudTalkApplication::class) | ||
| class ChatActivity : | ||
| open class ChatActivity : | ||
| BaseActivity(), | ||
| MessagesListAdapter.OnLoadMoreListener, | ||
| MessagesListAdapter.Formatter<Date>, | ||
|
|
@@ -2664,24 +2667,33 @@ | |
| ) | ||
| } | ||
|
|
||
| private fun showConversationInfoScreen() { | ||
| private fun showConversationInfoScreen(focusBubbleSwitch: Boolean = false) { | ||
| val bundle = Bundle() | ||
|
|
||
| bundle.putString(KEY_ROOM_TOKEN, roomToken) | ||
| bundle.putBoolean(BundleKeys.KEY_ROOM_ONE_TO_ONE, isOneToOneConversation()) | ||
| if (focusBubbleSwitch) { | ||
| bundle.putBoolean(BundleKeys.KEY_FOCUS_CONVERSATION_BUBBLE, true) | ||
| } | ||
|
|
||
| val intent = Intent(this, ConversationInfoActivity::class.java) | ||
| intent.putExtras(bundle) | ||
| startActivity(intent) | ||
| } | ||
|
|
||
| private fun openBubbleSettings() { | ||
| val intent = Intent(this, SettingsActivity::class.java) | ||
| intent.putExtra(BundleKeys.KEY_FOCUS_BUBBLE_SETTINGS, true) | ||
| startActivity(intent) | ||
| } | ||
|
|
||
| private fun validSessionId(): Boolean = | ||
| currentConversation != null && | ||
| sessionIdAfterRoomJoined?.isNotEmpty() == true && | ||
| sessionIdAfterRoomJoined != "0" | ||
|
|
||
| @Suppress("Detekt.TooGenericExceptionCaught") | ||
| private fun cancelNotificationsForCurrentConversation() { | ||
| protected open fun cancelNotificationsForCurrentConversation() { | ||
| if (conversationUser != null) { | ||
| if (!TextUtils.isEmpty(roomToken)) { | ||
| try { | ||
|
|
@@ -3270,6 +3282,10 @@ | |
| 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) | ||
|
|
@@ -3362,9 +3378,181 @@ | |
| true | ||
| } | ||
|
|
||
| R.id.create_conversation_bubble -> { | ||
| createConversationBubble() | ||
| true | ||
| } | ||
|
|
||
| else -> super.onOptionsItemSelected(item) | ||
| } | ||
|
|
||
| private fun createConversationBubble() { | ||
| lifecycleScope.launch { | ||
| if (!appPreferences.areBubblesEnabled()) { | ||
| Toast.makeText( | ||
| this@ChatActivity, | ||
| getString(R.string.nc_conversation_notification_bubble_disabled), | ||
| Toast.LENGTH_SHORT | ||
| ).show() | ||
| openBubbleSettings() | ||
| return@launch | ||
| } | ||
|
|
||
| if (!appPreferences.areBubblesForced()) { | ||
| val conversationAllowsBubbles = isConversationBubbleEnabled() | ||
| if (!conversationAllowsBubbles) { | ||
| Toast.makeText( | ||
| this@ChatActivity, | ||
| getString(R.string.nc_conversation_notification_bubble_enable_conversation), | ||
| Toast.LENGTH_SHORT | ||
| ).show() | ||
| showConversationInfoScreen(focusBubbleSwitch = true) | ||
| return@launch | ||
| } | ||
| } | ||
|
|
||
| try { | ||
| val shortcutId = "conversation_$roomToken" | ||
| val conversationName = currentConversation?.displayName ?: getString(R.string.nc_app_name) | ||
|
|
||
| val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as android.app.NotificationManager | ||
| val notificationId = NotificationUtils.calculateCRC32(roomToken).toInt() | ||
|
|
||
| notificationManager.cancel(notificationId) | ||
| androidx.core.content.pm.ShortcutManagerCompat.removeDynamicShortcuts(this@ChatActivity, listOf(shortcutId)) | ||
|
|
||
| // Load conversation avatar on background thread | ||
| val avatarIcon = withContext(Dispatchers.IO) { | ||
| try { | ||
| var avatarUrl = if (isOneToOneConversation()) { | ||
| ApiUtils.getUrlForAvatar( | ||
| conversationUser!!.baseUrl!!, | ||
| currentConversation!!.name, | ||
| true | ||
| ) | ||
| } else { | ||
| ApiUtils.getUrlForConversationAvatar( | ||
| ApiUtils.API_V1, | ||
| conversationUser!!.baseUrl!!, | ||
| roomToken | ||
| ) | ||
| } | ||
|
|
||
| if (DisplayUtils.isDarkModeOn(this@ChatActivity)) { | ||
| avatarUrl = "$avatarUrl/dark" | ||
| } | ||
|
|
||
| NotificationUtils.loadAvatarSyncForBubble(avatarUrl, this@ChatActivity, credentials) | ||
| } catch (e: Exception) { | ||
| Log.e(TAG, "Error loading bubble avatar", e) | ||
| null | ||
| } | ||
| } | ||
|
|
||
| val icon = avatarIcon ?: androidx.core.graphics.drawable.IconCompat.createWithResource( | ||
| this@ChatActivity, | ||
| R.drawable.ic_logo | ||
| ) | ||
|
|
||
| val person = androidx.core.app.Person.Builder() | ||
| .setName(conversationName) | ||
| .setKey(shortcutId) | ||
| .setImportant(true) | ||
| .setIcon(icon) | ||
| .build() | ||
|
|
||
| // Use the same request code calculation as NotificationWorker | ||
| val bubbleRequestCode = NotificationUtils.calculateCRC32("bubble_$roomToken").toInt() | ||
|
|
||
| val bubbleIntent = android.app.PendingIntent.getActivity( | ||
| this@ChatActivity, | ||
| bubbleRequestCode, | ||
| BubbleActivity.newIntent(this@ChatActivity, roomToken, conversationName), | ||
| android.app.PendingIntent.FLAG_UPDATE_CURRENT or android.app.PendingIntent.FLAG_MUTABLE | ||
| ) | ||
|
|
||
| val shortcut = androidx.core.content.pm.ShortcutInfoCompat.Builder(this@ChatActivity, shortcutId) | ||
| .setShortLabel(conversationName) | ||
| .setLongLabel(conversationName) | ||
| .setIcon(icon) | ||
| .setIntent(Intent(Intent.ACTION_DEFAULT)) | ||
| .setLongLived(true) | ||
| .setPerson(person) | ||
| .setCategories(setOf(android.app.Notification.CATEGORY_MESSAGE)) | ||
| .setLocusId(androidx.core.content.LocusIdCompat(shortcutId)) | ||
| .build() | ||
|
|
||
| androidx.core.content.pm.ShortcutManagerCompat.pushDynamicShortcut(this@ChatActivity, shortcut) | ||
|
|
||
| val bubbleData = androidx.core.app.NotificationCompat.BubbleMetadata.Builder( | ||
| bubbleIntent, | ||
| icon | ||
| ) | ||
| .setDesiredHeight(600) | ||
| .setAutoExpandBubble(false) | ||
| .setSuppressNotification(true) | ||
| .build() | ||
|
|
||
| val messagingStyle = androidx.core.app.NotificationCompat.MessagingStyle(person) | ||
| .setConversationTitle(conversationName) | ||
|
|
||
| // Create extras bundle to protect bubble from deletion | ||
| val notificationExtras = bundleOf( | ||
| BundleKeys.KEY_ROOM_TOKEN to roomToken, | ||
| BundleKeys.KEY_NOTIFICATION_RESTRICT_DELETION to true, | ||
| BundleKeys.KEY_INTERNAL_USER_ID to conversationUser!!.id!! | ||
| ) | ||
|
|
||
| val channelId = NotificationUtils.NotificationChannels.NOTIFICATION_CHANNEL_MESSAGES_V4.name | ||
| val notification = androidx.core.app.NotificationCompat.Builder(this@ChatActivity, channelId) | ||
| .setContentTitle(conversationName) | ||
| .setSmallIcon(R.drawable.ic_notification) | ||
| .setCategory(androidx.core.app.NotificationCompat.CATEGORY_MESSAGE) | ||
| .setShortcutId(shortcutId) | ||
| .setLocusId(androidx.core.content.LocusIdCompat(shortcutId)) | ||
| .addPerson(person) | ||
| .setStyle(messagingStyle) | ||
| .setBubbleMetadata(bubbleData) | ||
| .setContentIntent(bubbleIntent) | ||
| .setAutoCancel(true) | ||
| .setOngoing(false) | ||
| .setOnlyAlertOnce(true) | ||
| .setExtras(notificationExtras) | ||
| .build() | ||
|
|
||
| // Check if notification channel supports bubbles and recreate if needed | ||
| val channel = notificationManager.getNotificationChannel(channelId) | ||
|
|
||
| if (channel == null || !channel.canBubble()) { | ||
| NotificationUtils.registerNotificationChannels( | ||
| applicationContext, | ||
| appPreferences!! | ||
| ) | ||
| } | ||
|
|
||
| // Use the same notification ID calculation as NotificationWorker | ||
| // Show notification with bubble | ||
| notificationManager.notify(notificationId, notification) | ||
|
||
|
|
||
| } catch (e: Exception) { | ||
| Log.e(TAG, "Error creating bubble", e) | ||
| Toast.makeText(this@ChatActivity, R.string.nc_common_error_sorry, Toast.LENGTH_SHORT).show() | ||
| } | ||
| } | ||
| } | ||
|
|
||
| private suspend fun isConversationBubbleEnabled(): Boolean { | ||
| val user = conversationUser ?: return false | ||
| return withContext(Dispatchers.IO) { | ||
| try { | ||
| DatabaseStorageModule(user, roomToken).getBoolean(BUBBLE_SWITCH_KEY, false) | ||
| } catch (e: Exception) { | ||
| Log.e(TAG, "Failed to read conversation bubble preference", e) | ||
| false | ||
| } | ||
| } | ||
| } | ||
|
|
||
| @Suppress("Detekt.LongMethod") | ||
| private fun showThreadNotificationMenu() { | ||
| fun setThreadNotificationLevel(level: Int) { | ||
|
|
@@ -4592,6 +4780,7 @@ | |
| 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" | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
multiple instances of the app are opened when "open is app" is used.
https://developer.android.com/develop/ui/views/notifications/bubbles#launching-activities