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