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