001package com.box.sdk; 002 003import java.io.IOException; 004import java.io.InputStream; 005import java.net.URL; 006import java.security.DigestInputStream; 007import java.security.MessageDigest; 008import java.security.NoSuchAlgorithmException; 009import java.util.ArrayList; 010import java.util.List; 011import java.util.Map; 012import java.util.concurrent.Executors; 013import java.util.concurrent.ThreadPoolExecutor; 014import java.util.concurrent.TimeUnit; 015 016import com.box.sdk.http.HttpMethod; 017import com.eclipsesource.json.JsonObject; 018 019/** 020 * Utility class for uploading large files. 021 */ 022public final class LargeFileUpload { 023 024 private static final String DIGEST_HEADER_PREFIX_SHA = "sha="; 025 private static final String DIGEST_ALGORITHM_SHA1 = "SHA1"; 026 027 private static final String OFFSET_QUERY_STRING = "offset"; 028 private static final String LIMIT_QUERY_STRING = "limit"; 029 private static final int DEFAULT_CONNECTIONS = 3; 030 private static final int DEFAULT_TIMEOUT = 1; 031 private static final TimeUnit DEFAULT_TIMEUNIT = TimeUnit.HOURS; 032 private static final int THREAD_POOL_WAIT_TIME_IN_MILLIS = 1000; 033 private ThreadPoolExecutor executorService; 034 private long timeout; 035 private TimeUnit timeUnit; 036 private int connections; 037 038 /** 039 * Creates a LargeFileUpload object. 040 * @param nParallelConnections number of parallel http connections to use 041 * @param timeOut time to wait before killing the job 042 * @param unit time unit for the time wait value 043 */ 044 public LargeFileUpload(int nParallelConnections, long timeOut, TimeUnit unit) { 045 this.executorService = (ThreadPoolExecutor) Executors.newFixedThreadPool(nParallelConnections); 046 this.timeout = timeOut; 047 this.timeUnit = unit; 048 } 049 050 /** 051 * Creates a LargeFileUpload object with a default number of parallel conections and timeout. 052 */ 053 public LargeFileUpload() { 054 this.executorService = (ThreadPoolExecutor) Executors.newFixedThreadPool(LargeFileUpload.DEFAULT_CONNECTIONS); 055 this.timeout = LargeFileUpload.DEFAULT_TIMEOUT; 056 this.timeUnit = LargeFileUpload.DEFAULT_TIMEUNIT; 057 } 058 059 private BoxFileUploadSession.Info createUploadSession(BoxAPIConnection boxApi, String folderId, 060 URL url, String fileName, long fileSize) { 061 062 BoxJSONRequest request = new BoxJSONRequest(boxApi, url, HttpMethod.POST); 063 064 //Create the JSON body of the request 065 JsonObject body = new JsonObject(); 066 body.add("folder_id", folderId); 067 body.add("file_name", fileName); 068 body.add("file_size", fileSize); 069 request.setBody(body.toString()); 070 071 BoxJSONResponse response = (BoxJSONResponse) request.send(); 072 JsonObject jsonObject = JsonObject.readFrom(response.getJSON()); 073 074 String sessionId = jsonObject.get("id").asString(); 075 BoxFileUploadSession session = new BoxFileUploadSession(boxApi, sessionId); 076 077 return session.new Info(jsonObject); 078 } 079 080 /** 081 * Uploads a new large file. 082 * @param boxApi the API connection to be used by the upload session. 083 * @param folderId the id of the folder in which the file will be uploaded. 084 * @param stream the input stream that feeds the content of the file. 085 * @param url the upload session URL. 086 * @param fileName the name of the file to be created. 087 * @param fileSize the total size of the file. 088 * @return the created file instance. 089 * @throws InterruptedException when a thread gets interupted. 090 * @throws IOException when reading a stream throws exception. 091 */ 092 public BoxFile.Info upload(BoxAPIConnection boxApi, String folderId, InputStream stream, URL url, 093 String fileName, long fileSize) throws InterruptedException, IOException { 094 //Create a upload session 095 BoxFileUploadSession.Info session = this.createUploadSession(boxApi, folderId, url, fileName, fileSize); 096 return this.uploadHelper(session, stream, fileSize, null); 097 } 098 099 /** 100 * Uploads a new large file and sets file attributes. 101 * @param boxApi the API connection to be used by the upload session. 102 * @param folderId the id of the folder in which the file will be uploaded. 103 * @param stream the input stream that feeds the content of the file. 104 * @param url the upload session URL. 105 * @param fileName the name of the file to be created. 106 * @param fileSize the total size of the file. 107 * @param fileAttributes file attributes to set 108 * @return the created file instance. 109 * @throws InterruptedException when a thread gets interupted. 110 * @throws IOException when reading a stream throws exception. 111 */ 112 public BoxFile.Info upload(BoxAPIConnection boxApi, String folderId, InputStream stream, URL url, 113 String fileName, long fileSize, Map<String, String> fileAttributes) 114 throws InterruptedException, IOException { 115 //Create a upload session 116 BoxFileUploadSession.Info session = this.createUploadSession(boxApi, folderId, url, fileName, fileSize); 117 return this.uploadHelper(session, stream, fileSize, fileAttributes); 118 } 119 120 /** 121 * Creates a new version of a large file. 122 * @param boxApi the API connection to be used by the upload session. 123 * @param stream the input stream that feeds the content of the file. 124 * @param url the upload session URL. 125 * @param fileSize the total size of the file. 126 * @return the file instance that also contains the version information. 127 * @throws InterruptedException when a thread gets interupted. 128 * @throws IOException when reading a stream throws exception. 129 */ 130 public BoxFile.Info upload(BoxAPIConnection boxApi, InputStream stream, URL url, long fileSize) 131 throws InterruptedException, IOException { 132 //creates a upload session 133 BoxFileUploadSession.Info session = this.createUploadSession(boxApi, url, fileSize); 134 return this.uploadHelper(session, stream, fileSize, null); 135 } 136 137 /** 138 * Creates a new version of a large file and sets file attributes. 139 * @param boxApi the API connection to be used by the upload session. 140 * @param stream the input stream that feeds the content of the file. 141 * @param url the upload session URL. 142 * @param fileSize the total size of the file. 143 * @param fileAttributes file attributes to set. 144 * @return the file instance that also contains the version information. 145 * @throws InterruptedException when a thread gets interupted. 146 * @throws IOException when reading a stream throws exception. 147 */ 148 public BoxFile.Info upload(BoxAPIConnection boxApi, InputStream stream, URL url, long fileSize, 149 Map<String, String> fileAttributes) 150 throws InterruptedException, IOException { 151 //creates a upload session 152 BoxFileUploadSession.Info session = this.createUploadSession(boxApi, url, fileSize); 153 return this.uploadHelper(session, stream, fileSize, fileAttributes); 154 } 155 156 private BoxFile.Info uploadHelper(BoxFileUploadSession.Info session, InputStream stream, long fileSize, 157 Map<String, String> fileAttributes) 158 throws InterruptedException, IOException { 159 //Upload parts using the upload session 160 MessageDigest digest = null; 161 try { 162 digest = MessageDigest.getInstance(DIGEST_ALGORITHM_SHA1); 163 } catch (NoSuchAlgorithmException ae) { 164 throw new BoxAPIException("Digest algorithm not found", ae); 165 } 166 DigestInputStream dis = new DigestInputStream(stream, digest); 167 List<BoxFileUploadSessionPart> parts = this.uploadParts(session, dis, fileSize); 168 169 //Creates the file hash 170 byte[] digestBytes = digest.digest(); 171 String digestStr = Base64.encode(digestBytes); 172 173 //Commit the upload session. If there is a failure, abort the commit. 174 try { 175 return session.getResource().commit(digestStr, parts, fileAttributes, null, null); 176 } catch (Exception e) { 177 session.getResource().abort(); 178 throw new BoxAPIException("Unable to commit the upload session", e); 179 } 180 } 181 182 private BoxFileUploadSession.Info createUploadSession(BoxAPIConnection boxApi, URL url, long fileSize) { 183 BoxJSONRequest request = new BoxJSONRequest(boxApi, url, HttpMethod.POST); 184 185 //Creates the body of the request 186 JsonObject body = new JsonObject(); 187 body.add("file_size", fileSize); 188 request.setBody(body.toString()); 189 190 BoxJSONResponse response = (BoxJSONResponse) request.send(); 191 JsonObject jsonObject = JsonObject.readFrom(response.getJSON()); 192 193 String sessionId = jsonObject.get("id").asString(); 194 BoxFileUploadSession session = new BoxFileUploadSession(boxApi, sessionId); 195 196 return session.new Info(jsonObject); 197 } 198 199 /* 200 * Upload parts of the file. The part size is retrieved from the upload session. 201 */ 202 private List<BoxFileUploadSessionPart> uploadParts(BoxFileUploadSession.Info session, InputStream stream, 203 long fileSize) throws InterruptedException { 204 List<BoxFileUploadSessionPart> parts = new ArrayList<BoxFileUploadSessionPart>(); 205 206 int partSize = session.getPartSize(); 207 long offset = 0; 208 long processed = 0; 209 int partPostion = 0; 210 //Set the Max Queue Size to 1.5x the number of processors 211 double maxQueueSizeDouble = Math.ceil(this.executorService.getMaximumPoolSize() * 1.5); 212 int maxQueueSize = Double.valueOf(maxQueueSizeDouble).intValue(); 213 while (processed < fileSize) { 214 //Waiting for any thread to finish before 215 long timeoutForWaitingInMillis = TimeUnit.MILLISECONDS.convert(this.timeout, this.timeUnit); 216 if (this.executorService.getCorePoolSize() <= this.executorService.getActiveCount()) { 217 if (timeoutForWaitingInMillis > 0) { 218 Thread.sleep(LargeFileUpload.THREAD_POOL_WAIT_TIME_IN_MILLIS); 219 timeoutForWaitingInMillis -= THREAD_POOL_WAIT_TIME_IN_MILLIS; 220 } else { 221 throw new BoxAPIException("Upload parts timedout"); 222 } 223 } 224 if (this.executorService.getQueue().size() < maxQueueSize) { 225 long diff = fileSize - (long) processed; 226 //The size last part of the file can be lesser than the part size. 227 if (diff < (long) partSize) { 228 partSize = (int) diff; 229 } 230 parts.add(null); 231 byte[] bytes = getBytesFromStream(stream, partSize); 232 this.executorService.execute( 233 new LargeFileUploadTask(session.getResource(), bytes, offset, 234 partSize, fileSize, parts, partPostion) 235 ); 236 237 //Increase the offset and proceesed bytes to calculate the Content-Range header. 238 processed += partSize; 239 offset += partSize; 240 partPostion++; 241 } 242 } 243 this.executorService.shutdown(); 244 this.executorService.awaitTermination(this.timeout, this.timeUnit); 245 return parts; 246 } 247 248 private static byte[] getBytesFromStream(InputStream stream, int numBytes) { 249 250 int bytesNeeded = numBytes; 251 int offset = 0; 252 byte[] bytes = new byte[numBytes]; 253 254 while (bytesNeeded > 0) { 255 256 int bytesRead = -1; 257 try { 258 bytesRead = stream.read(bytes, offset, bytesNeeded); 259 } catch (IOException ioe) { 260 throw new BoxAPIException("Reading data from stream failed.", ioe); 261 } 262 263 if (bytesRead == -1) { 264 throw new BoxAPIException("Stream ended while upload was progressing"); 265 } 266 267 bytesNeeded = bytesNeeded - bytesRead; 268 offset = offset + bytesRead; 269 } 270 271 return bytes; 272 } 273 274 /** 275 * Generates the Base64 encoded SHA-1 hash for content available in the stream. 276 * It can be used to calculate the hash of a file. 277 * @param stream the input stream of the file or data. 278 * @return the Base64 encoded hash string. 279 */ 280 public String generateDigest(InputStream stream) { 281 MessageDigest digest = null; 282 try { 283 digest = MessageDigest.getInstance(DIGEST_ALGORITHM_SHA1); 284 } catch (NoSuchAlgorithmException ae) { 285 throw new BoxAPIException("Digest algorithm not found", ae); 286 } 287 288 //Calcuate the digest using the stream. 289 DigestInputStream dis = new DigestInputStream(stream, digest); 290 try { 291 int value = dis.read(); 292 while (value != -1) { 293 value = dis.read(); 294 } 295 } catch (IOException ioe) { 296 throw new BoxAPIException("Reading the stream failed.", ioe); 297 } 298 299 //Get the calculated digest for the stream 300 byte[] digestBytes = digest.digest(); 301 return Base64.encode(digestBytes); 302 } 303}