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