/* * Copyright (c) 2010-2019 Belledonne Communications SARL. * * This file is part of linphone-android * (see https://www.linphone.org). * * 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 <http://www.gnu.org/licenses/>. */ package org.linphone.chat; import static android.content.Context.INPUT_METHOD_SERVICE; import android.Manifest; import android.app.Activity; import android.app.Dialog; import android.app.Fragment; import android.content.ClipData; import android.content.ClipboardManager; import android.content.Context; import android.content.Intent; import android.graphics.Rect; import android.net.Uri; import android.os.Bundle; import android.os.Parcelable; import android.provider.MediaStore; import android.text.Editable; import android.text.TextWatcher; import android.view.ContextMenu; import android.view.LayoutInflater; import android.view.MenuInflater; import android.view.MenuItem; import android.view.View; import android.view.ViewGroup; import android.view.ViewTreeObserver; import android.view.WindowManager; import android.view.inputmethod.InputMethodManager; import android.widget.Button; import android.widget.CheckBox; import android.widget.ImageView; import android.widget.LinearLayout; import android.widget.TextView; import androidx.appcompat.view.menu.MenuBuilder; import androidx.appcompat.view.menu.MenuPopupHelper; import androidx.core.view.inputmethod.InputConnectionCompat; import androidx.core.view.inputmethod.InputContentInfoCompat; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; import com.bumptech.glide.Glide; import java.io.File; import java.util.ArrayList; import java.util.Arrays; import java.util.List; import org.linphone.LinphoneContext; import org.linphone.LinphoneManager; import org.linphone.R; import org.linphone.activities.MainActivity; import org.linphone.contacts.ContactAddress; import org.linphone.contacts.ContactsManager; import org.linphone.contacts.ContactsUpdatedListener; import org.linphone.contacts.LinphoneContact; import org.linphone.core.Address; import org.linphone.core.Call; import org.linphone.core.ChatMessage; import org.linphone.core.ChatRoom; import org.linphone.core.ChatRoomCapabilities; import org.linphone.core.ChatRoomListener; import org.linphone.core.ChatRoomSecurityLevel; import org.linphone.core.Content; import org.linphone.core.Core; import org.linphone.core.CoreListenerStub; import org.linphone.core.EventLog; import org.linphone.core.Factory; import org.linphone.core.Participant; import org.linphone.core.ParticipantDevice; import org.linphone.core.Reason; import org.linphone.core.tools.Log; import org.linphone.settings.LinphonePreferences; import org.linphone.utils.FileUtils; import org.linphone.utils.LinphoneUtils; import org.linphone.utils.SelectableHelper; import org.linphone.views.RichEditText; public class ChatMessagesFragment extends Fragment implements ChatRoomListener, ContactsUpdatedListener, ChatMessageViewHolderClickListener, SelectableHelper.DeleteListener, RichEditText.RichInputListener { private static final int ADD_PHOTO = 1337; private static final int MESSAGES_PER_PAGE = 20; private static final String INPUT_CONTENT_INFO_KEY = "COMMIT_CONTENT_INPUT_CONTENT_INFO"; private static final String COMMIT_CONTENT_FLAGS_KEY = "COMMIT_CONTENT_FLAGS"; private ImageView mCallButton; private ImageView mBackToCallButton; private ImageView mPopupMenu; private ImageView mAttachImageButton, mSendMessageButton, mSendEphemeralIcon; private TextView mRoomLabel, mParticipantsLabel, mSipUriLabel, mRemoteComposing; private RichEditText mMessageTextToSend; private LayoutInflater mInflater; private RecyclerView mChatEventsList; private LinearLayout mFilesUploadLayout; private SelectableHelper mSelectionHelper; private Context mContext; private ViewTreeObserver.OnGlobalLayoutListener mKeyboardListener; private Uri mImageToUploadUri; private String mRemoteSipUri; private Address mLocalSipAddress, mRemoteSipAddress, mRemoteParticipantAddress; private ChatRoom mChatRoom; private ArrayList<LinphoneContact> mParticipants; private int mContextMenuMessagePosition; private LinearLayout mTopBar; private ImageView mChatRoomSecurityLevel; private CoreListenerStub mCoreListener; private InputContentInfoCompat mCurrentInputContentInfo; @Override public View onCreateView( LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { super.onCreate(savedInstanceState); // Retain the fragment across configuration changes setRetainInstance(true); if (getArguments() != null) { if (getArguments().getString("LocalSipUri") != null) { String mLocalSipUri = getArguments().getString("LocalSipUri"); mLocalSipAddress = Factory.instance().createAddress(mLocalSipUri); } if (getArguments().getString("RemoteSipUri") != null) { mRemoteSipUri = getArguments().getString("RemoteSipUri"); mRemoteSipAddress = Factory.instance().createAddress(mRemoteSipUri); } } mContext = getActivity().getApplicationContext(); mInflater = inflater; View view = inflater.inflate(R.layout.chat, container, false); mTopBar = view.findViewById(R.id.top_bar); mChatRoomSecurityLevel = view.findViewById(R.id.room_security_level); mChatRoomSecurityLevel.setOnClickListener( new View.OnClickListener() { @Override public void onClick(View view) { goToDevices(); } }); ImageView backButton = view.findViewById(R.id.back); backButton.setOnClickListener( new View.OnClickListener() { @Override public void onClick(View view) { ((ChatActivity) getActivity()).goBack(); } }); backButton.setVisibility( getResources().getBoolean(R.bool.isTablet) ? View.INVISIBLE : View.VISIBLE); mCallButton = view.findViewById(R.id.start_call); mCallButton.setOnClickListener( new View.OnClickListener() { @Override public void onClick(View view) { LinphoneManager.getCallManager() .newOutgoingCall(mRemoteParticipantAddress.asString(), null); } }); mBackToCallButton = view.findViewById(R.id.back_to_call); mBackToCallButton.setOnClickListener( new View.OnClickListener() { @Override public void onClick(View view) { ((MainActivity) getActivity()).goBackToCall(); } }); mPopupMenu = view.findViewById(R.id.menu); mPopupMenu.setOnClickListener( new View.OnClickListener() { @Override public void onClick(View view) { showPopupMenu(); } }); mRoomLabel = view.findViewById(R.id.subject); mParticipantsLabel = view.findViewById(R.id.participants); mSipUriLabel = view.findViewById(R.id.sipUri); mFilesUploadLayout = view.findViewById(R.id.file_upload_layout); mAttachImageButton = view.findViewById(R.id.send_picture); mAttachImageButton.setOnClickListener( new View.OnClickListener() { @Override public void onClick(View view) { String[] permissions = { Manifest.permission.CAMERA, Manifest.permission.READ_EXTERNAL_STORAGE }; ((ChatActivity) getActivity()).requestPermissionsIfNotGranted(permissions); pickFile(); } }); if (getResources().getBoolean(R.bool.disable_chat_send_file)) { mAttachImageButton.setEnabled(false); mAttachImageButton.setVisibility(View.GONE); } mSendEphemeralIcon = view.findViewById(R.id.send_ephemeral_message); mSendMessageButton = view.findViewById(R.id.send_message); mSendMessageButton.setEnabled(false); mSendEphemeralIcon.setEnabled(mSendMessageButton.isEnabled()); mSendMessageButton.setOnClickListener( new View.OnClickListener() { @Override public void onClick(View view) { sendMessage(); } }); mMessageTextToSend = view.findViewById(R.id.message); mMessageTextToSend.addTextChangedListener( new TextWatcher() { @Override public void beforeTextChanged( CharSequence charSequence, int i, int i1, int i2) {} @Override public void onTextChanged(CharSequence charSequence, int i, int i1, int i2) { mSendMessageButton.setEnabled( mMessageTextToSend.getText().length() > 0 || mFilesUploadLayout.getChildCount() > 0); mSendEphemeralIcon.setEnabled(mSendMessageButton.isEnabled()); if (mChatRoom != null && mMessageTextToSend.getText().length() > 0) { if (!getResources().getBoolean(R.bool.allow_multiple_images_and_text)) { mAttachImageButton.setEnabled(false); } mChatRoom.compose(); } else { mAttachImageButton.setEnabled(true); } } @Override public void afterTextChanged(Editable editable) {} }); mMessageTextToSend.clearFocus(); mMessageTextToSend.setListener(this); mRemoteComposing = view.findViewById(R.id.remote_composing); mChatEventsList = view.findViewById(R.id.chat_message_list); mSelectionHelper = new SelectableHelper(view, this); LinearLayoutManager layoutManager = new LinphoneLinearLayoutManager(mContext, LinearLayoutManager.VERTICAL, true); mChatEventsList.setLayoutManager(layoutManager); ChatScrollListener chatScrollListener = new ChatScrollListener(layoutManager) { @Override public void onLoadMore(int totalItemsCount) { loadMoreData(totalItemsCount); } }; mChatEventsList.addOnScrollListener(chatScrollListener); if (getArguments() != null) { String fileSharedUri = getArguments().getString("SharedFiles"); if (fileSharedUri != null) { Log.i("[Chat Messages Fragment] Found shared file(s): " + fileSharedUri); if (fileSharedUri.contains(":")) { String[] files = fileSharedUri.split(":"); for (String file : files) { addFileIntoSharingArea(file); } } else { addFileIntoSharingArea(fileSharedUri); } } if (getArguments().containsKey("SharedText")) { String sharedText = getArguments().getString("SharedText"); mMessageTextToSend.setText(sharedText); Log.i("[Chat Messages Fragment] Found shared text: " + sharedText); } } if (savedInstanceState != null) { onRestoreInstanceState(savedInstanceState); } mCoreListener = new CoreListenerStub() { @Override public void onCallStateChanged( Core core, Call call, Call.State state, String message) { displayChatRoomHeader(); } }; return view; } @Override public void onResume() { super.onResume(); Core core = LinphoneManager.getCore(); if (core != null) { core.addListener(mCoreListener); } ContactsManager.getInstance().addContactsListener(this); addVirtualKeyboardVisiblityListener(); // Force hide keyboard getActivity() .getWindow() .setSoftInputMode( WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_HIDDEN | WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE); InputMethodManager inputMethodManager = (InputMethodManager) getActivity().getSystemService(INPUT_METHOD_SERVICE); if (getActivity().getCurrentFocus() != null) { inputMethodManager.hideSoftInputFromWindow( getActivity().getCurrentFocus().getWindowToken(), 0); } initChatRoom(); displayChatRoomHeader(); displayChatRoomHistory(); LinphoneContext.instance() .getNotificationManager() .setCurrentlyDisplayedChatRoom( mRemoteSipAddress != null ? mRemoteSipAddress.asStringUriOnly() : null); } @Override public void onPause() { Core core = LinphoneManager.getCore(); if (core != null) { core.removeListener(mCoreListener); } ContactsManager.getInstance().removeContactsListener(this); removeVirtualKeyboardVisiblityListener(); LinphoneContext.instance().getNotificationManager().setCurrentlyDisplayedChatRoom(null); if (mChatRoom != null) mChatRoom.removeListener(this); if (mChatEventsList.getAdapter() != null) ((ChatMessagesGenericAdapter) mChatEventsList.getAdapter()).clear(); super.onPause(); } @Override public void onSaveInstanceState(Bundle outState) { super.onSaveInstanceState(outState); ArrayList<String> files = new ArrayList<>(); for (int i = 0; i < mFilesUploadLayout.getChildCount(); i++) { View child = mFilesUploadLayout.getChildAt(i); String filePath = (String) child.getTag(); files.add(filePath); } outState.putStringArrayList("Files", files); } @Override public void onActivityResult(int requestCode, int resultCode, Intent data) { if (data != null) { if (requestCode == ADD_PHOTO && resultCode == Activity.RESULT_OK) { String fileToUploadPath = null; if (data.getData() != null) { Log.i( "[Chat Messages Fragment] Intent data after picking file is " + data.getData().toString()); if (data.getData().toString().contains("com.android.contacts/contacts/")) { Uri cvsPath = FileUtils.getCVSPathFromLookupUri(data.getData().toString()); if (cvsPath != null) { fileToUploadPath = cvsPath.toString(); Log.i("[Chat Messages Fragment] Found CVS path: " + fileToUploadPath); } else { // TODO Error return; } } else { fileToUploadPath = FileUtils.getRealPathFromURI(getActivity(), data.getData()); Log.i( "[Chat Messages Fragment] Resolved path for data is: " + fileToUploadPath); } if (fileToUploadPath == null) { fileToUploadPath = data.getData().toString(); Log.i( "[Chat Messages Fragment] Couldn't resolve path, using as-is: " + fileToUploadPath); } } else if (mImageToUploadUri != null) { fileToUploadPath = mImageToUploadUri.getPath(); Log.i( "[Chat Messages Fragment] Using pre-created path for dynamic capture " + fileToUploadPath); } if (fileToUploadPath.startsWith("content://") || fileToUploadPath.startsWith("file://")) { Uri uriToParse = Uri.parse(fileToUploadPath); fileToUploadPath = FileUtils.getFilePath( getActivity().getApplicationContext(), uriToParse); Log.i( "[Chat Messages Fragment] Path was using a content or file scheme, real path is: " + fileToUploadPath); if (fileToUploadPath == null) { Log.e( "[Chat Messages Fragment] Failed to get access to file " + uriToParse.toString()); } } else if (fileToUploadPath.contains("com.android.contacts/contacts/")) { fileToUploadPath = FileUtils.getCVSPathFromLookupUri(fileToUploadPath).toString(); Log.i( "[Chat Messages Fragment] Path was using a contact scheme, real path is: " + fileToUploadPath); } if (fileToUploadPath != null) { if (FileUtils.isExtensionImage(fileToUploadPath)) { addImageToPendingList(fileToUploadPath); } else { addFileToPendingList(fileToUploadPath); } } else { Log.e( "[Chat Messages Fragment] Failed to get a path that we could use, aborting attachment"); } } else { super.onActivityResult(requestCode, resultCode, data); } } else { if (FileUtils.isExtensionImage(mImageToUploadUri.getPath())) { File file = new File(mImageToUploadUri.getPath()); if (file.exists()) { addImageToPendingList(mImageToUploadUri.getPath()); } } } } @Override public void onDeleteSelection(Object[] objectsToDelete) { for (Object obj : objectsToDelete) { EventLog eventLog = (EventLog) obj; LinphoneUtils.deleteFileContentIfExists(eventLog); eventLog.deleteFromDatabase(); } ((ChatMessagesGenericAdapter) mChatEventsList.getAdapter()) .refresh(mChatRoom.getHistoryEvents(MESSAGES_PER_PAGE)); } @Override public void onCreateContextMenu( ContextMenu menu, View v, ContextMenu.ContextMenuInfo menuInfo) { super.onCreateContextMenu(menu, v, menuInfo); ChatMessageViewHolder holder = (ChatMessageViewHolder) v.getTag(); mContextMenuMessagePosition = holder.getAdapterPosition(); EventLog event = (EventLog) ((ChatMessagesGenericAdapter) mChatEventsList.getAdapter()) .getItem(mContextMenuMessagePosition); if (event.getType() != EventLog.Type.ConferenceChatMessage) { return; } MenuInflater inflater = getActivity().getMenuInflater(); ChatMessage message = event.getChatMessage(); if (message.getState() == ChatMessage.State.NotDelivered) { inflater.inflate(R.menu.chat_bubble_menu_with_resend, menu); } else { inflater.inflate(R.menu.chat_bubble_menu, menu); } if (!message.isOutgoing() || mChatRoom.hasCapability(ChatRoomCapabilities.OneToOne.toInt())) { // Do not show incoming messages IDMN state in 1 to 1 chat room as we don't receive IMDN // for them menu.removeItem(R.id.imdn_infos); } if (!message.hasTextContent()) { // Do not show copy text option if message doesn't have any text menu.removeItem(R.id.copy_text); } if (!message.isOutgoing()) { Address address = message.getFromAddress(); LinphoneContact contact = ContactsManager.getInstance().findContactFromAddress(address); if (contact != null) { menu.removeItem(R.id.add_to_contacts); } } else { menu.removeItem(R.id.add_to_contacts); } } @Override public boolean onContextItemSelected(MenuItem item) { EventLog event = (EventLog) ((ChatMessagesGenericAdapter) mChatEventsList.getAdapter()) .getItem(mContextMenuMessagePosition); if (event.getType() != EventLog.Type.ConferenceChatMessage) { return super.onContextItemSelected(item); } ChatMessage message = event.getChatMessage(); String messageId = message.getMessageId(); if (item.getItemId() == R.id.resend) { ((ChatMessagesGenericAdapter) mChatEventsList.getAdapter()) .removeItem(mContextMenuMessagePosition); message.resend(); return true; } if (item.getItemId() == R.id.imdn_infos) { ((ChatActivity) getActivity()).showImdn(mLocalSipAddress, mRemoteSipAddress, messageId); return true; } if (item.getItemId() == R.id.forward) { ((ChatActivity) getActivity()).forwardMessage(message); return true; } if (item.getItemId() == R.id.copy_text) { if (message.hasTextContent()) { ClipboardManager clipboard = (ClipboardManager) getActivity().getSystemService(Context.CLIPBOARD_SERVICE); ClipData clip = ClipData.newPlainText("Message", message.getTextContent()); clipboard.setPrimaryClip(clip); } return true; } if (item.getItemId() == R.id.delete_message) { LinphoneUtils.deleteFileContentIfExists(event); mChatRoom.deleteMessage(message); ((ChatMessagesGenericAdapter) mChatEventsList.getAdapter()) .removeItem(mContextMenuMessagePosition); return true; } if (item.getItemId() == R.id.add_to_contacts) { Address address = message.getFromAddress(); if (address == null) return true; address.clean(); ((ChatActivity) getActivity()).showContactsListForCreationOrEdition(address); return true; } return super.onContextItemSelected(item); } private void addFileIntoSharingArea(String fileSharedUri) { if (FileUtils.isExtensionImage(fileSharedUri)) { addImageToPendingList(fileSharedUri); } else { if (fileSharedUri.startsWith("content://") || fileSharedUri.startsWith("file://")) { fileSharedUri = FileUtils.getFilePath( getActivity().getApplicationContext(), Uri.parse(fileSharedUri)); } else if (fileSharedUri.contains("com.android.contacts/contacts/")) { fileSharedUri = FileUtils.getCVSPathFromLookupUri(fileSharedUri).toString(); } addFileToPendingList(fileSharedUri); } } private void loadMoreData(final int totalItemsCount) { LinphoneUtils.dispatchOnUIThread( new Runnable() { @Override public void run() { int maxSize = mChatRoom.getHistoryEventsSize(); if (totalItemsCount < maxSize) { int upperBound = totalItemsCount + MESSAGES_PER_PAGE; if (upperBound > maxSize) { upperBound = maxSize; } EventLog[] newLogs; newLogs = mChatRoom.getHistoryRangeEvents(totalItemsCount, upperBound); ArrayList<EventLog> logsList = new ArrayList<>(Arrays.asList(newLogs)); ((ChatMessagesGenericAdapter) mChatEventsList.getAdapter()) .addAllToHistory(logsList); } } }); } /** Keyboard management */ private void addVirtualKeyboardVisiblityListener() { mKeyboardListener = new ViewTreeObserver.OnGlobalLayoutListener() { @Override public void onGlobalLayout() { Rect visibleArea = new Rect(); getActivity() .getWindow() .getDecorView() .getWindowVisibleDisplayFrame(visibleArea); int screenHeight = getActivity().getWindow().getDecorView().getRootView().getHeight(); int heightDiff = screenHeight - (visibleArea.bottom - visibleArea.top); if (heightDiff > screenHeight * 0.15) { showKeyboardVisibleMode(); } else { hideKeyboardVisibleMode(); } } }; getActivity() .getWindow() .getDecorView() .getViewTreeObserver() .addOnGlobalLayoutListener(mKeyboardListener); } private void removeVirtualKeyboardVisiblityListener() { getActivity() .getWindow() .getDecorView() .getViewTreeObserver() .removeOnGlobalLayoutListener(mKeyboardListener); hideKeyboardVisibleMode(); } private void showKeyboardVisibleMode() { ((ChatActivity) getActivity()).hideTabBar(); ((ChatActivity) getActivity()).hideStatusBar(); mTopBar.setVisibility(View.GONE); } private void hideKeyboardVisibleMode() { if (!getResources().getBoolean(R.bool.hide_bottom_bar_on_second_level_views)) { ((ChatActivity) getActivity()).showTabBar(); } ((ChatActivity) getActivity()).showStatusBar(); mTopBar.setVisibility(View.VISIBLE); } /** View initialization */ private void setReadOnly() { mMessageTextToSend.setEnabled(false); mAttachImageButton.setEnabled(false); mSendMessageButton.setEnabled(false); mSendEphemeralIcon.setEnabled(mSendMessageButton.isEnabled()); } private void getContactsForParticipants() { mParticipants = new ArrayList<>(); if (mChatRoom.hasCapability(ChatRoomCapabilities.OneToOne.toInt())) { LinphoneContact c = ContactsManager.getInstance().findContactFromAddress(mRemoteParticipantAddress); if (c != null) { mParticipants.add(c); } } else { int index = 0; StringBuilder participantsLabel = new StringBuilder(); for (Participant p : mChatRoom.getParticipants()) { LinphoneContact c = ContactsManager.getInstance().findContactFromAddress(p.getAddress()); if (c != null) { mParticipants.add(c); participantsLabel.append(c.getFullName()); } else { String displayName = LinphoneUtils.getAddressDisplayName(p.getAddress()); participantsLabel.append(displayName); } index++; if (index != mChatRoom.getNbParticipants()) participantsLabel.append(", "); } mParticipantsLabel.setText(participantsLabel.toString()); } if (mChatEventsList.getAdapter() != null) { ((ChatMessagesGenericAdapter) mChatEventsList.getAdapter()).setContacts(mParticipants); } } private void initChatRoom() { if (mChatRoom != null) { // Required on tablets mChatRoom.removeListener(this); } Core core = LinphoneManager.getCore(); if (mRemoteSipAddress == null || mRemoteSipUri == null || mRemoteSipUri.isEmpty() || core == null) { // TODO error return; } if (mLocalSipAddress != null) { mChatRoom = core.getChatRoom(mRemoteSipAddress, mLocalSipAddress); } else { mChatRoom = core.getChatRoomFromUri(mRemoteSipAddress.asStringUriOnly()); } mChatRoom.addListener(this); mChatRoom.markAsRead(); ((ChatActivity) getActivity()).displayMissedChats(); mRemoteParticipantAddress = mRemoteSipAddress; if (mChatRoom.hasCapability(ChatRoomCapabilities.OneToOne.toInt()) && mChatRoom.getParticipants().length > 0) { mRemoteParticipantAddress = mChatRoom.getParticipants()[0].getAddress(); } getContactsForParticipants(); mRemoteComposing.setVisibility(View.GONE); } private void displayChatRoomHeader() { Core core = LinphoneManager.getCore(); if (core == null || mChatRoom == null) return; if (mChatRoom.hasCapability(ChatRoomCapabilities.OneToOne.toInt())) { mCallButton.setVisibility(View.VISIBLE); if (mChatRoom.hasCapability(ChatRoomCapabilities.Basic.toInt())) { mPopupMenu.setVisibility(View.GONE); mSelectionHelper.setEditButtonVisibility(true); } else { mPopupMenu.setVisibility(View.VISIBLE); mSelectionHelper.setEditButtonVisibility(false); } mParticipantsLabel.setVisibility(View.GONE); if (mContext.getResources().getBoolean(R.bool.show_sip_uri_in_chat)) { mSipUriLabel.setVisibility(View.VISIBLE); } else { mSipUriLabel.setVisibility(View.GONE); mRoomLabel.setOnClickListener( new View.OnClickListener() { @Override public void onClick(View v) { mSipUriLabel.setVisibility( mSipUriLabel.getVisibility() == View.VISIBLE ? View.GONE : View.VISIBLE); } }); } if (mParticipants.isEmpty()) { // Contact not found String displayName = LinphoneUtils.getAddressDisplayName(mRemoteParticipantAddress); mRoomLabel.setText(displayName); } else { mRoomLabel.setText(mParticipants.get(0).getFullName()); } mSipUriLabel.setText(mRemoteParticipantAddress.asStringUriOnly()); } else { mCallButton.setVisibility(View.GONE); mPopupMenu.setVisibility(View.VISIBLE); mSelectionHelper.setEditButtonVisibility(false); mRoomLabel.setText(mChatRoom.getSubject()); mParticipantsLabel.setVisibility(View.VISIBLE); mSipUriLabel.setVisibility(View.GONE); } mBackToCallButton.setVisibility(View.GONE); if (core.getCallsNb() > 0) { mBackToCallButton.setVisibility(View.VISIBLE); } mSendEphemeralIcon.setVisibility(mChatRoom.ephemeralEnabled() ? View.VISIBLE : View.GONE); if (mChatRoom.hasBeenLeft()) { setReadOnly(); } updateSecurityLevelIcon(); } private void updateSecurityLevelIcon() { if (!mChatRoom.hasCapability(ChatRoomCapabilities.Encrypted.toInt())) { mChatRoomSecurityLevel.setVisibility(View.GONE); } else { mChatRoomSecurityLevel.setVisibility(View.VISIBLE); ChatRoomSecurityLevel level = mChatRoom.getSecurityLevel(); switch (level) { case Safe: mChatRoomSecurityLevel.setImageResource(R.drawable.security_2_indicator); break; case Encrypted: mChatRoomSecurityLevel.setImageResource(R.drawable.security_1_indicator); break; case ClearText: case Unsafe: mChatRoomSecurityLevel.setImageResource(R.drawable.security_alert_indicator); break; } } } private void displayChatRoomHistory() { if (mChatRoom == null) return; ChatMessagesAdapter mEventsAdapter = new ChatMessagesAdapter( this, mSelectionHelper, R.layout.chat_bubble, mChatRoom.getHistoryEvents(MESSAGES_PER_PAGE), mParticipants, this); mSelectionHelper.setAdapter(mEventsAdapter); mChatEventsList.setAdapter(mEventsAdapter); scrollToBottom(); } private void showSecurityDialog(boolean oneParticipantOneDevice) { final Dialog dialog = ((ChatActivity) getActivity()) .displayDialog(getString(R.string.lime_security_popup)); Button delete = dialog.findViewById(R.id.dialog_delete_button); delete.setVisibility(View.GONE); Button ok = dialog.findViewById(R.id.dialog_ok_button); ok.setText(oneParticipantOneDevice ? getString(R.string.call) : getString(R.string.ok)); ok.setVisibility(View.VISIBLE); Button cancel = dialog.findViewById(R.id.dialog_cancel_button); cancel.setText(getString(R.string.cancel)); dialog.findViewById(R.id.dialog_do_not_ask_again_layout).setVisibility(View.VISIBLE); final CheckBox doNotAskAgain = dialog.findViewById(R.id.doNotAskAgain); dialog.findViewById(R.id.doNotAskAgainLabel) .setOnClickListener( new View.OnClickListener() { @Override public void onClick(View v) { doNotAskAgain.setChecked(!doNotAskAgain.isChecked()); } }); ok.setTag(oneParticipantOneDevice); ok.setOnClickListener( new View.OnClickListener() { @Override public void onClick(View view) { boolean oneParticipantOneDevice = (boolean) view.getTag(); if (doNotAskAgain.isChecked()) { LinphonePreferences.instance().enableLimeSecurityPopup(false); } if (oneParticipantOneDevice) { ParticipantDevice device = mChatRoom.getParticipants()[0].getDevices()[0]; LinphoneManager.getCallManager() .inviteAddress(device.getAddress(), true); } else { ((ChatActivity) getActivity()) .showDevices(mLocalSipAddress, mRemoteSipAddress); } dialog.dismiss(); } }); cancel.setOnClickListener( new View.OnClickListener() { @Override public void onClick(View view) { if (doNotAskAgain.isChecked()) { LinphonePreferences.instance().enableLimeSecurityPopup(false); } dialog.dismiss(); } }); dialog.show(); } private void scrollToBottom() { mChatEventsList.getLayoutManager().scrollToPosition(0); } @Override public void onItemClicked(int position) { if (mSelectionHelper.getAdapter().isEditionEnabled()) { mSelectionHelper.getAdapter().toggleSelection(position); } } private void onRestoreInstanceState(Bundle savedInstanceState) { ArrayList<String> files = savedInstanceState.getStringArrayList("Files"); if (files != null && !files.isEmpty()) { for (String file : files) { if (FileUtils.isExtensionImage(file)) { addImageToPendingList(file); } else { addFileToPendingList(file); } } } final InputContentInfoCompat previousInputContentInfo = InputContentInfoCompat.wrap( savedInstanceState.getParcelable(INPUT_CONTENT_INFO_KEY)); final int previousFlags = savedInstanceState.getInt(COMMIT_CONTENT_FLAGS_KEY); if (previousInputContentInfo != null) { onCommitContentInternal(previousInputContentInfo, previousFlags); } } private void pickFile() { List<Intent> cameraIntents = new ArrayList<>(); // Handles image & video picking Intent galleryIntent = new Intent(Intent.ACTION_PICK); galleryIntent.setType("*/*"); galleryIntent.putExtra(Intent.EXTRA_MIME_TYPES, new String[] {"image/*", "video/*"}); // Allows to capture directly from the camera Intent captureIntent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE); File file = new File( FileUtils.getStorageDirectory(mContext), getString(R.string.temp_photo_name_with_date) .replace("%s", System.currentTimeMillis() + ".jpeg")); mImageToUploadUri = Uri.fromFile(file); captureIntent.putExtra(MediaStore.EXTRA_OUTPUT, mImageToUploadUri); cameraIntents.add(captureIntent); // Finally allow any kind of file Intent fileIntent = new Intent(Intent.ACTION_GET_CONTENT); fileIntent.setType("*/*"); cameraIntents.add(fileIntent); Intent chooserIntent = Intent.createChooser(galleryIntent, getString(R.string.image_picker_title)); chooserIntent.putExtra( Intent.EXTRA_INITIAL_INTENTS, cameraIntents.toArray(new Parcelable[] {})); startActivityForResult(chooserIntent, ADD_PHOTO); } private void addFileToPendingList(String path) { if (path == null) { Log.e( "[Chat Messages Fragment] Can't add file to pending list because it's path is null..."); return; } View pendingFile = mInflater.inflate(R.layout.file_upload_cell, mFilesUploadLayout, false); pendingFile.setTag(path); TextView text = pendingFile.findViewById(R.id.pendingFileForUpload); String extension = path.substring(path.lastIndexOf('.')); text.setText(extension); ImageView remove = pendingFile.findViewById(R.id.remove); remove.setTag(pendingFile); remove.setOnClickListener( new View.OnClickListener() { @Override public void onClick(View view) { View pendingImage = (View) view.getTag(); mFilesUploadLayout.removeView(pendingImage); mAttachImageButton.setEnabled(true); mMessageTextToSend.setEnabled(true); mSendMessageButton.setEnabled( mMessageTextToSend.getText().length() > 0 || mFilesUploadLayout.getChildCount() > 0); mSendEphemeralIcon.setEnabled(mSendMessageButton.isEnabled()); } }); mFilesUploadLayout.addView(pendingFile); if (!getResources().getBoolean(R.bool.allow_multiple_images_and_text)) { mAttachImageButton.setEnabled(false); mMessageTextToSend.setEnabled(false); } mSendMessageButton.setEnabled(true); mSendEphemeralIcon.setEnabled(mSendMessageButton.isEnabled()); } private void addImageToPendingList(String path) { if (path == null) { Log.e( "[Chat Messages Fragment] Can't add image to pending list because it's path is null..."); return; } View pendingImage = mInflater.inflate(R.layout.image_upload_cell, mFilesUploadLayout, false); pendingImage.setTag(path); ImageView image = pendingImage.findViewById(R.id.pendingImageForUpload); Glide.with(mContext).load(path).into(image); ImageView remove = pendingImage.findViewById(R.id.remove); remove.setTag(pendingImage); remove.setOnClickListener( new View.OnClickListener() { @Override public void onClick(View view) { View pendingImage = (View) view.getTag(); mFilesUploadLayout.removeView(pendingImage); mAttachImageButton.setEnabled(true); mMessageTextToSend.setEnabled(true); mSendMessageButton.setEnabled( mMessageTextToSend.getText().length() > 0 || mFilesUploadLayout.getChildCount() > 0); mSendEphemeralIcon.setEnabled(mSendMessageButton.isEnabled()); } }); mFilesUploadLayout.addView(pendingImage); if (!getResources().getBoolean(R.bool.allow_multiple_images_and_text)) { mAttachImageButton.setEnabled(false); mMessageTextToSend.setEnabled(false); } mSendMessageButton.setEnabled(true); mSendEphemeralIcon.setEnabled(mSendMessageButton.isEnabled()); } /** Message sending */ private void sendMessage() { ChatMessage msg = mChatRoom.createEmptyMessage(); boolean isBasicChatRoom = mChatRoom.hasCapability(ChatRoomCapabilities.Basic.toInt()); boolean sendMultipleImagesAsDifferentMessages = getResources().getBoolean(R.bool.send_multiple_images_as_different_messages); boolean sendImageAndTextAsDifferentMessages = getResources().getBoolean(R.bool.send_text_and_images_as_different_messages); String text = mMessageTextToSend.getText().toString(); boolean hasText = text != null && text.length() > 0; int filesCount = mFilesUploadLayout.getChildCount(); for (int i = 0; i < filesCount; i++) { String filePath = (String) mFilesUploadLayout.getChildAt(i).getTag(); String fileName = filePath.substring(filePath.lastIndexOf("/") + 1); String extension = FileUtils.getExtensionFromFileName(fileName); Content content = Factory.instance().createContent(); if (FileUtils.isExtensionImage(fileName)) { content.setType("image"); } else { content.setType("file"); } content.setSubtype(extension); content.setName(fileName); content.setFilePath(filePath); // Let the file body handler take care of the upload boolean split = isBasicChatRoom; // Always split contents in basic chat rooms for compatibility if (hasText && sendImageAndTextAsDifferentMessages) { split = true; } else if (mFilesUploadLayout.getChildCount() > 1 && sendMultipleImagesAsDifferentMessages) { split = true; // Allow the last image to be sent with text if image and text at the same time OK if (hasText && i == filesCount - 1) { split = false; } } if (split) { ChatMessage fileMessage = mChatRoom.createFileTransferMessage(content); fileMessage.send(); } else { msg.addFileContent(content); } } if (hasText) { msg.addTextContent(text); } // Set listener not required here anymore, message will be added to messages list and // adapter will set the listener if (msg.getContents().length > 0) { msg.send(); } mFilesUploadLayout.removeAllViews(); mAttachImageButton.setEnabled(true); mMessageTextToSend.setEnabled(true); mMessageTextToSend.setText(""); } private void showPopupMenu() { MenuBuilder builder = new MenuBuilder(getActivity()); MenuPopupHelper popupMenu = new MenuPopupHelper(getActivity(), builder, mPopupMenu); popupMenu.setForceShowIcon(true); new MenuInflater(getActivity()).inflate(R.menu.chat_room_menu, builder); if (mChatRoom.hasCapability(ChatRoomCapabilities.OneToOne.toInt())) { builder.removeItem(R.id.chat_room_group_info); } if (!mChatRoom.hasCapability(ChatRoomCapabilities.Encrypted.toInt())) { builder.removeItem(R.id.chat_room_participants_devices); builder.removeItem(R.id.chat_room_ephemeral_messages); } else { if (!LinphonePreferences.instance().isEphemeralMessagesEnabled()) { builder.removeItem(R.id.chat_room_ephemeral_messages); } } builder.setCallback( new MenuBuilder.Callback() { @Override public boolean onMenuItemSelected(MenuBuilder menu, MenuItem item) { if (item.getItemId() == R.id.chat_room_group_info) { goToGroupInfo(); return true; } else if (item.getItemId() == R.id.chat_room_participants_devices) { goToDevices(); return true; } else if (item.getItemId() == R.id.chat_room_ephemeral_messages) { goToEphemeral(); return true; } else if (item.getItemId() == R.id.chat_room_delete_messages) { mSelectionHelper.enterEditionMode(); return true; } return false; } @Override public void onMenuModeChange(MenuBuilder menu) {} }); popupMenu.show(); } private void goToDevices() { boolean oneParticipantOneDevice = false; if (mChatRoom.hasCapability(ChatRoomCapabilities.OneToOne.toInt())) { ParticipantDevice[] devices = mChatRoom.getParticipants()[0].getDevices(); // Only start a call automatically if both ourselves and the remote // have 1 device exactly, otherwise show devices list. oneParticipantOneDevice = devices.length == 1 && mChatRoom.getMe().getDevices().length == 1; } if (LinphonePreferences.instance().isLimeSecurityPopupEnabled()) { showSecurityDialog(oneParticipantOneDevice); } else { if (oneParticipantOneDevice) { ParticipantDevice device = mChatRoom.getParticipants()[0].getDevices()[0]; LinphoneManager.getCallManager().inviteAddress(device.getAddress(), true); } else { ((ChatActivity) getActivity()).showDevices(mLocalSipAddress, mRemoteSipAddress); } } } private void goToGroupInfo() { if (mChatRoom == null) return; ArrayList<ContactAddress> participants = new ArrayList<>(); for (Participant p : mChatRoom.getParticipants()) { Address a = p.getAddress(); LinphoneContact c = ContactsManager.getInstance().findContactFromAddress(a); if (c == null) { c = new LinphoneContact(); String displayName = LinphoneUtils.getAddressDisplayName(a); c.setFullName(displayName); } ContactAddress ca = new ContactAddress(c, a.asString(), "", p.isAdmin()); participants.add(ca); } boolean encrypted = mChatRoom.hasCapability(ChatRoomCapabilities.Encrypted.toInt()); ((ChatActivity) getActivity()) .showChatRoomGroupInfo( mRemoteSipAddress, participants, mChatRoom.getSubject(), encrypted); } private void goToEphemeral() { if (mChatRoom == null) return; ((ChatActivity) getActivity()).showChatRoomEphemeral(mRemoteSipAddress); } /* * Chat room callbacks */ @Override public void onChatMessageSent(ChatRoom cr, EventLog event) { ((ChatMessagesGenericAdapter) mChatEventsList.getAdapter()).addToHistory(event); scrollToBottom(); } @Override public void onConferenceAddressGeneration(ChatRoom cr) {} @Override public void onParticipantRegistrationSubscriptionRequested( ChatRoom cr, Address participantAddr) {} @Override public void onParticipantRegistrationUnsubscriptionRequested( ChatRoom cr, Address participantAddr) {} @Override public void onUndecryptableMessageReceived(ChatRoom cr, ChatMessage msg) { final Address from = msg.getFromAddress(); final LinphoneContact contact = ContactsManager.getInstance().findContactFromAddress(from); if (LinphoneManager.getCore().limeX3DhEnabled()) { final Dialog dialog = ((ChatActivity) getActivity()) .displayDialog( getString(R.string.message_cant_be_decrypted) .replace( "%s", (contact != null) ? contact.getFullName() : from.getUsername())); Button delete = dialog.findViewById(R.id.dialog_delete_button); delete.setText(getString(R.string.call)); Button cancel = dialog.findViewById(R.id.dialog_cancel_button); cancel.setText(getString(R.string.ok)); delete.setOnClickListener( new View.OnClickListener() { @Override public void onClick(View view) { LinphoneManager.getCallManager() .newOutgoingCall( from.asStringUriOnly(), (contact != null) ? contact.getFullName() : from.getUsername()); dialog.dismiss(); } }); cancel.setOnClickListener( new View.OnClickListener() { @Override public void onClick(View view) { dialog.dismiss(); } }); dialog.show(); } } @Override public void onChatMessageReceived(ChatRoom cr, EventLog event) { cr.markAsRead(); ((ChatActivity) getActivity()).displayMissedChats(); ChatMessage msg = event.getChatMessage(); if (msg.getErrorInfo() != null && msg.getErrorInfo().getReason() == Reason.UnsupportedContent) { Log.w( "[Chat Messages Fragment] Message received but content is unsupported, do not display it"); return; } if (!msg.hasTextContent() && msg.getFileTransferInformation() == null) { Log.w( "[Chat Messages Fragment] Message has no text or file transfer information to display, ignoring it..."); return; } String externalBodyUrl = msg.getExternalBodyUrl(); Content fileTransferContent = msg.getFileTransferInformation(); if (externalBodyUrl != null || fileTransferContent != null) { ((ChatActivity) getActivity()) .requestPermissionIfNotGranted(Manifest.permission.WRITE_EXTERNAL_STORAGE); } ((ChatMessagesGenericAdapter) mChatEventsList.getAdapter()).addToHistory(event); scrollToBottom(); } @Override public void onIsComposingReceived(ChatRoom cr, Address remoteAddr, boolean isComposing) { ArrayList<String> composing = new ArrayList<>(); for (Address a : cr.getComposingAddresses()) { boolean found = false; for (LinphoneContact c : mParticipants) { if (c.hasAddress(a.asStringUriOnly())) { composing.add(c.getFullName()); found = true; break; } } if (!found) { LinphoneContact contact = ContactsManager.getInstance().findContactFromAddress(remoteAddr); String displayName; if (contact != null) { if (contact.getFullName() != null) { displayName = contact.getFullName(); } else { displayName = LinphoneUtils.getAddressDisplayName(remoteAddr); } } else { displayName = LinphoneUtils.getAddressDisplayName(a); } composing.add(displayName); } } mRemoteComposing.setVisibility(View.VISIBLE); if (composing.isEmpty()) { mRemoteComposing.setVisibility(View.GONE); } else if (composing.size() == 1) { mRemoteComposing.setText( getString(R.string.remote_composing_single).replace("%s", composing.get(0))); } else { StringBuilder remotes = new StringBuilder(); int i = 0; for (String remote : composing) { remotes.append(remote); i++; if (i != composing.size()) { remotes.append(", "); } } mRemoteComposing.setText( getString(R.string.remote_composing_multiple) .replace("%s", remotes.toString())); } } @Override public void onMessageReceived(ChatRoom cr, ChatMessage msg) {} @Override public void onConferenceJoined(ChatRoom cr, EventLog event) { // Currently flexisip doesn't send the participants list in the INVITE // So we have to refresh the display when information is available // In the meantime header will be chatroom-xxxxxxx if (mChatRoom == null) mChatRoom = cr; if (mChatRoom.hasCapability(ChatRoomCapabilities.OneToOne.toInt()) && mChatRoom.getParticipants().length > 0) { mRemoteParticipantAddress = mChatRoom.getParticipants()[0].getAddress(); } getContactsForParticipants(); displayChatRoomHeader(); if (mChatRoom.hasCapability(ChatRoomCapabilities.OneToOne.toInt())) return; ((ChatMessagesGenericAdapter) mChatEventsList.getAdapter()).addToHistory(event); scrollToBottom(); } @Override public void onConferenceLeft(ChatRoom cr, EventLog event) { if (mChatRoom.hasCapability(ChatRoomCapabilities.OneToOne.toInt())) return; ((ChatMessagesGenericAdapter) mChatEventsList.getAdapter()).addToHistory(event); scrollToBottom(); } @Override public void onEphemeralEvent(ChatRoom chatRoom, EventLog eventLog) { ((ChatMessagesGenericAdapter) mChatEventsList.getAdapter()).addToHistory(eventLog); scrollToBottom(); } @Override public void onEphemeralMessageTimerStarted(ChatRoom chatRoom, EventLog eventLog) {} @Override public void onEphemeralMessageDeleted(ChatRoom chatRoom, EventLog eventLog) { Log.i("[Chat Room] Ephemeral message expired"); LinphoneUtils.deleteFileContentIfExists(eventLog); if (!((ChatMessagesGenericAdapter) mChatEventsList.getAdapter()) .removeFromHistory(eventLog)) { Log.w("[Chat Room] Ephemeral message not found, refresh list"); ((ChatMessagesGenericAdapter) mChatEventsList.getAdapter()) .refresh(mChatRoom.getHistoryEvents(MESSAGES_PER_PAGE)); } } @Override public void onParticipantAdminStatusChanged(ChatRoom cr, EventLog event) { if (mChatRoom.hasCapability(ChatRoomCapabilities.OneToOne.toInt())) return; ((ChatMessagesGenericAdapter) mChatEventsList.getAdapter()).addToHistory(event); scrollToBottom(); } @Override public void onParticipantDeviceRemoved(ChatRoom cr, EventLog event) {} @Override public void onParticipantRemoved(ChatRoom cr, EventLog event) { if (mChatRoom.hasCapability(ChatRoomCapabilities.OneToOne.toInt())) return; getContactsForParticipants(); ((ChatMessagesGenericAdapter) mChatEventsList.getAdapter()).addToHistory(event); scrollToBottom(); } @Override public void onChatMessageShouldBeStored(ChatRoom cr, ChatMessage msg) {} @Override public void onParticipantDeviceAdded(ChatRoom cr, EventLog event) {} @Override public void onSecurityEvent(ChatRoom cr, EventLog eventLog) { updateSecurityLevelIcon(); ((ChatMessagesGenericAdapter) mChatEventsList.getAdapter()).addToHistory(eventLog); scrollToBottom(); } @Override public void onStateChanged(ChatRoom cr, ChatRoom.State newState) { if (mChatRoom.hasBeenLeft()) { setReadOnly(); } } @Override public void onParticipantAdded(ChatRoom cr, EventLog event) { if (mChatRoom.hasCapability(ChatRoomCapabilities.OneToOne.toInt())) return; getContactsForParticipants(); ((ChatMessagesGenericAdapter) mChatEventsList.getAdapter()).addToHistory(event); scrollToBottom(); } @Override public void onSubjectChanged(ChatRoom cr, EventLog event) { if (mChatRoom.hasCapability(ChatRoomCapabilities.OneToOne.toInt())) return; mRoomLabel.setText(event.getSubject()); ((ChatMessagesGenericAdapter) mChatEventsList.getAdapter()).addToHistory(event); scrollToBottom(); } @Override public void onContactsUpdated() { getContactsForParticipants(); displayChatRoomHeader(); mChatEventsList.getAdapter().notifyDataSetChanged(); } @Override public boolean onCommitContent( InputContentInfoCompat inputContentInfo, int flags, Bundle opts, String[] contentMimeTypes) { try { if (mCurrentInputContentInfo != null) { mCurrentInputContentInfo.releasePermission(); } } catch (Exception e) { Log.e("[Chat Messages Fragment] releasePermission failed : ", e); } finally { mCurrentInputContentInfo = null; } boolean supported = false; for (final String mimeType : contentMimeTypes) { if (inputContentInfo.getDescription().hasMimeType(mimeType)) { supported = true; break; } } if (!supported) { return false; } return onCommitContentInternal(inputContentInfo, flags); } private boolean onCommitContentInternal(InputContentInfoCompat inputContentInfo, int flags) { if ((flags & InputConnectionCompat.INPUT_CONTENT_GRANT_READ_URI_PERMISSION) != 0) { try { inputContentInfo.requestPermission(); } catch (Exception e) { Log.e("[Chat Messages Fragment] requestPermission failed : ", e); return false; } } if (inputContentInfo.getContentUri() != null) { String contentUri = FileUtils.getFilePath(mContext, inputContentInfo.getContentUri()); addImageToPendingList(contentUri); } mCurrentInputContentInfo = inputContentInfo; return true; } // This is a workaround to prevent a crash from happening while rotating the device private class LinphoneLinearLayoutManager extends LinearLayoutManager { LinphoneLinearLayoutManager(Context context, int orientation, boolean reverseLayout) { super(context, orientation, reverseLayout); } @Override public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) { try { super.onLayoutChildren(recycler, state); } catch (IndexOutOfBoundsException e) { Log.e( "[Chat Messages Fragment] InvalidIndexOutOfBound Exception, probably while rotating the device"); } } } }