/* * Kontalk Android client * Copyright (C) 2017 Kontalk Devteam <devteam@kontalk.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.kontalk.util; import java.io.File; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; import java.security.GeneralSecurityException; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.util.HashMap; import java.util.Locale; import java.util.Map; import java.util.TimeZone; import com.afollestad.materialdialogs.MaterialDialog; import org.jivesoftware.smack.util.StringUtils; import org.spongycastle.openpgp.PGPException; import android.content.ContentValues; import android.content.Context; import android.content.res.Resources; import android.database.Cursor; import android.graphics.Bitmap; import android.graphics.Canvas; import android.graphics.Color; import android.graphics.Typeface; import android.graphics.drawable.BitmapDrawable; import android.graphics.drawable.Drawable; import android.text.Editable; import android.text.Spannable; import android.text.SpannableStringBuilder; import android.text.format.DateUtils; import android.text.format.Time; import android.text.style.ForegroundColorSpan; import android.text.style.StyleSpan; import org.kontalk.Kontalk; import org.kontalk.R; import org.kontalk.client.EndpointServer; import org.kontalk.crypto.Coder; import org.kontalk.crypto.PersonalKey; import org.kontalk.message.AttachmentComponent; import org.kontalk.message.AudioComponent; import org.kontalk.message.CompositeMessage; import org.kontalk.message.DefaultAttachmentComponent; import org.kontalk.message.GroupCommandComponent; import org.kontalk.message.GroupComponent; import org.kontalk.message.ImageComponent; import org.kontalk.message.RawComponent; import org.kontalk.message.TextComponent; import org.kontalk.message.VCardComponent; import org.kontalk.provider.Keyring; import org.kontalk.provider.MyMessages.Messages; public final class MessageUtils { // TODO convert these to XML styles // these spans can't be used more than once in a Spanned because it's the same object reference!!! @Deprecated public static final StyleSpan STYLE_BOLD = new StyleSpan(Typeface.BOLD); @Deprecated private static final ForegroundColorSpan STYLE_RED = new ForegroundColorSpan(Color.RED); @Deprecated private static final ForegroundColorSpan STYLE_GREEN = new ForegroundColorSpan(Color.rgb(0, 0xAA, 0)); /** For ascii to emoji converter. */ private static Map<String, String> sEmojiConverterMap = new HashMap<>(); static { sEmojiConverterMap.put(":)", "\uD83D\uDE42"); sEmojiConverterMap.put(":-)", "\uD83D\uDE42"); sEmojiConverterMap.put(":(", "\uD83D\uDE41"); sEmojiConverterMap.put(":-(", "\uD83D\uDE41"); sEmojiConverterMap.put(":'(", "\uD83D\uDE22"); } public static final int MILLISECONDS_IN_DAY = 86400000; private MessageUtils() {} public static CharSequence formatRelativeTimeSpan(Context context, long when) { int format_flags = DateUtils.FORMAT_NO_NOON_MIDNIGHT | DateUtils.FORMAT_ABBREV_ALL | DateUtils.FORMAT_CAP_AMPM; return DateUtils.getRelativeDateTimeString(context, when, DateUtils.SECOND_IN_MILLIS, DateUtils.DAY_IN_MILLIS * 2, format_flags); } public static String formatDateString(Context context, long when) { Time then = new Time(); then.set(when); Time now = new Time(); now.setToNow(); // Basic settings for formatDateTime() we want for all cases. int format_flags = DateUtils.FORMAT_NO_NOON_MIDNIGHT | DateUtils.FORMAT_SHOW_DATE | DateUtils.FORMAT_CAP_AMPM; // If the message is from a different year, show the date and year. if (then.year != now.year) { format_flags |= DateUtils.FORMAT_SHOW_YEAR | DateUtils.FORMAT_SHOW_DATE; } else if (then.yearDay != now.yearDay) { // If it is from a different day than today, show only the date. format_flags |= DateUtils.FORMAT_SHOW_DATE; } return DateUtils.formatDateTime(context, when, format_flags); } public static String formatTimeStampString(Context context, long when) { return formatTimeStampString(context, when, false); } public static String formatTimeStampString(Context context, long when, boolean fullFormat) { Time then = new Time(); then.set(when); Time now = new Time(); now.setToNow(); // Basic settings for formatDateTime() we want for all cases. int format_flags = DateUtils.FORMAT_NO_NOON_MIDNIGHT | DateUtils.FORMAT_ABBREV_ALL | DateUtils.FORMAT_CAP_AMPM; // If the message is from a different year, show the date and year. if (then.year != now.year) { format_flags |= DateUtils.FORMAT_SHOW_YEAR | DateUtils.FORMAT_SHOW_DATE; } else if (then.yearDay != now.yearDay) { // If it is from a different day than today, show only the date. format_flags |= DateUtils.FORMAT_SHOW_DATE; } else { // Otherwise, if the message is from today, show the time. format_flags |= DateUtils.FORMAT_SHOW_TIME; } // If the caller has asked for full details, make sure to show the date // and time no matter what we've determined above (but still make showing // the year only happen if it is a different year from today). if (fullFormat) { format_flags |= (DateUtils.FORMAT_SHOW_DATE | DateUtils.FORMAT_SHOW_TIME); } return DateUtils.formatDateTime(context, when, format_flags); } public static String formatTimeString(Context context, long when) { Time then = new Time(); then.set(when); Time now = new Time(); now.setToNow(); // Basic settings for formatDateTime() we want for all cases. int format_flags = DateUtils.FORMAT_NO_NOON_MIDNIGHT | DateUtils.FORMAT_ABBREV_ALL | DateUtils.FORMAT_CAP_AMPM | DateUtils.FORMAT_SHOW_TIME; return DateUtils.formatDateTime(context, when, format_flags); } public static boolean isSameDate(long a, long b) { TimeZone tm = TimeZone.getDefault(); a += tm.getOffset(a); b += tm.getOffset(b); return (a / MILLISECONDS_IN_DAY) == (b / MILLISECONDS_IN_DAY); } public static long getMessageTimestamp(CompositeMessage msg) { long serverTime = msg.getServerTimestamp(); return serverTime > 0 ? serverTime : msg.getTimestamp(); } public static long getMessageTimestamp(Cursor c) { long serverTime = c.getLong(CompositeMessage.COLUMN_SERVER_TIMESTAMP); return serverTime > 0 ? serverTime : c.getLong(CompositeMessage.COLUMN_TIMESTAMP); } public static String getMessagePeer(CompositeMessage msg) { return msg.getDirection() == Messages.DIRECTION_IN ? msg.getSender(true) : msg.getRecipients().get(0); } public static String getMessagePeer(Cursor c) { return c.getString(CompositeMessage.COLUMN_PEER); } public static int getMessageDirection(Cursor c) { return c.getInt(CompositeMessage.COLUMN_DIRECTION); } public static String bytesToHex(byte[] data) { StringBuilder buf = new StringBuilder(); for (int i = 0; i < data.length; i++) { int halfbyte = (data[i] >>> 4) & 0x0F; int two_halfs = 0; do { if ((0 <= halfbyte) && (halfbyte <= 9)) buf.append((char) ('0' + halfbyte)); else buf.append((char) ('a' + (halfbyte - 10))); halfbyte = data[i] & 0x0F; } while(two_halfs++ < 1); } return buf.toString(); } /** TODO move somewhere else */ public static String sha1(String text) { try { MessageDigest md; md = MessageDigest.getInstance("SHA-1"); md.update(text.getBytes(), 0, text.length()); byte[] sha1hash = md.digest(); return bytesToHex(sha1hash); } catch (NoSuchAlgorithmException e) { // no SHA-1?? WWWHHHHAAAAAATTTT???!?!?!?!?! throw new RuntimeException("no SHA-1 available. What the crap of a device do you have?"); } } public static ByteArrayInOutStream readFully(InputStream in, long maxSize) throws IOException { byte[] buf = new byte[1024]; ByteArrayInOutStream out = new ByteArrayInOutStream(); int l; while ((l = in.read(buf, 0, 1024)) > 0 && out.size() < maxSize) out.write(buf, 0, l); return out; } public static CharSequence getFileInfoMessage(Context context, CompositeMessage msg, String decodedPeer) { StringBuilder details = new StringBuilder(); Resources res = context.getResources(); int direction = msg.getDirection(); // To/From if (direction == Messages.DIRECTION_OUT) details.append(res.getString(R.string.to_address_label)); else details.append(res.getString(R.string.from_label)); details.append(decodedPeer); // Message type details.append('\n'); details.append(res.getString(R.string.message_type_label)); int resId = R.string.text_message; AttachmentComponent attachment = msg .getComponent(AttachmentComponent.class); if (attachment != null) { if (attachment instanceof ImageComponent) resId = R.string.image_message; else if (attachment instanceof VCardComponent) resId = R.string.vcard_message; else if (attachment instanceof AudioComponent) resId = R.string.audio_message; } details.append(res.getString(resId)); // Message length details.append('\n'); details.append(res.getString(R.string.size_label)); long length = -1; if (attachment != null) { // attachment length length = attachment.getLength(); } else { // text content length (if found) TextComponent txt = msg .getComponent(TextComponent.class); if (txt != null) length = txt.getLength(); } // otherwise unknown length details.append(length >= 0 ? humanReadableByteCount(length, false) : res.getString(R.string.size_unknown)); return details.toString(); } public static void showMessageDetails(Context context, CompositeMessage msg, String decodedPeer, String decodedName) { CharSequence messageDetails = MessageUtils.getMessageDetails( context, msg, decodedPeer, decodedName); new MaterialDialog.Builder(context) .title(R.string.title_message_details) .content(messageDetails) .cancelable(true) .show(); } private static CharSequence getMessageDetails(Context context, CompositeMessage msg, String decodedPeer, String decodedName) { SpannableStringBuilder details = new SpannableStringBuilder(); Resources res = context.getResources(); int direction = msg.getDirection(); // Message type details.append(res.getString(R.string.message_type_label)); int resId = R.string.text_message; AttachmentComponent attachment = msg .getComponent(AttachmentComponent.class); if (attachment != null) { if (attachment instanceof ImageComponent) resId = R.string.image_message; else if (attachment instanceof VCardComponent) resId = R.string.vcard_message; else if (attachment instanceof AudioComponent) resId = R.string.audio_message; } details.append(res.getString(resId)); // To/From if (!msg.hasComponent(GroupComponent.class) || direction == Messages.DIRECTION_IN) { details.append('\n'); if (direction == Messages.DIRECTION_OUT) details.append(res.getString(R.string.to_address_label)); else details.append(res.getString(R.string.from_label)); String displayName = (decodedName != null) ? decodedName + "\n<" + decodedPeer + ">" : decodedPeer; details.append(displayName); } // Encrypted int securityFlags = msg.getSecurityFlags(); details.append('\n'); details.append(res.getString(R.string.encrypted_label)); if (securityFlags != Coder.SECURITY_CLEARTEXT) { details.append(res.getString(R.string.yes)); // Security flags (verification status) details.append('\n'); details.append(res.getString(R.string.security_label)); boolean securityError = Coder.isError(securityFlags); // save start position for spans int startPos = details.length(); if (securityError) { details.append(res.getString(R.string.security_status_bad)); int stringId = 0; if ((securityFlags & Coder.SECURITY_ERROR_INVALID_SIGNATURE) != 0) { stringId = R.string.security_error_invalid_signature; } else if ((securityFlags & Coder.SECURITY_ERROR_INVALID_SENDER) != 0) { stringId = R.string.security_error_invalid_sender; } else if ((securityFlags & Coder.SECURITY_ERROR_INVALID_RECIPIENT) != 0) { stringId = R.string.security_error_invalid_recipient; } else if ((securityFlags & Coder.SECURITY_ERROR_INVALID_TIMESTAMP) != 0) { stringId = R.string.security_error_invalid_timestamp; } else if ((securityFlags & Coder.SECURITY_ERROR_INVALID_DATA) != 0) { stringId = R.string.security_error_invalid_data; } else if ((securityFlags & Coder.SECURITY_ERROR_DECRYPT_FAILED) != 0) { stringId = R.string.security_error_decrypt_failed; } else if ((securityFlags & Coder.SECURITY_ERROR_INTEGRITY_CHECK) != 0) { stringId = R.string.security_error_integrity_check; } else if ((securityFlags & Coder.SECURITY_ERROR_PUBLIC_KEY_UNAVAILABLE) != 0) { stringId = R.string.security_error_public_key_unavail; } if (stringId > 0) details.append(res.getString(stringId)); } else { details.append(res.getString(R.string.security_status_good)); } details.setSpan(STYLE_BOLD, startPos, details.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); details.setSpan(securityError ? STYLE_RED : STYLE_GREEN, startPos, details.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); } else { CharSequence noText = res.getString(R.string.no); int startPos = details.length(); details.append(noText); details.setSpan(STYLE_BOLD, startPos, details.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); details.setSpan(STYLE_RED, startPos, details.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); } // Message length details.append('\n'); details.append(res.getString(R.string.size_label)); long length = -1; if (attachment != null) { // attachment length length = attachment.getLength(); } else { // text content length (if found) TextComponent txt = msg .getComponent(TextComponent.class); if (txt != null) length = txt.getLength(); } // otherwise unknown length details.append(length >= 0 ? humanReadableByteCount(length, false) : res.getString(R.string.size_unknown)); // Date int status = msg.getStatus(); // incoming message if (direction == Messages.DIRECTION_IN) { details.append('\n'); appendTimestamp(context, details, res.getString(R.string.received_label), msg.getTimestamp(), true); details.append('\n'); appendTimestamp(context, details, res.getString(R.string.sent_label), msg.getServerTimestamp(), true); } // outgoing messages else { long timestamp = 0; switch (status) { case Messages.STATUS_NOTACCEPTED: resId = R.string.refused_label; timestamp = msg.getStatusChanged(); break; case Messages.STATUS_ERROR: resId = R.string.error_label; timestamp = msg.getStatusChanged(); break; case Messages.STATUS_SENDING: resId = R.string.sending_label; timestamp = msg.getTimestamp(); break; case Messages.STATUS_SENT: case Messages.STATUS_RECEIVED: case Messages.STATUS_NOTDELIVERED: resId = R.string.sent_label; long serverTime = msg.getServerTimestamp(); timestamp = serverTime > 0 ? serverTime : msg.getTimestamp(); break; default: resId = -1; break; } if (resId > 0) { details.append('\n'); appendTimestamp(context, details, res.getString(resId), timestamp, true); } // print out received if any if (status == Messages.STATUS_RECEIVED) { details.append('\n'); appendTimestamp(context, details, res.getString(R.string.delivered_label), msg.getStatusChanged(), true); } else if (status == Messages.STATUS_NOTDELIVERED) { details.append('\n'); appendTimestamp(context, details, res.getString(R.string.notdelivered_label), msg.getStatusChanged(), true); } } // TODO Error code/reason return details; } private static void appendTimestamp(Context context, Appendable details, String label, long time, boolean fullFormat) { try { details.append(label); details.append(MessageUtils.formatTimeStampString(context, time, fullFormat)); } catch (IOException e) { // ignored } } /** * Cool handy method to format a size in bytes in some human readable form. * http://stackoverflow.com/questions/3758606/how-to-convert-byte-size-into-human-readable-format-in-java */ public static String humanReadableByteCount(long bytes, boolean si) { int unit = si ? 1000 : 1024; if (bytes < unit) return bytes + " B"; int exp = (int) (Math.log(bytes) / Math.log(unit)); String pre = (si ? "kMGTPE" : "KMGTPE").charAt(exp-1) + (si ? "i" : ""); return String.format(Locale.US, "%.1f %sB", bytes / Math.pow(unit, exp), pre); } /** Converts a Kontalk user id to a JID. */ public static String toJID(String userId, String network) { StringBuilder jid = new StringBuilder(); // this is for avoiding a useless call to subSequence int l = userId.length(); if (l > CompositeMessage.USERID_LENGTH) jid.append(userId.subSequence(0, CompositeMessage.USERID_LENGTH)); else jid.append(userId); jid.append('@'); jid.append(network); if (l > CompositeMessage.USERID_LENGTH) jid.append(userId.subSequence(CompositeMessage.USERID_LENGTH, l)); return jid.toString(); } public static boolean compareUserId(String a, String b, boolean full) throws IllegalArgumentException { int aLen = a.length(); int bLen = b.length(); // validate :) if ((aLen != CompositeMessage.USERID_LENGTH && aLen != CompositeMessage.USERID_LENGTH_RESOURCE) || (bLen != CompositeMessage.USERID_LENGTH && bLen != CompositeMessage.USERID_LENGTH_RESOURCE) || a.contains("@") || b.contains("@")) throw new IllegalArgumentException("either one or both parameters are not valid user id."); if (full) // full comparison - just equals return a.equalsIgnoreCase(b); else // user id comparison return a.substring(0, CompositeMessage.USERID_LENGTH) .equalsIgnoreCase(b.substring(0, CompositeMessage.USERID_LENGTH)); } public static String messageId() { return StringUtils.randomString(30); } public static File encryptFile(Context context, InputStream in, String[] users) throws GeneralSecurityException, IOException, PGPException { PersonalKey key = Kontalk.get(context).getPersonalKey(); EndpointServer server = Preferences.getEndpointServer(context); Coder coder = Keyring.getEncryptCoder(context, server, key, users); // create a temporary file to store encrypted data File temp = File.createTempFile("media", null, context.getCacheDir()); FileOutputStream out = new FileOutputStream(temp); coder.encryptFile(in, out); // close encrypted file out.close(); return temp; } /** Fills in a {@link ContentValues} object from the given message. */ public static void fillContentValues(ContentValues values, CompositeMessage msg) { byte[] content = null; String mime = null; boolean checkAttachment; // message still encrypted - use whole body of raw component if (msg.isEncrypted()) { RawComponent raw = msg.getComponent(RawComponent.class); // if raw it's null it's a bug content = raw.getContent(); mime = null; checkAttachment = false; } else { GroupCommandComponent group = msg.getComponent(GroupCommandComponent.class); if (group != null) { content = group.getTextContent().getBytes(); mime = GroupCommandComponent.MIME_TYPE; } else { TextComponent txt = msg.getComponent(TextComponent.class); if (txt != null) { content = txt.getContent().getBytes(); mime = TextComponent.MIME_TYPE; } } checkAttachment = true; } // selective components detection if (checkAttachment) { @SuppressWarnings("unchecked") Class<AttachmentComponent>[] tryComponents = new Class[] { ImageComponent.class, VCardComponent.class, AudioComponent.class, DefaultAttachmentComponent.class, }; for (Class<AttachmentComponent> klass : tryComponents) { AttachmentComponent att = msg.getComponent(klass); if (att != null) { values.put(Messages.ATTACHMENT_MIME, att.getMime()); values.put(Messages.ATTACHMENT_FETCH_URL, att.getFetchUrl()); values.put(Messages.ATTACHMENT_LENGTH, att.getLength()); values.put(Messages.ATTACHMENT_ENCRYPTED, att.isEncrypted()); values.put(Messages.ATTACHMENT_SECURITY_FLAGS, att.getSecurityFlags()); File previewFile = att.getPreviewFile(); if (previewFile != null) values.put(Messages.ATTACHMENT_PREVIEW_PATH, previewFile.getAbsolutePath()); // only one attachment is supported break; } } } values.put(Messages.BODY_CONTENT, content); values.put(Messages.BODY_LENGTH, content != null ? content.length : 0); values.put(Messages.BODY_MIME, mime); values.put(Messages.ENCRYPTED, msg.isEncrypted()); values.put(Messages.SECURITY_FLAGS, msg.getSecurityFlags()); values.put(Messages.SERVER_TIMESTAMP, msg.getServerTimestamp()); } public static Bitmap drawableToBitmap(Drawable drawable) { Bitmap bitmap; if (drawable instanceof BitmapDrawable) { BitmapDrawable bitmapDrawable = (BitmapDrawable) drawable; if(bitmapDrawable.getBitmap() != null) { return bitmapDrawable.getBitmap(); } } if(drawable.getIntrinsicWidth() <= 0 || drawable.getIntrinsicHeight() <= 0) { bitmap = Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888); // Single color bitmap will be created of 1x1 pixel } else { bitmap = Bitmap.createBitmap(drawable.getIntrinsicWidth(), drawable.getIntrinsicHeight(), Bitmap.Config.ARGB_8888); } Canvas canvas = new Canvas(bitmap); drawable.setBounds(0, 0, canvas.getWidth(), canvas.getHeight()); drawable.draw(canvas); return bitmap; } public static String toString(byte[] text) { return new String(trimNul(text)); } public static byte[] trimNul(byte[] text) { if (text.length > 0 && text[text.length - 1] == '\0') { byte[] nulBody = new byte[text.length - 1]; System.arraycopy(text, 0, nulBody, 0, nulBody.length); text = nulBody; } return text; } public static void convertSmileys(Editable input) { for (String key : sEmojiConverterMap.keySet()) { replaceEditable(input, key, sEmojiConverterMap.get(key)); } } private static void replaceEditable(Editable text, String in, String out) { int position = text.toString().indexOf(in); if (position >= 0) { text.replace(position, position + in.length(), out); } } public static boolean sendEncrypted(Context context, boolean chatEncryptionEnabled) { return Preferences.getEncryptionEnabled(context) && chatEncryptionEnabled; } }