/* * 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.message; import java.io.File; import java.lang.reflect.Method; import java.util.ArrayList; import java.util.Date; import java.util.List; import org.jxmpp.util.XmppStringUtils; import android.content.AsyncQueryHandler; import android.content.ContentUris; import android.content.Context; import android.database.Cursor; import android.net.Uri; import android.os.Parcelable; import android.support.annotation.NonNull; import org.kontalk.authenticator.Authenticator; import org.kontalk.client.GroupExtension; import org.kontalk.data.GroupInfo; import org.kontalk.provider.MessagesProviderUtils; import org.kontalk.provider.MyMessages.Groups; import org.kontalk.provider.MyMessages.Messages; import org.kontalk.provider.MyMessages.Threads.Conversations; import org.kontalk.util.MediaStorage; import org.kontalk.util.MessageUtils; /** * A composite message, made up of one or more {@link MessageComponent}. * TODO make it a {@link Parcelable} * @author Daniele Ricci * @version 1.0 */ public class CompositeMessage { public static final int USERID_LENGTH = 40; public static final int USERID_LENGTH_RESOURCE = 48; @SuppressWarnings("unchecked") private static final Class<AttachmentComponent>[] TRY_COMPONENTS = new Class[] { ImageComponent.class, AudioComponent.class, VCardComponent.class, }; private static final String[] MESSAGE_LIST_PROJECTION = { Messages._ID, Messages.MESSAGE_ID, Messages.PEER, Messages.DIRECTION, Messages.TIMESTAMP, Messages.SERVER_TIMESTAMP, Messages.STATUS_CHANGED, Messages.STATUS, Messages.ENCRYPTED, Messages.SECURITY_FLAGS, Messages.BODY_MIME, Messages.BODY_CONTENT, Messages.BODY_LENGTH, Messages.ATTACHMENT_MIME, Messages.ATTACHMENT_PREVIEW_PATH, Messages.ATTACHMENT_LOCAL_URI, Messages.ATTACHMENT_FETCH_URL, Messages.ATTACHMENT_LENGTH, Messages.ATTACHMENT_ENCRYPTED, Messages.ATTACHMENT_SECURITY_FLAGS, Groups.GROUP_JID, Groups.SUBJECT, Groups.GROUP_TYPE, Groups.MEMBERSHIP, }; // these indexes matches MESSAGE_LIST_PROJECTION public static final int COLUMN_ID = 0; public static final int COLUMN_MESSAGE_ID = 1; public static final int COLUMN_PEER = 2; public static final int COLUMN_DIRECTION = 3; public static final int COLUMN_TIMESTAMP = 4; public static final int COLUMN_SERVER_TIMESTAMP = 5; public static final int COLUMN_STATUS_CHANGED = 6; public static final int COLUMN_STATUS = 7; public static final int COLUMN_ENCRYPTED = 8; public static final int COLUMN_SECURITY = 9; public static final int COLUMN_BODY_MIME = 10; public static final int COLUMN_BODY_CONTENT = 11; public static final int COLUMN_BODY_LENGTH = 12; public static final int COLUMN_ATTACHMENT_MIME = 13; public static final int COLUMN_ATTACHMENT_PREVIEW_PATH = 14; public static final int COLUMN_ATTACHMENT_LOCAL_URI = 15; public static final int COLUMN_ATTACHMENT_FETCH_URL = 16; public static final int COLUMN_ATTACHMENT_LENGTH = 17; public static final int COLUMN_ATTACHMENT_ENCRYPTED = 18; public static final int COLUMN_ATTACHMENT_SECURITY_FLAGS = 19; public static final int COLUMN_GROUP_JID = 20; public static final int COLUMN_GROUP_SUBJECT = 21; public static final int COLUMN_GROUP_TYPE = 22; public static final int COLUMN_GROUP_MEMBERSHIP = 23; public static final String MSG_ID = "org.kontalk.message.id"; public static final String MSG_SERVER_ID = "org.kontalk.message.serverId"; public static final String MSG_SENDER = "org.kontalk.message.sender"; public static final String MSG_MIME = "org.kontalk.message.mime"; public static final String MSG_CONTENT = "org.kontalk.message.content"; public static final String MSG_RECIPIENTS = "org.kontalk.message.recipients"; public static final String MSG_GROUP = "org.kontalk.message.group"; public static final String MSG_TIMESTAMP = "org.kontalk.message.timestamp"; public static final String MSG_ENCRYPTED = "org.kontalk.message.encrypted"; public static final String MSG_COMPRESS = "org.kontalk.message.compress"; private static final int SUFFIX_LENGTH = "Component".length(); protected Context mContext; protected long mDatabaseId; protected String mId; protected String mSender; protected long mTimestamp; protected long mServerTimestamp; protected long mStatusChanged; protected int mStatus; protected boolean mEncrypted; protected int mSecurityFlags; /** * Recipients (outgoing) - will contain one element for incoming */ protected List<String> mRecipients; /** Message components. */ protected List<MessageComponent<?>> mComponents; /** Creates a new composite message. */ public CompositeMessage(Context context, String id, long timestamp, String sender, boolean encrypted, int securityFlags) { this(context); mId = id; mSender = sender; mRecipients = new ArrayList<String>(); // will be updated if necessary mTimestamp = System.currentTimeMillis(); mServerTimestamp = timestamp; mEncrypted = encrypted; mSecurityFlags = securityFlags; } /** Empty constructor for local use. */ private CompositeMessage(Context context) { mContext = context; mComponents = new ArrayList<>(); } public String getId() { return mId; } public void setId(String id) { mId = id; } public String getSender(boolean generic) { return generic && XmppStringUtils.isFullJID(mSender) ? XmppStringUtils.parseBareJid(mSender) : mSender; } public String getSender() { return getSender(false); } public List<String> getRecipients() { return mRecipients; } public void addRecipient(String userId) { mRecipients.add(userId); } public long getTimestamp() { return mTimestamp; } public void setTimestamp(long timestamp) { mTimestamp = timestamp; } public long getStatusChanged() { return mStatusChanged; } public void setStatusChanged(long statusChanged) { mStatusChanged = statusChanged; } public long getServerTimestamp() { return mServerTimestamp; } // for internal use only. public void setStatus(int status) { mStatus = status; } public int getStatus() { return mStatus; } @Override public String toString() { // FIXME include components return getClass().getSimpleName() + ": id=" + mId; } public int getDirection() { return (mSender != null) ? Messages.DIRECTION_IN : Messages.DIRECTION_OUT; } public void setDatabaseId(long databaseId) { mDatabaseId = databaseId; } public long getDatabaseId() { return mDatabaseId; } public boolean isEncrypted() { return mEncrypted; } public void setEncrypted(boolean encrypted) { mEncrypted = encrypted; } public int getSecurityFlags() { return mSecurityFlags; } public void setSecurityFlags(int flags) { mSecurityFlags = flags; } public void addComponent(MessageComponent<?> c) { mComponents.add(c); } public void clearComponents() { mComponents.clear(); } public <T extends MessageComponent<?>> boolean hasComponent(Class<T> type) { return getComponent(type) != null; } /** Returns the first component of the given type. */ public <T extends MessageComponent<?>> T getComponent(Class<T> type) { for (MessageComponent<?> cmp : mComponents) { if (type.isInstance(cmp)) return (T) cmp; } return null; } public List<MessageComponent<?>> getComponents() { return mComponents; } private void populateFromCursor(Cursor c) { // be sure to stick to our projection array mDatabaseId = c.getLong(COLUMN_ID); mId = c.getString(COLUMN_MESSAGE_ID); mTimestamp = c.getLong(COLUMN_TIMESTAMP); mStatusChanged = c.getLong(COLUMN_STATUS_CHANGED); mStatus = c.getInt(COLUMN_STATUS); mRecipients = new ArrayList<>(); mEncrypted = (c.getShort(COLUMN_ENCRYPTED) > 0); mSecurityFlags = c.getInt(COLUMN_SECURITY); mServerTimestamp = c.getLong(COLUMN_SERVER_TIMESTAMP); String peer = c.getString(COLUMN_PEER); int direction = c.getInt(COLUMN_DIRECTION); if (direction == Messages.DIRECTION_OUT) { // we are the origin mSender = null; mRecipients.add(peer); } else { mSender = peer; // we are the origin - no recipient } byte[] body = c.getBlob(COLUMN_BODY_CONTENT); // encrypted message - single raw encrypted component if (mEncrypted) { RawComponent raw = new RawComponent(body, true, mSecurityFlags); addComponent(raw); } else { String mime = c.getString(COLUMN_BODY_MIME); String groupJid = c.getString(COLUMN_GROUP_JID); String groupSubject = c.getString(COLUMN_GROUP_SUBJECT); String groupType = c.getString(COLUMN_GROUP_TYPE); int groupMembership = c.getInt(COLUMN_GROUP_MEMBERSHIP); if (body != null) { // remove trailing zero String bodyText = MessageUtils.toString(body); // text data if (TextComponent.supportsMimeType(mime)) { TextComponent txt = new TextComponent(bodyText); addComponent(txt); } // group command else if (GroupCommandComponent.supportsMimeType(mime)) { String groupId = XmppStringUtils.parseLocalpart(groupJid); String groupOwner = XmppStringUtils.parseDomain(groupJid); GroupExtension ext = null; String subject; String[] createMembers; if ((createMembers = GroupCommandComponent.getCreateCommandMembers(bodyText)) != null) { ext = new GroupExtension(groupId, groupOwner, GroupExtension.Type.CREATE, groupSubject, GroupCommandComponent.membersFromJIDs(createMembers)); } else if (GroupCommandComponent.COMMAND_PART.equals(bodyText)) { ext = new GroupExtension(groupId, groupOwner, GroupExtension.Type.PART); } else if ((subject = GroupCommandComponent.getSubjectCommand(bodyText)) != null) { ext = new GroupExtension(groupId, groupOwner, GroupExtension.Type.SET, subject); } else { String[] addMembers = GroupCommandComponent.getAddCommandMembers(bodyText); String[] removeMembers = GroupCommandComponent.getRemoveCommandMembers(bodyText); if (addMembers != null || removeMembers != null) { ext = new GroupExtension(groupId, groupOwner, GroupExtension.Type.SET, groupSubject, GroupCommandComponent // TODO what about existing members here? .membersFromJIDs(null, addMembers, removeMembers)); } } if (ext != null) addComponent(new GroupCommandComponent(ext, peer, Authenticator.getSelfJID(mContext))); } // unknown data else { RawComponent raw = new RawComponent(body, false, mSecurityFlags); addComponent(raw); } } // attachment String attMime = c.getString(COLUMN_ATTACHMENT_MIME); if (attMime != null) { String attPreview = c.getString(COLUMN_ATTACHMENT_PREVIEW_PATH); String attLocal = c.getString(COLUMN_ATTACHMENT_LOCAL_URI); String attFetch = c.getString(COLUMN_ATTACHMENT_FETCH_URL); long attLength = c.getLong(COLUMN_ATTACHMENT_LENGTH); boolean attEncrypted = c.getInt(COLUMN_ATTACHMENT_ENCRYPTED) > 0; int attSecurityFlags = c.getInt(COLUMN_ATTACHMENT_SECURITY_FLAGS); AttachmentComponent att = null; File previewFile = (attPreview != null) ? new File(attPreview) : null; Uri localUri = (attLocal != null) ? Uri.parse(attLocal) : null; if (ImageComponent.supportsMimeType(attMime)) { att = new ImageComponent(attMime, previewFile, localUri, attFetch, attLength, attEncrypted, attSecurityFlags); } else if (VCardComponent.supportsMimeType(attMime)) { att = new VCardComponent(previewFile, localUri, attFetch, attLength, attEncrypted, attSecurityFlags); } else if (AudioComponent.supportsMimeType(attMime)) { att = new AudioComponent(attMime, localUri, attFetch, attLength, attEncrypted, attSecurityFlags); } else { att = new DefaultAttachmentComponent(attMime, localUri, attFetch, attLength, attEncrypted, attSecurityFlags); } // TODO other type of attachments if (att != null) { att.populateFromCursor(mContext, c); addComponent(att); } } // group information if (groupJid != null) { GroupInfo groupInfo = new GroupInfo(groupJid, groupSubject, groupType, groupMembership); addComponent(new GroupComponent(groupInfo)); } } } /** Clears all local fields for recycle. */ protected void clear() { // clear all fields mContext = null; mDatabaseId = 0; mId = null; mSender = null; mTimestamp = 0; mServerTimestamp = 0; mStatusChanged = 0; mStatus = 0; mEncrypted = false; mSecurityFlags = 0; } /** Builds an instance from a {@link Cursor} row. */ public static CompositeMessage fromCursor(Context context, Cursor cursor) { CompositeMessage msg = new CompositeMessage(context); msg.populateFromCursor(cursor); // TODO return msg; } public static void deleteFromCursor(Context context, Cursor cursor) { MessagesProviderUtils.deleteMessage(context, cursor.getLong(COLUMN_ID)); } public static void startQuery(AsyncQueryHandler handler, int token, long threadId, long count, long lastId) { Uri.Builder builder = ContentUris.withAppendedId(Conversations.CONTENT_URI, threadId) .buildUpon() .appendQueryParameter("count", String.valueOf(count)); if (lastId > 0) { builder.appendQueryParameter("last", String.valueOf(lastId)); } // cancel previous operations handler.cancelOperation(token); handler.startQuery(token, lastId > 0 ? "append" : null, builder.build(), MESSAGE_LIST_PROJECTION, null, null, Messages.DEFAULT_SORT_ORDER); } /** A sample text content from class name and mime type. */ public static String getSampleTextContent(String mime) { Class<AttachmentComponent> klass = getSupportingComponent(mime); if (klass != null) { String cname = klass.getSimpleName(); return cname.substring(0, cname.length() - SUFFIX_LENGTH) + ": " + mime; } // no supporting component - return mime // TODO i18n return "Unknown: " + mime; } private static Class<AttachmentComponent> getSupportingComponent(String mime) { // FIXME using reflection BAD BAD BAD !!! for (Class<AttachmentComponent> klass : TRY_COMPONENTS) { Boolean supported = null; try { Method m = klass.getMethod("supportsMimeType", String.class); supported = (Boolean) m.invoke(klass, mime); } catch (Exception e) { // ignored } if (supported != null && supported) { return klass; } } return null; } /** * Returns a correct file object for an incoming message. * @param mime MIME type of the incoming attachment * @param timestamp timestamp of the message */ public static File getIncomingFile(String mime, @NonNull Date timestamp) { if (mime != null) { if (ImageComponent.supportsMimeType(mime)) { String ext = ImageComponent.getFileExtension(mime); return MediaStorage.getIncomingImageFile(timestamp, ext); } else if (AudioComponent.supportsMimeType(mime)) { String ext = AudioComponent.getFileExtension(mime); return MediaStorage.getIncomingAudioFile(timestamp, ext); } // TODO maybe other file types? } return null; } /** * Returns a correct file name for the given MIME. * @param mime MIME type of the incoming attachment * @param timestamp timestamp of the message */ public static String getFilename(String mime, @NonNull Date timestamp) { if (ImageComponent.supportsMimeType(mime)) { String ext = ImageComponent.getFileExtension(mime); return MediaStorage.getOutgoingPictureFilename(timestamp, ext); } else if (AudioComponent.supportsMimeType(mime)) { String ext = AudioComponent.getFileExtension(mime); return MediaStorage.getOutgoingAudioFilename(timestamp, ext); } return null; } /** Still unused. public static void startQuery(AsyncQueryHandler handler, int token, String peer) { // cancel previous operations handler.cancelOperation(token); handler.startQuery(token, null, Messages.CONTENT_URI, MESSAGE_LIST_PROJECTION, "peer = ?", new String[] { peer }, Messages.DEFAULT_SORT_ORDER); } */ }