/* * 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.sql.ResultSet; import java.sql.SQLException; import java.util.Arrays; import java.util.Date; import java.util.EnumSet; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Observable; import java.util.Observer; import java.util.Optional; import java.util.Set; import java.util.logging.Level; import java.util.logging.Logger; import org.json.simple.JSONObject; import org.json.simple.JSONValue; import org.kontalk.crypto.Coder; import org.kontalk.misc.Searchable; import org.kontalk.model.Contact; import org.kontalk.model.Model; import org.kontalk.model.chat.Chat; import org.kontalk.model.message.MessageContent.Attachment; import org.kontalk.model.message.MessageContent.Preview; import org.kontalk.persistence.Database; import org.kontalk.util.EncodingUtils; /** * Base class for incoming and outgoing XMMP messages. * * @author Alexander Bikadorov {@literal <bikaejkb@mail.tu-berlin.de>} */ public abstract class KonMessage extends Observable implements Searchable, Observer { private static final Logger LOGGER = Logger.getLogger(KonMessage.class.getName()); /** * Sending status of one message. * Do not modify, only add! Ordinal used in database */ public enum Status { /** For all incoming messages. */ IN, /** Outgoing message, message is about to be send. */ PENDING, /** Outgoing message, message was handled by server. */ SENT, /** Outgoing message, message was received by recipient. * Not saved to database. Transmission used for that. */ RECEIVED, /** Outgoing message, an error occurred somewhere in the transmission. */ ERROR } public enum ViewChange { STATUS, CONTENT, ATTACHMENT } public static final String TABLE = "messages"; public static final String COL_CHAT_ID = "thread_id"; //public static final String COL_DIR = "direction"; public static final String COL_XMPP_ID = "xmpp_id"; public static final String COL_DATE = "date"; public static final String COL_STATUS = "status"; public static final String COL_CONTENT = "content"; public static final String COL_ENCR_STAT = "encryption_status"; public static final String COL_SIGN_STAT = "signing_status"; public static final String COL_COD_ERR = "coder_errors"; public static final String COL_SERV_ERR = "server_error"; public static final String COL_SERV_DATE = "server_date"; public static final String SCHEMA = "( " + Database.SQL_ID + COL_CHAT_ID + " INTEGER NOT NULL, " + // XMPP ID attribute; only RECOMMENDED and must be unique only // within a stream (RFC 6120) COL_XMPP_ID + " TEXT, " + // unix time, local creation timestamp COL_DATE + " INTEGER NOT NULL, " + // enum, message sending status COL_STATUS + " INTEGER NOT NULL, " + // message content in JSON format COL_CONTENT + " TEXT NOT NULL, " + // enum, determines if content is encrypted COL_ENCR_STAT + " INTEGER NOT NULL, " + // enum, determines if content is verified // can only tell after encryption COL_SIGN_STAT + " INTEGER NOT NULL, " + // enum set, encryption and signing errors of content COL_COD_ERR + " INTEGER NOT NULL, " + // optional error reply in JSON format COL_SERV_ERR + " TEXT, " + // unix time, transmission/delay timestamp COL_SERV_DATE + " INTEGER, " + "FOREIGN KEY (" + COL_CHAT_ID + ") REFERENCES " + Chat.TABLE + " (_id) " + ")"; final int mID; private final Chat mChat; private final String mXMPPID; private final Date mDate; final MessageContent mContent; // last timestamp of server transmission packet // incoming: (delayed) sent; outgoing: sent or error Date mServerDate; Status mStatus; final CoderStatus mCoderStatus; ServerError mServerError; KonMessage(Chat chat, String xmppID, MessageContent content, Optional<Date> serverDate, Status status, CoderStatus coderStatus) { mChat = chat; mXMPPID = xmppID; mDate = new Date(); mContent = content; mContent.getAttachment().ifPresent(att -> att.addObserver(this)); mServerDate = serverDate.orElse(null); mStatus = status; mCoderStatus = coderStatus; mServerError = new ServerError(); // insert List<Object> values = Arrays.asList( mChat.getID(), // database downward compatibility due to bug in version 3.1.2 (and prior) //Database.setString(mXMPPID), mXMPPID, mDate, mStatus, // i simply don't like to save all possible content explicitly in the // database, so we use JSON here mContent.toJSON(), mCoderStatus.getEncryption(), mCoderStatus.getSigning(), mCoderStatus.getErrors(), mServerError.toJSON(), mServerDate); mID = Model.database().execInsert(TABLE, values); if (mID <= 0) { LOGGER.log(Level.WARNING, "db, could not insert message"); } } // used when loading from database KonMessage(Builder builder) { mID = builder.mID; mChat = builder.mChat; mXMPPID = builder.mXMPPID; mDate = builder.mDate; mContent = builder.mContent; mContent.getAttachment().ifPresent(att -> att.addObserver(this)); mServerDate = builder.mServerDate; mStatus = builder.mStatus; mCoderStatus = builder.mCoderStatus; mServerError = builder.mServerError; } public int getID() { return mID; } public Chat getChat() { return mChat; } public boolean isInMessage() { return mStatus == Status.IN; } /** Get transmissions: Exactly one for incoming messages. For outgoing messages exactly one in * a single chat and one or more in a group chat. */ public abstract Set<Transmission> getTransmissions(); public String getXMPPID() { return mXMPPID; } /** Return (local) creation time of this message. */ public Date getDate() { return mDate; } public Optional<Date> getServerDate() { return Optional.ofNullable(mServerDate); } public Status getStatus() { return mStatus; } public MessageContent getContent() { return mContent; } public CoderStatus getCoderStatus() { return mCoderStatus; } public void setSecurityErrors(EnumSet<Coder.Error> errors) { if (mCoderStatus.getErrors().equals(errors)) return; mCoderStatus.setSecurityErrors(errors); this.save(); this.changed(ViewChange.STATUS); } public ServerError getServerError() { return mServerError; } public void setPreview(Preview preview) { mContent.setPreview(preview); this.save(); this.changed(ViewChange.ATTACHMENT); } public boolean isEncrypted() { return mCoderStatus.isEncrypted(); } /** * Return the message that is placed before this message (in or out) - if any - * in the ordered chat message list of this message. */ public Optional<KonMessage> getPredecessor() { return mChat.getMessages().getPredecessor(this); } /** Get the contact who sent this message if this is not an outgoing message. */ public Optional<Contact> getSender() { return this instanceof InMessage ? Optional.of(((InMessage) this).getContact()) : Optional.empty(); } void save() { Map<String, Object> set = new HashMap<>(); set.put(COL_STATUS, mStatus); set.put(COL_CONTENT, mContent.toJSON()); set.put(COL_ENCR_STAT, mCoderStatus.getEncryption()); set.put(COL_SIGN_STAT, mCoderStatus.getSigning()); set.put(COL_COD_ERR, mCoderStatus.getErrors()); set.put(COL_SERV_ERR, Database.setString(mServerError.toJSON())); set.put(COL_SERV_DATE, mServerDate); Model.database().execUpdate(TABLE, set, mID); } public boolean delete() { boolean succ = this.getTransmissions().stream().allMatch(Transmission::delete); if (!succ) return false; if (mID < 0) { LOGGER.warning("not in database: "+this); return true; } return Model.database().execDelete(TABLE, mID); } void changed(ViewChange change) { this.setChanged(); this.notifyObservers(change); } boolean abstractEquals(KonMessage oMessage) { return mChat.equals(oMessage.mChat) && !mXMPPID.isEmpty() && mXMPPID.equals(oMessage.mXMPPID); } int abstractHashCode() { int hash = 7; hash = 17 * hash + Objects.hashCode(this.mChat); hash = 17 * hash + Objects.hashCode(this.mXMPPID); return hash; } @Override public boolean contains(String search) { if (mContent.getText().toLowerCase().contains(search)) return true; return this.getTransmissions().stream() .anyMatch(t -> t.getContact().getName().toLowerCase().contains(search) || t.getContact().getJID().string().toLowerCase().contains(search)); } @Override public void update(Observable o, Object arg) { this.save(); if (o instanceof Attachment && arg instanceof Boolean && ((boolean) arg)) this.changed(ViewChange.ATTACHMENT); } @Override public String toString() { return "M:id="+mID+",status="+mStatus+",chat="+mChat+",xmppid="+mXMPPID +",transmissions="+this.getTransmissions() +",date="+mDate+",sdate="+mServerDate +",cont="+mContent +",codstat="+mCoderStatus+",serverr="+mServerError; } public static KonMessage load(ResultSet messageRS, Chat chat, Map<Integer, Contact> contactMap) throws SQLException { int id = messageRS.getInt("_id"); String xmppID = Database.getString(messageRS, KonMessage.COL_XMPP_ID); Date date = new Date(messageRS.getLong(KonMessage.COL_DATE)); int statusIndex = messageRS.getInt(KonMessage.COL_STATUS); KonMessage.Status status = KonMessage.Status.values()[statusIndex]; String jsonContent = messageRS.getString(KonMessage.COL_CONTENT); MessageContent content = MessageContent.fromJSONString(jsonContent); int encryptionIndex = messageRS.getInt(KonMessage.COL_ENCR_STAT); Coder.Encryption encryption = Coder.Encryption.values()[encryptionIndex]; int signingIndex = messageRS.getInt(KonMessage.COL_SIGN_STAT); Coder.Signing signing = Coder.Signing.values()[signingIndex]; int errorFlags = messageRS.getInt(KonMessage.COL_COD_ERR); EnumSet<Coder.Error> coderErrors = EncodingUtils.intToEnumSet( Coder.Error.class, errorFlags); CoderStatus coderStatus = new CoderStatus(encryption, signing, coderErrors); String jsonServerError = messageRS.getString(KonMessage.COL_SERV_ERR); KonMessage.ServerError serverError = KonMessage.ServerError.fromJSON(jsonServerError); long sDate = messageRS.getLong(KonMessage.COL_SERV_DATE); Date serverDate = sDate == 0 ? null : new Date(sDate); KonMessage.Builder builder = new KonMessage.Builder(id, chat, status, date, content); builder.transmissions(Transmission.load(id, contactMap)); builder.xmppID(xmppID); if (serverDate != null) builder.serverDate(serverDate); builder.coderStatus(coderStatus); builder.serverError(serverError); return builder.build(); } public static final class ServerError { private static final String JSON_COND = "cond"; private static final String JSON_TEXT = "text"; public final String condition; public final String text; private ServerError() { this("", ""); } ServerError(String condition, String text) { this.condition = condition; this.text = text; } private String toJSON() { JSONObject json = new JSONObject(); EncodingUtils.putJSON(json, JSON_COND, condition); EncodingUtils.putJSON(json, JSON_TEXT, text); return json.toJSONString(); } @Override public String toString() { return this.toJSON(); } static ServerError fromJSON(String jsonContent) { Object obj = JSONValue.parse(jsonContent); Map<?, ?> map = (Map) obj; if (map == null) return new ServerError(); String condition = EncodingUtils.getJSONString(map, JSON_COND); String text = EncodingUtils.getJSONString(map, JSON_TEXT); return new ServerError(condition, text); } } static class Builder { private final int mID; private final Chat mChat; private final Status mStatus; private final Date mDate; private final MessageContent mContent; Set<Transmission> mTransmissions = null; private String mXMPPID = null; private Date mServerDate = null; private CoderStatus mCoderStatus = null; private ServerError mServerError = null; private Builder(int id, Chat chat, Status status, Date date, MessageContent content) { mID = id; mChat = chat; mStatus = status; mDate = date; mContent = content; } private void transmissions(Set<Transmission> transmission) { mTransmissions = transmission; } private void xmppID(String xmppID) { mXMPPID = xmppID; } private void serverDate(Date date) { mServerDate = date; } private void coderStatus(CoderStatus coderStatus) { mCoderStatus = coderStatus; } private void serverError(ServerError error) { mServerError = error; } private KonMessage build() { if (mTransmissions == null || mXMPPID == null || mCoderStatus == null || mServerError == null) throw new IllegalStateException(); if (mStatus == Status.IN) return new InMessage(this); else return new OutMessage(this); } } }