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 >,
@@ -2826,11 +2829,14 @@ class ChatActivity :
28262829 )
28272830 }
28282831
2829- private fun showConversationInfoScreen () {
2832+ private fun showConversationInfoScreen (focusBubbleSwitch : Boolean = false ) {
28302833 val bundle = Bundle ()
28312834
28322835 bundle.putString(KEY_ROOM_TOKEN , roomToken)
28332836 bundle.putBoolean(BundleKeys .KEY_ROOM_ONE_TO_ONE , isOneToOneConversation())
2837+ if (focusBubbleSwitch) {
2838+ bundle.putBoolean(BundleKeys .KEY_FOCUS_CONVERSATION_BUBBLE , true )
2839+ }
28342840
28352841 val upcomingEvent =
28362842 (chatViewModel.upcomingEventViewState.value as ? ChatViewModel .UpcomingEventUIState .Success )?.event
@@ -2843,15 +2849,22 @@ class ChatActivity :
28432849 startActivity(intent)
28442850 }
28452851
2852+ private fun openBubbleSettings () {
2853+ val intent = Intent (this , SettingsActivity ::class .java)
2854+ intent.putExtra(BundleKeys .KEY_FOCUS_BUBBLE_SETTINGS , true )
2855+ startActivity(intent)
2856+ }
2857+
28462858 private fun validSessionId (): Boolean =
28472859 currentConversation != null &&
28482860 sessionIdAfterRoomJoined?.isNotEmpty() == true &&
28492861 sessionIdAfterRoomJoined != " 0"
28502862
28512863 @Suppress(" Detekt.TooGenericExceptionCaught" )
2852- private fun cancelNotificationsForCurrentConversation () {
2864+ protected open fun cancelNotificationsForCurrentConversation () {
2865+ val isBubbleMode = Build .VERSION .SDK_INT >= Build .VERSION_CODES .S && isLaunchedFromBubble
28532866 if (conversationUser != null ) {
2854- if (! TextUtils .isEmpty(roomToken)) {
2867+ if (! TextUtils .isEmpty(roomToken) && ! isBubbleMode ) {
28552868 try {
28562869 NotificationUtils .cancelExistingNotificationsForRoom(
28572870 applicationContext,
@@ -3464,10 +3477,11 @@ class ChatActivity :
34643477 showThreadsItem.isVisible = ! isChatThread() &&
34653478 hasSpreedFeatureCapability(spreedCapabilities, SpreedFeatures .THREADS )
34663479
3467- if (CapabilitiesUtil .isAbleToCall(spreedCapabilities) &&
3468- ! isChatThread() &&
3469- ! ConversationUtils .isNoteToSelfConversation(currentConversation)
3470- ) {
3480+ val createBubbleItem = menu.findItem(R .id.create_conversation_bubble)
3481+ createBubbleItem.isVisible = Build .VERSION .SDK_INT >= Build .VERSION_CODES .R &&
3482+ ! isChatThread()
3483+
3484+ if (CapabilitiesUtil .isAbleToCall(spreedCapabilities) && ! isChatThread()) {
34713485 conversationVoiceCallMenuItem = menu.findItem(R .id.conversation_voice_call)
34723486 conversationVideoMenuItem = menu.findItem(R .id.conversation_video_call)
34733487
@@ -3499,6 +3513,8 @@ class ChatActivity :
34993513 menu.removeItem(R .id.conversation_voice_call)
35003514 }
35013515
3516+ menu.findItem(R .id.create_conversation_bubble)?.isVisible = NotificationUtils .deviceSupportsBubbles
3517+
35023518 handleThreadNotificationIcon(menu.findItem(R .id.thread_notifications))
35033519 }
35043520 return true
@@ -3509,8 +3525,8 @@ class ChatActivity :
35093525 hasSpreedFeatureCapability(spreedCapabilities, SpreedFeatures .THREADS )
35103526
35113527 val threadNotificationIcon = when (conversationThreadInfo?.attendee?.notificationLevel) {
3512- 1 -> R .drawable.outline_notifications_active_24
3513- 3 -> R .drawable.ic_baseline_notifications_off_24
3528+ NOTIFICATION_LEVEL_DEFAULT -> R .drawable.outline_notifications_active_24
3529+ NOTIFICATION_LEVEL_NEVER -> R .drawable.ic_baseline_notifications_off_24
35143530 else -> R .drawable.baseline_notifications_24
35153531 }
35163532 threadNotificationItem.icon = ContextCompat .getDrawable(context, threadNotificationIcon)
@@ -3569,6 +3585,11 @@ class ChatActivity :
35693585 true
35703586 }
35713587
3588+ R .id.create_conversation_bubble -> {
3589+ createConversationBubble()
3590+ true
3591+ }
3592+
35723593 else -> super .onOptionsItemSelected(item)
35733594 }
35743595
@@ -3649,6 +3670,73 @@ class ChatActivity :
36493670 )
36503671 }
36513672
3673+ @Suppress(" ReturnCount" )
3674+ private fun createConversationBubble () {
3675+ if (! NotificationUtils .deviceSupportsBubbles) {
3676+ Log .e(
3677+ TAG ,
3678+ " createConversationBubble was called but device doesn't support it. It should not be possible " +
3679+ " to get here via UI!"
3680+ )
3681+ return
3682+ }
3683+
3684+ if (! appPreferences.areBubblesEnabled() || ! NotificationUtils .areSystemBubblesEnabled(context)) {
3685+ // Do not replace with snackbar as it needs to survive screen change
3686+ Toast .makeText(
3687+ context,
3688+ getString(R .string.nc_conversation_notification_bubble_disabled),
3689+ Toast .LENGTH_SHORT
3690+ ).show()
3691+ openBubbleSettings()
3692+ return
3693+ }
3694+
3695+ if (! appPreferences.areBubblesForced() && ! isConversationBubbleEnabled()) {
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+ val conversationName = currentConversation?.displayName ? : getString(R .string.nc_app_name)
3707+ currentConversation?.let {
3708+ val bubbleInfo = NotificationUtils .BubbleInfo (
3709+ roomToken = roomToken,
3710+ conversationRemoteId = it.name,
3711+ conversationName = conversationName,
3712+ conversationUser = conversationUser,
3713+ isOneToOneConversation = isOneToOneConversation(),
3714+ credentials = credentials
3715+ )
3716+
3717+ NotificationUtils .createConversationBubble(
3718+ context = context,
3719+ bubbleInfo = bubbleInfo,
3720+ appPreferences = appPreferences,
3721+ lifecycleScope
3722+ )
3723+ }
3724+ }
3725+
3726+ private fun isConversationBubbleEnabled (): Boolean =
3727+ runCatching {
3728+ DatabaseStorageModule (conversationUser, roomToken).getBoolean(BUBBLE_SWITCH_KEY , false )
3729+ }.onFailure { e ->
3730+ when (e) {
3731+ is IOException -> Log .e(TAG , " Failed to read conversation bubble preference: IO error" , e)
3732+ is IllegalStateException -> Log .e(
3733+ TAG ,
3734+ " Failed to read conversation bubble preference: Invalid state" ,
3735+ e
3736+ )
3737+ }
3738+ }.isSuccess
3739+
36523740 @Suppress(" Detekt.LongMethod" )
36533741 private fun showThreadNotificationMenu () {
36543742 fun setThreadNotificationLevel (level : Int ) {
@@ -3703,7 +3791,7 @@ class ChatActivity :
37033791 subtitle = null ,
37043792 icon = R .drawable.ic_baseline_notifications_off_24,
37053793 onClick = {
3706- setThreadNotificationLevel(3 )
3794+ setThreadNotificationLevel(NOTIFICATION_LEVEL_NEVER )
37073795 }
37083796 )
37093797 )
@@ -4412,8 +4500,8 @@ class ChatActivity :
44124500 displayName = currentConversation?.displayName ? : " "
44134501 )
44144502 showSnackBar(roomToken)
4415- } catch (e: Exception ) {
4416- Log .w(TAG , " File corresponding to the uri does not exist $shareUri " , e)
4503+ } catch (e: IOException ) {
4504+ Log .w(TAG , " File corresponding to the uri does not exist: IO error $shareUri " , e)
44174505 downloadFileToCache(message, false ) {
44184506 uploadFile(
44194507 fileUri = shareUri.toString(),
@@ -4899,6 +4987,8 @@ class ChatActivity :
48994987 private const val HTTP_FORBIDDEN = 403
49004988 private const val HTTP_NOT_FOUND = 404
49014989 private const val MESSAGE_PULL_LIMIT = 100
4990+ private const val NOTIFICATION_LEVEL_DEFAULT = 1
4991+ private const val NOTIFICATION_LEVEL_NEVER = 3
49024992 private const val INVITE_LENGTH = 6
49034993 private const val ACTOR_LENGTH = 6
49044994 private const val CHUNK_SIZE : Int = 10
@@ -4914,6 +5004,7 @@ class ChatActivity :
49145004 private const val CURRENT_AUDIO_POSITION_KEY = " CURRENT_AUDIO_POSITION"
49155005 private const val CURRENT_AUDIO_WAS_PLAYING_KEY = " CURRENT_AUDIO_PLAYING"
49165006 private const val RESUME_AUDIO_TAG = " RESUME_AUDIO_TAG"
5007+ private const val BUBBLE_SWITCH_KEY = " bubble_switch"
49175008 private const val FIVE_MINUTES_IN_SECONDS : Long = 300
49185009 private const val ROOM_TYPE_ONE_TO_ONE = " 1"
49195010 private const val ACTOR_TYPE = " users"
0 commit comments