diff --git a/app/src/main/java/com/nextcloud/talk/controllers/ChatController.kt b/app/src/main/java/com/nextcloud/talk/controllers/ChatController.kt index 9b7b457ff..4bec27819 100644 --- a/app/src/main/java/com/nextcloud/talk/controllers/ChatController.kt +++ b/app/src/main/java/com/nextcloud/talk/controllers/ChatController.kt @@ -147,6 +147,7 @@ import com.nextcloud.talk.ui.dialog.ShowReactionsDialog import com.nextcloud.talk.ui.recyclerview.MessageSwipeActions import com.nextcloud.talk.ui.recyclerview.MessageSwipeCallback import com.nextcloud.talk.utils.ApiUtils +import com.nextcloud.talk.utils.AttendeePermissionsUtil import com.nextcloud.talk.utils.ConductorRemapping import com.nextcloud.talk.utils.ConductorRemapping.remapChatController import com.nextcloud.talk.utils.ContactUtils @@ -273,6 +274,8 @@ class ChatController(args: Bundle) : lateinit var mediaPlayerHandler: Handler var currentlyPlayedVoiceMessage: ChatMessage? = null + var hasChatPermission: Boolean = false + init { Log.d(TAG, "init ChatController: " + System.identityHashCode(this).toString()) @@ -306,6 +309,9 @@ class ChatController(args: Bundle) : } this.voiceOnly = args.getBoolean(BundleKeys.KEY_CALL_VOICE_ONLY, false) + + hasChatPermission = + AttendeePermissionsUtil(currentConversation!!.permissions).hasChatPermission(conversationUser) } private fun getRoomInfo() { @@ -337,11 +343,17 @@ class ChatController(args: Bundle) : " sessionId: " + currentConversation?.sessionId ) loadAvatarForStatusBar() - setTitle() + + hasChatPermission = + AttendeePermissionsUtil(currentConversation!!.permissions).hasChatPermission( + conversationUser + ) + try { setupMentionAutocomplete() - checkReadOnlyState() + checkShowCallButtons() + checkShowMessageInputView() checkLobbyState() if (!inConversation) { @@ -580,7 +592,7 @@ class ChatController(args: Bundle) : } } - if (context != null) { + if (context != null && hasChatPermission && !isReadOnlyConversation()) { val messageSwipeController = MessageSwipeCallback( activity!!, object : MessageSwipeActions { @@ -1158,13 +1170,24 @@ class ChatController(args: Bundle) : ) } - private fun checkReadOnlyState() { + private fun checkShowCallButtons() { if (isAlive()) { if (isReadOnlyConversation() || shouldShowLobby()) { disableCallButtons() - binding.messageInputView.visibility = View.GONE } else { enableCallButtons() + } + } + } + + private fun checkShowMessageInputView() { + if (isAlive()) { + if (isReadOnlyConversation() || + shouldShowLobby() || + !hasChatPermission + ) { + binding.messageInputView.visibility = View.GONE + } else { binding.messageInputView.visibility = View.VISIBLE } } @@ -1437,6 +1460,12 @@ class ChatController(args: Bundle) : private fun uploadFiles(files: MutableList, isVoiceMessage: Boolean) { var metaData = "" + + if (!hasChatPermission) { + Log.w(TAG, "uploading file(s) is forbidden because of missing attendee permissions") + return + } + if (isVoiceMessage) { metaData = VOICE_MESSAGE_META_DATA } @@ -2349,7 +2378,7 @@ class ChatController(args: Bundle) : super.onPrepareOptionsMenu(menu) conversationUser?.let { if (CapabilitiesUtil.hasSpreedFeatureCapability(it, "read-only-rooms")) { - checkReadOnlyState() + checkShowCallButtons() } } } @@ -2474,6 +2503,7 @@ class ChatController(args: Bundle) : currentConversation, chatMessage, conversationUser, + hasChatPermission, ncApi!! ).show() } @@ -2501,6 +2531,7 @@ class ChatController(args: Bundle) : conversationUser, currentConversation, isShowMessageDeletionButton(message), + hasChatPermission, ncApi!! ).show() } @@ -2512,50 +2543,59 @@ class ChatController(args: Bundle) : } fun deleteMessage(message: IMessage?) { - var apiVersion = 1 - // FIXME Fix API checking with guests? - if (conversationUser != null) { - apiVersion = ApiUtils.getChatApiVersion(conversationUser, intArrayOf(1)) - } - - ncApi?.deleteChatMessage( - credentials, - ApiUtils.getUrlForChatMessage( - apiVersion, - conversationUser?.baseUrl, - roomToken, - message?.id + if (!hasChatPermission) { + Log.w( + TAG, + "Deletion of message is skipped because of restrictions by permissions. " + + "This method should not have been called!" ) - )?.subscribeOn(Schedulers.io()) - ?.observeOn(AndroidSchedulers.mainThread()) - ?.subscribe(object : Observer { - override fun onSubscribe(d: Disposable) { - // unused atm - } + Toast.makeText(context, R.string.nc_common_error_sorry, Toast.LENGTH_LONG).show() + } else { + var apiVersion = 1 + // FIXME Fix API checking with guests? + if (conversationUser != null) { + apiVersion = ApiUtils.getChatApiVersion(conversationUser, intArrayOf(1)) + } - override fun onNext(t: ChatOverallSingleMessage) { - if (t.ocs.meta.statusCode == HttpURLConnection.HTTP_ACCEPTED) { - Toast.makeText( - context, R.string.nc_delete_message_leaked_to_matterbridge, - Toast.LENGTH_LONG - ).show() + ncApi?.deleteChatMessage( + credentials, + ApiUtils.getUrlForChatMessage( + apiVersion, + conversationUser?.baseUrl, + roomToken, + message?.id + ) + )?.subscribeOn(Schedulers.io()) + ?.observeOn(AndroidSchedulers.mainThread()) + ?.subscribe(object : Observer { + override fun onSubscribe(d: Disposable) { + // unused atm } - } - override fun onError(e: Throwable) { - Log.e( - TAG, - "Something went wrong when trying to delete message with id " + - message?.id, - e - ) - Toast.makeText(context, R.string.nc_common_error_sorry, Toast.LENGTH_LONG).show() - } + override fun onNext(t: ChatOverallSingleMessage) { + if (t.ocs.meta.statusCode == HttpURLConnection.HTTP_ACCEPTED) { + Toast.makeText( + context, R.string.nc_delete_message_leaked_to_matterbridge, + Toast.LENGTH_LONG + ).show() + } + } - override fun onComplete() { - // unused atm - } - }) + override fun onError(e: Throwable) { + Log.e( + TAG, + "Something went wrong when trying to delete message with id " + + message?.id, + e + ) + Toast.makeText(context, R.string.nc_common_error_sorry, Toast.LENGTH_LONG).show() + } + + override fun onComplete() { + // unused atm + } + }) + } } fun replyPrivately(message: IMessage?) { @@ -2809,29 +2849,27 @@ class ChatController(args: Bundle) : private fun isShowMessageDeletionButton(message: ChatMessage): Boolean { if (conversationUser == null) return false - if (message.systemMessageType != ChatMessage.SystemMessageType.DUMMY) return false - - if (message.isDeleted) return false - - if (message.hasFileAttachment()) return false - - if (OBJECT_MESSAGE.equals(message.message)) return false - - val isOlderThanSixHours = message - .createdAt - ?.before(Date(System.currentTimeMillis() - AGE_THREHOLD_FOR_DELETE_MESSAGE)) == true - if (isOlderThanSixHours) return false - val isUserAllowedByPrivileges = if (message.actorId == conversationUser.userId) { true } else { currentConversation!!.canModerate(conversationUser) } - if (!isUserAllowedByPrivileges) return false - if (!CapabilitiesUtil.hasSpreedFeatureCapability(conversationUser, "delete-messages")) return false + val isOlderThanSixHours = message + .createdAt + ?.before(Date(System.currentTimeMillis() - AGE_THREHOLD_FOR_DELETE_MESSAGE)) == true - return true + return when { + !isUserAllowedByPrivileges -> false + isOlderThanSixHours -> false + message.systemMessageType != ChatMessage.SystemMessageType.DUMMY -> false + message.isDeleted -> false + message.hasFileAttachment() -> false + OBJECT_MESSAGE == message.message -> false + !CapabilitiesUtil.hasSpreedFeatureCapability(conversationUser, "delete-messages") -> false + !hasChatPermission -> false + else -> true + } } override fun hasContentFor(message: ChatMessage, type: Byte): Boolean { diff --git a/app/src/main/java/com/nextcloud/talk/controllers/ConversationsListController.java b/app/src/main/java/com/nextcloud/talk/controllers/ConversationsListController.java index f2732e726..4d0f893a5 100644 --- a/app/src/main/java/com/nextcloud/talk/controllers/ConversationsListController.java +++ b/app/src/main/java/com/nextcloud/talk/controllers/ConversationsListController.java @@ -86,6 +86,7 @@ import com.nextcloud.talk.models.json.statuses.StatusesOverall; import com.nextcloud.talk.ui.dialog.ChooseAccountDialogFragment; import com.nextcloud.talk.ui.dialog.ConversationsListBottomDialog; import com.nextcloud.talk.utils.ApiUtils; +import com.nextcloud.talk.utils.AttendeePermissionsUtil; import com.nextcloud.talk.utils.ClosedInterfaceImpl; import com.nextcloud.talk.utils.ConductorRemapping; import com.nextcloud.talk.utils.DisplayUtils; @@ -359,7 +360,6 @@ public class ConversationsListController extends BaseController implements Searc showShareToScreen = !showShareToScreen && hasActivityActionSendIntent(); - if (showShareToScreen) { hideSearchBar(); getActionBar().setTitle(R.string.send_to_three_dots); @@ -867,13 +867,25 @@ public class ConversationsListController extends BaseController implements Searc public boolean onItemClick(View view, int position) { try { selectedConversation = ((ConversationItem) Objects.requireNonNull(adapter.getItem(position))).getModel(); + if (selectedConversation != null && getActivity() != null) { + boolean hasChatPermission = + new AttendeePermissionsUtil(selectedConversation.permissions).hasChatPermission(currentUser); + if (showShareToScreen) { - handleSharedData(); - showShareToScreen = false; + if (hasChatPermission && !isReadOnlyConversation(selectedConversation)) { + handleSharedData(); + showShareToScreen = false; + } else { + Toast.makeText(context, R.string.send_to_forbidden, Toast.LENGTH_LONG).show(); + } } else if (forwardMessage) { - openConversation(bundle.getString(BundleKeys.INSTANCE.getKEY_FORWARD_MSG_TEXT())); - forwardMessage = false; + if (hasChatPermission && !isReadOnlyConversation(selectedConversation)) { + openConversation(bundle.getString(BundleKeys.INSTANCE.getKEY_FORWARD_MSG_TEXT())); + forwardMessage = false; + } else { + Toast.makeText(context, R.string.send_to_forbidden, Toast.LENGTH_LONG).show(); + } } else { openConversation(); } @@ -885,6 +897,11 @@ public class ConversationsListController extends BaseController implements Searc return true; } + private Boolean isReadOnlyConversation(Conversation conversation) { + return conversation.conversationReadOnlyState == + Conversation.ConversationReadOnlyState.CONVERSATION_READ_ONLY; + } + private void handleSharedData() { collectDataFromIntent(); if (!textToPaste.isEmpty()) { @@ -930,7 +947,8 @@ public class ConversationsListController extends BaseController implements Searc .setNegativeButton(R.string.nc_no, new View.OnClickListener() { @Override public void onClick(View v) { - Log.d(TAG, "sharing files aborted"); + Log.d(TAG, "sharing files aborted, going back to share-to screen"); + showShareToScreen = true; } }) .show(); diff --git a/app/src/main/java/com/nextcloud/talk/models/json/conversations/Conversation.java b/app/src/main/java/com/nextcloud/talk/models/json/conversations/Conversation.java index 2888d105d..13bd0f41c 100644 --- a/app/src/main/java/com/nextcloud/talk/models/json/conversations/Conversation.java +++ b/app/src/main/java/com/nextcloud/talk/models/json/conversations/Conversation.java @@ -111,6 +111,9 @@ public class Conversation { @JsonField(name = "notificationCalls") public Integer notificationCalls; + @JsonField(name = "permissions") + public int permissions; + public boolean isPublic() { return (ConversationType.ROOM_PUBLIC_CALL.equals(type)); } @@ -281,6 +284,10 @@ public class Conversation { public Integer getNotificationCalls() { return notificationCalls; } + public int getPermissions() { + return permissions; + } + public void setRoomId(String roomId) { this.roomId = roomId; } @@ -398,6 +405,9 @@ public class Conversation { this.unreadMentionDirect = unreadMentionDirect; } + public void setPermissions(int permissions) { + this.permissions = permissions; + } @Override public boolean equals(Object o) { @@ -500,6 +510,9 @@ public class Conversation { if (!Objects.equals(notificationCalls, that.notificationCalls)) { return false; } + if (permissions != that.permissions) { + return false; + } return Objects.equals(canDeleteConversation, that.canDeleteConversation); } @@ -540,6 +553,7 @@ public class Conversation { result = 31 * result + (canLeaveConversation != null ? canLeaveConversation.hashCode() : 0); result = 31 * result + (canDeleteConversation != null ? canDeleteConversation.hashCode() : 0); result = 31 * result + (notificationCalls != null ? notificationCalls.hashCode() : 0); + result = 31 * result + permissions; return result; } @@ -577,6 +591,7 @@ public class Conversation { ", canLeaveConversation=" + canLeaveConversation + ", canDeleteConversation=" + canDeleteConversation + ", notificationCalls=" + notificationCalls + + ", permissions=" + permissions + '}'; } diff --git a/app/src/main/java/com/nextcloud/talk/ui/dialog/MessageActionsDialog.kt b/app/src/main/java/com/nextcloud/talk/ui/dialog/MessageActionsDialog.kt index da085c583..3090858ed 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/dialog/MessageActionsDialog.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/dialog/MessageActionsDialog.kt @@ -58,6 +58,7 @@ class MessageActionsDialog( private val user: UserEntity?, private val currentConversation: Conversation?, private val showMessageDeletionButton: Boolean, + private val hasChatPermission: Boolean, private val ncApi: NcApi ) : BottomSheetDialog(chatController.activity!!, R.style.BottomSheetDialogThemeNoFloating) { @@ -71,9 +72,9 @@ class MessageActionsDialog( setContentView(dialogMessageActionsBinding.root) window?.setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) - initEmojiBar() + initEmojiBar(hasChatPermission) initMenuItemCopy(!message.isDeleted) - initMenuReplyToMessage(message.replyable) + initMenuReplyToMessage(message.replyable && hasChatPermission) initMenuReplyPrivately( message.replyable && hasUserId(user) && @@ -160,8 +161,9 @@ class MessageActionsDialog( } } - private fun initEmojiBar() { - if (CapabilitiesUtil.hasSpreedFeatureCapability(user, "reactions") && + private fun initEmojiBar(hasChatPermission: Boolean) { + if (hasChatPermission && + CapabilitiesUtil.hasSpreedFeatureCapability(user, "reactions") && Conversation.ConversationReadOnlyState.CONVERSATION_READ_ONLY != currentConversation?.conversationReadOnlyState && isReactableMessageType(message) diff --git a/app/src/main/java/com/nextcloud/talk/ui/dialog/ShowReactionsDialog.kt b/app/src/main/java/com/nextcloud/talk/ui/dialog/ShowReactionsDialog.kt index a9711ec24..d28fb9220 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/dialog/ShowReactionsDialog.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/dialog/ShowReactionsDialog.kt @@ -63,6 +63,7 @@ class ShowReactionsDialog( private val currentConversation: Conversation?, private val chatMessage: ChatMessage, private val userEntity: UserEntity?, + private val hasChatPermission: Boolean, private val ncApi: NcApi ) : BottomSheetDialog(activity), ReactionItemClickListener { @@ -183,7 +184,7 @@ class ShowReactionsDialog( } override fun onClick(reactionItem: ReactionItem) { - if (reactionItem.reactionVoter.actorId?.equals(userEntity?.userId) == true) { + if (hasChatPermission && reactionItem.reactionVoter.actorId?.equals(userEntity?.userId) == true) { deleteReaction(chatMessage, reactionItem.reaction!!) dismiss() } diff --git a/app/src/main/java/com/nextcloud/talk/utils/AttendeePermissionsUtil.kt b/app/src/main/java/com/nextcloud/talk/utils/AttendeePermissionsUtil.kt new file mode 100644 index 000000000..e1e291b6f --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/utils/AttendeePermissionsUtil.kt @@ -0,0 +1,72 @@ +/* + * Nextcloud Talk application + * + * @author Marcel Hibbe + * Copyright (C) 2022 Marcel Hibbe + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.nextcloud.talk.utils + +import com.nextcloud.talk.models.database.CapabilitiesUtil +import com.nextcloud.talk.models.database.UserEntity + +/** + * see https://nextcloud-talk.readthedocs.io/en/latest/constants/#attendee-permissions + */ +class AttendeePermissionsUtil(flag: Int) { + var isDefault: Boolean = false + var isCustom: Boolean = false + var canStartCall: Boolean = false + var canJoinCall: Boolean = false + var canIgnoreLobby: Boolean = false + var canPublishAudio: Boolean = false + var canPublishVideo: Boolean = false + var canPublishScreen: Boolean = false + private var hasChatPermission: Boolean = false + + init { + isDefault = (flag and DEFAULT) == DEFAULT + isCustom = (flag and CUSTOM) == CUSTOM + canStartCall = (flag and START_CALL) == START_CALL + canJoinCall = (flag and JOIN_CALL) == JOIN_CALL + canIgnoreLobby = (flag and CAN_IGNORE_LOBBY) == CAN_IGNORE_LOBBY + canPublishAudio = (flag and PUBLISH_AUDIO) == PUBLISH_AUDIO + canPublishVideo = (flag and PUBLISH_VIDEO) == PUBLISH_VIDEO + canPublishScreen = (flag and PUBLISH_SCREEN) == PUBLISH_SCREEN + hasChatPermission = (flag and CHAT) == CHAT + } + + fun hasChatPermission(user: UserEntity): Boolean { + if (CapabilitiesUtil.hasSpreedFeatureCapability(user, "chat-permission")) { + return hasChatPermission + } + // if capability is not available then the spreed version doesn't support to restrict this + return true + } + + companion object { + val TAG = AttendeePermissionsUtil::class.simpleName + const val DEFAULT = 0 + const val CUSTOM = 1 + const val START_CALL = 2 + const val JOIN_CALL = 4 + const val CAN_IGNORE_LOBBY = 8 + const val PUBLISH_AUDIO = 16 + const val PUBLISH_VIDEO = 32 + const val PUBLISH_SCREEN = 64 + const val CHAT = 128 + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ba1df70e9..b94aa7bd1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -392,6 +392,8 @@ Send to … Sharing files from storage is not possible without permissions Open in Files app + You are not allowed to share content to this chat + Add to conversation diff --git a/app/src/test/java/com/nextcloud/talk/utils/AttendeePermissionsUtilTest.kt b/app/src/test/java/com/nextcloud/talk/utils/AttendeePermissionsUtilTest.kt new file mode 100644 index 000000000..6ce41bd3c --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/utils/AttendeePermissionsUtilTest.kt @@ -0,0 +1,47 @@ +/* + * Nextcloud Talk application + * + * @author Marcel Hibbe + * Copyright (C) 2022 Marcel Hibbe + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.nextcloud.talk.utils + +import junit.framework.TestCase +import org.junit.Test + +class AttendeePermissionsUtilTest : TestCase() { + + @Test + fun test_areFlagsSet() { + val attendeePermissionsUtil = + AttendeePermissionsUtil( + AttendeePermissionsUtil.PUBLISH_SCREEN or + AttendeePermissionsUtil.JOIN_CALL or + AttendeePermissionsUtil.DEFAULT + ) + + assert(attendeePermissionsUtil.canPublishScreen) + assert(attendeePermissionsUtil.canJoinCall) + assert(attendeePermissionsUtil.isDefault) + + assertFalse(attendeePermissionsUtil.isCustom) + assertFalse(attendeePermissionsUtil.canStartCall) + assertFalse(attendeePermissionsUtil.canIgnoreLobby) + assertFalse(attendeePermissionsUtil.canPublishAudio) + assertFalse(attendeePermissionsUtil.canPublishVideo) + } +}