/* * Copyright (C) 2008 Esmertec AG. Copyright (C) 2008 The Android Open Source * Project * * Licensed under the Apache License, Version 2.0 (the "License"); you may not * use this file except in compliance with the License. You may obtain a copy of * the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the * License for the specific language governing permissions and limitations under * the License. */ package info.guardianproject.otr.app.im.app; import info.guardianproject.emoji.EmojiManager; import info.guardianproject.otr.app.im.R; import info.guardianproject.otr.app.im.engine.Presence; import info.guardianproject.otr.app.im.plugin.xmpp.XmppAddress; import info.guardianproject.otr.app.im.provider.Imps; import info.guardianproject.otr.app.im.ui.ImageViewActivity; import info.guardianproject.otr.app.im.ui.LetterAvatar; import info.guardianproject.otr.app.im.ui.RoundedAvatarDrawable; import info.guardianproject.util.AudioPlayer; import info.guardianproject.util.LinkifyHelper; import info.guardianproject.util.LogCleaner; import java.io.File; import java.io.IOException; import java.io.InputStream; import java.net.URLConnection; import java.text.DateFormat; import java.text.SimpleDateFormat; import java.util.Date; import java.util.List; import android.annotation.TargetApi; import android.app.AlertDialog; import android.content.ContentResolver; import android.content.Context; import android.content.DialogInterface; import android.content.Intent; import android.content.pm.PackageManager; import android.content.pm.ResolveInfo; import android.content.res.Resources; import android.database.Cursor; import android.graphics.Bitmap; import android.graphics.BitmapFactory; import android.graphics.Color; import android.graphics.Typeface; import android.graphics.drawable.Drawable; import android.media.AudioManager; import android.media.MediaPlayer; import android.net.Uri; import android.os.AsyncTask; import android.os.Build; import android.provider.Browser; import android.provider.MediaStore; import android.support.v4.util.LruCache; import android.text.Spannable; import android.text.SpannableString; import android.text.TextUtils; import android.text.style.ClickableSpan; import android.text.style.ImageSpan; import android.text.style.RelativeSizeSpan; import android.text.style.StyleSpan; import android.text.style.URLSpan; import android.util.AttributeSet; import android.util.Log; import android.view.View; import android.widget.FrameLayout; import android.widget.ImageView; import android.widget.TextView; import android.widget.Toast; public class MessageView extends FrameLayout { private static int sCacheSize = 10; // 1MiB private static LruCache<String,Bitmap> mBitmapCache = new LruCache<String,Bitmap>(sCacheSize); public enum DeliveryState { NEUTRAL, DELIVERED, UNDELIVERED } public enum EncryptionState { NONE, ENCRYPTED, ENCRYPTED_AND_VERIFIED } private CharSequence lastMessage = null; private Context context; private boolean linkify = false; public MessageView(Context context, AttributeSet attrs) { super(context, attrs); this.context = context; } private ViewHolder mHolder = null; private final static DateFormat MESSAGE_DATETIME_FORMAT = SimpleDateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT); private final static DateFormat MESSAGE_TIME_FORMAT = SimpleDateFormat.getTimeInstance(DateFormat.SHORT); private static final SimpleDateFormat FMT_SAME_DAY = new SimpleDateFormat("yyyyMMdd"); private final static Date DATE_NOW = new Date(); private final static char DELIVERED_SUCCESS = '\u2714'; private final static char DELIVERED_FAIL = '\u2718'; private final static String LOCK_CHAR = "Secure"; class ViewHolder { TextView mTextViewForMessages = (TextView) findViewById(R.id.message); TextView mTextViewForTimestamp = (TextView) findViewById(R.id.messagets); ImageView mAvatar = (ImageView) findViewById(R.id.avatar); // View mStatusBlock = findViewById(R.id.status_block); ImageView mMediaThumbnail = (ImageView) findViewById(R.id.media_thumbnail); View mContainer = findViewById(R.id.message_container); // save the media uri while the MediaScanner is creating the thumbnail // if the holder was reused, the pair is broken Uri mMediaUri = null; ViewHolder() { // disable built-in autoLink so we can add custom ones mTextViewForMessages.setAutoLinkMask(0); } public void setOnClickListenerMediaThumbnail( final String mimeType, final Uri mediaUri ) { mMediaThumbnail.setOnClickListener( new OnClickListener() { @Override public void onClick(View v) { onClickMediaIcon( mimeType, mediaUri ); } }); mMediaThumbnail.setOnLongClickListener( new OnLongClickListener() { @Override public boolean onLongClick(View v) { onLongClickMediaIcon( mimeType, mediaUri ); return false; } }); } public void resetOnClickListenerMediaThumbnail() { mMediaThumbnail.setOnClickListener( null ); } long mTimeDiff = -1; } /** * This trickery is needed in order to have clickable links that open things * in a new {@code Task} rather than in ChatSecure's {@code Task.} Thanks to @commonsware * https://stackoverflow.com/a/11417498 * */ class NewTaskUrlSpan extends ClickableSpan { private String urlString; NewTaskUrlSpan(String urlString) { this.urlString = urlString; } @Override public void onClick(View widget) { Uri uri = Uri.parse(urlString); Context context = widget.getContext(); Intent intent = new Intent(Intent.ACTION_VIEW, uri); intent.putExtra(Browser.EXTRA_APPLICATION_ID, context.getPackageName()); intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); context.startActivity(intent); } } class URLSpanConverter implements LinkifyHelper.SpanConverter<URLSpan, ClickableSpan> { @Override public NewTaskUrlSpan convert(URLSpan span) { return (new NewTaskUrlSpan(span.getURL())); } } @Override protected void onFinishInflate() { super.onFinishInflate(); mHolder = (ViewHolder)getTag(); if (mHolder == null) { mHolder = new ViewHolder(); setTag(mHolder); } } public void setLinkify(boolean linkify) { this.linkify = linkify; } public void setMessageBackground (Drawable d) { mHolder.mContainer.setBackgroundDrawable(d); } public URLSpan[] getMessageLinks() { return mHolder.mTextViewForMessages.getUrls(); } public String getLastMessage () { return lastMessage.toString(); } public void bindIncomingMessage(int id, int messageType, String address, String nickname, final String mimeType, final String body, Date date, Markup smileyRes, boolean scrolling, EncryptionState encryption, boolean showContact, int presenceStatus) { mHolder = (ViewHolder)getTag(); mHolder.mTextViewForMessages.setVisibility(View.VISIBLE); if (nickname == null) nickname = address; if (showContact && nickname != null) { lastMessage = nickname + ": " + formatMessage(body); showAvatar(address,nickname,true,presenceStatus); } else { lastMessage = formatMessage(body); showAvatar(address,nickname,true,presenceStatus); mHolder.resetOnClickListenerMediaThumbnail(); if( mimeType != null ) { mHolder.mTextViewForMessages.setVisibility(View.GONE); mHolder.mMediaThumbnail.setVisibility(View.VISIBLE); Uri mediaUri = Uri.parse( body ) ; lastMessage = ""; showMediaThumbnail(mimeType, mediaUri, id, mHolder); } else { mHolder.mMediaThumbnail.setVisibility(View.GONE); if (showContact) { String[] nickParts = nickname.split("/"); lastMessage = nickParts[nickParts.length-1] + ": " + formatMessage(body); } else { lastMessage = formatMessage(body); } } } if (lastMessage.length() > 0) { try { SpannableString spannablecontent=new SpannableString(lastMessage); EmojiManager.getInstance(getContext()).addEmoji(getContext(), spannablecontent); mHolder.mTextViewForMessages.setText(spannablecontent); } catch (IOException e) { LogCleaner.error(ImApp.LOG_TAG, "error processing message", e); } } else { mHolder.mTextViewForMessages.setText(lastMessage); } if (date != null) { CharSequence tsText = null; if (isSameDay(date,DATE_NOW)) tsText = formatTimeStamp(date,messageType,MESSAGE_TIME_FORMAT, null, encryption); else tsText = formatTimeStamp(date,messageType,MESSAGE_DATETIME_FORMAT, null, encryption); mHolder.mTextViewForTimestamp.setText(tsText); mHolder.mTextViewForTimestamp.setVisibility(View.VISIBLE); } else { mHolder.mTextViewForTimestamp.setText(""); //mHolder.mTextViewForTimestamp.setVisibility(View.GONE); } if (linkify) LinkifyHelper.addLinks(mHolder.mTextViewForMessages, new URLSpanConverter()); LinkifyHelper.addTorSafeLinks(mHolder.mTextViewForMessages); } private void showMediaThumbnail (String mimeType, Uri mediaUri, int id, ViewHolder holder) { /* Guess the MIME type in case we received a file that we can display or play*/ if (TextUtils.isEmpty(mimeType) || mimeType.startsWith("application")) { String guessed = URLConnection.guessContentTypeFromName(mediaUri.toString()); if (!TextUtils.isEmpty(guessed)) { if (TextUtils.equals(guessed, "video/3gpp")) mimeType = "audio/3gpp"; else mimeType = guessed; } } holder.setOnClickListenerMediaThumbnail(mimeType, mediaUri); holder.mMediaThumbnail.setVisibility(View.VISIBLE); holder.mTextViewForMessages.setText(lastMessage); holder.mTextViewForMessages.setVisibility(View.GONE); if( mimeType.startsWith("image/") ) { setImageThumbnail( getContext().getContentResolver(), id, holder, mediaUri ); holder.mMediaThumbnail.setBackgroundColor(Color.TRANSPARENT); // holder.mMediaThumbnail.setBackgroundColor(Color.WHITE); } else if (mimeType.startsWith("audio")) { holder.mMediaThumbnail.setImageResource(R.drawable.media_audio_play); holder.mMediaThumbnail.setBackgroundColor(Color.TRANSPARENT); } else { holder.mMediaThumbnail.setImageResource(R.drawable.ic_file); // generic file icon } holder.mContainer.setBackgroundColor(getResources().getColor(android.R.color.transparent)); } private boolean isSameDay (Date date1, Date date2) { return FMT_SAME_DAY.format(date1).equals(FMT_SAME_DAY.format(date2)); } protected String convertMediaUriToPath(Uri uri) { String path = null; String [] proj={MediaStore.Images.Media.DATA}; Cursor cursor = getContext().getContentResolver().query(uri, proj, null, null, null); if (cursor != null && (!cursor.isClosed())) { if (cursor.isBeforeFirst()) { int column_index = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DATA); cursor.moveToFirst(); path = cursor.getString(column_index); } cursor.close(); } return path; } private MediaPlayer mMediaPlayer = null; /** * @param mimeType * @param body */ @TargetApi(Build.VERSION_CODES.HONEYCOMB) protected void onClickMediaIcon(String mimeType, Uri mediaUri) { if (ChatFileStore.isVfsUri(mediaUri)) { if (mimeType.startsWith("image")) { Intent intent = new Intent(context, ImageViewActivity.class); intent.putExtra( ImageViewActivity.FILENAME, mediaUri.getPath()); context.startActivity(intent); return; } if (mimeType.startsWith("audio")) { new AudioPlayer(getContext(), mediaUri.getPath(), mimeType).play(); return; } return; } else { String body = convertMediaUriToPath(mediaUri); if (body == null) body = new File(mediaUri.getPath()).getAbsolutePath(); if (mimeType.startsWith("audio") || (body.endsWith("3gp")||body.endsWith("3gpp")||body.endsWith("amr"))) { if (mMediaPlayer != null) mMediaPlayer.release(); try { mMediaPlayer = new MediaPlayer(); mMediaPlayer.setAudioStreamType(AudioManager.STREAM_MUSIC); mMediaPlayer.setDataSource(body); mMediaPlayer.prepare(); mMediaPlayer.start(); return; } catch (IOException e) { Log.e(ImApp.LOG_TAG,"error playing audio: " + body,e); } } Intent intent = new Intent(Intent.ACTION_VIEW); intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); if (Build.VERSION.SDK_INT >= 11) intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK); //set a general mime type not specific intent.setDataAndType(Uri.parse( body ), mimeType); Context context = getContext().getApplicationContext(); if (isIntentAvailable(context,intent)) { context.startActivity(intent); } else { Toast.makeText(getContext(), R.string.there_is_no_viewer_available_for_this_file_format, Toast.LENGTH_LONG).show(); } } } protected void onLongClickMediaIcon(final String mimeType, final Uri mediaUri) { final java.io.File exportPath = ChatFileStore.exportPath(mimeType, mediaUri); new AlertDialog.Builder(context) .setTitle(context.getString(R.string.export_media)) .setMessage(context.getString(R.string.export_media_file_to, exportPath.getAbsolutePath())) .setPositiveButton(R.string.export, new DialogInterface.OnClickListener() { @Override public void onClick(DialogInterface dialog, int whichButton) { try { ChatFileStore.exportContent(mimeType, mediaUri, exportPath); Intent shareIntent = new Intent(); shareIntent.setAction(Intent.ACTION_SEND); shareIntent.putExtra(Intent.EXTRA_STREAM, Uri.fromFile(exportPath)); shareIntent.setType(mimeType); context.startActivity(Intent.createChooser(shareIntent, getResources().getText(R.string.export_media))); } catch (IOException e) { Toast.makeText(getContext(), "Export Failed " + e.getMessage(), Toast.LENGTH_LONG).show(); e.printStackTrace(); } return; } }) .setNegativeButton(R.string.cancel, new DialogInterface.OnClickListener() { @Override public void onClick(DialogInterface dialog, int whichButton) { return; } }) .create().show(); } public static boolean isIntentAvailable(Context context, Intent intent) { final PackageManager packageManager = context.getPackageManager(); List<ResolveInfo> list = packageManager.queryIntentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY); return list.size() > 0; } /** * @param contentResolver * @param id * @param aHolder * @param mediaUri */ private void setImageThumbnail(final ContentResolver contentResolver, final int id, final ViewHolder aHolder, final Uri mediaUri) { // pair this holder to the uri. if the holder is recycled, the pairing is broken aHolder.mMediaUri = mediaUri; // if a content uri - already scanned setThumbnail(contentResolver, aHolder, mediaUri); } /** * @param contentResolver * @param aHolder * @param uri */ private void setThumbnail(final ContentResolver contentResolver, final ViewHolder aHolder, final Uri uri) { new AsyncTask<String, Void, Bitmap>() { @Override protected Bitmap doInBackground(String... params) { Bitmap result = mBitmapCache.get(uri.toString()); if (result == null) return getThumbnail( contentResolver, uri ); else return result; } @Override protected void onPostExecute(Bitmap result) { if (uri != null && result != null) { mBitmapCache.put(uri.toString(), result); // confirm the holder is still paired to this uri if( ! uri.equals( aHolder.mMediaUri ) ) { return ; } // set the thumbnail aHolder.mMediaThumbnail.setImageBitmap(result); } } }.execute(); } public final static int THUMBNAIL_SIZE_DEFAULT = 400; public static Bitmap getThumbnail(ContentResolver cr, Uri uri) { // Log.e( MessageView.class.getSimpleName(), "getThumbnail uri:" + uri); if (ChatFileStore.isVfsUri(uri)) { return ChatFileStore.getThumbnailVfs(uri, THUMBNAIL_SIZE_DEFAULT); } return getThumbnailFile(cr, uri, THUMBNAIL_SIZE_DEFAULT); } public static Bitmap getThumbnailFile(ContentResolver cr, Uri uri, int thumbnailSize) { try { BitmapFactory.Options options = new BitmapFactory.Options(); options.inJustDecodeBounds = true; options.inInputShareable = true; options.inPurgeable = true; InputStream is = cr.openInputStream(uri); BitmapFactory.decodeStream(is, null, options); if ((options.outWidth == -1) || (options.outHeight == -1)) return null; int originalSize = (options.outHeight > options.outWidth) ? options.outHeight : options.outWidth; BitmapFactory.Options opts = new BitmapFactory.Options(); opts.inSampleSize = originalSize / thumbnailSize; is = cr.openInputStream(uri); Bitmap scaledBitmap = BitmapFactory.decodeStream(is, null, options); return scaledBitmap; } catch (Exception e) { Log.d(ImApp.LOG_TAG,"could not getThumbnailFile",e); return null; } } private String formatMessage (String body) { if (body != null) return android.text.Html.fromHtml(body).toString(); else return null; } public void bindOutgoingMessage(int id, int messageType, String address, final String mimeType, final String body, Date date, Markup smileyRes, boolean scrolling, DeliveryState delivery, EncryptionState encryption) { mHolder = (ViewHolder)getTag(); mHolder.mTextViewForMessages.setVisibility(View.VISIBLE); mHolder.resetOnClickListenerMediaThumbnail(); if( mimeType != null ) { lastMessage = ""; Uri mediaUri = Uri.parse( body ) ; showMediaThumbnail(mimeType, mediaUri, id, mHolder); mHolder.mTextViewForMessages.setVisibility(View.GONE); mHolder.mMediaThumbnail.setVisibility(View.VISIBLE); } else { mHolder.mMediaThumbnail.setVisibility(View.GONE); lastMessage = body;//formatMessage(body); try { SpannableString spannablecontent=new SpannableString(lastMessage); EmojiManager.getInstance(getContext()).addEmoji(getContext(), spannablecontent); mHolder.mTextViewForMessages.setText(spannablecontent); } catch (IOException e) { // TODO Auto-generated catch block e.printStackTrace(); } } if (date != null) { CharSequence tsText = null; if (isSameDay(date,DATE_NOW)) tsText = formatTimeStamp(date,messageType, MESSAGE_TIME_FORMAT, delivery, encryption); else tsText = formatTimeStamp(date,messageType, MESSAGE_DATETIME_FORMAT, delivery, encryption); mHolder.mTextViewForTimestamp.setText(tsText); mHolder.mTextViewForTimestamp.setVisibility(View.VISIBLE); } else { mHolder.mTextViewForTimestamp.setText(""); } if (linkify) LinkifyHelper.addLinks(mHolder.mTextViewForMessages, new URLSpanConverter()); LinkifyHelper.addTorSafeLinks(mHolder.mTextViewForMessages); } private void showAvatar (String address, String nickname, boolean isLeft, int presenceStatus) { if (mHolder.mAvatar == null) return; mHolder.mAvatar.setVisibility(View.GONE); if (address != null && isLeft) { RoundedAvatarDrawable avatar = null; try { avatar = DatabaseUtils.getAvatarFromAddress(this.getContext().getContentResolver(),XmppAddress.stripResource(address), ImApp.DEFAULT_AVATAR_WIDTH,ImApp.DEFAULT_AVATAR_HEIGHT);} catch (Exception e){} if (avatar != null) { mHolder.mAvatar.setVisibility(View.VISIBLE); mHolder.mAvatar.setImageDrawable(avatar); setAvatarBorder(presenceStatus, avatar); } else { int color = getAvatarBorder(presenceStatus); int padding = 16; LetterAvatar lavatar = new LetterAvatar(getContext(), color, nickname.substring(0,1).toUpperCase(), padding); mHolder.mAvatar.setVisibility(View.VISIBLE); mHolder.mAvatar.setImageDrawable(lavatar); } } } public int getAvatarBorder(int status) { switch (status) { case Presence.AVAILABLE: return (getResources().getColor(R.color.holo_green_light)); case Presence.IDLE: return (getResources().getColor(R.color.holo_green_dark)); case Presence.AWAY: return (getResources().getColor(R.color.holo_orange_light)); case Presence.DO_NOT_DISTURB: return(getResources().getColor(R.color.holo_red_dark)); case Presence.OFFLINE: return(getResources().getColor(R.color.holo_grey_dark)); default: } return Color.TRANSPARENT; } public void bindPresenceMessage(String contact, int type, boolean isGroupChat, boolean scrolling) { mHolder = (ViewHolder)getTag(); CharSequence message = formatPresenceUpdates(contact, type, isGroupChat, scrolling); mHolder.mTextViewForMessages.setText(message); // mHolder.mTextViewForMessages.setTextColor(getResources().getColor(R.color.chat_msg_presence)); } public void bindErrorMessage(int errCode) { mHolder = (ViewHolder)getTag(); mHolder.mTextViewForMessages.setText(R.string.msg_sent_failed); mHolder.mTextViewForMessages.setTextColor(getResources().getColor(R.color.error)); } private SpannableString formatTimeStamp(Date date, int messageType, DateFormat format, MessageView.DeliveryState delivery, EncryptionState encryptionState) { StringBuilder deliveryText = new StringBuilder(); deliveryText.append(format.format(date)); deliveryText.append(' '); if (delivery != null) { //this is for delivery if (delivery == DeliveryState.DELIVERED) { deliveryText.append(DELIVERED_SUCCESS); } else if (delivery == DeliveryState.UNDELIVERED) { deliveryText.append(DELIVERED_FAIL); } } if (messageType != Imps.MessageType.POSTPONED) deliveryText.append(DELIVERED_SUCCESS);//this is for sent, so we know show 2 checks like WhatsApp! SpannableString spanText = null; if (encryptionState == EncryptionState.ENCRYPTED) { deliveryText.append('X'); spanText = new SpannableString(deliveryText.toString()); int len = spanText.length(); spanText.setSpan(new ImageSpan(getContext(), R.drawable.lock16), len-1,len,Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); } else if (encryptionState == EncryptionState.ENCRYPTED_AND_VERIFIED) { deliveryText.append('X'); spanText = new SpannableString(deliveryText.toString()); int len = spanText.length(); spanText.setSpan(new ImageSpan(getContext(), R.drawable.lock16), len-1,len,Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); } else { spanText = new SpannableString(deliveryText.toString()); int len = spanText.length(); } // spanText.setSpan(new StyleSpan(Typeface.SANS_SERIF), 0, len, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); // spanText.setSpan(new RelativeSizeSpan(0.8f), 0, len, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); // spanText.setSpan(new ForegroundColorSpan(R.color.soft_grey), // 0, len, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); return spanText; } private CharSequence formatPresenceUpdates(String contact, int type, boolean isGroupChat, boolean scrolling) { String body; Resources resources =getResources(); switch (type) { case Imps.MessageType.PRESENCE_AVAILABLE: body = resources.getString(isGroupChat ? R.string.contact_joined : R.string.contact_online, contact); break; case Imps.MessageType.PRESENCE_AWAY: body = resources.getString(R.string.contact_away, contact); break; case Imps.MessageType.PRESENCE_DND: body = resources.getString(R.string.contact_busy, contact); break; case Imps.MessageType.PRESENCE_UNAVAILABLE: body = resources.getString(isGroupChat ? R.string.contact_left : R.string.contact_offline, contact); break; default: return null; } if (scrolling) { return body; } else { SpannableString spanText = new SpannableString(body); int len = spanText.length(); spanText.setSpan(new StyleSpan(Typeface.ITALIC), 0, len, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); spanText.setSpan(new RelativeSizeSpan((float) 0.8), 0, len, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); return spanText; } } public void setAvatarBorder(int status, RoundedAvatarDrawable avatar) { switch (status) { case Presence.AVAILABLE: avatar.setBorderColor(getResources().getColor(R.color.holo_green_light)); avatar.setAlpha(255); break; case Presence.IDLE: avatar.setBorderColor(getResources().getColor(R.color.holo_green_dark)); avatar.setAlpha(255); break; case Presence.AWAY: avatar.setBorderColor(getResources().getColor(R.color.holo_orange_light)); avatar.setAlpha(255); break; case Presence.DO_NOT_DISTURB: avatar.setBorderColor(getResources().getColor(R.color.holo_red_dark)); avatar.setAlpha(255); break; case Presence.OFFLINE: avatar.setBorderColor(getResources().getColor(R.color.holo_grey_light)); avatar.setAlpha(150); break; default: } } }