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    private static final int LIMIT = 800;
023    private static final URLTemplate EVENT_URL = new URLTemplate("events?limit=" + LIMIT + "&stream_position=%s");
024    private static final int STREAM_POSITION_NOW = -1;
025
026    private final BoxAPIConnection api;
027    private final long startingPosition;
028    private final Collection<EventListener> listeners;
029    private final Object listenerLock;
030
031    private LRUCache<String> receivedEvents;
032    private boolean started;
033    private Poller poller;
034    private Thread pollerThread;
035
036    /**
037     * Constructs an EventStream using an API connection.
038     * @param  api the API connection to use.
039     */
040    public EventStream(BoxAPIConnection api) {
041        this(api, STREAM_POSITION_NOW);
042    }
043
044    /**
045     * Constructs an EventStream using an API connection and a starting initial position.
046     * @param api the API connection to use.
047     * @param startingPosition the starting position of the event stream.
048     */
049    public EventStream(BoxAPIConnection api, long startingPosition) {
050        this.api = api;
051        this.startingPosition = startingPosition;
052        this.listeners = new ArrayList<EventListener>();
053        this.listenerLock = new Object();
054    }
055
056    /**
057     * Adds a listener that will be notified when an event is received.
058     * @param listener the listener to add.
059     */
060    public void addListener(EventListener listener) {
061        synchronized (this.listenerLock) {
062            this.listeners.add(listener);
063        }
064    }
065
066    /**
067     * Indicates whether or not this EventStream has been started.
068     * @return true if this EventStream has been started; otherwise false.
069     */
070    public boolean isStarted() {
071        return this.started;
072    }
073
074    /**
075     * Stops this EventStream and disconnects from the API.
076     * @throws IllegalStateException if the EventStream is already stopped.
077     */
078    public void stop() {
079        if (!this.started) {
080            throw new IllegalStateException("Cannot stop the EventStream because it isn't started.");
081        }
082
083        this.started = false;
084        this.pollerThread.interrupt();
085    }
086
087    /**
088     * Starts this EventStream and begins long polling the API.
089     * @throws IllegalStateException if the EventStream is already started.
090     */
091    public void start() {
092        if (this.started) {
093            throw new IllegalStateException("Cannot start the EventStream because it isn't stopped.");
094        }
095
096        final long initialPosition;
097
098        if (this.startingPosition == STREAM_POSITION_NOW) {
099            BoxAPIRequest request = new BoxAPIRequest(this.api, EVENT_URL.build(this.api.getBaseURL(), "now"), "GET");
100            BoxJSONResponse response = (BoxJSONResponse) request.send();
101            JsonObject jsonObject = JsonObject.readFrom(response.getJSON());
102            initialPosition = jsonObject.get("next_stream_position").asLong();
103        } else {
104            initialPosition = this.startingPosition;
105        }
106
107        this.poller = new Poller(initialPosition);
108
109        this.pollerThread = new Thread(this.poller);
110        this.pollerThread.setUncaughtExceptionHandler(new Thread.UncaughtExceptionHandler() {
111            public void uncaughtException(Thread t, Throwable e) {
112                EventStream.this.notifyException(e);
113            }
114        });
115        this.pollerThread.start();
116
117        this.started = true;
118    }
119
120    /**
121     * Indicates whether or not an event ID is a duplicate.
122     *
123     * <p>This method can be overridden by a subclass in order to provide custom de-duping logic.</p>
124     *
125     * @param  eventID the event ID.
126     * @return         true if the event is a duplicate; otherwise false.
127     */
128    protected boolean isDuplicate(String eventID) {
129        if (this.receivedEvents == null) {
130            this.receivedEvents = new LRUCache<String>();
131        }
132
133        return !this.receivedEvents.add(eventID);
134    }
135
136    private void notifyNextPosition(long position) {
137        synchronized (this.listenerLock) {
138            for (EventListener listener : this.listeners) {
139                listener.onNextPosition(position);
140            }
141        }
142    }
143
144    private void notifyEvent(BoxEvent event) {
145        synchronized (this.listenerLock) {
146            boolean isDuplicate = this.isDuplicate(event.getID());
147            if (!isDuplicate) {
148                for (EventListener listener : this.listeners) {
149                    listener.onEvent(event);
150                }
151            }
152        }
153    }
154
155    private void notifyException(Throwable e) {
156        if (e instanceof InterruptedException && !this.started) {
157            return;
158        }
159
160        this.stop();
161        synchronized (this.listenerLock) {
162            for (EventListener listener : this.listeners) {
163                if (listener.onException(e)) {
164                    return;
165                }
166            }
167        }
168    }
169
170    private class Poller implements Runnable {
171        private final long initialPosition;
172
173        private RealtimeServerConnection server;
174
175        public Poller(long initialPosition) {
176            this.initialPosition = initialPosition;
177            this.server = new RealtimeServerConnection(EventStream.this.api);
178        }
179
180        @Override
181        public void run() {
182            long position = this.initialPosition;
183            while (!Thread.interrupted()) {
184                if (this.server.getRemainingRetries() == 0) {
185                    this.server = new RealtimeServerConnection(EventStream.this.api);
186                }
187
188                if (this.server.waitForChange(position)) {
189                    if (Thread.interrupted()) {
190                        return;
191                    }
192
193                    BoxAPIRequest request = new BoxAPIRequest(EventStream.this.api,
194                        EVENT_URL.build(EventStream.this.api.getBaseURL(), position), "GET");
195                    BoxJSONResponse response = (BoxJSONResponse) request.send();
196                    JsonObject jsonObject = JsonObject.readFrom(response.getJSON());
197                    JsonArray entriesArray = jsonObject.get("entries").asArray();
198                    for (JsonValue entry : entriesArray) {
199                        BoxEvent event = new BoxEvent(EventStream.this.api, entry.asObject());
200                        EventStream.this.notifyEvent(event);
201                    }
202                    position = jsonObject.get("next_stream_position").asLong();
203                    EventStream.this.notifyNextPosition(position);
204                }
205            }
206        }
207    }
208}