11/*
22 * Nextcloud Talk - Android Client
33 *
4+ * SPDX-FileCopyrightText: 2025 Alexandre Wery <nextcloud-talk-android@alwy.be>
45 * SPDX-FileCopyrightText: 2024 Christian Reiner <foss@christian-reiner.info>
56 * SPDX-FileCopyrightText: 2024 Parneet Singh <gurayaparneet@gmail.com>
67 * SPDX-FileCopyrightText: 2024 Giacomo Pacini <giacomo@paciosoft.com>
@@ -165,6 +166,7 @@ import com.nextcloud.talk.models.json.signaling.settings.SignalingSettingsOveral
165166import com.nextcloud.talk.models.json.threads.ThreadInfo
166167import com.nextcloud.talk.polls.ui.PollCreateDialogFragment
167168import com.nextcloud.talk.remotefilebrowser.activities.RemoteFileBrowserActivity
169+ import com.nextcloud.talk.settings.SettingsActivity
168170import com.nextcloud.talk.shareditems.activities.SharedItemsActivity
169171import com.nextcloud.talk.signaling.SignalingMessageReceiver
170172import com.nextcloud.talk.signaling.SignalingMessageSender
@@ -217,6 +219,7 @@ import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_START_CALL_AFTER_ROOM_SWIT
217219import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_SWITCH_TO_ROOM
218220import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_THREAD_ID
219221import com.nextcloud.talk.utils.permissions.PlatformPermissionUtil
222+ import com.nextcloud.talk.utils.preferences.preferencestorage.DatabaseStorageModule
220223import com.nextcloud.talk.utils.rx.DisposableSet
221224import com.nextcloud.talk.utils.singletons.ApplicationWideCurrentRoomHolder
222225import com.nextcloud.talk.webrtc.WebSocketConnectionHelper
@@ -260,7 +263,7 @@ import kotlin.math.roundToInt
260263
261264@Suppress(" TooManyFunctions" , " LargeClass" , " LongMethod" )
262265@AutoInjector(NextcloudTalkApplication ::class )
263- class ChatActivity :
266+ open class ChatActivity :
264267 BaseActivity (),
265268 MessagesListAdapter .OnLoadMoreListener ,
266269 MessagesListAdapter .Formatter <Date >,
@@ -2827,11 +2830,14 @@ class ChatActivity :
28272830 )
28282831 }
28292832
2830- private fun showConversationInfoScreen () {
2833+ private fun showConversationInfoScreen (focusBubbleSwitch : Boolean = false ) {
28312834 val bundle = Bundle ()
28322835
28332836 bundle.putString(KEY_ROOM_TOKEN , roomToken)
28342837 bundle.putBoolean(BundleKeys .KEY_ROOM_ONE_TO_ONE , isOneToOneConversation())
2838+ if (focusBubbleSwitch) {
2839+ bundle.putBoolean(BundleKeys .KEY_FOCUS_CONVERSATION_BUBBLE , true )
2840+ }
28352841
28362842 val upcomingEvent =
28372843 (chatViewModel.upcomingEventViewState.value as ? ChatViewModel .UpcomingEventUIState .Success )?.event
@@ -2844,15 +2850,22 @@ class ChatActivity :
28442850 startActivity(intent)
28452851 }
28462852
2853+ private fun openBubbleSettings () {
2854+ val intent = Intent (this , SettingsActivity ::class .java)
2855+ intent.putExtra(BundleKeys .KEY_FOCUS_BUBBLE_SETTINGS , true )
2856+ startActivity(intent)
2857+ }
2858+
28472859 private fun validSessionId (): Boolean =
28482860 currentConversation != null &&
28492861 sessionIdAfterRoomJoined?.isNotEmpty() == true &&
28502862 sessionIdAfterRoomJoined != " 0"
28512863
28522864 @Suppress(" Detekt.TooGenericExceptionCaught" )
2853- private fun cancelNotificationsForCurrentConversation () {
2865+ protected open fun cancelNotificationsForCurrentConversation () {
2866+ val isBubbleMode = Build .VERSION .SDK_INT >= Build .VERSION_CODES .S && isLaunchedFromBubble
28542867 if (conversationUser != null ) {
2855- if (! TextUtils .isEmpty(roomToken)) {
2868+ if (! TextUtils .isEmpty(roomToken) && ! isBubbleMode ) {
28562869 try {
28572870 NotificationUtils .cancelExistingNotificationsForRoom(
28582871 applicationContext,
@@ -3465,10 +3478,11 @@ class ChatActivity :
34653478 showThreadsItem.isVisible = ! isChatThread() &&
34663479 hasSpreedFeatureCapability(spreedCapabilities, SpreedFeatures .THREADS )
34673480
3468- if (CapabilitiesUtil .isAbleToCall(spreedCapabilities) &&
3469- ! isChatThread() &&
3470- ! ConversationUtils .isNoteToSelfConversation(currentConversation)
3471- ) {
3481+ val createBubbleItem = menu.findItem(R .id.create_conversation_bubble)
3482+ createBubbleItem.isVisible = Build .VERSION .SDK_INT >= Build .VERSION_CODES .R &&
3483+ ! isChatThread()
3484+
3485+ if (CapabilitiesUtil .isAbleToCall(spreedCapabilities) && ! isChatThread()) {
34723486 conversationVoiceCallMenuItem = menu.findItem(R .id.conversation_voice_call)
34733487 conversationVideoMenuItem = menu.findItem(R .id.conversation_video_call)
34743488
@@ -3500,6 +3514,8 @@ class ChatActivity :
35003514 menu.removeItem(R .id.conversation_voice_call)
35013515 }
35023516
3517+ menu.findItem(R .id.create_conversation_bubble)?.isVisible = NotificationUtils .deviceSupportsBubbles
3518+
35033519 handleThreadNotificationIcon(menu.findItem(R .id.thread_notifications))
35043520 }
35053521 return true
@@ -3510,8 +3526,8 @@ class ChatActivity :
35103526 hasSpreedFeatureCapability(spreedCapabilities, SpreedFeatures .THREADS )
35113527
35123528 val threadNotificationIcon = when (conversationThreadInfo?.attendee?.notificationLevel) {
3513- 1 -> R .drawable.outline_notifications_active_24
3514- 3 -> R .drawable.ic_baseline_notifications_off_24
3529+ NOTIFICATION_LEVEL_DEFAULT -> R .drawable.outline_notifications_active_24
3530+ NOTIFICATION_LEVEL_NEVER -> R .drawable.ic_baseline_notifications_off_24
35153531 else -> R .drawable.baseline_notifications_24
35163532 }
35173533 threadNotificationItem.icon = ContextCompat .getDrawable(context, threadNotificationIcon)
@@ -3570,6 +3586,11 @@ class ChatActivity :
35703586 true
35713587 }
35723588
3589+ R .id.create_conversation_bubble -> {
3590+ createConversationBubble()
3591+ true
3592+ }
3593+
35733594 else -> super .onOptionsItemSelected(item)
35743595 }
35753596
@@ -3650,6 +3671,70 @@ class ChatActivity :
36503671 )
36513672 }
36523673
3674+ @Suppress(" ReturnCount" )
3675+ private fun createConversationBubble () {
3676+ if (! NotificationUtils .deviceSupportsBubbles) {
3677+ Log .e(TAG , " createConversationBubble was called but device doesnt support it. It should not be possible " +
3678+ " to get here via UI!" )
3679+ return
3680+ }
3681+
3682+ if (! appPreferences.areBubblesEnabled() || ! NotificationUtils .areSystemBubblesEnabled(context)) {
3683+ // Do not replace with snackbar as it needs to survive screen change
3684+ Toast .makeText(
3685+ context,
3686+ getString(R .string.nc_conversation_notification_bubble_disabled),
3687+ Toast .LENGTH_SHORT
3688+ ).show()
3689+ openBubbleSettings()
3690+ return
3691+ }
3692+
3693+ if (! appPreferences.areBubblesForced()) {
3694+ val conversationAllowsBubbles = isConversationBubbleEnabled()
3695+ if (! conversationAllowsBubbles) {
3696+ // Do not replace with snackbar as it needs to survive screen change
3697+ Toast .makeText(
3698+ context,
3699+ getString(R .string.nc_conversation_notification_bubble_enable_conversation),
3700+ Toast .LENGTH_SHORT
3701+ ).show()
3702+ showConversationInfoScreen(focusBubbleSwitch = true )
3703+ return
3704+ }
3705+ }
3706+
3707+ val conversationName = currentConversation?.displayName ? : getString(R .string.nc_app_name)
3708+ currentConversation?.let {
3709+ NotificationUtils .createConversationBubble(
3710+ context = context,
3711+ bubbleInfo = NotificationUtils .BubbleInfo (
3712+ roomToken = roomToken,
3713+ conversationRemoteId = it.name,
3714+ conversationName = conversationName,
3715+ conversationUser = conversationUser,
3716+ isOneToOneConversation = isOneToOneConversation(),
3717+ credentials = credentials
3718+ ),
3719+ appPreferences = appPreferences,
3720+ lifecycleScope
3721+ )
3722+ }
3723+ }
3724+
3725+ private fun isConversationBubbleEnabled (): Boolean {
3726+ val user = conversationUser ? : return false
3727+ return try {
3728+ DatabaseStorageModule (user, roomToken).getBoolean(BUBBLE_SWITCH_KEY , false )
3729+ } catch (e: IOException ) {
3730+ Log .e(TAG , " Failed to read conversation bubble preference: IO error" , e)
3731+ false
3732+ } catch (e: IllegalStateException ) {
3733+ Log .e(TAG , " Failed to read conversation bubble preference: Invalid state" , e)
3734+ false
3735+ }
3736+ }
3737+
36533738 @Suppress(" Detekt.LongMethod" )
36543739 private fun showThreadNotificationMenu () {
36553740 fun setThreadNotificationLevel (level : Int ) {
@@ -3704,7 +3789,7 @@ class ChatActivity :
37043789 subtitle = null ,
37053790 icon = R .drawable.ic_baseline_notifications_off_24,
37063791 onClick = {
3707- setThreadNotificationLevel(3 )
3792+ setThreadNotificationLevel(NOTIFICATION_LEVEL_NEVER )
37083793 }
37093794 )
37103795 )
@@ -4413,8 +4498,8 @@ class ChatActivity :
44134498 displayName = currentConversation?.displayName ? : " "
44144499 )
44154500 showSnackBar(roomToken)
4416- } catch (e: Exception ) {
4417- Log .w(TAG , " File corresponding to the uri does not exist $shareUri " , e)
4501+ } catch (e: IOException ) {
4502+ Log .w(TAG , " File corresponding to the uri does not exist: IO error $shareUri " , e)
44184503 downloadFileToCache(message, false ) {
44194504 uploadFile(
44204505 fileUri = shareUri.toString(),
@@ -4900,6 +4985,8 @@ class ChatActivity :
49004985 private const val HTTP_FORBIDDEN = 403
49014986 private const val HTTP_NOT_FOUND = 404
49024987 private const val MESSAGE_PULL_LIMIT = 100
4988+ private const val NOTIFICATION_LEVEL_DEFAULT = 1
4989+ private const val NOTIFICATION_LEVEL_NEVER = 3
49034990 private const val INVITE_LENGTH = 6
49044991 private const val ACTOR_LENGTH = 6
49054992 private const val CHUNK_SIZE : Int = 10
@@ -4915,6 +5002,7 @@ class ChatActivity :
49155002 private const val CURRENT_AUDIO_POSITION_KEY = " CURRENT_AUDIO_POSITION"
49165003 private const val CURRENT_AUDIO_WAS_PLAYING_KEY = " CURRENT_AUDIO_PLAYING"
49175004 private const val RESUME_AUDIO_TAG = " RESUME_AUDIO_TAG"
5005+ private const val BUBBLE_SWITCH_KEY = " bubble_switch"
49185006 private const val FIVE_MINUTES_IN_SECONDS : Long = 300
49195007 private const val ROOM_TYPE_ONE_TO_ONE = " 1"
49205008 private const val ACTOR_TYPE = " users"
0 commit comments