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}