/*
* Kontalk Java client
* Copyright (C) 2016 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.model.message;
import java.net.URI;
import java.nio.file.InvalidPathException;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Collections;
import java.util.EnumSet;
import java.util.List;
import java.util.Map;
import java.util.Observable;
import java.util.Optional;
import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.stream.Collectors;
import org.json.simple.JSONObject;
import org.json.simple.JSONValue;
import org.kontalk.crypto.Coder;
import org.kontalk.misc.JID;
import org.kontalk.model.Model;
import org.kontalk.model.chat.GroupMetaData;
import org.kontalk.model.chat.GroupMetaData.KonGroupData;
import org.kontalk.system.AttachmentManager;
import org.kontalk.util.EncodingUtils;
import org.kontalk.util.MediaUtils;
/**
* All possible content a message can contain.
*
* Implementation detail: nested, an incoming message can contain a decrypted message.
*
* @author Alexander Bikadorov {@literal <bikaejkb@mail.tu-berlin.de>}
*/
public class MessageContent {
private static final Logger LOGGER = Logger.getLogger(MessageContent.class.getName());
// plain message text, empty string if not present
private final String mPlainText;
// encrypted content, empty string if not present
private String mEncryptedContent;
// attachment (file url, path and metadata)
private final Attachment mAttachment;
// small preview file of attachment
private Preview mPreview;
// group id
private final KonGroupData mGroupData;
// group command
private final GroupCommand mGroupCommand;
// decrypted message content
private MessageContent mDecryptedContent;
private static final String JSON_PLAIN_TEXT = "plain_text";
private static final String JSON_ENC_CONTENT = "encrypted_content";
private static final String JSON_ATTACHMENT = "attachment";
private static final String JSON_PREVIEW = "preview";
private static final String JSON_GROUP_COMMAND = "group_command";
private static final String JSON_DEC_CONTENT = "decrypted_content";
// used for decrypted content of incoming messages, outgoing messages
// and as fallback
public static MessageContent plainText(String plainText) {
return new Builder().body(plainText).build();
}
// used for outgoing messages
public static MessageContent outgoing(String plainText, Attachment attachment) {
return new Builder().body(plainText).attachment(attachment).build();
}
// used for outgoing group commands
public static MessageContent groupCommand(GroupCommand group) {
return new Builder().groupCommand(group).build();
}
private MessageContent(Builder builder) {
mPlainText = builder.mBodyText;
mEncryptedContent = builder.mEncrypted;
mAttachment = builder.mAttachment;
mPreview = builder.mPreview;
mGroupData = builder.mGroupData;
mGroupCommand = builder.mGroupCommand;
mDecryptedContent = builder.mDecrypted;
}
/**
* Get encrypted or plain text content.
* @return decrypted content text if present, else plain text. If there is no
* plain text either returns an empty string.
*/
public String getText() {
return mDecryptedContent != null ? mDecryptedContent.getPlainText() : mPlainText;
}
public String getPlainText() {
return mPlainText;
}
public Optional<Attachment> getAttachment() {
if (mDecryptedContent != null && mDecryptedContent.getAttachment().isPresent()) {
return mDecryptedContent.getAttachment();
}
return Optional.ofNullable(mAttachment);
}
public Optional<InAttachment> getInAttachment() {
Attachment att = this.getAttachment().orElse(null);
return !(att instanceof InAttachment)? Optional.empty() : Optional.of((InAttachment) att);
}
public Optional<OutAttachment> getOutAttachment() {
Attachment att = this.getAttachment().orElse(null);
return !(att instanceof OutAttachment)? Optional.empty() : Optional.of((OutAttachment) att);
}
String getEncryptedContent() {
return mEncryptedContent;
}
void setDecryptedContent(MessageContent decryptedContent) {
assert mDecryptedContent == null;
mDecryptedContent = decryptedContent;
// deleting encrypted data!
mEncryptedContent = "";
}
public Optional<Preview> getPreview() {
if (mDecryptedContent != null && mDecryptedContent.getPreview().isPresent()) {
return mDecryptedContent.getPreview();
}
return Optional.ofNullable(mPreview);
}
void setPreview(Preview preview) {
if (mPreview != null) {
LOGGER.warning("preview already present, not overwriting");
return;
}
mPreview = preview;
}
public Optional<GroupMetaData> getGroupData() {
if (mDecryptedContent != null &&
mDecryptedContent.getGroupData().isPresent()) {
return mDecryptedContent.getGroupData();
}
return Optional.ofNullable(mGroupData);
}
public Optional<GroupCommand> getGroupCommand() {
if (mDecryptedContent != null &&
mDecryptedContent.getGroupCommand().isPresent()) {
return mDecryptedContent.getGroupCommand();
}
return Optional.ofNullable(mGroupCommand);
}
/**
* Return if there is no content in this message.
* @return true if there is no content at all, false otherwise
*/
public boolean isEmpty() {
return mPlainText.isEmpty() &&
mEncryptedContent.isEmpty() &&
mAttachment == null &&
mPreview == null &&
mDecryptedContent == null &&
mGroupCommand == null;
}
@Override
public String toString() {
return "CONT:plain="+mPlainText+",encr="+mEncryptedContent
+",att="+mAttachment+",gd="+mGroupData+",gc="+mGroupCommand
+",decr="+mDecryptedContent;
}
// using legacy lib, raw types extend Object
@SuppressWarnings("unchecked")
String toJSON() {
JSONObject json = new JSONObject();
EncodingUtils.putJSON(json, JSON_PLAIN_TEXT, mPlainText);
if (mAttachment != null)
json.put(JSON_ATTACHMENT, mAttachment.toJSONString());
EncodingUtils.putJSON(json, JSON_ENC_CONTENT, mEncryptedContent);
if (mPreview != null)
json.put(JSON_PREVIEW, mPreview.toJSON());
if (mGroupCommand != null)
json.put(JSON_GROUP_COMMAND, mGroupCommand.toJSON());
if (mDecryptedContent != null)
json.put(JSON_DEC_CONTENT, mDecryptedContent.toJSON());
return json.toJSONString();
}
static MessageContent fromJSONString(String jsonContent) {
Object obj = JSONValue.parse(jsonContent);
try {
Map<?, ?> map = (Map) obj;
String plainText = EncodingUtils.getJSONString(map, JSON_PLAIN_TEXT);
String encrypted = EncodingUtils.getJSONString(map, JSON_ENC_CONTENT);
String att = (String) map.get(JSON_ATTACHMENT);
Attachment attachment = att == null ? null : Attachment.fromJSONOrNull(att);
String pre = (String) map.get(JSON_PREVIEW);
Preview preview = pre == null ? null : Preview.fromJSONOrNull(pre);
String gc = (String) map.get(JSON_GROUP_COMMAND);
GroupCommand groupCommand = gc == null ? null : GroupCommand.fromJSONOrNull(gc);
String jsonDecryptedContent = (String) map.get(JSON_DEC_CONTENT);
MessageContent decryptedContent = jsonDecryptedContent == null ?
null :
fromJSONString(jsonDecryptedContent);
return new Builder().body(plainText).encrypted(encrypted)
.attachment(attachment)
.preview(preview)
.groupCommand(groupCommand)
.decryptedContent(decryptedContent).build();
} catch(ClassCastException ex) {
LOGGER.log(Level.WARNING, "can't parse JSON message content", ex);
return plainText("");
}
}
public abstract static class Attachment extends Observable {
protected static final String JSON_URL = "url";
protected static final String JSON_FILENAME = "file_name";
protected void changed(boolean repeat) {
this.setChanged();
this.notifyObservers(repeat);
}
public abstract String getFilename();
public abstract Path getFilePath();
public abstract String getMimeType();
/** Download progress in percent. <br>
* -1: no download/default <br>
* 0: download started... <br>
* 100: ...download finished <br>
* -2: unknown size <br>
* -3: download aborted
*/
public abstract int getDownloadProgress();
public abstract boolean isEncrypted();
protected abstract String toJSONString();
// using legacy lib, raw types extend Object
@SuppressWarnings("unchecked")
private static Attachment fromJSONOrNull(String json) {
Object obj = JSONValue.parse(json);
try {
Map<?, ?> map = (Map) obj;
return map.containsKey(InAttachment.JSON_ENCRYPTION) ?
InAttachment.fromJSON(map) :
OutAttachment.fromJSON(map);
} catch (ClassCastException | InvalidPathException ex) {
LOGGER.log(Level.WARNING, "can't parse JSON attachment", ex);
return null;
}
}
}
public static final class InAttachment extends Attachment {
private static final String JSON_ENCRYPTION = "encryption";
private static final String JSON_SIGNING = "signing";
private static final String JSON_CODER_ERRORS = "coder_errors";
// URL for file download
private final URI mURL;
// file name of downloaded file, empty by default
private String mFilename;
// coder status of file encryption, known after file is downloaded
protected CoderStatus mCoderStatus;
// progress downloaded of (encrypted) file in percent
private int mDownloadProgress = -1;
public InAttachment(URI url) {
this(url, "",
// TODO we don't know, but value is not used anyway
CoderStatus.createInsecure());
}
// used when loading from database.
private InAttachment(URI url, String filename, CoderStatus coderStatus) {
mURL = url;
mFilename = filename;
mCoderStatus = coderStatus;
}
public URI getURL() {
return mURL;
}
@Override
public int getDownloadProgress() {
return mDownloadProgress;
}
/** Set download progress. See getDownloadProgress() */
public void setDownloadProgress(int p) {
mDownloadProgress = p;
if (p <= 0)
this.changed(true);
}
@Override
public String getFilename() {
return mFilename;
}
public void setFile(String fileName, boolean encrypted) {
mFilename = fileName;
mCoderStatus = encrypted ? CoderStatus.createEncrypted() : CoderStatus.createInsecure();
if (!encrypted)
this.changed(true);
}
@Override
public Path getFilePath() {
return path(mFilename, AttachmentManager.ATT_DIRNAME);
}
public void setDecryptedFile(String filename) {
mCoderStatus.setDecrypted();
mFilename = filename;
this.changed(true);
}
public void setErrors(EnumSet<Coder.Error> errors) {
mCoderStatus.setSecurityErrors(errors);
}
public void setSigning(Coder.Signing signing) {
mCoderStatus.setSigning(signing);
}
@Override
public boolean isEncrypted() {
return mCoderStatus.isEncrypted();
}
@Override
public String getMimeType() {
// guess from file
return MediaUtils.mimeForFile(this.getFilePath());
}
@Override
public String toString() {
return "{IOATT:url="+mURL+",file="+mFilename+",status="+mCoderStatus+"}";
}
// using legacy lib, raw types extend Object
@SuppressWarnings("unchecked")
@Override
protected String toJSONString() {
JSONObject json = new JSONObject();
EncodingUtils.putJSON(json, JSON_URL, mURL.toString());
EncodingUtils.putJSON(json, JSON_FILENAME, mFilename.toString());
json.put(JSON_ENCRYPTION, mCoderStatus.getEncryption().ordinal());
json.put(JSON_SIGNING, mCoderStatus.getSigning().ordinal());
int errs = EncodingUtils.enumSetToInt(mCoderStatus.getErrors());
json.put(JSON_CODER_ERRORS, errs);
return json.toJSONString();
}
private static InAttachment fromJSON(Map<?, ?> map) {
URI url = URI.create(EncodingUtils.getJSONString(map, JSON_URL));
String filename = EncodingUtils.getJSONString(map, JSON_FILENAME);
Number enc = (Number) map.get(JSON_ENCRYPTION);
Coder.Encryption encryption = Coder.Encryption.values()[enc.intValue()];
Number sig = (Number) map.get(JSON_SIGNING);
Coder.Signing signing = Coder.Signing.values()[sig.intValue()];
Number err = ((Number) map.get(JSON_CODER_ERRORS));
EnumSet<Coder.Error> errors = EncodingUtils.intToEnumSet(Coder.Error.class, err.intValue());
return new InAttachment(url, filename, new CoderStatus(encryption, signing, errors));
}
}
public static final class OutAttachment extends Attachment {
private static final String JSON_MIME_TYPE = "mime_type";
private static final String JSON_LENGTH = "length";
// path to upload file
private final Path mFile;
// URL for file download, empty string by default
private URI mURL;
// MIME of file, empty string by default
private String mMimeType;
// size of (decrypted) upload file in bytes, -1 by default
private long mLength;
/** URI, length and (maybe) new MIME type are set after upload. */
public OutAttachment(Path path, String mimeType) {
this(URI.create(""), path, mimeType, -1);
}
// used when loading from database.
private OutAttachment(URI url, Path file,
String mimeType, long length) {
mURL = url;
mFile = file;
mMimeType = mimeType;
mLength = length;
}
public boolean hasURL() {
return !mURL.toString().isEmpty();
}
public URI getURL() {
return mURL;
}
@Override
public String getFilename() {
return mFile.getFileName().toString();
}
public void setUploaded(URI url, String mime, long length){
mURL = url;
mMimeType = mime;
mLength = length;
this.changed(false);
}
@Override
public String getMimeType() {
return mMimeType;
}
public long getLength() {
return mLength;
}
public Path getFilePath() {
return mFile;
}
@Override
public int getDownloadProgress() {
return -1;
}
@Override
public boolean isEncrypted() {
return false;
}
@Override
public String toString() {
return "{OATT:file="+mFile+",url="+mURL+",mime="+mMimeType+",length="+mLength+"}";
}
// using legacy lib, raw types extend Object
@SuppressWarnings("unchecked")
@Override
protected String toJSONString() {
JSONObject json = new JSONObject();
EncodingUtils.putJSON(json, JSON_URL, mURL.toString());
EncodingUtils.putJSON(json, JSON_MIME_TYPE, mMimeType);
json.put(JSON_LENGTH, mLength);
EncodingUtils.putJSON(json, JSON_FILENAME, mFile.toString());
return json.toJSONString();
}
private static OutAttachment fromJSON(Map<?, ?> map) {
URI url = URI.create(EncodingUtils.getJSONString(map, JSON_URL));
Path file = Paths.get(EncodingUtils.getJSONString(map, JSON_FILENAME));
String mimeType = EncodingUtils.getJSONString(map, JSON_MIME_TYPE);
long length = ((Number) map.get(JSON_LENGTH)).longValue();
return new OutAttachment(url, file, mimeType, length);
}
}
// immutable
public static class Preview {
private static final String JSON_MIME_TYPE = "mime_type";
private final byte[] mData;
private final String mMimeType;
public Preview(byte[] data, String mimeType) {
mData = data;
mMimeType = mimeType;
}
private Preview(String mimeType) {
mData = new byte[0];
mMimeType = mimeType;
}
public byte[] getData() {
return mData;
}
public Path getImagePath(int messageID) {
return !MediaUtils.isImage(mMimeType) ? Paths.get("") :
path(AttachmentManager.previewFilename(messageID, mMimeType),
AttachmentManager.PREVIEW_DIRNAME);
}
public String getMimeType() {
return mMimeType;
}
// using legacy lib, raw types extend Object
@SuppressWarnings("unchecked")
private String toJSON() {
JSONObject json = new JSONObject();
EncodingUtils.putJSON(json, JSON_MIME_TYPE, mMimeType);
return json.toJSONString();
}
private static Preview fromJSONOrNull(String json) {
Object obj = JSONValue.parse(json);
try {
Map<?, ?> map = (Map) obj;
String mimeType = EncodingUtils.getJSONString(map, JSON_MIME_TYPE);
return new Preview(mimeType);
} catch (NullPointerException | ClassCastException ex) {
LOGGER.log(Level.WARNING, "can't parse JSON preview", ex);
return null;
}
}
@Override
public String toString() {
return "{PRE:mime="+mMimeType+"}";
}
}
public static class GroupCommand {
private static final String JSON_OP = "op";
private static final String JSON_ADDED = "added";
private static final String JSON_REMOVED = "removed";
private static final String JSON_SUBJECT = "subj";
// ordinals used in database
public enum OP {
CREATE,
SET,
LEAVE
}
private final OP mOP;
// unchanged members in set command, not saved to database
private final List<JID> mUnchanged;
private final List<JID> mAdded;
private final List<JID> mRemoved;
private final String mSubject;
/** Group creation. */
public static GroupCommand create(List<JID> added, String subject) {
return new GroupCommand(OP.CREATE, added, Collections.emptyList(), subject);
}
/** Group changed. */
public static GroupCommand set(List<JID> unchanged, List<JID> added, List<JID> removed,
String subject) {
return new GroupCommand(OP.SET, unchanged, added, removed, subject);
}
public static GroupCommand set(String subject) {
return new GroupCommand(OP.SET, Collections.emptyList(), Collections.emptyList(), subject);
}
/** Member left. Identified by sender JID */
public static GroupCommand leave() {
return new GroupCommand(OP.LEAVE, Collections.emptyList(), Collections.emptyList(), "");
}
private GroupCommand(OP operation, List<JID> added, List<JID> removed, String subject) {
this(operation, Collections.emptyList(), added, removed, subject);
}
private GroupCommand(OP operation, List<JID> unchanged, List<JID> added, List<JID> removed,
String subject) {
mOP = operation;
mUnchanged = unchanged;
mAdded = added;
mRemoved = removed;
mSubject = subject;
}
public OP getOperation() {
return mOP;
}
public List<JID> getUnchanged() {
return mUnchanged;
}
public List<JID> getAdded() {
return mAdded;
}
public boolean isAddingMe() {
JID myJID = Model.getUserJID();
return mAdded.stream().anyMatch(jid -> jid.equals(myJID));
}
public List<JID> getRemoved() {
return mRemoved;
}
public String getSubject() {
return mSubject;
}
// using legacy lib, raw types extend Object
@SuppressWarnings("unchecked")
private String toJSON() {
JSONObject json = new JSONObject();
json.put(JSON_OP, mOP.ordinal());
EncodingUtils.putJSON(json, JSON_SUBJECT, mSubject);
List<String> added = mAdded.stream()
.map(JID::string)
.collect(Collectors.toList());
json.put(JSON_ADDED, added);
List<String> removed = mRemoved.stream()
.map(JID::string)
.collect(Collectors.toList());
json.put(JSON_REMOVED, removed);
return json.toJSONString();
}
// using legacy lib
@SuppressWarnings("unchecked")
private static GroupCommand fromJSONOrNull(String json) {
Object obj = JSONValue.parse(json);
try {
Map<?, ?> map = (Map) obj;
Number o = (Number) map.get(JSON_OP);
OP op = OP.values()[o.intValue()];
String subj = EncodingUtils.getJSONString(map, JSON_SUBJECT);
List<String> a = (List<String>) map.get(JSON_ADDED);
List<JID> added = a.stream()
.map(JID::bare)
.collect(Collectors.toList());
List<String> r = (List<String>) map.get(JSON_REMOVED);
List<JID> removed = r.stream()
.map(JID::bare)
.collect(Collectors.toList());
return new GroupCommand(op, added, removed, subj);
} catch (NullPointerException | ClassCastException ex) {
LOGGER.log(Level.WARNING, "can't parse JSON group command", ex);
LOGGER.log(Level.WARNING, "JSON='"+json+"'");
return null;
}
}
@Override
public String toString() {
return "{GC:op="+mOP+",subj="+mSubject+"}";
}
}
public static class Builder {
private String mBodyText = "";
private String mEncrypted = "";
private Attachment mAttachment = null;
private Preview mPreview = null;
private KonGroupData mGroupData = null;
private GroupCommand mGroupCommand = null;
private MessageContent mDecrypted = null;
public Builder body(String body) {
mBodyText = body; return this; }
public Builder encrypted(String encrypted) {
mEncrypted = encrypted; return this; }
public Builder attachment(Attachment attachment) {
mAttachment = attachment; return this; }
public Builder preview(Preview preview) {
mPreview = preview; return this; }
public Builder groupData(KonGroupData gData) {
mGroupData = gData; return this; }
public Builder groupCommand(GroupCommand group) {
mGroupCommand = group; return this; }
private Builder decryptedContent(MessageContent decrypted) {
mDecrypted = decrypted; return this; }
public MessageContent build() {
return new MessageContent(this);
}
}
private static Path path(String filename, String dirName) {
return filename.isEmpty() ? Paths.get("") :
Model.appDir().resolve(dirName).resolve(filename);
}
}