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