/* * 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 . */ package org.linphone.chat; import static android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION; import android.Manifest; import android.content.ActivityNotFoundException; import android.content.Context; import android.content.Intent; import android.content.pm.PackageManager; import android.net.Uri; import android.os.CountDownTimer; import android.text.Spanned; import android.text.method.LinkMovementMethod; import android.view.LayoutInflater; import android.view.View; import android.webkit.MimeTypeMap; import android.widget.Button; import android.widget.CheckBox; import android.widget.ImageView; import android.widget.LinearLayout; import android.widget.ProgressBar; import android.widget.RelativeLayout; import android.widget.TextView; import android.widget.Toast; import androidx.core.content.FileProvider; import androidx.recyclerview.widget.RecyclerView; import com.bumptech.glide.Glide; import com.google.android.flexbox.FlexboxLayout; import java.io.File; import java.util.ArrayList; import java.util.List; import org.linphone.R; import org.linphone.contacts.ContactsManager; import org.linphone.contacts.LinphoneContact; import org.linphone.contacts.views.ContactAvatar; import org.linphone.core.Address; import org.linphone.core.ChatMessage; import org.linphone.core.Content; import org.linphone.core.tools.Log; import org.linphone.utils.FileUtils; import org.linphone.utils.ImageUtils; import org.linphone.utils.LinphoneUtils; public class ChatMessageViewHolder extends RecyclerView.ViewHolder implements View.OnClickListener { public final LinearLayout eventLayout; public final TextView eventMessage; public final LinearLayout securityEventLayout; public final TextView securityEventMessage; public final View rightAnchor; public final RelativeLayout bubbleLayout; public final LinearLayout background; public final RelativeLayout avatarLayout; private final ProgressBar downloadInProgress; public final ProgressBar sendInProgress; public final TextView timeText; private final ImageView outgoingImdn; private final TextView messageText; private final FlexboxLayout multiFileContents; private final RelativeLayout singleFileContent; private final LinearLayout forwardLayout; private final LinearLayout ephemeralLayout; private final TextView ephemeralCountdown; private CountDownTimer countDownTimer; public final CheckBox delete; public boolean isEditionEnabled; private Context mContext; private ChatMessageViewHolderClickListener mListener; public ChatMessageViewHolder( Context context, View view, ChatMessageViewHolderClickListener listener) { this(view); mContext = context; mListener = listener; view.setOnClickListener(this); } private ChatMessageViewHolder(View view) { super(view); eventLayout = view.findViewById(R.id.event); eventMessage = view.findViewById(R.id.event_text); securityEventLayout = view.findViewById(R.id.security_event); securityEventMessage = view.findViewById(R.id.security_event_text); rightAnchor = view.findViewById(R.id.rightAnchor); bubbleLayout = view.findViewById(R.id.bubble); background = view.findViewById(R.id.background); avatarLayout = view.findViewById(R.id.avatar_layout); downloadInProgress = view.findViewById(R.id.download_in_progress); sendInProgress = view.findViewById(R.id.send_in_progress); timeText = view.findViewById(R.id.time); outgoingImdn = view.findViewById(R.id.imdn); messageText = view.findViewById(R.id.message); singleFileContent = view.findViewById(R.id.single_content); multiFileContents = view.findViewById(R.id.multi_content); forwardLayout = view.findViewById(R.id.forward_layout); ephemeralLayout = view.findViewById(R.id.ephemeral_layout); ephemeralCountdown = view.findViewById(R.id.ephemeral_time); countDownTimer = null; delete = view.findViewById(R.id.delete_event); } @Override public void onClick(View v) { if (mListener != null) { mListener.onItemClicked(getAdapterPosition()); } } public void bindMessage(final ChatMessage message, LinphoneContact contact) { eventLayout.setVisibility(View.GONE); securityEventLayout.setVisibility(View.GONE); rightAnchor.setVisibility(View.VISIBLE); bubbleLayout.setVisibility(View.VISIBLE); messageText.setVisibility(View.GONE); timeText.setVisibility(View.VISIBLE); outgoingImdn.setVisibility(View.GONE); avatarLayout.setVisibility(View.GONE); sendInProgress.setVisibility(View.GONE); downloadInProgress.setVisibility(View.GONE); singleFileContent.setVisibility(View.GONE); multiFileContents.setVisibility(View.GONE); forwardLayout.setVisibility(message.isForward() ? View.VISIBLE : View.GONE); ephemeralLayout.setVisibility(message.isEphemeral() ? View.VISIBLE : View.GONE); updateEphemeralTimer(message); ChatMessage.State status = message.getState(); Address remoteSender = message.getFromAddress(); String displayName; String time = LinphoneUtils.timestampToHumanDate( mContext, message.getTime(), R.string.messages_date_format); if (message.isOutgoing()) { bubbleLayout.setPadding(0, 0, 0, 0); // Reset padding outgoingImdn.setVisibility(View.INVISIBLE); // For anchoring purposes if (status == ChatMessage.State.DeliveredToUser) { outgoingImdn.setVisibility(View.VISIBLE); outgoingImdn.setImageResource(R.drawable.imdn_received); } else if (status == ChatMessage.State.Displayed) { outgoingImdn.setVisibility(View.VISIBLE); outgoingImdn.setImageResource(R.drawable.imdn_read); } else if (status == ChatMessage.State.NotDelivered) { outgoingImdn.setVisibility(View.VISIBLE); outgoingImdn.setImageResource(R.drawable.imdn_error); } else if (status == ChatMessage.State.FileTransferError) { outgoingImdn.setVisibility(View.VISIBLE); outgoingImdn.setImageResource(R.drawable.imdn_error); } else if (status == ChatMessage.State.InProgress || status == ChatMessage.State.FileTransferInProgress) { sendInProgress.setVisibility(View.VISIBLE); } timeText.setVisibility(View.VISIBLE); background.setBackgroundResource(R.drawable.chat_bubble_outgoing_full); } else { rightAnchor.setVisibility(View.GONE); avatarLayout.setVisibility(View.VISIBLE); background.setBackgroundResource(R.drawable.chat_bubble_incoming_full); // Can't anchor incoming messages, setting this to align max width with LIME icon bubbleLayout.setPadding(0, 0, (int) ImageUtils.dpToPixels(mContext, 18), 0); if (status == ChatMessage.State.FileTransferInProgress) { downloadInProgress.setVisibility(View.VISIBLE); } } if (contact == null) { contact = ContactsManager.getInstance().findContactFromAddress(remoteSender); } if (contact != null) { if (contact.getFullName() != null) { displayName = contact.getFullName(); } else { displayName = LinphoneUtils.getAddressDisplayName(remoteSender); } ContactAvatar.displayAvatar(contact, avatarLayout); } else { displayName = LinphoneUtils.getAddressDisplayName(remoteSender); ContactAvatar.displayAvatar(displayName, avatarLayout); } if (message.isOutgoing()) { timeText.setText(time); } else { timeText.setText(time + " - " + displayName); } if (message.hasTextContent()) { String msg = message.getTextContent(); Spanned text = LinphoneUtils.getTextWithHttpLinks(msg); messageText.setText(text); messageText.setMovementMethod(LinkMovementMethod.getInstance()); messageText.setVisibility(View.VISIBLE); } List fileContents = new ArrayList<>(); for (Content c : message.getContents()) { if (c.isFile() || c.isFileTransfer()) { fileContents.add(c); } } if (fileContents.size() == 1) { singleFileContent.setVisibility(View.VISIBLE); displayContent(message, fileContents.get(0), singleFileContent, false); } else if (fileContents.size() > 1) { multiFileContents.removeAllViews(); multiFileContents.setVisibility(View.VISIBLE); for (Content c : fileContents) { View content = LayoutInflater.from(mContext) .inflate(R.layout.chat_bubble_content, null, false); displayContent(message, c, content, true); multiFileContents.addView(content); } } } private void displayContent( final ChatMessage message, Content c, View content, boolean isMultiContent) { final Button downloadOrCancel = content.findViewById(R.id.download); downloadOrCancel.setVisibility(View.GONE); final ImageView bigImage = content.findViewById(R.id.bigImage); bigImage.setVisibility(View.GONE); final ImageView smallImage = content.findViewById(R.id.image); smallImage.setVisibility(View.GONE); final TextView fileName = content.findViewById(R.id.file); fileName.setVisibility(View.GONE); if (c.isFile() || (c.isFileTransfer() && !c.getFilePath().isEmpty())) { // If message is outgoing, even if content // is file transfer we have the file available final String filePath = c.getFilePath(); View v; if (FileUtils.isExtensionImage(filePath)) { if (!isMultiContent && mContext.getResources() .getBoolean( R.bool.use_big_pictures_to_preview_images_file_transfers)) { loadBitmap(c.getFilePath(), bigImage); v = bigImage; } else { loadBitmap(c.getFilePath(), smallImage); v = smallImage; } } else { fileName.setText(FileUtils.getNameFromFilePath(filePath)); v = fileName; } v.setVisibility(View.VISIBLE); v.setOnClickListener( new View.OnClickListener() { @Override public void onClick(View v) { if (isEditionEnabled) { ChatMessageViewHolder.this.onClick(v); } else { openFile(filePath); } } }); } else { downloadOrCancel.setVisibility(View.VISIBLE); if (mContext.getPackageManager() .checkPermission( Manifest.permission.WRITE_EXTERNAL_STORAGE, mContext.getPackageName()) == PackageManager.PERMISSION_GRANTED) { String filename = c.getName(); File file = new File(FileUtils.getStorageDirectory(mContext), filename); int prefix = 1; while (file.exists()) { file = new File( FileUtils.getStorageDirectory(mContext), prefix + "_" + filename); Log.w( "[Chat Message View] File with that name already exists, renamed to " + prefix + "_" + filename); prefix += 1; } c.setFilePath(file.getPath()); downloadOrCancel.setTag(c); if (!message.isFileTransferInProgress()) { downloadOrCancel.setText(R.string.download_file); } else { downloadOrCancel.setText(R.string.cancel); } downloadOrCancel.setOnClickListener( new View.OnClickListener() { @Override public void onClick(View v) { if (isEditionEnabled) { ChatMessageViewHolder.this.onClick(v); } else { Content c = (Content) v.getTag(); if (!message.isFileTransferInProgress()) { message.downloadContent(c); } else { message.cancelFileTransfer(); } } } }); } else { Log.w( "[Chat Message View] WRITE_EXTERNAL_STORAGE permission not granted, won't be able to store the downloaded file"); ((ChatActivity) mContext) .requestPermissionIfNotGranted(Manifest.permission.WRITE_EXTERNAL_STORAGE); } } } private void openFile(String path) { Intent intent = new Intent(Intent.ACTION_VIEW); File file; Uri contentUri; if (path.startsWith("file://")) { path = path.substring("file://".length()); file = new File(path); contentUri = FileProvider.getUriForFile( mContext, mContext.getResources().getString(R.string.file_provider), file); } else if (path.startsWith("content://")) { contentUri = Uri.parse(path); } else { file = new File(path); try { contentUri = FileProvider.getUriForFile( mContext, mContext.getResources().getString(R.string.file_provider), file); } catch (Exception e) { Log.e( "[Chat Message View] Couldn't get URI for file " + file + " using file provider " + mContext.getResources().getString(R.string.file_provider)); contentUri = Uri.parse(path); } } String filePath = contentUri.toString(); Log.i("[Chat Message View] Trying to open file: " + filePath); String type = null; String extension = FileUtils.getExtensionFromFileName(filePath); if (extension != null && !extension.isEmpty()) { Log.i("[Chat Message View] Found extension " + extension); type = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension); } else { Log.e("[Chat Message View] Couldn't find extension"); } if (type != null) { Log.i("[Chat Message View] Found matching MIME type " + type); } else { type = FileUtils.getMimeFromFile(filePath); Log.e( "[Chat Message View] Can't get MIME type from extension: " + extension + ", will use " + type); } intent.setDataAndType(contentUri, type); intent.addFlags(FLAG_GRANT_READ_URI_PERMISSION); try { mContext.startActivity(intent); } catch (ActivityNotFoundException anfe) { Log.e("[Chat Message View] Couldn't find an activity to handle MIME type: " + type); Toast.makeText(mContext, R.string.cant_open_file_no_app_found, Toast.LENGTH_LONG) .show(); } } private void loadBitmap(String path, ImageView imageView) { Glide.with(mContext).load(path).into(imageView); } private void updateEphemeralTimer(ChatMessage message) { if (!message.isEphemeral()) { if (countDownTimer != null) { countDownTimer.cancel(); countDownTimer = null; } return; } if (message.getEphemeralExpireTime() == 0) { // This means the message hasn't been read by all participants yet, so the countdown // hasn't started // In this case we simply display the configured value for lifetime ephemeralCountdown.setText(formatLifetime(message.getEphemeralLifetime())); if (countDownTimer != null) { countDownTimer.cancel(); countDownTimer = null; } } else { // Countdown has started, display remaining time long remaining = message.getEphemeralExpireTime() - (System.currentTimeMillis() / 1000); ephemeralCountdown.setText(formatLifetime(remaining)); if (countDownTimer == null) { countDownTimer = new CountDownTimer(remaining * 1000, 1000) { @Override public void onTick(long millisUntilFinished) { ephemeralCountdown.setText( formatLifetime(millisUntilFinished / 1000)); } @Override public void onFinish() {} }; countDownTimer.start(); } } } private String formatLifetime(long seconds) { long days = seconds / 86400; if (days == 0) { return String.format( "%02d:%02d:%02d", seconds / 3600, (seconds % 3600) / 60, (seconds % 60)); } else { return mContext.getResources().getQuantityString(R.plurals.days, (int) days, days); } } }