/* * 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.client; import java.io.File; import java.io.IOException; import java.nio.file.Path; import java.util.ArrayList; import java.util.Collections; import java.util.EnumMap; import java.util.EnumSet; import java.util.List; import java.util.Optional; import java.util.concurrent.LinkedBlockingQueue; import java.util.logging.Level; import java.util.logging.Logger; import org.jivesoftware.smack.SmackException; import org.jivesoftware.smack.StanzaListener; import org.jivesoftware.smack.XMPPException; import org.jivesoftware.smack.filter.IQTypeFilter; import org.jivesoftware.smack.filter.StanzaFilter; import org.jivesoftware.smack.filter.StanzaTypeFilter; import org.jivesoftware.smack.packet.IQ; import org.jivesoftware.smack.packet.Message; import org.jivesoftware.smack.packet.Presence; import org.jivesoftware.smack.packet.Stanza; import org.jivesoftware.smack.roster.Roster; import org.jivesoftware.smack.roster.RosterEntry; import org.jivesoftware.smackx.caps.EntityCapsManager; import org.jivesoftware.smackx.caps.cache.SimpleDirectoryPersistentCache; import org.jivesoftware.smackx.chatstates.ChatState; import org.jivesoftware.smackx.chatstates.packet.ChatStateExtension; import org.jivesoftware.smackx.disco.ServiceDiscoveryManager; import org.jivesoftware.smackx.iqlast.packet.LastActivity; import org.jxmpp.jid.EntityFullJid; import org.jxmpp.jid.Jid; import org.kontalk.crypto.PersonalKey; import org.kontalk.misc.JID; import org.kontalk.misc.KonException; import org.kontalk.persistence.Config; import org.kontalk.system.AttachmentManager; import org.kontalk.system.Control; import org.kontalk.system.RosterHandler; import org.kontalk.util.MessageUtils.SendTask; /** * Network client for an XMPP Kontalk Server. * * @author Alexander Bikadorov {@literal <bikaejkb@mail.tu-berlin.de>} */ 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.getServiceName()) : 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; } } }