public final class Client implements StanzaListener, Runnable {
    private static final Logger LOGGER = Logger.getLogger(Client.class.getName());

    private static final String CAPS_CACHE_DIR = "caps_cache";
    private static final LinkedBlockingQueue<Task> TASK_QUEUE = new LinkedBlockingQueue<>();

    public enum PresenceCommand {REQUEST, GRANT, DENY}

    // NOTE: disconnect is instantaneous, all resulting exceptions should be catched
    private enum Command {CONNECT, LAST_ACTIVITY}

    private final Control mControl;

    private final KonMessageSender mMessageSender;
    private final EnumMap<FeatureDiscovery.Feature, JID> mFeatures;

    private KonConnection mConn = null;
    private AvatarSendReceiver mAvatarSendReceiver = null;
    private HTTPFileSlotRequester mSlotRequester = null;
    private FeatureDiscovery mFeatureDiscovery = null;

    private Client(Control control, Path appDir) {
        mControl = control;
        //mLimited = limited;

        mMessageSender = new KonMessageSender(this);

        // enable Smack debugging (print raw XML packets)
        //SmackConfiguration.DEBUG = true;

        mFeatures = new EnumMap<>(FeatureDiscovery.Feature.class);

        // setting caps cache
        // NOTE: the cache is actually not used right now: only client entity requests (==full JIDs)
        // can be cached
        File cacheDir = appDir.resolve(CAPS_CACHE_DIR).toFile();
        if (cacheDir.mkdir())
            LOGGER.info("created caps cache directory");

        if (!cacheDir.isDirectory()) {
            LOGGER.warning("invalid cache directory: "+cacheDir);
            return;
        }

        EntityCapsManager.setPersistentCache(
                new SimpleDirectoryPersistentCache(cacheDir));
    }

    public static Client create(Control control, Path appDir) {
        Client client = new Client(control, appDir);

        Thread clientThread = new Thread(client, "Client Connector");
        clientThread.setDaemon(true);
        clientThread.start();

        return client;
    }

    public void connect(PersonalKey key) {
        this.disconnect();

        LOGGER.config("connecting...");
        this.newStatus(Control.Status.CONNECTING);

        Config config = Config.getInstance();
        //String network = config.getString(KonConf.SERV_NET);
        String host = config.getString(Config.SERV_HOST);
        int port = config.getInt(Config.SERV_PORT);
        EndpointServer server = new EndpointServer(host, port);

        boolean validateCertificate = config.getBoolean(Config.SERV_CERT_VALIDATION);

        // create connection
        mConn = new KonConnection(server,
                        key.getServerLoginKey(),
                        key.getBridgeCertificate(),
                        validateCertificate);

        // connection listener
        mConn.addConnectionListener(new KonConnectionListener(this, mControl));

        Roster roster = Roster.getInstanceFor(mConn);
        // subscriptions handled by roster handler
        roster.setSubscriptionMode(Roster.SubscriptionMode.manual);

        mAvatarSendReceiver = new AvatarSendReceiver(mConn, mControl.getAvatarHandler());

        // packet listeners
        RosterHandler rosterHandler = mControl.getRosterHandler();
        KonRosterListener rl = new KonRosterListener(roster, rosterHandler);
        roster.addRosterListener(rl);
        roster.addRosterLoadedListener(rl);

        StanzaFilter messageFilter = new StanzaTypeFilter(Message.class);
        // must be synchronized: we want to receive messages in the order they were sent
        mConn.addSyncStanzaListener(
                new KonMessageListener(this, mControl, mAvatarSendReceiver),
                messageFilter);

        StanzaFilter vCardFilter = new StanzaTypeFilter(VCard4.class);
        mConn.addAsyncStanzaListener(new VCardListener(mControl), vCardFilter);

        StanzaFilter blockingCommandFilter = new StanzaTypeFilter(BlockingCommand.class);
        mConn.addAsyncStanzaListener(new BlockListListener(mControl), blockingCommandFilter);

        StanzaFilter publicKeyFilter = new StanzaTypeFilter(PublicKeyPublish.class);
        mConn.addAsyncStanzaListener(new PublicKeyListener(mControl), publicKeyFilter);

        StanzaFilter presenceFilter = new StanzaTypeFilter(Presence.class);
        mConn.addAsyncStanzaListener(new PresenceListener(roster, rosterHandler), presenceFilter);

        StanzaFilter lastActivityFilter = new StanzaTypeFilter(LastActivity.class);
        mConn.addAsyncStanzaListener(new LastActivityListener(mControl), lastActivityFilter);

        if (config.getBoolean(Config.NET_REQUEST_AVATARS)) {
            // our service discovery: want avatar from other users
            ServiceDiscoveryManager.getInstanceFor(mConn).
                    addFeature(AvatarSendReceiver.NOTIFY_FEATURE);
        }

        // listen to all ACKs
        mConn.addStanzaAcknowledgedListener(new AcknowledgedListener(mControl));

        // listen to all IQ errors
        mConn.addAsyncStanzaListener(this, IQTypeFilter.ERROR);

        // continue async
        Client.TASK_QUEUE.offer(new Client.Task(Client.Command.CONNECT, new ArrayList<>(0)));
    }

    private void connectAsync() {
        // TODO unsure if everything is thread-safe
        synchronized (this) {
            // connect
            try {
                mConn.connect();
            } catch (XMPPException | SmackException | IOException | InterruptedException ex) {
                LOGGER.log(Level.WARNING, "can't connect to "+mConn.getServer(), ex);
                this.newStatus(Control.Status.FAILED);
                mControl.onException(new KonException(KonException.Error.CLIENT_CONNECT, ex));
                return;
            }

            // login
            try {
                mConn.login();
            } catch (XMPPException | SmackException | IOException | InterruptedException ex) {
                LOGGER.log(Level.WARNING, "can't login on "+mConn.getServer(), ex);
                mConn.disconnect();
                this.newStatus(Control.Status.FAILED);
                mControl.onException(new KonException(KonException.Error.CLIENT_LOGIN, ex));
                return;
            }
        }

        mFeatureDiscovery = new FeatureDiscovery(mConn);

        mFeatures.clear();
        mFeatures.putAll(mFeatureDiscovery.getServerFeatures());

        mSlotRequester = mFeatures.containsKey(FeatureDiscovery.Feature.HTTP_FILE_UPLOAD) ?
                new HTTPFileSlotRequester(mConn,
                        mFeatures.get(FeatureDiscovery.Feature.HTTP_FILE_UPLOAD)) :
                null;

        // Caps, XEP-0115
        // NOTE: caps manager is automatically used by Smack
        //EntityCapsManager capsManager = EntityCapsManager.getInstanceFor(mConn);

        // PEP, XEP-0163
        // NOTE: Smack's implementation is not usable, use PubSub instead
//        PEPManager m = new PEPManager(mConn);
//        m.addPEPListener(new PEPListener() {
//            @Override
//            public void eventReceived(String from, PEPEvent event) {
//                LOGGER.info("from: "+from+" event: "+event);
//            }
//        });

        // PubSub, XEP-0060
        // NOTE: pubsub is currently unsupported by beta.kontalk.net
//        PubSubManager pubSubManager = new PubSubManager(mConn, mConn.getServiceName());
//        try {
//            DiscoverInfo i = pubSubManager.getSupportedFeatures();
//            // same as server service discovery features!?
//            for (DiscoverInfo.Feature f: i.getFeatures()) {
//                System.out.println("feature: "+f.getVar());
//            }
//        } catch (SmackException.NoResponseException |
//                XMPPException.XMPPErrorException |
//                SmackException.NotConnectedException ex) {
//            Logger.getLogger(Client.class.getName()).log(Level.SEVERE, null, ex);
//        }
        // here be exceptions
//        try {
//            for (Affiliation a: pubSubManager.getAffiliations()) {
//                System.out.println("aff: "+a.toXML());
//            }
//            for (Subscription s: pubSubManager.getSubscriptions()) {
//                System.out.println("subs: "+s.toXML());
//            }
//        } catch (SmackException.NoResponseException |
//                XMPPException.XMPPErrorException |
//                SmackException.NotConnectedException ex) {
//            Logger.getLogger(Client.class.getName()).log(Level.SEVERE, null, ex);
//        }

        this.newStatus(Control.Status.CONNECTED);

        this.sendBlocklistRequest();
    }

    public void disconnect() {
        if (mConn != null && mConn.isConnected()) {
            this.newStatus(Control.Status.DISCONNECTING);
            mConn.disconnect();
        }
    }

    public boolean isConnected() {
        return mConn != null && mConn.isAuthenticated();
    }

    /** The full JID of the user currently logged in. */
    public Optional<JID> getOwnJID() {
        EntityFullJid user = mConn.getUser();
        if (user == null)
            return Optional.empty();
        return Optional.of(JID.fromSmack(user));
    }

    private EnumSet<FeatureDiscovery.Feature> getServerFeature() {
        EnumSet<FeatureDiscovery.Feature> e = EnumSet.noneOf(FeatureDiscovery.Feature.class);
        e.addAll(mFeatures.keySet());
        return e;
    }

    public boolean sendMessage(SendTask task) {
        Optional<Jid> multiAddressHost =
                mFeatures.containsKey(FeatureDiscovery.Feature.MULTI_ADDRESSING)
                && mConn != null ? Optional.of(mConn.getXMPPServiceDomain()) : Optional.empty();

        return mMessageSender.sendMessage(task, multiAddressHost);
    }

    // TODO unused
    public void sendVCardRequest(JID jid) {
        VCard4 vcard = new VCard4();
        vcard.setType(IQ.Type.get);
        vcard.setTo(jid.toBareSmack());
        this.sendPacket(vcard);
    }

    public void sendPublicKeyRequest(JID jid) {
        LOGGER.info("to "+jid);
        PublicKeyPublish publicKeyRequest = new PublicKeyPublish();
        publicKeyRequest.setTo(jid.toBareSmack());
        this.sendPacket(publicKeyRequest);
    }

    private void sendBlocklistRequest() {
        this.sendPacket(BlockingCommand.blocklist());
    }

    public void sendBlockingCommand(JID jid, boolean blocking) {
        if (mConn == null || !this.isConnected()) {
            LOGGER.warning("not connected");
            return;
        }

        new BlockSendReceiver(mControl, mConn, blocking, jid).sendAndListen();
    }

    public void sendUserPresence(String statusText) {
        Presence presence = new Presence(Presence.Type.available);
        if (!statusText.isEmpty())
            presence.setStatus(statusText);

        // note: not setting priority, according to anti-dicrimination rules;)

        // for testing
        //presence.addExtension(new PresenceSignature(""));

        this.sendPacket(presence);
    }

    public void sendPresenceSubscription(JID jid, PresenceCommand command) {
        LOGGER.info("to: "+jid+ ", command: "+command);
        Presence.Type type = null;
        switch(command) {
            case REQUEST: type = Presence.Type.subscribe; break;
            case GRANT: type = Presence.Type.subscribed; break;
            case DENY: type = Presence.Type.unsubscribed; break;
        }
        Presence presence = new Presence(type);
        presence.setTo(jid.toBareSmack());
        this.sendPacket(presence);
    }

    public void sendChatState(JID jid, String threadID, ChatState state) {
        Message message = new Message(jid.toBareSmack(), Message.Type.chat);
        if (!threadID.isEmpty())
            message.setThread(threadID);
        message.addExtension(new ChatStateExtension(state));

        this.sendPacket(message);
    }

    public void sendLastActivityRequest(JID jid) {
        Client.TASK_QUEUE.offer(new Client.Task(Client.Command.LAST_ACTIVITY,
                                                       Collections.singletonList(jid)));
    }

    private void sendLastActivityRequestAsync(JID jid) {
        if (mFeatureDiscovery == null) {
            LOGGER.warning("no feature discovery");
            return;
        }

        // blocking
        if (!mFeatureDiscovery.getFeaturesFor(jid.toDomain())
                .containsKey(FeatureDiscovery.Feature.LAST_ACTIVITY))
            // not supported by server
            return;

        LastActivity request = new LastActivity(jid.toBareSmack());
        this.sendPacket(request);
    }

    synchronized boolean sendPackets(Stanza[] stanzas) {
        boolean sent = true;
        for (Stanza s: stanzas)
            sent &= this.sendPacket(s);
        return sent;
    }

    synchronized boolean sendPacket(Stanza p) {
        if (mConn == null) {
            LOGGER.warning("not connected");
            return false;
        }

        return mConn.send(p);
    }

    @Override
    public void processStanza(Stanza packet) {
        LOGGER.warning("IQ error: "+packet);
    }

    public boolean addToRoster(JID jid, String name) {
        if (!this.isConnected()) {
            LOGGER.info("not connected");
            return false;
        }

        if (!jid.isValid()) {
            LOGGER.warning("invalid JID: " + jid);
            return false;
        }

        try {
            // also sends presence subscription request
            Roster.getInstanceFor(mConn).createEntry(jid.toBareSmack(), name,
                    null);
        } catch (SmackException.NotLoggedInException |
                SmackException.NoResponseException |
                XMPPException.XMPPErrorException |
                SmackException.NotConnectedException |
                InterruptedException ex) {
            LOGGER.log(Level.WARNING, "can't add contact to roster", ex);
            return false;
        }
        return true;
    }

    public boolean removeFromRoster(JID jid) {
        if (!this.isConnected()) {
            LOGGER.info("not connected");
            return false;
        }
        Roster roster = Roster.getInstanceFor(mConn);
        RosterEntry entry = roster.getEntry(jid.toBareSmack());
        if (entry == null) {
            LOGGER.info("can't find roster entry for jid: "+jid);
            return true;
        }
        try {
            // blocking
            roster.removeEntry(entry);
        } catch (SmackException.NotLoggedInException |
                SmackException.NoResponseException |
                XMPPException.XMPPErrorException |
                SmackException.NotConnectedException |
                InterruptedException ex) {
            LOGGER.log(Level.WARNING, "can't remove contact from roster", ex);
            return false;
        }
        return true;
    }

    public void updateRosterEntry(JID jid, String newName) {
        if (!this.isConnected()) {
            LOGGER.info("not connected");
            return;
        }
        Roster roster = Roster.getInstanceFor(mConn);
        RosterEntry entry = roster.getEntry(jid.toBareSmack());
        if (entry == null) {
            LOGGER.warning("can't find roster entry for jid: "+jid);
            return;
        }
        try {
            entry.setName(newName);
        } catch (SmackException.NotConnectedException |
                SmackException.NoResponseException |
                XMPPException.XMPPErrorException |
                InterruptedException ex) {
            LOGGER.log(Level.WARNING, "can't set name for entry", ex);
        }
    }

    public void requestAvatar(JID jid, String id) {
        if (mAvatarSendReceiver == null) {
            LOGGER.warning("no avatar sender");
            return;
        }
        mAvatarSendReceiver.requestAndListen(jid, id);
    }

    public void publishAvatar(String id, byte[] data) {
        if (mAvatarSendReceiver == null) {
            LOGGER.warning("no avatar sender");
            return;
        }
        if (mFeatures.containsKey(FeatureDiscovery.Feature.USER_AVATAR)) {
            mAvatarSendReceiver.publish(id, data);
        } else {
            LOGGER.warning("not supported by server");
        }
    }

    public boolean deleteAvatar() {
        if (mAvatarSendReceiver == null) {
            LOGGER.warning("no avatar sender");
            return false;
        }

        if (mFeatures.containsKey(FeatureDiscovery.Feature.USER_AVATAR)) {
            return mAvatarSendReceiver.delete();
        } else {
            LOGGER.warning("not supported by server");
            // if not supported there should be no avatar set
            return true;
        }
    }

    /** Request upload slot (XEP-0636). Blocking */
    public AttachmentManager.Slot getUploadSlot(String name, long length, String mime) {
        if (mSlotRequester == null) {
            LOGGER.warning("no slot requester");
            return new AttachmentManager.Slot();
        }

        return mSlotRequester.getSlot(name, length, mime);
    }

    /* package internal*/

    void newStatus(Control.Status status) {
        if (status != Control.Status.CONNECTED)
            mFeatures.clear();

        mControl.onStatusChange(status, this.getServerFeature());
    }

    void newException(KonException konException) {
        mControl.onException(konException);
    }

    @Override
    public void run() {
        while (true) {
            Task t;
            try {
                // blocking
                t = TASK_QUEUE.take();
            } catch (InterruptedException ex) {
                LOGGER.log(Level.WARNING, "interrupted while waiting ", ex);
                return;
            }
            switch (t.command) {
                case CONNECT:
                    this.connectAsync();
                    break;
                case LAST_ACTIVITY:
                    this.sendLastActivityRequestAsync((JID) t.args.get(0));
                    break;
            }
        }
    }

    private static class Task {

        final Command command;
        final List<?> args;

        Task(Command c, List<?> a) {
            command = c;
            args = a;
        }
    }
} public final class HKPClient {
    private static final Logger LOGGER = Logger.getLogger(HKPClient.class.getName());

    //private static final int DEFAULT_PORT = 11371;
    //private static final int DEFAULT_SSL_PORT = 443;

    private static final int MAX_CONTENT_LENGTH = 9001;

    public String search(String server, String keyID) {
        CloseableHttpClient client = HttpClients.createDefault();
        HttpRequestBase get = new HttpGet(
                "https://"+server+"/pks/lookup?op=get&options=mr&exact=on&search=0x"+keyID);

        // execute request
        CloseableHttpResponse response;
        try {
            response = client.execute(get);
        } catch (IOException ex) {
            LOGGER.log(Level.WARNING, "can't execute request, server: "+server, ex);
            return "";
        }
        try {
            int code = response.getStatusLine().getStatusCode();
            if (code != HttpStatus.SC_OK) {
                if (code == HttpStatus.SC_NOT_FOUND) {
                    LOGGER.config("key not found, server: "+server+"; keyID="+keyID);
                } else {
                    LOGGER.warning("unexpected response, server: "+server+"; code=" + code);
                }
                return "";
            }

            HttpEntity entity = response.getEntity();
            if (entity == null) {
                LOGGER.warning("no download response entity");
                return "";
            }

            if (entity.getContentLength() > MAX_CONTENT_LENGTH) {
                LOGGER.warning("content too big");
                return "";
            }

            String contentStr;
            try {
                contentStr = IOUtils.toString(entity.getContent(), "UTF-8");
            } catch (IOException | IllegalStateException ex) {
                LOGGER.log(Level.WARNING, " can't read content", ex);
                return "";
            }

    //        for (Header h: response.getAllHeaders()) {
    //            System.out.println("header: "+h);
    //        }

            return contentStr;
        } finally {
            try {
                response.close();
            } catch (IOException ex) {
                LOGGER.log(Level.WARNING, "can't close response", ex);
            }
        }
    }
} public final class Coder {
    private static final Logger LOGGER = Logger.getLogger(Coder.class.getName());

    private Coder() {
    }

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

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

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

        //INVALID_MIME,
        //INVALID_TIMESTAMP,
    }

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

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

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

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

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

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

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

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

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

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

    /** Security provider: Bouncy Castle. */
    public static final String PROVIDER = "BC";

    /** The fingerprint calculator to use whenever it is needed. */
    static final KeyFingerPrintCalculator FP_CALC = new BcKeyFingerprintCalculator();

    /** Singleton for converting a PGP key to a JCA key. */
    private static JcaPGPKeyConverter sKeyConverter;

    private PGPUtils() {}

    /**
     * A contacts public keys for encryption and signing together with UID and
     * fingerprint (from signing key).
     */
    public static final class PGPCoderKey {
        final PGPPublicKey encryptKey;
        public final PGPPublicKey signKey;
        public final String userID;
        public final String fingerprint;
        public final byte[] rawKey;

        PGPCoderKey(PGPPublicKey encryptKey, PGPPublicKey signKey,
                String userID, String fingerprint, byte[] rawKey) {
            this.encryptKey = encryptKey;
            this.signKey = signKey;
            this.userID = userID;
            this.fingerprint = fingerprint;
            this.rawKey = rawKey;
        }
    }

    public static void registerProvider() {
        // register bouncy castle provider
        Security.insertProviderAt(new BouncyCastleProvider(), 1);
    }

    public static byte[] mayDisarm(InputStream input) throws IOException {
        return IOUtils.toByteArray(PGPUtil.getDecoderStream(input));
    }

    /**
     * Read a public key from ASCII armored key ring data.
     */
    public static Optional<PGPCoderKey> readPublicKey(String armoredInput) {
        try {
            return readPublicKey(IOUtils.toByteArray(
                    PGPUtil.getDecoderStream(IOUtils.toInputStream(armoredInput, "UTF-8"))));
        } catch (IOException ex) {
            LOGGER.log(Level.WARNING, "can't read armored input", ex);
            return Optional.empty();
        }
    }

    /**
     * Read a public key from key ring byte data.
     */
    public static Optional<PGPCoderKey> readPublicKey(byte[] publicKeyring) {
        PGPPublicKey encryptKey = null;
        PGPPublicKey signKey = null;
        // for legacy keyring
        PGPPublicKey authKey = null;
        String uid = null;
        String fp = null;

        PGPPublicKeyRing keyRing = keyRingOrNull(publicKeyring);
        if (keyRing == null)
            return Optional.empty();

        Iterator<PGPPublicKey> keyIter = keyRing.getPublicKeys();
        while (keyIter.hasNext()) {
            PGPPublicKey key = keyIter.next();
            if (key.isMasterKey()) {
                authKey = key;
                fp = Hex.toHexString(key.getFingerprint());
                Iterator<?> uidIt = key.getUserIDs();
                if (uidIt.hasNext())
                    uid = (String) uidIt.next();
                // TODO if more than one UID?
            } else if (isSigningKey(key)) {
                signKey = key;
            } else if (key.isEncryptionKey()) {
                encryptKey = key;
            }
        }

        // legacy: auth key is actually signing key
        if (signKey == null && authKey != null) {
            LOGGER.info("loading legacy public key, uid: "+uid);
            signKey = authKey;
        }

        if (encryptKey == null || signKey == null || uid == null) {
            LOGGER.warning("can't find public keys in key ring, uid: "+uid);
            return Optional.empty();
        }
        return Optional.of(new PGPCoderKey(encryptKey, signKey, uid, fp, publicKeyring));
    }

    public static X509Certificate loadX509Cert(byte[] certData)
            throws CertificateException, NoSuchProviderException {

        CertificateFactory certFactory = CertificateFactory.getInstance("X.509", PROVIDER);
        InputStream in = new ByteArrayInputStream(certData);

        return (X509Certificate) certFactory.generateCertificate(in);
    }

    private static void ensureKeyConverter() {
        if (sKeyConverter == null)
            sKeyConverter = new JcaPGPKeyConverter().setProvider(PGPUtils.PROVIDER);
    }

    static PrivateKey convertPrivateKey(PGPPrivateKey key) throws PGPException {
        ensureKeyConverter();
    	return sKeyConverter.getPrivateKey(key);
    }

    static PublicKey convertPublicKey(PGPPublicKey key) throws PGPException {
        ensureKeyConverter();
        return sKeyConverter.getPublicKey(key);
    }

    private static int getKeyFlags(PGPPublicKey key) {
        @SuppressWarnings("unchecked")
        Iterator<PGPSignature> sigs = key.getSignatures();
        while (sigs.hasNext()) {
            PGPSignature sig = sigs.next();
            PGPSignatureSubpacketVector subpackets = sig.getHashedSubPackets();
            if (subpackets != null)
                return subpackets.getKeyFlags();
        }
        return 0;
    }

    static boolean isSigningKey(PGPPublicKey key) {
        int keyFlags = getKeyFlags(key);
        return (keyFlags & PGPKeyFlags.CAN_SIGN) == PGPKeyFlags.CAN_SIGN;
    }

    static PGPKeyPair decrypt(PGPSecretKey secretKey, PBESecretKeyDecryptor dec) throws KonException {
        try {
            return new PGPKeyPair(secretKey.getPublicKey(), secretKey.extractPrivateKey(dec));
        } catch (PGPException ex) {
            LOGGER.log(Level.WARNING, "failed", ex);
            throw new KonException(KonException.Error.LOAD_KEY_DECRYPT, ex);
        }
    }

    public static PGPSecretKeyRing copySecretKeyRingWithNewPassword(byte[] privateKeyData,
            char[] oldPassphrase, char[] newPassphrase) throws PGPException, IOException, KonException {

        // load the secret key ring
        PGPSecretKeyRing secRing = new PGPSecretKeyRing(privateKeyData, FP_CALC);

        PGPDigestCalculatorProvider calcProv = new JcaPGPDigestCalculatorProviderBuilder().build();
        PBESecretKeyDecryptor decryptor = new JcePBESecretKeyDecryptorBuilder(calcProv)
            .setProvider(PGPUtils.PROVIDER)
            .build(oldPassphrase);

        PGPDigestCalculator calc = new JcaPGPDigestCalculatorProviderBuilder().build().get(HashAlgorithmTags.SHA256);
        PBESecretKeyEncryptor encryptor = new JcePBESecretKeyEncryptorBuilder(PGPEncryptedData.AES_256, calc)
            .setProvider(PROVIDER).build(newPassphrase);

        try {
            return PGPSecretKeyRing.copyWithNewPassword(secRing, decryptor, encryptor);
        } catch (PGPException ex) {
            // treat this special, cause most like the decryption password was wrong
            throw new KonException(KonException.Error.CHANGE_PASS_COPY, ex);
        }
    }

    public static long parseKeyIDFromSignature(String signatureData) {
        Object o;
        try {
            JcaPGPObjectFactory pgpFact = new JcaPGPObjectFactory(
                    PGPUtil.getDecoderStream(IOUtils.toInputStream(signatureData, "UTF-8")));
            o = pgpFact.nextObject();
            if (o instanceof PGPCompressedData) {
                PGPCompressedData data = (PGPCompressedData) o;
                pgpFact = new JcaPGPObjectFactory(data.getDataStream());
                o = pgpFact.nextObject();
            }
        } catch (IOException | PGPException ex) {
            LOGGER.log(Level.WARNING, "can't get signature object", ex);
            return 0;
        }

        // somehow two signature lists possible
        if (o instanceof PGPSignatureList) {
            PGPSignatureList signList = (PGPSignatureList) o;
            if (signList.size() > 1) {
                LOGGER.warning("more than one signature in signature list");
            } else if (signList.isEmpty()) {
                LOGGER.warning("signature list is empty");
                return 0;
            }
            return signList.get(0).getKeyID();
        } else if (o instanceof PGPOnePassSignatureList) {
            PGPOnePassSignatureList signList = (PGPOnePassSignatureList) o;
            if (signList.size() > 1) {
                LOGGER.warning("more than one signature in signature list");
            } else if (signList.isEmpty()) {
                LOGGER.warning("signature list is empty");
                return 0;
            }
            return signList.get(0).getKeyID();
        } else {
            LOGGER.warning("object not signature list: "+o);
            return 0;
        }
    }

    private static PGPPublicKeyRing keyRingOrNull(byte[] keyData) {
        PGPPublicKeyRingCollection keyRingCollection;
        try {
            keyRingCollection = new PGPPublicKeyRingCollection(keyData, FP_CALC);
        } catch (IOException | PGPException ex) {
            LOGGER.log(Level.WARNING, "can't read public key ring", ex);
            return null;
        }

        if (keyRingCollection.size() > 1) {
            LOGGER.warning("more than one key ring in collection");
        }

        Iterator<PGPPublicKeyRing> keyRingIter = keyRingCollection.getKeyRings();
        if (!keyRingIter.hasNext()) {
            LOGGER.warning("no key ring in collection");
            return null;
        }
        return keyRingIter.next();
    }

    private static final Pattern UID_PATTERN =
            Pattern.compile("(^.+?)( \\((.+)\\))?( <([A-Za-z0-9\\._%+-]+@[A-Za-z0-9\\.-]+)>$)?");

    /**
     * Parses a PGP user id string and returns exactly three strings (in this
     * order): (1) user name, (2) comment and (3) email address.
     * All strings are optional and empty if not found in user id.
     * Email address maybe invalid to RFC-standards.
     */
    public static String[] parseUID(String userID) {
        Matcher matcher = UID_PATTERN.matcher(userID);
        if (!matcher.matches() || matcher.groupCount() < 5)
            return new String[]{"", "", ""};

        return new String[]{StringUtils.defaultString(matcher.group(1)),
            StringUtils.defaultString(matcher.group(3)),
            StringUtils.defaultString(matcher.group(5))};
    }

    public static boolean isEncryptedFile(Path file) {
        try (FileInputStream input = new FileInputStream(file.toFile())) {
            PGPObjectFactory factory = new PGPObjectFactory(input, FP_CALC);
            Object o = factory.nextObject();
            return o instanceof PGPEncryptedDataList || o instanceof PGPMarker;
        // NOTE: exception class is not well defined for non-pgp data
        } catch(IOException | RuntimeException ex) {
            return false;
        }
    }
} 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 class ViewEvent {

    private ViewEvent() {}

    /** Application status changed. */
    public static class StatusChange extends ViewEvent {
        public final Control.Status status;
        public final EnumSet<FeatureDiscovery.Feature> features;

        public StatusChange(Control.Status status, EnumSet<FeatureDiscovery.Feature> features) {
            this.status = status;
            this.features = features;
        }
    }

    /** Key is password protected (ask for password). */
    public static class PasswordSet extends ViewEvent {
    }

    /** The personal account is missing (show import wizard). */
    public static class MissingAccount extends ViewEvent {
        public final boolean connect;

        public MissingAccount(boolean connect) {
            this.connect = connect;
        }
    }

    /** An exception was thrown somewhere. */
    public static class Exception extends ViewEvent {
        public final KonException exception;

        public Exception(KonException exception) {
            this.exception = exception;
        }
    }

    /** There was a security error while de-/encrypting a message. */
    public static class SecurityError extends ViewEvent {
        public final KonMessage message;

        public SecurityError(KonMessage message) {
            this.message = message;
        }
    }

    /** Got a new message. */
    public static class NewMessage extends ViewEvent {
        public final InMessage message;

        public NewMessage(InMessage message) {
            this.message = message;
        }
    }

    /** Got a new public key (ask whattodo). */
    public static class NewKey extends ViewEvent {
        public final Contact contact;
        public final PGPCoderKey key;

        public NewKey(Contact contact, PGPCoderKey key) {
            this.contact = contact;
            this.key = key;
        }
    }

    /** Contact was deleted in roster (ask whattodo). */
    public static class ContactDeleted extends ViewEvent {
        public final Contact contact;

        public ContactDeleted(Contact contact) {
            this.contact = contact;
        }
    }

    /** A presence error. */
    public static class PresenceError extends ViewEvent {
        public final Contact contact;
        public final RosterHandler.Error error;

        public PresenceError(Contact contact, RosterHandler.Error error) {
            this.contact = contact;
            this.error = error;
        }
    }

    /** A contact wants presence subscription (ask whattodo). */
    public static class SubscriptionRequest extends ViewEvent {
        public final Contact contact;

        public SubscriptionRequest(Contact contact) {
            this.contact = contact;
        }
    }

    /** Notify view about connection retry in X seconds. */
    public static class RetryTimerMessage extends ViewEvent {
        public final int countdown;

        public RetryTimerMessage(int countdown) {
            this.countdown = countdown;
        }
    }
} public final class Contact extends Observable implements Searchable {
    private static final Logger LOGGER = Logger.getLogger(Contact.class.getName());

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

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

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

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

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

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

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

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

    public JID getJID() {
        return mJID;
    }

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

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

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

    public int getID() {
        return mID;
    }

    public String getName() {
        return mName;
    }

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

        mName = name;
        this.save();

        this.changed(ViewChange.NAME);
    }

    public String getStatus() {
        return mStatus;
    }

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

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

    public boolean getEncrypted() {
        return mEncrypted;
    }

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

        mEncrypted = encrypted;
        this.save();
    }

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

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

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

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

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

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

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

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

    public String getFingerprint() {
        return mFingerprint;
    }

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

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

    public boolean isBlocked() {
        return mBlocked;
    }

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

    public Subscription getSubScription() {
        return mSubStatus;
    }

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

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

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

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

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

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

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

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

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

        this.changed(ViewChange.AVATAR);
    }

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

        this.changed(ViewChange.AVATAR);
    }

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

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

        mCustomAvatar.delete();
        mCustomAvatar = null;

        this.changed(ViewChange.AVATAR);
    }

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

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

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

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

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

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

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

        mSaveOnShutdown = false;
    }

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

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

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

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

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

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

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

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

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

        return new Contact(id, jid, name, status,
                Optional.ofNullable(lastSeen), encr, key, fp, avatarID);
    }
} public enum Subscription {
    UNKNOWN, PENDING, SUBSCRIBED, UNSUBSCRIBED
} 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) {
        InMessage newMessage = new InMessage(protoMessage, chat, ids.jid,
        ids.xmppID, serverDate);

        if (newMessage.getID() <= 0)
            return Optional.empty();

        if (chat.getMessages().contains(newMessage)) {
            LOGGER.info("message already in chat, dropping this one");
            return Optional.empty();
        }
        boolean added = chat.addMessage(newMessage);
        if (!added) {
            LOGGER.warning("can't add message to chat");
            return Optional.empty();
        }
        return Optional.of(newMessage);
    }

    public Optional<OutMessage> createOutMessage(Chat chat,
            List<Contact> contacts, MessageContent content) {
        OutMessage newMessage = new OutMessage(chat, contacts, content,
                chat.isSendEncrypted());

        boolean added = chat.addMessage(newMessage);
        if (!added) {
            LOGGER.warning("could not add outgoing message to chat");
            return Optional.empty();
        }
        return Optional.of(newMessage);
    }

    public static Path appDir() {
        if (APP_DIR == null)
            throw new IllegalStateException("model not set up");

        return APP_DIR;
    }

    public static Database database(){
        if (DATABASE == null)
            throw new IllegalStateException("model not set up");

        return DATABASE;
    }

    public static JID getUserJID() {
        return JID.bare(Config.getInstance().getString(Config.ACC_JID));
    }

    public void onShutDown() {
        mContactList.onShutDown();
    }
} 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 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.misc.JID;
import org.kontalk.misc.ViewEvent;
import org.kontalk.model.Contact;
import org.kontalk.model.Contact.Subscription;
import org.kontalk.model.Model;
import org.kontalk.persistence.Config;
import org.kontalk.util.ClientUtils;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
import java.util.logging.Logger;
import java.util.stream.Collectors;
import org.jivesoftware.smack.packet.Presence;
import org.jivesoftware.smack.packet.StanzaError;
import org.kontalk.client.Client;
import org.kontalk.client.HKPClient;
import org.kontalk.crypto.Coder;
import org.kontalk.crypto.PGPUtils;
/*
 *  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.system;



/**
 * Process incoming roster and presence changes.
 *
 * @author Alexander Bikadorov {@literal <bikaejkb@mail.tu-berlin.de>}
 */
public final class RosterHandler {
    private static final Logger LOGGER = Logger.getLogger(RosterHandler.class.getName());

    private final Control mControl;
    private final Client mClient;
    private final Model mModel;

    private static final List<String> KEY_SERVERS = Collections.singletonList(
            "pgp.mit.edu"
            // TODO: add CA for this
            //"pool.sks-keyservers.net"
    );

    public enum Error {
        SERVER_NOT_FOUND
    }

    RosterHandler(Control control, Client client, Model model) {
        mControl = control;
        mClient = client;
        mModel = model;
    }

    public void onLoaded(List<ClientUtils.KonRosterEntry> entries) {
        for (ClientUtils.KonRosterEntry entry: entries)
            this.onEntryAdded(entry);

        // check for deleted entries
        List<JID> rosterJIDs = entries.stream().map(e -> e.jid).collect(Collectors.toList());
        for (Contact contact : mModel.contacts().getAll(false, true))
            if (!rosterJIDs.contains(contact.getJID()))
                this.onEntryDeleted(contact.getJID());
    }

    public void onEntryAdded(ClientUtils.KonRosterEntry entry) {
        if (mModel.contacts().contains(entry.jid)) {
            this.onEntryUpdate(entry);
            return;
        }

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

        String name = entry.name.equals(entry.jid.local()) && entry.jid.isHash() ?
                // this must be the hash string, don't use it as name
                "" :
                entry.name;

        Contact newContact = mControl.createContact(entry.jid, name).orElse(null);
        if (newContact == null)
            return;

        newContact.setSubscriptionStatus(entry.subscription);

        mControl.maySendKeyRequest(newContact);

        if (entry.subscription == Contact.Subscription.UNSUBSCRIBED)
            mControl.sendPresenceSubscription(entry.jid, Client.PresenceCommand.REQUEST);
    }

    public void onEntryDeleted(JID jid) {
        // NOTE: also called on rename
        Contact contact = mModel.contacts().get(jid).orElse(null);
        if (contact == null) {
            LOGGER.info("can't find contact with jid: "+jid);
            return;
        }

        // TODO detect if contact account still exists

        mControl.getViewControl().changed(new ViewEvent.ContactDeleted(contact));