001/**
002 *
003 * Copyright 2016 Florian Schmaus
004 *
005 * Licensed under the Apache License, Version 2.0 (the "License");
006 * you may not use this file except in compliance with the License.
007 * You may obtain a copy of the License at
008 *
009 *     http://www.apache.org/licenses/LICENSE-2.0
010 *
011 * Unless required by applicable law or agreed to in writing, software
012 * distributed under the License is distributed on an "AS IS" BASIS,
013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
014 * See the License for the specific language governing permissions and
015 * limitations under the License.
016 */
017package org.jivesoftware.smackx.iot.provisioning;
018
019import java.util.List;
020import java.util.Map;
021import java.util.Set;
022import java.util.WeakHashMap;
023import java.util.concurrent.CopyOnWriteArraySet;
024import java.util.logging.Level;
025import java.util.logging.Logger;
026
027import org.jivesoftware.smack.ConnectionCreationListener;
028import org.jivesoftware.smack.Manager;
029import org.jivesoftware.smack.SmackException.NoResponseException;
030import org.jivesoftware.smack.SmackException.NotConnectedException;
031import org.jivesoftware.smack.StanzaListener;
032import org.jivesoftware.smack.XMPPConnection;
033import org.jivesoftware.smack.XMPPConnectionRegistry;
034import org.jivesoftware.smack.XMPPException.XMPPErrorException;
035import org.jivesoftware.smack.filter.AndFilter;
036import org.jivesoftware.smack.filter.StanzaExtensionFilter;
037import org.jivesoftware.smack.filter.StanzaFilter;
038import org.jivesoftware.smack.filter.StanzaTypeFilter;
039import org.jivesoftware.smack.iqrequest.AbstractIqRequestHandler;
040import org.jivesoftware.smack.iqrequest.IQRequestHandler.Mode;
041import org.jivesoftware.smack.packet.IQ;
042import org.jivesoftware.smack.packet.IQ.Type;
043import org.jivesoftware.smack.packet.Message;
044import org.jivesoftware.smack.packet.Presence;
045import org.jivesoftware.smack.packet.Stanza;
046import org.jivesoftware.smack.roster.AbstractPresenceEventListener;
047import org.jivesoftware.smack.roster.Roster;
048import org.jivesoftware.smack.roster.SubscribeListener;
049import org.jivesoftware.smackx.disco.ServiceDiscoveryManager;
050import org.jivesoftware.smackx.disco.packet.DiscoverInfo;
051import org.jivesoftware.smackx.iot.IoTManager;
052import org.jivesoftware.smackx.iot.discovery.IoTDiscoveryManager;
053import org.jivesoftware.smackx.iot.provisioning.element.ClearCache;
054import org.jivesoftware.smackx.iot.provisioning.element.ClearCacheResponse;
055import org.jivesoftware.smackx.iot.provisioning.element.Constants;
056import org.jivesoftware.smackx.iot.provisioning.element.Friend;
057import org.jivesoftware.smackx.iot.provisioning.element.IoTIsFriend;
058import org.jivesoftware.smackx.iot.provisioning.element.IoTIsFriendResponse;
059import org.jivesoftware.smackx.iot.provisioning.element.Unfriend;
060import org.jxmpp.jid.BareJid;
061import org.jxmpp.jid.DomainBareJid;
062import org.jxmpp.jid.Jid;
063import org.jxmpp.util.cache.LruCache;
064
065/**
066 * A manager for XEP-0324: Internet of Things - Provisioning.
067 *
068 * @author Florian Schmaus {@literal <flo@geekplace.eu>}
069 * @see <a href="http://xmpp.org/extensions/xep-0324.html">XEP-0324: Internet of Things - Provisioning</a>
070 */
071public final class IoTProvisioningManager extends Manager {
072
073    private static final Logger LOGGER = Logger.getLogger(IoTProvisioningManager.class.getName());
074
075    private static final StanzaFilter FRIEND_MESSAGE = new AndFilter(StanzaTypeFilter.MESSAGE,
076            new StanzaExtensionFilter(Friend.ELEMENT, Friend.NAMESPACE));
077    private static final StanzaFilter UNFRIEND_MESSAGE = new AndFilter(StanzaTypeFilter.MESSAGE,
078                    new StanzaExtensionFilter(Unfriend.ELEMENT, Unfriend.NAMESPACE));
079
080    private static final Map<XMPPConnection, IoTProvisioningManager> INSTANCES = new WeakHashMap<>();
081
082    // Ensure a IoTProvisioningManager exists for every connection.
083    static {
084        XMPPConnectionRegistry.addConnectionCreationListener(new ConnectionCreationListener() {
085            @Override
086            public void connectionCreated(XMPPConnection connection) {
087                if (!IoTManager.isAutoEnableActive()) return;
088                getInstanceFor(connection);
089            }
090        });
091    }
092
093    /**
094     * Get the manger instance responsible for the given connection.
095     *
096     * @param connection the XMPP connection.
097     * @return a manager instance.
098     */
099    public static synchronized IoTProvisioningManager getInstanceFor(XMPPConnection connection) {
100        IoTProvisioningManager manager = INSTANCES.get(connection);
101        if (manager == null) {
102            manager = new IoTProvisioningManager(connection);
103            INSTANCES.put(connection, manager);
104        }
105        return manager;
106    }
107
108    private final Roster roster;
109    private final LruCache<Jid, LruCache<BareJid, Void>> negativeFriendshipRequestCache = new LruCache<>(8);
110    private final LruCache<BareJid, Void> friendshipDeniedCache = new LruCache<>(16);
111
112    private final LruCache<BareJid, Void> friendshipRequestedCache = new LruCache<>(16);
113
114    private final Set<BecameFriendListener> becameFriendListeners = new CopyOnWriteArraySet<>();
115
116    private final Set<WasUnfriendedListener> wasUnfriendedListeners = new CopyOnWriteArraySet<>();
117
118    private Jid configuredProvisioningServer;
119
120    private IoTProvisioningManager(XMPPConnection connection) {
121        super(connection);
122
123        // Stanza listener for XEP-0324 § 3.2.3.
124        connection.addAsyncStanzaListener(new StanzaListener() {
125            @Override
126            public void processStanza(Stanza stanza) throws NotConnectedException, InterruptedException {
127                if (!isFromProvisioningService(stanza, true)) {
128                    return;
129                }
130
131                Message message = (Message) stanza;
132                Unfriend unfriend = Unfriend.from(message);
133                BareJid unfriendJid = unfriend.getJid();
134                final XMPPConnection connection = connection();
135                Roster roster = Roster.getInstanceFor(connection);
136                if (!roster.isSubscribedToMyPresence(unfriendJid)) {
137                    LOGGER.warning("Ignoring <unfriend/> request '" + stanza + "' because " + unfriendJid
138                                    + " is already not subscribed to our presence.");
139                    return;
140                }
141                Presence unsubscribed = new Presence(Presence.Type.unsubscribed);
142                unsubscribed.setTo(unfriendJid);
143                connection.sendStanza(unsubscribed);
144            }
145        }, UNFRIEND_MESSAGE);
146
147        // Stanza listener for XEP-0324 § 3.2.4 "Recommending Friendships".
148        // Also includes business logic for thing-to-thing friendship recommendations, which is not
149        // (yet) part of the XEP.
150        connection.addAsyncStanzaListener(new StanzaListener() {
151            @Override
152            public void processStanza(final Stanza stanza) throws NotConnectedException, InterruptedException {
153                final Message friendMessage = (Message) stanza;
154                final Friend friend = Friend.from(friendMessage);
155                final BareJid friendJid = friend.getFriend();
156
157                if (isFromProvisioningService(friendMessage, false)) {
158                    // We received a recommendation from a provisioning server.
159                    // Notify the recommended friend that we will now accept his
160                    // friendship requests.
161                    final XMPPConnection connection = connection();
162                    Friend friendNotifiacation = new Friend(connection.getUser().asBareJid());
163                    Message notificationMessage = new Message(friendJid, friendNotifiacation);
164                    connection.sendStanza(notificationMessage);
165                } else {
166                    // Check is the message was send from a thing we previously
167                    // tried to become friends with. If this is the case, then
168                    // thing is likely telling us that we can become now
169                    // friends.
170                    BareJid bareFrom = friendMessage.getFrom().asBareJid();
171                    if (!friendshipDeniedCache.containsKey(bareFrom)) {
172                        LOGGER.log(Level.WARNING, "Ignoring friendship recommendation "
173                                        + friendMessage
174                                        + " because friendship to this JID was not previously denied.");
175                        return;
176                    }
177
178                    // Sanity check: If a thing recommends us itself as friend,
179                    // which should be the case once we reach this code, then
180                    // the bare 'from' JID should be equals to the JID of the
181                    // recommended friend.
182                    if (!bareFrom.equals(friendJid)) {
183                        LOGGER.log(Level.WARNING,
184                                        "Ignoring friendship recommendation " + friendMessage
185                                                        + " because it does not recommend itself, but "
186                                                        + friendJid + '.');
187                        return;
188                    }
189
190                    // Re-try the friendship request.
191                    sendFriendshipRequest(friendJid);
192                }
193            }
194        }, FRIEND_MESSAGE);
195
196        connection.registerIQRequestHandler(
197                        new AbstractIqRequestHandler(ClearCache.ELEMENT, ClearCache.NAMESPACE, Type.set, Mode.async) {
198                            @Override
199                            public IQ handleIQRequest(IQ iqRequest) {
200                                if (!isFromProvisioningService(iqRequest, true)) {
201                                    return null;
202                                }
203
204                                ClearCache clearCache = (ClearCache) iqRequest;
205
206                                // Handle <clearCache/> request.
207                                Jid from = iqRequest.getFrom();
208                                LruCache<BareJid, Void> cache = negativeFriendshipRequestCache.lookup(from);
209                                if (cache != null) {
210                                    cache.clear();
211                                }
212
213                                return new ClearCacheResponse(clearCache);
214                            }
215                        });
216
217        roster = Roster.getInstanceFor(connection);
218        roster.addSubscribeListener(new SubscribeListener() {
219            @Override
220            public SubscribeAnswer processSubscribe(Jid from, Presence subscribeRequest) {
221                // First check if the subscription request comes from a known registry and accept the request if so.
222                try {
223                    if (IoTDiscoveryManager.getInstanceFor(connection()).isRegistry(from.asBareJid())) {
224                        return SubscribeAnswer.Approve;
225                    }
226                }
227                catch (NoResponseException | XMPPErrorException | NotConnectedException | InterruptedException e) {
228                    LOGGER.log(Level.WARNING, "Could not determine if " + from + " is a registry", e);
229                }
230
231                Jid provisioningServer = null;
232                try {
233                    provisioningServer = getConfiguredProvisioningServer();
234                }
235                catch (NoResponseException | XMPPErrorException | NotConnectedException | InterruptedException e) {
236                    LOGGER.log(Level.WARNING,
237                                    "Could not determine privisioning server. Ignoring friend request from " + from, e);
238                }
239                if (provisioningServer == null) {
240                    return null;
241                }
242
243                boolean isFriend;
244                try {
245                    isFriend = isFriend(provisioningServer, from.asBareJid());
246                }
247                catch (NoResponseException | XMPPErrorException | NotConnectedException | InterruptedException e) {
248                    LOGGER.log(Level.WARNING, "Could not determine if " + from + " is a friend.", e);
249                    return null;
250                }
251
252                if (isFriend) {
253                    return SubscribeAnswer.Approve;
254                }
255                else {
256                    return SubscribeAnswer.Deny;
257                }
258            }
259        });
260
261        roster.addPresenceEventListener(new AbstractPresenceEventListener() {
262            @Override
263            public void presenceSubscribed(BareJid address, Presence subscribedPresence) {
264                friendshipRequestedCache.remove(address);
265                for (BecameFriendListener becameFriendListener : becameFriendListeners) {
266                    becameFriendListener.becameFriend(address, subscribedPresence);
267                }
268            }
269            @Override
270            public void presenceUnsubscribed(BareJid address, Presence unsubscribedPresence) {
271                if (friendshipRequestedCache.containsKey(address)) {
272                    friendshipDeniedCache.put(address, null);
273                }
274                for (WasUnfriendedListener wasUnfriendedListener : wasUnfriendedListeners) {
275                    wasUnfriendedListener.wasUnfriendedListener(address, unsubscribedPresence);
276                }
277            }
278        });
279    }
280
281    /**
282     * Set the configured provisioning server. Use <code>null</code> as provisioningServer to use
283     * automatic discovery of the provisioning server (the default behavior).
284     * 
285     * @param provisioningServer
286     */
287    public void setConfiguredProvisioningServer(Jid provisioningServer) {
288        this.configuredProvisioningServer = provisioningServer;
289    }
290
291    public Jid getConfiguredProvisioningServer()
292                    throws NoResponseException, XMPPErrorException, NotConnectedException, InterruptedException {
293        if (configuredProvisioningServer == null) {
294            configuredProvisioningServer = findProvisioningServerComponent();
295        }
296        return configuredProvisioningServer;
297    }
298
299    /**
300     * Try to find a provisioning server component.
301     * 
302     * @return the XMPP address of the provisioning server component if one was found.
303     * @throws NoResponseException
304     * @throws XMPPErrorException
305     * @throws NotConnectedException
306     * @throws InterruptedException
307     * @see <a href="http://xmpp.org/extensions/xep-0324.html#servercomponent">XEP-0324 § 3.1.2 Provisioning Server as a server component</a>
308     */
309    public DomainBareJid findProvisioningServerComponent() throws NoResponseException, XMPPErrorException, NotConnectedException, InterruptedException {
310        final XMPPConnection connection = connection();
311        ServiceDiscoveryManager sdm = ServiceDiscoveryManager.getInstanceFor(connection);
312        List<DiscoverInfo> discoverInfos = sdm.findServicesDiscoverInfo(Constants.IOT_PROVISIONING_NAMESPACE, true, true);
313        if (discoverInfos.isEmpty()) {
314            return null;
315        }
316        Jid jid = discoverInfos.get(0).getFrom();
317        assert (jid.isDomainBareJid());
318        return jid.asDomainBareJid();
319    }
320
321    /**
322     * As the given provisioning server is the given JID is a friend.
323     *
324     * @param provisioningServer the provisioning server to ask.
325     * @param friendInQuestion the JID to ask about.
326     * @return <code>true</code> if the JID is a friend, <code>false</code> otherwise.
327     * @throws NoResponseException
328     * @throws XMPPErrorException
329     * @throws NotConnectedException
330     * @throws InterruptedException
331     */
332    public boolean isFriend(Jid provisioningServer, BareJid friendInQuestion) throws NoResponseException, XMPPErrorException, NotConnectedException, InterruptedException {
333        LruCache<BareJid, Void> cache = negativeFriendshipRequestCache.lookup(provisioningServer);
334        if (cache != null && cache.containsKey(friendInQuestion)) {
335            // We hit a cached negative isFriend response for this provisioning server.
336            return false;
337        }
338
339        IoTIsFriend iotIsFriend = new IoTIsFriend(friendInQuestion);
340        iotIsFriend.setTo(provisioningServer);
341        IoTIsFriendResponse response = connection().createStanzaCollectorAndSend(iotIsFriend).nextResultOrThrow();
342        assert (response.getJid().equals(friendInQuestion));
343        boolean isFriend = response.getIsFriendResult();
344        if (!isFriend) {
345            // Cache the negative is friend response.
346            if (cache == null) {
347                cache = new LruCache<>(1024);
348                negativeFriendshipRequestCache.put(provisioningServer, cache);
349            }
350            cache.put(friendInQuestion, null);
351        }
352        return isFriend;
353    }
354
355    public boolean iAmFriendOf(BareJid otherJid) {
356        return roster.iAmSubscribedTo(otherJid);
357    }
358
359    public void sendFriendshipRequest(BareJid bareJid) throws NotConnectedException, InterruptedException {
360        Presence presence = new Presence(Presence.Type.subscribe);
361        presence.setTo(bareJid);
362
363        friendshipRequestedCache.put(bareJid, null);
364
365        connection().sendStanza(presence);
366    }
367
368    public void sendFriendshipRequestIfRequired(BareJid jid) throws NotConnectedException, InterruptedException {
369        if (iAmFriendOf(jid)) return;
370
371        sendFriendshipRequest(jid);
372    }
373
374    public boolean isMyFriend(Jid friendInQuestion) {
375        return roster.isSubscribedToMyPresence(friendInQuestion);
376    }
377
378    public void unfriend(Jid friend) throws NotConnectedException, InterruptedException {
379        if (isMyFriend(friend)) {
380            Presence presence = new Presence(Presence.Type.unsubscribed);
381            presence.setTo(friend);
382            connection().sendStanza(presence);
383        }
384    }
385
386    public boolean addBecameFriendListener(BecameFriendListener becameFriendListener) {
387        return becameFriendListeners.add(becameFriendListener);
388    }
389
390    public boolean removeBecameFriendListener(BecameFriendListener becameFriendListener) {
391        return becameFriendListeners.remove(becameFriendListener);
392    }
393
394    public boolean addWasUnfriendedListener(WasUnfriendedListener wasUnfriendedListener) {
395        return wasUnfriendedListeners.add(wasUnfriendedListener);
396    }
397
398    public boolean removeWasUnfriendedListener(WasUnfriendedListener wasUnfriendedListener) {
399        return wasUnfriendedListeners.remove(wasUnfriendedListener);
400    }
401
402    private boolean isFromProvisioningService(Stanza stanza, boolean log) {
403        Jid provisioningServer;
404        try {
405            provisioningServer = getConfiguredProvisioningServer();
406        }
407        catch (NotConnectedException | InterruptedException | NoResponseException | XMPPErrorException e) {
408            LOGGER.log(Level.WARNING, "Could determine provisioning server", e);
409            return false;
410        }
411        if (provisioningServer == null) {
412            if (log) {
413                LOGGER.warning("Ignoring request '" + stanza
414                                + "' because no provisioning server configured.");
415            }
416            return false;
417        }
418        if (!provisioningServer.equals(stanza.getFrom())) {
419            if (log) {
420                LOGGER.warning("Ignoring  request '" + stanza
421                                + "' because not from provising server '" + provisioningServer
422                                + "'.");
423            }
424            return false;
425        }
426        return true;
427    }
428}