diff --git a/bindings/java/ivy.xml b/bindings/java/ivy.xml
index af9f85d09f8d67a0bc96cab2f3647f211098b736..e70fc02a89891d2e3a9aebfc76ae2b5c4b6f5106 100644
--- a/bindings/java/ivy.xml
+++ b/bindings/java/ivy.xml
@@ -20,6 +20,9 @@
 		</dependency>
 		<dependency org="com.mchange" name="c3p0" rev="0.9.5" />
 		<dependency org="com.zaxxer" name="SparseBitSet" rev="1.1" />
+		
+		<dependency org="org.apache.httpcomponents" name="httpmime" rev="4.5.13"/>
+		<dependency org="org.apache.httpcomponents" name="httpclient" rev="4.5.13"/>
 	</dependencies>
 </ivy-module>
 
diff --git a/bindings/java/nbproject/project.xml b/bindings/java/nbproject/project.xml
index 58ab53b9b262c0a5a2414f1bbbefc6a354627323..24a86c1896e50ffb3e19dbd0ed510bc9ee6dad89 100755
--- a/bindings/java/nbproject/project.xml
+++ b/bindings/java/nbproject/project.xml
@@ -114,7 +114,7 @@
         <java-data xmlns="http://www.netbeans.org/ns/freeform-project-java/4">
             <compilation-unit>
                 <package-root>src</package-root>
-                <classpath mode="compile">lib;lib/diffutils-1.2.1.jar;lib/junit-4.8.2.jar;lib/postgresql-9.4-1201.jdbc41.jar;lib/c3p0-0.9.5.jar;lib/mchange-commons-java-0.2.9.jar;lib/c3p0-0.9.5-sources.jar;lib/c3p0-0.9.5-javadoc.jar;lib/joda-time-2.4.jar;lib/commons-lang3-3.0.jar;lib/guava-19.0.jar;lib/SparseBitSet-1.1.jar;lib/gson-2.8.5.jar;lib/commons-validator-1.6.jar</classpath>
+                <classpath mode="compile">lib;lib/diffutils-1.2.1.jar;lib/junit-4.8.2.jar;lib/postgresql-9.4-1201.jdbc41.jar;lib/c3p0-0.9.5.jar;lib/mchange-commons-java-0.2.9.jar;lib/c3p0-0.9.5-sources.jar;lib/c3p0-0.9.5-javadoc.jar;lib/joda-time-2.4.jar;lib/commons-lang3-3.0.jar;lib/guava-19.0.jar;lib/SparseBitSet-1.1.jar;lib/gson-2.8.5.jar;lib/commons-validator-1.6.jar;lib/httpclient-4.5.13.jar;lib/httpcore-4.4.13.jar;lib/commons-logging-1.2.jar;lib/commons-codec-1.11.jar;lib/httpmime-4.5.13.jar</classpath>
                 <built-to>build</built-to>
                 <source-level>1.8</source-level>
             </compilation-unit>
diff --git a/bindings/java/src/org/sleuthkit/datamodel/AbstractFile.java b/bindings/java/src/org/sleuthkit/datamodel/AbstractFile.java
index 82776adccab51e5c539d65e76af0a2a9954654d4..9696a7c308c241880e3c349ea7a11a89020c2f7e 100644
--- a/bindings/java/src/org/sleuthkit/datamodel/AbstractFile.java
+++ b/bindings/java/src/org/sleuthkit/datamodel/AbstractFile.java
@@ -18,13 +18,20 @@
  */
 package org.sleuthkit.datamodel;
 
+import org.sleuthkit.datamodel.filerepository.FileRepository;
 import java.io.FileNotFoundException;
 import java.io.IOException;
+import java.io.InputStream;
 import java.io.RandomAccessFile;
+import java.nio.file.FileAlreadyExistsException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
 import java.sql.SQLException;
 import java.sql.Statement;
 import java.text.MessageFormat;
 import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.List;
 import java.util.ResourceBundle;
 import java.util.Set;
@@ -37,6 +44,7 @@
 import org.sleuthkit.datamodel.TskData.TSK_FS_META_TYPE_ENUM;
 import org.sleuthkit.datamodel.TskData.TSK_FS_NAME_FLAG_ENUM;
 import org.sleuthkit.datamodel.TskData.TSK_FS_NAME_TYPE_ENUM;
+import org.sleuthkit.datamodel.filerepository.FileRepositoryException;
 
 /**
  * An abstract base class for classes that represent files that have been added
@@ -1074,12 +1082,17 @@ private synchronized void loadLocalFile() throws TskCoreException {
 			}
 			
 			// Copy the file from the server
-			try {
-				localFile = FileRepository.downloadFromFileRepository(this);
-			} catch (TskCoreException ex) {
+			Path localFilePath = Paths.get(System.getProperty("java.io.tmpdir"), this.getSha256Hash());
+			try (InputStream fileRepositoryStream = FileRepository.download(this)) {
+				Files.copy(fileRepositoryStream, localFilePath);
+				localFile = localFilePath.toFile();
+			} catch (FileAlreadyExistsException ex) {
+				// Do nothing, file already exists.
+				this.localFile = localFilePath.toFile();
+			} catch (FileRepositoryException | IOException ex) {
 				// If we've failed to download from the file repository, don't try again for this session.
 				errorLoadingFromFileRepo = true;
-				throw ex;
+				throw new TskCoreException("Failed trying to download file from repository", ex);
 			}
 		}
 	}
diff --git a/bindings/java/src/org/sleuthkit/datamodel/Bundle.properties b/bindings/java/src/org/sleuthkit/datamodel/Bundle.properties
index 74eeb7f4a9e9cc75adb712f49373d558e8eeb9e3..d36df3e0b55b4a5172a5e72abf5f633f29eda561 100644
--- a/bindings/java/src/org/sleuthkit/datamodel/Bundle.properties
+++ b/bindings/java/src/org/sleuthkit/datamodel/Bundle.properties
@@ -354,6 +354,3 @@ tagsFilter.displayName.text=Must be tagged
 TextFilter.displayName.text=Must include text:
 TypeFilter.displayName.text=Limit event types to
 FileTypesFilter.displayName.text=Limit file types to
-FileRepository.downloadError.title.text=Error downloading from file repository
-FileRepository.downloadError.msg.text=Failed to download file with object ID {0} and SHA-256 hash {1}
-FileRepository.notEnabled.msg.text=File repository is not enabled
diff --git a/bindings/java/src/org/sleuthkit/datamodel/Bundle.properties-MERGED b/bindings/java/src/org/sleuthkit/datamodel/Bundle.properties-MERGED
index 74eeb7f4a9e9cc75adb712f49373d558e8eeb9e3..d36df3e0b55b4a5172a5e72abf5f633f29eda561 100644
--- a/bindings/java/src/org/sleuthkit/datamodel/Bundle.properties-MERGED
+++ b/bindings/java/src/org/sleuthkit/datamodel/Bundle.properties-MERGED
@@ -354,6 +354,3 @@ tagsFilter.displayName.text=Must be tagged
 TextFilter.displayName.text=Must include text:
 TypeFilter.displayName.text=Limit event types to
 FileTypesFilter.displayName.text=Limit file types to
-FileRepository.downloadError.title.text=Error downloading from file repository
-FileRepository.downloadError.msg.text=Failed to download file with object ID {0} and SHA-256 hash {1}
-FileRepository.notEnabled.msg.text=File repository is not enabled
diff --git a/bindings/java/src/org/sleuthkit/datamodel/FileRepository.java b/bindings/java/src/org/sleuthkit/datamodel/FileRepository.java
deleted file mode 100644
index 6c4b456b4acc1f195adbeda028df9c0efa0a5b7a..0000000000000000000000000000000000000000
--- a/bindings/java/src/org/sleuthkit/datamodel/FileRepository.java
+++ /dev/null
@@ -1,315 +0,0 @@
-/*
- * SleuthKit Java Bindings
- *
- * Copyright 2020 Basis Technology Corp.
- * Contact: carrier <at> sleuthkit <dot> org
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *     http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-package org.sleuthkit.datamodel;
-
-import java.io.File;
-import java.io.IOException;
-import java.nio.file.Paths;
-import java.text.MessageFormat;
-import java.util.ArrayList;
-import java.util.List;
-import java.util.ResourceBundle;
-import java.util.logging.Level;
-import java.util.logging.Logger;
-
-/**
- * Class to represent a file repository.
- */
-public class FileRepository {
-	private static final ResourceBundle BUNDLE = ResourceBundle.getBundle("org.sleuthkit.datamodel.Bundle");
-	private static final Logger logger = Logger.getLogger(FileRepository.class.getName());
-	private static FileRepositoryErrorHandler errorHandler;
-	
-	private final static String FILE_PATH = "v1/files/";
-	private final FileRepositorySettings settings;
-	private final File fileDownloadFolder;
-	
-	private static FileRepository instance;
-
-	/**
-     * Create the file repository.
-     *
-     * @param settings          The file repository settings
-     * @param fileDownloadPath  The temporary folder to download files to from the repository
-     */
-	private FileRepository(FileRepositorySettings settings, File fileDownloadPath) {
-		this.settings = settings;
-		this.fileDownloadFolder = fileDownloadPath;
-	}
-	
-	/**
-     * Initializes the file repository.
-     *
-     * @param settings          The file repository settings
-     * @param fileDownloadPath  The temporary folder to download files to from the repository
-     */	
-	public static synchronized void initialize(FileRepositorySettings settings, File fileDownloadPath) {
-		// If the download path is changing, delete any files in the old one
-		if ((instance != null) && (instance.fileDownloadFolder != null)
-				&& ( ! instance.fileDownloadFolder.equals(fileDownloadPath))) {
-			deleteDownloadFolder(instance.fileDownloadFolder);
-		}
-		instance = new FileRepository(settings, fileDownloadPath);
-	}
-	
-	/**
-     * De-initializes the file repository.
-     */	
-	public static synchronized void deinitialize() {
-		if (instance != null) {
-			// Delete the temp folder
-			deleteDownloadFolder(instance.fileDownloadFolder);
-		}
-		
-		instance = null;
-	}
-	
-	/**
-	 * Check if the file repository is enabled.
-	 * 
-	 * @return true if enabled, false otherwise.
-	 */
-	public static boolean isEnabled() {
-		return instance != null;
-	}
-	
-	/**
-	 * Set the error handling callback.
-	 * 
-	 * @param handler The error handler.
-	 */
-	public static synchronized void setErrorHandler(FileRepositoryErrorHandler handler) {
-		errorHandler = handler;
-	}
-	
-	/**
-	 * Report an error to the user.
-	 * The idea is to use this for cases where it's a user error that may be able
-	 * to be corrected through changing the repository settings.
-	 * 
-	 * @param errorTitle The title for the error display.
-	 * @param errorStr   The error message.
-	 */
-	private static synchronized void reportError(String errorTitle, String errorStr) {
-		if (errorHandler != null) {
-			errorHandler.displayErrorToUser(errorTitle, errorStr);
-		}
-	}
-	
-	/**
-	 * Delete the folder of downloaded files.
-	 */
-	private static synchronized void deleteDownloadFolder(File dirPath) {
-        if (dirPath.isDirectory() == false || dirPath.exists() == false) {
-            return;
-        }
-
-        File[] files = dirPath.listFiles();
-        if (files != null) {
-            for (File file : files) {
-                if (file.isDirectory()) {
-                    deleteDownloadFolder(file);
-                } else {
-                    if (file.delete() == false) {
-                        logger.log(Level.WARNING, "Failed to delete file {0}", file.getPath()); //NON-NLS
-                    }
-                }
-            }
-        }
-        if (dirPath.delete() == false) {
-            logger.log(Level.WARNING, "Failed to delete the empty directory at {0}", dirPath.getPath()); //NON-NLS
-        }
-	}
-	
-	/**
-     * Download a file from the file repository.
-	 * The caller must ensure that this is not called on the same file multiple times concurrently. 
-     *
-     * @param abstractFile The file being downloaded. 
-	 * 
-	 * @return The downloaded file.
-	 * 
-	 * @throws TskCoreException
-     */
-	public static synchronized File downloadFromFileRepository(AbstractFile abstractFile) throws TskCoreException {
-
-		if (instance == null) {
-			String title = BUNDLE.getString("FileRepository.downloadError.title.text");
-			String msg = BUNDLE.getString("FileRepository.notEnabled.msg.text");
-			reportError(title, msg);
-			throw new TskCoreException("File repository is not enabled");
-		}
-		
-		if (! abstractFile.getFileLocation().equals(TskData.FileLocation.REPOSITORY)) {
-			throw new TskCoreException("File with object ID " + abstractFile.getId() + " is not stored in the file repository");
-		}
-		
-		if (abstractFile.getSha256Hash() == null || abstractFile.getSha256Hash().isEmpty()) {
-			throw new TskCoreException("File with object ID " + abstractFile.getId() + " has no SHA-256 hash and can not be downloaded");
-		}
-		
-		// Download the file if it's not already there.
-		String targetPath = Paths.get(instance.fileDownloadFolder.getAbsolutePath(), abstractFile.getSha256Hash()).toString();
-		if ( ! new File(targetPath).exists()) {
-			instance.downloadFile(abstractFile, targetPath);
-		}
-		
-		// Check that we got the file.
-		File tempFile = new File(targetPath);
-		if (tempFile.exists()) {
-			return tempFile;
-		} else {
-			String title = BUNDLE.getString("FileRepository.downloadError.title.text");
-			String msg = MessageFormat.format(BUNDLE.getString("FileRepository.downloadError.msg.text"), abstractFile.getId(), abstractFile.getSha256Hash());
-			reportError(title, msg);
-			throw new TskCoreException("Failed to download file with object ID " + abstractFile.getId() 
-					+ " and SHA-256 hash " + abstractFile.getSha256Hash() + " from file repository");
-		}
-	}
-	
-	/**
-     * Download the file.
-     *
-     * @param abstractFile The file being downloaded.
-     * @param targetPath   The location to save the file to.
-	 * 
-	 * @throws TskCoreException
-     */
-	private void downloadFile(AbstractFile abstractFile, String targetPath) throws TskCoreException {		
-		
-		String url = "http://" + settings.getAddress() + ":" + settings.getPort() + "/" + FILE_PATH + abstractFile.getSha256Hash();
-		
-		List<String> command = new ArrayList<>();
-		command.add("curl");
-		command.add("-X");
-		command.add("GET");
-		command.add(url);
-		command.add("-H");
-		command.add("accept: */*");
-		command.add("--output");
-		command.add(targetPath);
-		
-		ProcessBuilder processBuilder = new ProcessBuilder(command).inheritIO();
-		try {
-			Process process = processBuilder.start();
-			process.waitFor();
-		} catch (IOException ex) {
-			String title = BUNDLE.getString("FileRepository.downloadError.title.text");
-			String msg = MessageFormat.format(BUNDLE.getString("FileRepository.downloadError.msg.text"), abstractFile.getId(), abstractFile.getSha256Hash());
-			reportError(title, msg);
-			throw new TskCoreException("Error downloading file with SHA-256 hash " + abstractFile.getSha256Hash() + " from file repository", ex);
-		} catch (InterruptedException ex) {
-			throw new TskCoreException("Interrupted while downloading file with SHA-256 hash " + abstractFile.getSha256Hash() + " from file repository", ex);
-		}
-	}
-	
-	/**
-     * Upload a given file to the file repository.
-     *
-     * @param filePath The path on disk to the file being uploaded.
-	 * 
-	 * @throws TskCoreException
-     */
-	public static synchronized void uploadToFileRepository(String filePath) throws TskCoreException {
-	
-		if (instance == null) {
-			throw new TskCoreException("File repository is not enabled");
-		}
-		
-		File file = new File(filePath);
-		if (! file.exists()) {
-			throw new TskCoreException("Error uploading file " + filePath + " to file repository - file does not exist");
-		}
-		
-		// Upload the file.
-		instance.uploadFile(file);
-	}
-	
-	/**
-     * Upload the file.
-     *
-     * @param file The file being uploaded.
-	 * 
-	 * @throws TskCoreException
-     */	
-	private void uploadFile(File file) throws TskCoreException {
-		String url = "http://" + settings.getAddress() + ":" + settings.getPort() + "/" + FILE_PATH;
-		
-		// Example: curl -X POST "http://localhost:8080/api/files" -H "accept: application/json" -H "Content-Type: multipart/form-data" -F "file=@Report.xml"
-		List<String> command = new ArrayList<>();
-		command.add("curl");
-		command.add("-X");
-		command.add("POST");
-		command.add(url);
-		command.add("-H");
-		command.add("accept: application/json");
-		command.add("-H");
-		command.add("Content-Type: multipart/form-data");
-		command.add("-F");
-		command.add("file=@" + file.getAbsolutePath());
-		
-		ProcessBuilder processBuilder = new ProcessBuilder(command).inheritIO();
-		try {
-			Process process = processBuilder.start();
-			process.waitFor();
-		} catch (IOException | InterruptedException ex) {
-			throw new TskCoreException("Error saving file at " + file.getAbsolutePath() + " to file repository", ex);
-		}	
-	}	
-		
-	/**
-	 * Utility class to hold the file repository server settings.
-	 */
-	static public class FileRepositorySettings {
-		private final String address;
-		private final String port;
-		
-		/**
-		 * Create a FileRepositorySettings instance for the server.
-		 * 
-		 * @param address The IP address/hostname of the server.
-		 * @param port    The port.
-		 */
-		public FileRepositorySettings(String address, String port) {
-			this.address = address;
-			this.port = port;
-		}
-		
-		String getAddress() {
-			return address;
-		}
-		
-		String getPort() {
-			return port;
-		}
-	}
-	
-	/**
-	 * Callback class to use for error reporting. 
-	 */
-	public interface FileRepositoryErrorHandler {
-		/**
-		 * Handles displaying an error message to the user (if appropriate).
-		 * 
-		 * @param title The title for the error display.
-		 * @param error The more detailed error message to display.
-		 */
-		void displayErrorToUser(String title, String error);
-	}
-}
diff --git a/bindings/java/src/org/sleuthkit/datamodel/filerepository/BulkExistenceEnum.java b/bindings/java/src/org/sleuthkit/datamodel/filerepository/BulkExistenceEnum.java
new file mode 100755
index 0000000000000000000000000000000000000000..c6e30283be04cb0b3fd8865922084dbf00ea55eb
--- /dev/null
+++ b/bindings/java/src/org/sleuthkit/datamodel/filerepository/BulkExistenceEnum.java
@@ -0,0 +1,30 @@
+/*
+ * SleuthKit Java Bindings
+ *
+ * Copyright 2020 Basis Technology Corp.
+ * Contact: carrier <at> sleuthkit <dot> org
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.sleuthkit.datamodel.filerepository;
+
+/**
+ * Represents the states of existence for a file in the file repository.
+ * Given that the bulk endpoint is tolerant of dirty SHA-256 values, INVALID
+ * is an enum status for all malformed SHA-256 values.
+ */
+public enum BulkExistenceEnum {
+	TRUE,
+	FALSE,
+	INVALID;
+}
diff --git a/bindings/java/src/org/sleuthkit/datamodel/filerepository/BulkExistenceResult.java b/bindings/java/src/org/sleuthkit/datamodel/filerepository/BulkExistenceResult.java
new file mode 100755
index 0000000000000000000000000000000000000000..9effcb9f38b92c7a560bca5ae1fb121644368a40
--- /dev/null
+++ b/bindings/java/src/org/sleuthkit/datamodel/filerepository/BulkExistenceResult.java
@@ -0,0 +1,50 @@
+/*
+ * SleuthKit Java Bindings
+ *
+ * Copyright 2020 Basis Technology Corp.
+ * Contact: carrier <at> sleuthkit <dot> org
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.sleuthkit.datamodel.filerepository;
+
+import java.util.Collections;
+import java.util.Map;
+import org.sleuthkit.datamodel.AbstractFile;
+
+/**
+ * Container for bulk existence results.
+ */
+public class BulkExistenceResult {
+	
+	private Map<String, BulkExistenceEnum> files;
+	
+	/**
+	 * Checks the status of a file in this container. It is assumed that the
+	 * file is contained within response, otherwise a null value is returned.
+	 * 
+	 * @param file File to test
+	 * @return 
+	 */
+	public BulkExistenceEnum getResult(AbstractFile file) {
+		if (file.getSha256Hash() == null || file.getSha256Hash().isEmpty()) {
+			return BulkExistenceEnum.INVALID;
+		}
+		
+		return files.get(file.getSha256Hash());
+	}
+	
+	void setFiles(Map<String, BulkExistenceEnum> files) {
+		this.files = Collections.unmodifiableMap(files);
+	}
+}
diff --git a/bindings/java/src/org/sleuthkit/datamodel/filerepository/Bundle.properties b/bindings/java/src/org/sleuthkit/datamodel/filerepository/Bundle.properties
new file mode 100755
index 0000000000000000000000000000000000000000..801211667774deef18adbfc98408a0302f22efbf
--- /dev/null
+++ b/bindings/java/src/org/sleuthkit/datamodel/filerepository/Bundle.properties
@@ -0,0 +1,3 @@
+FileRepository.error.title.text=Error from file repository
+FileRepository.downloadError.msg.text=Encountered unexpected error from the file repository. Please see logs for more information.
+FileRepository.notEnabled.msg.text=File repository is not enabled
\ No newline at end of file
diff --git a/bindings/java/src/org/sleuthkit/datamodel/filerepository/Bundle.properties-MERGED b/bindings/java/src/org/sleuthkit/datamodel/filerepository/Bundle.properties-MERGED
new file mode 100755
index 0000000000000000000000000000000000000000..801211667774deef18adbfc98408a0302f22efbf
--- /dev/null
+++ b/bindings/java/src/org/sleuthkit/datamodel/filerepository/Bundle.properties-MERGED
@@ -0,0 +1,3 @@
+FileRepository.error.title.text=Error from file repository
+FileRepository.downloadError.msg.text=Encountered unexpected error from the file repository. Please see logs for more information.
+FileRepository.notEnabled.msg.text=File repository is not enabled
\ No newline at end of file
diff --git a/bindings/java/src/org/sleuthkit/datamodel/filerepository/FileRepository.java b/bindings/java/src/org/sleuthkit/datamodel/filerepository/FileRepository.java
new file mode 100644
index 0000000000000000000000000000000000000000..5b862e176465ece98b02595e85f8c3c2b6a0b952
--- /dev/null
+++ b/bindings/java/src/org/sleuthkit/datamodel/filerepository/FileRepository.java
@@ -0,0 +1,448 @@
+/*
+ * SleuthKit Java Bindings
+ *
+ * Copyright 2020 Basis Technology Corp.
+ * Contact: carrier <at> sleuthkit <dot> org
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.sleuthkit.datamodel.filerepository;
+
+import com.google.gson.Gson;
+import java.io.BufferedReader;
+import java.io.ByteArrayOutputStream;
+import java.io.FilterInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.ResourceBundle;
+import java.util.UUID;
+import java.util.stream.Collectors;
+import org.apache.http.HttpEntity;
+import org.apache.http.HttpStatus;
+import org.apache.http.client.methods.CloseableHttpResponse;
+import org.apache.http.client.methods.HttpGet;
+import org.apache.http.client.methods.HttpHead;
+import org.apache.http.client.methods.HttpPut;
+import org.apache.http.client.methods.HttpUriRequest;
+import org.apache.http.client.methods.RequestBuilder;
+import org.apache.http.entity.StringEntity;
+import org.apache.http.entity.mime.MultipartEntityBuilder;
+import org.apache.http.impl.client.CloseableHttpClient;
+import org.apache.http.impl.client.HttpClients;
+import org.sleuthkit.datamodel.AbstractFile;
+import org.sleuthkit.datamodel.TskData;
+
+/**
+ * Class to represent a file repository.
+ */
+public class FileRepository {
+
+	private static final ResourceBundle BUNDLE = ResourceBundle.getBundle("org.sleuthkit.datamodel.filerepository.Bundle");
+	private static final int MAX_BULK_QUERY_SIZE = 500;
+	private final static String V1_FILES_TEMPLATE = "http://%s:%s/v1/files/";
+	
+	private final Gson gson;
+	private final FileRepositorySettings settings;
+	
+	private static FileRepositoryErrorHandler errorHandler;
+	private static FileRepository instance;
+
+	/**
+	 * Create the file repository.
+	 *
+	 * @param settings         The file repository settings
+	 * @param fileDownloadPath The temporary folder to download files to from
+	 *                         the repository
+	 */
+	FileRepository(FileRepositorySettings settings) {
+		this.settings = settings;
+		this.gson = new Gson();
+	}
+
+	/**
+	 * Initializes the file repository.
+	 *
+	 * @param settings The file repository settings
+	 */
+	public static synchronized void initialize(FileRepositorySettings settings) {
+		instance = new FileRepository(settings);
+	}
+
+	/**
+	 * De-initializes the file repository.
+	 */
+	public static synchronized void deinitialize() {
+		instance = null;
+	}
+
+	/**
+	 * Check if the file repository is enabled.
+	 *
+	 * @return true if enabled, false otherwise.
+	 */
+	public static boolean isEnabled() {
+		return instance != null;
+	}
+
+	/**
+	 * Set the error handling callback.
+	 *
+	 * @param handler The error handler.
+	 */
+	public static synchronized void setErrorHandler(FileRepositoryErrorHandler handler) {
+		errorHandler = handler;
+	}
+
+	/**
+	 * Report an error to the user. The idea is to use this for cases where it's
+	 * a user error that may be able to be corrected through changing the
+	 * repository settings.
+	 *
+	 * @param errorTitle The title for the error display.
+	 * @param errorStr   The error message.
+	 */
+	private static synchronized void reportError(String errorTitle, String errorStr) {
+		if (errorHandler != null) {
+			errorHandler.displayErrorToUser(errorTitle, errorStr);
+		}
+	}
+
+	/**
+	 * Download a file's data from the file repository. The resulting stream
+	 * must be closed and it should be read as soon as possible.
+	 *
+	 * @param abstractFile The file to be downloaded.
+	 *
+	 * @return The file contents, as a stream.
+	 *
+	 * @throws org.sleuthkit.datamodel.filerepository.FileRepositoryException
+	 * @throws java.io.IOException
+	 *
+	 */
+	public static synchronized InputStream download(AbstractFile abstractFile) throws FileRepositoryException, IOException {
+		// Preconditions
+		ensureInstanceIsEnabled();
+		ensureNonEmptySHA256(abstractFile);
+		ensureFileLocationIsRemote(abstractFile);
+
+		return instance.sendDownloadRequest(abstractFile.getSha256Hash());
+	}
+
+	/**
+	 * Private function to perform file download.
+	 */
+	private InputStream sendDownloadRequest(String SHA256) throws IOException, FileRepositoryException {
+		final CloseableHttpClient httpClient = HttpClients.createDefault();
+		final String downloadURL = settings.createBaseURL(V1_FILES_TEMPLATE) + SHA256;
+
+		final HttpGet downloadRequest = new HttpGet(downloadURL);
+		final CloseableHttpResponse response = httpClient.execute(downloadRequest);
+		final int statusCode = response.getStatusLine().getStatusCode();
+
+		if (statusCode != HttpStatus.SC_OK) {
+			FileRepositoryException repoEx = null;
+			try {
+				final String title = BUNDLE.getString("FileRepository.error.title.text");
+				final String message = BUNDLE.getString("FileRepository.downloadError.msg.text");
+				reportError(title, message);
+				final String errorMessage = extractErrorMessage(response);
+				repoEx = new FileRepositoryException(String.format("Request "
+						+ "failed with the following response body %s. Please "
+						+ "check the file repository logs for more information.", errorMessage));
+			} finally {
+				try {
+					response.close();
+				} catch (IOException ex) {
+					// Best effort
+					if (repoEx != null) {
+						repoEx.addSuppressed(ex);
+					}
+				}
+
+				try {
+					httpClient.close();
+				} catch (IOException ex) {
+					// Best effort
+					if (repoEx != null) {
+						repoEx.addSuppressed(ex);
+					}
+				}
+			}
+
+			throw repoEx;
+		}
+
+		// Client and response will close once the stream has been
+		// consumed and closed by the client.
+		return new HTTPInputStream(httpClient, response);
+	}
+
+	/**
+	 * Uploads a stream of data to the file repository.
+	 *
+	 *
+	 * @param stream Arbitrary data to store in this file repository.
+	 *
+	 * @throws java.io.IOException
+	 * @throws org.sleuthkit.datamodel.filerepository.FileRepositoryException
+	 */
+	public static synchronized void upload(InputStream stream) throws IOException, FileRepositoryException {
+		// Preconditions
+		ensureInstanceIsEnabled();
+
+		instance.sendUploadRequest(stream);
+	}
+
+	/**
+	 * Private function to perform file upload.
+	 */
+	private void sendUploadRequest(InputStream stream) throws IOException, FileRepositoryException {
+		try (CloseableHttpClient httpClient = HttpClients.createDefault()) {
+			final String uploadURL = settings.createBaseURL(V1_FILES_TEMPLATE);
+
+			// Flush the stream to a local temp file for transport.
+			final Path temp = Paths.get(System.getProperty("java.io.tmpdir"), UUID.randomUUID().toString());
+			Files.copy(stream, temp);
+
+			final HttpEntity fileUpload = MultipartEntityBuilder.create()
+					.addBinaryBody("file", temp.toFile())
+					.build();
+			final HttpUriRequest postRequest = RequestBuilder.post(uploadURL)
+					.setEntity(fileUpload)
+					.build();
+
+			try (CloseableHttpResponse response = httpClient.execute(postRequest)) {
+				checkSuccess(response);
+			} catch (IOException | FileRepositoryException ex) {
+				try {
+					Files.delete(temp);
+				} catch (IOException deleteEx) {
+					ex.addSuppressed(deleteEx);
+				}
+				throw ex;
+			} finally {
+				try {
+					Files.delete(temp);
+				} catch (IOException ex) {
+					// Do nothing, best effort.
+				}
+			}
+		}
+	}
+
+	/**
+	 * Checks if many abstract files are stored within this file repository.
+	 * This API is tolerant of files without SHA-256 values, as opposed to its
+	 * overridden counterpart, which will throw an exception if not present.
+	 *
+	 * @param files Files to test
+	 *
+	 * @return An object encapsulating the response for each file.
+	 *
+	 * @throws java.io.IOException
+	 * @throws org.sleuthkit.datamodel.filerepository.FileRepositoryException
+	 */
+	public static synchronized BulkExistenceResult exists(List<AbstractFile> files) throws IOException, FileRepositoryException {
+		// Preconditions
+		ensureInstanceIsEnabled();
+		ensureBulkQuerySize(files);
+
+		return instance.sendMultiExistenceQuery(new ExistenceQuery(files));
+	}
+
+	/**
+	 * Private function to perform the bulk existence query.
+	 */
+	private BulkExistenceResult sendMultiExistenceQuery(ExistenceQuery query) throws IOException, FileRepositoryException {
+		try (CloseableHttpClient httpClient = HttpClients.createDefault()) {
+			final String existsURL = settings.createBaseURL(V1_FILES_TEMPLATE) + "exists";
+			final String jsonString = gson.toJson(query);
+			final StringEntity jsonEntity = new StringEntity(jsonString, StandardCharsets.UTF_8);
+
+			final HttpPut bulkExistsPost = new HttpPut(existsURL);
+			bulkExistsPost.setEntity(jsonEntity);
+			bulkExistsPost.setHeader("Accept", "application/json");
+			bulkExistsPost.setHeader("Content-type", "application/json");
+
+			try (CloseableHttpResponse response = httpClient.execute(bulkExistsPost)) {
+				checkSuccess(response);
+
+				final HttpEntity entity = response.getEntity();
+				final ByteArrayOutputStream byteOutputStream = new ByteArrayOutputStream();
+				entity.writeTo(byteOutputStream);
+				final String jsonBody = new String(byteOutputStream.toByteArray(), StandardCharsets.UTF_8);
+				return gson.fromJson(jsonBody, BulkExistenceResult.class);
+			}
+		}
+	}
+
+	/**
+	 * Checks if the abstract file is stored within this file repository.
+	 *
+	 * @param file Abstract file to query
+	 *
+	 * @return True/False
+	 *
+	 * @throws IOException
+	 * @throws FileRepositoryException
+	 */
+	public static synchronized boolean exists(AbstractFile file) throws IOException, FileRepositoryException {
+		// Preconditions
+		ensureInstanceIsEnabled();
+		ensureNonEmptySHA256(file);
+
+		if (!file.getFileLocation().equals(TskData.FileLocation.REPOSITORY)) {
+			return false;
+		}
+
+		return instance.sendSingularExistenceQuery(file.getSha256Hash());
+	}
+
+	/**
+	 * Private function to perform the existence query.
+	 */
+	private boolean sendSingularExistenceQuery(String SHA256) throws IOException, FileRepositoryException {
+		try (CloseableHttpClient httpClient = HttpClients.createDefault()) {
+			final String existsURL = settings.createBaseURL(V1_FILES_TEMPLATE) + SHA256;
+
+			final HttpHead request = new HttpHead(existsURL);
+
+			try (CloseableHttpResponse response = httpClient.execute(request)) {
+				final int statusCode = response.getStatusLine().getStatusCode();
+				switch (statusCode) {
+					case HttpStatus.SC_OK:
+						return true;
+					case HttpStatus.SC_NOT_FOUND:
+						return false;
+					default:
+						throw new FileRepositoryException(String.format("Unexpected "
+								+ "response code. Expected 200 or 404, but instead got %d. "
+								+ "Please check the file repository logs for more information.", statusCode));
+				}
+			}
+		}
+	}
+
+	/**
+	 * Prevents a query if the file is not remote.
+	 */
+	private static void ensureFileLocationIsRemote(AbstractFile abstractFile) throws FileRepositoryException {
+		if (!abstractFile.getFileLocation().equals(TskData.FileLocation.REPOSITORY)) {
+			throw new FileRepositoryException("File with object ID " + abstractFile.getId() + " is not stored in the file repository");
+		}
+	}
+
+	/**
+	 * Prevents a query for a file with no SHA-256.
+	 */
+	private static void ensureNonEmptySHA256(AbstractFile abstractFile) throws FileRepositoryException {
+		if (abstractFile.getSha256Hash() == null || abstractFile.getSha256Hash().isEmpty()) {
+			throw new FileRepositoryException("File with object ID " + abstractFile.getId() + " has no SHA-256 hash.");
+		}
+	}
+
+	/**
+	 * Ensures the instance is enabled, notifying users otherwise.
+	 */
+	private static void ensureInstanceIsEnabled() throws FileRepositoryException {
+		if (!isEnabled()) {
+			final String title = BUNDLE.getString("FileRepository.error.title.text");
+			final String msg = BUNDLE.getString("FileRepository.notEnabled.msg.text");
+			reportError(title, msg);
+			throw new FileRepositoryException("File repository is not enabled");
+		}
+	}
+
+	/**
+	 * Prevents a request from being made if it exceeds the maximum threshold
+	 * for a bulk query.
+	 */
+	private static void ensureBulkQuerySize(List<AbstractFile> files) throws FileRepositoryException {
+		if (files.size() > MAX_BULK_QUERY_SIZE) {
+			throw new FileRepositoryException(String.format("Exceeds the allowable "
+					+ "threshold (%d) for a single request.", MAX_BULK_QUERY_SIZE));
+		}
+	}
+
+	/**
+	 * Checks the status code of the response and throws a templated exception
+	 * if it's not the expected 200 code.
+	 */
+	private static void checkSuccess(CloseableHttpResponse response) throws FileRepositoryException, IOException {
+		final int statusCode = response.getStatusLine().getStatusCode();
+
+		if (statusCode != HttpStatus.SC_OK) {
+			final String errorMessage = extractErrorMessage(response);
+			throw new FileRepositoryException(String.format("Request failed with "
+					+ "the following response body %s. Please check the file "
+					+ "repository logs for more information.", errorMessage));
+		}
+	}
+
+	/**
+	 * Extracts the entire response body as a plain string.
+	 */
+	private static String extractErrorMessage(CloseableHttpResponse response) throws IOException {
+		try (BufferedReader reader = new BufferedReader(new InputStreamReader(
+				response.getEntity().getContent(), StandardCharsets.UTF_8))) {
+			return reader.lines().collect(Collectors.joining("\n"));
+		}
+	}
+
+	/**
+	 * Query object to be serialized by GSON and sent as a payload to the bulk
+	 * exists endpoint.
+	 */
+	private static class ExistenceQuery {
+
+		private final List<String> files;
+
+		ExistenceQuery(List<AbstractFile> absFiles) {
+			files = new ArrayList<>();
+			for (AbstractFile file : absFiles) {
+				if (file.getSha256Hash() != null && !file.getSha256Hash().isEmpty()) {
+					files.add(file.getSha256Hash());
+				}
+			}
+		}
+	}
+
+	/**
+	 * Streams data over a HTTP connection.
+	 */
+	private static class HTTPInputStream extends FilterInputStream {
+
+		private final CloseableHttpResponse response;
+		private final CloseableHttpClient client;
+
+		HTTPInputStream(CloseableHttpClient client, CloseableHttpResponse response) throws IOException {
+			super(response.getEntity().getContent());
+			this.response = response;
+			this.client = client;
+		}
+
+		@Override
+		public void close() throws IOException {
+			super.close();
+			response.close();
+			client.close();
+		}
+	}
+}
diff --git a/bindings/java/src/org/sleuthkit/datamodel/filerepository/FileRepositoryErrorHandler.java b/bindings/java/src/org/sleuthkit/datamodel/filerepository/FileRepositoryErrorHandler.java
new file mode 100755
index 0000000000000000000000000000000000000000..b58a4b064631a8049a0ae8a500c5c1d4f7ead867
--- /dev/null
+++ b/bindings/java/src/org/sleuthkit/datamodel/filerepository/FileRepositoryErrorHandler.java
@@ -0,0 +1,34 @@
+/*
+ * SleuthKit Java Bindings
+ *
+ * Copyright 2020 Basis Technology Corp.
+ * Contact: carrier <at> sleuthkit <dot> org
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.sleuthkit.datamodel.filerepository;
+
+/**
+ * Callback class to use for error reporting.
+ */
+public interface FileRepositoryErrorHandler {
+
+	/**
+	 * Handles displaying an error message to the user (if appropriate).
+	 *
+	 * @param title The title for the error display.
+	 * @param error The more detailed error message to display.
+	 */
+	void displayErrorToUser(String title, String error);
+    
+}
diff --git a/bindings/java/src/org/sleuthkit/datamodel/filerepository/FileRepositoryException.java b/bindings/java/src/org/sleuthkit/datamodel/filerepository/FileRepositoryException.java
new file mode 100755
index 0000000000000000000000000000000000000000..b3b1f66571f89a5ba3cba36f4140d611c9668abb
--- /dev/null
+++ b/bindings/java/src/org/sleuthkit/datamodel/filerepository/FileRepositoryException.java
@@ -0,0 +1,28 @@
+/*
+ * SleuthKit Java Bindings
+ *
+ * Copyright 2020 Basis Technology Corp.
+ * Contact: carrier <at> sleuthkit <dot> org
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.sleuthkit.datamodel.filerepository;
+
+public class FileRepositoryException extends Exception {
+	
+	private static final long serialVersionUID = 1L;
+	
+	public FileRepositoryException(String msg) {
+		super(msg);
+	}
+}
diff --git a/bindings/java/src/org/sleuthkit/datamodel/filerepository/FileRepositorySettings.java b/bindings/java/src/org/sleuthkit/datamodel/filerepository/FileRepositorySettings.java
new file mode 100755
index 0000000000000000000000000000000000000000..8bbfa07290531a7e8ebfd893ac7e954d27d33f2d
--- /dev/null
+++ b/bindings/java/src/org/sleuthkit/datamodel/filerepository/FileRepositorySettings.java
@@ -0,0 +1,46 @@
+/*
+ * SleuthKit Java Bindings
+ *
+ * Copyright 2020 Basis Technology Corp.
+ * Contact: carrier <at> sleuthkit <dot> org
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.sleuthkit.datamodel.filerepository;
+
+/**
+ * Utility class to hold the file repository server settings.
+ */
+public class FileRepositorySettings {
+
+	private final String address;
+	private final String port;
+	
+	/**
+	 * Create a FileRepositorySettings instance for the server.
+	 *
+	 * @param address The IP address/hostname of the server.
+	 * @param port    The port.
+	 */
+	public FileRepositorySettings(String address, String port) {
+		this.address = address;
+		this.port = port;
+	}
+	
+	/**
+	 * Fills in an API template with the address and port.
+	 */
+	String createBaseURL(String urlTemplate) {
+		return String.format(urlTemplate, address, port);
+	}
+}