public final class Coder {
    private static final Logger LOGGER = Logger.getLogger(Coder.class.getName());

    private Coder() {
    }

    /**
     * Encryption status of a message.
     * Do not modify, only add! Ordinal used in database.
     */
    public enum Encryption {NOT, ENCRYPTED, DECRYPTED}

    /**
     * Signing status of a message.
     * Do not modify, only add! Ordinal used in database.
     */
    public enum Signing {NOT, SIGNED, VERIFIED, UNKNOWN}

    /**
     * Errors that can occur during de-/encryption and verification.
     * Do not modify, only add! Ordinal used in database.
     */
    public enum Error {
        /** Some unknown error. */
        UNKNOWN_ERROR,
        /** Own personal key not found. Unused. */
        MY_KEY_UNAVAILABLE,
        /** Public key of sender not found. */
        KEY_UNAVAILABLE,
        /** My private key does not match. */
        INVALID_PRIVATE_KEY,
        /** Invalid data / parsing failed. */
        INVALID_DATA,
        /** No integrity protection found. */
        NO_INTEGRITY,
        /** Integrity check failed. */
        INVALID_INTEGRITY,
        /** Invalid data / parsing failed of signature. */
        INVALID_SIGNATURE_DATA,
        /** Signature does not match sender. */
        INVALID_SIGNATURE,
        /** Recipient user id in decrypted data does not match id in my key. */
        INVALID_RECIPIENT,
        /** Sender user id in decrypted data does not match id in sender key. */
        INVALID_SENDER,

        //INVALID_MIME,
        //INVALID_TIMESTAMP,
    }

    private static final HashMap<Contact, PGPCoderKey> KEY_MAP = new HashMap<>();

    public static Optional<PGPCoderKey> contactkey(Contact contact) {
        if (KEY_MAP.containsKey(contact)) {
            PGPCoderKey key = KEY_MAP.get(contact);
            if (key.fingerprint.equals(contact.getFingerprint()))
                return Optional.of(key);
        }

        byte[] rawKey = contact.getKey();
        if (rawKey.length != 0) {
            PGPCoderKey key = PGPUtils.readPublicKey(rawKey).orElse(null);
            if (key != null) {
                KEY_MAP.put(contact, key);
                return Optional.of(key);
            }
        }

        LOGGER.warning("key not found for contact: "+contact);
        return Optional.empty();
    }

    /**
     * Decrypt and verify the body of a message. Sets the encryption and signing
     * status of the message and errors that may occur are saved to the message.
     */
    public static boolean decryptMessage(PersonalKey myKey, DecryptMessage message) {
        return Decryptor.decryptMessage(message, myKey);
    }

    /**
     * Decrypt and verify a downloaded attachment file. Sets the encryption and
     * signing status of the message attachment and errors that may occur are
     * saved to the attachment.
     */
    public static void decryptAttachment(PersonalKey myKey, InAttachment attachment, Contact sender) {
        Decryptor.decryptAttachment(attachment, myKey, sender);
    }

    /**
     * Creates encrypted and signed message body.
     * Errors that may occur are saved to the message.
     * @return the encrypted and signed text.
     */
    public static String encryptMessageRFC3923(PersonalKey myKey, OutMessage message) {
        return new Encryptor(myKey, message).encryptMessageRFC3923();
    }

    public static String encryptStanzaRFC3923(PersonalKey myKey, OutMessage message, String xml) {
        return new Encryptor(myKey, message).encryptStanzaRFC3923(xml);
    }

    /** Encrypt an arbitrary UTF-8 string as a Base64 encoded OpenPGP Message. */
    public static String encryptString(PersonalKey myKey, OutMessage message, String plainText) {
        return new Encryptor(myKey, message).encryptString(plainText);
    }

    public static Optional<File> encryptAttachment(PersonalKey myKey, OutMessage message, File file) {
        return new Encryptor(myKey, message).encryptAttachment(file);
    }
} public final class JID {
    private static final Logger LOGGER = Logger.getLogger(JID.class.getName());

    static {
        // good to know. For working JID validation
        SimpleXmppStringprep.setup();
    }

    private final String mLocal; // escaped!
    private final String mDomain;
    private final String mResource;
    private final boolean mValid;

    private JID(String local, String domain, String resource) {
        mLocal = local;
        mDomain = domain;
        mResource = resource;

        mValid = !mLocal.isEmpty() && !mDomain.isEmpty()
                // NOTE: domain check could be stronger - compliant with RFC 6122, but
                // server does not accept most special characters
                // NOTE: resource not checked
                && JidUtil.isTypicalValidEntityBareJid(
                        XmppStringUtils.completeJidFrom(mLocal, mDomain));
    }

    /** Return unescaped local part. */
    public String local() {
        return XmppStringUtils.unescapeLocalpart(mLocal);
    }

    public String domain() {
        return mDomain;
    }

    /** Return JID as escaped string. */
    public String string() {
        return XmppStringUtils.completeJidFrom(mLocal, mDomain, mResource);
    }

    public String asUnescapedString() {
        return XmppStringUtils.completeJidFrom(this.local(), mDomain, mResource);
    }

    public boolean isValid() {
        return mValid;
    }

    public boolean isHash() {
        return mLocal.matches("[0-9a-f]{40}");
    }

    public boolean isFull() {
        return !mResource.isEmpty();
    }

    public JID toBare() {
        return new JID(mLocal, mDomain, "");
    }

    /** To invalid(!) domain JID. */
    public JID toDomain() {
        return new JID("", mDomain, "");
    }

    public BareJid toBareSmack() {
        try {
            return JidCreate.bareFrom(this.string());
        } catch (XmppStringprepException ex) {
            LOGGER.log(Level.WARNING, "could not convert to smack", ex);
            throw new RuntimeException("You didn't check isValid(), idiot!");
        }
    }

    public Jid toSmack() {
        try {
            return JidCreate.from(this.string());
        } catch (XmppStringprepException ex) {
            LOGGER.log(Level.WARNING, "could not convert to smack", ex);
            throw new RuntimeException("Not even a simple Jid?");
        }
    }

    /**
     * Comparing only bare JIDs.
     * Case-insensitive.
     */
    @Override
    public final boolean equals(Object o) {
        if (o == this)
            return true;

       if (!(o instanceof JID))
           return false;

       JID oJID = (JID) o;
       return mLocal.equalsIgnoreCase(oJID.mLocal) &&
               mDomain.equalsIgnoreCase(oJID.mDomain);
    }

    @Override
    public int hashCode() {
        return Objects.hash(mLocal, mDomain);
    }

    /** Use this only for debugging and otherwise string() instead! */
    @Override
    public String toString() {
        return "'"+this.string()+"'";
    }

    public static JID full(String jid) {
        jid = StringUtils.defaultString(jid);
        return escape(XmppStringUtils.parseLocalpart(jid),
                XmppStringUtils.parseDomain(jid),
                XmppStringUtils.parseResource(jid));
    }

    public static JID bare(String jid) {
        jid = StringUtils.defaultString(jid);
        return escape(XmppStringUtils.parseLocalpart(jid), XmppStringUtils.parseDomain(jid), "");
    }

    public static JID fromSmack(Jid jid) {
        Localpart localpart = jid.getLocalpartOrNull();
        return new JID(localpart != null ? localpart.toString() : "",
                jid.getDomain().toString(),
                jid.getResourceOrEmpty().toString());
    }

    public static JID bare(String local, String domain) {
        return escape(local, domain, "");
    }

    private static JID escape(String local, String domain, String resource) {
        return new JID(XmppStringUtils.escapeLocalpart(local), domain, resource);
    }

    public static JID deleted(int id) {
        return new JID("", Integer.toString(id), "");
    }
} public final class Contact extends Observable implements Searchable {
    private static final Logger LOGGER = Logger.getLogger(Contact.class.getName());

    /**
     * Online status of one contact.
     */
    public enum Online {UNKNOWN, YES, NO, ERROR}

    /**
     * XMPP subscription status in roster.
     */
    public enum Subscription {
        UNKNOWN, PENDING, SUBSCRIBED, UNSUBSCRIBED
    }

    public enum ViewChange {
        JID, NAME, LAST_SEEN, ONLINE_STATUS, KEY, BLOCKING, SUBSCRIPTION, AVATAR, DELETED
    }

    public static final String TABLE = "user";
    public static final String COL_JID = "jid";
    public static final String COL_NAME = "name";
    public static final String COL_STAT = "status";
    public static final String COL_LAST_SEEN = "last_seen";
    public static final String COL_ENCR = "encrypted";
    public static final String COL_PUB_KEY = "public_key";
    public static final String COL_KEY_FP = "key_fingerprint";
    public static final String COL_AVATAR_ID = "avatar_id";
    public static final String SCHEMA = "(" +
            Database.SQL_ID +
            COL_JID + " TEXT NOT NULL UNIQUE, " +
            COL_NAME + " TEXT, " +
            COL_STAT + " TEXT, " +
            COL_LAST_SEEN + " INTEGER, " +
            // boolean, send messages encrypted?
            COL_ENCR + " INTEGER NOT NULL, " +
            COL_PUB_KEY + " TEXT, " +
            COL_KEY_FP + " TEXT," +
            COL_AVATAR_ID + " TEXT" +
            ")";

    private final int mID;
    private JID mJID;
    private String mName;
    private String mStatus = "";
    private Date mLastSeen = null;
    private Online mOnline = Online.UNKNOWN; // not in database
    private boolean mEncrypted = true;
    private String mKey = "";
    private String mFingerprint = "";
    private boolean mBlocked = false;
    private Subscription mSubStatus = Subscription.UNKNOWN; // not in database
    //private ItemType mType;
    private Avatar.DefaultAvatar mAvatar = null;
    private Avatar.CustomAvatar mCustomAvatar = null;
    private boolean mSaveOnShutdown = false;

    // new contact (eg from roster)
    Contact(JID jid, String name) {
        mJID = jid;
        mName = name;

        // insert
        List<Object> values = Arrays.asList(
                mJID,
                mName,
                mStatus,
                mLastSeen,
                mEncrypted,
                null, // key
                null, // fingerprint
                null); // avatar id
        mID = Model.database().execInsert(TABLE, values);
        if (mID < 1)
            LOGGER.log(Level.WARNING, "could not insert contact");
    }

    // loading from database
    private Contact(
            int id,
            JID jid,
            String name,
            String status,
            Optional<Date> lastSeen,
            boolean encrypted,
            String publicKey,
            String fingerprint,
            String avatarID) {
        mID = id;
        mJID = jid;
        mName = name;
        mStatus = status;
        mLastSeen = lastSeen.orElse(null);
        mEncrypted = encrypted;
        mKey = publicKey;
        mFingerprint = fingerprint.toLowerCase();
        mAvatar = avatarID.isEmpty() ?
                null :
                Avatar.DefaultAvatar.load(avatarID).orElse(null);
        mCustomAvatar = Avatar.CustomAvatar.load(mID).orElse(null);
    }

    public JID getJID() {
        return mJID;
    }

    void setJID(JID jid) {
        if (jid.equals(mJID))
            return;

        if (!jid.isValid()) {
            LOGGER.warning("jid is not valid: "+jid);
            return;
        }

        mJID = jid;
        this.save();
        this.changed(ViewChange.JID);
    }

    public int getID() {
        return mID;
    }

    public String getName() {
        return mName;
    }

    public void setName(String name) {
        if (name.equals(mName))
            return;

        mName = name;
        this.save();

        this.changed(ViewChange.NAME);
    }

    public String getStatus() {
        return mStatus;
    }

    public Optional<Date> getLastSeen() {
        return Optional.ofNullable(mLastSeen);
    }

    public void setLastSeen(Date lastSeen, String status) {
        if (!lastSeen.equals(mLastSeen)) {
            mLastSeen = lastSeen;
            mSaveOnShutdown = true;
            this.changed(ViewChange.LAST_SEEN);
        }
        if (!status.isEmpty() && !status.equals(mStatus)) {
            mStatus = status;
            mSaveOnShutdown = true;
            // notify on status change not required
        }
    }

    public boolean getEncrypted() {
        return mEncrypted;
    }

    public void setEncrypted(boolean encrypted) {
        if (encrypted == mEncrypted)
            return;

        mEncrypted = encrypted;
        this.save();
    }

    public Online getOnline() {
        return this.mOnline;
    }

    public void setStatusText(String status) {
        if (mStatus.equals(status))
            return;

        mStatus = status;
        mSaveOnShutdown = true;
        // notify on status change not required
    }

    public void setOnlineStatus(Online onlineStatus) {
        if (onlineStatus == mOnline)
            return;

        if (onlineStatus == Online.YES ||
                (onlineStatus == Online.NO && mOnline == Online.YES)) {
            mLastSeen = new Date();
            mSaveOnShutdown = true;
            // notify on last_seen change not required here
        }

        mOnline = onlineStatus;
        this.changed(ViewChange.ONLINE_STATUS);
    }

    public byte[] getKey() {
        return EncodingUtils.base64ToBytes(mKey);
    }

    public boolean hasKey() {
        return !mKey.isEmpty();
    }

    public String getFingerprint() {
        return mFingerprint;
    }

    public void setKey(byte[] rawKey, String fingerprint) {
        if (!mKey.isEmpty())
            LOGGER.info("overwriting public key of contact: "+this);

        mKey = EncodingUtils.bytesToBase64(rawKey);
        mFingerprint = fingerprint.toLowerCase();
        this.save();
        this.changed(ViewChange.KEY);
    }

    public boolean isBlocked() {
        return mBlocked;
    }

    public void setBlocked(boolean blocked) {
        mBlocked = blocked;
        this.changed(ViewChange.BLOCKING);
    }

    public Subscription getSubScription() {
        return mSubStatus;
    }

    public void setSubscriptionStatus(Subscription status) {
        if (status == mSubStatus)
            return;

        mSubStatus = status;
        this.changed(ViewChange.SUBSCRIPTION);
    }

    public Optional<Avatar> getAvatar() {
        return Optional.ofNullable(mAvatar);
    }

    /** Get custom or downloaded avatar. */
    public Optional<Avatar> getDisplayAvatar() {
        return mCustomAvatar != null ?
                Optional.of(mCustomAvatar) :
                Optional.ofNullable(mAvatar);
    }

    public void setAvatar(Avatar.DefaultAvatar avatar) {
        // delete old
        if (mAvatar != null)
            mAvatar.delete();

        // set new
        mAvatar = avatar;
        this.save();

        if (mCustomAvatar == null)
            this.changed(ViewChange.AVATAR);
    }

    public void deleteAvatar() {
        if (mAvatar == null)
            return;

        mAvatar.delete();
        mAvatar = null;
        this.save();

        this.changed(ViewChange.AVATAR);
    }

    public void setCustomAvatar(Avatar.CustomAvatar avatar) {
        // overwrite file!
        mCustomAvatar = avatar;

        this.changed(ViewChange.AVATAR);
    }

    public boolean hasCustomAvatarSet() {
        return mCustomAvatar != null;
    }

    public void deleteCustomAvatar() {
        if (mCustomAvatar == null)
            return;

        mCustomAvatar.delete();
        mCustomAvatar = null;

        this.changed(ViewChange.AVATAR);
    }

    public boolean isMe() {
        return mJID.isValid() && mJID.equals(Model.getUserJID());
    }

    public boolean isKontalkUser(){
        return XMPPUtils.isKontalkJID(mJID);
    }

    /**
     * 'Delete' this contact: faked by resetting all values.
     */
    void setDeleted() {
        LOGGER.config("contact: "+this);
        mJID = JID.deleted(mID);
        mName = "";
        mStatus = "";
        mLastSeen = null;
        mEncrypted = false;
        mKey = "";
        mFingerprint = "";
        if (mAvatar != null)
            mAvatar.delete();
        mAvatar = null;

        this.save();
        this.changed(ViewChange.DELETED);
    }

    public boolean isDeleted() {
        return mJID.string().equals(Integer.toString(mID));
    }

    void onShutDown() {
        if (!this.isDeleted() && mSaveOnShutdown)
            this.save();
    }

    private void save() {
        Map<String, Object> set = new HashMap<>();
        set.put(COL_JID, mJID);
        set.put(COL_NAME, mName);
        set.put(COL_STAT, mStatus);
        set.put(COL_LAST_SEEN, mLastSeen);
        set.put(COL_ENCR, mEncrypted);
        set.put(COL_PUB_KEY, Database.setString(mKey));
        set.put(COL_KEY_FP, Database.setString(mFingerprint));
        set.put(COL_AVATAR_ID, Database.setString(mAvatar != null ? mAvatar.getID() : ""));
        Model.database().execUpdate(TABLE, set, mID);

        mSaveOnShutdown = false;
    }

    private void changed(ViewChange change) {
        this.setChanged();
        this.notifyObservers(change);
    }

    @Override
    public boolean contains(String search) {
        return this.getName().toLowerCase().contains(search) ||
                this.getJID().string().toLowerCase().contains(search);
    }

    @Override
    public final boolean equals(Object o) {
        if (o == this)
            return true;

        if (!(o instanceof Contact))
            return false;

        return mID == ((Contact) o).mID;
    }

    @Override
    public int hashCode() {
        return Objects.hash(mID);
    }

    @Override
    public String toString() {
        return "C:id="+mID+",jid="+mJID+",name="+mName+",fp="+mFingerprint
                +",subsc="+mSubStatus;
    }

    static Contact load(ResultSet rs) throws SQLException {
        int id = rs.getInt("_id");
        JID jid = JID.bare(rs.getString(Contact.COL_JID));

        String name = rs.getString(Contact.COL_NAME);
        String status = rs.getString(Contact.COL_STAT);
        long l = rs.getLong(Contact.COL_LAST_SEEN);
        Date lastSeen = l == 0 ? null : new Date(l);
        boolean encr = rs.getBoolean(Contact.COL_ENCR);
        String key = Database.getString(rs, Contact.COL_PUB_KEY);
        String fp = Database.getString(rs, Contact.COL_KEY_FP);
        String avatarID = Database.getString(rs, Contact.COL_AVATAR_ID);

        return new Contact(id, jid, name, status,
                Optional.ofNullable(lastSeen), encr, key, fp, avatarID);
    }
} public abstract class Chat extends Observable implements Observer, Searchable {
    private static final Logger LOGGER = Logger.getLogger(Chat.class.getName());

    public enum ViewChange {
        READ, NEW_MESSAGE, VIEW_SETTINGS, CONTACT, MEMBER_STATE, SUBJECT, MEMBERS
    }

    public static final String TABLE = "threads";
    public static final String COL_XMPPID = "xmpp_id";
    public static final String COL_GD = "gid";
    public static final String COL_SUBJ = "subject";
    public static final String COL_READ = "read";
    public static final String COL_VIEW_SET = "view_settings";
    public static final String SCHEMA = "( " +
            Database.SQL_ID +
            // optional XMPP chat ID
            COL_XMPPID+" TEXT UNIQUE, " +
            // optional subject
            COL_SUBJ+" TEXT, " +
            // boolean, contains unread messages?
            COL_READ+" INTEGER NOT NULL, " +
            // view settings in JSON format
            COL_VIEW_SET+" TEXT NOT NULL, " +
            // optional group id in JSON format
            COL_GD+" TEXT " +
            ")";

    final int mID;
    private final ChatMessages mMessages;

    private boolean mRead;
    private boolean mDeleted = false;

    private ViewSettings mViewSettings;

    Chat(String xmppID, String subject, GroupMetaData gData) {
        mMessages = new ChatMessages();
        mRead = true;
        mViewSettings = ViewSettings.createDefault();

        // insert
        List<Object> values = Arrays.asList(
                Database.setString(xmppID),
                Database.setString(subject),
                mRead,
                mViewSettings.toJSONString(),
                Database.setString(gData == null ? "" : gData.toJSON()));
        mID = Model.database().execInsert(TABLE, values);
        if (mID < 1) {
            LOGGER.warning("could not insert chat");
        }
    }

    // used when loading from database
    Chat(int id, boolean read, String jsonViewSettings) {
        mID = id;
        mMessages = new ChatMessages();
        mRead = read;
        mViewSettings = new ViewSettings(jsonViewSettings);
    }

    private void loadMessages(Database db, Map<Integer, Contact> contactMap) {
        mMessages.load(db, this, contactMap);
    }

    public ChatMessages getMessages() {
        return mMessages;
    }

    public boolean addMessage(KonMessage message) {
        assert message.getChat() == this;

        boolean added = mMessages.add(message);
        if (added) {
            if (message.isInMessage() && mRead) {
                mRead = false;
                this.save();
                this.changed(ViewChange.READ);
            }
            this.changed(ViewChange.NEW_MESSAGE);
        }
        return added;
    }

    public int getID() {
        return mID;
    }

    public boolean isRead() {
        return mRead;
    }

    public void setRead() {
        if (mRead)
            return;

        mRead = true;
        this.save();
        this.changed(ViewChange.READ);
    }

    public ViewSettings getViewSettings() {
        return mViewSettings;
    }

    public void setViewSettings(ViewSettings settings) {
        if (settings.equals(mViewSettings))
            return;

        mViewSettings = settings;
        this.save();
        this.changed(ViewChange.VIEW_SETTINGS);
    }

    public boolean isGroupChat() {
        return (this instanceof GroupChat);
    }

    public abstract List<Member> getAllMembers();

    /** Get all contacts (including deleted, blocked and user contact). */
    public abstract List<Contact> getAllContacts();

    /** Get valid receiver contacts (without deleted and blocked). */
    public abstract List<Contact> getValidContacts();

    /** XMPP thread ID (empty string if not set). */
    public abstract String getXMPPID();

    /** Subject/title (empty string if not set). */
    public abstract String getSubject();

    /**
     * Return if new outgoing messages in chat will be encrypted.
     * True if encryption is turned on for at least one valid chat contact.
     */
    public abstract boolean isSendEncrypted();

    /**
     * Return if new outgoing messages could be send encrypted.
     * True if all valid  chat contacts have a key.
     */
    public abstract boolean canSendEncrypted();

    /** Return if new valid outgoing message could be send. */
    public abstract boolean isValid();

    public abstract boolean isAdministratable();

    public abstract void setChatState(Contact contact, ChatState chatState);

    abstract void save();

    // not saving members here
    void save(String subject) {
        Map<String, Object> set = new HashMap<>();
        set.put(COL_SUBJ, Database.setString(subject));
        set.put(COL_READ, mRead);
        set.put(COL_VIEW_SET, mViewSettings.toJSONString());

        Database db = Model.database();
        db.execUpdate(TABLE, set, mID);
    }

    void delete() {
        // messages
        boolean succ = mMessages.getAll().stream().allMatch(KonMessage::delete);
        if (!succ)
            return;

        // members
        Database db = Model.database();
        succ = this.getAllMembers().stream().allMatch(m -> m.delete(db));
        if (!succ)
            return;

        // chat itself
        db.execDelete(TABLE, mID);

        // all done, commit deletions
        succ = db.commit();
        if (!succ)
            return;

        mDeleted = true;
    }

    public boolean isDeleted()  {
        return mDeleted;
    }

    void changed(ViewChange change) {
        this.setChanged();
        this.notifyObservers(change);
    }

    @Override
    public void update(Observable o, Object arg) {
        this.changed(ViewChange.CONTACT);
    }

    @Override
    public boolean contains(String search) {
            for (Contact contact: this.getAllContacts()) {
                if (contact.contains(search))
                    return true;
            }
            return this.getSubject().toLowerCase().contains(search);
    }

    static Optional<Chat> load(Database db, ResultSet rs, Map<Integer, Contact> contactMap)
            throws SQLException {
        int id = rs.getInt("_id");

        String jsonGD = Database.getString(rs, Chat.COL_GD);
        GroupMetaData gData = jsonGD.isEmpty() ?
                null :
                GroupMetaData.fromJSONOrNull(jsonGD);

        String xmppID = Database.getString(rs, Chat.COL_XMPPID);

        // get members of chat
        List<Member> members = Member.load(db, id, contactMap);

        String subject = Database.getString(rs, Chat.COL_SUBJ);

        boolean read = rs.getBoolean(Chat.COL_READ);

        String jsonViewSettings = Database.getString(rs,
                Chat.COL_VIEW_SET);

        Chat chat;
        if (gData != null) {
            chat = GroupChat.create(id, members, gData, subject, read, jsonViewSettings);
        } else {
            if (members.size() != 1) {
                LOGGER.warning("not one contact for single chat, id="+id);
                return Optional.empty();
            }
            chat = new SingleChat(id, members.get(0), xmppID, read, jsonViewSettings);
        }

        chat.loadMessages(db, contactMap);
        return Optional.of(chat);
    }

    public static class ViewSettings {
        private static final String JSON_BG_COLOR = "bg_color";
        private static final String JSON_IMAGE_PATH = "img";

        // background color, if set
        private final Color mColor;
        // custom image, if set
        private final String mImagePath;

        private ViewSettings(String json) {
            Object obj = JSONValue.parse(json);
            Color color;
            String imagePath;
            try {
                Map<?, ?> map = (Map) obj;
                color = map.containsKey(JSON_BG_COLOR) ?
                    new Color(((Long) map.get(JSON_BG_COLOR)).intValue()) :
                    null;
                imagePath = map.containsKey(JSON_IMAGE_PATH) ?
                    (String) map.get(JSON_IMAGE_PATH) :
                    "";
            } catch (NullPointerException | ClassCastException ex) {
                LOGGER.log(Level.WARNING, "can't parse JSON view settings", ex);
                color = null;
                imagePath = "";
            }
            mColor = color;
            mImagePath = imagePath;
        }

        public static ViewSettings createDefault() {
            return new ViewSettings(null, "");
        }

        public static ViewSettings fromColor(Color color) {
            return new ViewSettings(color, "");
        }

        public static ViewSettings fromImagePath(String imagePath) {
            return new ViewSettings(null, imagePath);
        }

        private ViewSettings(Color c, String p) {
            mColor = c;
            mImagePath = p;
        }

        public Optional<Color> getBGColor() {
            return Optional.ofNullable(mColor);
        }

        public String getImagePath() {
            return mImagePath;
        }

        // using legacy lib, raw types extend Object
        @SuppressWarnings("unchecked")
        String toJSONString() {
            JSONObject json = new JSONObject();
            if (mColor != null)
                json.put(JSON_BG_COLOR, mColor.getRGB());
            if (!mImagePath.isEmpty())
                json.put(JSON_IMAGE_PATH, mImagePath);
            return json.toJSONString();
        }

        @Override
        public final boolean equals(Object o) {
            if (o == this)
                return true;

            if (!(o instanceof ViewSettings))
                return false;

            ViewSettings ovs = (ViewSettings) o;

            return ObjectUtils.equals(mColor, ovs.mColor) &&
                    mImagePath.equals(ovs.mImagePath);
        }

        @Override
        public int hashCode() {
            return Objects.hash(mColor, mImagePath);
        }

        @Override
        public String toString() {
            return "VS:color="+mColor+",imgPath="+mImagePath;
        }
    }
} public final class InMessage extends KonMessage implements DecryptMessage {

    private final Transmission mTransmission;

    public InMessage(ProtoMessage proto, Chat chat, JID from,
            String xmppID, Optional<Date> serverDate) {
        super(
                chat,
                xmppID,
                proto.getContent(),
                serverDate,
                Status.IN,
                proto.getCoderStatus());

        mTransmission = new Transmission(proto.getContact(), from, mID);
    }

    // used when loading from database
    InMessage(KonMessage.Builder builder) {
        super(builder);

        if (builder.mTransmissions.size() != 1)
            throw new IllegalArgumentException("builder does not contain one transmission");

        mTransmission = builder.mTransmissions.iterator().next();
    }

    @Override
    public Contact getContact() {
        return mTransmission.getContact();
    }

    public JID getJID() {
        return mTransmission.getJID();
    }

    @Override
    public void setSigning(Coder.Signing signing) {
        mCoderStatus.setSigning(signing);
        this.save();
    }

    @Override
    public String getEncryptedContent() {
        return mContent.getEncryptedContent();
    }

    @Override
    public void setDecryptedContent(MessageContent decryptedContent) {
        mContent.setDecryptedContent(decryptedContent);
        mCoderStatus.setDecrypted();
        this.save();
        this.changed(ViewChange.CONTENT);
    }

    @Override
    public Set<Transmission> getTransmissions() {
        return new HashSet<>(Collections.singletonList(mTransmission));
    }

    @Override
    public final boolean equals(Object o) {
        if (o == this)
            return true;

        if (!(o instanceof InMessage))
            return false;

        InMessage oMessage = (InMessage) o;
        return this.abstractEquals(oMessage) &&
                mTransmission.equals(oMessage.mTransmission);
    }

    @Override
    public int hashCode() {
        int hash = this.abstractHashCode();
        hash = 67 * hash + Objects.hash(mTransmission);
        return hash;
    }
} 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);
        }
    }
} public abstract static class Attachment extends Observable {
    static final String JSON_URL = "url";
    static final String JSON_FILENAME = "file_name";

    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 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 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
    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);
        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 final class OutMessage extends KonMessage {
    private static final Logger LOGGER = Logger.getLogger(OutMessage.class.getName());

    private final Set<Transmission> mTransmissions;

    public OutMessage(Chat chat, List<Contact> contacts,
            MessageContent content, boolean encrypted) {
        super(
                chat,
                "Kon_" + EncodingUtils.randomString(8),
                content,
                Optional.empty(),
                Status.PENDING,
                encrypted ?
                        CoderStatus.createToEncrypt() :
                        CoderStatus.createInsecure());

        Set<Transmission> ts = new HashSet<>();
        contacts.forEach(contact -> {
            boolean succ = ts.add(new Transmission(contact, contact.getJID(), mID));
            if (!succ)
                LOGGER.warning("duplicate contact: " + contact);
        });
        mTransmissions = Collections.unmodifiableSet(ts);
    }

    // used when loading from database
    OutMessage(KonMessage.Builder builder) {
        super(builder);

        mTransmissions = Collections.unmodifiableSet(builder.mTransmissions);
    }

    public void setReceived(JID jid, Date date) {
        Transmission transmission = mTransmissions.stream()
                .filter(t -> t.getContact().getJID().equals(jid))
                .findFirst().orElse(null);
        if (transmission == null) {
            LOGGER.warning("can't find transmission for received status, IDs: "+jid);
            return;
        }

        if (transmission.isReceived())
            // probably already received by another client
            return;

        transmission.setReceived(date);
        this.changed(ViewChange.STATUS);
    }

    public void setStatus(Status status) {
        if (status == Status.IN || status == Status.RECEIVED) {
            LOGGER.warning("wrong status argument: "+status);
            return;
        }

        if (status == Status.SENT && mStatus != Status.PENDING)
            LOGGER.warning("unexpected new status of sent message: "+status);

        mStatus = status;
        if (status != Status.PENDING)
            mServerDate = new Date();
        this.save();
        this.changed(ViewChange.STATUS);
    }

    // Note: only one error per message (not transmission) possible
    public void setServerError(String condition, String text) {
        if (mStatus != Status.SENT)
            LOGGER.warning("unexpected status of message with error: "+mStatus);
        mServerError = new KonMessage.ServerError(condition, text);
        this.setStatus(Status.ERROR);
    }

    public boolean isSendEncrypted() {
        return mCoderStatus.getEncryption() != Coder.Encryption.NOT ||
                mCoderStatus.getSigning() != Coder.Signing.NOT;
    }

    @Override
    public Set<Transmission> getTransmissions() {
        return mTransmissions;
    }

    @Override
    public final boolean equals(Object o) {
        if (o == this)
            return true;

        if (!(o instanceof OutMessage))
            return false;

        return this.abstractEquals((KonMessage) o);
    }

    @Override
    public int hashCode() {
        int hash = this.abstractHashCode();
        hash = 67 * hash + Objects.hash(mTransmissions);
        return hash;
    }
} final public class Transmission {
    private static final Logger LOGGER = Logger.getLogger(Transmission.class.getName());

    public static final String TABLE = "transmissions";
    public static final String COL_MESSAGE_ID = "message_id";
    private static final String COL_CONTACT_ID = "user_id";
    private static final String COL_JID = "jid";
    private static final String COL_REC_DATE = "received_date";
    public static final String SCHEMA = "( " +
            Database.SQL_ID +
            COL_MESSAGE_ID + " INTEGER NOT NULL, " +
            // from or to contact
            COL_CONTACT_ID + " INTEGER NOT NULL, " +
            // full jid with resource
            // TODO do we really need this anywhere?
            COL_JID + " TEXT NOT NULL, " +
            // received date, if received yet
            COL_REC_DATE + " INTEGER, " +
            "FOREIGN KEY ("+COL_MESSAGE_ID+") REFERENCES "+KonMessage.TABLE+" (_id) " +
            "FOREIGN KEY ("+COL_CONTACT_ID+") REFERENCES "+Contact.TABLE+" (_id) " +
            ")";

    private final int mID;

    private final Contact mContact;
    private final JID mJID;
    private Date mReceivedDate;

    Transmission(Contact contact, JID jid, int messageID) {
        mContact = contact;
        mJID = jid;
        mReceivedDate = null;

        mID = this.insert(messageID);
    }

    private Transmission(int id, Contact contact, JID jid, Date receivedDate) {
        mID = id;
        mContact = contact;
        mJID = jid;
        mReceivedDate = receivedDate;
    }

    public Contact getContact() {
        return mContact;
    }

    public JID getJID() {
        return mJID;
    }

    public Optional<Date> getReceivedDate() {
        return Optional.ofNullable(mReceivedDate);
    }

    boolean isReceived() {
        return mReceivedDate != null;
    }

    void setReceived(Date date) {
        mReceivedDate = date;
        this.save();
    }

    private int insert(int messageID) {
        List<Object> values = Arrays.asList(
                messageID,
                mContact.getID(),
                mJID,
                mReceivedDate);

        int id = Model.database().execInsert(TABLE, values);
        if (id <= 0) {
            LOGGER.log(Level.WARNING, "could not insert");
            return -2;
        }
        return id;
    }

    private void save() {
        Map<String, Object> set = new HashMap<>();
        set.put(COL_REC_DATE, mReceivedDate);
        Model.database().execUpdate(TABLE, set, mID);
    }

    boolean delete() {
        if (mID < 0) {
            LOGGER.warning("not in database: "+this);
            return true;
        }
        return Model.database().execDelete(TABLE, mID);
    }

    @Override
    public String toString() {
        return "T:id="+mID+",contact="+mContact+",jid="+mJID+",recdate="+mReceivedDate;
    }

    static Set<Transmission> load(int messageID, Map<Integer, Contact> contactMap) {
        HashSet<Transmission> ts = new HashSet<>();
        try (ResultSet transmissionRS = Model.database().execSelectWhereInsecure(TABLE,
                COL_MESSAGE_ID + " == " + messageID)) {
            while (transmissionRS.next()) {
                ts.add(load(transmissionRS, contactMap));
            }
        } catch (SQLException ex) {
            LOGGER.log(Level.WARNING, "can't load transmission(s) from db", ex);
            return Collections.emptySet();
        }
        if (ts.isEmpty())
            LOGGER.warning("no transmission(s) found, messageID: "+messageID);
        return ts;
    }

    private static Transmission load(ResultSet resultSet,
            Map<Integer, Contact> contactMap)
            throws SQLException {
        int id = resultSet.getInt("_id");

        int contactID = resultSet.getInt(COL_CONTACT_ID);
        Contact contact = contactMap.get(contactID);
        if (contact == null) {
            LOGGER.warning("can't find contact in db, id: "+contactID);
            return null;
        }
        JID jid = JID.full(resultSet.getString(COL_JID));
        long rDate = resultSet.getLong(COL_REC_DATE);
        Date receivedDate = rDate == 0 ? null : new Date(rDate);

        return new Transmission(id, contact, jid, receivedDate);
    }

    @Override
    public final boolean equals(Object o) {
        if (o == this)
            return true;

        if (!(o instanceof Transmission))
            return false;

        Transmission oTransmission = (Transmission) o;

        return mContact.equals(oTransmission.mContact)
                && mJID.equals(oTransmission.mJID);
    }

    @Override
    public int hashCode() {
        return Objects.hash(mContact, mJID);
    }
} public final class Config extends PropertiesConfiguration {
    private static final Logger LOGGER = Logger.getLogger(Config.class.getName());

    private static Config INSTANCE = null;

    private static final String FILENAME = "kontalk.properties";

    // all configuration property keys
    // disable network property for now -> same as server host
    //public static final String SERV_NET = "server.network";
    public static final String SERV_HOST = "server.host";
    public static final String SERV_PORT = "server.port";
    public static final String SERV_CERT_VALIDATION = "server.cert_validation";
    public static final String ACC_PASS = "account.passphrase";
    public static final String ACC_JID = "account.jid";
    public static final String VIEW_FRAME_WIDTH = "view.frame.width";
    public static final String VIEW_FRAME_HEIGHT = "view.frame.height";
    public static final String VIEW_SELECTED_CHAT = "view.thread";
    public static final String VIEW_CHAT_BG = "view.thread_bg";
    public static final String VIEW_USER_CONTACT = "view.user_in_contactlist";
    public static final String VIEW_HIDE_BLOCKED = "view.hide_blocked_contacts";
    public static final String VIEW_MESSAGE_FONT_SIZE = "view.msg_font_size";
    public static final String NET_SEND_CHAT_STATE = "net.chatstate";
    public static final String NET_SEND_ROSTER_NAME = "net.roster_name";
    public static final String NET_STATUS_LIST = "net.status_list";
    public static final String NET_AUTO_SUBSCRIPTION = "net.auto_subscription";
    public static final String NET_REQUEST_AVATARS = "net.request_avatars";
    public static final String NET_MAX_IMG_SIZE = "net.max_img_size";
    public static final String MAIN_CONNECT_STARTUP = "main.connect_startup";
    public static final String NET_RETRY_CONNECT = "main.retry_connect";
    public static final String MAIN_TRAY = "main.tray";
    public static final String MAIN_TRAY_CLOSE = "main.tray_close";
    public static final String MAIN_ENTER_SENDS = "main.enter_sends";

    // default server address
    //public static final String DEFAULT_SERV_NET = "kontalk.net";
    public static final String DEFAULT_SERV_HOST = "beta.kontalk.net";
    public static final int DEFAULT_SERV_PORT = 5999;

    private final String mDefaultXMPPStatus =
            Tr.tr("Hey, I'm using Kontalk on my PC!");

    private Config(Path configFile) {
        super();

        // separate list elements by tab character
        this.setListDelimiter((char) 9);

        try {
            this.load(configFile.toString());
        } catch (ConfigurationException ex) {
            LOGGER.info("configuration file not found; using default values");
        }

        this.setFileName(configFile.toString());

        // init config / set default values for new properties
        Map<String, Object> map = new HashMap<>();
        //map.put(SERV_NET, DEFAULT_SERV_NET);
        map.put(SERV_HOST, DEFAULT_SERV_HOST);
        map.put(SERV_PORT, DEFAULT_SERV_PORT);
        map.put(SERV_CERT_VALIDATION, true);
        map.put(ACC_PASS, "");
        map.put(ACC_JID, "");
        map.put(VIEW_FRAME_WIDTH, 600);
        map.put(VIEW_FRAME_HEIGHT, 650);
        map.put(VIEW_SELECTED_CHAT, -1);
        map.put(VIEW_CHAT_BG, "");
        map.put(VIEW_USER_CONTACT, false);
        map.put(VIEW_HIDE_BLOCKED, false);
        map.put(VIEW_MESSAGE_FONT_SIZE, -1);
        map.put(NET_SEND_CHAT_STATE, true);
        map.put(NET_SEND_ROSTER_NAME, false);
        map.put(NET_STATUS_LIST, new String[]{mDefaultXMPPStatus});
        map.put(NET_AUTO_SUBSCRIPTION, false);
        map.put(NET_REQUEST_AVATARS, true);
        map.put(NET_MAX_IMG_SIZE, -1);
        map.put(NET_RETRY_CONNECT, true);
        map.put(MAIN_CONNECT_STARTUP, true);
        map.put(MAIN_TRAY, true);
        map.put(MAIN_TRAY_CLOSE, false);
        map.put(MAIN_ENTER_SENDS, true);

        map.entrySet().stream()
                .filter(e -> !this.containsKey(e.getKey()))
                .forEach(e -> this.setProperty(e.getKey(), e.getValue()));
    }

    public void saveToFile() {
        try {
            this.save();
        } catch (ConfigurationException ex) {
            LOGGER.log(Level.WARNING, "can't save configuration", ex);
        }
    }

    public static void initialize(Path appDir) {
        if (INSTANCE != null) {
            LOGGER.warning("already initialized");
            return;
        }

        INSTANCE = new Config(appDir.resolve(Config.FILENAME));
    }

    public static Config getInstance() {
        if (INSTANCE == null)
            throw new IllegalStateException("not initialized");

        return INSTANCE;
    }
} public class Tr {
    private static final Logger LOGGER = Logger.getLogger(Tr.class.getName());

    private static final String DEFAULT_LANG = "en";
    private static final String I18N_DIR = "i18n/";
    private static final String STRING_FILE = "strings";
    private static final String PROP_EXT = ".properties";

    private static final String WIKI_BASE = "https://github.com/kontalk/desktopclient-java/wiki";
    private static final String WIKI_HOME = "Home";
    private static final List<String> WIKI_LANGS = Arrays.asList("de");

    /** Map default (English) strings to translated strings. **/
    private static Map<String, String> TR_MAP = null;

    /**
     * Translate string used in user interface.
     * Spaces at beginning or end of string not supported!
     * @param s string that wants to be translated (in English)
     * @return translation of input string (depending on platform language)
     */
    public static String tr(String s) {
        if (TR_MAP == null || !TR_MAP.containsKey(s))
            return s;
        return TR_MAP.get(s);
    }

    public static void init() {
        // get language
        String lang = Locale.getDefault().getLanguage();
        // for testing
        //String lang = new Locale("zh").getLanguage();
        if (lang.equals(DEFAULT_LANG)) {
            return;
        }

        LOGGER.info("Setting language: "+lang);

        // load string keys file
        String path = I18N_DIR + STRING_FILE + PROP_EXT;
        PropertiesConfiguration stringKeys;
        try {
            stringKeys = new PropertiesConfiguration(ClassLoader.getSystemResource(path));
        } catch (ConfigurationException ex) {
            LOGGER.log(Level.WARNING, "can't load string key file", ex);
            return;
        }

        // load translation file
        path = I18N_DIR + STRING_FILE + "_" + lang + PROP_EXT;
        URL url = ClassLoader.getSystemResource(path);
        if (url == null) {
            LOGGER.info("can't find translation file: "+path);
            return;
        }
        PropertiesConfiguration tr = new PropertiesConfiguration();
        tr.setEncoding("UTF-8");
        try {
            tr.load(url);
        } catch (ConfigurationException ex) {
            LOGGER.log(Level.WARNING, "can't load translation file", ex);
            return;
        }

        TR_MAP = new HashMap<>();
        Iterator<String> it = tr.getKeys();
        while (it.hasNext()) {
            String k = it.next();
            if (!stringKeys.containsKey(k)) {
                LOGGER.warning("key in translation but not in key file: "+k);
                continue;
            }
            TR_MAP.put(stringKeys.getString(k), tr.getString(k));
        }
    }

    public static String getLocalizedWikiLink() {
        String lang = Locale.getDefault().getLanguage();
        if (WIKI_LANGS.contains(lang)) {
            // damn URI decoding
            return WIKI_BASE + "/%5B" + lang + "%5D-" + WIKI_HOME;
        }
        return WIKI_BASE;
    }
} final class Background implements ImageObserver {
    private final Component mParent;
    // background image from resource or user selected
    private final Image mOrigin;
    // background color, set by user or null
    private final Color mCustomColor;
    // cached background with size of viewport
    private BufferedImage mCached = null;

    /** Default, no chat specific settings. */
    Background(Component parent) {
        this(parent, (Color) null);
    }

    /** Chat specific color. */
    Background(Component parent, Color bottomColor) {
        this(parent, Utils.getImage("chat_bg.png"), bottomColor);
    }

    /** Image set by user (global or only for chat). */
    Background(Component parent, String imagePath) {
        // image loaded async!
        this(parent, Toolkit.getDefaultToolkit().createImage(imagePath), null);
    }

    private Background(Component parent, Image origin, Color color) {
        mParent = parent;
        mOrigin = origin;
        mCustomColor = color;
    }

    /**
     * Update the background image for this parent. Returns immediately, but
     * repaints parent if updating is done asynchronously.
     * @return if synchronized update is possible the updated image, else an
     * old image if present
     */
    Optional<BufferedImage> updateNowOrLater() {
        if (mCached == null ||
                mCached.getWidth() != mParent.getWidth() ||
                mCached.getHeight() != mParent.getHeight()) {
            if (this.loadOrigin()) {
                // goto 2
                this.scaleOrigin();
            }
        }
        return Optional.ofNullable(mCached);
    }

    // step 1: ensure original image is loaded (if present)
    private boolean loadOrigin() {
        if (mOrigin == null)
            return true;
        return mOrigin.getWidth(this) != -1;
    }

    // step 2: scale image (if present)
    private boolean scaleOrigin() {
        if (mOrigin == null) {
            // goto 3
            this.updateCachedBG(null);
            return true;
        }
        Image scaledImage = MediaUtils.scaleMaxAsync(mOrigin,
                mParent.getWidth(),
                mParent.getHeight());
        if (scaledImage.getWidth(this) != -1) {
            // goto 3
            this.updateCachedBG(scaledImage);
            return true;
        }
        return false;
    }

    // step 3: paint cache from scaled image (if present)
    private void updateCachedBG(Image scaledImage) {
        int width = mParent.getWidth();
        int height = mParent.getHeight();
        mCached = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB);
        Graphics2D cachedG = mCached.createGraphics();
        if (scaledImage == null)
            return;
        // tiling
        int iw = scaledImage.getWidth(null);
        int ih = scaledImage.getHeight(null);
        for (int x = 0; x < width; x += iw) {
            for (int y = 0; y < height; y += ih) {
                cachedG.drawImage(scaledImage, x, y, iw, ih, null);
            }
        }

        // gradient background of background
        if (mCustomColor != null) {
            Color overlayColor = new Color(
                    mCustomColor.getRed(),
                    mCustomColor.getGreen(),
                    mCustomColor.getBlue(),
                    View.CHAT_BG_ALPHA);
            cachedG.setPaint(overlayColor);
            cachedG.fillRect(0, 0, width, ChatView.this.getHeight());
        }
    }

    @Override
    public boolean imageUpdate(Image img, int infoflags, int x, int y, int w, int h) {
        // ignore if image is not completely loaded
        if ((infoflags & ImageObserver.ALLBITS) == 0) {
            return true;
        }

        if (img.equals(mOrigin)) {
            // original image done loading, goto 2
            boolean sync = this.scaleOrigin();
            if (sync)
                mParent.repaint();
            return false;
        } else {
            // scaling done, goto 3
            this.updateCachedBG(img);
            mParent.repaint();
            return false;
        }
    }
} static class AttachmentPanel extends GroupPanel {
    private final WebLabel mStatus;
    private final WebLinkLabel mAttLabel;
    private final WebFileChooser mFileChooser;

    private File mFile = null;

    AttachmentPanel() {
        super(View.GAP_SMALL, false);

        this.add(mStatus = new WebLabel().setItalicFont());
        this.add(mAttLabel = new WebLinkLabel());

        mFileChooser = new WebFileChooser() {
            @Override
            public void approveSelection(){
                File f = getSelectedFile();
                if (f.exists()) {
                    int option = JOptionPane.showConfirmDialog(this,
                            String.format(
                                    Tr.tr("The file \"%s\" already exists, overwrite?"),
                                    f.getName()),
                            Tr.tr("Overwrite File?"),
                            JOptionPane.OK_CANCEL_OPTION);
                    if (option != JOptionPane.YES_OPTION)
                        return; // doing nothing means not approve
                }
                super.approveSelection();
            }
        };
    }

    /** Set image preview. */
    void setAttachment(Path imagePath, Path linkPath) {
        this.setAttachment("", imagePath, linkPath);
    }

    /** Set link text. */
    void setAttachment(String text, Path linkPath) {
        this.setAttachment(text, null, linkPath);
    }

    private void setAttachment(String text, Path imagePath, Path linkPath) {
        mFile = linkPath.toFile();

        mAttLabel.setIcon(imagePath == null ?
                null :
                // file should be present and should be an image, show it
                ImageLoader.imageIcon(imagePath));

        mAttLabel.setLink(text, Utils.createLinkRunnable(linkPath));
    }

    @Override
    public JPopupMenu getComponentPopupMenu() {
        WebPopupMenu menu = new WebPopupMenu();

        if (mFile == null)
            return null; // should never happen

        WebMenuItem saveMenuItem = new WebMenuItem(Tr.tr("Save File As…"));
        saveMenuItem.addActionListener(new ActionListener() {
            @Override
            public void actionPerformed(ActionEvent event) {
                if (mFile == null)
                    return; // should never happen

                File suggestedFile = new File(
                        mFileChooser.getCurrentDirectory(), mFile.getName());

                mFileChooser.setSelectedFile(suggestedFile);
                // fix WebLaf bug
                mFileChooser.getFileChooserPanel().setSelectedFiles(new File[]{suggestedFile});

                int option = mFileChooser.showSaveDialog(AttachmentPanel.this);
                if (option == JFileChooser.APPROVE_OPTION) {
                    try {
                        Files.copy(mFile.toPath(), mFileChooser.getSelectedFile().toPath(),
                                StandardCopyOption.REPLACE_EXISTING);
                    } catch (IOException ex) {
                        LOGGER.log(Level.WARNING, "can't copy file", ex);
                    }
                }
            }
        });
        if (!mFile.exists()) {
            saveMenuItem.setEnabled(false);
            saveMenuItem.setToolTipText(Tr.tr("File does not exist"));
        }
        menu.add(saveMenuItem);

        return menu;
    }

    void setStatus(String text) {
        mStatus.setText(text);
        mAttLabel.setEnabled(text.isEmpty());
    }
}
import javax.swing.AbstractAction;
import javax.swing.Action;
import javax.swing.Box;
import javax.swing.Icon;
import javax.swing.JComponent;
import javax.swing.KeyStroke;
import javax.swing.ListSelectionModel;
import javax.swing.SwingUtilities;
import javax.swing.TransferHandler;
import javax.swing.text.AbstractDocument;
import javax.swing.text.BadLocationException;
import javax.swing.text.BoxView;
import javax.swing.text.ComponentView;
import javax.swing.text.Element;
import javax.swing.text.IconView;
import javax.swing.text.LabelView;
import javax.swing.text.ParagraphView;
import javax.swing.text.Style;
import javax.swing.text.StyleConstants;
import javax.swing.text.StyledDocument;
import javax.swing.text.StyledEditorKit;
import javax.swing.text.ViewFactory;
import java.awt.BorderLayout;
import java.awt.Color;
import java.awt.ComponentOrientation;
import java.awt.Dimension;
import java.awt.FlowLayout;
import java.awt.datatransfer.StringSelection;
import java.awt.datatransfer.Transferable;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
import java.nio.file.Path;
import java.util.Date;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.logging.Level;
import java.util.logging.Logger;
import com.alee.extended.panel.FlowPanel;
import com.alee.extended.panel.GroupPanel;
import com.alee.extended.panel.GroupingType;
import com.alee.laf.label.WebLabel;
import com.alee.laf.menu.WebMenuItem;
import com.alee.laf.menu.WebPopupMenu;
import com.alee.laf.panel.WebPanel;
import com.alee.laf.text.WebEditorPane;
import com.alee.laf.text.WebTextPane;
import com.alee.managers.tooltip.TooltipManager;
import org.apache.commons.lang.time.DateUtils;
import org.jivesoftware.smackx.chatstates.ChatState;
import org.kontalk.crypto.Coder;
import org.kontalk.misc.JID;
import org.kontalk.model.Contact;
import org.kontalk.model.chat.Chat;
import org.kontalk.model.message.InMessage;
import org.kontalk.model.message.KonMessage;
import org.kontalk.model.message.MessageContent.Attachment;
import org.kontalk.model.message.MessageContent.GroupCommand;
import org.kontalk.model.message.MessageContent.InAttachment;
import org.kontalk.model.message.OutMessage;
import org.kontalk.model.message.Transmission;
import org.kontalk.persistence.Config;
import org.kontalk.util.Tr;
import org.kontalk.view.ChatView.Background;
import org.kontalk.view.ComponentUtils.AttachmentPanel;
            Dimension prefSize = new Dimension(width, height);

            mTextPane.setSize(prefSize);
            // textArea does not need this but textPane does, and editorPane
            // is again totally different; I love Swing
            mTextPane.setPreferredSize(prefSize);

            boolean showWriting = isLast
                    && value.getChat().getAllMembers().stream()
                    .anyMatch(m -> m.getState() == ChatState.composing);
            mWritingPanel.setMargin(showWriting ? View.MARGIN_SMALL : 0);
            // decoration consumes space, even if nothing is visible in panel
            mWritingPanel.setUndecorated(!showWriting);
            mWritingLabel.setText(showWriting ? Tr.tr("is writing…") : "");
        }
    }

    private static String getFromString(InMessage message) {
        return Utils.displayName(message.getContact(), message.getJID(), View.MAX_NAME_IN_FROM_LABEL);
    }

    /**
     * Fix for the infamous "Wrap long words" problem in Java 7+.
     * Source: https://stackoverflow.com/a/13375811
     */
    private static class WrapEditorKit extends StyledEditorKit {
        final ViewFactory defaultFactory = new WrapColumnFactory();
        @Override
        public ViewFactory getViewFactory() {
            return defaultFactory;
        }

        private static class WrapColumnFactory implements ViewFactory {
            @Override
            public javax.swing.text.View create(Element elem) {
                String kind = elem.getName();
                if (kind != null) {
                    switch (kind) {
                        case AbstractDocument.ContentElementName:
                            return new WrapLabelView(elem);
                        case AbstractDocument.ParagraphElementName:
                            return new ParagraphView(elem);
                        case AbstractDocument.SectionElementName:
                            return new BoxView(elem, javax.swing.text.View.Y_AXIS);
                        case StyleConstants.ComponentElementName:
                            return new ComponentView(elem);
                        case StyleConstants.IconElementName:
                            return new IconView(elem);
                    }
                }
                // default to text display
                return new LabelView(elem);
            }
        }

        private static class WrapLabelView extends LabelView {
            WrapLabelView(Element elem) {
                super(elem);
            }
            @Override
            public float getMinimumSpan(int axis) {
                switch (axis) {
                    case javax.swing.text.View.X_AXIS:
                        return 0;
                    case javax.swing.text.View.Y_AXIS:
                        return super.getMinimumSpan(axis);
                    default:
                        throw new IllegalArgumentException("Invalid axis: " + axis);
                }
            }
        }
    }

    // overwriting non-public BasicTableUI.TableTransferHandler for copy action
    private static class CopyTransferHandler extends TransferHandler {

        private final View mView;

        private CopyTransferHandler(View view) {
            mView = view;
        }

        protected Transferable createTransferable(JComponent c) {
            if (!(c instanceof MessageList)) {
                return null;
            }

            List<KonMessage> messages = ((MessageList) c).getSelectedValues();
            if (messages.isEmpty()) {
                return null;
            }

            StringBuilder plainBuf = new StringBuilder();
            for (KonMessage m : messages) {
                String val = messageToString(m, mView, true);
                plainBuf.append(val).append("\n"); // NOTE: newline after last line
            }

            //return new BasicTransferable(plainBuf.toString(), htmlBuf.toString());
            return new StringSelection(plainBuf.toString());
        }

        public int getSourceActions(JComponent c) {
            return COPY;
        }
    }

    private static String messageToString(KonMessage message, View view, boolean copy) {
        String pre = "";
        if (copy) {
            String date = Utils.LONG_DATE_FORMAT.format(message.getDate());
            String from = message instanceof InMessage ?
                    getFromString((InMessage) message) :
                    Tr.tr("me"); // TODO get my name
            Attachment att = message.getContent().getAttachment().orElse(null);
            String as = att == null ? "" : "[" + att.getFilename() + "] ";
            pre = date + " - " + from + " : " + as;
        }

        String text = "";
        GroupCommand com = message.getContent().getGroupCommand().orElse(null);