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