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}