Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,13 @@
android:name=".chat.ChatActivity"
android:theme="@style/AppTheme" />

<activity
android:name=".chat.BubbleActivity"
android:theme="@style/AppTheme"
android:allowEmbedded="true"
android:resizeableActivity="true"
android:documentLaunchMode="always" />

<activity
android:name=".activities.CallActivity"
android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation"
Expand Down
72 changes: 72 additions & 0 deletions app/src/main/java/com/nextcloud/talk/chat/BubbleActivity.kt
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
Copy link
Collaborator

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

}
startActivity(intent)
moveTaskToBack(false)
}

override fun onBackPressed() {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

as onBackPressed is deprecated we want to avoid it and use onBackPressedDispatcher

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
}
}
}
}
195 changes: 192 additions & 3 deletions app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt
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]>
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -252,7 +255,7 @@

@Suppress("TooManyFunctions")
@AutoInjector(NextcloudTalkApplication::class)
class ChatActivity :
open class ChatActivity :
BaseActivity(),
MessagesListAdapter.OnLoadMoreListener,
MessagesListAdapter.Formatter<Date>,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)

Check failure

Code scanning / CodeQL

Use of implicit PendingIntents High

An implicit Intent is created
and sent to an unspecified third party through a PendingIntent.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please check the details at https://github.com/nextcloud/talk-android/security/code-scanning/92 which contains recommendations how to avoid this.
Using FLAG_IMMUTABLE was also done in other parts of the app.


} 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) {
Expand Down Expand Up @@ -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"
Expand Down
Loading
Loading