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 >,
@@ -2814,11 +2817,14 @@ class ChatActivity :
28142817 )
28152818 }
28162819
2817- private fun showConversationInfoScreen () {
2820+ private fun showConversationInfoScreen (focusBubbleSwitch : Boolean = false ) {
28182821 val bundle = Bundle ()
28192822
28202823 bundle.putString(KEY_ROOM_TOKEN , roomToken)
28212824 bundle.putBoolean(BundleKeys .KEY_ROOM_ONE_TO_ONE , isOneToOneConversation())
2825+ if (focusBubbleSwitch) {
2826+ bundle.putBoolean(BundleKeys .KEY_FOCUS_CONVERSATION_BUBBLE , true )
2827+ }
28222828
28232829 val upcomingEvent =
28242830 (chatViewModel.upcomingEventViewState.value as ? ChatViewModel .UpcomingEventUIState .Success )?.event
@@ -2831,15 +2837,22 @@ class ChatActivity :
28312837 startActivity(intent)
28322838 }
28332839
2840+ private fun openBubbleSettings () {
2841+ val intent = Intent (this , SettingsActivity ::class .java)
2842+ intent.putExtra(BundleKeys .KEY_FOCUS_BUBBLE_SETTINGS , true )
2843+ startActivity(intent)
2844+ }
2845+
28342846 private fun validSessionId (): Boolean =
28352847 currentConversation != null &&
28362848 sessionIdAfterRoomJoined?.isNotEmpty() == true &&
28372849 sessionIdAfterRoomJoined != " 0"
28382850
28392851 @Suppress(" Detekt.TooGenericExceptionCaught" )
2840- private fun cancelNotificationsForCurrentConversation () {
2852+ protected open fun cancelNotificationsForCurrentConversation () {
2853+ val isBubbleMode = Build .VERSION .SDK_INT >= Build .VERSION_CODES .S && isLaunchedFromBubble
28412854 if (conversationUser != null ) {
2842- if (! TextUtils .isEmpty(roomToken)) {
2855+ if (! TextUtils .isEmpty(roomToken) && ! isBubbleMode ) {
28432856 try {
28442857 NotificationUtils .cancelExistingNotificationsForRoom(
28452858 applicationContext,
@@ -3452,10 +3465,11 @@ class ChatActivity :
34523465 showThreadsItem.isVisible = ! isChatThread() &&
34533466 hasSpreedFeatureCapability(spreedCapabilities, SpreedFeatures .THREADS )
34543467
3455- if (CapabilitiesUtil .isAbleToCall(spreedCapabilities) &&
3456- ! isChatThread() &&
3457- ! ConversationUtils .isNoteToSelfConversation(currentConversation)
3458- ) {
3468+ val createBubbleItem = menu.findItem(R .id.create_conversation_bubble)
3469+ createBubbleItem.isVisible = Build .VERSION .SDK_INT >= Build .VERSION_CODES .R &&
3470+ ! isChatThread()
3471+
3472+ if (CapabilitiesUtil .isAbleToCall(spreedCapabilities) && ! isChatThread()) {
34593473 conversationVoiceCallMenuItem = menu.findItem(R .id.conversation_voice_call)
34603474 conversationVideoMenuItem = menu.findItem(R .id.conversation_video_call)
34613475
@@ -3487,6 +3501,8 @@ class ChatActivity :
34873501 menu.removeItem(R .id.conversation_voice_call)
34883502 }
34893503
3504+ menu.findItem(R .id.create_conversation_bubble)?.isVisible = NotificationUtils .deviceSupportsBubbles
3505+
34903506 handleThreadNotificationIcon(menu.findItem(R .id.thread_notifications))
34913507 }
34923508 return true
@@ -3497,8 +3513,8 @@ class ChatActivity :
34973513 hasSpreedFeatureCapability(spreedCapabilities, SpreedFeatures .THREADS )
34983514
34993515 val threadNotificationIcon = when (conversationThreadInfo?.attendee?.notificationLevel) {
3500- 1 -> R .drawable.outline_notifications_active_24
3501- 3 -> R .drawable.ic_baseline_notifications_off_24
3516+ NOTIFICATION_LEVEL_DEFAULT -> R .drawable.outline_notifications_active_24
3517+ NOTIFICATION_LEVEL_NEVER -> R .drawable.ic_baseline_notifications_off_24
35023518 else -> R .drawable.baseline_notifications_24
35033519 }
35043520 threadNotificationItem.icon = ContextCompat .getDrawable(context, threadNotificationIcon)
@@ -3557,6 +3573,11 @@ class ChatActivity :
35573573 true
35583574 }
35593575
3576+ R .id.create_conversation_bubble -> {
3577+ createConversationBubble()
3578+ true
3579+ }
3580+
35603581 else -> super .onOptionsItemSelected(item)
35613582 }
35623583
@@ -3637,6 +3658,73 @@ class ChatActivity :
36373658 )
36383659 }
36393660
3661+ @Suppress(" ReturnCount" )
3662+ private fun createConversationBubble () {
3663+ if (! NotificationUtils .deviceSupportsBubbles) {
3664+ Log .e(
3665+ TAG ,
3666+ " createConversationBubble was called but device doesn't support it. It should not be possible " +
3667+ " to get here via UI!"
3668+ )
3669+ return
3670+ }
3671+
3672+ if (! appPreferences.areBubblesEnabled() || ! NotificationUtils .areSystemBubblesEnabled(context)) {
3673+ // Do not replace with snackbar as it needs to survive screen change
3674+ Toast .makeText(
3675+ context,
3676+ getString(R .string.nc_conversation_notification_bubble_disabled),
3677+ Toast .LENGTH_SHORT
3678+ ).show()
3679+ openBubbleSettings()
3680+ return
3681+ }
3682+
3683+ if (! appPreferences.areBubblesForced() && ! isConversationBubbleEnabled()) {
3684+ // Do not replace with snackbar as it needs to survive screen change
3685+ Toast .makeText(
3686+ context,
3687+ getString(R .string.nc_conversation_notification_bubble_enable_conversation),
3688+ Toast .LENGTH_SHORT
3689+ ).show()
3690+ showConversationInfoScreen(focusBubbleSwitch = true )
3691+ return
3692+ }
3693+
3694+ val conversationName = currentConversation?.displayName ? : getString(R .string.nc_app_name)
3695+ currentConversation?.let {
3696+ val bubbleInfo = NotificationUtils .BubbleInfo (
3697+ roomToken = roomToken,
3698+ conversationRemoteId = it.name,
3699+ conversationName = conversationName,
3700+ conversationUser = conversationUser,
3701+ isOneToOneConversation = isOneToOneConversation(),
3702+ credentials = credentials
3703+ )
3704+
3705+ NotificationUtils .createConversationBubble(
3706+ context = context,
3707+ bubbleInfo = bubbleInfo,
3708+ appPreferences = appPreferences,
3709+ lifecycleScope
3710+ )
3711+ }
3712+ }
3713+
3714+ private fun isConversationBubbleEnabled (): Boolean =
3715+ runCatching {
3716+ DatabaseStorageModule (conversationUser, roomToken).getBoolean(BUBBLE_SWITCH_KEY , false )
3717+ }.onFailure { e ->
3718+ when (e) {
3719+ is IOException -> Log .e(TAG , " Failed to read conversation bubble preference: IO error" , e)
3720+ is IllegalStateException -> Log .e(
3721+ TAG ,
3722+ " Failed to read conversation bubble preference: Invalid state" ,
3723+ e
3724+ )
3725+ }
3726+ }.getOrDefault(false )
3727+
36403728 @Suppress(" Detekt.LongMethod" )
36413729 private fun showThreadNotificationMenu () {
36423730 fun setThreadNotificationLevel (level : Int ) {
@@ -3691,7 +3779,7 @@ class ChatActivity :
36913779 subtitle = null ,
36923780 icon = R .drawable.ic_baseline_notifications_off_24,
36933781 onClick = {
3694- setThreadNotificationLevel(3 )
3782+ setThreadNotificationLevel(NOTIFICATION_LEVEL_NEVER )
36953783 }
36963784 )
36973785 )
@@ -4400,8 +4488,8 @@ class ChatActivity :
44004488 displayName = currentConversation?.displayName ? : " "
44014489 )
44024490 showSnackBar(roomToken)
4403- } catch (e: Exception ) {
4404- Log .w(TAG , " File corresponding to the uri does not exist $shareUri " , e)
4491+ } catch (e: IOException ) {
4492+ Log .w(TAG , " File corresponding to the uri does not exist: IO error $shareUri " , e)
44054493 downloadFileToCache(message, false ) {
44064494 uploadFile(
44074495 fileUri = shareUri.toString(),
@@ -4887,6 +4975,8 @@ class ChatActivity :
48874975 private const val HTTP_FORBIDDEN = 403
48884976 private const val HTTP_NOT_FOUND = 404
48894977 private const val MESSAGE_PULL_LIMIT = 100
4978+ private const val NOTIFICATION_LEVEL_DEFAULT = 1
4979+ private const val NOTIFICATION_LEVEL_NEVER = 3
48904980 private const val INVITE_LENGTH = 6
48914981 private const val ACTOR_LENGTH = 6
48924982 private const val CHUNK_SIZE : Int = 10
@@ -4902,6 +4992,7 @@ class ChatActivity :
49024992 private const val CURRENT_AUDIO_POSITION_KEY = " CURRENT_AUDIO_POSITION"
49034993 private const val CURRENT_AUDIO_WAS_PLAYING_KEY = " CURRENT_AUDIO_PLAYING"
49044994 private const val RESUME_AUDIO_TAG = " RESUME_AUDIO_TAG"
4995+ private const val BUBBLE_SWITCH_KEY = " bubble_switch"
49054996 private const val FIVE_MINUTES_IN_SECONDS : Long = 300
49064997 private const val ROOM_TYPE_ONE_TO_ONE = " 1"
49074998 private const val ACTOR_TYPE = " users"
0 commit comments