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