001package com.box.sdk; 002 003import com.eclipsesource.json.JsonArray; 004import com.eclipsesource.json.JsonObject; 005import com.eclipsesource.json.JsonValue; 006import java.util.ArrayList; 007import java.util.Collection; 008 009/** 010 * Receives real-time events from the API and forwards them to {@link EventListener EventListeners}. 011 * 012 * <p>This class handles long polling the Box events endpoint in order to receive real-time user events. 013 * When an EventStream is started, it begins long polling on a separate thread until the {@link #stop} method 014 * 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 * <p>Note: Enterprise Events can be accessed by admin users with the EventLog.getEnterpriseEvents method</p> 018 */ 019public class EventStream { 020 021 private static final int LIMIT = 800; 022 /** 023 * Events URL. 024 */ 025 public static final URLTemplate EVENT_URL = new URLTemplate("events?limit=" + LIMIT + "&stream_position=%s"); 026 private static final int STREAM_POSITION_NOW = -1; 027 private static final int DEFAULT_POLLING_DELAY = 1000; 028 private final BoxAPIConnection api; 029 private final long startingPosition; 030 private final int pollingDelay; 031 private final Collection<EventListener> listeners; 032 private final Object listenerLock; 033 034 private LRUCache<String> receivedEvents; 035 private boolean started; 036 private Poller poller; 037 private Thread pollerThread; 038 039 /** 040 * Constructs an EventStream using an API connection. 041 * 042 * @param api the API connection to use. 043 */ 044 public EventStream(BoxAPIConnection api) { 045 this(api, STREAM_POSITION_NOW, DEFAULT_POLLING_DELAY); 046 } 047 048 /** 049 * Constructs an EventStream using an API connection and a starting initial position. 050 * 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, startingPosition, DEFAULT_POLLING_DELAY); 056 } 057 058 /** 059 * Constructs an EventStream using an API connection and a starting initial position with custom polling delay. 060 * 061 * @param api the API connection to use. 062 * @param startingPosition the starting position of the event stream. 063 * @param pollingDelay the delay in milliseconds between successive calls to get more events. 064 */ 065 public EventStream(BoxAPIConnection api, long startingPosition, int pollingDelay) { 066 this.api = api; 067 this.startingPosition = startingPosition; 068 this.listeners = new ArrayList<EventListener>(); 069 this.listenerLock = new Object(); 070 this.pollingDelay = pollingDelay; 071 } 072 073 /** 074 * Adds a listener that will be notified when an event is received. 075 * 076 * @param listener the listener to add. 077 */ 078 public void addListener(EventListener listener) { 079 synchronized (this.listenerLock) { 080 this.listeners.add(listener); 081 } 082 } 083 084 /** 085 * Indicates whether or not this EventStream has been started. 086 * 087 * @return true if this EventStream has been started; otherwise false. 088 */ 089 public boolean isStarted() { 090 return this.started; 091 } 092 093 /** 094 * Stops this EventStream and disconnects from the API. 095 * 096 * @throws IllegalStateException if the EventStream is already stopped. 097 */ 098 public void stop() { 099 if (!this.started) { 100 throw new IllegalStateException("Cannot stop the EventStream because it isn't started."); 101 } 102 103 this.started = false; 104 this.pollerThread.interrupt(); 105 } 106 107 /** 108 * Starts this EventStream and begins long polling the API. 109 * 110 * @throws IllegalStateException if the EventStream is already started. 111 */ 112 public void start() { 113 if (this.started) { 114 throw new IllegalStateException("Cannot start the EventStream because it isn't stopped."); 115 } 116 117 final long initialPosition; 118 119 if (this.startingPosition == STREAM_POSITION_NOW) { 120 BoxAPIRequest request = new BoxAPIRequest(this.api, 121 EVENT_URL.buildAlpha(this.api.getBaseURL(), "now"), "GET"); 122 BoxJSONResponse response = (BoxJSONResponse) request.send(); 123 JsonObject jsonObject = JsonObject.readFrom(response.getJSON()); 124 initialPosition = jsonObject.get("next_stream_position").asLong(); 125 } else { 126 assert this.startingPosition >= 0 : "Starting position must be non-negative"; 127 initialPosition = this.startingPosition; 128 } 129 130 this.poller = new Poller(initialPosition); 131 132 this.pollerThread = new Thread(this.poller); 133 this.pollerThread.setUncaughtExceptionHandler(new Thread.UncaughtExceptionHandler() { 134 public void uncaughtException(Thread t, Throwable e) { 135 EventStream.this.notifyException(e); 136 } 137 }); 138 this.pollerThread.start(); 139 140 this.started = true; 141 } 142 143 /** 144 * Indicates whether or not an event ID is a duplicate. 145 * 146 * <p>This method can be overridden by a subclass in order to provide custom de-duping logic.</p> 147 * 148 * @param eventID the event ID. 149 * @return true if the event is a duplicate; otherwise false. 150 */ 151 protected boolean isDuplicate(String eventID) { 152 if (this.receivedEvents == null) { 153 this.receivedEvents = new LRUCache<String>(); 154 } 155 156 return !this.receivedEvents.add(eventID); 157 } 158 159 private void notifyNextPosition(long position) { 160 synchronized (this.listenerLock) { 161 for (EventListener listener : this.listeners) { 162 listener.onNextPosition(position); 163 } 164 } 165 } 166 167 private void notifyEvent(BoxEvent event) { 168 synchronized (this.listenerLock) { 169 boolean isDuplicate = this.isDuplicate(event.getID()); 170 if (!isDuplicate) { 171 for (EventListener listener : this.listeners) { 172 listener.onEvent(event); 173 } 174 } 175 } 176 } 177 178 private void notifyException(Throwable e) { 179 if (e instanceof InterruptedException && !this.started) { 180 return; 181 } 182 183 this.stop(); 184 synchronized (this.listenerLock) { 185 for (EventListener listener : this.listeners) { 186 if (listener.onException(e)) { 187 return; 188 } 189 } 190 } 191 } 192 193 private class Poller implements Runnable { 194 private final long initialPosition; 195 196 private RealtimeServerConnection server; 197 198 Poller(long initialPosition) { 199 this.initialPosition = initialPosition; 200 this.server = new RealtimeServerConnection(EventStream.this.api); 201 } 202 203 @Override 204 public void run() { 205 long position = this.initialPosition; 206 while (!Thread.interrupted()) { 207 if (this.server.getRemainingRetries() == 0) { 208 this.server = new RealtimeServerConnection(EventStream.this.api); 209 } 210 211 if (this.server.waitForChange(position)) { 212 if (Thread.interrupted()) { 213 return; 214 } 215 216 BoxAPIRequest request = new BoxAPIRequest(EventStream.this.api, 217 EVENT_URL.buildAlpha(EventStream.this.api.getBaseURL(), position), "GET"); 218 BoxJSONResponse response = (BoxJSONResponse) request.send(); 219 JsonObject jsonObject = JsonObject.readFrom(response.getJSON()); 220 JsonArray entriesArray = jsonObject.get("entries").asArray(); 221 for (JsonValue entry : entriesArray) { 222 BoxEvent event = new BoxEvent(EventStream.this.api, entry.asObject()); 223 EventStream.this.notifyEvent(event); 224 } 225 position = jsonObject.get("next_stream_position").asLong(); 226 EventStream.this.notifyNextPosition(position); 227 try { 228 // Delay re-polling to avoid making too many API calls 229 // Since duplicate events may appear in the stream, without any delay added 230 // the stream can make 3-5 requests per second and not produce any new 231 // events. A short delay between calls balances latency for new events 232 // and the risk of hitting rate limits. 233 Thread.sleep(EventStream.this.pollingDelay); 234 } catch (InterruptedException ex) { 235 return; 236 } 237 } 238 } 239 } 240 } 241}