001package com.box.sdk;
002
003import java.util.ArrayList;
004import java.util.Collection;
005import java.util.LinkedHashSet;
006
007import com.eclipsesource.json.JsonArray;
008import com.eclipsesource.json.JsonObject;
009import com.eclipsesource.json.JsonValue;
010
011/**
012 * Receives real-time events from the API and forwards them to {@link EventListener EventListeners}.
013 *
014 * <p>This class handles long polling the Box events endpoint in order to receive real-time user or enterprise events.
015 * When an EventStream is started, it begins long polling on a separate thread until the {@link #stop} method 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 *
019 */
020public class EventStream {
021    private static final int LIMIT = 800;
022    private static final int LRU_SIZE = 512;
023    private static final URLTemplate EVENT_URL = new URLTemplate("events?limit=" + LIMIT + "&stream_position=%s");
024
025    private final BoxAPIConnection api;
026    private final Collection<EventListener> listeners;
027    private final Object listenerLock;
028
029    private LinkedHashSet<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 = api;
040        this.listeners = new ArrayList<EventListener>();
041        this.listenerLock = new Object();
042    }
043
044    /**
045     * Adds a listener that will be notified when an event is received.
046     * @param listener the listener to add.
047     */
048    public void addListener(EventListener listener) {
049        synchronized (this.listenerLock) {
050            this.listeners.add(listener);
051        }
052    }
053
054    /**
055     * Indicates whether or not this EventStream has been started.
056     * @return true if this EventStream has been started; otherwise false.
057     */
058    public boolean isStarted() {
059        return this.started;
060    }
061
062    /**
063     * Stops this EventStream and disconnects from the API.
064     * @throws IllegalStateException if the EventStream is already stopped.
065     */
066    public void stop() {
067        if (!this.started) {
068            throw new IllegalStateException("Cannot stop the EventStream because it isn't started.");
069        }
070
071        this.started = false;
072        this.pollerThread.interrupt();
073    }
074
075    /**
076     * Starts this EventStream and begins long polling the API.
077     * @throws IllegalStateException if the EventStream is already started.
078     */
079    public void start() {
080        if (this.started) {
081            throw new IllegalStateException("Cannot start the EventStream because it isn't stopped.");
082        }
083
084        BoxAPIRequest request = new BoxAPIRequest(this.api, EVENT_URL.build(this.api.getBaseURL(), "now"), "GET");
085        BoxJSONResponse response = (BoxJSONResponse) request.send();
086        JsonObject jsonObject = JsonObject.readFrom(response.getJSON());
087        final long initialPosition = jsonObject.get("next_stream_position").asLong();
088        this.poller = new Poller(initialPosition);
089
090        this.pollerThread = new Thread(this.poller);
091        this.pollerThread.setUncaughtExceptionHandler(new Thread.UncaughtExceptionHandler() {
092            public void uncaughtException(Thread t, Throwable e) {
093                EventStream.this.notifyException(e);
094            }
095        });
096        this.pollerThread.start();
097
098        this.started = true;
099    }
100
101    /**
102     * Indicates whether or not an event ID is a duplicate.
103     *
104     * <p>This method can be overridden by a subclass in order to provide custom de-duping logic.</p>
105     *
106     * @param  eventID the event ID.
107     * @return         true if the event is a duplicate; otherwise false.
108     */
109    protected boolean isDuplicate(String eventID) {
110        if (this.receivedEvents == null) {
111            this.receivedEvents = new LinkedHashSet<String>(LRU_SIZE);
112        }
113
114        boolean newEvent = this.receivedEvents.add(eventID);
115        if (newEvent && this.receivedEvents.size() > LRU_SIZE) {
116            this.receivedEvents.iterator().remove();
117        }
118
119        return newEvent;
120    }
121
122    private void notifyEvent(BoxEvent event) {
123        synchronized (this.listenerLock) {
124            boolean isDuplicate = this.isDuplicate(event.getID());
125            if (!isDuplicate) {
126                for (EventListener listener : this.listeners) {
127                    listener.onEvent(event);
128                }
129            }
130        }
131    }
132
133    private void notifyException(Throwable e) {
134        if (e instanceof InterruptedException && !this.started) {
135            return;
136        }
137
138        this.stop();
139        synchronized (this.listenerLock) {
140            for (EventListener listener : this.listeners) {
141                if (listener.onException(e)) {
142                    return;
143                }
144            }
145        }
146    }
147
148    private class Poller implements Runnable {
149        private final long initialPosition;
150
151        private RealtimeServerConnection server;
152
153        public Poller(long initialPosition) {
154            this.initialPosition = initialPosition;
155            this.server = new RealtimeServerConnection(EventStream.this.api);
156        }
157
158        @Override
159        public void run() {
160            long position = this.initialPosition;
161            while (!Thread.interrupted()) {
162                if (this.server.getRemainingRetries() == 0) {
163                    this.server = new RealtimeServerConnection(EventStream.this.api);
164                }
165
166                if (this.server.waitForChange(position)) {
167                    if (Thread.interrupted()) {
168                        return;
169                    }
170
171                    BoxAPIRequest request = new BoxAPIRequest(EventStream.this.api,
172                        EVENT_URL.build(EventStream.this.api.getBaseURL(), position), "GET");
173                    BoxJSONResponse response = (BoxJSONResponse) request.send();
174                    JsonObject jsonObject = JsonObject.readFrom(response.getJSON());
175                    position = jsonObject.get("next_stream_position").asLong();
176                    JsonArray entriesArray = jsonObject.get("entries").asArray();
177                    for (JsonValue entry : entriesArray) {
178                        BoxEvent event = new BoxEvent(EventStream.this.api, entry.asObject());
179                        EventStream.this.notifyEvent(event);
180                    }
181                }
182            }
183        }
184    }
185}