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 notifyEvent(BoxEvent event) { 135 synchronized (this.listenerLock) { 136 boolean isDuplicate = this.isDuplicate(event.getID()); 137 if (!isDuplicate) { 138 for (EventListener listener : this.listeners) { 139 listener.onEvent(event); 140 } 141 } 142 } 143 } 144 145 private void notifyException(Throwable e) { 146 if (e instanceof InterruptedException && !this.started) { 147 return; 148 } 149 150 this.stop(); 151 synchronized (this.listenerLock) { 152 for (EventListener listener : this.listeners) { 153 if (listener.onException(e)) { 154 return; 155 } 156 } 157 } 158 } 159 160 private class Poller implements Runnable { 161 private final long initialPosition; 162 163 private RealtimeServerConnection server; 164 165 public Poller(long initialPosition) { 166 this.initialPosition = initialPosition; 167 this.server = new RealtimeServerConnection(EventStream.this.api); 168 } 169 170 @Override 171 public void run() { 172 long position = this.initialPosition; 173 while (!Thread.interrupted()) { 174 if (this.server.getRemainingRetries() == 0) { 175 this.server = new RealtimeServerConnection(EventStream.this.api); 176 } 177 178 if (this.server.waitForChange(position)) { 179 if (Thread.interrupted()) { 180 return; 181 } 182 183 BoxAPIRequest request = new BoxAPIRequest(EventStream.this.api, 184 EVENT_URL.build(EventStream.this.api.getBaseURL(), position), "GET"); 185 BoxJSONResponse response = (BoxJSONResponse) request.send(); 186 JsonObject jsonObject = JsonObject.readFrom(response.getJSON()); 187 position = jsonObject.get("next_stream_position").asLong(); 188 JsonArray entriesArray = jsonObject.get("entries").asArray(); 189 for (JsonValue entry : entriesArray) { 190 BoxEvent event = new BoxEvent(EventStream.this.api, entry.asObject()); 191 EventStream.this.notifyEvent(event); 192 } 193 } 194 } 195 } 196 } 197}