001package com.box.sdk;
002
003import com.eclipsesource.json.JsonArray;
004import com.eclipsesource.json.JsonObject;
005import com.eclipsesource.json.JsonValue;
006import java.util.ArrayList;
007import java.util.Collection;
008
009/**
010 * Receives real-time events from the API and forwards them to {@link EventListener EventListeners}.
011 *
012 * <p>This class handles long polling the Box events endpoint in order to receive real-time user events.
013 * When an EventStream is started, it begins long polling on a separate thread until the {@link #stop} method
014 * is called.
015 * Since the API may return duplicate events, EventStream also maintains a small cache of the most recently received
016 * event IDs in order to automatically deduplicate events.</p>
017 * <p>Note: Enterprise Events can be accessed by admin users with the EventLog.getEnterpriseEvents method</p>
018 */
019public class EventStream {
020
021    private static final int LIMIT = 800;
022    /**
023     * Events URL.
024     */
025    public static final URLTemplate EVENT_URL = new URLTemplate("events?limit=" + LIMIT + "&stream_position=%s");
026    private static final int STREAM_POSITION_NOW = -1;
027    private static final int DEFAULT_POLLING_DELAY = 1000;
028    private final BoxAPIConnection api;
029    private final long startingPosition;
030    private final int pollingDelay;
031    private final Collection<EventListener> listeners;
032    private final Object listenerLock;
033
034    private LRUCache<String> receivedEvents;
035    private boolean started;
036    private Poller poller;
037    private Thread pollerThread;
038
039    /**
040     * Constructs an EventStream using an API connection.
041     *
042     * @param api the API connection to use.
043     */
044    public EventStream(BoxAPIConnection api) {
045        this(api, STREAM_POSITION_NOW, DEFAULT_POLLING_DELAY);
046    }
047
048    /**
049     * Constructs an EventStream using an API connection and a starting initial position.
050     *
051     * @param api              the API connection to use.
052     * @param startingPosition the starting position of the event stream.
053     */
054    public EventStream(BoxAPIConnection api, long startingPosition) {
055        this(api, startingPosition, DEFAULT_POLLING_DELAY);
056    }
057
058    /**
059     * Constructs an EventStream using an API connection and a starting initial position with custom polling delay.
060     *
061     * @param api              the API connection to use.
062     * @param startingPosition the starting position of the event stream.
063     * @param pollingDelay     the delay in milliseconds between successive calls to get more events.
064     */
065    public EventStream(BoxAPIConnection api, long startingPosition, int pollingDelay) {
066        this.api = api;
067        this.startingPosition = startingPosition;
068        this.listeners = new ArrayList<EventListener>();
069        this.listenerLock = new Object();
070        this.pollingDelay = pollingDelay;
071    }
072
073    /**
074     * Adds a listener that will be notified when an event is received.
075     *
076     * @param listener the listener to add.
077     */
078    public void addListener(EventListener listener) {
079        synchronized (this.listenerLock) {
080            this.listeners.add(listener);
081        }
082    }
083
084    /**
085     * Indicates whether or not this EventStream has been started.
086     *
087     * @return true if this EventStream has been started; otherwise false.
088     */
089    public boolean isStarted() {
090        return this.started;
091    }
092
093    /**
094     * Stops this EventStream and disconnects from the API.
095     *
096     * @throws IllegalStateException if the EventStream is already stopped.
097     */
098    public void stop() {
099        if (!this.started) {
100            throw new IllegalStateException("Cannot stop the EventStream because it isn't started.");
101        }
102
103        this.started = false;
104        this.pollerThread.interrupt();
105    }
106
107    /**
108     * Starts this EventStream and begins long polling the API.
109     *
110     * @throws IllegalStateException if the EventStream is already started.
111     */
112    public void start() {
113        if (this.started) {
114            throw new IllegalStateException("Cannot start the EventStream because it isn't stopped.");
115        }
116
117        final long initialPosition;
118
119        if (this.startingPosition == STREAM_POSITION_NOW) {
120            BoxAPIRequest request = new BoxAPIRequest(this.api,
121                EVENT_URL.buildAlpha(this.api.getBaseURL(), "now"), "GET");
122            BoxJSONResponse response = (BoxJSONResponse) request.send();
123            JsonObject jsonObject = JsonObject.readFrom(response.getJSON());
124            initialPosition = jsonObject.get("next_stream_position").asLong();
125        } else {
126            assert this.startingPosition >= 0 : "Starting position must be non-negative";
127            initialPosition = this.startingPosition;
128        }
129
130        this.poller = new Poller(initialPosition);
131
132        this.pollerThread = new Thread(this.poller);
133        this.pollerThread.setUncaughtExceptionHandler(new Thread.UncaughtExceptionHandler() {
134            public void uncaughtException(Thread t, Throwable e) {
135                EventStream.this.notifyException(e);
136            }
137        });
138        this.pollerThread.start();
139
140        this.started = true;
141    }
142
143    /**
144     * Indicates whether or not an event ID is a duplicate.
145     *
146     * <p>This method can be overridden by a subclass in order to provide custom de-duping logic.</p>
147     *
148     * @param eventID the event ID.
149     * @return true if the event is a duplicate; otherwise false.
150     */
151    protected boolean isDuplicate(String eventID) {
152        if (this.receivedEvents == null) {
153            this.receivedEvents = new LRUCache<String>();
154        }
155
156        return !this.receivedEvents.add(eventID);
157    }
158
159    private void notifyNextPosition(long position) {
160        synchronized (this.listenerLock) {
161            for (EventListener listener : this.listeners) {
162                listener.onNextPosition(position);
163            }
164        }
165    }
166
167    private void notifyEvent(BoxEvent event) {
168        synchronized (this.listenerLock) {
169            boolean isDuplicate = this.isDuplicate(event.getID());
170            if (!isDuplicate) {
171                for (EventListener listener : this.listeners) {
172                    listener.onEvent(event);
173                }
174            }
175        }
176    }
177
178    private void notifyException(Throwable e) {
179        if (e instanceof InterruptedException && !this.started) {
180            return;
181        }
182
183        this.stop();
184        synchronized (this.listenerLock) {
185            for (EventListener listener : this.listeners) {
186                if (listener.onException(e)) {
187                    return;
188                }
189            }
190        }
191    }
192
193    private class Poller implements Runnable {
194        private final long initialPosition;
195
196        private RealtimeServerConnection server;
197
198        Poller(long initialPosition) {
199            this.initialPosition = initialPosition;
200            this.server = new RealtimeServerConnection(EventStream.this.api);
201        }
202
203        @Override
204        public void run() {
205            long position = this.initialPosition;
206            while (!Thread.interrupted()) {
207                if (this.server.getRemainingRetries() == 0) {
208                    this.server = new RealtimeServerConnection(EventStream.this.api);
209                }
210
211                if (this.server.waitForChange(position)) {
212                    if (Thread.interrupted()) {
213                        return;
214                    }
215
216                    BoxAPIRequest request = new BoxAPIRequest(EventStream.this.api,
217                        EVENT_URL.buildAlpha(EventStream.this.api.getBaseURL(), position), "GET");
218                    BoxJSONResponse response = (BoxJSONResponse) request.send();
219                    JsonObject jsonObject = JsonObject.readFrom(response.getJSON());
220                    JsonArray entriesArray = jsonObject.get("entries").asArray();
221                    for (JsonValue entry : entriesArray) {
222                        BoxEvent event = new BoxEvent(EventStream.this.api, entry.asObject());
223                        EventStream.this.notifyEvent(event);
224                    }
225                    position = jsonObject.get("next_stream_position").asLong();
226                    EventStream.this.notifyNextPosition(position);
227                    try {
228                        // Delay re-polling to avoid making too many API calls
229                        // Since duplicate events may appear in the stream, without any delay added
230                        // the stream can make 3-5 requests per second and not produce any new
231                        // events.  A short delay between calls balances latency for new events
232                        // and the risk of hitting rate limits.
233                        Thread.sleep(EventStream.this.pollingDelay);
234                    } catch (InterruptedException ex) {
235                        return;
236                    }
237                }
238            }
239        }
240    }
241}