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 notifyEvent(BoxEvent event) {
135        synchronized (this.listenerLock) {
136            boolean isDuplicate = this.isDuplicate(event.getID());
137            if (!isDuplicate) {
138                for (EventListener listener : this.listeners) {
139                    listener.onEvent(event);
140                }
141            }
142        }
143    }
144
145    private void notifyException(Throwable e) {
146        if (e instanceof InterruptedException && !this.started) {
147            return;
148        }
149
150        this.stop();
151        synchronized (this.listenerLock) {
152            for (EventListener listener : this.listeners) {
153                if (listener.onException(e)) {
154                    return;
155                }
156            }
157        }
158    }
159
160    private class Poller implements Runnable {
161        private final long initialPosition;
162
163        private RealtimeServerConnection server;
164
165        public Poller(long initialPosition) {
166            this.initialPosition = initialPosition;
167            this.server = new RealtimeServerConnection(EventStream.this.api);
168        }
169
170        @Override
171        public void run() {
172            long position = this.initialPosition;
173            while (!Thread.interrupted()) {
174                if (this.server.getRemainingRetries() == 0) {
175                    this.server = new RealtimeServerConnection(EventStream.this.api);
176                }
177
178                if (this.server.waitForChange(position)) {
179                    if (Thread.interrupted()) {
180                        return;
181                    }
182
183                    BoxAPIRequest request = new BoxAPIRequest(EventStream.this.api,
184                        EVENT_URL.build(EventStream.this.api.getBaseURL(), position), "GET");
185                    BoxJSONResponse response = (BoxJSONResponse) request.send();
186                    JsonObject jsonObject = JsonObject.readFrom(response.getJSON());
187                    position = jsonObject.get("next_stream_position").asLong();
188                    JsonArray entriesArray = jsonObject.get("entries").asArray();
189                    for (JsonValue entry : entriesArray) {
190                        BoxEvent event = new BoxEvent(EventStream.this.api, entry.asObject());
191                        EventStream.this.notifyEvent(event);
192                    }
193                }
194            }
195        }
196    }
197}