org.kontalk.system.Control.java Source code

Java tutorial

Introduction

Here is the source code for org.kontalk.system.Control.java

Source

/*
 *  Kontalk Java client
 *  Copyright (C) 2014 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.system;

import java.util.Date;
import java.util.EnumSet;
import java.util.List;
import java.util.Observable;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import java.util.logging.Level;
import java.util.logging.Logger;
import org.apache.commons.lang.StringUtils;
import org.jivesoftware.smack.packet.Message;
import org.jivesoftware.smack.packet.Presence;
import org.jivesoftware.smack.packet.XMPPError.Condition;
import org.jivesoftware.smack.roster.packet.RosterPacket;
import org.jivesoftware.smack.roster.packet.RosterPacket.ItemStatus;
import org.jivesoftware.smack.roster.packet.RosterPacket.ItemType;
import org.jivesoftware.smackx.chatstates.ChatState;
import org.jxmpp.util.XmppStringUtils;
import org.kontalk.Kontalk;
import org.kontalk.client.Client;
import org.kontalk.crypto.Coder;
import org.kontalk.crypto.PGPUtils;
import org.kontalk.crypto.PersonalKey;
import org.kontalk.misc.KonException;
import org.kontalk.misc.ViewEvent;
import org.kontalk.model.InMessage;
import org.kontalk.model.KonMessage;
import org.kontalk.model.KonThread;
import org.kontalk.model.MessageContent;
import org.kontalk.model.OutMessage;
import org.kontalk.model.ThreadList;
import org.kontalk.model.User;
import org.kontalk.model.UserList;
import org.kontalk.util.XMPPUtils;

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

    private static final String LEGACY_CUT_FROM_ID = " (NO COMMENT)";

    /** The current application state. */
    public enum Status {
        DISCONNECTING, DISCONNECTED, CONNECTING, CONNECTED, SHUTTING_DOWN, FAILED, ERROR
    }

    /**
     * Message attributes to identify the thread for a message.
     */
    public static class MessageIDs {
        public final String jid;
        public final String xmppID;
        public final String threadID;
        //public final Optional<GroupID> groupID;

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

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

        public static MessageIDs from(Message m, String receiptID) {
            return new MessageIDs(StringUtils.defaultString(m.getFrom()),
                    !receiptID.isEmpty() ? receiptID : StringUtils.defaultString(m.getStanzaId()),
                    StringUtils.defaultString(m.getThread()));
        }

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

    private final Client mClient;
    private final ChatStateManager mChatStateManager;

    private Status mCurrentStatus = Status.DISCONNECTED;

    public Control() {
        mClient = new Client(this);
        mChatStateManager = new ChatStateManager(mClient);
    }

    public void launch() {
        new Thread(mClient).start();

        boolean connect = Config.getInstance().getBoolean(Config.MAIN_CONNECT_STARTUP);
        if (!AccountLoader.getInstance().isPresent()) {
            this.changed(new ViewEvent.MissingAccount(connect));
            return;
        }

        if (connect)
            this.connect();
    }

    /* commands from view */

    public void shutDown() {
        this.disconnect();
        LOGGER.info("Shutting down...");
        mCurrentStatus = Status.SHUTTING_DOWN;
        this.changed(new ViewEvent.StatusChanged());
        UserList.getInstance().save();
        ThreadList.getInstance().save();
        try {
            Database.getInstance().close();
        } catch (RuntimeException ex) {
            // ignore
        }
        Config.getInstance().saveToFile();

        Kontalk.exit();
    }

    public void connect() {
        this.connect(new char[0]);
    }

    public void connect(char[] password) {
        Optional<PersonalKey> optKey = this.loadKey(password);
        if (!optKey.isPresent())
            return;

        mClient.connect(optKey.get());
    }

    private Optional<PersonalKey> loadKey(char[] password) {
        AccountLoader account = AccountLoader.getInstance();
        Optional<PersonalKey> optKey = account.getPersonalKey();
        if (optKey.isPresent())
            return optKey;

        if (password.length == 0) {
            if (account.isPasswordProtected()) {
                this.changed(new ViewEvent.PasswordSet());
                return Optional.empty();
            }

            password = Config.getInstance().getString(Config.ACC_PASS).toCharArray();
        }

        try {
            optKey = Optional.of(account.load(password));
        } catch (KonException ex) {
            // something wrong with the account, tell view
            this.handleException(ex);
            return Optional.empty();
        }
        return optKey;
    }

    public void disconnect() {
        mChatStateManager.imGone();
        mCurrentStatus = Status.DISCONNECTING;
        this.changed(new ViewEvent.StatusChanged());
        mClient.disconnect();
    }

    public void sendText(KonThread thread, String text) {
        // TODO no group chat support yet
        Set<User> user = thread.getUser();
        for (User oneUser : user) {
            OutMessage newMessage = newOutMessage(thread, oneUser, text, oneUser.getEncrypted());
            this.sendMessage(newMessage);
        }
    }

    public void sendUserBlocking(User user, boolean blocking) {
        mClient.sendBlockingCommand(user.getJID(), blocking);
    }

    public Status getCurrentStatus() {
        return mCurrentStatus;
    }

    public KonThread createNewThread(Set<User> user) {
        return ThreadList.getInstance().createNew(user);
    }

    public void deleteThread(KonThread thread) {
        ThreadList.getInstance().delete(thread.getID());
    }

    public Optional<User> createNewUser(String jid, String name, boolean encrypted) {
        Optional<User> optNewUser = UserList.getInstance().createUser(jid, name);
        if (!optNewUser.isPresent()) {
            LOGGER.warning("can't create new user");
            return Optional.empty();
        }
        User newUser = optNewUser.get();

        newUser.setEncrypted(encrypted);

        mClient.addToRoster(newUser);

        return Optional.of(newUser);
    }

    public void changeJID(User user, String jid) {
        jid = XmppStringUtils.parseBareJid(jid);
        if (user.getJID().equals(jid))
            return;

        UserList.getInstance().changeJID(user, jid);
    }

    public void sendKeyRequest(User user) {
        if (user.getSubScription() == User.Subscription.UNSUBSCRIBED
                || user.getSubScription() == User.Subscription.PENDING) {
            LOGGER.info("no presence subscription, not sending key request, user: " + user);
            return;
        }
        mClient.sendPublicKeyRequest(user.getJID());
    }

    public void handleOwnChatStateEvent(KonThread thread, ChatState state) {
        mChatStateManager.handleOwnChatStateEvent(thread, state);
    }

    public void sendStatusText() {
        mClient.sendInitialPresence();
    }

    /* events from network client */

    public void setStatus(Status status) {
        mCurrentStatus = status;
        this.changed(new ViewEvent.StatusChanged());

        if (status == Status.CONNECTED) {
            // send all pending messages
            for (KonThread thread : ThreadList.getInstance().getAll())
                for (OutMessage m : thread.getMessages().getPending()) {
                    this.sendMessage(m);
                }

            // send public key requests for Kontalk users with missing key
            for (User user : UserList.getInstance().getAll()) {
                // TODO only for domains that are part of the Kontalk network
                if (user.getFingerprint().isEmpty()) {
                    LOGGER.info("public key missing for user, requesting it...");
                    this.sendKeyRequest(user);
                }
            }
        }
    }

    public void handleException(KonException ex) {
        this.changed(new ViewEvent.Exception(ex));
    }

    public void handleSecurityErrors(KonMessage message) {
        EnumSet<Coder.Error> errors = message.getCoderStatus().getErrors();
        if (errors.contains(Coder.Error.KEY_UNAVAILABLE) || errors.contains(Coder.Error.INVALID_SIGNATURE)
                || errors.contains(Coder.Error.INVALID_SENDER)) {
            // maybe there is something wrong with the senders key
            this.sendKeyRequest(message.getUser());
        }
        this.changed(new ViewEvent.SecurityError(message));
    }

    /**
     * All-in-one method for a new outgoing message (except sending): Create,
     * save and process the message.
     * @return the new created message
     */
    private OutMessage newOutMessage(KonThread thread, User user, String text, boolean encrypted) {
        MessageContent content = new MessageContent(text);
        OutMessage.Builder builder = new OutMessage.Builder(thread, user, encrypted);
        builder.content(content);
        OutMessage newMessage = builder.build();
        boolean added = thread.addMessage(newMessage);
        if (!added) {
            LOGGER.warning("could not add outgoing message to thread");
        }
        return newMessage;
    }

    /**
     * All-in-one method for a new incoming message (except handling server
     * receipts): Create, save and process the message.
     * @return true on success or message is a duplicate, false on unexpected failure
     */
    public boolean newInMessage(MessageIDs ids, Optional<Date> serverDate, MessageContent content) {
        String jid = XmppStringUtils.parseBareJid(ids.jid);
        UserList userList = UserList.getInstance();
        Optional<User> optUser = userList.contains(jid) ? userList.get(jid) : this.createNewUser(jid, "", true);
        if (!optUser.isPresent()) {
            LOGGER.warning("can't get user for message");
            return false;
        }
        User user = optUser.get();
        KonThread thread = getThread(ids.threadID, user);
        InMessage.Builder builder = new InMessage.Builder(thread, user);
        builder.jid(ids.jid);
        builder.xmppID(ids.xmppID);
        builder.serverDate(serverDate);
        builder.content(content);
        InMessage newMessage = builder.build();
        boolean added = thread.addMessage(newMessage);
        if (!added) {
            LOGGER.info("message already in thread, dropping this one");
            return true;
        }
        newMessage.save();

        this.decryptAndDownload(newMessage);

        this.changed(new ViewEvent.NewMessage(newMessage));

        return newMessage.getID() >= -1;
    }

    /**
     * Decrypt an incoming message and download attachment if present.
     */
    public void decryptAndDownload(InMessage message) {
        Coder.processInMessage(message);

        if (!message.getCoderStatus().getErrors().isEmpty()) {
            this.handleSecurityErrors(message);
        }

        if (message.getContent().getAttachment().isPresent()) {
            Downloader.getInstance().queueDownload(message);
        }
    }

    /**
     * Set the receipt status of a message.
     * @param xmppID XMPP ID of message
     * @param status new receipt status of message
     */
    public void setMessageStatus(MessageIDs ids, KonMessage.Status status) {
        Optional<OutMessage> optMessage = getMessage(ids);
        if (!optMessage.isPresent())
            return;
        OutMessage m = optMessage.get();

        if (m.getReceiptStatus() == KonMessage.Status.RECEIVED)
            // probably by another client
            return;

        m.setStatus(status);
    }

    public void setMessageError(MessageIDs ids, Condition condition, String errorText) {
        Optional<OutMessage> optMessage = getMessage(ids);
        if (!optMessage.isPresent())
            return;
        optMessage.get().setError(condition.toString(), errorText);
    }

    /**
     * Inform model (and view) about a received chat state notification.
     */
    public void processChatState(String from, String xmppThreadID, Optional<Date> serverDate,
            String chatStateString) {
        if (serverDate.isPresent()) {
            long diff = new Date().getTime() - serverDate.get().getTime();
            if (diff > TimeUnit.SECONDS.toMillis(10)) {
                // too old
                return;
            }
        }
        ChatState chatState;
        try {
            chatState = ChatState.valueOf(chatStateString);
        } catch (IllegalArgumentException ex) {
            LOGGER.log(Level.WARNING, "can't parse chat state ", ex);
            return;
        }
        String jid = XmppStringUtils.parseBareJid(from);
        Optional<User> optUser = UserList.getInstance().get(jid);
        if (!optUser.isPresent()) {
            LOGGER.warning("(chat state) can't find user with jid: " + jid);
            return;
        }
        User user = optUser.get();
        KonThread thread = getThread(xmppThreadID, user);
        thread.setChatState(user, chatState);
    }

    public void addUserFromRoster(String jid, String rosterName, ItemType type, ItemStatus itemStatus) {
        if (UserList.getInstance().contains(jid)) {
            this.setSubscriptionStatus(jid, type, itemStatus);
            return;
        }

        LOGGER.info("adding user from roster, jid: " + jid);

        String name = rosterName == null ? "" : rosterName;
        if (name.equals(XmppStringUtils.parseLocalpart(jid)) && XMPPUtils.isHash(jid)) {
            // this must be the hash string, don't use it as name
            name = "";
        }

        Optional<User> optNewUser = UserList.getInstance().createUser(jid, name);
        if (!optNewUser.isPresent())
            return;
        User newUser = optNewUser.get();

        User.Subscription status = rosterToModelSubscription(itemStatus, type);
        newUser.setSubScriptionStatus(status);

        if (status == User.Subscription.UNSUBSCRIBED)
            mClient.sendPresenceSubscriptionRequest(jid);

        this.sendKeyRequest(newUser);
    }

    public void setSubscriptionStatus(String jid, ItemType type, ItemStatus itemStatus) {
        Optional<User> optUser = UserList.getInstance().get(jid);
        if (!optUser.isPresent()) {
            LOGGER.warning("(subscription) can't find user with jid: " + jid);
            return;
        }
        optUser.get().setSubScriptionStatus(rosterToModelSubscription(itemStatus, type));
    }

    public void setPresence(String jid, Presence.Type type, String status) {
        if (jid.equals(XmppStringUtils.parseBareJid(mClient.getOwnJID())) && !UserList.getInstance().contains(jid))
            // don't wanna see myself
            return;

        Optional<User> optUser = UserList.getInstance().get(jid);
        if (!optUser.isPresent()) {
            LOGGER.warning("(presence) can't find user with jid: " + jid);
            return;
        }
        optUser.get().setOnline(type, status);
    }

    public void checkFingerprint(String jid, String fingerprint) {
        Optional<User> optUser = UserList.getInstance().get(jid);
        if (!optUser.isPresent()) {
            LOGGER.warning("(fingerprint) can't find user with jid:" + jid);
            return;
        }

        User user = optUser.get();
        if (!user.getFingerprint().equals(fingerprint)) {
            LOGGER.info("detected public key change, requesting new key...");
            this.sendKeyRequest(user);
        }
    }

    public void setPGPKey(String jid, byte[] rawKey) {
        Optional<User> optUser = UserList.getInstance().get(jid);
        if (!optUser.isPresent()) {
            LOGGER.warning("(PGPKey) can't find user with jid: " + jid);
            return;
        }
        User user = optUser.get();

        Optional<PGPUtils.PGPCoderKey> optKey = PGPUtils.readPublicKey(rawKey);
        if (!optKey.isPresent()) {
            LOGGER.log(Level.WARNING, "can't get public key");
            return;
        }
        PGPUtils.PGPCoderKey key = optKey.get();
        user.setKey(rawKey, key.fingerprint);

        // if not set, use uid in key for user name
        LOGGER.info("full UID in key: '" + key.userID + "'");
        if (user.getName().isEmpty() && key.userID != null) {
            String userName = key.userID.replaceFirst(" <[a-f0-9]+@.+>$", "");
            if (userName.endsWith(LEGACY_CUT_FROM_ID))
                userName = userName.substring(0, userName.length() - LEGACY_CUT_FROM_ID.length());
            LOGGER.info("user name from key: '" + userName + "'");
            if (!userName.isEmpty())
                user.setName(userName);
        }
    }

    public void setBlockedUser(List<String> jids) {
        for (String jid : jids) {
            if (XmppStringUtils.isFullJID(jid)) {
                LOGGER.info("ignoring blocking of JID with resource");
                return;
            }
            this.setUserBlocking(jid, true);
        }
    }

    public void setUserBlocking(String jid, boolean blocking) {
        Optional<User> optUser = UserList.getInstance().get(jid);
        if (!optUser.isPresent()) {
            LOGGER.info("ignoring blocking of JID not in user list");
            return;
        }
        User user = optUser.get();

        LOGGER.info("set user blocking: " + user + " " + blocking);
        user.setBlocked(blocking);
    }

    /* private */

    private void changed(ViewEvent event) {
        this.setChanged();
        this.notifyObservers(event);
    }

    private void sendMessage(OutMessage message) {
        mClient.sendMessage(message);
        mChatStateManager.handleOwnChatStateEvent(message.getThread(), ChatState.active);
    }

    private static KonThread getThread(String xmppThreadID, User user) {
        ThreadList threadList = ThreadList.getInstance();
        Optional<KonThread> optThread = threadList.get(xmppThreadID);
        return optThread.orElse(threadList.get(user));
    }

    private static Optional<OutMessage> getMessage(MessageIDs ids) {
        // get thread by thread ID
        ThreadList tl = ThreadList.getInstance();
        Optional<KonThread> optThread = tl.get(ids.threadID);
        if (optThread.isPresent()) {
            return optThread.get().getMessages().getLast(ids.xmppID);
        }

        // get thread by thread by jid
        Optional<User> optUser = UserList.getInstance().get(ids.jid);
        if (optUser.isPresent() && tl.contains(optUser.get())) {
            Optional<OutMessage> optM = tl.get(optUser.get()).getMessages().getLast(ids.xmppID);
            if (optM.isPresent())
                return optM;
        }

        // fallback: search everywhere
        for (KonThread thread : tl.getAll()) {
            Optional<OutMessage> optM = thread.getMessages().getLast(ids.xmppID);
            if (optM.isPresent())
                return optM;
        }

        LOGGER.warning("can't find message by IDs: " + ids);
        return Optional.empty();
    }

    private static User.Subscription rosterToModelSubscription(RosterPacket.ItemStatus status,
            RosterPacket.ItemType type) {
        if (type == RosterPacket.ItemType.both || type == RosterPacket.ItemType.to
                || type == RosterPacket.ItemType.remove)
            return User.Subscription.SUBSCRIBED;

        if (status == RosterPacket.ItemStatus.SUBSCRIPTION_PENDING)
            return User.Subscription.PENDING;

        return User.Subscription.UNSUBSCRIBED;
    }
}