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 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 ChatList extends Observable implements Observer, Iterable<Chat> {
    private static final Logger LOGGER = Logger.getLogger(ChatList.class.getName());

    public enum ViewChange {
        MODIFIED, UNREAD
    }

    private final Set<Chat> mChats = Collections.synchronizedSet(new HashSet<Chat>());

    private boolean mUnread = false;

    public void load(Map<Integer, Contact> contactMap) {
        assert mChats.isEmpty();

        Database db = Model.database();
        try (ResultSet chatRS = db.execSelectAll(Chat.TABLE)) {
            while (chatRS.next()) {
                Chat chat = Chat.load(db, chatRS, contactMap).orElse(null);
                if (chat == null)
                    continue;
                this.putSilent(chat);

                mUnread |= !chat.isRead();
            }
        } catch (SQLException ex) {
            LOGGER.log(Level.WARNING, "can't load chats from db", ex);
        }
        this.changed(ViewChange.MODIFIED);
    }

    public Set<Chat> getAll() {
        return Collections.unmodifiableSet(mChats);
    }

    /** Get single chat with contact and XMPPID. */
    public Optional<SingleChat> get(Contact contact, String xmmpThreadID) {
        synchronized(mChats) {
            return mChats.stream()
                    .filter(chat -> chat instanceof SingleChat)
                    .map(chat -> (SingleChat) chat)
                    .filter(chat -> chat.getXMPPID().equals(xmmpThreadID)
                            && chat.getMember().getContact().equals(contact))
                    .findFirst();
        }
    }

    public Optional<GroupChat> get(GroupMetaData gData) {
        synchronized(mChats) {
            return mChats.stream()
                    .filter(chat -> chat instanceof GroupChat)
                    .map(chat -> (GroupChat) chat)
                    .filter(chat -> chat.getGroupData().equals(gData))
                    .findFirst();
        }
    }

    public SingleChat getOrCreate(Contact contact) {
        return this.getOrCreate(contact, "");
    }

    /** Find single chat for contact and XMPP ID or creates a new chat. */
    public SingleChat getOrCreate(Contact contact, String xmppThreadID) {
        SingleChat chat = this.get(contact, xmppThreadID).orElse(null);
        if (chat != null)
            return chat;

        return this.createNew(contact, xmppThreadID);
    }

    private SingleChat createNew(Contact contact, String xmppThreadID) {
        SingleChat newChat = new SingleChat(contact, xmppThreadID);
        LOGGER.config("new single chat: "+newChat);
        this.putSilent(newChat);
        this.changed(ViewChange.MODIFIED);
        return newChat;
    }

    public GroupChat create(List<ProtoMember> members, GroupMetaData gData) {
        return createNew(members, gData, "");
    }

    public GroupChat createNew(List<ProtoMember> members, GroupMetaData gData, String subject) {
        GroupChat newChat = GroupChat.create(members, gData, subject);
        LOGGER.config("new group chat: "+newChat);
        this.putSilent(newChat);
        this.changed(ViewChange.MODIFIED);
        return newChat;
    }

    private void putSilent(Chat chat) {
        boolean succ = mChats.add(chat);
        if (!succ) {
            LOGGER.warning("chat already in chat list: "+chat);
            return;
        }
        chat.addObserver(this);
    }

    public boolean contains(Contact contact) {
        return this.get(contact, "").isPresent();
    }

    public boolean isEmpty() {
        return mChats.isEmpty();
    }

    public void delete(Chat chat) {
        boolean succ = mChats.remove(chat);
        if (!succ) {
            LOGGER.warning("can't delete chat, not found: "+chat);
            return;
        }
        chat.delete();
        chat.deleteObservers();
        this.changed(ViewChange.MODIFIED);
    }

    /** Return if any chat is unread. */
    public boolean isUnread() {
        return mUnread;
    }

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

    @Override
    public void update(Observable o, Object arg) {
        if (arg != Chat.ViewChange.READ || !(o instanceof Chat))
            return;

        boolean unread = !((Chat) o).isRead();
        if (mUnread == unread)
            return;

        if (!unread) {
            // one chat was read, are there still any unread chats?
            synchronized(mChats) {
                if (mChats.stream().anyMatch(chat -> !chat.isRead()))
                    return;
            }
        }

        mUnread = !mUnread;
        this.changed(ViewChange.UNREAD);
    }

    @Override
    public Iterator<Chat> iterator() {
        return mChats.iterator();
    }
} 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 class MessageContent {
    private static final Logger LOGGER = Logger.getLogger(MessageContent.class.getName());

    // plain message text, empty string if not present
    private final String mPlainText;
    // encrypted content, empty string if not present
    private String mEncryptedContent;
    // attachment (file url, path and metadata)
    private final Attachment mAttachment;
    // small preview file of attachment
    private Preview mPreview;
    // group id
    private final KonGroupData mGroupData;
    // group command
    private final GroupCommand mGroupCommand;
    // decrypted message content
    private MessageContent mDecryptedContent;

    private static final String JSON_PLAIN_TEXT = "plain_text";
    private static final String JSON_ENC_CONTENT = "encrypted_content";
    private static final String JSON_ATTACHMENT = "attachment";
    private static final String JSON_PREVIEW = "preview";
    private static final String JSON_GROUP_COMMAND = "group_command";
    private static final String JSON_DEC_CONTENT = "decrypted_content";

    // used for decrypted content of incoming messages, outgoing messages
    // and as fallback
    public static MessageContent plainText(String plainText) {
        return new Builder().body(plainText).build();
    }

    // used for outgoing messages
    public static MessageContent outgoing(String plainText, Attachment attachment) {
        return new Builder().body(plainText).attachment(attachment).build();
    }

    // used for outgoing group commands
    public static MessageContent groupCommand(GroupCommand group) {
        return new Builder().groupCommand(group).build();
    }

    private MessageContent(Builder builder) {
        mPlainText = builder.mBodyText;
        mEncryptedContent = builder.mEncrypted;
        mAttachment = builder.mAttachment;
        mPreview = builder.mPreview;
        mGroupData = builder.mGroupData;
        mGroupCommand = builder.mGroupCommand;
        mDecryptedContent = builder.mDecrypted;
    }

    /**
     * Get encrypted or plain text content.
     * @return decrypted content text if present, else plain text. If there is no
     * plain text either returns an empty string.
     */
    public String getText() {
        return mDecryptedContent != null ? mDecryptedContent.getPlainText() : mPlainText;
    }

    public String getPlainText() {
        return mPlainText;
    }

    public Optional<Attachment> getAttachment() {
        if (mDecryptedContent != null && mDecryptedContent.getAttachment().isPresent()) {
            return mDecryptedContent.getAttachment();
        }
        return Optional.ofNullable(mAttachment);
    }

    public Optional<InAttachment> getInAttachment() {
        Attachment att = this.getAttachment().orElse(null);
        return !(att instanceof InAttachment)? Optional.empty() : Optional.of((InAttachment) att);
    }

    public Optional<OutAttachment> getOutAttachment() {
        Attachment att = this.getAttachment().orElse(null);
        return !(att instanceof OutAttachment)? Optional.empty() : Optional.of((OutAttachment) att);
    }

    String getEncryptedContent() {
        return mEncryptedContent;
    }

    void setDecryptedContent(MessageContent decryptedContent) {
        assert mDecryptedContent == null;
        mDecryptedContent = decryptedContent;
        // deleting encrypted data!
        mEncryptedContent = "";
    }

    public Optional<Preview> getPreview() {
        if (mDecryptedContent != null && mDecryptedContent.getPreview().isPresent()) {
            return mDecryptedContent.getPreview();
        }
        return Optional.ofNullable(mPreview);
    }

    void setPreview(Preview preview) {
        if (mPreview != null) {
            LOGGER.warning("preview already present, not overwriting");
            return;
        }
        mPreview = preview;
    }

    public Optional<GroupMetaData> getGroupData() {
        if (mDecryptedContent != null &&
                mDecryptedContent.getGroupData().isPresent()) {
            return mDecryptedContent.getGroupData();
        }
        return Optional.ofNullable(mGroupData);
    }

    public Optional<GroupCommand> getGroupCommand() {
        if (mDecryptedContent != null &&
                mDecryptedContent.getGroupCommand().isPresent()) {
            return mDecryptedContent.getGroupCommand();
        }
        return Optional.ofNullable(mGroupCommand);
    }

    /**
     * Return if there is no content in this message.
     * @return true if there is no content at all, false otherwise
     */
    public boolean isEmpty() {
        return mPlainText.isEmpty() &&
                mEncryptedContent.isEmpty() &&
                mAttachment == null &&
                mPreview == null &&
                mDecryptedContent == null &&
                mGroupCommand == null;
    }

    @Override
    public String toString() {
        return "CONT:plain="+mPlainText+",encr="+mEncryptedContent
                +",att="+mAttachment+",gd="+mGroupData+",gc="+mGroupCommand
                +",decr="+mDecryptedContent;
    }

    // using legacy lib, raw types extend Object
    @SuppressWarnings("unchecked")
    String toJSON() {
        JSONObject json = new JSONObject();

        EncodingUtils.putJSON(json, JSON_PLAIN_TEXT, mPlainText);

        if (mAttachment != null)
            json.put(JSON_ATTACHMENT, mAttachment.toJSONString());

        EncodingUtils.putJSON(json, JSON_ENC_CONTENT, mEncryptedContent);

        if (mPreview != null)
            json.put(JSON_PREVIEW, mPreview.toJSON());

        if (mGroupCommand != null)
            json.put(JSON_GROUP_COMMAND, mGroupCommand.toJSON());

        if (mDecryptedContent != null)
            json.put(JSON_DEC_CONTENT, mDecryptedContent.toJSON());

        return json.toJSONString();
    }

    static MessageContent fromJSONString(String jsonContent) {
        Object obj = JSONValue.parse(jsonContent);
        try {
            Map<?, ?> map = (Map) obj;

            String plainText = EncodingUtils.getJSONString(map, JSON_PLAIN_TEXT);
            String encrypted = EncodingUtils.getJSONString(map, JSON_ENC_CONTENT);

            String att = (String) map.get(JSON_ATTACHMENT);
            Attachment attachment = att == null ? null : Attachment.fromJSONOrNull(att);

            String pre = (String) map.get(JSON_PREVIEW);
            Preview preview = pre == null ? null : Preview.fromJSONOrNull(pre);

            String gc = (String) map.get(JSON_GROUP_COMMAND);
            GroupCommand groupCommand = gc == null ? null : GroupCommand.fromJSONOrNull(gc);

            String jsonDecryptedContent = (String) map.get(JSON_DEC_CONTENT);
            MessageContent decryptedContent = jsonDecryptedContent == null ?
                    null :
                    fromJSONString(jsonDecryptedContent);

            return new Builder().body(plainText).encrypted(encrypted)
                    .attachment(attachment)
                    .preview(preview)
                    .groupCommand(groupCommand)
                    .decryptedContent(decryptedContent).build();
        } catch(ClassCastException ex) {
            LOGGER.log(Level.WARNING, "can't parse JSON message content", ex);
            return plainText("");
        }
    }

    public abstract static class Attachment extends Observable {
        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 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 static final class OutAttachment extends Attachment {
        private static final String JSON_MIME_TYPE = "mime_type";
        private static final String JSON_LENGTH = "length";

        // path to upload file
        private final Path mFile;
        // URL for file download, empty string by default
        private URI mURL;
        // MIME of file, empty string by default
        private String mMimeType;
        // size of (decrypted) upload file in bytes, -1 by default
        private long mLength;

        /** URI, length and (maybe) new MIME type are set after upload. */
        public OutAttachment(Path path, String mimeType) {
            this(URI.create(""), path, mimeType, -1);
        }

        // used when loading from database.
        private OutAttachment(URI url, Path file,
                String mimeType, long length)  {
            mURL = url;
            mFile = file;
            mMimeType = mimeType;
            mLength = length;
        }

        public boolean hasURL() {
            return !mURL.toString().isEmpty();
        }

        public URI getURL() {
            return mURL;
        }

        @Override
        public String getFilename() {
            return mFile.getFileName().toString();
        }

        public void setUploaded(URI url, String mime, long length){
            mURL = url;
            mMimeType = mime;
            mLength = length;
            this.changed(false);
        }

        @Override
        public String getMimeType() {
            return mMimeType;
        }

        public long getLength() {
            return mLength;
        }

        public Path getFilePath() {
            return mFile;
        }

        @Override
        public int getDownloadProgress() {
            return -1;
        }

        @Override
        public boolean isEncrypted() {
            return false;
        }

        @Override
        public String toString() {
            return "{OATT:file="+mFile+",url="+mURL+",mime="+mMimeType+",length="+mLength+"}";
        }

        // using legacy lib, raw types extend Object
        @SuppressWarnings("unchecked")
        @Override
        protected String toJSONString() {
            JSONObject json = new JSONObject();
            EncodingUtils.putJSON(json, JSON_URL, mURL.toString());
            EncodingUtils.putJSON(json, JSON_MIME_TYPE, mMimeType);
            json.put(JSON_LENGTH, mLength);
            EncodingUtils.putJSON(json, JSON_FILENAME, mFile.toString());
            return json.toJSONString();
        }

        private static OutAttachment fromJSON(Map<?, ?> map) {
            URI url = URI.create(EncodingUtils.getJSONString(map, JSON_URL));
            Path file = Paths.get(EncodingUtils.getJSONString(map, JSON_FILENAME));
            String mimeType = EncodingUtils.getJSONString(map, JSON_MIME_TYPE);
            long length = ((Number) map.get(JSON_LENGTH)).longValue();

            return new OutAttachment(url, file, mimeType, length);
        }
    }

    // immutable
    public static class Preview {

        private static final String JSON_MIME_TYPE = "mime_type";

        private final byte[] mData;
        private final String mMimeType;

        public Preview(byte[] data, String mimeType) {
            mData = data;
            mMimeType = mimeType;
        }

        private Preview(String mimeType) {
            mData = new byte[0];
            mMimeType = mimeType;
        }

        public byte[] getData() {
            return mData;
        }

        public Path getImagePath(int messageID) {
            return !MediaUtils.isImage(mMimeType) ? Paths.get("") :
                    path(AttachmentManager.previewFilename(messageID, mMimeType),
                            AttachmentManager.PREVIEW_DIRNAME);
        }

        public String getMimeType() {
            return mMimeType;
        }

        // using legacy lib, raw types extend Object
        @SuppressWarnings("unchecked")
        private String toJSON() {
            JSONObject json = new JSONObject();
            EncodingUtils.putJSON(json, JSON_MIME_TYPE, mMimeType);
            return json.toJSONString();
        }

        private static Preview fromJSONOrNull(String json) {
            Object obj = JSONValue.parse(json);
            try {
                Map<?, ?> map = (Map) obj;
                String mimeType = EncodingUtils.getJSONString(map, JSON_MIME_TYPE);
                return new Preview(mimeType);
            }  catch (NullPointerException | ClassCastException ex) {
                LOGGER.log(Level.WARNING, "can't parse JSON preview", ex);
                return null;
            }
        }

        @Override
        public String toString() {
            return "{PRE:mime="+mMimeType+"}";
        }
    }

    public static class GroupCommand {
        private static final String JSON_OP = "op";
        private static final String JSON_ADDED = "added";
        private static final String JSON_REMOVED = "removed";
        private static final String JSON_SUBJECT = "subj";

        // ordinals used in database
        public enum OP {
            CREATE,
            SET,
            LEAVE
        }

        private final OP mOP;
        // unchanged members in set command, not saved to database
        private final List<JID> mUnchanged;
        private final List<JID> mAdded;
        private final List<JID> mRemoved;
        private final String mSubject;

        /** Group creation. */
        public static GroupCommand create(List<JID> added, String subject) {
            return new GroupCommand(OP.CREATE, added, Collections.emptyList(), subject);
        }

        /** Group changed. */
        public static GroupCommand set(List<JID> unchanged, List<JID> added, List<JID> removed,
                                       String subject) {
            return new GroupCommand(OP.SET, unchanged, added, removed, subject);
        }

        public static GroupCommand set(String subject) {
            return new GroupCommand(OP.SET, Collections.emptyList(), Collections.emptyList(), subject);
        }

        /** Member left. Identified by sender JID */
        public static GroupCommand leave() {
            return new GroupCommand(OP.LEAVE, Collections.emptyList(), Collections.emptyList(), "");
        }

        private GroupCommand(OP operation, List<JID> added, List<JID> removed, String subject) {
            this(operation, Collections.emptyList(), added, removed, subject);
        }

        private GroupCommand(OP operation, List<JID> unchanged, List<JID> added, List<JID> removed,
                             String subject) {
            mOP = operation;
            mUnchanged = unchanged;
            mAdded = added;
            mRemoved = removed;
            mSubject = subject;
        }

        public OP getOperation() {
            return mOP;
        }

        public List<JID> getUnchanged() {
            return mUnchanged;
        }

        public List<JID> getAdded() {
            return mAdded;
        }

        public boolean isAddingMe() {
            JID myJID = Model.getUserJID();
            return mAdded.stream().anyMatch(jid -> jid.equals(myJID));
        }

        public List<JID> getRemoved() {
            return mRemoved;
        }

        public String getSubject() {
            return mSubject;
        }

        // using legacy lib, raw types extend Object
        @SuppressWarnings("unchecked")
        private String toJSON() {
            JSONObject json = new JSONObject();
            json.put(JSON_OP, mOP.ordinal());
            EncodingUtils.putJSON(json, JSON_SUBJECT, mSubject);

            List<String> added = mAdded.stream()
                    .map(JID::string)
                    .collect(Collectors.toList());
            json.put(JSON_ADDED, added);

            List<String> removed = mRemoved.stream()
                    .map(JID::string)
                    .collect(Collectors.toList());
            json.put(JSON_REMOVED, removed);

            return json.toJSONString();
        }

        // using legacy lib
        @SuppressWarnings("unchecked")
        private static GroupCommand fromJSONOrNull(String json) {
            Object obj = JSONValue.parse(json);
            try {
                Map<?, ?> map = (Map) obj;

                Number o = (Number) map.get(JSON_OP);
                OP op = OP.values()[o.intValue()];

                String subj = EncodingUtils.getJSONString(map, JSON_SUBJECT);

                List<String> a = (List<String>) map.get(JSON_ADDED);
                List<JID> added = a.stream()
                        .map(JID::bare)
                        .collect(Collectors.toList());

                List<String> r = (List<String>) map.get(JSON_REMOVED);
                List<JID> removed = r.stream()
                        .map(JID::bare)
                        .collect(Collectors.toList());

                return new GroupCommand(op, added, removed, subj);
            } catch (NullPointerException | ClassCastException ex) {
                LOGGER.log(Level.WARNING, "can't parse JSON group command", ex);
                LOGGER.log(Level.WARNING, "JSON='"+json+"'");
                return null;
            }
        }

        @Override
        public String toString() {
            return "{GC:op="+mOP+",subj="+mSubject+"}";
        }
    }

    public static class Builder {
        private String mBodyText = "";
        private String mEncrypted = "";
        private Attachment mAttachment = null;
        private Preview mPreview = null;
        private KonGroupData mGroupData = null;
        private GroupCommand mGroupCommand = null;
        private MessageContent mDecrypted = null;

        public Builder body(String body) {
            mBodyText = body; return this; }

        public Builder encrypted(String encrypted) {
            mEncrypted = encrypted; return this; }

        public Builder attachment(Attachment attachment) {
            mAttachment = attachment; return this; }

        public Builder preview(Preview preview) {
            mPreview = preview; return this; }

        public Builder groupData(KonGroupData gData) {
            mGroupData = gData; return this; }

        public Builder groupCommand(GroupCommand group) {
            mGroupCommand = group; return this; }

        private Builder decryptedContent(MessageContent decrypted) {
            mDecrypted = decrypted; return this; }

        public MessageContent build() {
            return new MessageContent(this);
        }
    }

    private static Path path(String filename, String dirName) {
        return filename.isEmpty() ? Paths.get("") :
                Model.appDir().resolve(dirName).resolve(filename);
    }
} 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;
    }
} public final class ProtoMessage implements DecryptMessage {

    private final Contact mContact;
    private final CoderStatus mCoderStatus;
    private final MessageContent mContent;

    public ProtoMessage(Contact contact, MessageContent content) {
        mContact = contact;
        mContent = content;

        boolean encrypted = !content.getEncryptedContent().isEmpty();
        mCoderStatus = new CoderStatus(
            // no decryption attempt yet
            encrypted ? Coder.Encryption.ENCRYPTED : Coder.Encryption.NOT,
            // if encrypted we don't know yet
            encrypted ? Coder.Signing.UNKNOWN : Coder.Signing.NOT,
            // no errors
            EnumSet.noneOf(Coder.Error.class)
        );
    }

    @Override
    public Contact getContact() {
        return mContact;
    }

    public CoderStatus getCoderStatus() {
        return mCoderStatus;
    }

    @Override
    public boolean isEncrypted() {
        return mCoderStatus.isEncrypted();
    }

    MessageContent getContent() {
        return mContent;
    }

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

    @Override
    public void setDecryptedContent(MessageContent content) {
        mContent.setDecryptedContent(content);
        mCoderStatus.setDecrypted();
    }

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

    @Override
    public void setSecurityErrors(EnumSet<Coder.Error> errors) {
        mCoderStatus.setSecurityErrors(errors);
    }

    @Override
    public String toString() {
        return "PM:contact="+mContact+",content="+mContent+",codstat="+mCoderStatus;
    }
} 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 final class Database {
    private static final Logger LOGGER = Logger.getLogger(Database.class.getName());

    public static final String SQL_ID = "_id INTEGER PRIMARY KEY AUTOINCREMENT, ";

    private static final String FILENAME = "kontalk_db.sqlite";
    private static final int DB_VERSION = 5;
    private static final String SQL_CREATE = "CREATE TABLE IF NOT EXISTS ";
    private static final String SV = "schema_version";
    private static final String UV = "user_version";

    private Connection mConn = null;

    public Database(Path appDir) throws KonException {
        // load the sqlite-JDBC driver using the current class loader
        try {
            Class.forName("org.sqlite.JDBC");
        } catch (ClassNotFoundException ex) {
            LOGGER.log(Level.SEVERE, "sqlite-JDBC driver not found", ex);
            throw new KonException(KonException.Error.DB, ex);
        }

        // create database connection
        Path path = appDir.resolve(FILENAME);
        SQLiteConfig config = new SQLiteConfig();
        config.enforceForeignKeys(true);
        try {
          mConn = DriverManager.getConnection("jdbc:sqlite:" + path.toString(), config.toProperties());
        } catch(SQLException ex) {
          // if the error message is "out of memory",
          // it probably means no database file is found
          LOGGER.log(Level.SEVERE, "can't create database connection", ex);
          throw new KonException(KonException.Error.DB, ex);
        }

        try {
            // setting to false!
            mConn.setAutoCommit(false);
        } catch (SQLException ex) {
            LOGGER.log(Level.WARNING, "can't set autocommit", ex);
        }

        boolean isNew;
        try (ResultSet rs = this.execQuery("PRAGMA "+SV)) {
            isNew = rs.getInt(SV) == 0;
        } catch (SQLException ex) {
            LOGGER.log(Level.SEVERE, "can't get schema version", ex);
            throw new KonException(KonException.Error.DB, ex);
        }

        if (isNew) {
            LOGGER.info("new database, creating tables");
            try (Statement stat = mConn.createStatement()) {
                // set version
                mConn.createStatement().execute("PRAGMA "+UV+" = "+DB_VERSION);
                this.commit();
                this.createTable(stat, Contact.TABLE, Contact.SCHEMA);
                this.createTable(stat, Chat.TABLE, Chat.SCHEMA);
                this.createTable(stat, Member.TABLE, Member.SCHEMA);
                this.createTable(stat, KonMessage.TABLE, KonMessage.SCHEMA);
                this.createTable(stat, Transmission.TABLE, Transmission.SCHEMA);
            } catch (SQLException ex) {
                LOGGER.log(Level.SEVERE, "can't create tables", ex);
                throw new KonException(KonException.Error.DB, ex);
            }
            return;
        }

        // update if needed
        int version;
        try (ResultSet rs = this.execQuery("PRAGMA "+UV)) {
            version = rs.getInt(UV);
        } catch (SQLException ex) {
            LOGGER.log(Level.WARNING, "can't get db version", ex);
            return;
        }
        LOGGER.config("version: "+version);
        if (version < DB_VERSION) {
            try {
                this.update(version);
            } catch (SQLException ex) {
                LOGGER.log(Level.WARNING, "can't update db", ex);
            }
        }
    }

    private void createTable(Statement stat, String table, String schema) throws SQLException {
        stat.executeUpdate(SQL_CREATE + table + " " + schema);
    }

    private void update(int fromVersion) throws SQLException {
        if (fromVersion < 1) {
            mConn.createStatement().execute("ALTER TABLE "+Chat.TABLE+
                    " ADD COLUMN "+Chat.COL_VIEW_SET+" NOT NULL DEFAULT '{}'");
        }
        if (fromVersion < 2) {
            mConn.createStatement().execute("ALTER TABLE "+KonMessage.TABLE+
                    " ADD COLUMN "+KonMessage.COL_SERV_DATE+" DEFAULT NULL");
        }
        if (fromVersion < 3) {
            String messageTableTemp = KonMessage.TABLE + "_TEMP";
            this.createTable(mConn.createStatement(), messageTableTemp, KonMessage.SCHEMA);
            mConn.createStatement().execute("INSERT INTO "+messageTableTemp +
                    " SELECT _id, thread_id, xmpp_id, date, receipt_status, " +
                    "content, encryption_status, signing_status, coder_errors, " +
                    "server_error, server_date FROM "+KonMessage.TABLE);

            this.createTable(mConn.createStatement(), Transmission.TABLE, Transmission.SCHEMA);
            mConn.createStatement().execute("INSERT INTO "+Transmission.TABLE +
                    " SELECT NULL, _id, user_id, jid, NULL FROM "+KonMessage.TABLE);

            mConn.createStatement().execute("PRAGMA foreign_keys=OFF");
            mConn.createStatement().execute("DROP TABLE "+KonMessage.TABLE);
            mConn.createStatement().execute("ALTER TABLE "+messageTableTemp+
                    " RENAME TO "+KonMessage.TABLE);
            mConn.createStatement().execute("PRAGMA foreign_keys=ON");

            mConn.createStatement().execute("ALTER TABLE "+Chat.TABLE+
                    " ADD COLUMN "+Chat.COL_GD+" DEFAULT NULL");
        }
        if (fromVersion < 4) {
            mConn.createStatement().execute("ALTER TABLE "+Contact.TABLE+
                    " ADD COLUMN "+Contact.COL_AVATAR_ID+" DEFAULT NULL");
        }
        if (fromVersion < 5) {
            mConn.createStatement().execute("ALTER TABLE "+Member.TABLE+
                    " ADD COLUMN "+Member.COL_ROLE+" DEFAULT 0");
        }

        // set new version
        mConn.createStatement().execute("PRAGMA "+UV+" = "+DB_VERSION);
        this.commit();
        LOGGER.info("updated to version "+DB_VERSION);
    }

    public synchronized void close() {
        try {
            if(mConn == null || mConn.isClosed())
                return;
            // just to be sure
            mConn.commit();
            mConn.close();
        } catch(SQLException ex) {
            LOGGER.log(Level.WARNING, "can't close db", ex);
        }
    }

    /**
     * Select all rows from one table.
     * The returned ResultSet must be closed by the caller after usage!
     */
    public ResultSet execSelectAll(String table) throws SQLException {
        return this.execQuery("SELECT * FROM " + table);
    }

    /**
     * Select rows from one table that match an arbitrary 'where' clause.
     * Insecure to SQL injections, use with caution!
     * The returned ResultSet must be closed by the caller after usage!
     */
    public ResultSet execSelectWhereInsecure(String table, String where) throws SQLException {
        return this.execQuery("SELECT * FROM " + table + " WHERE " + where);
    }

    private ResultSet execQuery(String select) throws SQLException {
        try {
            PreparedStatement stat = mConn.prepareStatement(select);
            // does not work, i dont care
            //stat.closeOnCompletion();
            ResultSet resultSet = stat.executeQuery();
            return resultSet;
        } catch (SQLException ex) {
            LOGGER.log(Level.WARNING, "can't execute select: " + select, ex);
            throw ex;
        }
    }

    /**
     * Add a new model / row to database.
     * @param table table name the values are inserted into
     * @param values all objects / row fields that to insert
     * @return id value of inserted row, -1 if something went wrong
     */
    public synchronized int execInsert(String table, List<Object> values) {
        // first column is the id
        String insert = "INSERT INTO " + table + " VALUES (NULL,";

        List<String> vList = new ArrayList<>(values.size());
        while(vList.size() < values.size())
            vList.add("?");

        insert += StringUtils.join(vList, ", ") + ")";

        try (PreparedStatement stat = mConn.prepareStatement(insert,
                Statement.RETURN_GENERATED_KEYS)) {
            insertValues(stat, values);
            stat.executeUpdate();
            mConn.commit();
            ResultSet keys = stat.getGeneratedKeys();
            return keys.getInt(1);
        } catch (SQLException ex) {
            LOGGER.log(Level.WARNING, "can't execute insert: " + insert + " " + values, ex);
            return -1;
        }
    }

    /** Update values (at most one row). */
    public synchronized void execUpdate(String table, Map<String, Object> set, int id) {
        LOGGER.config("table: "+table);
        String update = "UPDATE OR FAIL " + table + " SET ";

        List<String> keyList = new ArrayList<>(set.keySet());

        List<String> vList = keyList.stream()
                .map(key -> key + " = ?")
                .collect(Collectors.toList());

        update += StringUtils.join(vList, ", ") + " WHERE _id == " + id ;
        // note: looks like driver doesn't support "LIMIT"
        //update += " LIMIT 1";

        try (PreparedStatement stat = mConn.prepareStatement(update, Statement.RETURN_GENERATED_KEYS)) {
            insertValues(stat, keyList, set);
            stat.executeUpdate();
            mConn.commit();
            stat.getGeneratedKeys();
        } catch (SQLException ex) {
            LOGGER.log(Level.WARNING, "can't execute update: " + update + " " + set, ex);
        }
    }

    /** Delete one row. Not commited! Call commit() after deletions. */
    public boolean execDelete(String table, int id) {
        LOGGER.info("deletion, table: " + table + "; id: " + id);
        try (Statement stat = mConn.createStatement()) {
            stat.executeUpdate("DELETE FROM " + table + " WHERE _id = " + id);
        } catch (SQLException ex) {
            LOGGER.log(Level.WARNING, "can't delete", ex);
            return false;
        }
        return true;
    }

    public boolean commit() {
        try {
            mConn.commit();
        } catch (SQLException ex) {
            LOGGER.log(Level.WARNING, "can't commit", ex);
            return false;
        }
        return true;
    }

    private static void insertValues(PreparedStatement stat,
            List<String> keys,
            Map<String, Object> map) throws SQLException {
        for (int i = 0; i < keys.size(); i++) {
            setValue(stat, i, map.get(keys.get(i)));
         }
    }

    private static void insertValues(PreparedStatement stat,
            List<Object> values) throws SQLException {
        for (int i = 0; i < values.size(); i++) {
            setValue(stat, i, values.get(i));
        }
    }

    private static void setValue(PreparedStatement stat, int i, Object value)
            throws SQLException {
        if (value instanceof String) {
                stat.setString(i+1, (String) value);
            } else if (value instanceof Integer) {
                stat.setInt(i+1, (int) value);
            } else if (value instanceof Date) {
                stat.setLong(i+1, ((Date) value).getTime());
            } else if (value instanceof Boolean) {
                stat.setBoolean(i+1, (boolean) value);
            } else if (value instanceof Enum) {
                stat.setInt(i+1, ((Enum) value).ordinal());
            } else if (value instanceof EnumSet) {
                stat.setInt(i+1, EncodingUtils.enumSetToInt(((EnumSet) value)));
            } else if (value instanceof Optional) {
                setValue(stat, i, ((Optional<?>) value).orElse(null));
            } else if (value instanceof JID) {
                stat.setString(i+1, ((JID) value).string());
            } else if (value == null) {
                stat.setNull(i+1, Types.NULL);
            } else {
                LOGGER.warning("unknown type: " + value);
            }
    }

    /**
     * Return the value for a specific column as string; the string is empty if
     * the value is SQL NULL.
     */
    public static String getString(ResultSet r, String columnLabel){
        String s;
        try {
            s = r.getString(columnLabel);
        } catch (SQLException ex) {
            LOGGER.log(Level.WARNING, "can't get string from db", ex);
            return "";
        }
        return StringUtils.defaultString(s);
    }

    public static String setString(String s) {
        return s.isEmpty() ? null : s;
    }
} public final class ClientUtils {
    private static final Logger LOGGER = Logger.getLogger(ClientUtils.class.getName());

    private static final List<String> IGNORED_NAMESPACES = Arrays.asList(
            ChatStateExtension.NAMESPACE, DeliveryReceipt.NAMESPACE);

    /**
     * Message attributes for identifying the chat for a message.
     * KonGroupData is missing here as this could be part of the encrypted content.
     */
    public static class MessageIDs {
        public final JID jid;
        public final String xmppID;
        public final String xmppThreadID;

        private MessageIDs(JID jid, String xmppID, String threadID) {
            this.jid = jid;
            this.xmppID = xmppID;
            this.xmppThreadID = threadID;
        }

        public static MessageIDs from(Message m) {
            return from(m, "");
        }

        public static MessageIDs from(Message m, String receiptID) {
            return create(m, m.getFrom(), receiptID);
        }

        public static MessageIDs to(Message m) {
            return create(m, m.getTo(), "");
        }

        private static MessageIDs create(Message m, Jid jid, String receiptID) {
            return new MessageIDs(
                    JID.fromSmack(jid),
                    !receiptID.isEmpty() ? receiptID :
                            StringUtils.defaultString(m.getStanzaId()),
                    StringUtils.defaultString(m.getThread()));
        }

        @Override
        public String toString() {
            return "IDs:jid="+jid+",xmpp="+xmppID+",thread="+xmppThreadID;
        }
    }

    public static class KonRosterEntry {
        public final JID jid;
        public final String name;
        public final Contact.Subscription subscription;

        public KonRosterEntry(JID jid, String name,
                              RosterPacket.ItemType type, boolean subscriptionPending) {
            this.jid = jid;
            this.name = name;
            this.subscription = rosterToModelSubscription(subscriptionPending, type);
        }


        private static Contact.Subscription rosterToModelSubscription(
                boolean subscriptionPending, RosterPacket.ItemType type) {
            if (type == RosterPacket.ItemType.both ||
                    type == RosterPacket.ItemType.to ||
                    type == RosterPacket.ItemType.remove)
                return Contact.Subscription.SUBSCRIBED;

            if (subscriptionPending)
                return Contact.Subscription.PENDING;

            return Contact.Subscription.UNSUBSCRIBED;
        }
    }

    public static MessageContent parseMessageContent(Message m, boolean decrypted) {
        MessageContent.Builder builder = new MessageContent.Builder();

        // parsing only default body
        String plainText = StringUtils.defaultString(m.getBody());
        String encrypted = "";

        if (!decrypted) {
            ExtensionElement e2eExt = m.getExtension(E2EEncryption.ELEMENT_NAME, E2EEncryption.NAMESPACE);
            if (e2eExt instanceof E2EEncryption) {
                // encryption extension (RFC 3923), decrypted later
                encrypted = EncodingUtils.bytesToBase64(((E2EEncryption) e2eExt).getData());
                // remove extension before parsing all others
                m.removeExtension(E2EEncryption.ELEMENT_NAME, E2EEncryption.NAMESPACE);
            }
            ExtensionElement openPGPExt = m.getExtension(OpenPGPExtension.ELEMENT_NAME, OpenPGPExtension.NAMESPACE);
            if (openPGPExt instanceof OpenPGPExtension) {
                if (!encrypted.isEmpty()) {
                    LOGGER.info("message contains e2e and OpenPGP element, ignoring e2e");
                }
                encrypted = ((OpenPGPExtension) openPGPExt).getData();
                // remove extension before parsing all others
                m.removeExtension(OpenPGPExtension.ELEMENT_NAME, OpenPGPExtension.NAMESPACE);
            }
        }

        if (!encrypted.isEmpty()) {
            if (!plainText.isEmpty()) {
                LOGGER.config("message contains encryption and body (ignoring body): " + plainText);
                plainText = "";
            }
            builder.encrypted(encrypted);
        }
        addContent(builder, m.getExtensions(), plainText, decrypted);
        return builder.build();
    }

    public static MessageContent extensionsToContent(List<ExtensionElement> elements) {
        MessageContent.Builder builder = new MessageContent.Builder();
        addContent(builder, elements, "", true);
        return builder.build();
    }

    private static void addContent(MessageContent.Builder builder, List<ExtensionElement> elements,
                                   String body, boolean decrypted) {
        String outOfBandURL = null;
        for (ExtensionElement element : elements) {
            if (element instanceof Message.Body) {
                body = ((Message.Body) element).getMessage();
            } else if (element instanceof BitsOfBinary) {
                // Bits of Binary: preview for file attachment
                BitsOfBinary bob = (BitsOfBinary) element;
                String mime = StringUtils.defaultString(bob.getType());
                byte[] bits = bob.getContents();
                if (bits == null)
                    bits = new byte[0];
                if (mime.isEmpty() || bits.length <= 0)
                    LOGGER.warning("invalid BOB data: " + bob.toXML(null));
                else
                    builder.preview(new Preview(bits, mime));
            } else if (element instanceof OutOfBandData) { // Out of Band Data: a URI to a file
                OutOfBandData oobData = (OutOfBandData) element;
                URI url;
                try {
                    url = new URI(oobData.getUrl());
                } catch (URISyntaxException ex) {
                    LOGGER.log(Level.WARNING, "can't parse URL", ex);
                    url = URI.create("");
                }

                builder.attachment(new InAttachment(url));

                outOfBandURL = url.toString();
            } else if (element instanceof GroupExtension) { // Kontalk group element and (maybe) command
                GroupExtension group = (GroupExtension) element;
                KonGroupData gid = new KonGroupData(JID.bare(group.getOwner()), group.getID());
                GroupCommand groupCommand = ClientUtils.groupExtensionToGroupCommand(
                        group.getType(), group.getMembers(), group.getSubject()).orElse(null);

                builder.groupData(gid);
                if (groupCommand != null)
                    builder.groupCommand(groupCommand);
            } else {
                if (decrypted || !IGNORED_NAMESPACES.contains(element.getNamespace()))
                    LOGGER.warning("unexpected extension: "
                            + (element == null ? null : element.toXML(null)));
            }
        }

        // body text is maybe URI, for clients that dont understand OOB,
        // but we do, don't save it twice
        if (body.equals(outOfBandURL))
            body = "";

        if (!body.isEmpty())
            builder.body(body);
    }

    /* Internal to external */
    public static GroupExtension groupCommandToGroupExtension(KonGroupChat chat,
        GroupCommand groupCommand) {
        assert chat.isGroupChat();

        KonGroupData gid = chat.getGroupData();
        switch (groupCommand.getOperation()) {
            case LEAVE:
                // weare leaving
                return new GroupExtension(gid.id, gid.owner.string(), Type.PART);
            case CREATE: {
                Set<Member> members = new HashSet<>();
                groupCommand.getAdded().forEach(added -> members.add(new Member(added.string())));
                return new GroupExtension(gid.id, gid.owner.string(), Type.CREATE,
                        groupCommand.getSubject(), members);
            }
            case SET: {
                Set<Member> members = new HashSet<>();
                Set<JID> incl = new HashSet<>();
                for (JID added : groupCommand.getAdded()) {
                    incl.add(added);
                    members.add(new Member(added.string(), Member.Operation.ADD));
                }
                for (JID removed : groupCommand.getRemoved()) {
                    incl.add(removed);
                    members.add(new Member(removed.string(), Member.Operation.REMOVE));
                }
                if (!groupCommand.getAdded().isEmpty()) {
                    // list all remaining members for the new members
                    for (Contact c : chat.getValidContacts()) {
                        JID old = c.getJID();
                        if (!incl.contains(old))
                            members.add(new Member(old.string()));
                    }
                }
                return new GroupExtension(gid.id, gid.owner.string(), Type.SET,
                        groupCommand.getSubject(), members);
            }
            default:
                LOGGER.warning("not implemented: "+groupCommand.getOperation());
                return new GroupExtension(gid.id, gid.owner.string());
        }
    }

    /* External to internal */
    private static Optional<GroupCommand> groupExtensionToGroupCommand(
            Type com, List<Member> members, String subject) {
        switch (com) {
            case NONE:
                return Optional.empty();
            case CREATE:
                List<JID> jids = members.stream()
                        .peek(m -> { if (m.operation != Member.Operation.NONE) {
                            LOGGER.warning("unexpected member operation in "
                                    + "create command: " + m.operation + " " + m.jid);
                            }})
                        .map(m -> JID.bare(m.jid))
                        .collect(Collectors.toList());
                return Optional.of(GroupCommand.create(jids, subject));
            case PART:
                return Optional.of(GroupCommand.leave());
            case SET:
                // ignoring duplicate JIDs (no log output)
                Set<JID> unchanged = new HashSet<>();
                Set<JID> added = new HashSet<>();
                Set<JID> removed = new HashSet<>();
                for (Member m : members) {
                   switch (m.operation) {
                       case NONE: unchanged.add(JID.bare(m.jid)); break;
                       case ADD: added.add(JID.bare(m.jid)); break;
                       case REMOVE: removed.add((JID.bare(m.jid))); break;
                   }
                }
                // sanity check; prioritize 'removed' over 'added'
                removed.stream()
                        .filter(added::contains)
                        .peek(jid -> LOGGER.warning("member added AND removed (removing) " + jid))
                        .forEach(added::remove);

                return Optional.of(GroupCommand.set(
                        new ArrayList<>(unchanged),
                        new ArrayList<>(added),
                        new ArrayList<>(removed),
                        subject));
            case GET:
            case RESULT:
            default:
                // TODO
                return Optional.empty();
        }
    }
}
import org.kontalk.model.message.ProtoMessage;
import org.kontalk.persistence.Config;
import org.kontalk.persistence.Database;
import org.kontalk.util.ClientUtils;
import java.nio.file.Path;
import java.util.Date;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.logging.Logger;
import org.kontalk.misc.JID;
import org.kontalk.model.chat.Chat;
import org.kontalk.model.chat.ChatList;
import org.kontalk.model.message.InMessage;
import org.kontalk.model.message.MessageContent;
import org.kontalk.model.message.OutMessage;
/*
 *  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;


/**
 *
 * @author Alexander Bikadorov {@literal <bikaejkb@mail.tu-berlin.de>}
 */
public final class Model {
    private static final Logger LOGGER = Logger.getLogger(Model.class.getName());

    private static Model INSTANCE = null;
    private static Path APP_DIR;
    private static Database DATABASE;

    private final ContactList mContactList;
    private final ChatList mChatList;
    private final Account mAccount;

    private Model(Database db, Path appDir) {
        DATABASE = db;
        APP_DIR = appDir;

        mAccount = new Account(APP_DIR, Config.getInstance());
        mContactList = new ContactList();
        mChatList = new ChatList();

        Avatar.createStorageDir(appDir);
    }

    public static Model setup(Database db, Path appDir) {
        if (INSTANCE != null) {
            LOGGER.warning("already set up");
            return INSTANCE;
        }

        return INSTANCE = new Model(db, appDir);
    }

    public Account account() {
        return mAccount;
    }

    public ContactList contacts() {
        return mContactList;
    }

    public ChatList chats() {
        return mChatList;
    }

    public void load() {
        // order matters!
        Map<Integer, Contact> contactMap = mContactList.load();
        mChatList.load(contactMap);
    }

    public void setUserJID(JID jid) {
        Config.getInstance().setProperty(Config.ACC_JID, jid.string());

        if (!mContactList.contains(jid)) {
            LOGGER.info("creating user contact, jid: "+jid);
            mContactList.create(jid, "");
        }
    }

    public Optional<InMessage> createInMessage(ProtoMessage protoMessage,
            Chat chat, ClientUtils.MessageIDs ids, Optional<Date> serverDate) {