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