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>
@@ -145,6 +146,7 @@ import com.nextcloud.talk.models.json.threads.ThreadInfo
145146import com.nextcloud.talk.polls.ui.PollCreateDialogFragment
146147import com.nextcloud.talk.polls.ui.PollMainDialogFragment
147148import com.nextcloud.talk.remotefilebrowser.activities.RemoteFileBrowserActivity
149+ import com.nextcloud.talk.settings.SettingsActivity
148150import com.nextcloud.talk.shareditems.activities.SharedItemsActivity
149151import com.nextcloud.talk.signaling.SignalingMessageReceiver
150152import com.nextcloud.talk.signaling.SignalingMessageSender
@@ -197,6 +199,7 @@ import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_START_CALL_AFTER_ROOM_SWIT
197199import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_SWITCH_TO_ROOM
198200import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_THREAD_ID
199201import com.nextcloud.talk.utils.permissions.PlatformPermissionUtil
202+ import com.nextcloud.talk.utils.preferences.preferencestorage.DatabaseStorageModule
200203import com.nextcloud.talk.utils.rx.DisposableSet
201204import com.nextcloud.talk.utils.singletons.ApplicationWideCurrentRoomHolder
202205import com.nextcloud.talk.webrtc.WebSocketConnectionHelper
@@ -232,7 +235,7 @@ import kotlin.math.roundToInt
232235
233236@Suppress(" TooManyFunctions" , " LargeClass" , " LongMethod" )
234237@AutoInjector(NextcloudTalkApplication ::class )
235- class ChatActivity :
238+ open class ChatActivity :
236239 BaseActivity (),
237240 CallStartedMessageInterface {
238241
@@ -2356,11 +2359,14 @@ class ChatActivity :
23562359 )
23572360 }
23582361
2359- private fun showConversationInfoScreen () {
2362+ private fun showConversationInfoScreen (focusBubbleSwitch : Boolean = false ) {
23602363 val bundle = Bundle ()
23612364
23622365 bundle.putString(KEY_ROOM_TOKEN , roomToken)
23632366 bundle.putBoolean(BundleKeys .KEY_ROOM_ONE_TO_ONE , isOneToOneConversation())
2367+ if (focusBubbleSwitch) {
2368+ bundle.putBoolean(BundleKeys .KEY_FOCUS_CONVERSATION_BUBBLE , true )
2369+ }
23642370
23652371 val upcomingEvent =
23662372 (chatViewModel.upcomingEventViewState.value as ? ChatViewModel .UpcomingEventUIState .Success )?.event
@@ -2373,15 +2379,22 @@ class ChatActivity :
23732379 startActivity(intent)
23742380 }
23752381
2382+ private fun openBubbleSettings () {
2383+ val intent = Intent (this , SettingsActivity ::class .java)
2384+ intent.putExtra(BundleKeys .KEY_FOCUS_BUBBLE_SETTINGS , true )
2385+ startActivity(intent)
2386+ }
2387+
23762388 private fun validSessionId (): Boolean =
23772389 currentConversation != null &&
23782390 sessionIdAfterRoomJoined?.isNotEmpty() == true &&
23792391 sessionIdAfterRoomJoined != " 0"
23802392
23812393 @Suppress(" Detekt.TooGenericExceptionCaught" )
2382- private fun cancelNotificationsForCurrentConversation () {
2394+ protected open fun cancelNotificationsForCurrentConversation () {
2395+ val isBubbleMode = Build .VERSION .SDK_INT >= Build .VERSION_CODES .S && isLaunchedFromBubble
23832396 if (conversationUser != null ) {
2384- if (! TextUtils .isEmpty(roomToken)) {
2397+ if (! TextUtils .isEmpty(roomToken) && ! isBubbleMode ) {
23852398 try {
23862399 NotificationUtils .cancelExistingNotificationsForRoom(
23872400 applicationContext,
@@ -2717,10 +2730,11 @@ class ChatActivity :
27172730 showThreadsItem.isVisible = ! isChatThread() &&
27182731 hasSpreedFeatureCapability(spreedCapabilities, SpreedFeatures .THREADS )
27192732
2720- if (CapabilitiesUtil .isAbleToCall(spreedCapabilities) &&
2721- ! isChatThread() &&
2722- ! ConversationUtils .isNoteToSelfConversation(currentConversation)
2723- ) {
2733+ val createBubbleItem = menu.findItem(R .id.create_conversation_bubble)
2734+ createBubbleItem.isVisible = Build .VERSION .SDK_INT >= Build .VERSION_CODES .R &&
2735+ ! isChatThread()
2736+
2737+ if (CapabilitiesUtil .isAbleToCall(spreedCapabilities) && ! isChatThread()) {
27242738 conversationVoiceCallMenuItem = menu.findItem(R .id.conversation_voice_call)
27252739 conversationVideoMenuItem = menu.findItem(R .id.conversation_video_call)
27262740
@@ -2752,6 +2766,8 @@ class ChatActivity :
27522766 menu.removeItem(R .id.conversation_voice_call)
27532767 }
27542768
2769+ menu.findItem(R .id.create_conversation_bubble)?.isVisible = NotificationUtils .deviceSupportsBubbles
2770+
27552771 handleThreadNotificationIcon(menu.findItem(R .id.thread_notifications))
27562772 }
27572773 return true
@@ -2762,7 +2778,7 @@ class ChatActivity :
27622778 hasSpreedFeatureCapability(spreedCapabilities, SpreedFeatures .THREADS )
27632779
27642780 val threadNotificationIcon = when (conversationThreadInfo?.attendee?.notificationLevel) {
2765- NOTIFICATION_LEVEL_ALWAYS -> R .drawable.outline_notifications_active_24
2781+ NOTIFICATION_LEVEL_DEFAULT -> R .drawable.outline_notifications_active_24
27662782 NOTIFICATION_LEVEL_NEVER -> R .drawable.ic_baseline_notifications_off_24
27672783 else -> R .drawable.baseline_notifications_24
27682784 }
@@ -2822,6 +2838,11 @@ class ChatActivity :
28222838 true
28232839 }
28242840
2841+ R .id.create_conversation_bubble -> {
2842+ createConversationBubble()
2843+ true
2844+ }
2845+
28252846 else -> super .onOptionsItemSelected(item)
28262847 }
28272848
@@ -2902,6 +2923,73 @@ class ChatActivity :
29022923 )
29032924 }
29042925
2926+ @Suppress(" ReturnCount" )
2927+ private fun createConversationBubble () {
2928+ if (! NotificationUtils .deviceSupportsBubbles) {
2929+ Log .e(
2930+ TAG ,
2931+ " createConversationBubble was called but device doesn't support it. It should not be possible " +
2932+ " to get here via UI!"
2933+ )
2934+ return
2935+ }
2936+
2937+ if (! appPreferences.areBubblesEnabled() || ! NotificationUtils .areSystemBubblesEnabled(context)) {
2938+ // Do not replace with snackbar as it needs to survive screen change
2939+ Toast .makeText(
2940+ context,
2941+ getString(R .string.nc_conversation_notification_bubble_disabled),
2942+ Toast .LENGTH_SHORT
2943+ ).show()
2944+ openBubbleSettings()
2945+ return
2946+ }
2947+
2948+ if (! appPreferences.areBubblesForced() && ! isConversationBubbleEnabled()) {
2949+ // Do not replace with snackbar as it needs to survive screen change
2950+ Toast .makeText(
2951+ context,
2952+ getString(R .string.nc_conversation_notification_bubble_enable_conversation),
2953+ Toast .LENGTH_SHORT
2954+ ).show()
2955+ showConversationInfoScreen(focusBubbleSwitch = true )
2956+ return
2957+ }
2958+
2959+ val conversationName = currentConversation?.displayName ? : getString(R .string.nc_app_name)
2960+ currentConversation?.let {
2961+ val bubbleInfo = NotificationUtils .BubbleInfo (
2962+ roomToken = roomToken,
2963+ conversationRemoteId = it.name,
2964+ conversationName = conversationName,
2965+ conversationUser = conversationUser,
2966+ isOneToOneConversation = isOneToOneConversation(),
2967+ credentials = credentials
2968+ )
2969+
2970+ NotificationUtils .createConversationBubble(
2971+ context = context,
2972+ bubbleInfo = bubbleInfo,
2973+ appPreferences = appPreferences,
2974+ lifecycleScope
2975+ )
2976+ }
2977+ }
2978+
2979+ private fun isConversationBubbleEnabled (): Boolean =
2980+ runCatching {
2981+ DatabaseStorageModule (conversationUser, roomToken).getBoolean(BUBBLE_SWITCH_KEY , false )
2982+ }.onFailure { e ->
2983+ when (e) {
2984+ is IOException -> Log .e(TAG , " Failed to read conversation bubble preference: IO error" , e)
2985+ is IllegalStateException -> Log .e(
2986+ TAG ,
2987+ " Failed to read conversation bubble preference: Invalid state" ,
2988+ e
2989+ )
2990+ }
2991+ }.getOrDefault(false )
2992+
29052993 @Suppress(" Detekt.LongMethod" )
29062994 private fun showThreadNotificationMenu () {
29072995 fun setThreadNotificationLevel (level : Int ) {
@@ -3601,8 +3689,8 @@ class ChatActivity :
36013689 displayName = currentConversation?.displayName ? : " "
36023690 )
36033691 showSnackBar(roomToken)
3604- } catch (e: Exception ) {
3605- Log .w(TAG , " File corresponding to the uri does not exist $shareUri " , e)
3692+ } catch (e: IOException ) {
3693+ Log .w(TAG , " File corresponding to the uri does not exist: IO error $shareUri " , e)
36063694 downloadFileToCache(message, false ) {
36073695 uploadFile(
36083696 fileUri = shareUri.toString(),
@@ -3955,12 +4043,13 @@ class ChatActivity :
39554043 private const val HTTP_FORBIDDEN = 403
39564044 private const val HTTP_NOT_FOUND = 404
39574045 private const val MESSAGE_PULL_LIMIT = 100
4046+ private const val NOTIFICATION_LEVEL_DEFAULT = 1
4047+ private const val NOTIFICATION_LEVEL_NEVER = 3
4048+ private const val NOTIFICATION_LEVEL_ALWAYS = 1
4049+ private const val NOTIFICATION_LEVEL_MENTION_AND_CALLS = 2
39584050 private const val INVITE_LENGTH = 6
39594051 private const val ACTOR_LENGTH = 6
39604052 private const val CHUNK_SIZE : Int = 10
3961- private const val NOTIFICATION_LEVEL_ALWAYS = 1
3962- private const val NOTIFICATION_LEVEL_MENTION_AND_CALLS = 2
3963- private const val NOTIFICATION_LEVEL_NEVER = 3
39644053 private const val ONE_SECOND_IN_MILLIS = 1000
39654054 private const val WHITESPACE = " "
39664055 private const val COMMA = " , "
@@ -3973,6 +4062,7 @@ class ChatActivity :
39734062 private const val CURRENT_AUDIO_POSITION_KEY = " CURRENT_AUDIO_POSITION"
39744063 private const val CURRENT_AUDIO_WAS_PLAYING_KEY = " CURRENT_AUDIO_PLAYING"
39754064 private const val RESUME_AUDIO_TAG = " RESUME_AUDIO_TAG"
4065+ private const val BUBBLE_SWITCH_KEY = " bubble_switch"
39764066 private const val FIVE_MINUTES_IN_SECONDS : Long = 300
39774067 private const val ROOM_TYPE_ONE_TO_ONE = " 1"
39784068 private const val ACTOR_TYPE = " users"
0 commit comments