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