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, EVENT_URL.build(this.api.getBaseURL(), "now"), "GET");
118            BoxJSONResponse response = (BoxJSONResponse) request.send();
119            JsonObject jsonObject = JsonObject.readFrom(response.getJSON());
120            initialPosition = jsonObject.get("next_stream_position").asLong();
121        } else {
122            assert this.startingPosition >= 0 : "Starting position must be non-negative";
123            initialPosition = this.startingPosition;
124        }
125
126        this.poller = new Poller(initialPosition);
127
128        this.pollerThread = new Thread(this.poller);
129        this.pollerThread.setUncaughtExceptionHandler(new Thread.UncaughtExceptionHandler() {
130            public void uncaughtException(Thread t, Throwable e) {
131                EventStream.this.notifyException(e);
132            }
133        });
134        this.pollerThread.start();
135
136        this.started = true;
137    }
138
139    /**
140     * Indicates whether or not an event ID is a duplicate.
141     *
142     * <p>This method can be overridden by a subclass in order to provide custom de-duping logic.</p>
143     *
144     * @param  eventID the event ID.
145     * @return         true if the event is a duplicate; otherwise false.
146     */
147    protected boolean isDuplicate(String eventID) {
148        if (this.receivedEvents == null) {
149            this.receivedEvents = new LRUCache<String>();
150        }
151
152        return !this.receivedEvents.add(eventID);
153    }
154
155    private void notifyNextPosition(long position) {
156        synchronized (this.listenerLock) {
157            for (EventListener listener : this.listeners) {
158                listener.onNextPosition(position);
159            }
160        }
161    }
162
163    private void notifyEvent(BoxEvent event) {
164        synchronized (this.listenerLock) {
165            boolean isDuplicate = this.isDuplicate(event.getID());
166            if (!isDuplicate) {
167                for (EventListener listener : this.listeners) {
168                    listener.onEvent(event);
169                }
170            }
171        }
172    }
173
174    private void notifyException(Throwable e) {
175        if (e instanceof InterruptedException && !this.started) {
176            return;
177        }
178
179        this.stop();
180        synchronized (this.listenerLock) {
181            for (EventListener listener : this.listeners) {
182                if (listener.onException(e)) {
183                    return;
184                }
185            }
186        }
187    }
188
189    private class Poller implements Runnable {
190        private final long initialPosition;
191
192        private RealtimeServerConnection server;
193
194        public Poller(long initialPosition) {
195            this.initialPosition = initialPosition;
196            this.server = new RealtimeServerConnection(EventStream.this.api);
197        }
198
199        @Override
200        public void run() {
201            long position = this.initialPosition;
202            while (!Thread.interrupted()) {
203                if (this.server.getRemainingRetries() == 0) {
204                    this.server = new RealtimeServerConnection(EventStream.this.api);
205                }
206
207                if (this.server.waitForChange(position)) {
208                    if (Thread.interrupted()) {
209                        return;
210                    }
211
212                    BoxAPIRequest request = new BoxAPIRequest(EventStream.this.api,
213                        EVENT_URL.build(EventStream.this.api.getBaseURL(), position), "GET");
214                    BoxJSONResponse response = (BoxJSONResponse) request.send();
215                    JsonObject jsonObject = JsonObject.readFrom(response.getJSON());
216                    JsonArray entriesArray = jsonObject.get("entries").asArray();
217                    for (JsonValue entry : entriesArray) {
218                        BoxEvent event = new BoxEvent(EventStream.this.api, entry.asObject());
219                        EventStream.this.notifyEvent(event);
220                    }
221                    position = jsonObject.get("next_stream_position").asLong();
222                    EventStream.this.notifyNextPosition(position);
223                    try {
224                        // Delay re-polling to avoid making too many API calls
225                        // Since duplicate events may appear in the stream, without any delay added
226                        // the stream can make 3-5 requests per second and not produce any new
227                        // events.  A short delay between calls balances latency for new events
228                        // and the risk of hitting rate limits.
229                        Thread.sleep(EventStream.this.pollingDelay);
230                    } catch (InterruptedException ex) {
231                        return;
232                    }
233                }
234            }
235        }
236    }
237}