diff --git a/.gitignore b/.gitignore
old mode 100644
new mode 100755
index 83345a859bf3f41171903a8dffa4563b4841db41..faa1b92a945d5aa98e91272211df022525c4ff68
--- a/.gitignore
+++ b/.gitignore
@@ -99,4 +99,5 @@ hs_err_pid*.log
 /thirdparty/yara/YaraJNIWrapper/dist/
 /thirdparty/yara/YaraJNIWrapper/build/
 /thirdparty/yara/YaraJNIWrapper/nbproject/private/
-thirdparty/yara/yarabridge/.vs/
+/thirdparty/yara/yarabridge/.vs/
+
diff --git a/Core/build.xml b/Core/build.xml
index 1ec2a3b481cd7a5dc6f84fba4861e77b9491ccc0..d2e9b00f169d395d418f76fd3b574fdbf0d2a68c 100644
--- a/Core/build.xml
+++ b/Core/build.xml
@@ -166,7 +166,7 @@
         <get src="https://drive.google.com/uc?id=1ns2olaWsBu_c4EoE4Seh8t_B3U5RnLKd" dest="${test-input}/CommonFilesAttrs_img1_v1.vhd" skipexisting="true"/>
 
     </target>
-
+    
     <target name="get-deps" depends="init-ivy,getTSKJars,get-thirdparty-dependencies,get-InternalPythonModules, download-binlist">
         <mkdir dir="${ext.dir}"/>
         <copy file="${thirdparty.dir}/LICENSE-2.0.txt" todir="${ext.dir}" />        
@@ -199,5 +199,110 @@
             <globmapper from="*" to="*-MERGED"/>
         </copy>
     </target>
+
+    <!--sets up integration test system properties, calls underlying test-init and then sets up the pathing jar-->
+    <target name="test-init" depends="projectized-common.test-init,getTestDataFiles,qa-functional-pathing-jar,unit-test-path-simplification" />
+
+    <!--    
+        The paths specified in 'module.run.classpath' are incorporated into the manifest of a jar and then the path to the 
+        jar is used as part of the classpath for '-do-junit' instead of 'module.run.classpath'.  This was done to prevent 
+        classpath length issues on windows.  More information on this technique can be found here: 
+        https://stackoverflow.com/a/201969.
+    -->
+    <target name="qa-functional-pathing-jar" depends="projectized-common.test-init">
+        <sequential>
+            <!--set up pathing jar based on module.run.classpath as classpath-->
+            <path id="test.qa-functional.pathing-jar.module-cp.classpath" path="${module.run.classpath}"/>
+            <pathconvert pathsep=" " refid="test.qa-functional.pathing-jar.module-cp.classpath" property="test.qa-functional.pathing-jar.module-cp.classpathstr"/>
+            <property name="test.qa-functional.pathing-jar.module-cp.loc" value="${cluster}/test.qa-functional.pathing.module-cp.jar"/>
+            <jar destfile="${test.qa-functional.pathing-jar.module-cp.loc}">
+                <manifest>
+                    <attribute name="Class-Path" value="${test.qa-functional.pathing-jar.module-cp.classpathstr}"/>
+                </manifest>
+            </jar>
+             
+            <!--grab properties from common.xml:test-init so that "test.qa-functional.run.cp" can be properly formed-->
+            <property name="build.test.qa-functional.dir" location="${build.dir}/test/qa-functional"/>
+            <property name="build.test.qa-functional.classes.dir" location="${build.test.qa-functional.dir}/classes"/>
+            <property name="test.qa-functional.cp.extra" value=""/>
+                
+            <!--set up "test.qa-functional.run.cp" to be used by common.xml:-do-junit-->
+            <path id="test.qa-functional.run.cp">
+                <pathelement path="${build.test.qa-functional.classes.dir}"/>
+                <!-- Cannot use <path refid="cp"/> since that uses ${module.classpath} and we want ${module.run.classpath}: -->
+                <pathelement path="${test.qa-functional.runtime.cp}"/>
+                <pathelement path="${cp.extra}"/>
+                <pathelement location="${cluster}/${module.jar}"/>
+                <path refid="test.unit.lib.cp"/>
+                <!-- for compatibility with property based classpath-->
+                <pathelement path="${test.qa-functional.pathing-jar.module-cp.loc}"/>
+                <pathelement path="${test.qa-functional.run.cp.extra}"/>
+                <pathelement path="${test.qa-functional.cp.extra}"/>
+                <pathelement path="${test.extra.nb.javac.deps}"/>
+            </path>
+        </sequential>
+    </target>    
     
+ 
+    <!--  
+    This specifies the classpath for unit tests using * notation 
+    (i.e. https://stackoverflow.com/questions/219585/including-all-the-jars-in-a-directory-within-the-java-classpath).  
+    This solution involves taking the initial ‘module.run.classpath’ property and simplifying it to the directories containing jars 
+    (i.e. instead of “/dir/lib1.jar:/dir/lib2.jar:/dir/lib3.jar” it becomes “/dir/*” ).  
+    More information on ‘module.run.classpath’ can be found in “netbeans-plat\11.3\harness\README” and it appears that
+    “netbeans-plat\11.3\harness\build.xml:build-init target is in charge of setting the ‘module.run.classpath’ variable.
+    More information in Jira: 6970.
+    -->
+    <target name="unit-test-path-simplification" depends="projectized-common.test-init">
+        <sequential>
+            <script language="javascript">
+                <![CDATA[ 
+                    var moduleRunClasspath = project.getProperty("module.run.classpath");
+
+                    var directories = [];
+                    // searches for jar file parent directories with path separators of \ or /
+                    var individualPathRegex = /^\s*(.+?[\\\/])[^\\\/]+?\.jar\s*$/i;
+                    // split on ':' but not 'C:\'
+                    var classPathRegex = /((C:\\)?.+?)(:|$)/gi;
+                    var match;
+                    while((match = classPathRegex.exec(moduleRunClasspath)) !== null) {
+                        var thisPath = match[1];
+                        var pathMatch = thisPath.match(individualPathRegex);
+                        // find unique matches
+                        if (pathMatch && directories.indexOf(pathMatch[1]) < 0) {
+                            directories.push(pathMatch[1]);
+                        }
+                    }
+
+                    // suffix with *
+                    for (var i = 0; i < directories.length; i++) {
+                        directories[i] = directories[i] + "*";
+                    }
+
+                    // set project property
+                    project.setNewProperty("test.unit.abbreviatedModuleRunClassPath", directories.join(":"));
+                ]]>
+            </script>
+
+            <!--grab properties from common.xml:test-init so that "test.unit.run.cp" can be properly formed-->
+            <property name="build.test.unit.dir" location="${build.dir}/test/unit"/>
+            <property name="build.test.unit.classes.dir" location="${build.test.unit.dir}/classes"/>
+            <property name="test.unit.cp.extra" value=""/>
+                
+            <!--set up "test.unit.run.cp" to be used by common.xml:-do-junit-->
+            <path id="test.unit.run.cp">
+                <pathelement path="${build.test.unit.classes.dir}"/>
+                <!-- Cannot use <path refid="cp"/> since that uses ${module.classpath} and we want ${module.run.classpath}: -->
+                <pathelement path="${test.unit.runtime.cp}"/>
+                <pathelement path="${cp.extra}"/>
+                <pathelement location="${cluster}/${module.jar}"/>
+                <path refid="test.unit.lib.cp"/>
+                <!-- for compatibility with property based classpath-->
+                <pathelement path="${test.unit.abbreviatedModuleRunClassPath}"/>
+                <pathelement path="${test.unit.run.cp.extra}"/>
+                <pathelement path="${test.unit.cp.extra}"/>
+                <pathelement path="${test.extra.nb.javac.deps}"/>
+            </path>
+        </sequential>
+    </target>     
 </project>
diff --git a/Core/nbproject/project.xml b/Core/nbproject/project.xml
index 1cddbaa638a64e9bb94c8511031dd1388524b363..96d3ea1db693d93c77a1e81530dd3796ca1ae886 100644
--- a/Core/nbproject/project.xml
+++ b/Core/nbproject/project.xml
@@ -232,7 +232,8 @@
                     <build-prerequisite/>
                     <compile-dependency/>
                     <run-dependency>
-                        <specification-version>1.0</specification-version>
+                        <release-version>1</release-version>
+                        <specification-version>23</specification-version>
                     </run-dependency>
                 </dependency>
                 <dependency>
diff --git a/Core/src/org/sleuthkit/autopsy/contentviewers/Bundle.properties-MERGED b/Core/src/org/sleuthkit/autopsy/contentviewers/Bundle.properties-MERGED
index ec1b7be47ec91d00305af9859bf0cc68de6503a0..6df3399a0aaa9233bbaf44add103a5c7e5973599 100644
--- a/Core/src/org/sleuthkit/autopsy/contentviewers/Bundle.properties-MERGED
+++ b/Core/src/org/sleuthkit/autopsy/contentviewers/Bundle.properties-MERGED
@@ -96,7 +96,7 @@ Metadata.tableRowTitle.mimeType=MIME Type
 Metadata.tableRowTitle.name=Name
 Metadata.tableRowTitle.sectorSize=Sector Size
 Metadata.tableRowTitle.sha1=SHA1
-Metadata.tableRowTitle.sha256=SHA256
+Metadata.tableRowTitle.sha256=SHA-256
 Metadata.tableRowTitle.size=Size
 Metadata.tableRowTitle.fileNameAlloc=File Name Allocation
 Metadata.tableRowTitle.metadataAlloc=Metadata Allocation
diff --git a/Core/src/org/sleuthkit/autopsy/contentviewers/Metadata.java b/Core/src/org/sleuthkit/autopsy/contentviewers/Metadata.java
index 796386a5b3e0acf466f4269ac0f93ce8f075c73b..45dae8e2e07eed6952e809c561b603524efe783d 100644
--- a/Core/src/org/sleuthkit/autopsy/contentviewers/Metadata.java
+++ b/Core/src/org/sleuthkit/autopsy/contentviewers/Metadata.java
@@ -137,7 +137,7 @@ private void addRow(StringBuilder sb, String key, String value) {
         "Metadata.tableRowTitle.mimeType=MIME Type",
         "Metadata.nodeText.truncated=(results truncated)",
         "Metadata.tableRowTitle.sha1=SHA1",
-        "Metadata.tableRowTitle.sha256=SHA256",
+        "Metadata.tableRowTitle.sha256=SHA-256",
         "Metadata.tableRowTitle.imageType=Type",
         "Metadata.tableRowTitle.sectorSize=Sector Size",
         "Metadata.tableRowTitle.timezone=Time Zone",
@@ -182,6 +182,11 @@ public void setNode(Node node) {
                 md5 = NbBundle.getMessage(this.getClass(), "Metadata.tableRowContent.md5notCalc");
             }
             addRow(sb, NbBundle.getMessage(this.getClass(), "Metadata.tableRowTitle.md5"), md5);
+            String sha256 = file.getSha256Hash();
+            if (sha256 == null) {
+                sha256 = NbBundle.getMessage(this.getClass(), "Metadata.tableRowContent.md5notCalc");
+            }
+            addRow(sb, NbBundle.getMessage(this.getClass(), "Metadata.tableRowTitle.sha256"), sha256);
             addRow(sb, NbBundle.getMessage(this.getClass(), "Metadata.tableRowTitle.hashLookupResults"), file.getKnown().toString());
             addAcquisitionDetails(sb, dataSource);
             
diff --git a/Core/src/org/sleuthkit/autopsy/contentviewers/PDFViewer.java b/Core/src/org/sleuthkit/autopsy/contentviewers/PDFViewer.java
index b21a48a1937af96fc171f5b7cbbda0d9106c6f07..59e276ce435472c7e1c67bd4389c9ce65609b2c1 100755
--- a/Core/src/org/sleuthkit/autopsy/contentviewers/PDFViewer.java
+++ b/Core/src/org/sleuthkit/autopsy/contentviewers/PDFViewer.java
@@ -20,6 +20,7 @@
 
 import java.awt.BorderLayout;
 import java.awt.Component;
+import java.awt.Container;
 import java.io.IOException;
 import java.lang.reflect.InvocationTargetException;
 import java.util.Arrays;
@@ -97,6 +98,9 @@ public void setFile(AbstractFile file) {
 
         // Add the IcePDF view to the center of our container.
         this.container.add(icePdfPanel, BorderLayout.CENTER);
+        
+        // Disable all components until the document is ready to view.
+        enableComponents(container, false);
 
         // Document is the 'M' in IcePDFs MVC set up. Read the data needed to 
         // populate the model in the background.
@@ -122,12 +126,13 @@ protected void done() {
                 // will cause UI widgets to be updated.
                 try {
                     Document doc = get();
-                    controller.openDocument(doc, null);
+                    controller.openDocument(doc, file.getName());
                     // This makes the PDF viewer appear as one continuous 
                     // document, which is the default for most popular PDF viewers.
                     controller.setPageViewMode(DocumentViewControllerImpl.ONE_COLUMN_VIEW, true);
                     // This makes it possible to select text by left clicking and dragging.
                     controller.setDisplayTool(DocumentViewModelImpl.DISPLAY_TOOL_TEXT_SELECTION);
+                    enableComponents(container, true);
                 } catch (InterruptedException ex) {
                     // Do nothing.
                 } catch (ExecutionException ex) {
@@ -140,10 +145,28 @@ protected void done() {
                                 file.getId(), file.getName()), ex);
                         showErrorDialog();
                     }
+                } catch (Throwable ex) {
+                    logger.log(Level.WARNING, String.format("PDF content viewer "
+                            + "was unable to open document with id %d and name %s",
+                            file.getId(), file.getName()), ex);
                 }
             }
         }.execute();
     }
+    
+    /**
+     * Recursively enable/disable all components in this content viewer.
+     * This will disable/enable all internal IcePDF Swing components too.
+     */
+    private void enableComponents(Container container, boolean enabled) {
+        Component[] components = container.getComponents();
+        for(Component component : components) {
+            component.setEnabled(enabled);
+            if (component instanceof Container) {
+                enableComponents((Container)component, enabled);
+            }
+        }
+    }
 
     @Override
     public Component getComponent() {
diff --git a/Core/src/org/sleuthkit/autopsy/contentviewers/artifactviewers/CommunicationArtifactViewerHelper.java b/Core/src/org/sleuthkit/autopsy/contentviewers/artifactviewers/CommunicationArtifactViewerHelper.java
index a911ac20d74ab5fe0c6993d3b58ffc647af501f4..13e11d2a2dee19a98974158aabc3d41c38c51dad 100644
--- a/Core/src/org/sleuthkit/autopsy/contentviewers/artifactviewers/CommunicationArtifactViewerHelper.java
+++ b/Core/src/org/sleuthkit/autopsy/contentviewers/artifactviewers/CommunicationArtifactViewerHelper.java
@@ -44,7 +44,7 @@
  * A class to help display a communication artifact in a panel using a
  * gridbaglayout.
  */
-final class CommunicationArtifactViewerHelper {
+public final class CommunicationArtifactViewerHelper {
 
     // Number of columns in the gridbag layout.
     private final static int MAX_COLS = 4;
@@ -63,12 +63,12 @@ private CommunicationArtifactViewerHelper() {
      *
      * @param panel         Panel to update.
      * @param gridbagLayout Layout to use.
-     * @param constraints   Constrains to use.
+     * @param constraints   Constraints to use.
      * @param headerString  Heading string to display.
      *
      * @return JLabel Heading label added.
      */
-    static JLabel addHeader(JPanel panel, GridBagLayout gridbagLayout, GridBagConstraints constraints, String headerString) {
+    public static JLabel addHeader(JPanel panel, GridBagLayout gridbagLayout, GridBagConstraints constraints, String headerString) {
 
         Insets savedInsets = constraints.insets;
 
@@ -109,6 +109,23 @@ static JLabel addHeader(JPanel panel, GridBagLayout gridbagLayout, GridBagConstr
         return headingLabel;
     }
 
+    /**
+     * Add a key value row to the specified panel with the specified layout and
+     * constraints.
+     *
+     *
+     * @param panel         Panel to update.
+     * @param gridbagLayout Layout to use.
+     * @param constraints   Constraints to use.
+     * @param keyString     Key name to display.
+     * @param valueString   Value string to display.
+     *
+     */
+    public static void addNameValueRow(JPanel panel, GridBagLayout gridbagLayout, GridBagConstraints constraints, String keyString, String valueString) {
+        addKey(panel, gridbagLayout, constraints, keyString);
+        addValue(panel, gridbagLayout, constraints, valueString);
+    }
+
     /**
      * Adds the given component to the panel.
      *
@@ -116,7 +133,7 @@ static JLabel addHeader(JPanel panel, GridBagLayout gridbagLayout, GridBagConstr
      *
      * @param panel         Panel to update.
      * @param gridbagLayout Layout to use.
-     * @param constraints   Constrains to use.
+     * @param constraints   Constraints to use.
      * @param component     Component to add.
      */
     static void addComponent(JPanel panel, GridBagLayout gridbagLayout, GridBagConstraints constraints, JComponent component) {
@@ -132,7 +149,7 @@ static void addComponent(JPanel panel, GridBagLayout gridbagLayout, GridBagConst
      *
      * @param panel         Panel to update.
      * @param gridbagLayout Layout to use.
-     * @param constraints   Constrains to use.
+     * @param constraints   Constraints to use.
      */
     static void addLineEndGlue(JPanel panel, GridBagLayout gridbagLayout, GridBagConstraints constraints) {
         // Place the filler just past the last column.
@@ -159,9 +176,9 @@ static void addLineEndGlue(JPanel panel, GridBagLayout gridbagLayout, GridBagCon
      *
      * @param panel         Panel to update.
      * @param gridbagLayout Layout to use.
-     * @param constraints   Constrains to use.
+     * @param constraints   Constraints to use.
      */
-    static void addPageEndGlue(JPanel panel, GridBagLayout gridbagLayout, GridBagConstraints constraints) {
+    public static void addPageEndGlue(JPanel panel, GridBagLayout gridbagLayout, GridBagConstraints constraints) {
 
         constraints.gridx = 0;
 
@@ -185,7 +202,7 @@ static void addPageEndGlue(JPanel panel, GridBagLayout gridbagLayout, GridBagCon
      *
      * @param panel         Panel to update.
      * @param gridbagLayout Layout to use.
-     * @param constraints   Constrains to use.
+     * @param constraints   Constraints to use.
      */
     static void addBlankLine(JPanel panel, GridBagLayout gridbagLayout, GridBagConstraints constraints) {
         constraints.gridy++;
@@ -203,7 +220,7 @@ static void addBlankLine(JPanel panel, GridBagLayout gridbagLayout, GridBagConst
      *
      * @param panel         Panel to update.
      * @param gridbagLayout Layout to use.
-     * @param constraints   Constrains to use.
+     * @param constraints   Constraints to use.
      * @param keyString     Key name to display.
      *
      * @return Label added.
@@ -217,7 +234,7 @@ static JLabel addKey(JPanel panel, GridBagLayout gridbagLayout, GridBagConstrain
      *
      * @param panel         Panel to update.
      * @param gridbagLayout Layout to use.
-     * @param constraints   Constrains to use.
+     * @param constraints   Constraints to use.
      * @param keyString     Key name to display.
      * @param gridx         column index, must be less than MAX_COLS - 1.
      *
@@ -246,8 +263,8 @@ static JLabel addKeyAtCol(JPanel panel, GridBagLayout gridbagLayout, GridBagCons
      *
      * @param panel         Panel to update.
      * @param gridbagLayout Layout to use.
-     * @param constraints   Constrains to use.
-     * @param keyString     Value string to display.
+     * @param constraints   Constraints to use.
+     * @param valueString   Value string to display.
      *
      * @return Label added.
      */
@@ -260,7 +277,7 @@ static JTextPane addValue(JPanel panel, GridBagLayout gridbagLayout, GridBagCons
      *
      * @param panel         Panel to update.
      * @param gridbagLayout Layout to use.
-     * @param constraints   Constrains to use.
+     * @param constraints   Constraints to use.
      * @param keyString     Value string to display.
      * @param gridx         Column index, must be less than MAX_COLS;
      *
@@ -367,7 +384,7 @@ static JLabel addMessageRow(JPanel panel, GridBagLayout gridbagLayout, GridBagCo
      *
      * @param panel             Panel to update.
      * @param gridbagLayout     Layout to use.
-     * @param constraints       Constrains to use.
+     * @param constraints       Constraints to use.
      * @param accountIdentifier Account identifier to search the persona.
      *
      * @return List of AccountPersonaSearcherData objects.
@@ -435,7 +452,7 @@ static List<AccountPersonaSearcherData> addPersonaRow(JPanel panel, GridBagLayou
      *
      * @param panel         Panel to update.
      * @param gridbagLayout Layout to use.
-     * @param constraints   Constrains to use.
+     * @param constraints   Constraints to use.
      * @param contactId     Contact name to display.
      *
      * @return A JLabel with the contact information.
diff --git a/Core/src/org/sleuthkit/autopsy/contentviewers/artifactviewers/DefaultArtifactContentViewer.java b/Core/src/org/sleuthkit/autopsy/contentviewers/artifactviewers/DefaultArtifactContentViewer.java
index 8c0c89a865a00b79112e55b7cf625d6e885b29f6..0c9e41afd633340c90168c43bf2172030773a114 100644
--- a/Core/src/org/sleuthkit/autopsy/contentviewers/artifactviewers/DefaultArtifactContentViewer.java
+++ b/Core/src/org/sleuthkit/autopsy/contentviewers/artifactviewers/DefaultArtifactContentViewer.java
@@ -54,15 +54,14 @@
 import java.util.Locale;
 import java.util.Map;
 import javax.swing.SwingUtilities;
+import org.sleuthkit.autopsy.discovery.ui.AbstractArtifactDetailsPanel;
 //import org.sleuthkit.autopsy.contentviewers.Bundle;
 
 /**
- * This class displays a Blackboard artifact as a table listing all it's 
- * attributes names and values.
+ * This class displays a Blackboard artifact as a table of its attributes.
  */
-
 @SuppressWarnings("PMD.SingularField") // UI widgets cause lots of false positives
-public class DefaultArtifactContentViewer extends javax.swing.JPanel implements ArtifactContentViewer {
+public class DefaultArtifactContentViewer extends AbstractArtifactDetailsPanel implements ArtifactContentViewer {
 
     @NbBundle.Messages({
         "DefaultArtifactContentViewer.attrsTableHeader.type=Type",
@@ -71,11 +70,11 @@ public class DefaultArtifactContentViewer extends javax.swing.JPanel implements
         "DataContentViewerArtifact.failedToGetSourcePath.message=Failed to get source file path from case database",
         "DataContentViewerArtifact.failedToGetAttributes.message=Failed to get some or all attributes from case database"
     })
-    
+
     private final static Logger logger = Logger.getLogger(DefaultArtifactContentViewer.class.getName());
-   
+
     private static final long serialVersionUID = 1L;
-    
+
     private static final String[] COLUMN_HEADERS = {
         Bundle.DefaultArtifactContentViewer_attrsTableHeader_type(),
         Bundle.DefaultArtifactContentViewer_attrsTableHeader_value(),
@@ -124,7 +123,7 @@ public void columnMoved(TableColumnModelEvent e) {
                 // do nothing
             }
 
-            @Override  
+            @Override
             public void columnMarginChanged(ChangeEvent e) {
                 updateRowHeights(); //When the user changes column width we may need to resize row height
             }
@@ -153,12 +152,12 @@ private void updateRowHeights() {
                 Component comp = resultsTable.prepareRenderer(
                         resultsTable.getCellRenderer(row, valueColIndex), row, valueColIndex);
                 final int rowHeight;
-             if (comp instanceof JTextArea) {
+                if (comp instanceof JTextArea) {
                     final JTextArea tc = (JTextArea) comp;
                     final View rootView = tc.getUI().getRootView(tc);
                     java.awt.Insets i = tc.getInsets();
                     rootView.setSize(resultsTable.getColumnModel().getColumn(valueColIndex)
-                            .getWidth() - (i.left + i.right +CELL_RIGHT_MARGIN), //current width minus borders
+                            .getWidth() - (i.left + i.right + CELL_RIGHT_MARGIN), //current width minus borders
                             Integer.MAX_VALUE);
                     rowHeight = (int) rootView.getPreferredSpan(View.Y_AXIS);
                 } else {
@@ -267,7 +266,7 @@ public void actionPerformed(ActionEvent e) {
      * Resets the components to an empty view state.
      */
     private void resetComponents() {
-       
+
         ((DefaultTableModel) resultsTable.getModel()).setRowCount(0);
     }
 
@@ -279,7 +278,7 @@ public Component getComponent() {
     @Override
     public void setArtifact(BlackboardArtifact artifact) {
         try {
-            ResultsTableArtifact resultsTableArtifact = new ResultsTableArtifact(artifact, artifact.getParent());
+            ResultsTableArtifact resultsTableArtifact = artifact == null ? null : new ResultsTableArtifact(artifact, artifact.getParent());
 
             SwingUtilities.invokeLater(new Runnable() {
                 @Override
@@ -289,7 +288,7 @@ public void run() {
             });
 
         } catch (TskCoreException ex) {
-             logger.log(Level.SEVERE, String.format("Error getting parent content for artifact (artifact_id=%d, obj_id=%d)", artifact.getArtifactID(), artifact.getObjectID()), ex);
+            logger.log(Level.SEVERE, String.format("Error getting parent content for artifact (artifact_id=%d, obj_id=%d)", artifact.getArtifactID(), artifact.getObjectID()), ex);
         }
 
     }
@@ -301,7 +300,7 @@ public boolean isSupported(BlackboardArtifact artifact) {
     }
 
     /**
-     * This class is a container to hold the data necessary for the artifact 
+     * This class is a container to hold the data necessary for the artifact
      * being viewed.
      */
     private class ResultsTableArtifact {
@@ -340,20 +339,20 @@ private void addRows(BlackboardArtifact artifact) {
                      */
                     String value;
                     switch (attr.getAttributeType().getValueType()) {
-         
+
                         // Use Autopsy date formatting settings, not TSK defaults
                         case DATETIME:
                             value = epochTimeToString(attr.getValueLong());
                             break;
-                        case JSON: 
+                        case JSON:
                             // Get the attribute's JSON value and convert to indented multiline display string
                             String jsonVal = attr.getValueString();
                             JsonParser parser = new JsonParser();
                             JsonObject json = parser.parse(jsonVal).getAsJsonObject();
-                           
+
                             value = toJsonDisplayString(json, "");
                             break;
-                            
+
                         case STRING:
                         case INTEGER:
                         case LONG:
@@ -398,43 +397,43 @@ private void addRows(BlackboardArtifact artifact) {
         String getArtifactDisplayName() {
             return artifactDisplayName;
         }
-        
+
         private static final String INDENT_RIGHT = "    ";
         private static final String NEW_LINE = "\n";
-            
+
         /**
          * Recursively converts a JSON element into an indented multi-line
          * display string.
          *
-         * @param element JSON element to convert
+         * @param element     JSON element to convert
          * @param startIndent Starting indentation for the element.
          *
          * @return A multi-line display string.
          */
         private String toJsonDisplayString(JsonElement element, String startIndent) {
-           
+
             StringBuilder sb = new StringBuilder("");
             JsonObject obj = element.getAsJsonObject();
 
             for (Map.Entry<String, JsonElement> entry : obj.entrySet()) {
-                appendJsonElementToString(entry.getKey(), entry.getValue(), startIndent, sb );
+                appendJsonElementToString(entry.getKey(), entry.getValue(), startIndent, sb);
             }
 
             String returnString = sb.toString();
-            if (startIndent.length() == 0 &&  returnString.startsWith(NEW_LINE)) {
+            if (startIndent.length() == 0 && returnString.startsWith(NEW_LINE)) {
                 returnString = returnString.substring(NEW_LINE.length());
             }
             return returnString;
         }
-        
-       
+
         /**
-         * Converts the given JSON element into string and appends to the given string builder.
-         * 
+         * Converts the given JSON element into string and appends to the given
+         * string builder.
+         *
          * @param jsonKey
          * @param jsonElement
          * @param startIndent Starting indentation for the element.
-         * @param sb String builder to append to.
+         * @param sb          String builder to append to.
          */
         private void appendJsonElementToString(String jsonKey, JsonElement jsonElement, String startIndent, StringBuilder sb) {
             if (jsonElement.isJsonArray()) {
@@ -463,11 +462,12 @@ private void appendJsonElementToString(String jsonKey, JsonElement jsonElement,
                 sb.append(NEW_LINE).append(String.format("%s%s = null", startIndent, jsonKey));
             }
         }
-        
+
         /**
          * Converts epoch time to readable string.
-         * 
+         *
          * @param epochTime epoch time value to be converted to string.
+         *
          * @return String with human readable time.
          */
         private String epochTimeToString(long epochTime) {
@@ -482,21 +482,20 @@ private String epochTimeToString(long epochTime) {
     }
 
     /**
-     * Updates the table view with the given artifact data. 
-     * 
+     * Updates the table view with the given artifact data.
+     *
      * It should be called on EDT.
      *
      * @param resultsTableArtifact Artifact data to display in the view.
      */
     private void updateView(ResultsTableArtifact resultsTableArtifact) {
         this.setCursor(Cursor.getPredefinedCursor(Cursor.WAIT_CURSOR));
-
         DefaultTableModel tModel = ((DefaultTableModel) resultsTable.getModel());
-        tModel.setDataVector(resultsTableArtifact.getRows(), COLUMN_HEADERS);
+        String[][] rows = resultsTableArtifact == null ? new String[0][0] : resultsTableArtifact.getRows();
+        tModel.setDataVector(rows, COLUMN_HEADERS);
         updateColumnSizes();
         updateRowHeights();
         resultsTable.clearSelection();
-
         this.setCursor(null);
     }
 
diff --git a/Core/src/org/sleuthkit/autopsy/corecomponents/DataContentViewerArtifact.java b/Core/src/org/sleuthkit/autopsy/corecomponents/DataContentViewerArtifact.java
index 6ccc70c92033fb09155daed9f161265dd7639c28..b015dbf3adfbaac246e7a27d590a932be5adee15 100644
--- a/Core/src/org/sleuthkit/autopsy/corecomponents/DataContentViewerArtifact.java
+++ b/Core/src/org/sleuthkit/autopsy/corecomponents/DataContentViewerArtifact.java
@@ -357,7 +357,9 @@ public int isPreferred(Node node) {
                 || (artifact.getArtifactTypeID() == ARTIFACT_TYPE.TSK_INTERESTING_FILE_HIT.getTypeID())
                 || (artifact.getArtifactTypeID() == ARTIFACT_TYPE.TSK_OBJECT_DETECTED.getTypeID())
                 || (artifact.getArtifactTypeID() == ARTIFACT_TYPE.TSK_METADATA_EXIF.getTypeID())
-                || (artifact.getArtifactTypeID() == ARTIFACT_TYPE.TSK_EXT_MISMATCH_DETECTED.getTypeID())) {
+                || (artifact.getArtifactTypeID() == ARTIFACT_TYPE.TSK_EXT_MISMATCH_DETECTED.getTypeID())
+                || (artifact.getArtifactTypeID() == ARTIFACT_TYPE.TSK_WEB_DOWNLOAD.getTypeID())
+                || (artifact.getArtifactTypeID() == ARTIFACT_TYPE.TSK_WEB_CACHE.getTypeID())) {
             return 3;
         } else {
             return 6;
diff --git a/Core/src/org/sleuthkit/autopsy/coreutils/XMLUtil.java b/Core/src/org/sleuthkit/autopsy/coreutils/XMLUtil.java
index 966eb919e77086f900269704e0cf1bd9559c188b..4323753515a89d65e5704846d833befa0bad3b3e 100644
--- a/Core/src/org/sleuthkit/autopsy/coreutils/XMLUtil.java
+++ b/Core/src/org/sleuthkit/autopsy/coreutils/XMLUtil.java
@@ -1,7 +1,7 @@
 /*
  * Autopsy Forensic Browser
  *
- * Copyright 2012-2014 Basis Technology Corp.
+ * Copyright 2012-2020 Basis Technology Corp.
  * Contact: carrier <at> sleuthkit <dot> org
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
@@ -54,6 +54,40 @@
  * -Loading documents from disk
  */
 public class XMLUtil {
+    
+    private static DocumentBuilder getDocumentBuilder() throws ParserConfigurationException {
+        // See JIRA-6958 for details about class loading and jaxb.
+        ClassLoader original = Thread.currentThread().getContextClassLoader();
+        try {
+            Thread.currentThread().setContextClassLoader(XMLUtil.class.getClassLoader());
+            DocumentBuilderFactory builderFactory = DocumentBuilderFactory.newInstance();
+            return builderFactory.newDocumentBuilder();
+        } finally {
+            Thread.currentThread().setContextClassLoader(original);
+        }
+    }
+    
+    private static SchemaFactory getSchemaFactory(String schemaLanguage) {
+        // See JIRA-6958 for details about class loading and jaxb.
+        ClassLoader original = Thread.currentThread().getContextClassLoader();
+        try {
+            Thread.currentThread().setContextClassLoader(XMLUtil.class.getClassLoader());
+            return SchemaFactory.newInstance(schemaLanguage);
+        } finally {
+            Thread.currentThread().setContextClassLoader(original);
+        }
+    }
+    
+    private static TransformerFactory getTransformerFactory() {
+        // See JIRA-6958 for details about class loading and jaxb.
+        ClassLoader original = Thread.currentThread().getContextClassLoader();
+        try {
+            Thread.currentThread().setContextClassLoader(XMLUtil.class.getClassLoader());
+            return TransformerFactory.newInstance();
+        } finally {
+            Thread.currentThread().setContextClassLoader(original);
+        }
+    }
 
     /**
      * Creates a W3C DOM.
@@ -63,9 +97,7 @@ public class XMLUtil {
      * @throws ParserConfigurationException
      */
     public static Document createDocument() throws ParserConfigurationException {
-        DocumentBuilderFactory builderFactory = DocumentBuilderFactory.newInstance();
-        DocumentBuilder builder = builderFactory.newDocumentBuilder();
-        return builder.newDocument();
+        return getDocumentBuilder().newDocument();
     }
 
     /**
@@ -100,8 +132,7 @@ public static <T> Document loadDocument(String docPath, Class<T> clazz, String s
      * @throws IOException
      */
     public static Document loadDocument(String docPath) throws ParserConfigurationException, SAXException, IOException {
-        DocumentBuilderFactory builderFactory = DocumentBuilderFactory.newInstance();
-        DocumentBuilder builder = builderFactory.newDocumentBuilder();
+        DocumentBuilder builder = getDocumentBuilder();
         Document doc = builder.parse(new FileInputStream(docPath));
         return doc;
     }
@@ -119,7 +150,7 @@ public static Document loadDocument(String docPath) throws ParserConfigurationEx
     public static <T> void validateDocument(final Document doc, Class<T> clazz, String schemaResourceName) throws SAXException, IOException {
         PlatformUtil.extractResourceToUserConfigDir(clazz, schemaResourceName, false);
         File schemaFile = new File(Paths.get(PlatformUtil.getUserConfigDirectory(), schemaResourceName).toAbsolutePath().toString());
-        SchemaFactory schemaFactory = SchemaFactory.newInstance(XMLConstants.W3C_XML_SCHEMA_NS_URI);
+        SchemaFactory schemaFactory = getSchemaFactory(XMLConstants.W3C_XML_SCHEMA_NS_URI);
         Schema schema = schemaFactory.newSchema(schemaFile);
         Validator validator = schema.newValidator();
         validator.validate(new DOMSource(doc), new DOMResult());
@@ -140,7 +171,7 @@ public static <T> void validateDocument(final Document doc, Class<T> clazz, Stri
      * @throws IOException
      */
     public static void saveDocument(final Document doc, String encoding, String docPath) throws TransformerConfigurationException, FileNotFoundException, UnsupportedEncodingException, TransformerException, IOException {
-        TransformerFactory xf = TransformerFactory.newInstance();
+        TransformerFactory xf = getTransformerFactory();
         xf.setAttribute("indent-number", 1); //NON-NLS
         Transformer xformer = xf.newTransformer();
         xformer.setOutputProperty(OutputKeys.METHOD, "xml"); //NON-NLS
@@ -178,7 +209,7 @@ public static <T> boolean xmlIsValid(DOMSource xmlfile, Class<T> clazz, String s
         try {
             PlatformUtil.extractResourceToUserConfigDir(clazz, schemaFile, false);
             File schemaLoc = new File(PlatformUtil.getUserConfigDirectory() + File.separator + schemaFile);
-            SchemaFactory schm = SchemaFactory.newInstance(XMLConstants.W3C_XML_SCHEMA_NS_URI);
+            SchemaFactory schm = getSchemaFactory(XMLConstants.W3C_XML_SCHEMA_NS_URI);
             try {
                 Schema schema = schm.newSchema(schemaLoc);
                 Validator validator = schema.newValidator();
@@ -226,10 +257,9 @@ public static <T> boolean xmlIsValid(Document doc, Class<T> clazz, String type)
      */
     // TODO: Deprecate.
     public static <T> Document loadDoc(Class<T> clazz, String xmlPath) {
-        DocumentBuilderFactory builderFactory = DocumentBuilderFactory.newInstance();
         Document ret = null;
         try {
-            DocumentBuilder builder = builderFactory.newDocumentBuilder();
+            DocumentBuilder builder = getDocumentBuilder();
             ret = builder.parse(new FileInputStream(xmlPath));
         } catch (ParserConfigurationException e) {
             Logger.getLogger(clazz.getName()).log(Level.SEVERE, "Error loading XML file " + xmlPath + " : can't initialize parser.", e); //NON-NLS
@@ -268,7 +298,7 @@ public static <T> Document loadDoc(Class<T> clazz, String xmlPath, String xsdPat
      */
     // TODO: Deprecate.
     public static <T> boolean saveDoc(Class<T> clazz, String xmlPath, String encoding, final Document doc) {
-        TransformerFactory xf = TransformerFactory.newInstance();
+        TransformerFactory xf = getTransformerFactory();
         xf.setAttribute("indent-number", 1); //NON-NLS
         boolean success = false;
         try {
diff --git a/Core/src/org/sleuthkit/autopsy/datamodel/AbstractAbstractFileNode.java b/Core/src/org/sleuthkit/autopsy/datamodel/AbstractAbstractFileNode.java
index 74ac603e0b66ad179dd65e3a0d29c9ae7817b08d..ee862eee96b6bbda28873e2b1898354ddae1cc4f 100644
--- a/Core/src/org/sleuthkit/autopsy/datamodel/AbstractAbstractFileNode.java
+++ b/Core/src/org/sleuthkit/autopsy/datamodel/AbstractAbstractFileNode.java
@@ -278,6 +278,7 @@ protected synchronized Sheet createSheet() {
         "AbstractAbstractFileNode.typeMetaColLbl=Type(Meta)",
         "AbstractAbstractFileNode.knownColLbl=Known",
         "AbstractAbstractFileNode.md5HashColLbl=MD5 Hash",
+        "AbstractAbstractFileNode.sha256HashColLbl=SHA-256 Hash",
         "AbstractAbstractFileNode.objectId=Object ID",
         "AbstractAbstractFileNode.mimeType=MIME Type",
         "AbstractAbstractFileNode.extensionColLbl=Extension"})
@@ -305,6 +306,7 @@ public enum AbstractFilePropertyType {
         TYPE_META(AbstractAbstractFileNode_typeMetaColLbl()),
         KNOWN(AbstractAbstractFileNode_knownColLbl()),
         MD5HASH(AbstractAbstractFileNode_md5HashColLbl()),
+        SHA256HASH(AbstractAbstractFileNode_sha256HashColLbl()),
         ObjectID(AbstractAbstractFileNode_objectId()),
         MIMETYPE(AbstractAbstractFileNode_mimeType()),
         EXTENSION(AbstractAbstractFileNode_extensionColLbl());
@@ -358,6 +360,7 @@ private List<NodeProperty<?>> getProperties() {
         properties.add(new NodeProperty<>(KNOWN.toString(), KNOWN.toString(), NO_DESCR, content.getKnown().getName()));
         properties.add(new NodeProperty<>(LOCATION.toString(), LOCATION.toString(), NO_DESCR, getContentPath(content)));
         properties.add(new NodeProperty<>(MD5HASH.toString(), MD5HASH.toString(), NO_DESCR, StringUtils.defaultString(content.getMd5Hash())));
+        properties.add(new NodeProperty<>(SHA256HASH.toString(), SHA256HASH.toString(), NO_DESCR, StringUtils.defaultString(content.getSha256Hash())));
         properties.add(new NodeProperty<>(MIMETYPE.toString(), MIMETYPE.toString(), NO_DESCR, StringUtils.defaultString(content.getMIMEType())));
         properties.add(new NodeProperty<>(EXTENSION.toString(), EXTENSION.toString(), NO_DESCR, content.getNameExtension()));
 
@@ -577,6 +580,7 @@ static public void fillPropertyMap(Map<String, Object> map, AbstractFile content
         map.put(FLAGS_META.toString(), content.getMetaFlagsAsString());
         map.put(KNOWN.toString(), content.getKnown().getName());
         map.put(MD5HASH.toString(), StringUtils.defaultString(content.getMd5Hash()));
+        map.put(SHA256HASH.toString(), StringUtils.defaultString(content.getSha256Hash()));
         map.put(MIMETYPE.toString(), StringUtils.defaultString(content.getMIMEType()));
         map.put(EXTENSION.toString(), content.getNameExtension());
     }
diff --git a/Core/src/org/sleuthkit/autopsy/datamodel/BlackboardArtifactNode.java b/Core/src/org/sleuthkit/autopsy/datamodel/BlackboardArtifactNode.java
index c7ccf32099778d26c471c575c7d12b2eb020c2b8..34a6859292b5fbd1e4f0ac6a95b89dab84a6b24e 100644
--- a/Core/src/org/sleuthkit/autopsy/datamodel/BlackboardArtifactNode.java
+++ b/Core/src/org/sleuthkit/autopsy/datamodel/BlackboardArtifactNode.java
@@ -128,8 +128,8 @@ public class BlackboardArtifactNode extends AbstractContentNode<BlackboardArtifa
     };
 
     private final BlackboardArtifact artifact;
-    private Content srcContent;  
-    private volatile String translatedSourceName; 
+    private Content srcContent;
+    private volatile String translatedSourceName;
 
     /*
      * A method has been provided to allow the injection of properties into this
@@ -284,17 +284,49 @@ public BlackboardArtifactNode(BlackboardArtifact artifact) {
      */
     private static Lookup createLookup(BlackboardArtifact artifact) {
         final long objectID = artifact.getObjectID();
+        Content content = null;
         try {
-            Content content = contentCache.get(objectID, () -> artifact.getSleuthkitCase().getContentById(objectID));
+            if (artifact.getArtifactTypeID() == BlackboardArtifact.ARTIFACT_TYPE.TSK_WEB_DOWNLOAD.getTypeID() || artifact.getArtifactTypeID() == BlackboardArtifact.ARTIFACT_TYPE.TSK_WEB_CACHE.getTypeID()) {
+                content = getPathIdFile(artifact);
+            }
             if (content == null) {
-                return Lookups.fixed(artifact);
-            } else {
-                return Lookups.fixed(artifact, content);
+                content = contentCache.get(objectID, () -> artifact.getSleuthkitCase().getContentById(objectID));
             }
         } catch (ExecutionException ex) {
             logger.log(Level.SEVERE, MessageFormat.format("Error getting source content (artifact objID={0}", artifact.getId()), ex); //NON-NLS
+            content = null;
+        }
+        if (content == null) {
             return Lookups.fixed(artifact);
+        } else {
+            return Lookups.fixed(artifact, content);
+        }
+
+    }
+
+    /**
+     * Private helper method to allow content specified in a path id attribute
+     * to be retrieved.
+     *
+     * @param artifact The artifact for which content may be specified as a tsk
+     *                 path attribute.
+     *
+     * @return The Content specified by the artifact's path id attribute or null
+     *         if there was no content available.
+     *
+     * @throws ExecutionException Error retrieving the file specified by the
+     *                            path id from the cache.
+     */
+    private static Content getPathIdFile(BlackboardArtifact artifact) throws ExecutionException {
+        try {
+            BlackboardAttribute attribute = artifact.getAttribute(new BlackboardAttribute.Type(BlackboardAttribute.ATTRIBUTE_TYPE.TSK_PATH_ID));
+            if (attribute != null) {
+                return contentCache.get(attribute.getValueLong(), () -> artifact.getSleuthkitCase().getContentById(attribute.getValueLong()));
+            }
+        } catch (TskCoreException ex) {
+            logger.log(Level.WARNING, MessageFormat.format("Error getting content for path id attrbiute for artifact: ", artifact.getId()), ex); //NON-NLS
         }
+        return null;
     }
 
     /**
diff --git a/Core/src/org/sleuthkit/autopsy/datamodel/Bundle.properties-MERGED b/Core/src/org/sleuthkit/autopsy/datamodel/Bundle.properties-MERGED
index e7310882a37daace90eed9e1148dcacee3579889..4e87eab218db8262498b8c07a956b27909ee939d 100755
--- a/Core/src/org/sleuthkit/autopsy/datamodel/Bundle.properties-MERGED
+++ b/Core/src/org/sleuthkit/autopsy/datamodel/Bundle.properties-MERGED
@@ -30,6 +30,7 @@ AbstractAbstractFileNode.modifiedTimeColLbl=Modified Time
 AbstractAbstractFileNode.nameColLbl=Name
 AbstractAbstractFileNode.objectId=Object ID
 AbstractAbstractFileNode.originalName=Original Name
+AbstractAbstractFileNode.sha256HashColLbl=SHA-256 Hash
 AbstractAbstractFileNode.sizeColLbl=Size
 AbstractAbstractFileNode.tagsProperty.displayName=Tags
 AbstractAbstractFileNode.typeDirColLbl=Type(Dir)
diff --git a/Core/src/org/sleuthkit/autopsy/datamodel/accounts/Accounts.java b/Core/src/org/sleuthkit/autopsy/datamodel/accounts/Accounts.java
index 8e9a17931e685fabbb3d4ea4feb1102837d9c8f6..3c5f94f34c0fac743e60e586ed06b496fc842057 100644
--- a/Core/src/org/sleuthkit/autopsy/datamodel/accounts/Accounts.java
+++ b/Core/src/org/sleuthkit/autopsy/datamodel/accounts/Accounts.java
@@ -529,9 +529,11 @@ protected boolean createKeys(List<Long> list) {
                     + getRejectedArtifactFilterClause(); //NON-NLS
             try (SleuthkitCase.CaseDbQuery results = skCase.executeQuery(query);
                     ResultSet rs = results.getResultSet();) {
+                List<Long> tempList = new ArrayList<>();
                 while (rs.next()) {
-                    list.add(rs.getLong("artifact_id")); //NON-NLS
+                    tempList.add(rs.getLong("artifact_id")); // NON-NLS
                 }
+                list.addAll(tempList);
             } catch (TskCoreException | SQLException ex) {
                 LOGGER.log(Level.SEVERE, "Error querying for account artifacts.", ex); //NON-NLS
             }
diff --git a/Core/src/org/sleuthkit/autopsy/datasourcesummary/datamodel/DataSourceInfoUtilities.java b/Core/src/org/sleuthkit/autopsy/datasourcesummary/datamodel/DataSourceInfoUtilities.java
index 649c15940a0ab436c8a618657220c89b16afd677..c711f3c9a0459a4c754eb03ca8ac2137d0ddeba2 100644
--- a/Core/src/org/sleuthkit/autopsy/datasourcesummary/datamodel/DataSourceInfoUtilities.java
+++ b/Core/src/org/sleuthkit/autopsy/datasourcesummary/datamodel/DataSourceInfoUtilities.java
@@ -398,6 +398,20 @@ static Long getLongOrNull(BlackboardArtifact artifact, Type attributeType) {
         BlackboardAttribute attr = getAttributeOrNull(artifact, attributeType);
         return (attr == null) ? null : attr.getValueLong();
     }
+    
+    /**
+     * Retrieves the int value of a certain attribute type from an artifact.
+     *
+     * @param artifact      The artifact.
+     * @param attributeType The attribute type.
+     *
+     * @return The 'getValueInt()' value or null if the attribute could not be
+     *         retrieved.
+     */
+    static Integer getIntOrNull(BlackboardArtifact artifact, Type attributeType) {
+        BlackboardAttribute attr = getAttributeOrNull(artifact, attributeType);
+        return (attr == null) ? null : attr.getValueInt();
+    }
 
     /**
      * Retrieves the long value of a certain attribute type from an artifact and
diff --git a/Core/src/org/sleuthkit/autopsy/datasourcesummary/datamodel/RecentFilesSummary.java b/Core/src/org/sleuthkit/autopsy/datasourcesummary/datamodel/RecentFilesSummary.java
index 0a47e0ea6c84bb01cb01a51ac020feb731794ac5..6c4601af88bb0ba46ad2e904f0b6484da334679c 100755
--- a/Core/src/org/sleuthkit/autopsy/datasourcesummary/datamodel/RecentFilesSummary.java
+++ b/Core/src/org/sleuthkit/autopsy/datasourcesummary/datamodel/RecentFilesSummary.java
@@ -118,7 +118,7 @@ public List<RecentFileDetails> getRecentlyOpenedDocuments(DataSource dataSource,
                         dataSource,
                         DATETIME_ATT,
                         DataSourceInfoUtilities.SortOrder.DESCENDING,
-                        10);
+                        maxCount);
 
         List<RecentFileDetails> fileDetails = new ArrayList<>();
         for (BlackboardArtifact artifact : artifactList) {
@@ -134,12 +134,11 @@ public List<RecentFileDetails> getRecentlyOpenedDocuments(DataSource dataSource,
                 } else if (attribute.getAttributeType().equals(PATH_ATT)) {
                     path = attribute.getValueString();
                 }
-
-                if (accessedTime != null) {
-                    fileDetails.add(new RecentFileDetails(path, accessedTime));
-                }
             }
 
+            if (accessedTime != null && accessedTime != 0) {
+                fileDetails.add(new RecentFileDetails(path, accessedTime));
+            }
         }
 
         return fileDetails;
@@ -190,7 +189,7 @@ public List<RecentDownloadDetails> getRecentDownloads(DataSource dataSource, int
                     path = attribute.getValueString();
                 }
             }
-            if (accessedTime != null) {
+            if (accessedTime != null && accessedTime != 0L) {
                 fileDetails.add(new RecentDownloadDetails(path, accessedTime, domain));
             }
         }
@@ -215,6 +214,10 @@ public List<RecentAttachmentDetails> getRecentAttachments(DataSource dataSource,
             return Collections.emptyList();
         }
 
+        if (maxCount < 0) {
+            throw new IllegalArgumentException("Invalid maxCount passed to getRecentAttachments, value must be equal to or greater than 0");
+        }
+
         return createListFromMap(buildAttachmentMap(dataSource), maxCount);
     }
 
@@ -241,7 +244,7 @@ private SortedMap<Long, List<RecentAttachmentDetails>> buildAttachmentMap(DataSo
             }
 
             BlackboardArtifact messageArtifact = skCase.getBlackboardArtifact(attribute.getValueLong());
-            if (isMessageArtifact(messageArtifact)) {
+            if (messageArtifact != null && isMessageArtifact(messageArtifact)) {
                 Content content = artifact.getParent();
                 if (content instanceof AbstractFile) {
                     String sender;
diff --git a/Core/src/org/sleuthkit/autopsy/datasourcesummary/datamodel/TimelineSummary.java b/Core/src/org/sleuthkit/autopsy/datasourcesummary/datamodel/TimelineSummary.java
new file mode 100644
index 0000000000000000000000000000000000000000..1d634211beb9ea09bac7641a9eff37b66ddca700
--- /dev/null
+++ b/Core/src/org/sleuthkit/autopsy/datasourcesummary/datamodel/TimelineSummary.java
@@ -0,0 +1,318 @@
+/*
+ * Autopsy Forensic Browser
+ *
+ * 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.autopsy.datasourcesummary.datamodel;
+
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.TimeZone;
+import org.joda.time.Interval;
+import org.sleuthkit.autopsy.datasourcesummary.datamodel.SleuthkitCaseProvider.SleuthkitCaseProviderException;
+import org.sleuthkit.autopsy.datasourcesummary.uiutils.DefaultUpdateGovernor;
+import org.sleuthkit.autopsy.ingest.IngestManager;
+import org.sleuthkit.autopsy.ingest.ModuleContentEvent;
+import org.sleuthkit.datamodel.AbstractFile;
+import org.sleuthkit.datamodel.DataSource;
+import org.sleuthkit.datamodel.TimelineEvent;
+import org.sleuthkit.datamodel.TimelineEventType;
+import org.sleuthkit.datamodel.TimelineFilter;
+import org.sleuthkit.datamodel.TimelineFilter.DataSourcesFilter;
+import org.sleuthkit.datamodel.TimelineFilter.RootFilter;
+import org.sleuthkit.datamodel.TimelineManager;
+import org.sleuthkit.datamodel.TskCoreException;
+import java.util.function.Supplier;
+import org.sleuthkit.autopsy.core.UserPreferences;
+
+/**
+ * Provides data source summary information pertaining to Timeline data.
+ */
+public class TimelineSummary implements DefaultUpdateGovernor {
+
+    private static final long DAY_SECS = 24 * 60 * 60;
+    private static final Set<IngestManager.IngestJobEvent> INGEST_JOB_EVENTS = new HashSet<>(
+            Arrays.asList(IngestManager.IngestJobEvent.COMPLETED, IngestManager.IngestJobEvent.CANCELLED));
+
+    private static final Set<TimelineEventType> FILE_SYSTEM_EVENTS
+            = new HashSet<>(Arrays.asList(
+                    TimelineEventType.FILE_MODIFIED,
+                    TimelineEventType.FILE_ACCESSED,
+                    TimelineEventType.FILE_CREATED,
+                    TimelineEventType.FILE_CHANGED));
+
+    private final SleuthkitCaseProvider caseProvider;
+    private final Supplier<TimeZone> timeZoneProvider;
+
+    /**
+     * Default constructor.
+     */
+    public TimelineSummary() {
+        this(SleuthkitCaseProvider.DEFAULT, () -> TimeZone.getTimeZone(UserPreferences.getTimeZoneForDisplays()));
+    }
+
+    /**
+     * Construct object with given SleuthkitCaseProvider
+     *
+     * @param caseProvider SleuthkitCaseProvider provider, cannot be null.
+     * @param timeZoneProvider The timezone provider, cannot be null.
+     */
+    public TimelineSummary(SleuthkitCaseProvider caseProvider, Supplier<TimeZone> timeZoneProvider) {
+        this.caseProvider = caseProvider;
+        this.timeZoneProvider = timeZoneProvider;
+    }
+
+    @Override
+    public boolean isRefreshRequired(ModuleContentEvent evt) {
+        return true;
+    }
+
+    @Override
+    public boolean isRefreshRequired(AbstractFile file) {
+        return true;
+    }
+
+    @Override
+    public boolean isRefreshRequired(IngestManager.IngestJobEvent evt) {
+        return (evt != null && INGEST_JOB_EVENTS.contains(evt));
+    }
+
+    @Override
+    public Set<IngestManager.IngestJobEvent> getIngestJobEventUpdates() {
+        return INGEST_JOB_EVENTS;
+    }
+
+    /**
+     * Retrieves timeline summary data.
+     *
+     * @param dataSource The data source for which timeline data will be
+     * retrieved.
+     * @param recentDaysNum The maximum number of most recent days' activity to
+     * include.
+     * @return The retrieved data.
+     * @throws SleuthkitCaseProviderException
+     * @throws TskCoreException
+     */
+    public TimelineSummaryData getData(DataSource dataSource, int recentDaysNum) throws SleuthkitCaseProviderException, TskCoreException {
+        TimeZone timeZone = this.timeZoneProvider.get();
+        TimelineManager timelineManager = this.caseProvider.get().getTimelineManager();
+
+        // get a mapping of days from epoch to the activity for that day
+        Map<Long, DailyActivityAmount> dateCounts = getTimelineEventsByDay(dataSource, timelineManager, timeZone);
+
+        // get minimum and maximum usage date by iterating through 
+        Long minDay = null;
+        Long maxDay = null;
+        for (long daysFromEpoch : dateCounts.keySet()) {
+            minDay = (minDay == null) ? daysFromEpoch : Math.min(minDay, daysFromEpoch);
+            maxDay = (maxDay == null) ? daysFromEpoch : Math.max(maxDay, daysFromEpoch);
+        }
+
+        // if no min date or max date, no usage; return null.
+        if (minDay == null || maxDay == null) {
+            return null;
+        }
+
+        Date minDate = new Date(minDay * 1000 * DAY_SECS);
+        Date maxDate = new Date(maxDay * 1000 * DAY_SECS);
+
+        // The minimum recent day will be within recentDaysNum from the maximum day 
+        // (+1 since maxDay included) or the minimum day of activity
+        long minRecentDay = Math.max(maxDay - recentDaysNum + 1, minDay);
+
+        // get most recent days activity
+        List<DailyActivityAmount> mostRecentActivityAmt = getMostRecentActivityAmounts(dateCounts, minRecentDay, maxDay);
+
+        return new TimelineSummaryData(minDate, maxDate, mostRecentActivityAmt);
+    }
+
+    /**
+     * Given activity by day, converts to most recent days' activity handling
+     * empty values.
+     *
+     * @param dateCounts The day from epoch mapped to activity amounts for that
+     * day.
+     * @param minRecentDay The minimum recent day in days from epoch.
+     * @param maxDay The maximum recent day in days from epoch;
+     * @return The most recent daily activity amounts.
+     */
+    private List<DailyActivityAmount> getMostRecentActivityAmounts(Map<Long, DailyActivityAmount> dateCounts, long minRecentDay, long maxDay) {
+        List<DailyActivityAmount> mostRecentActivityAmt = new ArrayList<>();
+
+        for (long curRecentDay = minRecentDay; curRecentDay <= maxDay; curRecentDay++) {
+            DailyActivityAmount prevCounts = dateCounts.get(curRecentDay);
+            DailyActivityAmount countsHandleNotFound = prevCounts != null
+                    ? prevCounts
+                    : new DailyActivityAmount(new Date(curRecentDay * DAY_SECS * 1000), 0, 0);
+
+            mostRecentActivityAmt.add(countsHandleNotFound);
+        }
+        return mostRecentActivityAmt;
+    }
+
+    /**
+     * Fetches timeline events per day for a particular data source.
+     *
+     * @param dataSource The data source.
+     * @param timelineManager The timeline manager to use while fetching the
+     * data.
+     * @param timeZone The time zone to use to determine which day activity
+     * belongs.
+     * @return A Map mapping days from epoch to the activity for that day.
+     * @throws TskCoreException
+     */
+    private Map<Long, DailyActivityAmount> getTimelineEventsByDay(DataSource dataSource, TimelineManager timelineManager, TimeZone timeZone) throws TskCoreException {
+
+        DataSourcesFilter dataSourceFilter = new DataSourcesFilter();
+        dataSourceFilter.addSubFilter(new TimelineFilter.DataSourceFilter(dataSource.getName(), dataSource.getId()));
+
+        RootFilter dataSourceRootFilter = new RootFilter(
+                null,
+                null,
+                null,
+                null,
+                null,
+                dataSourceFilter,
+                null,
+                Collections.emptySet());
+
+        // get events for data source
+        long curRunTime = System.currentTimeMillis();
+        List<TimelineEvent> events = timelineManager.getEvents(new Interval(1, curRunTime), dataSourceRootFilter);
+
+        // get counts of events per day (left is file system events, right is everything else)
+        Map<Long, DailyActivityAmount> dateCounts = new HashMap<>();
+        for (TimelineEvent evt : events) {
+            long curSecondsFromEpoch = evt.getTime();
+            long curDaysFromEpoch = Instant.ofEpochMilli(curSecondsFromEpoch * 1000)
+                    .atZone(timeZone.toZoneId())
+                    .toLocalDate()
+                    .toEpochDay();
+
+            DailyActivityAmount prevAmt = dateCounts.get(curDaysFromEpoch);
+            long prevFileEvtCount = prevAmt == null ? 0 : prevAmt.getFileActivityCount();
+            long prevArtifactEvtCount = prevAmt == null ? 0 : prevAmt.getArtifactActivityCount();
+            Date thisDay = prevAmt == null ? new Date(curDaysFromEpoch * 1000 * DAY_SECS) : prevAmt.getDay();
+
+            boolean isFileEvt = FILE_SYSTEM_EVENTS.contains(evt.getEventType());
+            long curFileEvtCount = prevFileEvtCount + (isFileEvt ? 1 : 0);
+            long curArtifactEvtCount = prevArtifactEvtCount + (isFileEvt ? 0 : 1);
+
+            dateCounts.put(curDaysFromEpoch, new DailyActivityAmount(thisDay, curFileEvtCount, curArtifactEvtCount));
+        }
+
+        return dateCounts;
+    }
+
+    /**
+     * All the data to be represented in the timeline summary tab.
+     */
+    public static class TimelineSummaryData {
+
+        private final Date minDate;
+        private final Date maxDate;
+        private final List<DailyActivityAmount> histogramActivity;
+
+        /**
+         * Main constructor.
+         *
+         * @param minDate Earliest usage date recorded for the data source.
+         * @param maxDate Latest usage date recorded for the data source.
+         * @param recentDaysActivity A list of activity prior to and including
+         * the latest usage date by day.
+         */
+        TimelineSummaryData(Date minDate, Date maxDate, List<DailyActivityAmount> recentDaysActivity) {
+            this.minDate = minDate;
+            this.maxDate = maxDate;
+            this.histogramActivity = (recentDaysActivity == null) ? Collections.emptyList() : Collections.unmodifiableList(recentDaysActivity);
+        }
+
+        /**
+         * @return Earliest usage date recorded for the data source.
+         */
+        public Date getMinDate() {
+            return minDate;
+        }
+
+        /**
+         * @return Latest usage date recorded for the data source.
+         */
+        public Date getMaxDate() {
+            return maxDate;
+        }
+
+        /**
+         * @return A list of activity prior to and including the latest usage
+         * date by day.
+         */
+        public List<DailyActivityAmount> getMostRecentDaysActivity() {
+            return histogramActivity;
+        }
+    }
+
+    /**
+     * Represents the amount of usage based on timeline events for a day.
+     */
+    public static class DailyActivityAmount {
+
+        private final Date day;
+        private final long fileActivityCount;
+        private final long artifactActivityCount;
+
+        /**
+         * Main constructor.
+         *
+         * @param day The day for which activity is being measured.
+         * @param fileActivityCount The amount of file activity timeline events.
+         * @param artifactActivityCount The amount of artifact timeline events.
+         */
+        DailyActivityAmount(Date day, long fileActivityCount, long artifactActivityCount) {
+            this.day = day;
+            this.fileActivityCount = fileActivityCount;
+            this.artifactActivityCount = artifactActivityCount;
+        }
+
+        /**
+         * @return The day for which activity is being measured.
+         */
+        public Date getDay() {
+            return day;
+        }
+
+        /**
+         * @return The amount of file activity timeline events.
+         */
+        public long getFileActivityCount() {
+            return fileActivityCount;
+        }
+
+        /**
+         * @return The amount of artifact timeline events.
+         */
+        public long getArtifactActivityCount() {
+            return artifactActivityCount;
+        }
+
+    }
+}
diff --git a/Core/src/org/sleuthkit/autopsy/datasourcesummary/datamodel/TopProgramsSummary.java b/Core/src/org/sleuthkit/autopsy/datasourcesummary/datamodel/TopProgramsSummary.java
deleted file mode 100644
index e984b9efcad437686834abd7e2d04b937e5a7abd..0000000000000000000000000000000000000000
--- a/Core/src/org/sleuthkit/autopsy/datasourcesummary/datamodel/TopProgramsSummary.java
+++ /dev/null
@@ -1,370 +0,0 @@
-/*
- * Autopsy Forensic Browser
- *
- * 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.autopsy.datasourcesummary.datamodel;
-
-import org.sleuthkit.autopsy.datasourcesummary.uiutils.DefaultArtifactUpdateGovernor;
-import java.io.File;
-import java.sql.ResultSet;
-import java.sql.SQLException;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.Collections;
-import java.util.Date;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Set;
-import java.util.function.Function;
-import java.util.stream.Collectors;
-import org.apache.commons.lang.StringUtils;
-import org.sleuthkit.autopsy.datasourcesummary.datamodel.SleuthkitCaseProvider.SleuthkitCaseProviderException;
-import org.sleuthkit.datamodel.BlackboardArtifact;
-import org.sleuthkit.datamodel.BlackboardArtifact.ARTIFACT_TYPE;
-import org.sleuthkit.datamodel.BlackboardAttribute;
-import org.sleuthkit.datamodel.DataSource;
-import org.sleuthkit.datamodel.SleuthkitCase;
-import org.sleuthkit.datamodel.TskCoreException;
-
-/**
- * Provides information to populate Top Programs Summary queries.
- */
-public class TopProgramsSummary implements DefaultArtifactUpdateGovernor {
-
-    private static final Set<Integer> ARTIFACT_UPDATE_TYPE_IDS = new HashSet<>(Arrays.asList(
-            ARTIFACT_TYPE.TSK_PROG_RUN.getTypeID()
-    ));
-
-    /**
-     * A SQL join type.
-     */
-    private enum JoinType {
-        LEFT,
-        RIGHT,
-        INNER,
-        OUTER
-    }
-
-    /**
-     * A blackboard attribute value column.
-     */
-    private enum AttributeColumn {
-        value_text,
-        value_int32,
-        value_int64
-    }
-
-    /**
-     * The suffix joined to a key name for use as an identifier of a query.
-     */
-    private static final String QUERY_SUFFIX = "_query";
-
-    /**
-     * Functions that determine the folder name of a list of path elements. If
-     * not matched, function returns null.
-     */
-    private static final List<Function<List<String>, String>> SHORT_FOLDER_MATCHERS = Arrays.asList(
-            // handle Program Files and Program Files (x86) - if true, return the next folder
-            (pathList) -> {
-                if (pathList.size() < 2) {
-                    return null;
-                }
-
-                String rootParent = pathList.get(0).toUpperCase();
-                if ("PROGRAM FILES".equals(rootParent) || "PROGRAM FILES (X86)".equals(rootParent)) {
-                    return pathList.get(1);
-                } else {
-                    return null;
-                }
-            },
-            // if there is a folder named "APPLICATION DATA" or "APPDATA"
-            (pathList) -> {
-                for (String pathEl : pathList) {
-                    String uppered = pathEl.toUpperCase();
-                    if ("APPLICATION DATA".equals(uppered) || "APPDATA".equals(uppered)) {
-                        return "AppData";
-                    }
-                }
-                return null;
-            }
-    );
-
-    /**
-     * Creates a sql statement querying the blackboard attributes table for a
-     * particular attribute type and returning a specified value. That query
-     * also joins with the blackboard artifact table.
-     *
-     * @param joinType        The type of join statement to create.
-     * @param attributeColumn The blackboard attribute column that should be
-     *                        returned.
-     * @param attrType        The attribute type to query for.
-     * @param keyName         The aliased name of the attribute to return. This
-     *                        is also used to calculate the alias of the query
-     *                        same as getFullKey.
-     * @param bbaName         The blackboard artifact table alias.
-     *
-     * @return The generated sql statement.
-     */
-    private static String getAttributeJoin(JoinType joinType, AttributeColumn attributeColumn, BlackboardAttribute.ATTRIBUTE_TYPE attrType, String keyName, String bbaName) {
-        String queryName = keyName + QUERY_SUFFIX;
-        String innerQueryName = "inner_attribute_" + queryName;
-
-        return "\n" + joinType + " JOIN (\n"
-                + "    SELECT \n"
-                + "        " + innerQueryName + ".artifact_id,\n"
-                + "        " + innerQueryName + "." + attributeColumn + " AS " + keyName + "\n"
-                + "    FROM blackboard_attributes " + innerQueryName + "\n"
-                + "    WHERE " + innerQueryName + ".attribute_type_id = " + attrType.getTypeID() + " -- " + attrType.name() + "\n"
-                + ") " + queryName + " ON " + queryName + ".artifact_id = " + bbaName + ".artifact_id\n";
-    }
-
-    /**
-     * Given a column key, creates the full name for the column key.
-     *
-     * @param key The column key.
-     *
-     * @return The full identifier for the column key.
-     */
-    private static String getFullKey(String key) {
-        return key + QUERY_SUFFIX + "." + key;
-    }
-
-    /**
-     * Constructs a SQL 'where' statement from a list of clauses and puts
-     * parenthesis around each clause.
-     *
-     * @param clauses The clauses
-     *
-     * @return The generated 'where' statement.
-     */
-    private static String getWhereString(List<String> clauses) {
-        if (clauses.isEmpty()) {
-            return "";
-        }
-
-        List<String> parenthesized = clauses.stream()
-                .map(c -> "(" + c + ")")
-                .collect(Collectors.toList());
-
-        return "\nWHERE " + String.join("\n    AND ", parenthesized) + "\n";
-    }
-
-    /**
-     * Generates a [column] LIKE sql clause.
-     *
-     * @param column     The column identifier.
-     * @param likeString The string that will be used as column comparison.
-     * @param isLike     if false, the statement becomes NOT LIKE.
-     *
-     * @return The generated statement.
-     */
-    private static String getLikeClause(String column, String likeString, boolean isLike) {
-        return column + (isLike ? "" : " NOT") + " LIKE '" + likeString + "'";
-    }
-
-    private final SleuthkitCaseProvider provider;
-
-    public TopProgramsSummary() {
-        this(SleuthkitCaseProvider.DEFAULT);
-    }
-
-    public TopProgramsSummary(SleuthkitCaseProvider provider) {
-        this.provider = provider;
-    }
-
-    @Override
-    public Set<Integer> getArtifactTypeIdsForRefresh() {
-        return ARTIFACT_UPDATE_TYPE_IDS;
-    }
-
-    /**
-     * Retrieves a list of the top programs used on the data source. Currently
-     * determines this based off of which prefetch results return the highest
-     * count.
-     *
-     * @param dataSource The data source.
-     * @param count      The number of programs to return.
-     *
-     * @return The top results objects found.
-     *
-     * @throws SleuthkitCaseProviderException
-     * @throws TskCoreException
-     * @throws SQLException
-     */
-    public List<TopProgramsResult> getTopPrograms(DataSource dataSource, int count)
-            throws SleuthkitCaseProviderException, TskCoreException, SQLException {
-        if (dataSource == null || count <= 0) {
-            return Collections.emptyList();
-        }
-
-        // ntosboot should be ignored
-        final String ntosBootIdentifier = "NTOSBOOT";
-        // programs in windows directory to be ignored
-        final String windowsDir = "/WINDOWS%";
-
-        final String nameParam = "name";
-        final String pathParam = "path";
-        final String runCountParam = "run_count";
-        final String lastRunParam = "last_run";
-
-        String bbaQuery = "bba";
-
-        final String query = "SELECT\n"
-                + "    " + getFullKey(nameParam) + " AS " + nameParam + ",\n"
-                + "    " + getFullKey(pathParam) + " AS " + pathParam + ",\n"
-                + "    MAX(" + getFullKey(runCountParam) + ") AS " + runCountParam + ",\n"
-                + "    MAX(" + getFullKey(lastRunParam) + ") AS " + lastRunParam + "\n"
-                + "FROM blackboard_artifacts " + bbaQuery + "\n"
-                + getAttributeJoin(JoinType.INNER, AttributeColumn.value_text, BlackboardAttribute.ATTRIBUTE_TYPE.TSK_PROG_NAME, nameParam, bbaQuery)
-                + getAttributeJoin(JoinType.LEFT, AttributeColumn.value_text, BlackboardAttribute.ATTRIBUTE_TYPE.TSK_PATH, pathParam, bbaQuery)
-                + getAttributeJoin(JoinType.LEFT, AttributeColumn.value_int32, BlackboardAttribute.ATTRIBUTE_TYPE.TSK_COUNT, runCountParam, bbaQuery)
-                + getAttributeJoin(JoinType.LEFT, AttributeColumn.value_int64, BlackboardAttribute.ATTRIBUTE_TYPE.TSK_DATETIME, lastRunParam, bbaQuery)
-                + getWhereString(Arrays.asList(
-                        bbaQuery + ".artifact_type_id = " + BlackboardArtifact.ARTIFACT_TYPE.TSK_PROG_RUN.getTypeID(),
-                        bbaQuery + ".data_source_obj_id = " + dataSource.getId(),
-                        // exclude ntosBootIdentifier from results
-                        getLikeClause(getFullKey(nameParam), ntosBootIdentifier, false),
-                        // exclude windows directory items from results
-                        getFullKey(pathParam) + " IS NULL OR " + getLikeClause(getFullKey(pathParam), windowsDir, false)
-                ))
-                + "GROUP BY " + getFullKey(nameParam) + ", " + getFullKey(pathParam) + "\n"
-                + "ORDER BY \n"
-                + "    MAX(" + getFullKey(runCountParam) + ") DESC,\n"
-                + "    MAX(" + getFullKey(lastRunParam) + ") DESC,\n"
-                + "    " + getFullKey(nameParam) + " ASC";
-
-        DataSourceInfoUtilities.ResultSetHandler<List<TopProgramsResult>> handler = (resultSet) -> {
-            List<TopProgramsResult> progResults = new ArrayList<>();
-
-            boolean quitAtCount = false;
-
-            while (resultSet.next() && (!quitAtCount || progResults.size() < count)) {
-                long lastRunEpoch = resultSet.getLong(lastRunParam);
-                Date lastRun = (resultSet.wasNull()) ? null : new Date(lastRunEpoch * 1000);
-
-                Long runCount = resultSet.getLong(runCountParam);
-                if (resultSet.wasNull()) {
-                    runCount = null;
-                }
-
-                if (lastRun != null || runCount != null) {
-                    quitAtCount = true;
-                }
-
-                progResults.add(new TopProgramsResult(
-                        resultSet.getString(nameParam),
-                        resultSet.getString(pathParam),
-                        runCount,
-                        lastRun));
-            }
-
-            return progResults;
-        };
-
-        try (SleuthkitCase.CaseDbQuery dbQuery = provider.get().executeQuery(query);
-                ResultSet resultSet = dbQuery.getResultSet()) {
-
-            return handler.process(resultSet);
-        }
-    }
-
-    /**
-     * Determines a short folder name if any. Otherwise, returns empty string.
-     *
-     * @param strPath         The string path.
-     * @param applicationName The application name.
-     *
-     * @return The short folder name or empty string if not found.
-     */
-    public String getShortFolderName(String strPath, String applicationName) {
-        if (strPath == null) {
-            return "";
-        }
-
-        List<String> pathEls = new ArrayList<>(Arrays.asList(applicationName));
-
-        File file = new File(strPath);
-        while (file != null && StringUtils.isNotBlank(file.getName())) {
-            pathEls.add(file.getName());
-            file = file.getParentFile();
-        }
-
-        Collections.reverse(pathEls);
-
-        for (Function<List<String>, String> matchEntry : SHORT_FOLDER_MATCHERS) {
-            String result = matchEntry.apply(pathEls);
-            if (StringUtils.isNotBlank(result)) {
-                return result;
-            }
-        }
-
-        return "";
-    }
-
-    /**
-     * Describes a result of a program run on a datasource.
-     */
-    public static class TopProgramsResult {
-
-        private final String programName;
-        private final String programPath;
-        private final Long runTimes;
-        private final Date lastRun;
-
-        /**
-         * Main constructor.
-         *
-         * @param programName The name of the program.
-         * @param programPath The path of the program.
-         * @param runTimes    The number of runs.
-         */
-        TopProgramsResult(String programName, String programPath, Long runTimes, Date lastRun) {
-            this.programName = programName;
-            this.programPath = programPath;
-            this.runTimes = runTimes;
-            this.lastRun = lastRun;
-        }
-
-        /**
-         * @return The name of the program
-         */
-        public String getProgramName() {
-            return programName;
-        }
-
-        /**
-         * @return The path of the program.
-         */
-        public String getProgramPath() {
-            return programPath;
-        }
-
-        /**
-         * @return The number of run times or null if not present.
-         */
-        public Long getRunTimes() {
-            return runTimes;
-        }
-
-        /**
-         * @return The last time the program was run or null if not present.
-         */
-        public Date getLastRun() {
-            return lastRun;
-        }
-    }
-}
diff --git a/Core/src/org/sleuthkit/autopsy/datasourcesummary/datamodel/UserActivitySummary.java b/Core/src/org/sleuthkit/autopsy/datasourcesummary/datamodel/UserActivitySummary.java
index 481840776fb7f2540d8e55874ec414876edc4295..16da6f5c4bf1f41b01d2198c80f2b52f680e387e 100644
--- a/Core/src/org/sleuthkit/autopsy/datasourcesummary/datamodel/UserActivitySummary.java
+++ b/Core/src/org/sleuthkit/autopsy/datasourcesummary/datamodel/UserActivitySummary.java
@@ -18,6 +18,7 @@
  */
 package org.sleuthkit.autopsy.datasourcesummary.datamodel;
 
+import java.io.File;
 import org.sleuthkit.autopsy.datasourcesummary.uiutils.DefaultArtifactUpdateGovernor;
 import java.util.ArrayList;
 import java.util.Arrays;
@@ -30,6 +31,7 @@
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
+import java.util.function.Function;
 import java.util.logging.Level;
 import java.util.stream.Collectors;
 import java.util.stream.Stream;
@@ -54,6 +56,36 @@
  */
 public class UserActivitySummary implements DefaultArtifactUpdateGovernor {
 
+    /**
+     * Functions that determine the folder name of a list of path elements. If
+     * not matched, function returns null.
+     */
+    private static final List<Function<List<String>, String>> SHORT_FOLDER_MATCHERS = Arrays.asList(
+            // handle Program Files and Program Files (x86) - if true, return the next folder
+            (pathList) -> {
+                if (pathList.size() < 2) {
+                    return null;
+                }
+
+                String rootParent = pathList.get(0).toUpperCase();
+                if ("PROGRAM FILES".equals(rootParent) || "PROGRAM FILES (X86)".equals(rootParent)) {
+                    return pathList.get(1);
+                } else {
+                    return null;
+                }
+            },
+            // if there is a folder named "APPLICATION DATA" or "APPDATA"
+            (pathList) -> {
+                for (String pathEl : pathList) {
+                    String uppered = pathEl.toUpperCase();
+                    if ("APPLICATION DATA".equals(uppered) || "APPDATA".equals(uppered)) {
+                        return "AppData";
+                    }
+                }
+                return null;
+            }
+    );
+
     private static final BlackboardArtifact.Type TYPE_DEVICE_ATTACHED = new BlackboardArtifact.Type(ARTIFACT_TYPE.TSK_DEVICE_ATTACHED);
     private static final BlackboardArtifact.Type TYPE_WEB_HISTORY = new BlackboardArtifact.Type(ARTIFACT_TYPE.TSK_WEB_HISTORY);
 
@@ -69,17 +101,51 @@ public class UserActivitySummary implements DefaultArtifactUpdateGovernor {
     private static final BlackboardAttribute.Type TYPE_DATETIME_START = new BlackboardAttribute.Type(ATTRIBUTE_TYPE.TSK_DATETIME_START);
     private static final BlackboardAttribute.Type TYPE_DATETIME_END = new BlackboardAttribute.Type(ATTRIBUTE_TYPE.TSK_DATETIME_END);
     private static final BlackboardAttribute.Type TYPE_DOMAIN = new BlackboardAttribute.Type(ATTRIBUTE_TYPE.TSK_DOMAIN);
+    private static final BlackboardAttribute.Type TYPE_PROG_NAME = new BlackboardAttribute.Type(ATTRIBUTE_TYPE.TSK_PROG_NAME);
+    private static final BlackboardAttribute.Type TYPE_PATH = new BlackboardAttribute.Type(ATTRIBUTE_TYPE.TSK_PATH);
+    private static final BlackboardAttribute.Type TYPE_COUNT = new BlackboardAttribute.Type(ATTRIBUTE_TYPE.TSK_COUNT);
+
+    private static final String NTOS_BOOT_IDENTIFIER = "NTOSBOOT";
+    private static final String WINDOWS_PREFIX = "/WINDOWS";
 
     private static final Comparator<TopAccountResult> TOP_ACCOUNT_RESULT_DATE_COMPARE = (a, b) -> a.getLastAccess().compareTo(b.getLastAccess());
     private static final Comparator<TopWebSearchResult> TOP_WEBSEARCH_RESULT_DATE_COMPARE = (a, b) -> a.getDateAccessed().compareTo(b.getDateAccessed());
 
+    /**
+     * Sorts TopProgramsResults pushing highest run time count then most recent
+     * run and then the program name that comes earliest in the alphabet.
+     */
+    private static final Comparator<TopProgramsResult> TOP_PROGRAMS_RESULT_COMPARE = (a, b) -> {
+        // first priority for sorting is the run times 
+        // if non-0, this is the return value for the comparator
+        int runTimesCompare = nullableCompare(a.getRunTimes(), b.getRunTimes());
+        if (runTimesCompare != 0) {
+            return -runTimesCompare;
+        }
+
+        // second priority for sorting is the last run date
+        // if non-0, this is the return value for the comparator
+        int lastRunCompare = nullableCompare(
+                a.getLastRun() == null ? null : a.getLastRun().getTime(),
+                b.getLastRun() == null ? null : b.getLastRun().getTime());
+
+        if (lastRunCompare != 0) {
+            return -lastRunCompare;
+        }
+
+        // otherwise sort alphabetically
+        return (a.getProgramName() == null ? "" : a.getProgramName())
+                .compareToIgnoreCase((b.getProgramName() == null ? "" : b.getProgramName()));
+    };
+    
     private static final Set<Integer> ARTIFACT_UPDATE_TYPE_IDS = new HashSet<>(Arrays.asList(
             ARTIFACT_TYPE.TSK_WEB_SEARCH_QUERY.getTypeID(),
             ARTIFACT_TYPE.TSK_MESSAGE.getTypeID(),
             ARTIFACT_TYPE.TSK_EMAIL_MSG.getTypeID(),
             ARTIFACT_TYPE.TSK_CALLLOG.getTypeID(),
             ARTIFACT_TYPE.TSK_DEVICE_ATTACHED.getTypeID(),
-            ARTIFACT_TYPE.TSK_WEB_HISTORY.getTypeID()
+            ARTIFACT_TYPE.TSK_WEB_HISTORY.getTypeID(),
+            ARTIFACT_TYPE.TSK_PROG_RUN.getTypeID()
     ));
 
     private static final Set<String> DEVICE_EXCLUDE_LIST = new HashSet<>(Arrays.asList("ROOT_HUB", "ROOT_HUB20"));
@@ -539,6 +605,189 @@ public List<TopAccountResult> getRecentAccounts(DataSource dataSource, int count
                 .collect(Collectors.toList());
     }
 
+    /**
+     * Determines a short folder name if any. Otherwise, returns empty string.
+     *
+     * @param strPath         The string path.
+     * @param applicationName The application name.
+     *
+     * @return The short folder name or empty string if not found.
+     */
+    public String getShortFolderName(String strPath, String applicationName) {
+        if (strPath == null) {
+            return "";
+        }
+
+        List<String> pathEls = new ArrayList<>(Arrays.asList(applicationName));
+
+        File file = new File(strPath);
+        while (file != null && org.apache.commons.lang.StringUtils.isNotBlank(file.getName())) {
+            pathEls.add(file.getName());
+            file = file.getParentFile();
+        }
+
+        Collections.reverse(pathEls);
+
+        for (Function<List<String>, String> matchEntry : SHORT_FOLDER_MATCHERS) {
+            String result = matchEntry.apply(pathEls);
+            if (org.apache.commons.lang.StringUtils.isNotBlank(result)) {
+                return result;
+            }
+        }
+
+        return "";
+    }
+
+    /**
+     * Creates a TopProgramsResult from a TSK_PROG_RUN blackboard artifact.
+     *
+     * @param artifact The TSK_PROG_RUN blackboard artifact.
+     *
+     * @return The generated TopProgramsResult.
+     */
+    private TopProgramsResult getTopProgramsResult(BlackboardArtifact artifact) {
+        String programName = DataSourceInfoUtilities.getStringOrNull(artifact, TYPE_PROG_NAME);
+
+        // ignore items with no name or a ntos boot identifier
+        if (StringUtils.isBlank(programName) || NTOS_BOOT_IDENTIFIER.equalsIgnoreCase(programName)) {
+            return null;
+        }
+
+        String path = DataSourceInfoUtilities.getStringOrNull(artifact, TYPE_PATH);
+
+        // ignore windows directory
+        if (StringUtils.startsWithIgnoreCase(path, WINDOWS_PREFIX)) {
+            return null;
+        }
+        
+        Integer count = DataSourceInfoUtilities.getIntOrNull(artifact, TYPE_COUNT);
+        Long longCount = (count == null) ? null : (long) count;
+
+        return new TopProgramsResult(
+                programName,
+                path,
+                longCount,
+                DataSourceInfoUtilities.getDateOrNull(artifact, TYPE_DATETIME)
+        );
+    }
+
+    /**
+     * Retrieves the maximum date given two (possibly null) dates.
+     *
+     * @param date1 First date.
+     * @param date2 Second date.
+     *
+     * @return The maximum non-null date or null if both items are null.
+     */
+    private static Date getMax(Date date1, Date date2) {
+        if (date1 == null) {
+            return date2;
+        } else if (date2 == null) {
+            return date1;
+        } else {
+            return date1.compareTo(date2) > 0 ? date1 : date2;
+        }
+    }
+
+    /**
+     * Returns the compare value favoring the higher non-null number.
+     *
+     * @param long1 First possibly null long.
+     * @param long2 Second possibly null long.
+     *
+     * @return Returns the compare value: 1,0,-1 favoring the higher non-null
+     *         value.
+     */
+    private static int nullableCompare(Long long1, Long long2) {
+        if (long1 == null && long2 == null) {
+            return 0;
+        } else if (long1 != null && long2 == null) {
+            return 1;
+        } else if (long1 == null && long2 != null) {
+            return -1;
+        }
+
+        return Long.compare(long1, long2);
+    }
+
+    /**
+     * Returns true if number is non-null and higher than 0.
+     *
+     * @param longNum The number.
+     *
+     * @return True if non-null and higher than 0.
+     */
+    private static boolean isPositiveNum(Long longNum) {
+        return longNum != null && longNum > 0;
+    }
+
+
+    /**
+     * Retrieves the top programs results for the given data source limited to
+     * the count provided as a parameter. The highest run times are at the top
+     * of the list. If that information isn't available the last run date is
+     * used. If both, the last run date and the number of run times are
+     * unavailable, the programs will be sorted alphabetically, the count will
+     * be ignored and all items will be returned.
+     *
+     * @param dataSource The datasource. If the datasource is null, an empty
+     *                   list will be returned.
+     * @param count      The number of results to return. This value must be > 0
+     *                   or an IllegalArgumentException will be thrown.
+     *
+     * @return The sorted list and limited to the count if last run or run count
+     *         information is available on any item.
+     *
+     * @throws SleuthkitCaseProviderException
+     * @throws TskCoreException
+     */
+    public List<TopProgramsResult> getTopPrograms(DataSource dataSource, int count) throws SleuthkitCaseProviderException, TskCoreException {
+        assertValidCount(count);
+
+        if (dataSource == null) {
+            return Collections.emptyList();
+        }
+
+        // Get TopProgramsResults for each TSK_PROG_RUN artifact
+        Collection<TopProgramsResult> results = caseProvider.get().getBlackboard().getArtifacts(ARTIFACT_TYPE.TSK_PROG_RUN.getTypeID(), dataSource.getId())
+                .stream()
+                // convert to a TopProgramsResult object or null if missing critical information
+                .map((art) -> getTopProgramsResult(art))
+                // remove any null items
+                .filter((res) -> res != null)
+                // group by the program name and program path
+                // The value will be a TopProgramsResult with the max run times 
+                // and most recent last run date for each program name / program path pair.
+                .collect(Collectors.toMap(
+                        res -> Pair.of(res.getProgramName(), res.getProgramPath()),
+                        res -> res,
+                        (res1, res2) -> {
+                            return new TopProgramsResult(
+                                    res1.getProgramName(),
+                                    res1.getProgramPath(),
+                                    getMax(res1.getRunTimes(), res2.getRunTimes()),
+                                    getMax(res1.getLastRun(), res2.getLastRun()));
+                        })).values();
+
+        List<TopProgramsResult> orderedResults = results.stream()
+                .sorted(TOP_PROGRAMS_RESULT_COMPARE)
+                .collect(Collectors.toList());
+
+        // only limit the list to count if there is no last run date and no run times.
+        if (!orderedResults.isEmpty()) {
+            TopProgramsResult topResult = orderedResults.get(0);
+            // if run times / last run information is available, the first item should have some value,
+            // and then the items should be limited accordingly.
+            if (isPositiveNum(topResult.getRunTimes())
+                    || (topResult.getLastRun() != null && isPositiveNum(topResult.getLastRun().getTime()))) {
+                return orderedResults.stream().limit(count).collect(Collectors.toList());
+            }
+        }
+
+        // otherwise return the alphabetized list with no limit applied.
+        return orderedResults;
+    }
+
     /**
      * Object containing information about a web search artifact.
      */
@@ -722,4 +971,57 @@ public Date getLastVisit() {
             return lastVisit;
         }
     }
+
+    /**
+     * Describes a result of a program run on a datasource.
+     */
+    public static class TopProgramsResult {
+
+        private final String programName;
+        private final String programPath;
+        private final Long runTimes;
+        private final Date lastRun;
+
+        /**
+         * Main constructor.
+         *
+         * @param programName The name of the program.
+         * @param programPath The path of the program.
+         * @param runTimes    The number of runs.
+         */
+        TopProgramsResult(String programName, String programPath, Long runTimes, Date lastRun) {
+            this.programName = programName;
+            this.programPath = programPath;
+            this.runTimes = runTimes;
+            this.lastRun = lastRun;
+        }
+
+        /**
+         * @return The name of the program
+         */
+        public String getProgramName() {
+            return programName;
+        }
+
+        /**
+         * @return The path of the program.
+         */
+        public String getProgramPath() {
+            return programPath;
+        }
+
+        /**
+         * @return The number of run times or null if not present.
+         */
+        public Long getRunTimes() {
+            return runTimes;
+        }
+
+        /**
+         * @return The last time the program was run or null if not present.
+         */
+        public Date getLastRun() {
+            return lastRun;
+        }
+    }
 }
diff --git a/Core/src/org/sleuthkit/autopsy/datasourcesummary/ui/Bundle.properties b/Core/src/org/sleuthkit/autopsy/datasourcesummary/ui/Bundle.properties
index 94b497d2ff4d19a102ec14c5afcf3718bb8bfce4..5d9e6adf0c6a5d4960a8f46fe6d96f3e62e706af 100644
--- a/Core/src/org/sleuthkit/autopsy/datasourcesummary/ui/Bundle.properties
+++ b/Core/src/org/sleuthkit/autopsy/datasourcesummary/ui/Bundle.properties
@@ -42,3 +42,4 @@ RecentFilesPanel.attachmentLabel.text=Recent Attachments
 PastCasesPanel.notableFileLabel.text=Cases with Common Items That Were Tagged as Notable
 PastCasesPanel.sameIdLabel.text=Past Cases with the Same Device IDs
 DataSourceSummaryTabbedPane.noDataSourceLabel.text=No data source has been selected.
+TimelinePanel.activityRangeLabel.text=Activity Range
diff --git a/Core/src/org/sleuthkit/autopsy/datasourcesummary/ui/Bundle.properties-MERGED b/Core/src/org/sleuthkit/autopsy/datasourcesummary/ui/Bundle.properties-MERGED
index f03f55df3857f51c3e4c7a8c13f60d089ad2e4ac..4997d067f13569aec18210850a74fde783b108f1 100644
--- a/Core/src/org/sleuthkit/autopsy/datasourcesummary/ui/Bundle.properties-MERGED
+++ b/Core/src/org/sleuthkit/autopsy/datasourcesummary/ui/Bundle.properties-MERGED
@@ -3,6 +3,7 @@ AnalysisPanel_keyColumn_title=Name
 AnalysisPanel_keywordSearchModuleName=Keyword Search
 # {0} - module name
 BaseDataSourceSummaryPanel_defaultNotIngestMessage=The {0} ingest module has not been run on this data source.
+ContainerPanel_setFieldsForNonImageDataSource_na=N/A
 CTL_DataSourceSummaryAction=Data Source Summary
 DataSourceSummaryDialog.closeButton.text=Close
 ContainerPanel.displayNameLabel.text=Display Name:
@@ -47,6 +48,7 @@ DataSourceSummaryTabbedPane_detailsTab_title=Container
 DataSourceSummaryTabbedPane_ingestHistoryTab_title=Ingest History
 DataSourceSummaryTabbedPane_pastCasesTab_title=Past Cases
 DataSourceSummaryTabbedPane_recentFileTab_title=Recent Files
+DataSourceSummaryTabbedPane_timelineTab_title=Timeline
 DataSourceSummaryTabbedPane_typesTab_title=Types
 DataSourceSummaryTabbedPane_userActivityTab_title=User Activity
 PastCasesPanel_caseColumn_title=Case
@@ -64,6 +66,11 @@ SizeRepresentationUtil_units_kilobytes=\ kB
 SizeRepresentationUtil_units_megabytes=\ MB
 SizeRepresentationUtil_units_petabytes=\ PB
 SizeRepresentationUtil_units_terabytes=\ TB
+TimelinePanel_earliestLabel_title=Earliest
+TimelinePanel_latestLabel_title=Latest
+TimlinePanel_last30DaysChart_artifactEvts_title=Artifact Events
+TimlinePanel_last30DaysChart_fileEvts_title=File Events
+TimlinePanel_last30DaysChart_title=Last 30 Days
 TypesPanel_artifactsTypesPieChart_title=Artifact Types
 TypesPanel_fileMimeTypesChart_audio_title=Audio
 TypesPanel_fileMimeTypesChart_documents_title=Documents
@@ -95,6 +102,7 @@ RecentFilesPanel.attachmentLabel.text=Recent Attachments
 PastCasesPanel.notableFileLabel.text=Cases with Common Items That Were Tagged as Notable
 PastCasesPanel.sameIdLabel.text=Past Cases with the Same Device IDs
 DataSourceSummaryTabbedPane.noDataSourceLabel.text=No data source has been selected.
+TimelinePanel.activityRangeLabel.text=Activity Range
 UserActivityPanel_noDataExists=No communication data exists
 UserActivityPanel_tab_title=User Activity
 UserActivityPanel_TopAccountTableModel_accountType_header=Account Type
diff --git a/Core/src/org/sleuthkit/autopsy/datasourcesummary/ui/ContainerPanel.java b/Core/src/org/sleuthkit/autopsy/datasourcesummary/ui/ContainerPanel.java
index 8f11e0dcd3cafa87900967ff4a706d7c447f9864..bc331d952b25d09da4c494fd7c9f90b8a9de28bb 100644
--- a/Core/src/org/sleuthkit/autopsy/datasourcesummary/ui/ContainerPanel.java
+++ b/Core/src/org/sleuthkit/autopsy/datasourcesummary/ui/ContainerPanel.java
@@ -26,6 +26,7 @@
 import java.util.logging.Level;
 import org.sleuthkit.autopsy.coreutils.Logger;
 import javax.swing.table.DefaultTableModel;
+import org.openide.util.NbBundle.Messages;
 import org.sleuthkit.autopsy.casemodule.Case;
 import org.sleuthkit.autopsy.datasourcesummary.datamodel.ContainerSummary;
 import org.sleuthkit.autopsy.datasourcesummary.uiutils.DataFetchResult.ResultType;
@@ -52,7 +53,7 @@ private static class ContainerPanelData {
         /**
          * Main constructor.
          *
-         * @param dataSource           The original datasource.
+         * @param dataSource The original datasource.
          * @param unallocatedFilesSize The unallocated file size.
          */
         ContainerPanelData(DataSource dataSource, Long unallocatedFilesSize) {
@@ -165,8 +166,6 @@ protected void fetchInformation(DataSource dataSource) {
     private void updateDetailsPanelData(DataSource selectedDataSource, Long unallocatedFilesSize) {
         clearTableValues();
         if (selectedDataSource != null) {
-            unallocatedSizeValue.setText(SizeRepresentationUtil.getSizeString(unallocatedFilesSize));
-            timeZoneValue.setText(selectedDataSource.getTimeZone());
             displayNameValue.setText(selectedDataSource.getName());
             originalNameValue.setText(selectedDataSource.getName());
             deviceIdValue.setText(selectedDataSource.getDeviceId());
@@ -178,24 +177,48 @@ private void updateDetailsPanelData(DataSource selectedDataSource, Long unalloca
             }
 
             if (selectedDataSource instanceof Image) {
-                setFieldsForImage((Image) selectedDataSource);
+                setFieldsForImage((Image) selectedDataSource, unallocatedFilesSize);
+            } else {
+                setFieldsForNonImageDataSource();
             }
         }
-        updateFieldVisibility();
+
         this.repaint();
     }
 
+    @Messages({
+        "ContainerPanel_setFieldsForNonImageDataSource_na=N/A"
+    })
+    private void setFieldsForNonImageDataSource() {
+        String NA = Bundle.ContainerPanel_setFieldsForNonImageDataSource_na();
+
+        unallocatedSizeValue.setText(NA);
+        imageTypeValue.setText(NA);
+        sizeValue.setText(NA);
+        sectorSizeValue.setText(NA);
+        timeZoneValue.setText(NA);
+
+        ((DefaultTableModel) filePathsTable.getModel()).addRow(new Object[]{NA});
+
+        md5HashValue.setText(NA);
+        sha1HashValue.setText(NA);
+        sha256HashValue.setText(NA);
+    }
+
     /**
      * Sets text fields for an image. This should be called after
      * clearTableValues and before updateFieldVisibility to ensure the proper
      * rendering.
      *
      * @param selectedImage The selected image.
+     * @param unallocatedFilesSize Unallocated file size in bytes.
      */
-    private void setFieldsForImage(Image selectedImage) {
+    private void setFieldsForImage(Image selectedImage, Long unallocatedFilesSize) {
+        unallocatedSizeValue.setText(SizeRepresentationUtil.getSizeString(unallocatedFilesSize));
         imageTypeValue.setText(selectedImage.getType().getName());
         sizeValue.setText(SizeRepresentationUtil.getSizeString(selectedImage.getSize()));
         sectorSizeValue.setText(SizeRepresentationUtil.getSizeString(selectedImage.getSsize()));
+        timeZoneValue.setText(selectedImage.getTimeZone());
 
         for (String path : selectedImage.getPaths()) {
             ((DefaultTableModel) filePathsTable.getModel()).addRow(new Object[]{path});
@@ -233,41 +256,6 @@ private void setFieldsForImage(Image selectedImage) {
         }
     }
 
-    /**
-     * Update the visibility of all fields and their labels based on whether
-     * they have contents. Empty fields have them and their contents hidden.
-     */
-    private void updateFieldVisibility() {
-        displayNameValue.setVisible(!displayNameValue.getText().isEmpty());
-        displayNameLabel.setVisible(!displayNameValue.getText().isEmpty());
-        originalNameValue.setVisible(!originalNameValue.getText().isEmpty());
-        originalNameLabel.setVisible(!originalNameValue.getText().isEmpty());
-        deviceIdValue.setVisible(!deviceIdValue.getText().isEmpty());
-        deviceIdLabel.setVisible(!deviceIdValue.getText().isEmpty());
-        timeZoneValue.setVisible(!timeZoneValue.getText().isEmpty());
-        timeZoneLabel.setVisible(!timeZoneValue.getText().isEmpty());
-        acquisitionDetailsTextArea.setVisible(!acquisitionDetailsTextArea.getText().isEmpty());
-        acquisitionDetailsLabel.setVisible(!acquisitionDetailsTextArea.getText().isEmpty());
-        acquisitionDetailsScrollPane.setVisible(!acquisitionDetailsTextArea.getText().isEmpty());
-        imageTypeValue.setVisible(!imageTypeValue.getText().isEmpty());
-        imageTypeLabel.setVisible(!imageTypeValue.getText().isEmpty());
-        sizeValue.setVisible(!sizeValue.getText().isEmpty());
-        sizeLabel.setVisible(!sizeValue.getText().isEmpty());
-        sectorSizeValue.setVisible(!sectorSizeValue.getText().isEmpty());
-        sectorSizeLabel.setVisible(!sectorSizeValue.getText().isEmpty());
-        md5HashValue.setVisible(!md5HashValue.getText().isEmpty());
-        md5HashLabel.setVisible(!md5HashValue.getText().isEmpty());
-        sha1HashValue.setVisible(!sha1HashValue.getText().isEmpty());
-        sha1HashLabel.setVisible(!sha1HashValue.getText().isEmpty());
-        sha256HashValue.setVisible(!sha256HashValue.getText().isEmpty());
-        sha256HashLabel.setVisible(!sha256HashValue.getText().isEmpty());
-        unallocatedSizeValue.setVisible(!unallocatedSizeValue.getText().isEmpty());
-        unallocatedSizeLabel.setVisible(!unallocatedSizeValue.getText().isEmpty());
-        filePathsTable.setVisible(filePathsTable.getRowCount() > 0);
-        filePathsLabel.setVisible(filePathsTable.getRowCount() > 0);
-        filePathsScrollPane.setVisible(filePathsTable.getRowCount() > 0);
-    }
-
     /**
      * Set the contents of all fields to be empty.
      */
diff --git a/Core/src/org/sleuthkit/autopsy/datasourcesummary/ui/DataSourceSummaryTabbedPane.java b/Core/src/org/sleuthkit/autopsy/datasourcesummary/ui/DataSourceSummaryTabbedPane.java
index 55320f7dbb6308f16ab9704af97bc07af38019ba..3670124fa24349bab9f6e19d01c1c1081899c47b 100644
--- a/Core/src/org/sleuthkit/autopsy/datasourcesummary/ui/DataSourceSummaryTabbedPane.java
+++ b/Core/src/org/sleuthkit/autopsy/datasourcesummary/ui/DataSourceSummaryTabbedPane.java
@@ -38,7 +38,8 @@
     "DataSourceSummaryTabbedPane_ingestHistoryTab_title=Ingest History",
     "DataSourceSummaryTabbedPane_recentFileTab_title=Recent Files",
     "DataSourceSummaryTabbedPane_pastCasesTab_title=Past Cases",
-    "DataSourceSummaryTabbedPane_analysisTab_title=Analysis"
+    "DataSourceSummaryTabbedPane_analysisTab_title=Analysis",
+    "DataSourceSummaryTabbedPane_timelineTab_title=Timeline"
 })
 public class DataSourceSummaryTabbedPane extends javax.swing.JPanel {
 
@@ -56,10 +57,10 @@ private static class DataSourceTab {
         /**
          * Main constructor.
          *
-         * @param tabTitle     The title of the tab.
-         * @param component    The component to be displayed.
+         * @param tabTitle The title of the tab.
+         * @param component The component to be displayed.
          * @param onDataSource The function to be called on a new data source.
-         * @param onClose      Called to cleanup resources when closing tabs.
+         * @param onClose Called to cleanup resources when closing tabs.
          */
         DataSourceTab(String tabTitle, Component component, Consumer<DataSource> onDataSource, Runnable onClose) {
             this.tabTitle = tabTitle;
@@ -72,7 +73,7 @@ private static class DataSourceTab {
          * Main constructor.
          *
          * @param tabTitle The title of the tab.
-         * @param panel    The component to be displayed in the tab.
+         * @param panel The component to be displayed in the tab.
          */
         DataSourceTab(String tabTitle, BaseDataSourceSummaryPanel panel) {
             this.tabTitle = tabTitle;
@@ -123,6 +124,7 @@ public Runnable getOnClose() {
             new DataSourceTab(Bundle.DataSourceSummaryTabbedPane_analysisTab_title(), new AnalysisPanel()),
             new DataSourceTab(Bundle.DataSourceSummaryTabbedPane_recentFileTab_title(), new RecentFilesPanel()),
             new DataSourceTab(Bundle.DataSourceSummaryTabbedPane_pastCasesTab_title(), new PastCasesPanel()),
+            new DataSourceTab(Bundle.DataSourceSummaryTabbedPane_timelineTab_title(), new TimelinePanel()),
             // do nothing on closing 
             new DataSourceTab(Bundle.DataSourceSummaryTabbedPane_ingestHistoryTab_title(), ingestHistoryPanel, ingestHistoryPanel::setDataSource, () -> {
             }),
diff --git a/Core/src/org/sleuthkit/autopsy/datasourcesummary/ui/PastCasesPanel.java b/Core/src/org/sleuthkit/autopsy/datasourcesummary/ui/PastCasesPanel.java
index 76d36f4785cd7983297a40fcbae9e3aac56ed417..2400334ee3d3e59f856e643c384c415da9f6426e 100644
--- a/Core/src/org/sleuthkit/autopsy/datasourcesummary/ui/PastCasesPanel.java
+++ b/Core/src/org/sleuthkit/autopsy/datasourcesummary/ui/PastCasesPanel.java
@@ -20,7 +20,6 @@
 
 import java.util.Arrays;
 import java.util.List;
-import java.util.function.Function;
 import org.apache.commons.lang3.tuple.Pair;
 import org.openide.util.NbBundle.Messages;
 import org.sleuthkit.autopsy.centralrepository.ingestmodule.CentralRepoIngestModuleFactory;
@@ -28,7 +27,6 @@
 import org.sleuthkit.autopsy.datasourcesummary.datamodel.PastCasesSummary.PastCasesResult;
 import org.sleuthkit.autopsy.datasourcesummary.uiutils.CellModelTableCellRenderer.DefaultCellModel;
 import org.sleuthkit.autopsy.datasourcesummary.uiutils.DataFetchResult;
-import org.sleuthkit.autopsy.datasourcesummary.uiutils.DataFetchResult.ResultType;
 import org.sleuthkit.autopsy.datasourcesummary.uiutils.DataFetchWorker;
 import org.sleuthkit.autopsy.datasourcesummary.uiutils.DataFetchWorker.DataFetchComponents;
 import org.sleuthkit.autopsy.datasourcesummary.uiutils.IngestRunningLabel;
@@ -103,31 +101,8 @@ public PastCasesPanel(PastCasesSummary pastCaseData) {
      * @param result The result.
      */
     private void handleResult(DataFetchResult<PastCasesResult> result) {
-        showResultWithModuleCheck(notableFileTable, getSubResult(result, (res) -> res.getTaggedNotable()), CR_FACTORY, CR_NAME);
-        showResultWithModuleCheck(sameIdTable, getSubResult(result, (res) -> res.getSameIdsResults()), CR_FACTORY, CR_NAME);
-    }
-
-    /**
-     * Given an input data fetch result, creates an error result if the original
-     * is an error. Otherwise, uses the getSubResult function on the underlying
-     * data to create a new DataFetchResult.
-     *
-     * @param inputResult     The input result.
-     * @param getSubComponent The means of getting the data given the original
-     *                        data.
-     *
-     * @return The new result with the error of the original or the processed
-     *         data.
-     */
-    private <O> DataFetchResult<O> getSubResult(DataFetchResult<PastCasesResult> inputResult, Function<PastCasesResult, O> getSubResult) {
-        if (inputResult == null) {
-            return null;
-        } else if (inputResult.getResultType() == ResultType.SUCCESS) {
-            O innerData = (inputResult.getData() == null) ? null : getSubResult.apply(inputResult.getData());
-            return DataFetchResult.getSuccessResult(innerData);
-        } else {
-            return DataFetchResult.getErrorResult(inputResult.getException());
-        }
+        showResultWithModuleCheck(notableFileTable, DataFetchResult.getSubResult(result, (res) -> res.getTaggedNotable()), CR_FACTORY, CR_NAME);
+        showResultWithModuleCheck(sameIdTable, DataFetchResult.getSubResult(result, (res) -> res.getSameIdsResults()), CR_FACTORY, CR_NAME);
     }
 
     @Override
diff --git a/Core/src/org/sleuthkit/autopsy/datasourcesummary/ui/TimelinePanel.form b/Core/src/org/sleuthkit/autopsy/datasourcesummary/ui/TimelinePanel.form
new file mode 100644
index 0000000000000000000000000000000000000000..e3493d7a0d44177ae0541da539221ff964eb638d
--- /dev/null
+++ b/Core/src/org/sleuthkit/autopsy/datasourcesummary/ui/TimelinePanel.form
@@ -0,0 +1,214 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+
+<Form version="1.4" maxVersion="1.9" type="org.netbeans.modules.form.forminfo.JPanelFormInfo">
+  <AuxValues>
+    <AuxValue name="FormSettings_autoResourcing" type="java.lang.Integer" value="1"/>
+    <AuxValue name="FormSettings_autoSetComponentName" type="java.lang.Boolean" value="false"/>
+    <AuxValue name="FormSettings_generateFQN" type="java.lang.Boolean" value="true"/>
+    <AuxValue name="FormSettings_generateMnemonicsCode" type="java.lang.Boolean" value="true"/>
+    <AuxValue name="FormSettings_i18nAutoMode" type="java.lang.Boolean" value="true"/>
+    <AuxValue name="FormSettings_layoutCodeTarget" type="java.lang.Integer" value="1"/>
+    <AuxValue name="FormSettings_listenerGenerationStyle" type="java.lang.Integer" value="0"/>
+    <AuxValue name="FormSettings_variablesLocal" type="java.lang.Boolean" value="false"/>
+    <AuxValue name="FormSettings_variablesModifier" type="java.lang.Integer" value="2"/>
+  </AuxValues>
+
+  <Layout>
+    <DimensionLayout dim="0">
+      <Group type="103" groupAlignment="0" attributes="0">
+          <Component id="mainScrollPane" alignment="0" pref="400" max="32767" attributes="0"/>
+      </Group>
+    </DimensionLayout>
+    <DimensionLayout dim="1">
+      <Group type="103" groupAlignment="0" attributes="0">
+          <Component id="mainScrollPane" alignment="0" pref="300" max="32767" attributes="0"/>
+      </Group>
+    </DimensionLayout>
+  </Layout>
+  <SubComponents>
+    <Container class="javax.swing.JScrollPane" name="mainScrollPane">
+      <AuxValues>
+        <AuxValue name="JavaCodeGenerator_VariableLocal" type="java.lang.Boolean" value="true"/>
+        <AuxValue name="JavaCodeGenerator_VariableModifier" type="java.lang.Integer" value="0"/>
+      </AuxValues>
+
+      <Layout class="org.netbeans.modules.form.compat2.layouts.support.JScrollPaneSupportLayout"/>
+      <SubComponents>
+        <Container class="javax.swing.JPanel" name="mainContentPanel">
+          <Properties>
+            <Property name="border" type="javax.swing.border.Border" editor="org.netbeans.modules.form.editors2.BorderEditor">
+              <Border info="org.netbeans.modules.form.compat2.border.EmptyBorderInfo">
+                <EmptyBorder bottom="10" left="10" right="10" top="10"/>
+              </Border>
+            </Property>
+          </Properties>
+          <AuxValues>
+            <AuxValue name="JavaCodeGenerator_VariableLocal" type="java.lang.Boolean" value="true"/>
+            <AuxValue name="JavaCodeGenerator_VariableModifier" type="java.lang.Integer" value="0"/>
+          </AuxValues>
+
+          <Layout class="org.netbeans.modules.form.compat2.layouts.DesignBoxLayout">
+            <Property name="axis" type="int" value="3"/>
+          </Layout>
+          <SubComponents>
+            <Container class="javax.swing.JPanel" name="ingestRunningPanel">
+              <Properties>
+                <Property name="alignmentX" type="float" value="0.0"/>
+                <Property name="maximumSize" type="java.awt.Dimension" editor="org.netbeans.beaninfo.editors.DimensionEditor">
+                  <Dimension value="[32767, 25]"/>
+                </Property>
+                <Property name="minimumSize" type="java.awt.Dimension" editor="org.netbeans.beaninfo.editors.DimensionEditor">
+                  <Dimension value="[10, 25]"/>
+                </Property>
+                <Property name="preferredSize" type="java.awt.Dimension" editor="org.netbeans.beaninfo.editors.DimensionEditor">
+                  <Dimension value="[10, 25]"/>
+                </Property>
+              </Properties>
+              <AuxValues>
+                <AuxValue name="JavaCodeGenerator_CreateCodeCustom" type="java.lang.String" value="ingestRunningLabel"/>
+                <AuxValue name="JavaCodeGenerator_VariableLocal" type="java.lang.Boolean" value="true"/>
+                <AuxValue name="JavaCodeGenerator_VariableModifier" type="java.lang.Integer" value="0"/>
+              </AuxValues>
+
+              <Layout class="org.netbeans.modules.form.compat2.layouts.DesignFlowLayout"/>
+            </Container>
+            <Component class="javax.swing.JLabel" name="activityRangeLabel">
+              <Properties>
+                <Property name="font" type="java.awt.Font" editor="org.netbeans.beaninfo.editors.FontEditor">
+                  <Font name="Segoe UI" size="12" style="1"/>
+                </Property>
+                <Property name="text" type="java.lang.String" editor="org.netbeans.modules.i18n.form.FormI18nStringEditor">
+                  <ResourceString bundle="org/sleuthkit/autopsy/datasourcesummary/ui/Bundle.properties" key="TimelinePanel.activityRangeLabel.text" replaceFormat="org.openide.util.NbBundle.getMessage({sourceFileName}.class, &quot;{key}&quot;)"/>
+                </Property>
+              </Properties>
+              <AccessibilityProperties>
+                <Property name="AccessibleContext.accessibleName" type="java.lang.String" editor="org.netbeans.modules.i18n.form.FormI18nStringEditor">
+                  <ResourceString bundle="org/sleuthkit/autopsy/corecomponents/Bundle.properties" key="PastCasesPanel.notableFileLabel.text" replaceFormat="org.openide.util.NbBundle.getMessage({sourceFileName}.class, &quot;{key}&quot;)"/>
+                </Property>
+              </AccessibilityProperties>
+              <AuxValues>
+                <AuxValue name="JavaCodeGenerator_VariableLocal" type="java.lang.Boolean" value="true"/>
+                <AuxValue name="JavaCodeGenerator_VariableModifier" type="java.lang.Integer" value="0"/>
+              </AuxValues>
+            </Component>
+            <Component class="javax.swing.Box$Filler" name="filler1">
+              <Properties>
+                <Property name="maximumSize" type="java.awt.Dimension" editor="org.netbeans.beaninfo.editors.DimensionEditor">
+                  <Dimension value="[0, 2]"/>
+                </Property>
+                <Property name="minimumSize" type="java.awt.Dimension" editor="org.netbeans.beaninfo.editors.DimensionEditor">
+                  <Dimension value="[0, 2]"/>
+                </Property>
+                <Property name="preferredSize" type="java.awt.Dimension" editor="org.netbeans.beaninfo.editors.DimensionEditor">
+                  <Dimension value="[0, 2]"/>
+                </Property>
+                <Property name="alignmentX" type="float" value="0.0"/>
+              </Properties>
+              <AuxValues>
+                <AuxValue name="JavaCodeGenerator_VariableLocal" type="java.lang.Boolean" value="true"/>
+                <AuxValue name="JavaCodeGenerator_VariableModifier" type="java.lang.Integer" value="0"/>
+                <AuxValue name="classDetails" type="java.lang.String" value="Box.Filler.VerticalStrut"/>
+              </AuxValues>
+            </Component>
+            <Container class="javax.swing.JPanel" name="earliestLabelPanel">
+              <Properties>
+                <Property name="alignmentX" type="float" value="0.0"/>
+                <Property name="maximumSize" type="java.awt.Dimension" editor="org.netbeans.beaninfo.editors.DimensionEditor">
+                  <Dimension value="[32767, 20]"/>
+                </Property>
+                <Property name="minimumSize" type="java.awt.Dimension" editor="org.netbeans.beaninfo.editors.DimensionEditor">
+                  <Dimension value="[100, 20]"/>
+                </Property>
+                <Property name="preferredSize" type="java.awt.Dimension" editor="org.netbeans.beaninfo.editors.DimensionEditor">
+                  <Dimension value="[100, 20]"/>
+                </Property>
+              </Properties>
+              <AuxValues>
+                <AuxValue name="JavaCodeGenerator_CreateCodeCustom" type="java.lang.String" value="earliestLabel"/>
+                <AuxValue name="JavaCodeGenerator_VariableLocal" type="java.lang.Boolean" value="true"/>
+                <AuxValue name="JavaCodeGenerator_VariableModifier" type="java.lang.Integer" value="0"/>
+              </AuxValues>
+
+              <Layout class="org.netbeans.modules.form.compat2.layouts.DesignFlowLayout"/>
+            </Container>
+            <Container class="javax.swing.JPanel" name="latestLabelPanel">
+              <Properties>
+                <Property name="alignmentX" type="float" value="0.0"/>
+                <Property name="maximumSize" type="java.awt.Dimension" editor="org.netbeans.beaninfo.editors.DimensionEditor">
+                  <Dimension value="[32767, 20]"/>
+                </Property>
+                <Property name="minimumSize" type="java.awt.Dimension" editor="org.netbeans.beaninfo.editors.DimensionEditor">
+                  <Dimension value="[100, 20]"/>
+                </Property>
+                <Property name="preferredSize" type="java.awt.Dimension" editor="org.netbeans.beaninfo.editors.DimensionEditor">
+                  <Dimension value="[100, 20]"/>
+                </Property>
+              </Properties>
+              <AuxValues>
+                <AuxValue name="JavaCodeGenerator_CreateCodeCustom" type="java.lang.String" value="latestLabel"/>
+                <AuxValue name="JavaCodeGenerator_VariableLocal" type="java.lang.Boolean" value="true"/>
+                <AuxValue name="JavaCodeGenerator_VariableModifier" type="java.lang.Integer" value="0"/>
+              </AuxValues>
+
+              <Layout class="org.netbeans.modules.form.compat2.layouts.DesignFlowLayout"/>
+            </Container>
+            <Component class="javax.swing.Box$Filler" name="filler2">
+              <Properties>
+                <Property name="maximumSize" type="java.awt.Dimension" editor="org.netbeans.beaninfo.editors.DimensionEditor">
+                  <Dimension value="[0, 20]"/>
+                </Property>
+                <Property name="minimumSize" type="java.awt.Dimension" editor="org.netbeans.beaninfo.editors.DimensionEditor">
+                  <Dimension value="[0, 20]"/>
+                </Property>
+                <Property name="preferredSize" type="java.awt.Dimension" editor="org.netbeans.beaninfo.editors.DimensionEditor">
+                  <Dimension value="[0, 20]"/>
+                </Property>
+                <Property name="alignmentX" type="float" value="0.0"/>
+              </Properties>
+              <AuxValues>
+                <AuxValue name="JavaCodeGenerator_VariableLocal" type="java.lang.Boolean" value="true"/>
+                <AuxValue name="JavaCodeGenerator_VariableModifier" type="java.lang.Integer" value="0"/>
+                <AuxValue name="classDetails" type="java.lang.String" value="Box.Filler.VerticalStrut"/>
+              </AuxValues>
+            </Component>
+            <Container class="javax.swing.JPanel" name="sameIdPanel">
+              <Properties>
+                <Property name="alignmentX" type="float" value="0.0"/>
+                <Property name="maximumSize" type="java.awt.Dimension" editor="org.netbeans.beaninfo.editors.DimensionEditor">
+                  <Dimension value="[600, 300]"/>
+                </Property>
+                <Property name="minimumSize" type="java.awt.Dimension" editor="org.netbeans.beaninfo.editors.DimensionEditor">
+                  <Dimension value="[600, 300]"/>
+                </Property>
+                <Property name="preferredSize" type="java.awt.Dimension" editor="org.netbeans.beaninfo.editors.DimensionEditor">
+                  <Dimension value="[600, 300]"/>
+                </Property>
+                <Property name="verifyInputWhenFocusTarget" type="boolean" value="false"/>
+              </Properties>
+              <AuxValues>
+                <AuxValue name="JavaCodeGenerator_CreateCodeCustom" type="java.lang.String" value="last30DaysChart"/>
+                <AuxValue name="JavaCodeGenerator_VariableLocal" type="java.lang.Boolean" value="true"/>
+                <AuxValue name="JavaCodeGenerator_VariableModifier" type="java.lang.Integer" value="0"/>
+              </AuxValues>
+
+              <Layout class="org.netbeans.modules.form.compat2.layouts.DesignFlowLayout"/>
+            </Container>
+            <Component class="javax.swing.Box$Filler" name="filler5">
+              <Properties>
+                <Property name="maximumSize" type="java.awt.Dimension" editor="org.netbeans.beaninfo.editors.DimensionEditor">
+                  <Dimension value="[0, 32767]"/>
+                </Property>
+                <Property name="alignmentX" type="float" value="0.0"/>
+              </Properties>
+              <AuxValues>
+                <AuxValue name="JavaCodeGenerator_VariableLocal" type="java.lang.Boolean" value="true"/>
+                <AuxValue name="JavaCodeGenerator_VariableModifier" type="java.lang.Integer" value="0"/>
+                <AuxValue name="classDetails" type="java.lang.String" value="Box.Filler.VerticalGlue"/>
+              </AuxValues>
+            </Component>
+          </SubComponents>
+        </Container>
+      </SubComponents>
+    </Container>
+  </SubComponents>
+</Form>
diff --git a/Core/src/org/sleuthkit/autopsy/datasourcesummary/ui/TimelinePanel.java b/Core/src/org/sleuthkit/autopsy/datasourcesummary/ui/TimelinePanel.java
new file mode 100644
index 0000000000000000000000000000000000000000..87f170ccabfe5329d5c9dedb634fa3d368da24c0
--- /dev/null
+++ b/Core/src/org/sleuthkit/autopsy/datasourcesummary/ui/TimelinePanel.java
@@ -0,0 +1,263 @@
+/*
+ * Autopsy Forensic Browser
+ *
+ * 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.autopsy.datasourcesummary.ui;
+
+import java.awt.Color;
+import java.text.DateFormat;
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Date;
+import java.util.List;
+import java.util.Locale;
+import org.apache.commons.collections.CollectionUtils;
+import org.openide.util.NbBundle.Messages;
+import org.sleuthkit.autopsy.datasourcesummary.datamodel.TimelineSummary;
+import org.sleuthkit.autopsy.datasourcesummary.datamodel.TimelineSummary.DailyActivityAmount;
+import org.sleuthkit.autopsy.datasourcesummary.datamodel.TimelineSummary.TimelineSummaryData;
+import org.sleuthkit.autopsy.datasourcesummary.uiutils.BarChartPanel;
+import org.sleuthkit.autopsy.datasourcesummary.uiutils.BarChartPanel.BarChartItem;
+import org.sleuthkit.autopsy.datasourcesummary.uiutils.BarChartPanel.BarChartSeries;
+import org.sleuthkit.autopsy.datasourcesummary.uiutils.BarChartPanel.OrderedKey;
+import org.sleuthkit.autopsy.datasourcesummary.uiutils.DataFetchResult;
+import org.sleuthkit.autopsy.datasourcesummary.uiutils.DataFetchWorker;
+import org.sleuthkit.autopsy.datasourcesummary.uiutils.DataFetchWorker.DataFetchComponents;
+import org.sleuthkit.autopsy.datasourcesummary.uiutils.IngestRunningLabel;
+import org.sleuthkit.autopsy.datasourcesummary.uiutils.LoadableComponent;
+import org.sleuthkit.autopsy.datasourcesummary.uiutils.LoadableLabel;
+import org.sleuthkit.datamodel.DataSource;
+
+/**
+ * A tab shown in data source summary displaying information about a data
+ * source's timeline events.
+ */
+@Messages({
+    "TimelinePanel_earliestLabel_title=Earliest",
+    "TimelinePanel_latestLabel_title=Latest",
+    "TimlinePanel_last30DaysChart_title=Last 30 Days",
+    "TimlinePanel_last30DaysChart_fileEvts_title=File Events",
+    "TimlinePanel_last30DaysChart_artifactEvts_title=Artifact Events",})
+public class TimelinePanel extends BaseDataSourceSummaryPanel {
+
+    private static final long serialVersionUID = 1L;
+    private static final DateFormat EARLIEST_LATEST_FORMAT = getUtcFormat("MMM d, yyyy");
+    private static final DateFormat CHART_FORMAT = getUtcFormat("MMM d");
+    private static final int MOST_RECENT_DAYS_COUNT = 30;
+
+    /**
+     * Creates a DateFormat formatter that uses UTC for time zone.
+     *
+     * @param formatString The date format string.
+     * @return The data format.
+     */
+    private static DateFormat getUtcFormat(String formatString) {
+        return new SimpleDateFormat(formatString, Locale.getDefault());
+    }
+
+    // components displayed in the tab
+    private final IngestRunningLabel ingestRunningLabel = new IngestRunningLabel();
+    private final LoadableLabel earliestLabel = new LoadableLabel(Bundle.TimelinePanel_earliestLabel_title());
+    private final LoadableLabel latestLabel = new LoadableLabel(Bundle.TimelinePanel_latestLabel_title());
+    private final BarChartPanel last30DaysChart = new BarChartPanel(Bundle.TimlinePanel_last30DaysChart_title(), "", "");
+
+    // all loadable components on this tab
+    private final List<LoadableComponent<?>> loadableComponents = Arrays.asList(earliestLabel, latestLabel, last30DaysChart);
+
+    // actions to load data for this tab
+    private final List<DataFetchComponents<DataSource, ?>> dataFetchComponents;
+
+    public TimelinePanel() {
+        this(new TimelineSummary());
+    }
+
+    /**
+     * Creates new form PastCasesPanel
+     */
+    public TimelinePanel(TimelineSummary timelineData) {
+        // set up data acquisition methods
+        dataFetchComponents = Arrays.asList(
+                new DataFetchWorker.DataFetchComponents<>(
+                        (dataSource) -> timelineData.getData(dataSource, MOST_RECENT_DAYS_COUNT),
+                        (result) -> handleResult(result))
+        );
+
+        initComponents();
+    }
+
+    /**
+     * Formats a date using a DateFormat. In the event that the date is null,
+     * returns a null string.
+     *
+     * @param date The date to format.
+     * @param formatter The DateFormat to use to format the date.
+     * @return The formatted string generated from the formatter or null if the
+     * date is null.
+     */
+    private static String formatDate(Date date, DateFormat formatter) {
+        return date == null ? null : formatter.format(date);
+    }
+
+    private static final Color FILE_EVT_COLOR = new Color(228, 22, 28);
+    private static final Color ARTIFACT_EVT_COLOR = new Color(21, 227, 100);
+
+    /**
+     * Converts DailyActivityAmount data retrieved from TimelineSummary into
+     * data to be displayed as a bar chart.
+     *
+     * @param recentDaysActivity The data retrieved from TimelineSummary.
+     * @return The data to be displayed in the BarChart.
+     */
+    private List<BarChartSeries> parseChartData(List<DailyActivityAmount> recentDaysActivity) {
+        // if no data, return null indicating no result.
+        if (CollectionUtils.isEmpty(recentDaysActivity)) {
+            return null;
+        }
+
+        // Create a bar chart item for each recent days activity item
+        List<BarChartItem> fileEvtCounts = new ArrayList<>();
+        List<BarChartItem> artifactEvtCounts = new ArrayList<>();
+
+        for (int i = 0; i < recentDaysActivity.size(); i++) {
+            DailyActivityAmount curItem = recentDaysActivity.get(i);
+
+            long fileAmt = curItem.getFileActivityCount();
+            long artifactAmt = curItem.getArtifactActivityCount() * 100;
+            String formattedDate = (i == 0 || i == recentDaysActivity.size() - 1)
+                    ? formatDate(curItem.getDay(), CHART_FORMAT) : "";
+
+            OrderedKey thisKey = new OrderedKey(formattedDate, i);
+            fileEvtCounts.add(new BarChartItem(thisKey, fileAmt));
+            artifactEvtCounts.add(new BarChartItem(thisKey, artifactAmt));
+        }
+
+        return Arrays.asList(
+                new BarChartSeries(Bundle.TimlinePanel_last30DaysChart_fileEvts_title(), FILE_EVT_COLOR, fileEvtCounts),
+                new BarChartSeries(Bundle.TimlinePanel_last30DaysChart_artifactEvts_title(), ARTIFACT_EVT_COLOR, artifactEvtCounts));
+    }
+
+    /**
+     * Handles displaying the result for each displayable item in the
+     * TimelinePanel by breaking the TimelineSummaryData result into its
+     * constituent parts and then sending each data item to the pertinent
+     * component.
+     *
+     * @param result The result to be displayed on this tab.
+     */
+    private void handleResult(DataFetchResult<TimelineSummaryData> result) {
+        earliestLabel.showDataFetchResult(DataFetchResult.getSubResult(result, r -> formatDate(r.getMinDate(), EARLIEST_LATEST_FORMAT)));
+        latestLabel.showDataFetchResult(DataFetchResult.getSubResult(result, r -> formatDate(r.getMaxDate(), EARLIEST_LATEST_FORMAT)));
+        last30DaysChart.showDataFetchResult(DataFetchResult.getSubResult(result, r -> parseChartData(r.getMostRecentDaysActivity())));
+    }
+
+    @Override
+    protected void fetchInformation(DataSource dataSource) {
+        fetchInformation(dataFetchComponents, dataSource);
+    }
+
+    @Override
+    protected void onNewDataSource(DataSource dataSource) {
+        onNewDataSource(dataFetchComponents, loadableComponents, dataSource);
+    }
+
+    @Override
+    public void close() {
+        ingestRunningLabel.unregister();
+        super.close();
+    }
+
+    /**
+     * This method is called from within the constructor to initialize the form.
+     * WARNING: Do NOT modify this code. The content of this method is always
+     * regenerated by the Form Editor.
+     */
+    @SuppressWarnings("unchecked")
+    // <editor-fold defaultstate="collapsed" desc="Generated Code">//GEN-BEGIN:initComponents
+    private void initComponents() {
+
+        javax.swing.JScrollPane mainScrollPane = new javax.swing.JScrollPane();
+        javax.swing.JPanel mainContentPanel = new javax.swing.JPanel();
+        javax.swing.JPanel ingestRunningPanel = ingestRunningLabel;
+        javax.swing.JLabel activityRangeLabel = new javax.swing.JLabel();
+        javax.swing.Box.Filler filler1 = new javax.swing.Box.Filler(new java.awt.Dimension(0, 2), new java.awt.Dimension(0, 2), new java.awt.Dimension(0, 2));
+        javax.swing.JPanel earliestLabelPanel = earliestLabel;
+        javax.swing.JPanel latestLabelPanel = latestLabel;
+        javax.swing.Box.Filler filler2 = new javax.swing.Box.Filler(new java.awt.Dimension(0, 20), new java.awt.Dimension(0, 20), new java.awt.Dimension(0, 20));
+        javax.swing.JPanel sameIdPanel = last30DaysChart;
+        javax.swing.Box.Filler filler5 = new javax.swing.Box.Filler(new java.awt.Dimension(0, 0), new java.awt.Dimension(0, 0), new java.awt.Dimension(0, 32767));
+
+        mainContentPanel.setBorder(javax.swing.BorderFactory.createEmptyBorder(10, 10, 10, 10));
+        mainContentPanel.setLayout(new javax.swing.BoxLayout(mainContentPanel, javax.swing.BoxLayout.PAGE_AXIS));
+
+        ingestRunningPanel.setAlignmentX(0.0F);
+        ingestRunningPanel.setMaximumSize(new java.awt.Dimension(32767, 25));
+        ingestRunningPanel.setMinimumSize(new java.awt.Dimension(10, 25));
+        ingestRunningPanel.setPreferredSize(new java.awt.Dimension(10, 25));
+        mainContentPanel.add(ingestRunningPanel);
+
+        activityRangeLabel.setFont(new java.awt.Font("Segoe UI", 1, 12)); // NOI18N
+        org.openide.awt.Mnemonics.setLocalizedText(activityRangeLabel, org.openide.util.NbBundle.getMessage(TimelinePanel.class, "TimelinePanel.activityRangeLabel.text")); // NOI18N
+        mainContentPanel.add(activityRangeLabel);
+        activityRangeLabel.getAccessibleContext().setAccessibleName(org.openide.util.NbBundle.getMessage(TimelinePanel.class, "PastCasesPanel.notableFileLabel.text")); // NOI18N
+
+        filler1.setAlignmentX(0.0F);
+        mainContentPanel.add(filler1);
+
+        earliestLabelPanel.setAlignmentX(0.0F);
+        earliestLabelPanel.setMaximumSize(new java.awt.Dimension(32767, 20));
+        earliestLabelPanel.setMinimumSize(new java.awt.Dimension(100, 20));
+        earliestLabelPanel.setPreferredSize(new java.awt.Dimension(100, 20));
+        mainContentPanel.add(earliestLabelPanel);
+
+        latestLabelPanel.setAlignmentX(0.0F);
+        latestLabelPanel.setMaximumSize(new java.awt.Dimension(32767, 20));
+        latestLabelPanel.setMinimumSize(new java.awt.Dimension(100, 20));
+        latestLabelPanel.setPreferredSize(new java.awt.Dimension(100, 20));
+        mainContentPanel.add(latestLabelPanel);
+
+        filler2.setAlignmentX(0.0F);
+        mainContentPanel.add(filler2);
+
+        sameIdPanel.setAlignmentX(0.0F);
+        sameIdPanel.setMaximumSize(new java.awt.Dimension(600, 300));
+        sameIdPanel.setMinimumSize(new java.awt.Dimension(600, 300));
+        sameIdPanel.setPreferredSize(new java.awt.Dimension(600, 300));
+        sameIdPanel.setVerifyInputWhenFocusTarget(false);
+        mainContentPanel.add(sameIdPanel);
+
+        filler5.setAlignmentX(0.0F);
+        mainContentPanel.add(filler5);
+
+        mainScrollPane.setViewportView(mainContentPanel);
+
+        javax.swing.GroupLayout layout = new javax.swing.GroupLayout(this);
+        this.setLayout(layout);
+        layout.setHorizontalGroup(
+            layout.createParallelGroup(javax.swing.GroupLayout.Alignment.LEADING)
+            .addComponent(mainScrollPane, javax.swing.GroupLayout.DEFAULT_SIZE, 400, Short.MAX_VALUE)
+        );
+        layout.setVerticalGroup(
+            layout.createParallelGroup(javax.swing.GroupLayout.Alignment.LEADING)
+            .addComponent(mainScrollPane, javax.swing.GroupLayout.DEFAULT_SIZE, 300, Short.MAX_VALUE)
+        );
+    }// </editor-fold>//GEN-END:initComponents
+
+
+    // Variables declaration - do not modify//GEN-BEGIN:variables
+    // End of variables declaration//GEN-END:variables
+}
diff --git a/Core/src/org/sleuthkit/autopsy/datasourcesummary/ui/TypesPanel.java b/Core/src/org/sleuthkit/autopsy/datasourcesummary/ui/TypesPanel.java
index 448bf6bbea54de84cb18cf9d05a82a4f69794fcc..247aa0c304ee94ae720d6dbf79f14ae0ca9e9f50 100644
--- a/Core/src/org/sleuthkit/autopsy/datasourcesummary/ui/TypesPanel.java
+++ b/Core/src/org/sleuthkit/autopsy/datasourcesummary/ui/TypesPanel.java
@@ -18,7 +18,6 @@
  */
 package org.sleuthkit.autopsy.datasourcesummary.ui;
 
-import java.awt.BorderLayout;
 import java.awt.Color;
 import java.sql.SQLException;
 import java.text.DecimalFormat;
@@ -30,7 +29,6 @@
 import java.util.logging.Level;
 import java.util.stream.Collectors;
 import java.util.stream.Stream;
-import javax.swing.JLabel;
 import org.apache.commons.lang3.StringUtils;
 import org.openide.util.NbBundle.Messages;
 import org.sleuthkit.autopsy.coreutils.FileTypeUtils.FileTypeCategory;
@@ -40,13 +38,13 @@
 import org.sleuthkit.autopsy.datasourcesummary.datamodel.IngestModuleCheckUtil;
 import org.sleuthkit.autopsy.datasourcesummary.datamodel.MimeTypeSummary;
 import org.sleuthkit.autopsy.datasourcesummary.datamodel.SleuthkitCaseProvider.SleuthkitCaseProviderException;
-import org.sleuthkit.autopsy.datasourcesummary.uiutils.AbstractLoadableComponent;
 import org.sleuthkit.autopsy.datasourcesummary.uiutils.DataFetchResult;
 import org.sleuthkit.autopsy.datasourcesummary.uiutils.DataFetchResult.ResultType;
 import org.sleuthkit.autopsy.datasourcesummary.uiutils.DataFetchWorker;
 import org.sleuthkit.autopsy.datasourcesummary.uiutils.DataFetchWorker.DataFetchComponents;
 import org.sleuthkit.autopsy.datasourcesummary.uiutils.IngestRunningLabel;
 import org.sleuthkit.autopsy.datasourcesummary.uiutils.LoadableComponent;
+import org.sleuthkit.autopsy.datasourcesummary.uiutils.LoadableLabel;
 import org.sleuthkit.autopsy.datasourcesummary.uiutils.PieChartPanel;
 import org.sleuthkit.autopsy.datasourcesummary.uiutils.PieChartPanel.PieChartItem;
 import org.sleuthkit.autopsy.modules.filetypeid.FileTypeIdModuleFactory;
@@ -78,46 +76,6 @@
     "TypesPanel_sizeLabel_title=Size"})
 class TypesPanel extends BaseDataSourceSummaryPanel {
 
-    /**
-     * A label that allows for displaying loading messages and can be used with
-     * a DataFetchResult. Text displays as "<key>:<value | message>".
-     */
-    private static class LoadableLabel extends AbstractLoadableComponent<String> {
-
-        private static final long serialVersionUID = 1L;
-
-        private final JLabel label = new JLabel();
-        private final String key;
-
-        /**
-         * Main constructor for the label.
-         *
-         * @param key The key to be displayed.
-         */
-        LoadableLabel(String key) {
-            this.key = key;
-            setLayout(new BorderLayout());
-            add(label, BorderLayout.CENTER);
-            this.showResults(null);
-        }
-
-        private void setValue(String value) {
-            String formattedKey = StringUtils.isBlank(key) ? "" : key;
-            String formattedValue = StringUtils.isBlank(value) ? "" : value;
-            label.setText(String.format("%s: %s", formattedKey, formattedValue));
-        }
-
-        @Override
-        protected void setMessage(boolean visible, String message) {
-            setValue(message);
-        }
-
-        @Override
-        protected void setResults(String data) {
-            setValue(data);
-        }
-    }
-
     /**
      * Data for types pie chart.
      */
@@ -129,9 +87,9 @@ private static class TypesPieChartData {
         /**
          * Main constructor.
          *
-         * @param pieSlices     The pie slices.
+         * @param pieSlices The pie slices.
          * @param usefulContent True if this is useful content; false if there
-         *                      is 0 mime type information.
+         * is 0 mime type information.
          */
         public TypesPieChartData(List<PieChartItem> pieSlices, boolean usefulContent) {
             this.pieSlices = pieSlices;
@@ -165,9 +123,9 @@ private static class TypesPieCategory {
         /**
          * Main constructor.
          *
-         * @param label     The label for this slice.
+         * @param label The label for this slice.
          * @param mimeTypes The mime types associated with this slice.
-         * @param color     The color associated with this slice.
+         * @param color The color associated with this slice.
          */
         TypesPieCategory(String label, Set<String> mimeTypes, Color color) {
             this.label = label;
@@ -178,9 +136,9 @@ private static class TypesPieCategory {
         /**
          * Constructor that accepts FileTypeCategory.
          *
-         * @param label     The label for this slice.
+         * @param label The label for this slice.
          * @param mimeTypes The mime types associated with this slice.
-         * @param color     The color associated with this slice.
+         * @param color The color associated with this slice.
          */
         TypesPieCategory(String label, FileTypeCategory fileCategory, Color color) {
             this(label, fileCategory.getMediaTypes(), color);
@@ -278,8 +236,8 @@ public void close() {
     /**
      * Creates a new TypesPanel.
      *
-     * @param mimeTypeData  The service for mime types.
-     * @param typeData      The service for file types data.
+     * @param mimeTypeData The service for mime types.
+     * @param typeData The service for file types data.
      * @param containerData The service for container information.
      */
     public TypesPanel(
@@ -358,7 +316,7 @@ protected void onNewDataSource(DataSource dataSource) {
      * Gets all the data for the file type pie chart.
      *
      * @param mimeTypeData The means of acquiring data.
-     * @param dataSource   The datasource.
+     * @param dataSource The datasource.
      *
      * @return The pie chart items.
      */
diff --git a/Core/src/org/sleuthkit/autopsy/datasourcesummary/ui/UserActivityPanel.java b/Core/src/org/sleuthkit/autopsy/datasourcesummary/ui/UserActivityPanel.java
index 476d8c5e6fa20c56b72bcc1a52b4b855e4b3a381..baab54217ccd6f46d6fd7217a14ab22b6de5b18d 100644
--- a/Core/src/org/sleuthkit/autopsy/datasourcesummary/ui/UserActivityPanel.java
+++ b/Core/src/org/sleuthkit/autopsy/datasourcesummary/ui/UserActivityPanel.java
@@ -29,12 +29,11 @@
 import org.openide.util.NbBundle.Messages;
 import org.sleuthkit.autopsy.datasourcesummary.datamodel.IngestModuleCheckUtil;
 import org.sleuthkit.autopsy.datasourcesummary.datamodel.UserActivitySummary;
-import org.sleuthkit.autopsy.datasourcesummary.datamodel.TopProgramsSummary;
 import org.sleuthkit.autopsy.datasourcesummary.datamodel.UserActivitySummary.TopAccountResult;
 import org.sleuthkit.autopsy.datasourcesummary.datamodel.UserActivitySummary.TopDeviceAttachedResult;
 import org.sleuthkit.autopsy.datasourcesummary.datamodel.UserActivitySummary.TopWebSearchResult;
-import org.sleuthkit.autopsy.datasourcesummary.datamodel.TopProgramsSummary.TopProgramsResult;
 import org.sleuthkit.autopsy.datasourcesummary.datamodel.UserActivitySummary.TopDomainsResult;
+import org.sleuthkit.autopsy.datasourcesummary.datamodel.UserActivitySummary.TopProgramsResult;
 import org.sleuthkit.autopsy.datasourcesummary.uiutils.CellModelTableCellRenderer.DefaultCellModel;
 import org.sleuthkit.autopsy.datasourcesummary.uiutils.DataFetchWorker.DataFetchComponents;
 import org.sleuthkit.autopsy.datasourcesummary.uiutils.IngestRunningLabel;
@@ -227,35 +226,30 @@ private static String getFormatted(Date date) {
     private final IngestRunningLabel ingestRunningLabel = new IngestRunningLabel();
 
     private final List<DataFetchComponents<DataSource, ?>> dataFetchComponents;
-    private final TopProgramsSummary topProgramsData;
-
+    private final UserActivitySummary userActivityData;
+    
     /**
      * Creates a new UserActivityPanel.
      */
     public UserActivityPanel() {
-        this(new TopProgramsSummary(), new UserActivitySummary());
+        this(new UserActivitySummary());
     }
 
     /**
      * Creates a new UserActivityPanel.
      *
-     * @param topProgramsData  Class from which to obtain top programs data.
      * @param userActivityData Class from which to obtain remaining user
      *                         activity data.
      */
-    public UserActivityPanel(
-            TopProgramsSummary topProgramsData,
-            UserActivitySummary userActivityData) {
-
-        super(topProgramsData, userActivityData);
-
-        this.topProgramsData = topProgramsData;
+    public UserActivityPanel(UserActivitySummary userActivityData) {
+        super(userActivityData);
+        this.userActivityData = userActivityData;
 
         // set up data acquisition methods
         this.dataFetchComponents = Arrays.asList(
                 // top programs query
                 new DataFetchComponents<DataSource, List<TopProgramsResult>>(
-                        (dataSource) -> topProgramsData.getTopPrograms(dataSource, TOP_PROGS_COUNT),
+                        (dataSource) -> userActivityData.getTopPrograms(dataSource, TOP_PROGS_COUNT),
                         (result) -> {
                             showResultWithModuleCheck(topProgramsTable, result,
                                     IngestModuleCheckUtil.RECENT_ACTIVITY_FACTORY,
@@ -307,7 +301,7 @@ public UserActivityPanel(
      * @return The underlying short folder name if one exists.
      */
     private String getShortFolderName(String path, String appName) {
-        return this.topProgramsData.getShortFolderName(path, appName);
+        return this.userActivityData.getShortFolderName(path, appName);
     }
 
     @Override
diff --git a/Core/src/org/sleuthkit/autopsy/datasourcesummary/uiutils/BarChartPanel.java b/Core/src/org/sleuthkit/autopsy/datasourcesummary/uiutils/BarChartPanel.java
new file mode 100644
index 0000000000000000000000000000000000000000..3f3822f7c2c597fbccf6e740682ded3cef0b2afd
--- /dev/null
+++ b/Core/src/org/sleuthkit/autopsy/datasourcesummary/uiutils/BarChartPanel.java
@@ -0,0 +1,307 @@
+/*
+ * Autopsy Forensic Browser
+ *
+ * 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.autopsy.datasourcesummary.uiutils;
+
+import java.awt.BorderLayout;
+import java.awt.Color;
+import java.awt.Font;
+import java.util.Collections;
+import java.util.List;
+import javax.swing.JLabel;
+import org.apache.commons.collections4.CollectionUtils;
+import org.jfree.chart.ChartFactory;
+import org.jfree.chart.ChartPanel;
+import org.jfree.chart.JFreeChart;
+import org.jfree.chart.axis.ValueAxis;
+import org.jfree.chart.plot.CategoryPlot;
+import org.jfree.chart.plot.PlotOrientation;
+import org.jfree.chart.renderer.category.BarRenderer;
+import org.jfree.chart.renderer.category.StandardBarPainter;
+import org.jfree.data.category.DefaultCategoryDataset;
+
+/**
+ * A bar chart panel.
+ */
+public class BarChartPanel extends AbstractLoadableComponent<List<BarChartPanel.BarChartSeries>> {
+
+    /**
+     * Represents a series in a bar chart where all items pertain to one
+     * category.
+     */
+    public static class BarChartSeries {
+
+        private final Comparable<?> key;
+        private final Color color;
+        private final List<BarChartItem> items;
+
+        /**
+         * Main constructor.
+         *
+         * @param color The color for this series.
+         * @param items The bars to be displayed for this series.
+         */
+        public BarChartSeries(Comparable<?> key, Color color, List<BarChartItem> items) {
+            this.key = key;
+            this.color = color;
+            this.items = (items == null) ? Collections.emptyList() : Collections.unmodifiableList(items);
+        }
+
+        /**
+         * @return The color for this series.
+         */
+        public Color getColor() {
+            return color;
+        }
+
+        /**
+         * @return The bars to be displayed for this series.
+         */
+        public List<BarChartItem> getItems() {
+            return items;
+        }
+
+        /**
+         * @return The key for this item.
+         */
+        public Comparable<?> getKey() {
+            return key;
+        }
+    }
+
+    /**
+     * An individual bar to be displayed in the bar chart.
+     */
+    public static class BarChartItem {
+
+        private final Comparable<?> key;
+        private final double value;
+
+        /**
+         * Main constructor.
+         *
+         * @param label The key for this bar. Also serves as the label using
+         * toString().
+         * @param value The value for this item.
+         */
+        public BarChartItem(Comparable<?> key, double value) {
+            this.key = key;
+            this.value = value;
+        }
+
+        /**
+         * @return The key for this item.
+         */
+        public Comparable<?> getKey() {
+            return key;
+        }
+
+        /**
+         * @return The value for this item.
+         */
+        public double getValue() {
+            return value;
+        }
+    }
+
+    /**
+     * JFreeChart bar charts don't preserve the order of bars provided to the
+     * chart, but instead uses the comparable nature to order items. This
+     * provides order using a provided index as well as the value for the axis.
+     */
+    public static class OrderedKey implements Comparable<OrderedKey> {
+
+        private final Object keyValue;
+        private final int keyIndex;
+
+        /**
+         * Main constructor.
+         *
+         * @param keyValue The value for the key to be displayed in the domain
+         * axis.
+         * @param keyIndex The index at which it will be displayed.
+         */
+        public OrderedKey(Object keyValue, int keyIndex) {
+            this.keyValue = keyValue;
+            this.keyIndex = keyIndex;
+        }
+
+        /**
+         * @return The value for the key to be displayed in the domain axis.
+         */
+        Object getKeyValue() {
+            return keyValue;
+        }
+
+        /**
+         * @return The index at which it will be displayed.
+         */
+        int getKeyIndex() {
+            return keyIndex;
+        }
+
+        @Override
+        public int compareTo(OrderedKey o) {
+            // this will have a higher value than null.
+            if (o == null) {
+                return 1;
+            }
+
+            // compare by index
+            return Integer.compare(this.getKeyIndex(), o.getKeyIndex());
+        }
+
+        @Override
+        public int hashCode() {
+            int hash = 3;
+            return hash;
+        }
+
+        @Override
+        public boolean equals(Object obj) {
+            if (this == obj) {
+                return true;
+            }
+            if (obj == null) {
+                return false;
+            }
+            if (getClass() != obj.getClass()) {
+                return false;
+            }
+            final OrderedKey other = (OrderedKey) obj;
+            if (this.keyIndex != other.keyIndex) {
+                return false;
+            }
+            return true;
+        }
+
+        @Override
+        public String toString() {
+            // use toString on the key.
+            return this.getKeyValue() == null ? null : this.getKeyValue().toString();
+        }
+    }
+
+    private static final long serialVersionUID = 1L;
+
+    private static final Font DEFAULT_FONT = new JLabel().getFont();
+    private static final Font DEFAULT_HEADER_FONT = new Font(DEFAULT_FONT.getName(), DEFAULT_FONT.getStyle(), (int) (DEFAULT_FONT.getSize() * 1.5));
+
+    private final ChartMessageOverlay overlay = new ChartMessageOverlay();
+    private final DefaultCategoryDataset dataset = new DefaultCategoryDataset();
+    private final JFreeChart chart;
+    private final CategoryPlot plot;
+
+    /**
+     * Main constructor assuming null values for all items.
+     */
+    public BarChartPanel() {
+        this(null, null, null);
+    }
+
+    /**
+     * Main constructor for the pie chart.
+     *
+     * @param title The title for this pie chart.
+     * @param categoryLabel The x-axis label.
+     * @param valueLabel The y-axis label.
+     */
+    public BarChartPanel(String title, String categoryLabel, String valueLabel) {
+        this.chart = ChartFactory.createStackedBarChart(
+                title,
+                categoryLabel,
+                valueLabel,
+                dataset,
+                PlotOrientation.VERTICAL,
+                true, false, false);
+
+        // set style to match autopsy components
+        chart.setBackgroundPaint(null);
+        chart.getTitle().setFont(DEFAULT_HEADER_FONT);
+
+        this.plot = ((CategoryPlot) chart.getPlot());
+        this.plot.getRenderer().setBaseItemLabelFont(DEFAULT_FONT);
+        plot.setBackgroundPaint(null);
+        plot.setOutlinePaint(null);
+
+        // hide y axis labels
+        ValueAxis range = plot.getRangeAxis();
+        range.setVisible(false);
+
+        // make sure x axis labels don't get cut off
+        plot.getDomainAxis().setMaximumCategoryLabelWidthRatio(10);
+
+        ((BarRenderer) plot.getRenderer()).setBarPainter(new StandardBarPainter());
+
+        // Create Panel
+        ChartPanel panel = new ChartPanel(chart);
+        panel.addOverlay(overlay);
+        panel.setPopupMenu(null);
+
+        this.setLayout(new BorderLayout());
+        this.add(panel, BorderLayout.CENTER);
+    }
+
+    /**
+     * @return The title for this chart if one exists.
+     */
+    public String getTitle() {
+        return (this.chart == null || this.chart.getTitle() == null)
+                ? null
+                : this.chart.getTitle().getText();
+    }
+
+    /**
+     * Sets the title for this pie chart.
+     *
+     * @param title The title.
+     *
+     * @return As a utility, returns this.
+     */
+    public BarChartPanel setTitle(String title) {
+        this.chart.getTitle().setText(title);
+        return this;
+    }
+
+    @Override
+    protected void setMessage(boolean visible, String message) {
+        this.overlay.setVisible(visible);
+        this.overlay.setMessage(message);
+    }
+
+    @Override
+    protected void setResults(List<BarChartPanel.BarChartSeries> data) {
+        this.dataset.clear();
+
+        if (CollectionUtils.isNotEmpty(data)) {
+            for (int s = 0; s < data.size(); s++) {
+                BarChartPanel.BarChartSeries series = data.get(s);
+                if (series != null && CollectionUtils.isNotEmpty(series.getItems())) {
+                    if (series.getColor() != null) {
+                        this.plot.getRenderer().setSeriesPaint(s, series.getColor());
+                    }
+
+                    for (int i = 0; i < series.getItems().size(); i++) {
+                        BarChartItem bar = series.getItems().get(i);
+                        this.dataset.setValue(bar.getValue(), series.getKey(), bar.getKey());
+                    }
+                }
+            }
+        }
+    }
+}
diff --git a/Core/src/org/sleuthkit/autopsy/datasourcesummary/uiutils/ChartMessageOverlay.java b/Core/src/org/sleuthkit/autopsy/datasourcesummary/uiutils/ChartMessageOverlay.java
new file mode 100644
index 0000000000000000000000000000000000000000..2e21dfb796067b9f8d1c68acc23cc982186d104b
--- /dev/null
+++ b/Core/src/org/sleuthkit/autopsy/datasourcesummary/uiutils/ChartMessageOverlay.java
@@ -0,0 +1,63 @@
+/*
+ * Autopsy Forensic Browser
+ *
+ * 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.autopsy.datasourcesummary.uiutils;
+
+import java.awt.Graphics2D;
+import org.jfree.chart.ChartPanel;
+import org.jfree.chart.panel.AbstractOverlay;
+import org.jfree.chart.panel.Overlay;
+
+/**
+ * A JFreeChart message overlay that can show a message for the purposes of the
+ * LoadableComponent.
+ */
+class ChartMessageOverlay extends AbstractOverlay implements Overlay {
+
+    private static final long serialVersionUID = 1L;
+    private final BaseMessageOverlay overlay = new BaseMessageOverlay();
+
+    // multiply this value by the smaller dimension (height or width) of the component
+    // to determine width of text to be displayed.
+    private static final double MESSAGE_WIDTH_FACTOR = .6;
+
+    /**
+     * Sets this layer visible when painted. In order to be shown in UI, this
+     * component needs to be repainted.
+     *
+     * @param visible Whether or not it is visible.
+     */
+    void setVisible(boolean visible) {
+        overlay.setVisible(visible);
+    }
+
+    /**
+     * Sets the message to be displayed in the child jlabel.
+     *
+     * @param message The message to be displayed.
+     */
+    void setMessage(String message) {
+        overlay.setMessage(message);
+    }
+
+    @Override
+    public void paintOverlay(Graphics2D gd, ChartPanel cp) {
+        int labelWidth = (int) (Math.min(cp.getWidth(), cp.getHeight()) * MESSAGE_WIDTH_FACTOR);
+        overlay.paintOverlay(gd, cp.getWidth(), cp.getHeight(), labelWidth);
+    }
+}
diff --git a/Core/src/org/sleuthkit/autopsy/datasourcesummary/uiutils/DataFetchResult.java b/Core/src/org/sleuthkit/autopsy/datasourcesummary/uiutils/DataFetchResult.java
index 93cc24f5feb55b93f1d65d7141644d08c7dae33a..6e2d8f49910aea5caf40660fc0f23aedafbf0b06 100644
--- a/Core/src/org/sleuthkit/autopsy/datasourcesummary/uiutils/DataFetchResult.java
+++ b/Core/src/org/sleuthkit/autopsy/datasourcesummary/uiutils/DataFetchResult.java
@@ -18,6 +18,8 @@
  */
 package org.sleuthkit.autopsy.datasourcesummary.uiutils;
 
+import java.util.function.Function;
+
 /**
  * The result of a loading process.
  */
@@ -30,6 +32,29 @@ public enum ResultType {
         SUCCESS, ERROR
     }
 
+    /**
+     * A utility method that, given an input data fetch result, creates an error
+     * result if the original is an error. Otherwise, uses the getSubResult
+     * function on the underlying data to create a new DataFetchResult.
+     *
+     * @param inputResult The input result.
+     * @param getSubComponent The means of getting the data given the original
+     * data.
+     *
+     * @return The new result with the error of the original or the processed
+     * data.
+     */
+    public static <I, O> DataFetchResult<O> getSubResult(DataFetchResult<I> inputResult, Function<I, O> getSubResult) {
+        if (inputResult == null) {
+            return null;
+        } else if (inputResult.getResultType() == ResultType.SUCCESS) {
+            O innerData = (inputResult.getData() == null) ? null : getSubResult.apply(inputResult.getData());
+            return DataFetchResult.getSuccessResult(innerData);
+        } else {
+            return DataFetchResult.getErrorResult(inputResult.getException());
+        }
+    }
+
     /**
      * Creates a DataFetchResult of loaded data including the data.
      *
@@ -59,9 +84,8 @@ public static <R> DataFetchResult<R> getErrorResult(Throwable e) {
     /**
      * Main constructor for the DataLoadingResult.
      *
-     * @param state     The state of the result.
-     * @param data      If the result is SUCCESS, the data related to this
-     *                  result.
+     * @param state The state of the result.
+     * @param data If the result is SUCCESS, the data related to this result.
      * @param exception If the result is ERROR, the related exception.
      */
     private DataFetchResult(ResultType state, R data, Throwable exception) {
diff --git a/Core/src/org/sleuthkit/autopsy/datasourcesummary/uiutils/LoadableLabel.java b/Core/src/org/sleuthkit/autopsy/datasourcesummary/uiutils/LoadableLabel.java
new file mode 100644
index 0000000000000000000000000000000000000000..3fdf81ad1848ef5a2ec1eeee4d308236dec6ba89
--- /dev/null
+++ b/Core/src/org/sleuthkit/autopsy/datasourcesummary/uiutils/LoadableLabel.java
@@ -0,0 +1,63 @@
+/*
+ * Autopsy Forensic Browser
+ *
+ * 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.autopsy.datasourcesummary.uiutils;
+
+import java.awt.BorderLayout;
+import javax.swing.JLabel;
+import org.apache.commons.lang3.StringUtils;
+
+/**
+ * A label that allows for displaying loading messages and can be used with a
+ * DataFetchResult. Text displays as "<key>:<value | message>".
+ */
+public class LoadableLabel extends AbstractLoadableComponent<String> {
+
+    private static final long serialVersionUID = 1L;
+
+    private final JLabel label = new JLabel();
+    private final String key;
+
+    /**
+     * Main constructor for the label.
+     *
+     * @param key The key to be displayed.
+     */
+    public LoadableLabel(String key) {
+        this.key = key;
+        setLayout(new BorderLayout());
+        add(label, BorderLayout.CENTER);
+        this.showResults(null);
+    }
+
+    private void setValue(String value) {
+        String formattedKey = StringUtils.isBlank(key) ? "" : key;
+        String formattedValue = StringUtils.isBlank(value) ? "" : value;
+        label.setText(String.format("%s: %s", formattedKey, formattedValue));
+    }
+
+    @Override
+    protected void setMessage(boolean visible, String message) {
+        setValue(message);
+    }
+
+    @Override
+    protected void setResults(String data) {
+        setValue(data);
+    }
+}
diff --git a/Core/src/org/sleuthkit/autopsy/datasourcesummary/uiutils/PieChartPanel.java b/Core/src/org/sleuthkit/autopsy/datasourcesummary/uiutils/PieChartPanel.java
index 971cb83367200f2c22812c17d4769544e5d7d537..fa0d00dab66e1e629a31762479c303a11e8d556f 100644
--- a/Core/src/org/sleuthkit/autopsy/datasourcesummary/uiutils/PieChartPanel.java
+++ b/Core/src/org/sleuthkit/autopsy/datasourcesummary/uiutils/PieChartPanel.java
@@ -21,7 +21,6 @@
 import java.awt.BorderLayout;
 import java.awt.Color;
 import java.awt.Font;
-import java.awt.Graphics2D;
 import java.text.DecimalFormat;
 import java.util.List;
 import javax.swing.JLabel;
@@ -30,8 +29,6 @@
 import org.jfree.chart.JFreeChart;
 import org.jfree.chart.labels.PieSectionLabelGenerator;
 import org.jfree.chart.labels.StandardPieSectionLabelGenerator;
-import org.jfree.chart.panel.AbstractOverlay;
-import org.jfree.chart.panel.Overlay;
 import org.jfree.chart.plot.PiePlot;
 import org.jfree.data.general.DefaultPieDataset;
 import org.openide.util.NbBundle.Messages;
@@ -59,7 +56,7 @@ public static class PieChartItem {
          * @param label The label for this pie slice.
          * @param value The value for this item.
          * @param color The color for the pie slice. Can be null for
-         *              auto-determined.
+         * auto-determined.
          */
         public PieChartItem(String label, double value, Color color) {
             this.label = label;
@@ -89,46 +86,6 @@ public Color getColor() {
         }
     }
 
-    /**
-     * A JFreeChart message overlay that can show a message for the purposes of
-     * the LoadableComponent.
-     */
-    private static class MessageOverlay extends AbstractOverlay implements Overlay {
-
-        private static final long serialVersionUID = 1L;
-        private final BaseMessageOverlay overlay = new BaseMessageOverlay();
-
-        // multiply this value by the smaller dimension (height or width) of the component
-        // to determine width of text to be displayed.
-        private static final double MESSAGE_WIDTH_FACTOR = .6;
-
-        /**
-         * Sets this layer visible when painted. In order to be shown in UI,
-         * this component needs to be repainted.
-         *
-         * @param visible Whether or not it is visible.
-         */
-        void setVisible(boolean visible) {
-            overlay.setVisible(visible);
-        }
-
-        /**
-         * Sets the message to be displayed in the child jlabel.
-         *
-         * @param message The message to be displayed.
-         */
-        void setMessage(String message) {
-            overlay.setMessage(message);
-        }
-
-        @Override
-        public void paintOverlay(Graphics2D gd, ChartPanel cp) {
-            int labelWidth = (int) (Math.min(cp.getWidth(), cp.getHeight()) * MESSAGE_WIDTH_FACTOR);
-            overlay.paintOverlay(gd, cp.getWidth(), cp.getHeight(), labelWidth);
-        }
-
-    }
-
     private static final long serialVersionUID = 1L;
 
     private static final Font DEFAULT_FONT = new JLabel().getFont();
@@ -146,7 +103,7 @@ public void paintOverlay(Graphics2D gd, ChartPanel cp) {
             = new StandardPieSectionLabelGenerator(
                     "{0}: {1} ({2})", new DecimalFormat("#,###"), new DecimalFormat("0.0%"));
 
-    private final MessageOverlay overlay = new MessageOverlay();
+    private final ChartMessageOverlay overlay = new ChartMessageOverlay();
     private final DefaultPieDataset dataset = new DefaultPieDataset();
     private final JFreeChart chart;
     private final PiePlot plot;
@@ -242,7 +199,7 @@ protected void setResults(List<PieChartPanel.PieChartItem> data) {
     /**
      * Shows a message on top of data.
      *
-     * @param data    The data.
+     * @param data The data.
      * @param message The message.
      */
     public synchronized void showDataWithMessage(List<PieChartPanel.PieChartItem> data, String message) {
diff --git a/Core/src/org/sleuthkit/autopsy/discovery/search/DiscoveryEventUtils.java b/Core/src/org/sleuthkit/autopsy/discovery/search/DiscoveryEventUtils.java
index e224c02d516bf74a82ce3a13262858a07e7d9605..0b9a0a98c1b893af6ad5bdde76f32e3365e6e9a6 100644
--- a/Core/src/org/sleuthkit/autopsy/discovery/search/DiscoveryEventUtils.java
+++ b/Core/src/org/sleuthkit/autopsy/discovery/search/DiscoveryEventUtils.java
@@ -19,12 +19,14 @@
 package org.sleuthkit.autopsy.discovery.search;
 
 import com.google.common.eventbus.EventBus;
+import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
 import java.util.Map;
 import org.sleuthkit.autopsy.discovery.search.DiscoveryKeyUtils.GroupKey;
 import org.sleuthkit.autopsy.discovery.search.SearchData.Type;
 import org.sleuthkit.datamodel.AbstractFile;
+import org.sleuthkit.datamodel.BlackboardArtifact;
 
 /**
  * Class to handle event bus and events for discovery tool.
@@ -88,13 +90,13 @@ public ClearInstanceSelectionEvent() {
             //no arg constructor
         }
     }
-    
+
     /**
-     * Event to signal that any background tasks currently running should
-     * be cancelled.
+     * Event to signal that any background tasks currently running should be
+     * cancelled.
      */
     public static final class CancelBackgroundTasksEvent {
-        
+
         public CancelBackgroundTasksEvent() {
             //no-arg constructor
         }
@@ -124,6 +126,30 @@ public List<AbstractFile> getInstances() {
         }
     }
 
+    /**
+     * Event to signal that the list should be populated.
+     */
+    public static final class PopulateDomainTabsEvent {
+
+        private final String domain;
+
+        /**
+         * Construct a new PopulateDomainTabsEvent.
+         */
+        public PopulateDomainTabsEvent(String domain) {
+            this.domain = domain;
+        }
+
+        /**
+         * Get the domain for the details area.
+         *
+         * @return The the domain for the details area.
+         */
+        public String getDomain() {
+            return domain;
+        }
+    }
+
     /**
      * Event to signal the completion of a search being performed.
      */
@@ -203,6 +229,47 @@ public ResultsSorter.SortingMethod getResultSort() {
 
     }
 
+    /**
+     * Event to signal the completion of a search being performed.
+     */
+    public static final class ArtifactSearchResultEvent {
+
+        private final List<BlackboardArtifact> listOfArtifacts = new ArrayList<>();
+        private final BlackboardArtifact.ARTIFACT_TYPE artifactType;
+
+        /**
+         * Construct a new ArtifactSearchResultEvent with a list of specified
+         * artifacts and an artifact type.
+         *
+         * @param artifactType    The type of artifacts in the list.
+         * @param listOfArtifacts The list of artifacts retrieved.
+         */
+        public ArtifactSearchResultEvent(BlackboardArtifact.ARTIFACT_TYPE artifactType, List<BlackboardArtifact> listOfArtifacts) {
+            if (listOfArtifacts != null) {
+                this.listOfArtifacts.addAll(listOfArtifacts);
+            }
+            this.artifactType = artifactType;
+        }
+
+        /**
+         * Get the list of artifacts included in the event.
+         *
+         * @return The list of artifacts retrieved.
+         */
+        public List<BlackboardArtifact> getListOfArtifacts() {
+            return Collections.unmodifiableList(listOfArtifacts);
+        }
+
+        /**
+         * Get the type of BlackboardArtifact type of which exist in the list.
+         *
+         * @return The BlackboardArtifact type of which exist in the list.
+         */
+        public BlackboardArtifact.ARTIFACT_TYPE getArtifactType() {
+            return artifactType;
+        }
+    }
+
     /**
      * Event to signal the completion of page retrieval and include the page
      * contents.
diff --git a/Core/src/org/sleuthkit/autopsy/discovery/search/SearchFiltering.java b/Core/src/org/sleuthkit/autopsy/discovery/search/SearchFiltering.java
index 9505cfa5e285ee1c4a71a39485d5b05886e9d577..f39bb56634c43253db457ba9b11f5c329d64ef40 100644
--- a/Core/src/org/sleuthkit/autopsy/discovery/search/SearchFiltering.java
+++ b/Core/src/org/sleuthkit/autopsy/discovery/search/SearchFiltering.java
@@ -33,6 +33,7 @@
 import org.sleuthkit.datamodel.TskCoreException;
 import java.util.ArrayList;
 import java.util.Arrays;
+import java.util.Collections;
 import java.util.Date;
 import java.util.List;
 import java.util.Locale;
@@ -217,6 +218,15 @@ public ArtifactTypeFilter(List<ARTIFACT_TYPE> types) {
             this.types = types;
         }
 
+        /**
+         * Get the list of artifact types specified by the filter.
+         *
+         * @return The list of artifact types specified by the filter.
+         */
+        public List<ARTIFACT_TYPE> getTypes() {
+            return Collections.unmodifiableList(types);
+        }
+
         @Override
         public String getWhereClause() {
             StringJoiner joiner = new StringJoiner(",");
diff --git a/Core/src/org/sleuthkit/autopsy/discovery/ui/AbstractArtifactDetailsPanel.java b/Core/src/org/sleuthkit/autopsy/discovery/ui/AbstractArtifactDetailsPanel.java
new file mode 100644
index 0000000000000000000000000000000000000000..b36e93728a93e4ae57136532b4f2eee9c9a11300
--- /dev/null
+++ b/Core/src/org/sleuthkit/autopsy/discovery/ui/AbstractArtifactDetailsPanel.java
@@ -0,0 +1,41 @@
+/*
+ * Autopsy
+ *
+ * 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.autopsy.discovery.ui;
+
+import javax.swing.JPanel;
+import org.sleuthkit.autopsy.coreutils.ThreadConfined;
+import org.sleuthkit.datamodel.BlackboardArtifact;
+
+/**
+ * Class for ensuring all ArtifactDetailsPanels have a setArtifact method.
+ *
+ */
+public abstract class AbstractArtifactDetailsPanel extends JPanel {
+
+    private static final long serialVersionUID = 1L;
+
+    /**
+     * Called to display the contents of the given artifact.
+     *
+     * @param artifact the artifact to display.
+     */
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
+    abstract public void setArtifact(BlackboardArtifact artifact);
+
+}
diff --git a/Core/src/org/sleuthkit/autopsy/discovery/ui/AbstractDiscoveryFilterPanel.java b/Core/src/org/sleuthkit/autopsy/discovery/ui/AbstractDiscoveryFilterPanel.java
index 3e4ab4559261f45bd37a0c69711629aba68a2154..7fc964ad52c8f9cda3d6ddcc9d2c639126f6ae4e 100644
--- a/Core/src/org/sleuthkit/autopsy/discovery/ui/AbstractDiscoveryFilterPanel.java
+++ b/Core/src/org/sleuthkit/autopsy/discovery/ui/AbstractDiscoveryFilterPanel.java
@@ -24,6 +24,7 @@
 import javax.swing.JLabel;
 import javax.swing.JList;
 import javax.swing.event.ListSelectionListener;
+import org.sleuthkit.autopsy.coreutils.ThreadConfined;
 
 /**
  * Abstract class extending JPanel for filter controls.
@@ -41,6 +42,7 @@ abstract class AbstractDiscoveryFilterPanel extends javax.swing.JPanel {
      *                        selected, null to indicate leaving selected items
      *                        unchanged or that there are no items to select.
      */
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     abstract void configurePanel(boolean selected, int[] indicesSelected);
 
     /**
@@ -48,6 +50,7 @@ abstract class AbstractDiscoveryFilterPanel extends javax.swing.JPanel {
      *
      * @return The JCheckBox which enables and disables this filter.
      */
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     abstract JCheckBox getCheckbox();
 
     /**
@@ -57,6 +60,7 @@ abstract class AbstractDiscoveryFilterPanel extends javax.swing.JPanel {
      * @return The JList which contains the values available for selection for
      *         this filter.
      */
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     abstract JList<?> getList();
 
     /**
@@ -65,6 +69,7 @@ abstract class AbstractDiscoveryFilterPanel extends javax.swing.JPanel {
      *
      * @return The JLabel to display under the JCheckBox.
      */
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     abstract JLabel getAdditionalLabel();
 
     /**
@@ -73,6 +78,7 @@ abstract class AbstractDiscoveryFilterPanel extends javax.swing.JPanel {
      * @return If the settings are invalid returns the error that has occurred,
      *         otherwise returns empty string.
      */
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     abstract String checkForError();
 
     /**
@@ -82,6 +88,7 @@ abstract class AbstractDiscoveryFilterPanel extends javax.swing.JPanel {
      * @param actionlistener The listener for the checkbox selection events.
      * @param listListener   The listener for the list selection events.
      */
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     void addListeners(ActionListener actionListener, ListSelectionListener listListener) {
         if (getCheckbox() != null) {
             getCheckbox().addActionListener(actionListener);
@@ -97,11 +104,13 @@ void addListeners(ActionListener actionListener, ListSelectionListener listListe
      * @return The AbstractFilter for the selected settings, null if the
      *         settings are not in use.
      */
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     abstract AbstractFilter getFilter();
 
     /**
      * Remove listeners from the checkbox and the list if they exist.
      */
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     void removeListeners() {
         if (getCheckbox() != null) {
             for (ActionListener listener : getCheckbox().getActionListeners()) {
diff --git a/Core/src/org/sleuthkit/autopsy/discovery/ui/AbstractFiltersPanel.java b/Core/src/org/sleuthkit/autopsy/discovery/ui/AbstractFiltersPanel.java
index 6e8137a6ed3c9bae05c28b9aeb4bdc9c4824fbed..c8f71a5d99f4224ba12aa7a2a66fe2a17224a1b6 100644
--- a/Core/src/org/sleuthkit/autopsy/discovery/ui/AbstractFiltersPanel.java
+++ b/Core/src/org/sleuthkit/autopsy/discovery/ui/AbstractFiltersPanel.java
@@ -32,6 +32,7 @@
 import javax.swing.event.ListSelectionEvent;
 import javax.swing.event.ListSelectionListener;
 import org.apache.commons.lang3.StringUtils;
+import org.sleuthkit.autopsy.coreutils.ThreadConfined;
 import org.sleuthkit.autopsy.discovery.search.DiscoveryAttributes.GroupingAttributeType;
 import org.sleuthkit.autopsy.discovery.search.Group;
 import org.sleuthkit.autopsy.discovery.search.ResultsSorter.SortingMethod;
@@ -65,6 +66,7 @@ abstract class AbstractFiltersPanel extends JPanel implements ActionListener, Li
     /**
      * Setup necessary for implementations of this abstract class.
      */
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     AbstractFiltersPanel() {
         firstColumnPanel.setLayout(new GridBagLayout());
         secondColumnPanel.setLayout(new GridBagLayout());
@@ -75,6 +77,7 @@ abstract class AbstractFiltersPanel extends JPanel implements ActionListener, Li
      *
      * @return The type of results this panel filters.
      */
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     abstract SearchData.Type getType();
 
     /**
@@ -88,7 +91,8 @@ abstract class AbstractFiltersPanel extends JPanel implements ActionListener, Li
      *                        list, null if none are selected.
      * @param column          The column to add the DiscoveryFilterPanel to.
      */
-    final synchronized void addFilter(AbstractDiscoveryFilterPanel filterPanel, boolean isSelected, int[] indicesSelected, int column) {
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
+    final void addFilter(AbstractDiscoveryFilterPanel filterPanel, boolean isSelected, int[] indicesSelected, int column) {
         if (!isInitialized) {
             constraints.gridy = 0;
             constraints.anchor = GridBagConstraints.FIRST_LINE_START;
@@ -132,6 +136,7 @@ final synchronized void addFilter(AbstractDiscoveryFilterPanel filterPanel, bool
      *
      * @param splitPane The JSplitPane which the columns are added to.
      */
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     final void addPanelsToScrollPane(JSplitPane splitPane) {
         splitPane.setLeftComponent(firstColumnPanel);
         splitPane.setRightComponent(secondColumnPanel);
@@ -142,7 +147,8 @@ final void addPanelsToScrollPane(JSplitPane splitPane) {
     /**
      * Clear the filters from the panel
      */
-    final synchronized void clearFilters() {
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
+    final void clearFilters() {
         for (AbstractDiscoveryFilterPanel filterPanel : filters) {
             filterPanel.removeListeners();
         }
@@ -159,6 +165,7 @@ final synchronized void clearFilters() {
      *                                 column.
      * @param columnIndex              The column to add the Component to.
      */
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     private void addToGridBagLayout(Component componentToAdd, Component additionalComponentToAdd, int columnIndex) {
         addToColumn(componentToAdd, columnIndex);
         if (additionalComponentToAdd != null) {
@@ -174,6 +181,7 @@ private void addToGridBagLayout(Component componentToAdd, Component additionalCo
      * @param component    The Component to add.
      * @param columnNumber The column to add the Component to.
      */
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     private void addToColumn(Component component, int columnNumber) {
         if (columnNumber == 0) {
             firstColumnPanel.add(component, constraints);
@@ -186,7 +194,8 @@ private void addToColumn(Component component, int columnNumber) {
      * Check if the fields are valid, and fire a property change event to
      * indicate any errors.
      */
-    synchronized void validateFields() {
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
+    void validateFields() {
         String errorString = null;
         for (AbstractDiscoveryFilterPanel filterPanel : filters) {
             errorString = filterPanel.checkForError();
@@ -197,6 +206,7 @@ synchronized void validateFields() {
         firePropertyChange("FilterError", null, errorString);
     }
 
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     @Override
     public void actionPerformed(ActionEvent e) {
         validateFields();
@@ -209,6 +219,7 @@ public void actionPerformed(ActionEvent e) {
      *
      * @return True if the ObjectsDetectedFilter is supported, false otherwise.
      */
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     boolean isObjectsFilterSupported() {
         for (AbstractDiscoveryFilterPanel filter : filters) {
             if (filter instanceof ObjectDetectedFilterPanel) {
@@ -223,6 +234,7 @@ boolean isObjectsFilterSupported() {
      *
      * @return True if the HashSetFilter is supported, false otherwise.
      */
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     boolean isHashSetFilterSupported() {
         for (AbstractDiscoveryFilterPanel filter : filters) {
             if (filter instanceof HashSetFilterPanel) {
@@ -237,6 +249,7 @@ boolean isHashSetFilterSupported() {
      *
      * @return True if the InterestingItemsFilter is supported, false otherwise.
      */
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     boolean isInterestingItemsFilterSupported() {
         for (AbstractDiscoveryFilterPanel filter : filters) {
             if (filter instanceof InterestingItemsFilterPanel) {
@@ -251,8 +264,8 @@ boolean isInterestingItemsFilterSupported() {
      *
      * @return The list of filters selected by the user.
      */
-    synchronized List<AbstractFilter> getFilters() {
-
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
+    List<AbstractFilter> getFilters() {
         List<AbstractFilter> filtersToUse = new ArrayList<>();
         if (getType() != SearchData.Type.DOMAIN) { //Domain type does not have a file type
             filtersToUse.add(new SearchFiltering.FileTypeFilter(getType()));
@@ -268,6 +281,7 @@ synchronized List<AbstractFilter> getFilters() {
         return filtersToUse;
     }
 
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     @Override
     public void valueChanged(ListSelectionEvent evt) {
         if (!evt.getValueIsAdjusting()) {
@@ -282,6 +296,7 @@ public void valueChanged(ListSelectionEvent evt) {
      *
      * @return The most recently used sorting method.
      */
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     SortingMethod getLastSortingMethod() {
         return lastSortingMethod;
     }
@@ -291,6 +306,7 @@ SortingMethod getLastSortingMethod() {
      *
      * @param lastSortingMethod The most recently used sorting method.
      */
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     final void setLastSortingMethod(SortingMethod lastSortingMethod) {
         this.lastSortingMethod = lastSortingMethod;
     }
@@ -300,6 +316,7 @@ final void setLastSortingMethod(SortingMethod lastSortingMethod) {
      *
      * @return The most recently used grouping attribute.
      */
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     GroupingAttributeType getLastGroupingAttributeType() {
         return lastGroupingAttributeType;
     }
@@ -310,6 +327,7 @@ GroupingAttributeType getLastGroupingAttributeType() {
      * @param lastGroupingAttributeType The most recently used grouping
      *                                  attribute.
      */
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     final void setLastGroupingAttributeType(GroupingAttributeType lastGroupingAttributeType) {
         this.lastGroupingAttributeType = lastGroupingAttributeType;
     }
@@ -319,6 +337,7 @@ final void setLastGroupingAttributeType(GroupingAttributeType lastGroupingAttrib
      *
      * @return The most recently used group sorting algorithm.
      */
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     Group.GroupSortingAlgorithm getLastGroupSortingAlg() {
         return lastGroupSortingAlg;
     }
@@ -329,6 +348,7 @@ Group.GroupSortingAlgorithm getLastGroupSortingAlg() {
      * @param lastGroupSortingAlg The most recently used group sorting
      *                            algorithm.
      */
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     final void setLastGroupSortingAlg(Group.GroupSortingAlgorithm lastGroupSortingAlg) {
         this.lastGroupSortingAlg = lastGroupSortingAlg;
     }
diff --git a/Core/src/org/sleuthkit/autopsy/discovery/ui/ArtifactTypeFilterPanel.java b/Core/src/org/sleuthkit/autopsy/discovery/ui/ArtifactTypeFilterPanel.java
index 089c18bf13ca6d6571acc2a12116f3b72d5d435c..e6a1ccaaed101cc879220c1bb4cb76d3120e3201 100644
--- a/Core/src/org/sleuthkit/autopsy/discovery/ui/ArtifactTypeFilterPanel.java
+++ b/Core/src/org/sleuthkit/autopsy/discovery/ui/ArtifactTypeFilterPanel.java
@@ -26,6 +26,7 @@
 import javax.swing.JLabel;
 import javax.swing.JList;
 import org.openide.util.NbBundle;
+import org.sleuthkit.autopsy.coreutils.ThreadConfined;
 import org.sleuthkit.autopsy.discovery.search.SearchData;
 import org.sleuthkit.autopsy.discovery.search.SearchFiltering.ArtifactTypeFilter;
 import org.sleuthkit.datamodel.BlackboardArtifact;
@@ -40,6 +41,7 @@ class ArtifactTypeFilterPanel extends AbstractDiscoveryFilterPanel {
     /**
      * Creates new form ArtifactTypeFilterPanel
      */
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     ArtifactTypeFilterPanel() {
         initComponents();
         setUpArtifactTypeFilter();
@@ -49,6 +51,7 @@ class ArtifactTypeFilterPanel extends AbstractDiscoveryFilterPanel {
     /**
      * Initialize the data source filter.
      */
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     private void setUpArtifactTypeFilter() {
         int count = 0;
         DefaultListModel<ArtifactTypeItem> artifactTypeModel = (DefaultListModel<ArtifactTypeItem>) artifactList.getModel();
@@ -104,6 +107,7 @@ private void artifactTypeCheckboxActionPerformed(java.awt.event.ActionEvent evt)
         artifactList.setEnabled(artifactTypeCheckbox.isSelected());
     }//GEN-LAST:event_artifactTypeCheckboxActionPerformed
 
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     @Override
     void configurePanel(boolean selected, int[] indicesSelected) {
         artifactTypeCheckbox.setSelected(selected);
@@ -119,11 +123,13 @@ void configurePanel(boolean selected, int[] indicesSelected) {
         }
     }
 
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     @Override
     JCheckBox getCheckbox() {
         return artifactTypeCheckbox;
     }
 
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     @Override
     JList<?> getList() {
         return artifactList;
@@ -134,6 +140,7 @@ JLabel getAdditionalLabel() {
         return null;
     }
 
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     @NbBundle.Messages({"ArtifactTypeFilterPanel.selectionNeeded.text=At least one Result type must be selected."})
     @Override
     String checkForError() {
@@ -143,6 +150,7 @@ String checkForError() {
         return "";
     }
 
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     @Override
     AbstractFilter getFilter() {
         if (artifactTypeCheckbox.isSelected() && !artifactList.getSelectedValuesList().isEmpty()) {
diff --git a/Core/src/org/sleuthkit/autopsy/discovery/ui/ArtifactsListPanel.form b/Core/src/org/sleuthkit/autopsy/discovery/ui/ArtifactsListPanel.form
new file mode 100644
index 0000000000000000000000000000000000000000..d5c94ead08fd41b2662ee0220975eb6f354c6455
--- /dev/null
+++ b/Core/src/org/sleuthkit/autopsy/discovery/ui/ArtifactsListPanel.form
@@ -0,0 +1,59 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+
+<Form version="1.6" maxVersion="1.9" type="org.netbeans.modules.form.forminfo.JPanelFormInfo">
+  <Properties>
+    <Property name="opaque" type="boolean" value="false"/>
+  </Properties>
+  <AuxValues>
+    <AuxValue name="FormSettings_autoResourcing" type="java.lang.Integer" value="1"/>
+    <AuxValue name="FormSettings_autoSetComponentName" type="java.lang.Boolean" value="false"/>
+    <AuxValue name="FormSettings_generateFQN" type="java.lang.Boolean" value="true"/>
+    <AuxValue name="FormSettings_generateMnemonicsCode" type="java.lang.Boolean" value="true"/>
+    <AuxValue name="FormSettings_i18nAutoMode" type="java.lang.Boolean" value="true"/>
+    <AuxValue name="FormSettings_layoutCodeTarget" type="java.lang.Integer" value="1"/>
+    <AuxValue name="FormSettings_listenerGenerationStyle" type="java.lang.Integer" value="0"/>
+    <AuxValue name="FormSettings_variablesLocal" type="java.lang.Boolean" value="false"/>
+    <AuxValue name="FormSettings_variablesModifier" type="java.lang.Integer" value="2"/>
+  </AuxValues>
+
+  <Layout>
+    <DimensionLayout dim="0">
+      <Group type="103" groupAlignment="0" attributes="0">
+          <Component id="jScrollPane1" pref="400" max="32767" attributes="0"/>
+      </Group>
+    </DimensionLayout>
+    <DimensionLayout dim="1">
+      <Group type="103" groupAlignment="0" attributes="0">
+          <Component id="jScrollPane1" alignment="0" pref="607" max="32767" attributes="0"/>
+      </Group>
+    </DimensionLayout>
+  </Layout>
+  <SubComponents>
+    <Container class="javax.swing.JScrollPane" name="jScrollPane1">
+      <Properties>
+        <Property name="border" type="javax.swing.border.Border" editor="org.netbeans.modules.form.editors2.BorderEditor">
+          <Border info="null"/>
+        </Property>
+      </Properties>
+      <AuxValues>
+        <AuxValue name="JavaCodeGenerator_VariableLocal" type="java.lang.Boolean" value="true"/>
+        <AuxValue name="JavaCodeGenerator_VariableModifier" type="java.lang.Integer" value="0"/>
+      </AuxValues>
+
+      <Layout class="org.netbeans.modules.form.compat2.layouts.support.JScrollPaneSupportLayout"/>
+      <SubComponents>
+        <Component class="javax.swing.JTable" name="jTable1">
+          <Properties>
+            <Property name="autoCreateRowSorter" type="boolean" value="true"/>
+            <Property name="model" type="javax.swing.table.TableModel" editor="org.netbeans.modules.form.RADConnectionPropertyEditor">
+              <Connection code="tableModel" type="code"/>
+            </Property>
+            <Property name="selectionModel" type="javax.swing.ListSelectionModel" editor="org.netbeans.modules.form.editors2.JTableSelectionModelEditor">
+              <JTableSelectionModel selectionMode="0"/>
+            </Property>
+          </Properties>
+        </Component>
+      </SubComponents>
+    </Container>
+  </SubComponents>
+</Form>
diff --git a/Core/src/org/sleuthkit/autopsy/discovery/ui/ArtifactsListPanel.java b/Core/src/org/sleuthkit/autopsy/discovery/ui/ArtifactsListPanel.java
new file mode 100644
index 0000000000000000000000000000000000000000..6235761d6e09276e74d447c6ef7df1d78f5b1ef2
--- /dev/null
+++ b/Core/src/org/sleuthkit/autopsy/discovery/ui/ArtifactsListPanel.java
@@ -0,0 +1,346 @@
+/*
+ * Autopsy
+ *
+ * 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.autopsy.discovery.ui;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.logging.Level;
+import javax.swing.JPanel;
+import javax.swing.event.ListSelectionListener;
+import javax.swing.table.AbstractTableModel;
+import org.apache.commons.io.FilenameUtils;
+import org.apache.commons.lang.StringUtils;
+import org.openide.util.NbBundle;
+import org.sleuthkit.autopsy.casemodule.Case;
+import org.sleuthkit.autopsy.coreutils.Logger;
+import org.sleuthkit.autopsy.coreutils.ThreadConfined;
+import org.sleuthkit.datamodel.BlackboardArtifact;
+import org.sleuthkit.datamodel.BlackboardAttribute;
+import org.sleuthkit.datamodel.TskCoreException;
+
+/**
+ * Panel to display list of artifacts for selected domain.
+ *
+ */
+class ArtifactsListPanel extends JPanel {
+
+    private static final long serialVersionUID = 1L;
+    private final DomainArtifactTableModel tableModel;
+    private static final Logger logger = Logger.getLogger(ArtifactsListPanel.class.getName());
+
+    /**
+     * Creates new form ArtifactsListPanel.
+     *
+     * @param artifactType The type of artifact displayed in this table.
+     */
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
+    ArtifactsListPanel(BlackboardArtifact.ARTIFACT_TYPE artifactType) {
+        tableModel = new DomainArtifactTableModel(artifactType);
+        initComponents();        
+        jTable1.getRowSorter().toggleSortOrder(0);
+        jTable1.getRowSorter().toggleSortOrder(0);
+    }
+
+    /**
+     * Add a listener to the table of artifacts to perform actions when an
+     * artifact is selected.
+     *
+     * @param listener The listener to add to the table of artifacts.
+     */
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
+    void addSelectionListener(ListSelectionListener listener) {
+        jTable1.getSelectionModel().addListSelectionListener(listener);
+    }
+
+    /**
+     * Remove a listener from the table of artifacts.
+     *
+     * @param listener The listener to remove from the table of artifacts.
+     */
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
+    void removeListSelectionListener(ListSelectionListener listener) {
+        jTable1.getSelectionModel().removeListSelectionListener(listener);
+    }
+
+    /**
+     * The artifact which is currently selected, null if no artifact is
+     * selected.
+     *
+     * @return The currently selected BlackboardArtifact or null if none is
+     *         selected.
+     */
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
+    BlackboardArtifact getSelectedArtifact() {
+        int selectedIndex = jTable1.getSelectionModel().getLeadSelectionIndex();
+        if (selectedIndex < jTable1.getSelectionModel().getMinSelectionIndex() || jTable1.getSelectionModel().getMaxSelectionIndex() < 0 || selectedIndex > jTable1.getSelectionModel().getMaxSelectionIndex()) {
+            return null;
+        }
+        return tableModel.getArtifactByRow(jTable1.convertRowIndexToModel(selectedIndex));
+    }
+
+    /**
+     * Whether the list of artifacts is empty.
+     *
+     * @return true if the list of artifacts is empty, false if there are
+     *         artifacts.
+     */
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
+    boolean isEmpty() {
+        return tableModel.getRowCount() <= 0;
+    }
+
+    /**
+     * Select the first available artifact in the list if it is not empty to
+     * populate the panel to the right.
+     */
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
+    void selectFirst() {
+        if (!isEmpty()) {
+            jTable1.setRowSelectionInterval(0, 0);
+        } else {
+            jTable1.clearSelection();
+        }
+    }
+
+    /**
+     * Add the specified list of artifacts to the list of artifacts which should
+     * be displayed.
+     *
+     * @param artifactList The list of artifacts to display.
+     */
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
+    void addArtifacts(List<BlackboardArtifact> artifactList) {
+        tableModel.setContents(artifactList);
+        jTable1.validate();
+        jTable1.repaint();
+        tableModel.fireTableDataChanged();
+    }
+
+    /**
+     * Remove all artifacts from the list of artifacts displayed.
+     */
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
+    void clearArtifacts() {
+        tableModel.setContents(new ArrayList<>());
+        tableModel.fireTableDataChanged();
+    }
+
+    /**
+     * This method is called from within the constructor to initialize the form.
+     * WARNING: Do NOT modify this code. The content of this method is always
+     * regenerated by the Form Editor.
+     */
+    // <editor-fold defaultstate="collapsed" desc="Generated Code">//GEN-BEGIN:initComponents
+    private void initComponents() {
+
+        javax.swing.JScrollPane jScrollPane1 = new javax.swing.JScrollPane();
+        jTable1 = new javax.swing.JTable();
+
+        setOpaque(false);
+
+        jScrollPane1.setBorder(null);
+
+        jTable1.setAutoCreateRowSorter(true);
+        jTable1.setModel(tableModel);
+        jTable1.setSelectionMode(javax.swing.ListSelectionModel.SINGLE_SELECTION);
+        jScrollPane1.setViewportView(jTable1);
+
+        javax.swing.GroupLayout layout = new javax.swing.GroupLayout(this);
+        this.setLayout(layout);
+        layout.setHorizontalGroup(
+            layout.createParallelGroup(javax.swing.GroupLayout.Alignment.LEADING)
+            .addComponent(jScrollPane1, javax.swing.GroupLayout.DEFAULT_SIZE, 400, Short.MAX_VALUE)
+        );
+        layout.setVerticalGroup(
+            layout.createParallelGroup(javax.swing.GroupLayout.Alignment.LEADING)
+            .addComponent(jScrollPane1, javax.swing.GroupLayout.DEFAULT_SIZE, 607, Short.MAX_VALUE)
+        );
+    }// </editor-fold>//GEN-END:initComponents
+
+    /**
+     * Table model which allows the artifact table in this panel to mimic a list
+     * of artifacts.
+     */
+    private class DomainArtifactTableModel extends AbstractTableModel {
+
+        private static final long serialVersionUID = 1L;
+        private final List<BlackboardArtifact> artifactList = new ArrayList<>();
+        private final BlackboardArtifact.ARTIFACT_TYPE artifactType;
+
+        /**
+         * Construct a new DomainArtifactTableModel.
+         *
+         * @param artifactType The type of artifact displayed in this table.
+         */
+        @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
+        DomainArtifactTableModel(BlackboardArtifact.ARTIFACT_TYPE artifactType) {
+            this.artifactType = artifactType;
+        }
+
+        /**
+         * Set the list of artifacts which should be represented by this table
+         * model.
+         *
+         * @param artifacts The list of BlackboardArtifacts to represent.
+         */
+        @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
+        void setContents(List<BlackboardArtifact> artifacts) {
+            jTable1.clearSelection();
+            artifactList.clear();
+            artifactList.addAll(artifacts);
+        }
+
+        @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
+        @Override
+        public int getRowCount() {
+            return artifactList.size();
+        }
+
+        @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
+        @Override
+        public int getColumnCount() {
+            if (artifactType == BlackboardArtifact.ARTIFACT_TYPE.TSK_WEB_CACHE) {
+                return 3;
+            } else {
+                return 2;
+            }
+        }
+
+        /**
+         * Get the BlackboardArtifact at the specified row.
+         *
+         * @param rowIndex The row the artifact to return is at.
+         *
+         * @return The BlackboardArtifact at the specified row.
+         */
+        @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
+        BlackboardArtifact getArtifactByRow(int rowIndex) {
+            return artifactList.get(rowIndex);
+        }
+
+        @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
+        @NbBundle.Messages({"ArtifactsListPanel.value.noValue=No value available."})
+        @Override
+        public Object getValueAt(int rowIndex, int columnIndex) {
+            if (columnIndex < 2 || artifactType == BlackboardArtifact.ARTIFACT_TYPE.TSK_WEB_CACHE) {
+                try {
+                    for (BlackboardAttribute bba : getArtifactByRow(rowIndex).getAttributes()) {
+                        if (!StringUtils.isBlank(bba.getDisplayString())) {
+                            String stringFromAttribute = getStringForColumn(bba, columnIndex);
+                            if (!StringUtils.isBlank(stringFromAttribute)) {
+                                return stringFromAttribute;
+                            }
+                        }
+                    }
+                    return getFallbackValue(rowIndex, columnIndex);
+                } catch (TskCoreException ex) {
+                    logger.log(Level.WARNING, "Error getting attributes for artifact " + getArtifactByRow(rowIndex).getArtifactID(), ex);
+                }
+            }
+            return Bundle.ArtifactsListPanel_value_noValue();
+        }
+
+        /**
+         * Get the appropriate String for the specified column from the
+         * BlackboardAttribute.
+         *
+         * @param bba         The BlackboardAttribute which may contain a value.
+         * @param columnIndex The column the value will be displayed in.
+         *
+         * @return The value from the specified attribute which should be
+         *         displayed in the specified column, null if the specified
+         *         attribute does not contain a value for that column.
+         *
+         * @throws TskCoreException When unable to get abstract files based on
+         *                          the TSK_PATH_ID.
+         */
+        @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
+        private String getStringForColumn(BlackboardAttribute bba, int columnIndex) throws TskCoreException {
+            if (columnIndex == 0 && bba.getAttributeType().getTypeID() == BlackboardAttribute.ATTRIBUTE_TYPE.TSK_DATETIME_ACCESSED.getTypeID()) {
+                return bba.getDisplayString();
+            } else if (columnIndex == 1) {
+                if (artifactType == BlackboardArtifact.ARTIFACT_TYPE.TSK_WEB_DOWNLOAD || artifactType == BlackboardArtifact.ARTIFACT_TYPE.TSK_WEB_CACHE) {
+                    if (bba.getAttributeType().getTypeID() == BlackboardAttribute.ATTRIBUTE_TYPE.TSK_PATH_ID.getTypeID()) {
+                        return Case.getCurrentCase().getSleuthkitCase().getAbstractFileById(bba.getValueLong()).getName();
+                    } else if (bba.getAttributeType().getTypeID() == BlackboardAttribute.ATTRIBUTE_TYPE.TSK_PATH.getTypeID()) {
+                        return FilenameUtils.getName(bba.getDisplayString());
+                    }
+                } else if (bba.getAttributeType().getTypeID() == BlackboardAttribute.ATTRIBUTE_TYPE.TSK_TITLE.getTypeID()) {
+                    return bba.getDisplayString();
+                }
+            } else if (columnIndex == 2 && bba.getAttributeType().getTypeID() == BlackboardAttribute.ATTRIBUTE_TYPE.TSK_PATH_ID.getTypeID()) {
+                return Case.getCurrentCase().getSleuthkitCase().getAbstractFileById(bba.getValueLong()).getMIMEType();
+            }
+            return null;
+        }
+
+        /**
+         * Private helper method to use when the value we want for either date
+         * or title is not available.
+         *
+         *
+         * @param rowIndex    The row the artifact to return is at.
+         * @param columnIndex The column index the attribute will be displayed
+         *                    at.
+         *
+         * @return A string that can be used in place of the accessed date time
+         *         attribute title when they are not available.
+         *
+         * @throws TskCoreException
+         */
+        @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
+        private String getFallbackValue(int rowIndex, int columnIndex) throws TskCoreException {
+            for (BlackboardAttribute bba : getArtifactByRow(rowIndex).getAttributes()) {
+                if (columnIndex == 0 && bba.getAttributeType().getTypeName().startsWith("TSK_DATETIME") && !StringUtils.isBlank(bba.getDisplayString())) {
+                    return bba.getDisplayString();
+                } else if (columnIndex == 1 && bba.getAttributeType().getTypeID() == BlackboardAttribute.ATTRIBUTE_TYPE.TSK_URL.getTypeID() && !StringUtils.isBlank(bba.getDisplayString())) {
+                    return bba.getDisplayString();
+                }
+            }
+            return Bundle.ArtifactsListPanel_value_noValue();
+        }
+
+        @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
+        @NbBundle.Messages({"ArtifactsListPanel.titleColumn.name=Title",
+            "ArtifactsListPanel.fileNameColumn.name=Name",
+            "ArtifactsListPanel.dateColumn.name=Date/Time",
+            "ArtifactsListPanel.mimeTypeColumn.name=MIME Type"})
+        @Override
+        public String getColumnName(int column) {
+            switch (column) {
+                case 0:
+                    return Bundle.ArtifactsListPanel_dateColumn_name();
+                case 1:
+                    if (artifactType == BlackboardArtifact.ARTIFACT_TYPE.TSK_WEB_CACHE || artifactType == BlackboardArtifact.ARTIFACT_TYPE.TSK_WEB_DOWNLOAD) {
+                        return Bundle.ArtifactsListPanel_fileNameColumn_name();
+                    } else {
+                        return Bundle.ArtifactsListPanel_titleColumn_name();
+                    }
+                case 2:
+                    return Bundle.ArtifactsListPanel_mimeTypeColumn_name();
+                default:
+                    return "";
+            }
+        }
+    }
+
+    // Variables declaration - do not modify//GEN-BEGIN:variables
+    private javax.swing.JTable jTable1;
+    // End of variables declaration//GEN-END:variables
+}
diff --git a/Core/src/org/sleuthkit/autopsy/discovery/ui/ArtifactsWorker.java b/Core/src/org/sleuthkit/autopsy/discovery/ui/ArtifactsWorker.java
new file mode 100644
index 0000000000000000000000000000000000000000..9a47e164cfa368473902c527c8e4355b1e89d0d5
--- /dev/null
+++ b/Core/src/org/sleuthkit/autopsy/discovery/ui/ArtifactsWorker.java
@@ -0,0 +1,80 @@
+/*
+ * Autopsy
+ *
+ * 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.autopsy.discovery.ui;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.CancellationException;
+import java.util.concurrent.ExecutionException;
+import java.util.logging.Level;
+import javax.swing.SwingWorker;
+import org.apache.commons.lang3.StringUtils;
+import org.sleuthkit.autopsy.casemodule.Case;
+import org.sleuthkit.autopsy.coreutils.Logger;
+import org.sleuthkit.autopsy.discovery.search.DiscoveryEventUtils;
+import org.sleuthkit.autopsy.discovery.search.DomainSearch;
+import org.sleuthkit.autopsy.discovery.search.DomainSearchArtifactsRequest;
+import org.sleuthkit.datamodel.BlackboardArtifact;
+
+/**
+ * SwingWorker to retrieve a list of artifacts for a specified type and domain.
+ */
+class ArtifactsWorker extends SwingWorker<List<BlackboardArtifact>, Void> {
+
+    private final BlackboardArtifact.ARTIFACT_TYPE artifactType;
+    private final static Logger logger = Logger.getLogger(ArtifactsWorker.class.getName());
+    private final String domain;
+
+    /**
+     * Construct a new ArtifactsWorker.
+     *
+     * @param artifactType The type of artifact being retrieved.
+     * @param domain       The domain the artifacts should have as an attribute.
+     */
+    ArtifactsWorker(BlackboardArtifact.ARTIFACT_TYPE artifactType, String domain) {
+        this.artifactType = artifactType;
+        this.domain = domain;
+    }
+
+    @Override
+    protected List<BlackboardArtifact> doInBackground() throws Exception {
+        if (artifactType != null && !StringUtils.isBlank(domain)) {
+            DomainSearch domainSearch = new DomainSearch();
+            return domainSearch.getArtifacts(new DomainSearchArtifactsRequest(Case.getCurrentCase().getSleuthkitCase(), domain, artifactType));
+        }
+        return new ArrayList<>();
+    }
+
+    @Override
+    protected void done() {
+        List<BlackboardArtifact> listOfArtifacts = new ArrayList<>();
+        if (!isCancelled()) {
+            try {
+                listOfArtifacts.addAll(get());
+            } catch (InterruptedException | ExecutionException ex) {
+                logger.log(Level.SEVERE, "Exception while trying to get list of artifacts for Domain details for artifact type: "
+                        + artifactType.getDisplayName() + " and domain: " + domain, ex);
+            } catch (CancellationException ignored) {
+                //Worker was cancelled after previously finishing its background work, exception ignored to cut down on non-helpful logging
+            }
+        }
+        DiscoveryEventUtils.getDiscoveryEventBus().post(new DiscoveryEventUtils.ArtifactSearchResultEvent(artifactType, listOfArtifacts));
+    }
+
+}
diff --git a/Core/src/org/sleuthkit/autopsy/discovery/ui/Bundle.properties b/Core/src/org/sleuthkit/autopsy/discovery/ui/Bundle.properties
index 5fc4ff56e6fcaa8f3a314bd72972e0e084e66c84..851045c71aa72ae32db8e550329d9628e15379dc 100644
--- a/Core/src/org/sleuthkit/autopsy/discovery/ui/Bundle.properties
+++ b/Core/src/org/sleuthkit/autopsy/discovery/ui/Bundle.properties
@@ -51,10 +51,12 @@ HashSetFilterPanel.hashSetCheckbox.text=Hash Set:
 PastOccurrencesFilterPanel.pastOccurrencesCheckbox.text=Past Occurrences:
 DocumentFilterPanel.documentsFiltersSplitPane.border.title=Step 2: Filter which documents to show
 ObjectDetectedFilterPanel.text=Object Detected:
-DetailsPanel.instancesList.border.title=Instances
 DateFilterPanel.mostRecentRadioButton.text=Only last:
 DateFilterPanel.dateFilterCheckBox.text=Date Filter:
 DomainSummaryPanel.activityLabel.text=
 DomainSummaryPanel.pagesLabel.text=
 DomainSummaryPanel.filesDownloadedLabel.text=
 DomainSummaryPanel.totalVisitsLabel.text=
+FileDetailsPanel.instancesList.border.title=Instances
+CookieDetailsPanel.jLabel1.text=Artifact:
+CookieDetailsPanel.jLabel2.text=
diff --git a/Core/src/org/sleuthkit/autopsy/discovery/ui/Bundle.properties-MERGED b/Core/src/org/sleuthkit/autopsy/discovery/ui/Bundle.properties-MERGED
index 7c2c5e6757e9470c0399e5a0585d7728a5547097..a5305503b5a04d6e376cd5f9a8e799d702e34b2e 100644
--- a/Core/src/org/sleuthkit/autopsy/discovery/ui/Bundle.properties-MERGED
+++ b/Core/src/org/sleuthkit/autopsy/discovery/ui/Bundle.properties-MERGED
@@ -1,3 +1,8 @@
+ArtifactsListPanel.dateColumn.name=Date/Time
+ArtifactsListPanel.fileNameColumn.name=Name
+ArtifactsListPanel.mimeTypeColumn.name=MIME Type
+ArtifactsListPanel.titleColumn.name=Title
+ArtifactsListPanel.value.noValue=No value available.
 ArtifactTypeFilterPanel.selectionNeeded.text=At least one Result type must be selected.
 CTL_OpenDiscoveryAction=Discovery
 DataSourceFilterPanel.error.text=At least one data source must be selected.
@@ -126,13 +131,15 @@ HashSetFilterPanel.hashSetCheckbox.text=Hash Set:
 PastOccurrencesFilterPanel.pastOccurrencesCheckbox.text=Past Occurrences:
 DocumentFilterPanel.documentsFiltersSplitPane.border.title=Step 2: Filter which documents to show
 ObjectDetectedFilterPanel.text=Object Detected:
-DetailsPanel.instancesList.border.title=Instances
 DateFilterPanel.mostRecentRadioButton.text=Only last:
 DateFilterPanel.dateFilterCheckBox.text=Date Filter:
 DomainSummaryPanel.activityLabel.text=
 DomainSummaryPanel.pagesLabel.text=
 DomainSummaryPanel.filesDownloadedLabel.text=
 DomainSummaryPanel.totalVisitsLabel.text=
+FileDetailsPanel.instancesList.border.title=Instances
+CookieDetailsPanel.jLabel1.text=Artifact:
+CookieDetailsPanel.jLabel2.text=
 VideoThumbnailPanel.bytes.text=bytes
 VideoThumbnailPanel.deleted.text=All instances of file are deleted.
 VideoThumbnailPanel.gigaBytes.text=GB
@@ -144,3 +151,7 @@ VideoThumbnailPanel.nameLabel.more.text=\ and {0} more
 # {1} - units
 VideoThumbnailPanel.sizeLabel.text=Size: {0} {1}
 VideoThumbnailPanel.terraBytes.text=TB
+WebHistoryDetailsPanel.details.attrHeader=Attributes
+WebHistoryDetailsPanel.details.dataSource=Data Source
+WebHistoryDetailsPanel.details.file=File
+WebHistoryDetailsPanel.details.sourceHeader=Source
diff --git a/Core/src/org/sleuthkit/autopsy/discovery/ui/ContentViewerDetailsPanel.form b/Core/src/org/sleuthkit/autopsy/discovery/ui/ContentViewerDetailsPanel.form
new file mode 100644
index 0000000000000000000000000000000000000000..2c7924e2a4178535a69140e08538958b1ff20ca0
--- /dev/null
+++ b/Core/src/org/sleuthkit/autopsy/discovery/ui/ContentViewerDetailsPanel.form
@@ -0,0 +1,18 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+
+<Form version="1.4" maxVersion="1.9" type="org.netbeans.modules.form.forminfo.JPanelFormInfo">
+  <AuxValues>
+    <AuxValue name="FormSettings_autoResourcing" type="java.lang.Integer" value="1"/>
+    <AuxValue name="FormSettings_autoSetComponentName" type="java.lang.Boolean" value="false"/>
+    <AuxValue name="FormSettings_generateFQN" type="java.lang.Boolean" value="true"/>
+    <AuxValue name="FormSettings_generateMnemonicsCode" type="java.lang.Boolean" value="true"/>
+    <AuxValue name="FormSettings_i18nAutoMode" type="java.lang.Boolean" value="true"/>
+    <AuxValue name="FormSettings_layoutCodeTarget" type="java.lang.Integer" value="1"/>
+    <AuxValue name="FormSettings_listenerGenerationStyle" type="java.lang.Integer" value="0"/>
+    <AuxValue name="FormSettings_variablesLocal" type="java.lang.Boolean" value="false"/>
+    <AuxValue name="FormSettings_variablesModifier" type="java.lang.Integer" value="2"/>
+    <AuxValue name="designerSize" type="java.awt.Dimension" value="-84,-19,0,5,115,114,0,18,106,97,118,97,46,97,119,116,46,68,105,109,101,110,115,105,111,110,65,-114,-39,-41,-84,95,68,20,2,0,2,73,0,6,104,101,105,103,104,116,73,0,5,119,105,100,116,104,120,112,0,0,1,44,0,0,1,-112"/>
+  </AuxValues>
+
+  <Layout class="org.netbeans.modules.form.compat2.layouts.DesignBorderLayout"/>
+</Form>
diff --git a/Core/src/org/sleuthkit/autopsy/discovery/ui/ContentViewerDetailsPanel.java b/Core/src/org/sleuthkit/autopsy/discovery/ui/ContentViewerDetailsPanel.java
new file mode 100644
index 0000000000000000000000000000000000000000..81e4fcaddc68e17d246045389c606fae6721ebae
--- /dev/null
+++ b/Core/src/org/sleuthkit/autopsy/discovery/ui/ContentViewerDetailsPanel.java
@@ -0,0 +1,69 @@
+/*
+ * Autopsy
+ *
+ * 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.autopsy.discovery.ui;
+
+import org.openide.nodes.Node;
+import org.sleuthkit.autopsy.corecomponents.DataContentPanel;
+import org.sleuthkit.autopsy.coreutils.ThreadConfined;
+import org.sleuthkit.autopsy.datamodel.BlackboardArtifactNode;
+import org.sleuthkit.datamodel.BlackboardArtifact;
+
+/**
+ * Details panel for displaying the collection of content viewers.
+ */
+final class ContentViewerDetailsPanel extends AbstractArtifactDetailsPanel {
+
+    private static final long serialVersionUID = 1L;
+    private final DataContentPanel contentViewer = DataContentPanel.createInstance();
+
+    /**
+     * Creates new form ContentViewerDetailsPanel
+     */
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
+    ContentViewerDetailsPanel() {
+        initComponents();
+        add(contentViewer);
+    }
+
+    /**
+     * This method is called from within the constructor to initialize the form.
+     * WARNING: Do NOT modify this code. The content of this method is always
+     * regenerated by the Form Editor.
+     */
+    @SuppressWarnings("unchecked")
+    // <editor-fold defaultstate="collapsed" desc="Generated Code">//GEN-BEGIN:initComponents
+    private void initComponents() {
+
+        setLayout(new java.awt.BorderLayout());
+    }// </editor-fold>//GEN-END:initComponents
+
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
+    @Override
+    public void setArtifact(BlackboardArtifact artifact) {
+        Node node = Node.EMPTY;
+        if (artifact != null) {
+            node = new BlackboardArtifactNode(artifact);
+        }
+        contentViewer.setNode(node);
+    }
+
+
+    // Variables declaration - do not modify//GEN-BEGIN:variables
+    // End of variables declaration//GEN-END:variables
+}
diff --git a/Core/src/org/sleuthkit/autopsy/discovery/ui/DataSourceFilterPanel.java b/Core/src/org/sleuthkit/autopsy/discovery/ui/DataSourceFilterPanel.java
index ab54df4341216ba3d99e985bc093274824efd9f0..065c989ddbb6643a30315f7035f13b5253146409 100644
--- a/Core/src/org/sleuthkit/autopsy/discovery/ui/DataSourceFilterPanel.java
+++ b/Core/src/org/sleuthkit/autopsy/discovery/ui/DataSourceFilterPanel.java
@@ -30,6 +30,7 @@
 import org.openide.util.NbBundle;
 import org.sleuthkit.autopsy.casemodule.Case;
 import org.sleuthkit.autopsy.coreutils.Logger;
+import org.sleuthkit.autopsy.coreutils.ThreadConfined;
 import org.sleuthkit.autopsy.discovery.search.SearchFiltering;
 import org.sleuthkit.datamodel.DataSource;
 import org.sleuthkit.datamodel.TskCoreException;
@@ -45,6 +46,7 @@ final class DataSourceFilterPanel extends AbstractDiscoveryFilterPanel {
     /**
      * Creates new form DataSourceFilterPanel.
      */
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     DataSourceFilterPanel() {
         initComponents();
         setUpDataSourceFilter();
@@ -109,6 +111,7 @@ private void dataSourceCheckboxActionPerformed(java.awt.event.ActionEvent evt) {
     private javax.swing.JScrollPane dataSourceScrollPane;
     // End of variables declaration//GEN-END:variables
 
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     @Override
     void configurePanel(boolean selected, int[] indicesSelected) {
         dataSourceCheckbox.setSelected(selected);
@@ -124,6 +127,7 @@ void configurePanel(boolean selected, int[] indicesSelected) {
         }
     }
 
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     @Override
     JCheckBox getCheckbox() {
         return dataSourceCheckbox;
@@ -137,6 +141,7 @@ JLabel getAdditionalLabel() {
     /**
      * Initialize the data source filter.
      */
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     private void setUpDataSourceFilter() {
         int count = 0;
         try {
@@ -156,6 +161,7 @@ private void setUpDataSourceFilter() {
         }
     }
 
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     @Override
     JList<?> getList() {
         return dataSourceList;
@@ -193,6 +199,7 @@ public String toString() {
         }
     }
 
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     @NbBundle.Messages({"DataSourceFilterPanel.error.text=At least one data source must be selected."})
     @Override
     String checkForError() {
@@ -202,6 +209,7 @@ String checkForError() {
         return "";
     }
 
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     @Override
     AbstractFilter getFilter() {
         if (dataSourceCheckbox.isSelected()) {
diff --git a/Core/src/org/sleuthkit/autopsy/discovery/ui/DateFilterPanel.java b/Core/src/org/sleuthkit/autopsy/discovery/ui/DateFilterPanel.java
index 528a5d1bc290980661f6e6fcb38004ade4899900..2fbe6610ff02ad2673126eec7042c56e77515386 100644
--- a/Core/src/org/sleuthkit/autopsy/discovery/ui/DateFilterPanel.java
+++ b/Core/src/org/sleuthkit/autopsy/discovery/ui/DateFilterPanel.java
@@ -33,6 +33,7 @@
 import javax.swing.event.ListSelectionListener;
 import org.openide.util.NbBundle;
 import org.sleuthkit.autopsy.communications.Utils;
+import org.sleuthkit.autopsy.coreutils.ThreadConfined;
 import org.sleuthkit.autopsy.discovery.search.SearchFiltering;
 
 /**
@@ -48,6 +49,7 @@ class DateFilterPanel extends AbstractDiscoveryFilterPanel {
      */
     @NbBundle.Messages({"# {0} - timeZone",
         "DateFilterPanel.dateRange.text=Date Range ({0}):"})
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     DateFilterPanel() {
         initComponents();
         rangeRadioButton.setText(Bundle.DateFilterPanel_dateRange_text(Utils.getUserPreferredZoneId().toString()));
@@ -225,6 +227,7 @@ private void rangeRadioButtonStateChanged(javax.swing.event.ChangeEvent evt) {//
         endCheckBox.firePropertyChange("EndButtonChange", true, false);
     }//GEN-LAST:event_rangeRadioButtonStateChanged
 
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     @Override
     void configurePanel(boolean selected, int[] indicesSelected) {
         dateFilterCheckBox.setSelected(selected);
@@ -238,6 +241,7 @@ void configurePanel(boolean selected, int[] indicesSelected) {
         }
     }
 
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     @Override
     JCheckBox getCheckbox() {
         return dateFilterCheckBox;
@@ -253,6 +257,7 @@ JLabel getAdditionalLabel() {
         return null;
     }
 
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     @Override
     void addListeners(ActionListener actionListener, ListSelectionListener listListener) {
         dateFilterCheckBox.addActionListener(actionListener);
@@ -274,6 +279,7 @@ public void dateChanged(DateChangeEvent event) {
         });
     }
 
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     @Override
     void removeListeners() {
         for (ActionListener listener : dateFilterCheckBox.getActionListeners()) {
@@ -302,6 +308,7 @@ void removeListeners() {
         }
     }
 
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     @NbBundle.Messages({"DateFilterPanel.invalidRange.text=Range or Only Last must be selected.",
         "DateFilterPanel.startOrEndNeeded.text=A start or end date must be specified to use the range filter.",
         "DateFilterPanel.startAfterEnd.text=Start date should be before the end date when both are enabled."})
@@ -320,6 +327,7 @@ String checkForError() {
         return "";
     }
 
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     @Override
     AbstractFilter getFilter() {
         if (dateFilterCheckBox.isSelected()) {
diff --git a/Core/src/org/sleuthkit/autopsy/discovery/ui/DiscoveryDialog.java b/Core/src/org/sleuthkit/autopsy/discovery/ui/DiscoveryDialog.java
index f2f42bd1134576618ace5f9d15be54fc7d7e195d..565dad8a88184fb13c44035fca4bcfc9a229d1d7 100644
--- a/Core/src/org/sleuthkit/autopsy/discovery/ui/DiscoveryDialog.java
+++ b/Core/src/org/sleuthkit/autopsy/discovery/ui/DiscoveryDialog.java
@@ -40,6 +40,7 @@
 import org.sleuthkit.autopsy.centralrepository.datamodel.CentralRepoException;
 import org.sleuthkit.autopsy.centralrepository.datamodel.CentralRepository;
 import org.sleuthkit.autopsy.coreutils.Logger;
+import org.sleuthkit.autopsy.coreutils.ThreadConfined;
 import org.sleuthkit.autopsy.discovery.search.DiscoveryAttributes;
 import org.sleuthkit.autopsy.discovery.search.DiscoveryEventUtils;
 import org.sleuthkit.autopsy.discovery.search.Group;
@@ -99,6 +100,7 @@ static synchronized DiscoveryDialog getDiscoveryDialogInstance() {
     /**
      * Private constructor to construct a new DiscoveryDialog
      */
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     @Messages("DiscoveryDialog.name.text=Discovery")
     private DiscoveryDialog() {
         super(WindowManager.getDefault().getMainWindow(), Bundle.DiscoveryDialog_name_text(), true);
@@ -151,6 +153,7 @@ public void itemStateChanged(ItemEvent event) {
     /**
      * Update the search settings to a default state.
      */
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     void updateSearchSettings() {
         removeAllPanels();
         imageFilterPanel = null;
@@ -176,6 +179,7 @@ void updateSearchSettings() {
     /**
      * Set the type buttons to a default state where none are selected.
      */
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     private void unselectAllButtons() {
         imagesButton.setSelected(false);
         imagesButton.setEnabled(true);
@@ -194,6 +198,7 @@ private void unselectAllButtons() {
     /**
      * Private helper method to perform update of comboboxes update.
      */
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     private void updateComboBoxes() {
         // Set up the grouping attributes
         List<GroupingAttributeType> groupingAttrs = new ArrayList<>();
@@ -230,6 +235,7 @@ private void updateComboBoxes() {
      *
      * @return The panel that corresponds to the currently selected type.
      */
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     private AbstractFiltersPanel getSelectedFilterPanel() {
         switch (type) {
             case IMAGE:
@@ -251,6 +257,7 @@ private AbstractFiltersPanel getSelectedFilterPanel() {
      *
      * @param type The Type of GroupingAttribute to add.
      */
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     private void addTypeToGroupByComboBox(GroupingAttributeType type) {
         switch (type) {
             case FREQUENCY:
@@ -282,7 +289,8 @@ private void addTypeToGroupByComboBox(GroupingAttributeType type) {
     /**
      * Validate the filter settings for File type filters.
      */
-    synchronized void validateDialog() {
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
+    void validateDialog() {
         AbstractFiltersPanel panel = getSelectedFilterPanel();
         if (panel != null) {
             panel.validateFields();
@@ -551,6 +559,7 @@ private void documentsButtonActionPerformed(java.awt.event.ActionEvent evt) {//G
     /**
      * Helper method to remove all filter panels and their listeners
      */
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     private void removeAllPanels() {
         if (imageFilterPanel != null) {
             remove(imageFilterPanel);
@@ -635,6 +644,7 @@ private void domainsButtonActionPerformed(java.awt.event.ActionEvent evt) {//GEN
         repaint();
     }//GEN-LAST:event_domainsButtonActionPerformed
 
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     @Override
     public void dispose() {
         setVisible(false);
@@ -643,6 +653,7 @@ public void dispose() {
     /**
      * Cancel the searchWorker if it exists.
      */
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     void cancelSearch() {
         if (searchWorker != null) {
             searchWorker.cancel(true);
@@ -656,6 +667,7 @@ void cancelSearch() {
      * @param error The error message to display, empty string if there is no
      *              error.
      */
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     private void setValid(String error) {
         if (StringUtils.isBlank(error)) {
             errorLabel.setText("");
diff --git a/Core/src/org/sleuthkit/autopsy/discovery/ui/DiscoveryThumbnailChildren.java b/Core/src/org/sleuthkit/autopsy/discovery/ui/DiscoveryThumbnailChildren.java
index dce68e3a41b6a1bd520b385445d90f9ba3e72dda..229c7510e370831b2949a2e984b6057520c3e78d 100644
--- a/Core/src/org/sleuthkit/autopsy/discovery/ui/DiscoveryThumbnailChildren.java
+++ b/Core/src/org/sleuthkit/autopsy/discovery/ui/DiscoveryThumbnailChildren.java
@@ -27,6 +27,7 @@
 import org.openide.nodes.Node;
 import org.openide.nodes.Sheet;
 import org.openide.util.NbBundle;
+import org.sleuthkit.autopsy.coreutils.ThreadConfined;
 import org.sleuthkit.autopsy.datamodel.AbstractAbstractFileNode;
 import org.sleuthkit.autopsy.datamodel.FileNode;
 import org.sleuthkit.datamodel.AbstractFile;
@@ -42,28 +43,27 @@ class DiscoveryThumbnailChildren extends Children.Keys<AbstractFile> {
     /*
      * Creates the list of thumbnails from the given list of AbstractFiles.
      */
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     DiscoveryThumbnailChildren(List<AbstractFile> files) {
         super(false);
-
         this.files = files;
-
     }
 
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     @Override
     protected Node[] createNodes(AbstractFile t) {
         return new Node[]{new ThumbnailNode(t)};
     }
 
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     @Override
     protected void addNotify() {
         super.addNotify();
-
         Set<AbstractFile> thumbnails = new TreeSet<>((AbstractFile file1, AbstractFile file2) -> {
             int result = Long.compare(file1.getSize(), file2.getSize());
             if (result == 0) {
                 result = file1.getName().compareTo(file2.getName());
             }
-
             return result;
         });
         thumbnails.addAll(files);
@@ -75,10 +75,12 @@ protected void addNotify() {
      */
     static class ThumbnailNode extends FileNode {
 
+        @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
         ThumbnailNode(AbstractFile file) {
             super(file, false);
         }
 
+        @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
         @Override
         protected Sheet createSheet() {
             Sheet sheet = super.createSheet();
diff --git a/Core/src/org/sleuthkit/autopsy/discovery/ui/DiscoveryTopComponent.form b/Core/src/org/sleuthkit/autopsy/discovery/ui/DiscoveryTopComponent.form
index 54630599ec188533000e2ceebf07085dfe1f8b1c..c84525148c2710bfe1725d99fd6768beece3c73c 100644
--- a/Core/src/org/sleuthkit/autopsy/discovery/ui/DiscoveryTopComponent.form
+++ b/Core/src/org/sleuthkit/autopsy/discovery/ui/DiscoveryTopComponent.form
@@ -1,6 +1,6 @@
 <?xml version="1.0" encoding="UTF-8" ?>
 
-<Form version="1.4" maxVersion="1.9" type="org.netbeans.modules.form.forminfo.JPanelFormInfo">
+<Form version="1.5" maxVersion="1.9" type="org.netbeans.modules.form.forminfo.JPanelFormInfo">
   <Properties>
     <Property name="minimumSize" type="java.awt.Dimension" editor="org.netbeans.beaninfo.editors.DimensionEditor">
       <Dimension value="[199, 200]"/>
diff --git a/Core/src/org/sleuthkit/autopsy/discovery/ui/DiscoveryTopComponent.java b/Core/src/org/sleuthkit/autopsy/discovery/ui/DiscoveryTopComponent.java
index 03a271dcdd00878b9cc69c417a0a19f4cfc39a4d..371f329f0047bd6a0532285e604d44b7b76ddc29 100644
--- a/Core/src/org/sleuthkit/autopsy/discovery/ui/DiscoveryTopComponent.java
+++ b/Core/src/org/sleuthkit/autopsy/discovery/ui/DiscoveryTopComponent.java
@@ -28,6 +28,7 @@
 import java.util.List;
 import java.util.stream.Collectors;
 import javax.swing.JSplitPane;
+import javax.swing.SwingUtilities;
 import javax.swing.plaf.basic.BasicSplitPaneDivider;
 import javax.swing.plaf.basic.BasicSplitPaneUI;
 import org.openide.util.NbBundle;
@@ -40,6 +41,8 @@
 import org.sleuthkit.autopsy.discovery.search.DiscoveryEventUtils;
 import org.sleuthkit.autopsy.discovery.search.SearchData.Type;
 import static org.sleuthkit.autopsy.discovery.search.SearchData.Type.DOMAIN;
+import org.sleuthkit.autopsy.discovery.search.SearchFiltering.ArtifactTypeFilter;
+import org.sleuthkit.datamodel.BlackboardArtifact.ARTIFACT_TYPE;
 
 /**
  * Create a dialog for displaying the Discovery results.
@@ -55,8 +58,8 @@ public final class DiscoveryTopComponent extends TopComponent {
     private static final int ANIMATION_INCREMENT = 30;
     private volatile static int resultsAreaSize = 250;
     private final GroupListPanel groupListPanel;
-    private final DetailsPanel detailsPanel;
     private final ResultsPanel resultsPanel;
+    private String selectedDomainTabName;
     private Type searchType;
     private int dividerLocation = -1;
     private SwingAnimator animator = null;
@@ -70,10 +73,7 @@ public DiscoveryTopComponent() {
         setName(Bundle.DiscoveryTopComponent_name());
         groupListPanel = new GroupListPanel();
         resultsPanel = new ResultsPanel();
-        detailsPanel = new DetailsPanel();
         mainSplitPane.setLeftComponent(groupListPanel);
-        rightSplitPane.setTopComponent(resultsPanel);
-        rightSplitPane.setBottomComponent(detailsPanel);
         //set color of divider
         rightSplitPane.setUI(new BasicSplitPaneUI() {
             @Override
@@ -95,6 +95,7 @@ public void propertyChange(PropertyChangeEvent evt) {
                 }
             }
         });
+        rightSplitPane.setTopComponent(resultsPanel);
     }
 
     /**
@@ -108,6 +109,7 @@ private final class BasicSplitPaneDividerImpl extends BasicSplitPaneDivider {
          * @param ui The component which contains the split pane this divider is
          *           in.
          */
+        @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
         BasicSplitPaneDividerImpl(BasicSplitPaneUI ui) {
             super(ui);
             this.setLayout(new BorderLayout());
@@ -129,11 +131,13 @@ public static DiscoveryTopComponent getTopComponent() {
     /**
      * Reset the top component so it isn't displaying any results.
      */
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     public void resetTopComponent() {
         resultsPanel.resetResultViewer();
         groupListPanel.resetGroupList();
     }
 
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     @Override
     public void componentOpened() {
         super.componentOpened();
@@ -141,9 +145,9 @@ public void componentOpened() {
         DiscoveryEventUtils.getDiscoveryEventBus().register(this);
         DiscoveryEventUtils.getDiscoveryEventBus().register(resultsPanel);
         DiscoveryEventUtils.getDiscoveryEventBus().register(groupListPanel);
-        DiscoveryEventUtils.getDiscoveryEventBus().register(detailsPanel);
     }
 
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     @Override
     protected void componentClosed() {
         DiscoveryDialog.getDiscoveryDialogInstance().cancelSearch();
@@ -152,7 +156,10 @@ protected void componentClosed() {
         DiscoveryEventUtils.getDiscoveryEventBus().unregister(this);
         DiscoveryEventUtils.getDiscoveryEventBus().unregister(groupListPanel);
         DiscoveryEventUtils.getDiscoveryEventBus().unregister(resultsPanel);
-        DiscoveryEventUtils.getDiscoveryEventBus().unregister(detailsPanel);
+        DiscoveryEventUtils.getDiscoveryEventBus().unregister(rightSplitPane.getBottomComponent());
+        if (rightSplitPane.getBottomComponent() instanceof DomainDetailsPanel) {
+            selectedDomainTabName = ((DomainDetailsPanel) rightSplitPane.getBottomComponent()).getSelectedTabName();
+        }
         super.componentClosed();
     }
 
@@ -262,7 +269,7 @@ public List<Mode> availableModes(List<Mode> modes) {
      */
     @Subscribe
     void handleDetailsVisibleEvent(DiscoveryEventUtils.DetailsVisibleEvent detailsVisibleEvent) {
-        if (resultsPanel.getActiveType() != DOMAIN) {
+        SwingUtilities.invokeLater(() -> {
             if (animator != null && animator.isRunning()) {
                 animator.stop();
                 animator = null;
@@ -274,7 +281,7 @@ void handleDetailsVisibleEvent(DiscoveryEventUtils.DetailsVisibleEvent detailsVi
                 animator = new SwingAnimator(new HideDetailsAreaCallback());
             }
             animator.start();
-        }
+        });
     }
 
     /**
@@ -289,12 +296,12 @@ void handleDetailsVisibleEvent(DiscoveryEventUtils.DetailsVisibleEvent detailsVi
         "DiscoveryTopComponent.searchError.text=Error no type specified for search."})
     @Subscribe
     void handleSearchStartedEvent(DiscoveryEventUtils.SearchStartedEvent searchStartedEvent) {
-        newSearchButton.setText(Bundle.DiscoveryTopComponent_cancelButton_text());
-        progressMessageTextArea.setForeground(Color.red);
-        searchType = searchStartedEvent.getType();
-        progressMessageTextArea.setText(Bundle.DiscoveryTopComponent_searchInProgress_text(searchType.name()));
-        rightSplitPane.getComponent(1).setVisible(searchStartedEvent.getType() != DOMAIN);
-        rightSplitPane.getComponent(2).setVisible(searchStartedEvent.getType() != DOMAIN);
+        SwingUtilities.invokeLater(() -> {
+            newSearchButton.setText(Bundle.DiscoveryTopComponent_cancelButton_text());
+            progressMessageTextArea.setForeground(Color.red);
+            searchType = searchStartedEvent.getType();
+            progressMessageTextArea.setText(Bundle.DiscoveryTopComponent_searchInProgress_text(searchType.name()));
+        });
     }
 
     /**
@@ -310,19 +317,51 @@ void handleSearchStartedEvent(DiscoveryEventUtils.SearchStartedEvent searchStart
         "DiscoveryTopComponent.domainSearch.text=Type: Domain",
         "DiscoveryTopComponent.additionalFilters.text=; "})
     void handleSearchCompleteEvent(DiscoveryEventUtils.SearchCompleteEvent searchCompleteEvent) {
-        newSearchButton.setText(Bundle.DiscoveryTopComponent_newSearch_text());
-        progressMessageTextArea.setForeground(Color.black);
-        String descriptionText = "";
-        if (searchType == DOMAIN) {
-            //domain does not have a file type filter to add the type information so it is manually added
-            descriptionText = Bundle.DiscoveryTopComponent_domainSearch_text();
-            if (!searchCompleteEvent.getFilters().isEmpty()) {
-                descriptionText += Bundle.DiscoveryTopComponent_additionalFilters_text();
+        SwingUtilities.invokeLater(() -> {
+            newSearchButton.setText(Bundle.DiscoveryTopComponent_newSearch_text());
+            progressMessageTextArea.setForeground(Color.black);
+            String descriptionText = "";
+            if (searchType == DOMAIN) {
+                //domain does not have a file type filter to add the type information so it is manually added
+                descriptionText = Bundle.DiscoveryTopComponent_domainSearch_text();
+                if (!searchCompleteEvent.getFilters().isEmpty()) {
+                    descriptionText += Bundle.DiscoveryTopComponent_additionalFilters_text();
+                }
+                selectedDomainTabName = validateLastSelectedType(searchCompleteEvent);
+                rightSplitPane.setBottomComponent(new DomainDetailsPanel(selectedDomainTabName));
+            } else {
+                rightSplitPane.setBottomComponent(new FileDetailsPanel());
+            }
+            DiscoveryEventUtils.getDiscoveryEventBus().register(rightSplitPane.getBottomComponent());
+            descriptionText += searchCompleteEvent.getFilters().stream().map(AbstractFilter::getDesc).collect(Collectors.joining("; "));
+            progressMessageTextArea.setText(Bundle.DiscoveryTopComponent_searchComplete_text(descriptionText));
+            progressMessageTextArea.setCaretPosition(0);
+        });
+    }
+
+    /**
+     * Get the name of the tab which was last selected unless the tab last
+     * selected would not be included in the types currently being displayed or
+     * was not previously set.
+     *
+     * @return The name of the tab which should be selected in the new
+     *         DomainDetailsPanel.
+     */
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
+    private String validateLastSelectedType(DiscoveryEventUtils.SearchCompleteEvent searchCompleteEvent) {
+        String typeFilteredOn = selectedDomainTabName;
+        for (AbstractFilter filter : searchCompleteEvent.getFilters()) {
+            if (filter instanceof ArtifactTypeFilter) {
+                for (ARTIFACT_TYPE type : ((ArtifactTypeFilter) filter).getTypes()) {
+                    typeFilteredOn = type.getDisplayName();
+                    if (selectedDomainTabName == null || typeFilteredOn.equalsIgnoreCase(selectedDomainTabName)) {
+                        break;
+                    }
+                }
+                break;
             }
         }
-        descriptionText += searchCompleteEvent.getFilters().stream().map(AbstractFilter::getDesc).collect(Collectors.joining("; "));
-        progressMessageTextArea.setText(Bundle.DiscoveryTopComponent_searchComplete_text(descriptionText));
-        progressMessageTextArea.setCaretPosition(0);
+        return typeFilteredOn;
     }
 
     /**
@@ -334,9 +373,11 @@ void handleSearchCompleteEvent(DiscoveryEventUtils.SearchCompleteEvent searchCom
     @Messages({"DiscoveryTopComponent.searchCancelled.text=Search has been cancelled."})
     @Subscribe
     void handleSearchCancelledEvent(DiscoveryEventUtils.SearchCancelledEvent searchCancelledEvent) {
-        newSearchButton.setText(Bundle.DiscoveryTopComponent_newSearch_text());
-        progressMessageTextArea.setForeground(Color.red);
-        progressMessageTextArea.setText(Bundle.DiscoveryTopComponent_searchCancelled_text());
+        SwingUtilities.invokeLater(() -> {
+            newSearchButton.setText(Bundle.DiscoveryTopComponent_newSearch_text());
+            progressMessageTextArea.setForeground(Color.red);
+            progressMessageTextArea.setText(Bundle.DiscoveryTopComponent_searchCancelled_text());
+        });
 
     }
 
@@ -345,12 +386,14 @@ void handleSearchCancelledEvent(DiscoveryEventUtils.SearchCancelledEvent searchC
      */
     private final class ShowDetailsAreaCallback implements SwingAnimatorCallback {
 
+        @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
         @Override
         public void callback(Object caller) {
             dividerLocation -= ANIMATION_INCREMENT;
             repaint();
         }
 
+        @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
         @Override
         public boolean hasTerminated() {
             if (dividerLocation != JSplitPane.UNDEFINED_CONDITION && dividerLocation < resultsAreaSize) {
@@ -368,12 +411,14 @@ public boolean hasTerminated() {
      */
     private final class HideDetailsAreaCallback implements SwingAnimatorCallback {
 
+        @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
         @Override
         public void callback(Object caller) {
             dividerLocation += ANIMATION_INCREMENT;
             repaint();
         }
 
+        @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
         @Override
         public boolean hasTerminated() {
             if (dividerLocation > rightSplitPane.getHeight() || dividerLocation == JSplitPane.UNDEFINED_CONDITION) {
@@ -399,6 +444,7 @@ private final class AnimatedSplitPane extends JSplitPane {
 
         private static final long serialVersionUID = 1L;
 
+        @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
         @Override
         public void paintComponent(Graphics g) {
             if (animator != null && animator.isRunning() && (dividerLocation == JSplitPane.UNDEFINED_CONDITION
diff --git a/Core/src/org/sleuthkit/autopsy/discovery/ui/DiscoveryUiUtils.java b/Core/src/org/sleuthkit/autopsy/discovery/ui/DiscoveryUiUtils.java
index a1a15616e735ec668d087bd50fc0b2ae55f3fb0a..7b4287a0b6a9f5fbcaa63c16485ba7ba999fa5ae 100644
--- a/Core/src/org/sleuthkit/autopsy/discovery/ui/DiscoveryUiUtils.java
+++ b/Core/src/org/sleuthkit/autopsy/discovery/ui/DiscoveryUiUtils.java
@@ -51,6 +51,7 @@
 import org.sleuthkit.autopsy.corelibs.ScalrWrapper;
 import org.sleuthkit.autopsy.coreutils.ImageUtils;
 import org.sleuthkit.autopsy.coreutils.Logger;
+import org.sleuthkit.autopsy.coreutils.ThreadConfined;
 import static org.sleuthkit.autopsy.coreutils.VideoUtils.getVideoFileInTempDir;
 import org.sleuthkit.autopsy.datamodel.ContentUtils;
 import org.sleuthkit.autopsy.discovery.search.ResultFile;
@@ -173,6 +174,7 @@ static List<String> getSetNames(BlackboardArtifact.ARTIFACT_TYPE artifactType, B
      *
      * @return True if the point is over the icon, false otherwise.
      */
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     static boolean isPointOnIcon(Component comp, Point point) {
         return comp instanceof JComponent && point.x >= comp.getX() && point.x <= comp.getX() + ICON_SIZE && point.y >= comp.getY() && point.y <= comp.getY() + ICON_SIZE;
     }
@@ -186,6 +188,7 @@ static boolean isPointOnIcon(Component comp, Point point) {
      * @param isDeletedLabel The label to set the icon and tooltip for.
      */
     @NbBundle.Messages({"DiscoveryUiUtils.isDeleted.text=All instances of file are deleted."})
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     static void setDeletedIcon(boolean isDeleted, javax.swing.JLabel isDeletedLabel) {
         if (isDeleted) {
             isDeletedLabel.setIcon(DELETED_ICON);
@@ -203,6 +206,7 @@ static void setDeletedIcon(boolean isDeleted, javax.swing.JLabel isDeletedLabel)
      *                   score of.
      * @param scoreLabel The label to set the icon and tooltip for.
      */
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     static void setScoreIcon(ResultFile resultFile, javax.swing.JLabel scoreLabel) {
         switch (resultFile.getScore()) {
             case NOTABLE_SCORE:
@@ -232,6 +236,7 @@ static int getIconSize() {
      * Helper method to display an error message when the results of the
      * Discovery Top component may be incomplete.
      */
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     @NbBundle.Messages({"DiscoveryUiUtils.resultsIncomplete.text=Discovery results may be incomplete"})
     static void displayErrorMessage(DiscoveryDialog dialog) {
         //check if modules run and assemble message
diff --git a/Core/src/org/sleuthkit/autopsy/discovery/ui/DocumentFilterPanel.java b/Core/src/org/sleuthkit/autopsy/discovery/ui/DocumentFilterPanel.java
index 44e303ccc1356329bb0fbec84b01c2191c4a2e5a..fe2dfe4b1f8e898842d86a0bf2aab05e760b744b 100644
--- a/Core/src/org/sleuthkit/autopsy/discovery/ui/DocumentFilterPanel.java
+++ b/Core/src/org/sleuthkit/autopsy/discovery/ui/DocumentFilterPanel.java
@@ -19,6 +19,7 @@
 package org.sleuthkit.autopsy.discovery.ui;
 
 import org.sleuthkit.autopsy.centralrepository.datamodel.CentralRepository;
+import org.sleuthkit.autopsy.coreutils.ThreadConfined;
 import org.sleuthkit.autopsy.discovery.search.SearchData;
 
 /**
@@ -32,6 +33,7 @@ final class DocumentFilterPanel extends AbstractFiltersPanel {
     /**
      * Constructs a new DocumentFilterPanel.
      */
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     DocumentFilterPanel() {
         super();
         initComponents();
diff --git a/Core/src/org/sleuthkit/autopsy/discovery/ui/DocumentPanel.form b/Core/src/org/sleuthkit/autopsy/discovery/ui/DocumentPanel.form
index 9329d6f9762b706f34b182012fe52a0afbfba34f..7f61fbd6fb93767da141fa5e7d71ae3d6464ee30 100644
--- a/Core/src/org/sleuthkit/autopsy/discovery/ui/DocumentPanel.form
+++ b/Core/src/org/sleuthkit/autopsy/discovery/ui/DocumentPanel.form
@@ -79,9 +79,6 @@
         <Property name="icon" type="javax.swing.Icon" editor="org.netbeans.modules.form.editors2.IconEditor">
           <Image iconType="3" name="/org/sleuthkit/autopsy/images/file-icon-deleted.png"/>
         </Property>
-        <Property name="toolTipText" type="java.lang.String" editor="org.netbeans.modules.i18n.form.FormI18nStringEditor">
-          <ResourceString bundle="org/sleuthkit/autopsy/discovery/ui/Bundle.properties" key="DocumentPanel.isDeletedLabel.toolTipText" replaceFormat="org.openide.util.NbBundle.getMessage({sourceFileName}.class, &quot;{key}&quot;)"/>
-        </Property>
         <Property name="maximumSize" type="java.awt.Dimension" editor="org.netbeans.modules.form.RADConnectionPropertyEditor">
           <Connection code="new Dimension(org.sleuthkit.autopsy.discovery.ui.DiscoveryUiUtils.getIconSize(),org.sleuthkit.autopsy.discovery.ui.DiscoveryUiUtils.getIconSize())" type="code"/>
         </Property>
@@ -91,6 +88,9 @@
         <Property name="preferredSize" type="java.awt.Dimension" editor="org.netbeans.modules.form.RADConnectionPropertyEditor">
           <Connection code="new Dimension(org.sleuthkit.autopsy.discovery.ui.DiscoveryUiUtils.getIconSize(),org.sleuthkit.autopsy.discovery.ui.DiscoveryUiUtils.getIconSize())" type="code"/>
         </Property>
+        <Property name="toolTipText" type="java.lang.String" editor="org.netbeans.modules.i18n.form.FormI18nStringEditor">
+          <ResourceString bundle="org/sleuthkit/autopsy/discovery/ui/Bundle.properties" key="DocumentPanel.isDeletedLabel.toolTipText" replaceFormat="org.openide.util.NbBundle.getMessage({sourceFileName}.class, &quot;{key}&quot;)"/>
+        </Property>
       </Properties>
     </Component>
     <Component class="javax.swing.JLabel" name="scoreLabel">
@@ -98,7 +98,6 @@
         <Property name="icon" type="javax.swing.Icon" editor="org.netbeans.modules.form.editors2.IconEditor">
           <Image iconType="3" name="/org/sleuthkit/autopsy/images/red-circle-exclamation.png"/>
         </Property>
-        <Property name="toolTipText" type="java.lang.String" value=""/>
         <Property name="maximumSize" type="java.awt.Dimension" editor="org.netbeans.modules.form.RADConnectionPropertyEditor">
           <Connection code="new Dimension(org.sleuthkit.autopsy.discovery.ui.DiscoveryUiUtils.getIconSize(),org.sleuthkit.autopsy.discovery.ui.DiscoveryUiUtils.getIconSize())" type="code"/>
         </Property>
@@ -108,6 +107,7 @@
         <Property name="preferredSize" type="java.awt.Dimension" editor="org.netbeans.modules.form.RADConnectionPropertyEditor">
           <Connection code="new Dimension(org.sleuthkit.autopsy.discovery.ui.DiscoveryUiUtils.getIconSize(),org.sleuthkit.autopsy.discovery.ui.DiscoveryUiUtils.getIconSize())" type="code"/>
         </Property>
+        <Property name="toolTipText" type="java.lang.String" value=""/>
       </Properties>
     </Component>
     <Component class="javax.swing.JLabel" name="fileSizeLabel">
diff --git a/Core/src/org/sleuthkit/autopsy/discovery/ui/DocumentPanel.java b/Core/src/org/sleuthkit/autopsy/discovery/ui/DocumentPanel.java
index 08bdd70791817c6aa0a13e2fc9ae0bb811973b63..a0c37e8639564b699dbe07b7abeeeec046e04dd2 100644
--- a/Core/src/org/sleuthkit/autopsy/discovery/ui/DocumentPanel.java
+++ b/Core/src/org/sleuthkit/autopsy/discovery/ui/DocumentPanel.java
@@ -29,6 +29,7 @@
 import javax.swing.ListCellRenderer;
 import org.openide.util.NbBundle.Messages;
 import org.sleuthkit.autopsy.corecomponents.AutoWrappingJTextPane;
+import org.sleuthkit.autopsy.coreutils.ThreadConfined;
 import org.sleuthkit.autopsy.discovery.search.SearchData;
 
 /**
@@ -43,6 +44,7 @@ class DocumentPanel extends javax.swing.JPanel implements ListCellRenderer<Docum
     /**
      * Creates new form DocumentPanel.
      */
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     DocumentPanel() {
         initComponents();
     }
@@ -150,7 +152,7 @@ private void initComponents() {
         "DocumentPanel.numberOfImages.text=1 of {0} images",
         "DocumentPanel.numberOfImages.noImages=No images",
         "DocumentPanel.noImageExtraction.text=0 of ? images"})
-
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     @Override
     public Component getListCellRendererComponent(JList<? extends DocumentWrapper> list, DocumentWrapper value, int index, boolean isSelected, boolean cellHasFocus) {
         fileSizeLabel.setText(DiscoveryUiUtils.getFileSizeString(value.getResultFile().getFirstInstance().getSize()));
@@ -180,6 +182,7 @@ public Component getListCellRendererComponent(JList<? extends DocumentWrapper> l
         return this;
     }
 
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     @Override
     public String getToolTipText(MouseEvent event) {
         if (event != null) {
diff --git a/Core/src/org/sleuthkit/autopsy/discovery/ui/DocumentPreviewViewer.java b/Core/src/org/sleuthkit/autopsy/discovery/ui/DocumentPreviewViewer.java
index 0ef1173c0e63450c1a72e933195f64f57707e2c4..505fddf437f4965764489eea264ae05760a6b28b 100644
--- a/Core/src/org/sleuthkit/autopsy/discovery/ui/DocumentPreviewViewer.java
+++ b/Core/src/org/sleuthkit/autopsy/discovery/ui/DocumentPreviewViewer.java
@@ -22,6 +22,7 @@
 import java.util.List;
 import javax.swing.DefaultListModel;
 import javax.swing.event.ListSelectionListener;
+import org.sleuthkit.autopsy.coreutils.ThreadConfined;
 import org.sleuthkit.datamodel.AbstractFile;
 
 /**
@@ -35,6 +36,7 @@ final class DocumentPreviewViewer extends javax.swing.JPanel {
     /**
      * Creates new form DocumentViewer.
      */
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     DocumentPreviewViewer() {
         initComponents();
     }
@@ -75,11 +77,10 @@ private void initComponents() {
     /**
      * Clear the list of documents being displayed.
      */
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     void clearViewer() {
-        synchronized (this) {
-            documentListModel.removeAllElements();
-            documentScrollPane.getVerticalScrollBar().setValue(0);
-        }
+        documentListModel.removeAllElements();
+        documentScrollPane.getVerticalScrollBar().setValue(0);
     }
 
     /**
@@ -88,6 +89,7 @@ void clearViewer() {
      *
      * @param listener The ListSelectionListener to add to the selection model.
      */
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     void addListSelectionListener(ListSelectionListener listener) {
         documentList.getSelectionModel().addListSelectionListener(listener);
     }
@@ -99,13 +101,12 @@ void addListSelectionListener(ListSelectionListener listener) {
      * @return The list of AbstractFiles which are represented by the selected
      *         document preview.
      */
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     List<AbstractFile> getInstancesForSelected() {
-        synchronized (this) {
-            if (documentList.getSelectedIndex() == -1) {
-                return new ArrayList<>();
-            } else {
-                return documentListModel.getElementAt(documentList.getSelectedIndex()).getResultFile().getAllInstances();
-            }
+        if (documentList.getSelectedIndex() == -1) {
+            return new ArrayList<>();
+        } else {
+            return documentListModel.getElementAt(documentList.getSelectedIndex()).getResultFile().getAllInstances();
         }
     }
 
@@ -120,9 +121,8 @@ List<AbstractFile> getInstancesForSelected() {
      * @param documentWrapper The object which contains the document preview
      *                        which will be displayed.
      */
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     void addDocument(DocumentWrapper documentWrapper) {
-        synchronized (this) {
-            documentListModel.addElement(documentWrapper);
-        }
+        documentListModel.addElement(documentWrapper);
     }
 }
diff --git a/Core/src/org/sleuthkit/autopsy/discovery/ui/DomainArtifactsTabPanel.form b/Core/src/org/sleuthkit/autopsy/discovery/ui/DomainArtifactsTabPanel.form
new file mode 100644
index 0000000000000000000000000000000000000000..1e93e06f8624a869ed8e04944d5a0ee76d5bdc25
--- /dev/null
+++ b/Core/src/org/sleuthkit/autopsy/discovery/ui/DomainArtifactsTabPanel.form
@@ -0,0 +1,29 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+
+<Form version="1.4" maxVersion="1.9" type="org.netbeans.modules.form.forminfo.JPanelFormInfo">
+  <AuxValues>
+    <AuxValue name="FormSettings_autoResourcing" type="java.lang.Integer" value="1"/>
+    <AuxValue name="FormSettings_autoSetComponentName" type="java.lang.Boolean" value="false"/>
+    <AuxValue name="FormSettings_generateFQN" type="java.lang.Boolean" value="true"/>
+    <AuxValue name="FormSettings_generateMnemonicsCode" type="java.lang.Boolean" value="true"/>
+    <AuxValue name="FormSettings_i18nAutoMode" type="java.lang.Boolean" value="true"/>
+    <AuxValue name="FormSettings_layoutCodeTarget" type="java.lang.Integer" value="1"/>
+    <AuxValue name="FormSettings_listenerGenerationStyle" type="java.lang.Integer" value="0"/>
+    <AuxValue name="FormSettings_variablesLocal" type="java.lang.Boolean" value="false"/>
+    <AuxValue name="FormSettings_variablesModifier" type="java.lang.Integer" value="2"/>
+    <AuxValue name="designerSize" type="java.awt.Dimension" value="-84,-19,0,5,115,114,0,18,106,97,118,97,46,97,119,116,46,68,105,109,101,110,115,105,111,110,65,-114,-39,-41,-84,95,68,20,2,0,2,73,0,6,104,101,105,103,104,116,73,0,5,119,105,100,116,104,120,112,0,0,1,44,0,0,1,-112"/>
+  </AuxValues>
+
+  <Layout class="org.netbeans.modules.form.compat2.layouts.DesignBorderLayout"/>
+  <SubComponents>
+    <Container class="javax.swing.JSplitPane" name="jSplitPane1">
+      <Constraints>
+        <Constraint layoutClass="org.netbeans.modules.form.compat2.layouts.DesignBorderLayout" value="org.netbeans.modules.form.compat2.layouts.DesignBorderLayout$BorderConstraintsDescription">
+          <BorderConstraints direction="Center"/>
+        </Constraint>
+      </Constraints>
+
+      <Layout class="org.netbeans.modules.form.compat2.layouts.support.JSplitPaneSupportLayout"/>
+    </Container>
+  </SubComponents>
+</Form>
diff --git a/Core/src/org/sleuthkit/autopsy/discovery/ui/DomainArtifactsTabPanel.java b/Core/src/org/sleuthkit/autopsy/discovery/ui/DomainArtifactsTabPanel.java
new file mode 100644
index 0000000000000000000000000000000000000000..295107ef7853223f55eb9ca0216717fc8391255e
--- /dev/null
+++ b/Core/src/org/sleuthkit/autopsy/discovery/ui/DomainArtifactsTabPanel.java
@@ -0,0 +1,188 @@
+/*
+ * Autopsy
+ *
+ * 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.autopsy.discovery.ui;
+
+import com.google.common.eventbus.Subscribe;
+import java.util.logging.Level;
+import javax.swing.JPanel;
+import javax.swing.JScrollPane;
+import javax.swing.SwingUtilities;
+import javax.swing.event.ListSelectionEvent;
+import javax.swing.event.ListSelectionListener;
+import org.sleuthkit.autopsy.contentviewers.artifactviewers.DefaultArtifactContentViewer;
+import org.sleuthkit.autopsy.coreutils.Logger;
+import org.sleuthkit.autopsy.coreutils.ThreadConfined;
+import org.sleuthkit.autopsy.discovery.search.DiscoveryEventUtils;
+import org.sleuthkit.datamodel.BlackboardArtifact;
+
+/**
+ * JPanel which should be used as a tab in the domain artifacts details area.
+ */
+final class DomainArtifactsTabPanel extends JPanel {
+
+    private static final long serialVersionUID = 1L;
+    private final static Logger logger = Logger.getLogger(DomainArtifactsTabPanel.class.getName());
+    private final ArtifactsListPanel listPanel;
+    private final BlackboardArtifact.ARTIFACT_TYPE artifactType;
+    private AbstractArtifactDetailsPanel rightPanel = null;
+
+    private volatile ArtifactRetrievalStatus status = ArtifactRetrievalStatus.UNPOPULATED;
+    private final ListSelectionListener listener = new ListSelectionListener() {
+        @Override
+        public void valueChanged(ListSelectionEvent event) {
+            if (!event.getValueIsAdjusting()) {
+                rightPanel.setArtifact(listPanel.getSelectedArtifact());
+            }
+        }
+    };
+
+    /**
+     * Creates new form CookiesPanel
+     *
+     * @param type The type of Artifact this tab is displaying information for.
+     */
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
+    DomainArtifactsTabPanel(BlackboardArtifact.ARTIFACT_TYPE type) {
+        initComponents();
+        this.artifactType = type;
+        listPanel = new ArtifactsListPanel(artifactType);
+        jSplitPane1.setLeftComponent(listPanel);
+        setRightComponent();
+        listPanel.addSelectionListener(listener);
+    }
+
+    /**
+     * Set the right component of the tab panel, which will display the details
+     * for the artifact.
+     */
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
+    private void setRightComponent() {
+        switch (artifactType) {
+            case TSK_WEB_HISTORY:
+                rightPanel = new WebHistoryDetailsPanel();
+                break;
+            case TSK_WEB_COOKIE:
+            case TSK_WEB_SEARCH_QUERY:
+            case TSK_WEB_BOOKMARK:
+                rightPanel = new DefaultArtifactContentViewer();
+                break;
+            case TSK_WEB_DOWNLOAD:
+            case TSK_WEB_CACHE:
+                rightPanel = new ContentViewerDetailsPanel();
+                break;
+            default:
+                rightPanel = new DefaultArtifactContentViewer();
+                break;
+        }
+        if (rightPanel != null) {
+            jSplitPane1.setRightComponent(new JScrollPane(rightPanel));
+        }
+    }
+
+    /**
+     * Get the status of the panel which indicates if it is populated.
+     *
+     * @return The ArtifactRetrievalStatuss of the panel.
+     */
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
+    ArtifactRetrievalStatus getStatus() {
+        return status;
+    }
+
+    /**
+     * Manually set the status of the panel.
+     *
+     * @param status The ArtifactRetrievalStatus of the panel.
+     */
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
+    void setStatus(ArtifactRetrievalStatus status) {
+        this.status = status;
+        if (status == ArtifactRetrievalStatus.UNPOPULATED && rightPanel != null) {
+            rightPanel.setArtifact(null);
+        }
+    }
+
+    /**
+     * Handle the event which indicates the artifacts have been retrieved.
+     *
+     * @param artifactresultEvent The event which indicates the artifacts have
+     *                            been retrieved.
+     */
+    @Subscribe
+    void handleArtifactSearchResultEvent(DiscoveryEventUtils.ArtifactSearchResultEvent artifactresultEvent) {
+        SwingUtilities.invokeLater(() -> {
+            if (artifactType == artifactresultEvent.getArtifactType()) {
+                listPanel.removeListSelectionListener(listener);
+                listPanel.addArtifacts(artifactresultEvent.getListOfArtifacts());
+                listPanel.addSelectionListener(listener);
+                listPanel.selectFirst();
+                try {
+                    DiscoveryEventUtils.getDiscoveryEventBus().unregister(this);
+                } catch (IllegalArgumentException notRegistered) {
+                    logger.log(Level.INFO, "Attempting to unregister tab which was not registered");
+                    // attempting to remove a tab that was never registered
+                }
+                status = ArtifactRetrievalStatus.POPULATED;
+                setEnabled(!listPanel.isEmpty());
+                validate();
+                repaint();
+            }
+        });
+    }
+
+    /**
+     * Get the type of Artifact the panel exists for.
+     *
+     * @return The ARTIFACT_TYPE of the BlackboardArtifact being displayed.
+     */
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
+    BlackboardArtifact.ARTIFACT_TYPE getArtifactType() {
+        return artifactType;
+    }
+
+    /**
+     * This method is called from within the constructor to initialize the form.
+     * WARNING: Do NOT modify this code. The content of this method is always
+     * regenerated by the Form Editor.
+     */
+    @SuppressWarnings("unchecked")
+    // <editor-fold defaultstate="collapsed" desc="Generated Code">//GEN-BEGIN:initComponents
+    private void initComponents() {
+
+        jSplitPane1 = new javax.swing.JSplitPane();
+
+        setLayout(new java.awt.BorderLayout());
+        add(jSplitPane1, java.awt.BorderLayout.CENTER);
+    }// </editor-fold>//GEN-END:initComponents
+
+
+    // Variables declaration - do not modify//GEN-BEGIN:variables
+    private javax.swing.JSplitPane jSplitPane1;
+    // End of variables declaration//GEN-END:variables
+
+    /**
+     * Enum to keep track of the populated state of this panel.
+     */
+    enum ArtifactRetrievalStatus {
+        UNPOPULATED(),
+        POPULATING(),
+        POPULATED();
+    }
+
+}
diff --git a/Core/src/org/sleuthkit/autopsy/discovery/ui/DomainDetailsPanel.form b/Core/src/org/sleuthkit/autopsy/discovery/ui/DomainDetailsPanel.form
new file mode 100644
index 0000000000000000000000000000000000000000..162d346225bd910a8e575ecefd7cfc2ca76a97f5
--- /dev/null
+++ b/Core/src/org/sleuthkit/autopsy/discovery/ui/DomainDetailsPanel.form
@@ -0,0 +1,32 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+
+<Form version="1.5" maxVersion="1.9" type="org.netbeans.modules.form.forminfo.JPanelFormInfo">
+  <Properties>
+    <Property name="enabled" type="boolean" value="false"/>
+  </Properties>
+  <AuxValues>
+    <AuxValue name="FormSettings_autoResourcing" type="java.lang.Integer" value="1"/>
+    <AuxValue name="FormSettings_autoSetComponentName" type="java.lang.Boolean" value="false"/>
+    <AuxValue name="FormSettings_generateFQN" type="java.lang.Boolean" value="true"/>
+    <AuxValue name="FormSettings_generateMnemonicsCode" type="java.lang.Boolean" value="true"/>
+    <AuxValue name="FormSettings_i18nAutoMode" type="java.lang.Boolean" value="true"/>
+    <AuxValue name="FormSettings_layoutCodeTarget" type="java.lang.Integer" value="1"/>
+    <AuxValue name="FormSettings_listenerGenerationStyle" type="java.lang.Integer" value="0"/>
+    <AuxValue name="FormSettings_variablesLocal" type="java.lang.Boolean" value="false"/>
+    <AuxValue name="FormSettings_variablesModifier" type="java.lang.Integer" value="2"/>
+    <AuxValue name="designerSize" type="java.awt.Dimension" value="-84,-19,0,5,115,114,0,18,106,97,118,97,46,97,119,116,46,68,105,109,101,110,115,105,111,110,65,-114,-39,-41,-84,95,68,20,2,0,2,73,0,6,104,101,105,103,104,116,73,0,5,119,105,100,116,104,120,112,0,0,1,44,0,0,1,-112"/>
+  </AuxValues>
+
+  <Layout class="org.netbeans.modules.form.compat2.layouts.DesignBorderLayout"/>
+  <SubComponents>
+    <Container class="javax.swing.JTabbedPane" name="jTabbedPane1">
+      <Constraints>
+        <Constraint layoutClass="org.netbeans.modules.form.compat2.layouts.DesignBorderLayout" value="org.netbeans.modules.form.compat2.layouts.DesignBorderLayout$BorderConstraintsDescription">
+          <BorderConstraints direction="Center"/>
+        </Constraint>
+      </Constraints>
+
+      <Layout class="org.netbeans.modules.form.compat2.layouts.support.JTabbedPaneSupportLayout"/>
+    </Container>
+  </SubComponents>
+</Form>
diff --git a/Core/src/org/sleuthkit/autopsy/discovery/ui/DomainDetailsPanel.java b/Core/src/org/sleuthkit/autopsy/discovery/ui/DomainDetailsPanel.java
new file mode 100644
index 0000000000000000000000000000000000000000..6fd407cbaf65d6b085531266171f3cbf2feae060
--- /dev/null
+++ b/Core/src/org/sleuthkit/autopsy/discovery/ui/DomainDetailsPanel.java
@@ -0,0 +1,182 @@
+/*
+ * Autopsy
+ *
+ * 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.autopsy.discovery.ui;
+
+import com.google.common.eventbus.Subscribe;
+import java.awt.Component;
+import javax.swing.JPanel;
+import javax.swing.SwingUtilities;
+import javax.swing.event.ChangeEvent;
+import javax.swing.event.ChangeListener;
+import org.apache.commons.lang.StringUtils;
+import org.sleuthkit.autopsy.coreutils.ThreadConfined;
+import org.sleuthkit.autopsy.discovery.search.DiscoveryEventUtils;
+import org.sleuthkit.datamodel.BlackboardArtifact;
+import org.sleuthkit.autopsy.discovery.search.SearchData;
+
+/**
+ * Panel to display details area for domain discovery results.
+ *
+ */
+final class DomainDetailsPanel extends JPanel {
+
+    private static final long serialVersionUID = 1L;
+    private ArtifactsWorker detailsWorker;
+    private String domain;
+    private String selectedTabName;
+
+    /**
+     * Creates new form ArtifactDetailsPanel.
+     *
+     * @param selectedTabName The name of the tab to select initially.
+     */
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
+    DomainDetailsPanel(String selectedTabName) {
+        initComponents();
+        addArtifactTabs(selectedTabName);
+    }
+
+    /**
+     * Add the tabs for each of the artifact types which we will be displaying.
+     *
+     * @param tabName The name of the tab to select initially.
+     */
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
+    private void addArtifactTabs(String tabName) {
+        for (BlackboardArtifact.ARTIFACT_TYPE type : SearchData.Type.DOMAIN.getArtifactTypes()) {
+            jTabbedPane1.add(type.getDisplayName(), new DomainArtifactsTabPanel(type));
+        }
+        selectedTabName = tabName;
+        selectTab();
+        jTabbedPane1.addChangeListener(new ChangeListener() {
+            @Override
+            public void stateChanged(ChangeEvent e) {
+                if (jTabbedPane1.getSelectedIndex() >= 0) {
+                    String newTabTitle = jTabbedPane1.getTitleAt(jTabbedPane1.getSelectedIndex());
+                    if (selectedTabName == null || !selectedTabName.equals(newTabTitle)) {
+                        selectedTabName = newTabTitle;
+                        runDomainWorker();
+                    }
+                }
+            }
+        });
+    }
+
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
+    /**
+     * Set the selected tab index to be the previously selected tab if a
+     * previously selected tab exists.
+     */
+    private void selectTab() {
+        for (int i = 0; i < jTabbedPane1.getTabCount(); i++) {
+            if (!StringUtils.isBlank(selectedTabName) && selectedTabName.equals(jTabbedPane1.getTitleAt(i))) {
+                jTabbedPane1.setSelectedIndex(i);
+                return;
+            }
+        }
+    }
+
+    /**
+     * Run the worker which retrieves the list of artifacts for the domain to
+     * populate the details area.
+     */
+    private void runDomainWorker() {
+        SwingUtilities.invokeLater(() -> {
+            Component selectedComponent = jTabbedPane1.getSelectedComponent();
+            if (selectedComponent instanceof DomainArtifactsTabPanel) {
+                if (detailsWorker != null && !detailsWorker.isDone()) {
+                    detailsWorker.cancel(true);
+                }
+                DomainArtifactsTabPanel selectedTab = (DomainArtifactsTabPanel) selectedComponent;
+                if (selectedTab.getStatus() == DomainArtifactsTabPanel.ArtifactRetrievalStatus.UNPOPULATED) {
+                    selectedTab.setStatus(DomainArtifactsTabPanel.ArtifactRetrievalStatus.POPULATING);
+                    DiscoveryEventUtils.getDiscoveryEventBus().register(selectedTab);
+                    detailsWorker = new ArtifactsWorker(selectedTab.getArtifactType(), domain);
+                    detailsWorker.execute();
+                }
+            }
+        });
+    }
+
+    /**
+     * Populate the the details tabs.
+     *
+     * @param populateEvent The PopulateDomainTabsEvent which indicates which
+     *                      domain the details tabs should be populated for.
+     */
+    @Subscribe
+    void handlePopulateDomainTabsEvent(DiscoveryEventUtils.PopulateDomainTabsEvent populateEvent) {
+        SwingUtilities.invokeLater(() -> {
+            domain = populateEvent.getDomain();
+            resetTabsStatus();
+            selectTab();
+            runDomainWorker();
+            if (StringUtils.isBlank(domain)) {
+                //send fade out event
+                DiscoveryEventUtils.getDiscoveryEventBus().post(new DiscoveryEventUtils.DetailsVisibleEvent(false));
+            } else {
+                //send fade in event
+                DiscoveryEventUtils.getDiscoveryEventBus().post(new DiscoveryEventUtils.DetailsVisibleEvent(true));
+            }
+        });
+    }
+
+    /**
+     * Private helper method to ensure tabs will re-populate after a new domain
+     * is selected.
+     */
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
+    private void resetTabsStatus() {
+        for (Component comp : jTabbedPane1.getComponents()) {
+            if (comp instanceof DomainArtifactsTabPanel) {
+                ((DomainArtifactsTabPanel) comp).setStatus(DomainArtifactsTabPanel.ArtifactRetrievalStatus.UNPOPULATED);
+            }
+        }
+    }
+
+    /**
+     * Get the name of the tab that was most recently selected.
+     *
+     * @return The name of the tab that was most recently selected.
+     */
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
+    String getSelectedTabName() {
+        return selectedTabName;
+    }
+
+    /**
+     * This method is called from within the constructor to initialize the form.
+     * WARNING: Do NOT modify this code. The content of this method is always
+     * regenerated by the Form Editor.
+     */
+    @SuppressWarnings("unchecked")
+    // <editor-fold defaultstate="collapsed" desc="Generated Code">//GEN-BEGIN:initComponents
+    private void initComponents() {
+
+        jTabbedPane1 = new javax.swing.JTabbedPane();
+
+        setEnabled(false);
+        setLayout(new java.awt.BorderLayout());
+        add(jTabbedPane1, java.awt.BorderLayout.CENTER);
+    }// </editor-fold>//GEN-END:initComponents
+
+    // Variables declaration - do not modify//GEN-BEGIN:variables
+    private javax.swing.JTabbedPane jTabbedPane1;
+    // End of variables declaration//GEN-END:variables
+}
diff --git a/Core/src/org/sleuthkit/autopsy/discovery/ui/DomainFilterPanel.java b/Core/src/org/sleuthkit/autopsy/discovery/ui/DomainFilterPanel.java
index bce5577054ddb795906de0dc051a9a38b01ab71c..3216110694c088e5d2b60ba3ff8518e0e11df24b 100644
--- a/Core/src/org/sleuthkit/autopsy/discovery/ui/DomainFilterPanel.java
+++ b/Core/src/org/sleuthkit/autopsy/discovery/ui/DomainFilterPanel.java
@@ -19,6 +19,7 @@
 package org.sleuthkit.autopsy.discovery.ui;
 
 import org.sleuthkit.autopsy.centralrepository.datamodel.CentralRepository;
+import org.sleuthkit.autopsy.coreutils.ThreadConfined;
 import org.sleuthkit.autopsy.discovery.search.DiscoveryAttributes;
 import org.sleuthkit.autopsy.discovery.search.ResultsSorter;
 import org.sleuthkit.autopsy.discovery.search.SearchData;
@@ -34,6 +35,7 @@ public class DomainFilterPanel extends AbstractFiltersPanel {
     /**
      * Creates new form DomainFilterPanel.
      */
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     public DomainFilterPanel() {
         super();
         initComponents();
diff --git a/Core/src/org/sleuthkit/autopsy/discovery/ui/DomainSummaryPanel.java b/Core/src/org/sleuthkit/autopsy/discovery/ui/DomainSummaryPanel.java
index c6979663a707db42b823c15d304ac594ec343773..74e0d7d0bac0ec093ce97efa87c877fd7e8047d4 100644
--- a/Core/src/org/sleuthkit/autopsy/discovery/ui/DomainSummaryPanel.java
+++ b/Core/src/org/sleuthkit/autopsy/discovery/ui/DomainSummaryPanel.java
@@ -30,6 +30,7 @@
 import javax.swing.JList;
 import javax.swing.ListCellRenderer;
 import org.openide.util.NbBundle;
+import org.sleuthkit.autopsy.coreutils.ThreadConfined;
 
 /**
  * Class which displays a preview and details about a domain.
@@ -43,6 +44,7 @@ class DomainSummaryPanel extends javax.swing.JPanel implements ListCellRenderer<
     /**
      * Creates new form DomainPanel.
      */
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     DomainSummaryPanel() {
         initComponents();
         domainNameLabel.setFont(domainNameLabel.getFont().deriveFont(domainNameLabel.getFont().getStyle(), domainNameLabel.getFont().getSize() + 6));
@@ -136,6 +138,7 @@ private void initComponents() {
     private javax.swing.JLabel totalVisitsLabel;
     // End of variables declaration//GEN-END:variables
 
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     @NbBundle.Messages({"# {0} - startDate",
         "# {1} - endDate",
         "DomainSummaryPanel.activity.text=Activity: {0} to {1}",
@@ -163,6 +166,7 @@ public Component getListCellRendererComponent(JList<? extends DomainWrapper> lis
         return this;
     }
 
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     @Override
     public String getToolTipText(MouseEvent event) {
         if (event != null) {
diff --git a/Core/src/org/sleuthkit/autopsy/discovery/ui/DomainSummaryViewer.form b/Core/src/org/sleuthkit/autopsy/discovery/ui/DomainSummaryViewer.form
index 22296c01783ac99feef9d303de01a33346996470..9f1e3516b9bacd597c575bbf3f8ce86bc47044ed 100644
--- a/Core/src/org/sleuthkit/autopsy/discovery/ui/DomainSummaryViewer.form
+++ b/Core/src/org/sleuthkit/autopsy/discovery/ui/DomainSummaryViewer.form
@@ -39,8 +39,6 @@
           </Properties>
           <AuxValues>
             <AuxValue name="JavaCodeGenerator_TypeParameters" type="java.lang.String" value="&lt;DomainWrapper&gt;"/>
-            <AuxValue name="JavaCodeGenerator_VariableLocal" type="java.lang.Boolean" value="true"/>
-            <AuxValue name="JavaCodeGenerator_VariableModifier" type="java.lang.Integer" value="0"/>
           </AuxValues>
         </Component>
       </SubComponents>
diff --git a/Core/src/org/sleuthkit/autopsy/discovery/ui/DomainSummaryViewer.java b/Core/src/org/sleuthkit/autopsy/discovery/ui/DomainSummaryViewer.java
index 593ae49949dfb38289196431adef6c097dc75494..66125e36d0f72a663d411bc3ab63035677586d11 100644
--- a/Core/src/org/sleuthkit/autopsy/discovery/ui/DomainSummaryViewer.java
+++ b/Core/src/org/sleuthkit/autopsy/discovery/ui/DomainSummaryViewer.java
@@ -19,6 +19,9 @@
 package org.sleuthkit.autopsy.discovery.ui;
 
 import javax.swing.DefaultListModel;
+import javax.swing.event.ListSelectionListener;
+import org.sleuthkit.autopsy.coreutils.ThreadConfined;
+import org.sleuthkit.autopsy.discovery.search.DiscoveryEventUtils;
 
 /**
  * A JPanel to display domain summaries.
@@ -30,20 +33,20 @@ public class DomainSummaryViewer extends javax.swing.JPanel {
     private final DefaultListModel<DomainWrapper> domainListModel = new DefaultListModel<>();
 
     /**
-     * Clear the list of documents being displayed.
+     * Creates new form DomainSummaryPanel
      */
-    void clearViewer() {
-        synchronized (this) {
-            domainListModel.removeAllElements();
-            domainScrollPane.getVerticalScrollBar().setValue(0);
-        }
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
+    public DomainSummaryViewer() {
+        initComponents();
     }
 
     /**
-     * Creates new form DomainSummaryPanel
+     * Clear the list of documents being displayed.
      */
-    public DomainSummaryViewer() {
-        initComponents();
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
+    void clearViewer() {
+        domainListModel.removeAllElements();
+        domainScrollPane.getVerticalScrollBar().setValue(0);
     }
 
     /**
@@ -56,7 +59,7 @@ public DomainSummaryViewer() {
     private void initComponents() {
 
         domainScrollPane = new javax.swing.JScrollPane();
-        javax.swing.JList<DomainWrapper> domainList = new javax.swing.JList<>();
+        domainList = new javax.swing.JList<>();
 
         setLayout(new java.awt.BorderLayout());
 
@@ -69,6 +72,7 @@ private void initComponents() {
 
 
     // Variables declaration - do not modify//GEN-BEGIN:variables
+    private javax.swing.JList<DomainWrapper> domainList;
     private javax.swing.JScrollPane domainScrollPane;
     // End of variables declaration//GEN-END:variables
 
@@ -78,9 +82,39 @@ private void initComponents() {
      * @param domainWrapper The object which contains the domain summary which
      *                      will be displayed.
      */
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     void addDomain(DomainWrapper domainWrapper) {
-        synchronized (this) {
-            domainListModel.addElement(domainWrapper);
+        domainListModel.addElement(domainWrapper);
+    }
+
+    /**
+     * Send an event to perform the population of the domain details tabs to
+     * reflect the currently selected domain. Will populate the list with
+     * nothing when a domain is not used.
+     *
+     * @param useDomain If the currently selected domain should be used to
+     *                  retrieve a list.
+     */
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
+    void sendPopulateEvent(boolean useDomain) {
+        String domain = "";
+        if (useDomain) {
+            if (domainList.getSelectedIndex() != -1) {
+                domain = domainListModel.getElementAt(domainList.getSelectedIndex()).getResultDomain().getDomain();
+            }
         }
+        //send populateMesage
+        DiscoveryEventUtils.getDiscoveryEventBus().post(new DiscoveryEventUtils.PopulateDomainTabsEvent(domain));
+    }
+
+    /**
+     * Add a selection listener to the list of document previews being
+     * displayed.
+     *
+     * @param listener The ListSelectionListener to add to the selection model.
+     */
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
+    void addListSelectionListener(ListSelectionListener listener) {
+        domainList.getSelectionModel().addListSelectionListener(listener);
     }
 }
diff --git a/Core/src/org/sleuthkit/autopsy/discovery/ui/DetailsPanel.form b/Core/src/org/sleuthkit/autopsy/discovery/ui/FileDetailsPanel.form
similarity index 96%
rename from Core/src/org/sleuthkit/autopsy/discovery/ui/DetailsPanel.form
rename to Core/src/org/sleuthkit/autopsy/discovery/ui/FileDetailsPanel.form
index bd3d8c5af9b2af6708be5ccdf2dab8a7529b9676..1cac14678036bece07b3c830cda7571a44a7aff9 100644
--- a/Core/src/org/sleuthkit/autopsy/discovery/ui/DetailsPanel.form
+++ b/Core/src/org/sleuthkit/autopsy/discovery/ui/FileDetailsPanel.form
@@ -105,8 +105,8 @@
                   <Properties>
                     <Property name="border" type="javax.swing.border.Border" editor="org.netbeans.modules.form.editors2.BorderEditor">
                       <Border info="org.netbeans.modules.form.compat2.border.TitledBorderInfo">
-                        <TitledBorder title="Instances">
-                          <ResourceString PropertyName="titleX" bundle="org/sleuthkit/autopsy/discovery/ui/Bundle.properties" key="DetailsPanel.instancesList.border.title" replaceFormat="org.openide.util.NbBundle.getMessage({sourceFileName}.class, &quot;{key}&quot;)"/>
+                        <TitledBorder title="&lt;FileDetailsPanel.instancesList.border.title&gt;">
+                          <ResourceString PropertyName="titleX" bundle="org/sleuthkit/autopsy/discovery/ui/Bundle.properties" key="FileDetailsPanel.instancesList.border.title" replaceFormat="org.openide.util.NbBundle.getMessage({sourceFileName}.class, &quot;{key}&quot;)"/>
                         </TitledBorder>
                       </Border>
                     </Property>
diff --git a/Core/src/org/sleuthkit/autopsy/discovery/ui/DetailsPanel.java b/Core/src/org/sleuthkit/autopsy/discovery/ui/FileDetailsPanel.java
similarity index 94%
rename from Core/src/org/sleuthkit/autopsy/discovery/ui/DetailsPanel.java
rename to Core/src/org/sleuthkit/autopsy/discovery/ui/FileDetailsPanel.java
index e302669278bbc3a73d5d7f3fdb00be80aa36f7b6..9230459d6db63ac7c45f9921579b3965684057e6 100644
--- a/Core/src/org/sleuthkit/autopsy/discovery/ui/DetailsPanel.java
+++ b/Core/src/org/sleuthkit/autopsy/discovery/ui/FileDetailsPanel.java
@@ -36,6 +36,7 @@
 import org.sleuthkit.autopsy.actions.DeleteFileContentTagAction;
 import org.sleuthkit.autopsy.corecomponents.DataContentPanel;
 import org.sleuthkit.autopsy.corecomponents.TableFilterNode;
+import org.sleuthkit.autopsy.coreutils.ThreadConfined;
 import org.sleuthkit.autopsy.datamodel.FileNode;
 import org.sleuthkit.autopsy.directorytree.ExternalViewerAction;
 import org.sleuthkit.autopsy.directorytree.ViewContextAction;
@@ -48,7 +49,7 @@
 /**
  * Panel to display the details of the selected result.
  */
-final class DetailsPanel extends javax.swing.JPanel {
+final class FileDetailsPanel extends javax.swing.JPanel {
 
     private static final long serialVersionUID = 1L;
 
@@ -59,7 +60,8 @@ final class DetailsPanel extends javax.swing.JPanel {
     /**
      * Creates new form DetailsPanel.
      */
-    DetailsPanel() {
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
+    FileDetailsPanel() {
         initComponents();
         dataContentPanel = DataContentPanel.createInstance();
         detailsSplitPane.setBottomComponent(dataContentPanel);
@@ -112,7 +114,9 @@ public void valueChanged(ListSelectionEvent e) {
      */
     @Subscribe
     void handleClearSelectionListener(DiscoveryEventUtils.ClearInstanceSelectionEvent clearEvent) {
-        instancesList.clearSelection();
+        SwingUtilities.invokeLater(() -> {
+            instancesList.clearSelection();
+        });
     }
 
     /**
@@ -122,7 +126,7 @@ void handleClearSelectionListener(DiscoveryEventUtils.ClearInstanceSelectionEven
      *                      instances list should be populated
      */
     @Subscribe
-    synchronized void handlePopulateInstancesListEvent(DiscoveryEventUtils.PopulateInstancesListEvent populateEvent) {
+    void handlePopulateInstancesListEvent(DiscoveryEventUtils.PopulateInstancesListEvent populateEvent) {
         SwingUtilities.invokeLater(() -> {
             List<AbstractFile> files = populateEvent.getInstances();
             if (files.isEmpty()) {
@@ -154,7 +158,8 @@ synchronized void handlePopulateInstancesListEvent(DiscoveryEventUtils.PopulateI
      *
      * @return The AbstractFile which is currently selected.
      */
-    synchronized AbstractFile getSelectedFile() {
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
+    AbstractFile getSelectedFile() {
         if (instancesList.getSelectedIndex() == -1) {
             return null;
         } else {
@@ -186,7 +191,7 @@ private void initComponents() {
 
         instancesScrollPane.setPreferredSize(new java.awt.Dimension(775, 60));
 
-        instancesList.setBorder(javax.swing.BorderFactory.createTitledBorder(org.openide.util.NbBundle.getMessage(DetailsPanel.class, "DetailsPanel.instancesList.border.title"))); // NOI18N
+        instancesList.setBorder(javax.swing.BorderFactory.createTitledBorder(org.openide.util.NbBundle.getMessage(FileDetailsPanel.class, "FileDetailsPanel.instancesList.border.title"))); // NOI18N
         instancesList.setModel(instancesListModel);
         instancesList.setSelectionMode(javax.swing.ListSelectionModel.SINGLE_SELECTION);
         instancesList.setCellRenderer(new InstancesCellRenderer());
@@ -241,6 +246,7 @@ private class InstancesCellRenderer extends DefaultListCellRenderer {
 
         private static final long serialVersionUID = 1L;
 
+        @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
         @Override
         public Component getListCellRendererComponent(JList<?> list, Object value, int index, boolean isSelected, boolean cellHasFocus) {
             super.getListCellRendererComponent(list, value, index, isSelected, cellHasFocus);
diff --git a/Core/src/org/sleuthkit/autopsy/discovery/ui/GroupListPanel.java b/Core/src/org/sleuthkit/autopsy/discovery/ui/GroupListPanel.java
index 714d5202f06750c18ef2dd70b76947550bc1627a..5a3b5393af7507b0af6862f864fc110c82466f2c 100644
--- a/Core/src/org/sleuthkit/autopsy/discovery/ui/GroupListPanel.java
+++ b/Core/src/org/sleuthkit/autopsy/discovery/ui/GroupListPanel.java
@@ -31,6 +31,7 @@
 import javax.swing.JOptionPane;
 import javax.swing.SwingUtilities;
 import org.openide.util.NbBundle.Messages;
+import org.sleuthkit.autopsy.coreutils.ThreadConfined;
 import org.sleuthkit.autopsy.discovery.search.DiscoveryAttributes;
 import org.sleuthkit.autopsy.discovery.search.DiscoveryEventUtils;
 import org.sleuthkit.autopsy.discovery.search.DiscoveryKeyUtils.GroupKey;
@@ -56,6 +57,7 @@ final class GroupListPanel extends javax.swing.JPanel {
     /**
      * Creates new form GroupListPanel.
      */
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     GroupListPanel() {
         initComponents();
     }
@@ -67,8 +69,10 @@ final class GroupListPanel extends javax.swing.JPanel {
      */
     @Subscribe
     void handleSearchStartedEvent(DiscoveryEventUtils.SearchStartedEvent searchStartedEvent) {
-        type = searchStartedEvent.getType();
-        groupKeyList.setListData(new GroupKey[0]);
+        SwingUtilities.invokeLater(() -> {
+            type = searchStartedEvent.getType();
+            groupKeyList.setListData(new GroupKey[0]);
+        });
     }
 
     @Messages({"GroupsListPanel.noFileResults.message.text=No files were found for the selected filters.\n\n"
@@ -90,27 +94,29 @@ void handleSearchStartedEvent(DiscoveryEventUtils.SearchStartedEvent searchStart
      */
     @Subscribe
     void handleSearchCompleteEvent(DiscoveryEventUtils.SearchCompleteEvent searchCompleteEvent) {
-        groupMap = searchCompleteEvent.getGroupMap();
-        searchfilters = searchCompleteEvent.getFilters();
-        groupingAttribute = searchCompleteEvent.getGroupingAttr();
-        groupSort = searchCompleteEvent.getGroupSort();
-        resultSortMethod = searchCompleteEvent.getResultSort();
-        groupKeyList.setListData(groupMap.keySet().toArray(new GroupKey[groupMap.keySet().size()]));
         SwingUtilities.invokeLater(() -> {
-            if (groupKeyList.getModel().getSize() > 0) {
-                groupKeyList.setSelectedIndex(0);
-            } else if (type == DOMAIN) {
-                JOptionPane.showMessageDialog(DiscoveryTopComponent.getTopComponent(),
-                        Bundle.GroupsListPanel_noDomainResults_message_text(),
-                        Bundle.GroupsListPanel_noResults_title_text(),
-                        JOptionPane.PLAIN_MESSAGE);
-            } else {
-                JOptionPane.showMessageDialog(DiscoveryTopComponent.getTopComponent(),
-                        Bundle.GroupsListPanel_noFileResults_message_text(),
-                        Bundle.GroupsListPanel_noResults_title_text(),
-                        JOptionPane.PLAIN_MESSAGE);
-            }
-            setCursor(Cursor.getPredefinedCursor(Cursor.DEFAULT_CURSOR));
+            groupMap = searchCompleteEvent.getGroupMap();
+            searchfilters = searchCompleteEvent.getFilters();
+            groupingAttribute = searchCompleteEvent.getGroupingAttr();
+            groupSort = searchCompleteEvent.getGroupSort();
+            resultSortMethod = searchCompleteEvent.getResultSort();
+            groupKeyList.setListData(groupMap.keySet().toArray(new GroupKey[groupMap.keySet().size()]));
+            SwingUtilities.invokeLater(() -> {
+                if (groupKeyList.getModel().getSize() > 0) {
+                    groupKeyList.setSelectedIndex(0);
+                } else if (type == DOMAIN) {
+                    JOptionPane.showMessageDialog(DiscoveryTopComponent.getTopComponent(),
+                            Bundle.GroupsListPanel_noDomainResults_message_text(),
+                            Bundle.GroupsListPanel_noResults_title_text(),
+                            JOptionPane.PLAIN_MESSAGE);
+                } else {
+                    JOptionPane.showMessageDialog(DiscoveryTopComponent.getTopComponent(),
+                            Bundle.GroupsListPanel_noFileResults_message_text(),
+                            Bundle.GroupsListPanel_noResults_title_text(),
+                            JOptionPane.PLAIN_MESSAGE);
+                }
+                setCursor(Cursor.getPredefinedCursor(Cursor.DEFAULT_CURSOR));
+            });
         });
     }
 
@@ -168,10 +174,9 @@ public void valueChanged(javax.swing.event.ListSelectionEvent evt) {
     /**
      * Reset the group list to be empty.
      */
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     void resetGroupList() {
-        SwingUtilities.invokeLater(() -> {
-            setCursor(Cursor.getPredefinedCursor(Cursor.WAIT_CURSOR));
-        });
+        setCursor(Cursor.getPredefinedCursor(Cursor.WAIT_CURSOR));
         groupKeyList.setListData(new GroupKey[0]);
     }
 
@@ -211,6 +216,7 @@ private class GroupListRenderer extends DefaultListCellRenderer {
 
         private static final long serialVersionUID = 1L;
 
+        @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
         @Override
         public java.awt.Component getListCellRendererComponent(
                 JList<?> list,
diff --git a/Core/src/org/sleuthkit/autopsy/discovery/ui/HashSetFilterPanel.java b/Core/src/org/sleuthkit/autopsy/discovery/ui/HashSetFilterPanel.java
index 4698c43dc3784727a2ca951ce84fe0808c596840..0257352a167280430691c80149da819f421516b8 100644
--- a/Core/src/org/sleuthkit/autopsy/discovery/ui/HashSetFilterPanel.java
+++ b/Core/src/org/sleuthkit/autopsy/discovery/ui/HashSetFilterPanel.java
@@ -27,6 +27,7 @@
 import javax.swing.JList;
 import org.openide.util.NbBundle;
 import org.sleuthkit.autopsy.coreutils.Logger;
+import org.sleuthkit.autopsy.coreutils.ThreadConfined;
 import org.sleuthkit.autopsy.discovery.search.SearchFiltering;
 import org.sleuthkit.datamodel.BlackboardArtifact;
 import org.sleuthkit.datamodel.BlackboardAttribute;
@@ -43,6 +44,7 @@ final class HashSetFilterPanel extends AbstractDiscoveryFilterPanel {
     /**
      * Creates new form HashSetFilterPaenl.
      */
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     HashSetFilterPanel() {
         initComponents();
         setUpHashFilter();
@@ -51,6 +53,7 @@ final class HashSetFilterPanel extends AbstractDiscoveryFilterPanel {
     /**
      * Initialize the hash filter.
      */
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     private void setUpHashFilter() {
         int count = 0;
         try {
@@ -123,6 +126,7 @@ private void hashSetCheckboxActionPerformed(java.awt.event.ActionEvent evt) {//G
     private javax.swing.JScrollPane hashSetScrollPane;
     // End of variables declaration//GEN-END:variables
 
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     @Override
     void configurePanel(boolean selected, int[] indicesSelected) {
         boolean hasHashSets = hashSetList.getModel().getSize() > 0;
@@ -140,6 +144,7 @@ void configurePanel(boolean selected, int[] indicesSelected) {
         }
     }
 
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     @Override
     JCheckBox getCheckbox() {
         return hashSetCheckbox;
@@ -150,6 +155,7 @@ JLabel getAdditionalLabel() {
         return null;
     }
 
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     @NbBundle.Messages({"HashSetFilterPanel.error.text=At least one hash set name must be selected."})
     @Override
     String checkForError() {
@@ -159,11 +165,13 @@ String checkForError() {
         return "";
     }
 
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     @Override
     JList<?> getList() {
         return hashSetList;
     }
 
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     @Override
     AbstractFilter getFilter() {
         if (hashSetCheckbox.isSelected()) {
diff --git a/Core/src/org/sleuthkit/autopsy/discovery/ui/ImageFilterPanel.java b/Core/src/org/sleuthkit/autopsy/discovery/ui/ImageFilterPanel.java
index 308cdd569b351e4b8cd057a7820d494ec68a02fe..4557b02d7423a7ac342e7d13c884c9a23e416a5d 100644
--- a/Core/src/org/sleuthkit/autopsy/discovery/ui/ImageFilterPanel.java
+++ b/Core/src/org/sleuthkit/autopsy/discovery/ui/ImageFilterPanel.java
@@ -19,6 +19,7 @@
 package org.sleuthkit.autopsy.discovery.ui;
 
 import org.sleuthkit.autopsy.centralrepository.datamodel.CentralRepository;
+import org.sleuthkit.autopsy.coreutils.ThreadConfined;
 import org.sleuthkit.autopsy.discovery.search.SearchData;
 
 /**
@@ -32,6 +33,7 @@ final class ImageFilterPanel extends AbstractFiltersPanel {
     /**
      * Creates new form ImageFilterPanel.
      */
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     ImageFilterPanel() {
         super();
         initComponents();
diff --git a/Core/src/org/sleuthkit/autopsy/discovery/ui/ImageThumbnailPanel.form b/Core/src/org/sleuthkit/autopsy/discovery/ui/ImageThumbnailPanel.form
index 46c5d3601fb82e0fc724e64a5f02c29410f430bd..2a52cb88d357be1726718819cd6814e343e1a5bc 100644
--- a/Core/src/org/sleuthkit/autopsy/discovery/ui/ImageThumbnailPanel.form
+++ b/Core/src/org/sleuthkit/autopsy/discovery/ui/ImageThumbnailPanel.form
@@ -84,7 +84,6 @@
     </Component>
     <Component class="javax.swing.JLabel" name="nameLabel">
       <Properties>
-        <Property name="toolTipText" type="java.lang.String" value=""/>
         <Property name="maximumSize" type="java.awt.Dimension" editor="org.netbeans.beaninfo.editors.DimensionEditor">
           <Dimension value="[159, 12]"/>
         </Property>
@@ -94,6 +93,7 @@
         <Property name="preferredSize" type="java.awt.Dimension" editor="org.netbeans.beaninfo.editors.DimensionEditor">
           <Dimension value="[159, 12]"/>
         </Property>
+        <Property name="toolTipText" type="java.lang.String" value=""/>
       </Properties>
     </Component>
     <Component class="javax.swing.JLabel" name="isDeletedLabel">
@@ -101,9 +101,6 @@
         <Property name="icon" type="javax.swing.Icon" editor="org.netbeans.modules.form.editors2.IconEditor">
           <Image iconType="3" name="/org/sleuthkit/autopsy/images/file-icon-deleted.png"/>
         </Property>
-        <Property name="toolTipText" type="java.lang.String" editor="org.netbeans.modules.i18n.form.FormI18nStringEditor">
-          <ResourceString bundle="org/sleuthkit/autopsy/discovery/ui/Bundle.properties" key="ImageThumbnailPanel.isDeletedLabel.toolTipText" replaceFormat="org.openide.util.NbBundle.getMessage({sourceFileName}.class, &quot;{key}&quot;)"/>
-        </Property>
         <Property name="maximumSize" type="java.awt.Dimension" editor="org.netbeans.modules.form.RADConnectionPropertyEditor">
           <Connection code="new Dimension(org.sleuthkit.autopsy.discovery.ui.DiscoveryUiUtils.getIconSize(),org.sleuthkit.autopsy.discovery.ui.DiscoveryUiUtils.getIconSize())" type="code"/>
         </Property>
@@ -113,6 +110,9 @@
         <Property name="preferredSize" type="java.awt.Dimension" editor="org.netbeans.modules.form.RADConnectionPropertyEditor">
           <Connection code="new Dimension(org.sleuthkit.autopsy.discovery.ui.DiscoveryUiUtils.getIconSize(),org.sleuthkit.autopsy.discovery.ui.DiscoveryUiUtils.getIconSize())" type="code"/>
         </Property>
+        <Property name="toolTipText" type="java.lang.String" editor="org.netbeans.modules.i18n.form.FormI18nStringEditor">
+          <ResourceString bundle="org/sleuthkit/autopsy/discovery/ui/Bundle.properties" key="ImageThumbnailPanel.isDeletedLabel.toolTipText" replaceFormat="org.openide.util.NbBundle.getMessage({sourceFileName}.class, &quot;{key}&quot;)"/>
+        </Property>
       </Properties>
     </Component>
     <Component class="javax.swing.JLabel" name="scoreLabel">
@@ -120,7 +120,6 @@
         <Property name="icon" type="javax.swing.Icon" editor="org.netbeans.modules.form.editors2.IconEditor">
           <Image iconType="3" name="/org/sleuthkit/autopsy/images/red-circle-exclamation.png"/>
         </Property>
-        <Property name="toolTipText" type="java.lang.String" value=""/>
         <Property name="maximumSize" type="java.awt.Dimension" editor="org.netbeans.modules.form.RADConnectionPropertyEditor">
           <Connection code="new Dimension(org.sleuthkit.autopsy.discovery.ui.DiscoveryUiUtils.getIconSize(),org.sleuthkit.autopsy.discovery.ui.DiscoveryUiUtils.getIconSize())" type="code"/>
         </Property>
@@ -130,6 +129,7 @@
         <Property name="preferredSize" type="java.awt.Dimension" editor="org.netbeans.modules.form.RADConnectionPropertyEditor">
           <Connection code="new Dimension(org.sleuthkit.autopsy.discovery.ui.DiscoveryUiUtils.getIconSize(),org.sleuthkit.autopsy.discovery.ui.DiscoveryUiUtils.getIconSize())" type="code"/>
         </Property>
+        <Property name="toolTipText" type="java.lang.String" value=""/>
       </Properties>
     </Component>
   </SubComponents>
diff --git a/Core/src/org/sleuthkit/autopsy/discovery/ui/ImageThumbnailPanel.java b/Core/src/org/sleuthkit/autopsy/discovery/ui/ImageThumbnailPanel.java
index d294fa866d3bf0401a4d30bc8547ff2bee1e6293..22ce4a0809b57eeace9307d1e5173538381514a0 100644
--- a/Core/src/org/sleuthkit/autopsy/discovery/ui/ImageThumbnailPanel.java
+++ b/Core/src/org/sleuthkit/autopsy/discovery/ui/ImageThumbnailPanel.java
@@ -28,6 +28,7 @@
 import javax.swing.JList;
 import javax.swing.ListCellRenderer;
 import org.openide.util.NbBundle;
+import org.sleuthkit.autopsy.coreutils.ThreadConfined;
 
 /**
  * Class which displays a thumbnail and information for an image file.
@@ -41,6 +42,7 @@ final class ImageThumbnailPanel extends javax.swing.JPanel implements ListCellRe
     /**
      * Creates new form ImageThumbnailPanel
      */
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     ImageThumbnailPanel() {
         initComponents();
     }
@@ -129,6 +131,7 @@ private void initComponents() {
     private javax.swing.JLabel thumbnailLabel;
     // End of variables declaration//GEN-END:variables
 
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     @NbBundle.Messages({
         "# {0} - otherInstanceCount",
         "ImageThumbnailPanel.nameLabel.more.text= and {0} more",
@@ -152,6 +155,7 @@ public Component getListCellRendererComponent(JList<? extends ImageThumbnailWrap
         return this;
     }
 
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     @Override
     public String getToolTipText(MouseEvent event) {
         if (event != null) {
diff --git a/Core/src/org/sleuthkit/autopsy/discovery/ui/ImageThumbnailViewer.java b/Core/src/org/sleuthkit/autopsy/discovery/ui/ImageThumbnailViewer.java
index 273cca8023ce1f7878c24e1bd9a2256953f56edf..cd9fd4909a6e696d8d36128f6106587ee266583c 100644
--- a/Core/src/org/sleuthkit/autopsy/discovery/ui/ImageThumbnailViewer.java
+++ b/Core/src/org/sleuthkit/autopsy/discovery/ui/ImageThumbnailViewer.java
@@ -22,6 +22,7 @@
 import java.util.List;
 import javax.swing.DefaultListModel;
 import javax.swing.event.ListSelectionListener;
+import org.sleuthkit.autopsy.coreutils.ThreadConfined;
 import org.sleuthkit.datamodel.AbstractFile;
 
 /**
@@ -37,6 +38,7 @@ final class ImageThumbnailViewer extends javax.swing.JPanel {
     /**
      * Creates new form ImageThumbnailViewer.
      */
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     ImageThumbnailViewer() {
         initComponents();
 
@@ -77,6 +79,7 @@ private void initComponents() {
      *
      * @param listener The ListSelectionListener to add to the selection model.
      */
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     void addListSelectionListener(ListSelectionListener listener) {
         thumbnailList.getSelectionModel().addListSelectionListener(listener);
     }
@@ -88,24 +91,23 @@ void addListSelectionListener(ListSelectionListener listener) {
      * @return The list of AbstractFiles which are represented by the selected
      *         image thumbnail.
      */
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     List<AbstractFile> getInstancesForSelected() {
-        synchronized (this) {
-            if (thumbnailList.getSelectedIndex() == -1) {
-                return new ArrayList<>();
-            } else {
-                return thumbnailListModel.getElementAt(thumbnailList.getSelectedIndex()).getResultFile().getAllInstances();
-            }
+        if (thumbnailList.getSelectedIndex() == -1) {
+            return new ArrayList<>();
+        } else {
+            return thumbnailListModel.getElementAt(thumbnailList.getSelectedIndex()).getResultFile().getAllInstances();
         }
     }
 
     /**
      * Clear the list of thumbnails being displayed.
      */
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     void clearViewer() {
-        synchronized (this) {
-            thumbnailListModel.removeAllElements();
-            thumbnailListScrollPane.getVerticalScrollBar().setValue(0);
-        }
+        thumbnailListModel.removeAllElements();
+        thumbnailListScrollPane.getVerticalScrollBar().setValue(0);
+
     }
 
     /**
@@ -114,9 +116,8 @@ void clearViewer() {
      * @param thumbnailWrapper The object which contains the thumbnail which
      *                         will be displayed.
      */
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     void addImage(ImageThumbnailWrapper thumbnailWrapper) {
-        synchronized (this) {
-            thumbnailListModel.addElement(thumbnailWrapper);
-        }
+        thumbnailListModel.addElement(thumbnailWrapper);
     }
 }
diff --git a/Core/src/org/sleuthkit/autopsy/discovery/ui/InterestingItemsFilterPanel.java b/Core/src/org/sleuthkit/autopsy/discovery/ui/InterestingItemsFilterPanel.java
index b77956937d1852b5c0b7e76ad56c6e5a0219fc11..184ddb1d29d7f21ab8a7869ac4c385f0ed9668f4 100644
--- a/Core/src/org/sleuthkit/autopsy/discovery/ui/InterestingItemsFilterPanel.java
+++ b/Core/src/org/sleuthkit/autopsy/discovery/ui/InterestingItemsFilterPanel.java
@@ -27,6 +27,7 @@
 import javax.swing.JList;
 import org.openide.util.NbBundle;
 import org.sleuthkit.autopsy.coreutils.Logger;
+import org.sleuthkit.autopsy.coreutils.ThreadConfined;
 import org.sleuthkit.autopsy.discovery.search.SearchFiltering;
 import org.sleuthkit.datamodel.BlackboardArtifact;
 import org.sleuthkit.datamodel.BlackboardAttribute;
@@ -43,6 +44,7 @@ final class InterestingItemsFilterPanel extends AbstractDiscoveryFilterPanel {
     /**
      * Creates new form InterestingItemsFilterPanel.
      */
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     InterestingItemsFilterPanel() {
         initComponents();
         setUpInterestingItemsFilter();
@@ -51,6 +53,7 @@ final class InterestingItemsFilterPanel extends AbstractDiscoveryFilterPanel {
     /**
      * Initialize the interesting items filter.
      */
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     private void setUpInterestingItemsFilter() {
         int count = 0;
         try {
@@ -118,6 +121,7 @@ private void interestingItemsCheckboxActionPerformed(java.awt.event.ActionEvent
         interestingItemsList.setEnabled(interestingItemsCheckbox.isSelected());
     }//GEN-LAST:event_interestingItemsCheckboxActionPerformed
 
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     @Override
     void configurePanel(boolean selected, int[] indicesSelected) {
         boolean hasInterestingItems = interestingItemsList.getModel().getSize() > 0;
@@ -135,6 +139,7 @@ void configurePanel(boolean selected, int[] indicesSelected) {
         }
     }
 
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     @Override
     JCheckBox getCheckbox() {
         return interestingItemsCheckbox;
@@ -145,6 +150,7 @@ JLabel getAdditionalLabel() {
         return null;
     }
 
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     @NbBundle.Messages({"InterestingItemsFilterPanel.error.text=At least one interesting file set name must be selected."})
     @Override
     String checkForError() {
@@ -161,11 +167,13 @@ String checkForError() {
     private javax.swing.JScrollPane interestingItemsScrollPane;
     // End of variables declaration//GEN-END:variables
 
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     @Override
     JList<?> getList() {
         return interestingItemsList;
     }
 
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     @Override
     AbstractFilter getFilter() {
         if (interestingItemsCheckbox.isSelected()) {
diff --git a/Core/src/org/sleuthkit/autopsy/discovery/ui/ObjectDetectedFilterPanel.java b/Core/src/org/sleuthkit/autopsy/discovery/ui/ObjectDetectedFilterPanel.java
index eb1c1525c59d8b003bf668cda6c6f81263718e04..a6eb3de1c52fc6fd0ab322673f5144f964210f45 100644
--- a/Core/src/org/sleuthkit/autopsy/discovery/ui/ObjectDetectedFilterPanel.java
+++ b/Core/src/org/sleuthkit/autopsy/discovery/ui/ObjectDetectedFilterPanel.java
@@ -27,6 +27,7 @@
 import javax.swing.JList;
 import org.openide.util.NbBundle;
 import org.sleuthkit.autopsy.coreutils.Logger;
+import org.sleuthkit.autopsy.coreutils.ThreadConfined;
 import org.sleuthkit.autopsy.discovery.search.SearchFiltering;
 import org.sleuthkit.datamodel.BlackboardArtifact;
 import org.sleuthkit.datamodel.BlackboardAttribute;
@@ -43,6 +44,7 @@ final class ObjectDetectedFilterPanel extends AbstractDiscoveryFilterPanel {
     /**
      * Creates new form ObjectDetectedFilter.
      */
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     ObjectDetectedFilterPanel() {
         initComponents();
         setUpObjectFilter();
@@ -51,6 +53,7 @@ final class ObjectDetectedFilterPanel extends AbstractDiscoveryFilterPanel {
     /**
      * Initialize the object filter.
      */
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     private void setUpObjectFilter() {
         int count = 0;
         try {
@@ -129,6 +132,7 @@ private void objectsCheckboxActionPerformed(java.awt.event.ActionEvent evt) {//G
     private javax.swing.JScrollPane objectsScrollPane;
     // End of variables declaration//GEN-END:variables
 
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     @Override
     void configurePanel(boolean selected, int[] indicesSelected) {
         boolean hasObjects = objectsList.getModel().getSize() > 0;
@@ -146,6 +150,7 @@ void configurePanel(boolean selected, int[] indicesSelected) {
         }
     }
 
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     @Override
     JCheckBox getCheckbox() {
         return objectsCheckbox;
@@ -155,6 +160,8 @@ JCheckBox getCheckbox() {
     JLabel getAdditionalLabel() {
         return null;
     }
+
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     @NbBundle.Messages({"ObjectDetectedFilterPanel.error.text=At least one object type name must be selected."})
     @Override
     String checkForError() {
@@ -164,11 +171,13 @@ String checkForError() {
         return "";
     }
 
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     @Override
     JList<?> getList() {
         return objectsList;
     }
 
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     @Override
     AbstractFilter getFilter() {
         if (objectsCheckbox.isSelected()) {
diff --git a/Core/src/org/sleuthkit/autopsy/discovery/ui/OpenDiscoveryAction.java b/Core/src/org/sleuthkit/autopsy/discovery/ui/OpenDiscoveryAction.java
index 33228a6a9b36a61cc3523017e9cb33292a68dea7..ec767c4c8387edf265adfd9bb239b54498756842 100644
--- a/Core/src/org/sleuthkit/autopsy/discovery/ui/OpenDiscoveryAction.java
+++ b/Core/src/org/sleuthkit/autopsy/discovery/ui/OpenDiscoveryAction.java
@@ -31,6 +31,7 @@
 import org.openide.util.actions.CallableSystemAction;
 import org.openide.util.actions.Presenter;
 import org.sleuthkit.autopsy.casemodule.Case;
+import org.sleuthkit.autopsy.coreutils.ThreadConfined;
 
 /**
  * Class to open the Discovery dialog. Allows the user to run searches and see
@@ -76,6 +77,7 @@ public void performAction() {
      *
      * @return The toolbar button
      */
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     @Override
     public Component getToolbarPresenter() {
         ImageIcon icon = new ImageIcon(getClass().getResource("/org/sleuthkit/autopsy/images/discovery-icon-24.png")); //NON-NLS
diff --git a/Core/src/org/sleuthkit/autopsy/discovery/ui/ParentFolderFilterPanel.java b/Core/src/org/sleuthkit/autopsy/discovery/ui/ParentFolderFilterPanel.java
index a2a0d9615f2844e7ec28540a37d00257d6032841..2a77c28873d0e0ba95d1e4b7bf4f01ff64c3959d 100644
--- a/Core/src/org/sleuthkit/autopsy/discovery/ui/ParentFolderFilterPanel.java
+++ b/Core/src/org/sleuthkit/autopsy/discovery/ui/ParentFolderFilterPanel.java
@@ -26,6 +26,7 @@
 import javax.swing.JLabel;
 import javax.swing.JList;
 import org.openide.util.NbBundle;
+import org.sleuthkit.autopsy.coreutils.ThreadConfined;
 import org.sleuthkit.autopsy.discovery.search.SearchFiltering;
 import org.sleuthkit.autopsy.discovery.search.SearchFiltering.ParentSearchTerm;
 
@@ -41,6 +42,7 @@ final class ParentFolderFilterPanel extends AbstractDiscoveryFilterPanel {
     /**
      * Creates new form ParentFolderFilterPanel.
      */
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     ParentFolderFilterPanel() {
         initComponents();
         setUpParentPathFilter();
@@ -49,6 +51,7 @@ final class ParentFolderFilterPanel extends AbstractDiscoveryFilterPanel {
     /**
      * Initialize the parent path filter.
      */
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     private void setUpParentPathFilter() {
         fullRadioButton.setSelected(true);
         includeRadioButton.setSelected(true);
@@ -239,6 +242,7 @@ private void addButtonActionPerformed(java.awt.event.ActionEvent evt) {//GEN-FIR
     private javax.swing.JRadioButton substringRadioButton;
     // End of variables declaration//GEN-END:variables
 
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     @Override
     void configurePanel(boolean selected, int[] indicesSelected) {
         parentCheckbox.setSelected(selected);
@@ -270,16 +274,19 @@ void configurePanel(boolean selected, int[] indicesSelected) {
         }
     }
 
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     @Override
     JCheckBox getCheckbox() {
         return parentCheckbox;
     }
 
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     @Override
     JLabel getAdditionalLabel() {
         return parentLabel;
     }
 
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     @NbBundle.Messages({"ParentFolderFilterPanel.error.text=At least one parent path must be entered."})
     @Override
     String checkForError() {
@@ -290,6 +297,7 @@ String checkForError() {
         return "";
     }
 
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     /**
      * Utility method to get the parent path objects out of the JList.
      *
@@ -303,11 +311,13 @@ private List<SearchFiltering.ParentSearchTerm> getParentPaths() {
         return results;
     }
 
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     @Override
     JList<?> getList() {
         return parentList;
     }
 
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     @Override
     AbstractFilter getFilter() {
         if (parentCheckbox.isSelected()) {
diff --git a/Core/src/org/sleuthkit/autopsy/discovery/ui/PastOccurrencesFilterPanel.java b/Core/src/org/sleuthkit/autopsy/discovery/ui/PastOccurrencesFilterPanel.java
index b19c79355613f3cd34be007d465360c502f441ac..bedd0a24c865db332e521f289822d9c63431226a 100644
--- a/Core/src/org/sleuthkit/autopsy/discovery/ui/PastOccurrencesFilterPanel.java
+++ b/Core/src/org/sleuthkit/autopsy/discovery/ui/PastOccurrencesFilterPanel.java
@@ -24,6 +24,7 @@
 import javax.swing.JList;
 import org.openide.util.NbBundle;
 import org.sleuthkit.autopsy.centralrepository.datamodel.CentralRepository;
+import org.sleuthkit.autopsy.coreutils.ThreadConfined;
 import org.sleuthkit.autopsy.discovery.search.AbstractFilter;
 import org.sleuthkit.autopsy.discovery.search.SearchData;
 import org.sleuthkit.autopsy.discovery.search.SearchData.Frequency;
@@ -41,6 +42,7 @@ final class PastOccurrencesFilterPanel extends AbstractDiscoveryFilterPanel {
     /**
      * Creates new form PastOccurrencesFilterPanel.
      */
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     PastOccurrencesFilterPanel(Type type) {
         initComponents();
         this.type = type;
@@ -101,6 +103,7 @@ private void pastOccurrencesCheckboxActionPerformed(java.awt.event.ActionEvent e
     /**
      * Initialize the frequency filter.
      */
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     private void setUpFrequencyFilter() {
         int count = 0;
         DefaultListModel<SearchData.Frequency> frequencyListModel = (DefaultListModel<SearchData.Frequency>) crFrequencyList.getModel();
@@ -126,6 +129,7 @@ private void setUpFrequencyFilter() {
     private javax.swing.JCheckBox pastOccurrencesCheckbox;
     // End of variables declaration//GEN-END:variables
 
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     @Override
     void configurePanel(boolean selected, int[] indicesSelected) {
         boolean canBeFilteredOn = type != Type.DOMAIN || CentralRepository.isEnabled();
@@ -144,6 +148,7 @@ void configurePanel(boolean selected, int[] indicesSelected) {
         }
     }
 
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     @Override
     JCheckBox getCheckbox() {
         return pastOccurrencesCheckbox;
@@ -154,6 +159,7 @@ JLabel getAdditionalLabel() {
         return null;
     }
 
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     @NbBundle.Messages({"PastOccurrencesFilterPanel.error.text=At least one value in the past occurrence filter must be selected."})
     @Override
     String checkForError() {
@@ -163,11 +169,13 @@ String checkForError() {
         return "";
     }
 
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     @Override
     JList<?> getList() {
         return crFrequencyList;
     }
 
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     @Override
     AbstractFilter getFilter() {
         if (pastOccurrencesCheckbox.isSelected()) {
diff --git a/Core/src/org/sleuthkit/autopsy/discovery/ui/ResultsPanel.java b/Core/src/org/sleuthkit/autopsy/discovery/ui/ResultsPanel.java
index c077f65f223c585975fe8f83b993964106cd82ac..b6aad696ce134334cbd9e6d9a099bde609739442 100644
--- a/Core/src/org/sleuthkit/autopsy/discovery/ui/ResultsPanel.java
+++ b/Core/src/org/sleuthkit/autopsy/discovery/ui/ResultsPanel.java
@@ -40,6 +40,7 @@
 import org.sleuthkit.autopsy.coreutils.Logger;
 import org.sleuthkit.datamodel.AbstractFile;
 import org.sleuthkit.autopsy.centralrepository.datamodel.CentralRepository;
+import org.sleuthkit.autopsy.coreutils.ThreadConfined;
 import org.sleuthkit.autopsy.discovery.search.DiscoveryAttributes;
 import org.sleuthkit.autopsy.discovery.search.DiscoveryEventUtils;
 import org.sleuthkit.autopsy.discovery.search.DiscoveryKeyUtils.GroupKey;
@@ -85,6 +86,7 @@ final class ResultsPanel extends javax.swing.JPanel {
      */
     @Messages({"ResultsPanel.viewFileInDir.name=View File in Directory",
         "ResultsPanel.openInExternalViewer.name=Open in External Viewer"})
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     ResultsPanel() {
         initComponents();
         imageThumbnailViewer = new ImageThumbnailViewer();
@@ -125,9 +127,14 @@ final class ResultsPanel extends javax.swing.JPanel {
                 }
             }
         });
-        //JIRA-TODO 6307 Add listener for domainSummaryViewer when 6782, 6773, and the other details area related stories are done
+        domainSummaryViewer.addListSelectionListener((e) -> {
+            if (resultType == SearchData.Type.DOMAIN) {
+                domainSummaryViewer.sendPopulateEvent(!e.getValueIsAdjusting());
+            }
+        });
     }
 
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     SearchData.Type getActiveType() {
         return resultType;
     }
@@ -139,6 +146,7 @@ SearchData.Type getActiveType() {
      * @return The list of AbstractFiles which are represented by the item
      *         selected in the results viewer area.
      */
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     private List<AbstractFile> getInstancesForSelected() {
         if (null != resultType) {
             switch (resultType) {
@@ -209,23 +217,25 @@ void handlePageRetrievedEvent(DiscoveryEventUtils.PageRetrievedEvent pageRetriev
         }
         );
     }
-    
+
     @Subscribe
     void handleCancelBackgroundTasksEvent(DiscoveryEventUtils.CancelBackgroundTasksEvent cancelEvent) {
-        for (SwingWorker<Void, Void> thumbWorker : resultContentWorkers) {
-            if (!thumbWorker.isDone()) {
-                thumbWorker.cancel(true);
+        SwingUtilities.invokeLater(() -> {
+            for (SwingWorker<Void, Void> thumbWorker : resultContentWorkers) {
+                if (!thumbWorker.isDone()) {
+                    thumbWorker.cancel(true);
+                }
             }
-        }
-        
-        resultContentWorkers.clear();
+            resultContentWorkers.clear();
+        });
     }
 
     /**
      * Reset the result viewer and any associate workers to a default empty
      * state.
      */
-    synchronized void resetResultViewer() {
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
+    void resetResultViewer() {
         resultsViewerPanel.remove(imageThumbnailViewer);
         resultsViewerPanel.remove(videoThumbnailViewer);
         resultsViewerPanel.remove(documentPreviewViewer);
@@ -250,7 +260,8 @@ synchronized void resetResultViewer() {
      *
      * @param results The list of ResultFiles to populate the video viewer with.
      */
-    synchronized void populateVideoViewer(List<Result> results) {
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
+    void populateVideoViewer(List<Result> results) {
         for (Result result : results) {
             VideoThumbnailWorker thumbWorker = new VideoThumbnailWorker((ResultFile) result);
             thumbWorker.execute();
@@ -265,7 +276,8 @@ synchronized void populateVideoViewer(List<Result> results) {
      *
      * @param results The list of ResultFiles to populate the image viewer with.
      */
-    synchronized void populateImageViewer(List<Result> results) {
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
+    void populateImageViewer(List<Result> results) {
         for (Result result : results) {
             ImageThumbnailWorker thumbWorker = new ImageThumbnailWorker((ResultFile) result);
             thumbWorker.execute();
@@ -281,7 +293,8 @@ synchronized void populateImageViewer(List<Result> results) {
      * @param results The list of ResultFiles to populate the document viewer
      *                with.
      */
-    synchronized void populateDocumentViewer(List<Result> results) {
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
+    void populateDocumentViewer(List<Result> results) {
         for (Result result : results) {
             DocumentPreviewWorker documentWorker = new DocumentPreviewWorker((ResultFile) result);
             documentWorker.execute();
@@ -297,7 +310,8 @@ synchronized void populateDocumentViewer(List<Result> results) {
      * @param results The list of ResultDomains to populate the domain summary
      *                viewer with.
      */
-    synchronized void populateDomainViewer(List<Result> results) {
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
+    void populateDomainViewer(List<Result> results) {
         SleuthkitCase currentCase;
         try {
             currentCase = Case.getCurrentCaseThrows().getSleuthkitCase();
@@ -374,37 +388,33 @@ void handleSearchCompleteEvent(DiscoveryEventUtils.SearchCompleteEvent searchCom
      * @param startingEntry The index of the first file in the group to include
      *                      in this page.
      */
-    @Subscribe
-    private synchronized void setPage(int startingEntry
-    ) {
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
+    private void setPage(int startingEntry) {
         int pageSize = pageSizeComboBox.getItemAt(pageSizeComboBox.getSelectedIndex());
-        synchronized (this) {
-            if (pageWorker != null && !pageWorker.isDone()) {
-                pageWorker.cancel(true);
-            }
-            CentralRepository centralRepo = null;
-            if (CentralRepository.isEnabled()) {
-                try {
-                    centralRepo = CentralRepository.getInstance();
-                } catch (CentralRepoException ex) {
-                    centralRepo = null;
-                    logger.log(Level.SEVERE, "Error loading central repository database, no central repository options will be available for Discovery", ex);
-                }
-            }
-            if (groupSize != 0) {
-                pageWorker = new PageWorker(searchFilters, groupingAttribute, groupSort, fileSortMethod, selectedGroupKey, startingEntry, pageSize, resultType, centralRepo);
-                pageWorker.execute();
-            } else {
-                SwingUtilities.invokeLater(() -> {
-                    pageSizeComboBox.setEnabled(true);
-                });
+        if (pageWorker != null && !pageWorker.isDone()) {
+            pageWorker.cancel(true);
+        }
+        CentralRepository centralRepo = null;
+        if (CentralRepository.isEnabled()) {
+            try {
+                centralRepo = CentralRepository.getInstance();
+            } catch (CentralRepoException ex) {
+                centralRepo = null;
+                logger.log(Level.SEVERE, "Error loading central repository database, no central repository options will be available for Discovery", ex);
             }
         }
+        if (groupSize != 0) {
+            pageWorker = new PageWorker(searchFilters, groupingAttribute, groupSort, fileSortMethod, selectedGroupKey, startingEntry, pageSize, resultType, centralRepo);
+            pageWorker.execute();
+        } else {
+            pageSizeComboBox.setEnabled(true);
+        }
     }
 
     /**
      * Enable the paging controls based on what exists in the page.
      */
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     @Messages({"# {0} - currentPage",
         "# {1} - totalPages",
         "ResultsPanel.currentPage.displayValue=Page: {0} of {1}"})
@@ -678,6 +688,7 @@ private void pageSizeChanged(java.awt.event.ItemEvent evt) {//GEN-FIRST:event_pa
     /**
      * Disable all the paging controls.
      */
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     private void disablePagingControls() {
         nextPageButton.setEnabled(false);
         previousPageButton.setEnabled(false);
@@ -708,6 +719,7 @@ private class VideoThumbnailWorker extends SwingWorker<Void, Void> {
          * @param file The ResultFile which represents the video file thumbnails
          *             are being retrieved for.
          */
+        @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
         VideoThumbnailWorker(ResultFile file) {
             thumbnailWrapper = new VideoThumbnailsWrapper(file);
             videoThumbnailViewer.addVideo(thumbnailWrapper);
@@ -746,6 +758,7 @@ private class ImageThumbnailWorker extends SwingWorker<Void, Void> {
          * @param file The ResultFile which represents the image file thumbnails
          *             are being retrieved for.
          */
+        @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
         ImageThumbnailWorker(ResultFile file) {
             thumbnailWrapper = new ImageThumbnailWrapper(file);
             imageThumbnailViewer.addImage(thumbnailWrapper);
@@ -788,6 +801,7 @@ private class DocumentPreviewWorker extends SwingWorker<Void, Void> {
          * @param file The ResultFile which represents the document file a
          *             preview is being retrieved for.
          */
+        @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
         DocumentPreviewWorker(ResultFile file) {
             documentWrapper = new DocumentWrapper(file);
             documentPreviewViewer.addDocument(documentWrapper);
@@ -836,6 +850,7 @@ private class DomainThumbnailWorker extends SwingWorker<Void, Void> {
          * @param file The ResultFile which represents the domain attribute the
          *             preview is being retrieved for.
          */
+        @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
         DomainThumbnailWorker(SleuthkitCase caseDb, ResultDomain domain) {
             this.caseDb = caseDb;
             domainWrapper = new DomainWrapper(domain);
diff --git a/Core/src/org/sleuthkit/autopsy/discovery/ui/ResultsSplitPaneDivider.java b/Core/src/org/sleuthkit/autopsy/discovery/ui/ResultsSplitPaneDivider.java
index cffb2dac7ffc2c8b06f6a49911409dec5f3af6dd..87c42fbc39d1238d336d552b57e90a596bdfb220 100644
--- a/Core/src/org/sleuthkit/autopsy/discovery/ui/ResultsSplitPaneDivider.java
+++ b/Core/src/org/sleuthkit/autopsy/discovery/ui/ResultsSplitPaneDivider.java
@@ -19,6 +19,7 @@
 package org.sleuthkit.autopsy.discovery.ui;
 
 import java.awt.Cursor;
+import org.sleuthkit.autopsy.coreutils.ThreadConfined;
 
 import org.sleuthkit.autopsy.discovery.search.DiscoveryEventUtils;
 
@@ -32,6 +33,7 @@ final class ResultsSplitPaneDivider extends javax.swing.JPanel {
     /**
      * Creates new form LabeledSplitPaneDivider.
      */
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     ResultsSplitPaneDivider() {
         initComponents();
     }
diff --git a/Core/src/org/sleuthkit/autopsy/discovery/ui/SizeFilterPanel.java b/Core/src/org/sleuthkit/autopsy/discovery/ui/SizeFilterPanel.java
index 0585750aa4132869e1d38e09f84539b6ca8a6fe4..e037f1f8eedae039a446063e738b19870770a359 100644
--- a/Core/src/org/sleuthkit/autopsy/discovery/ui/SizeFilterPanel.java
+++ b/Core/src/org/sleuthkit/autopsy/discovery/ui/SizeFilterPanel.java
@@ -26,6 +26,7 @@
 import javax.swing.JLabel;
 import javax.swing.JList;
 import org.openide.util.NbBundle;
+import org.sleuthkit.autopsy.coreutils.ThreadConfined;
 import org.sleuthkit.autopsy.discovery.search.SearchData;
 import org.sleuthkit.autopsy.discovery.search.SearchData.FileSize;
 import org.sleuthkit.autopsy.discovery.search.SearchFiltering;
@@ -42,6 +43,7 @@ final class SizeFilterPanel extends AbstractDiscoveryFilterPanel {
      *
      * @param type The type of result being searched for.
      */
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     SizeFilterPanel(SearchData.Type type) {
         initComponents();
         setUpSizeFilter(type);
@@ -109,6 +111,7 @@ private void sizeCheckboxActionPerformed(java.awt.event.ActionEvent evt) {//GEN-
     private javax.swing.JScrollPane sizeScrollPane;
     // End of variables declaration//GEN-END:variables
 
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     @Override
     void configurePanel(boolean selected, int[] indicesSelected) {
         sizeCheckbox.setSelected(selected);
@@ -124,6 +127,7 @@ void configurePanel(boolean selected, int[] indicesSelected) {
         }
     }
 
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     @Override
     JCheckBox getCheckbox() {
         return sizeCheckbox;
@@ -137,6 +141,7 @@ JLabel getAdditionalLabel() {
     /**
      * Initialize the file size filter.
      */
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     private void setUpSizeFilter(SearchData.Type fileType) {
         int count = 0;
         DefaultListModel<FileSize> sizeListModel = (DefaultListModel<FileSize>) sizeList.getModel();
@@ -169,6 +174,7 @@ private void setUpSizeFilter(SearchData.Type fileType) {
 
     @NbBundle.Messages({"SizeFilterPanel.error.text=At least one size must be selected."})
     @Override
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     String checkForError() {
         if (sizeCheckbox.isSelected() && sizeList.getSelectedValuesList().isEmpty()) {
             return Bundle.SizeFilterPanel_error_text();
@@ -177,11 +183,13 @@ String checkForError() {
 
     }
 
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     @Override
     JList<?> getList() {
         return sizeList;
     }
 
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     @Override
     AbstractFilter getFilter() {
         if (sizeCheckbox.isSelected()) {
diff --git a/Core/src/org/sleuthkit/autopsy/discovery/ui/UserCreatedFilterPanel.java b/Core/src/org/sleuthkit/autopsy/discovery/ui/UserCreatedFilterPanel.java
index d65596633aa2be578957a3b74033baa8a0258f82..10d976f7fe60b67e5cf4bf22f85e0b7e4cfe5052 100644
--- a/Core/src/org/sleuthkit/autopsy/discovery/ui/UserCreatedFilterPanel.java
+++ b/Core/src/org/sleuthkit/autopsy/discovery/ui/UserCreatedFilterPanel.java
@@ -22,6 +22,7 @@
 import javax.swing.JCheckBox;
 import javax.swing.JLabel;
 import javax.swing.JList;
+import org.sleuthkit.autopsy.coreutils.ThreadConfined;
 import org.sleuthkit.autopsy.discovery.search.SearchFiltering;
 
 /**
@@ -34,6 +35,7 @@ final class UserCreatedFilterPanel extends AbstractDiscoveryFilterPanel {
     /**
      * Creates new form UserCreatedFilterPanel.
      */
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     UserCreatedFilterPanel() {
         initComponents();
     }
@@ -69,11 +71,13 @@ private void initComponents() {
         );
     }// </editor-fold>//GEN-END:initComponents
 
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     @Override
     void configurePanel(boolean selected, int[] indicesSelected) {
         userCreatedCheckbox.setSelected(selected);
     }
 
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     @Override
     JCheckBox getCheckbox() {
         return userCreatedCheckbox;
@@ -99,6 +103,7 @@ JList<?> getList() {
         return null;
     }
 
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     @Override
     AbstractFilter getFilter() {
         if (userCreatedCheckbox.isSelected()) {
diff --git a/Core/src/org/sleuthkit/autopsy/discovery/ui/VideoFilterPanel.java b/Core/src/org/sleuthkit/autopsy/discovery/ui/VideoFilterPanel.java
index 82b0d030bce8d1397aef5099a466cdc67420a7b2..ca96c217c4f02325056cc4da2cf0114dc4c46230 100644
--- a/Core/src/org/sleuthkit/autopsy/discovery/ui/VideoFilterPanel.java
+++ b/Core/src/org/sleuthkit/autopsy/discovery/ui/VideoFilterPanel.java
@@ -19,6 +19,7 @@
 package org.sleuthkit.autopsy.discovery.ui;
 
 import org.sleuthkit.autopsy.centralrepository.datamodel.CentralRepository;
+import org.sleuthkit.autopsy.coreutils.ThreadConfined;
 import org.sleuthkit.autopsy.discovery.search.SearchData;
 
 /**
@@ -32,6 +33,7 @@ final class VideoFilterPanel extends AbstractFiltersPanel {
     /**
      * Creates new form VideoFilterPanel.
      */
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     VideoFilterPanel() {
         super();
         initComponents();
diff --git a/Core/src/org/sleuthkit/autopsy/discovery/ui/VideoThumbnailPanel.java b/Core/src/org/sleuthkit/autopsy/discovery/ui/VideoThumbnailPanel.java
index 7a62937582fd7b6fdc7580585b5e65900ced3ef2..161e6a0770411a067a75e076f2e135e11c12da4d 100644
--- a/Core/src/org/sleuthkit/autopsy/discovery/ui/VideoThumbnailPanel.java
+++ b/Core/src/org/sleuthkit/autopsy/discovery/ui/VideoThumbnailPanel.java
@@ -32,6 +32,7 @@
 import javax.swing.JList;
 import javax.swing.ListCellRenderer;
 import org.openide.util.NbBundle.Messages;
+import org.sleuthkit.autopsy.coreutils.ThreadConfined;
 
 /**
  * Class which displays thumbnails and information for a video file.
@@ -47,6 +48,7 @@ final class VideoThumbnailPanel extends javax.swing.JPanel implements ListCellRe
     /**
      * Creates new form VideoThumbnailPanel.
      */
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     VideoThumbnailPanel() {
         initComponents();
         this.setFocusable(true);
@@ -58,6 +60,7 @@ final class VideoThumbnailPanel extends javax.swing.JPanel implements ListCellRe
      * @param thumbnailWrapper The object which contains the video thumbnails to
      *                         add.
      */
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     private void addThumbnails(VideoThumbnailsWrapper thumbnailWrapper) {
         imagePanel.removeAll();
         GridBagConstraints gridBagConstraints = new GridBagConstraints();
@@ -164,6 +167,7 @@ private void initComponents() {
     private javax.swing.JLabel scoreLabel;
     // End of variables declaration//GEN-END:variables
 
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     @Messages({
         "# {0} - otherInstanceCount",
         "VideoThumbnailPanel.nameLabel.more.text= and {0} more",
@@ -231,6 +235,7 @@ private String getFileSizeString(long bytes) {
         return Bundle.VideoThumbnailPanel_sizeLabel_text(size, units);
     }
 
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     @Override
     public String getToolTipText(MouseEvent event) {
         if (event != null) {
diff --git a/Core/src/org/sleuthkit/autopsy/discovery/ui/VideoThumbnailViewer.java b/Core/src/org/sleuthkit/autopsy/discovery/ui/VideoThumbnailViewer.java
index 8824e6f5d891a25f607f5452111e171be3a95771..f2c250344d5d378d7487e9875224736179e520ef 100644
--- a/Core/src/org/sleuthkit/autopsy/discovery/ui/VideoThumbnailViewer.java
+++ b/Core/src/org/sleuthkit/autopsy/discovery/ui/VideoThumbnailViewer.java
@@ -22,6 +22,7 @@
 import java.util.List;
 import javax.swing.DefaultListModel;
 import javax.swing.event.ListSelectionListener;
+import org.sleuthkit.autopsy.coreutils.ThreadConfined;
 import org.sleuthkit.datamodel.AbstractFile;
 
 /**
@@ -36,6 +37,7 @@ final class VideoThumbnailViewer extends javax.swing.JPanel {
     /**
      * Creates new form VideoThumbnailViewer.
      */
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     VideoThumbnailViewer() {
         initComponents();
     }
@@ -45,6 +47,7 @@ final class VideoThumbnailViewer extends javax.swing.JPanel {
      *
      * @param listener The ListSelectionListener to add to the selection model.
      */
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     void addListSelectionListener(ListSelectionListener listener) {
         thumbnailList.getSelectionModel().addListSelectionListener(listener);
     }
@@ -56,24 +59,22 @@ void addListSelectionListener(ListSelectionListener listener) {
      * @return The list of AbstractFiles which are represented by the selected
      *         Video thumbnails.
      */
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     List<AbstractFile> getInstancesForSelected() {
-        synchronized (this) {
-            if (thumbnailList.getSelectedIndex() == -1) {
-                return new ArrayList<>();
-            } else {
-                return thumbnailListModel.getElementAt(thumbnailList.getSelectedIndex()).getResultFile().getAllInstances();
-            }
+        if (thumbnailList.getSelectedIndex() == -1) {
+            return new ArrayList<>();
+        } else {
+            return thumbnailListModel.getElementAt(thumbnailList.getSelectedIndex()).getResultFile().getAllInstances();
         }
     }
 
     /**
      * Clear the list of thumbnails being displayed.
      */
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     void clearViewer() {
-        synchronized (this) {
-            thumbnailListModel.removeAllElements();
-            thumbnailListScrollPane.getVerticalScrollBar().setValue(0);
-        }
+        thumbnailListModel.removeAllElements();
+        thumbnailListScrollPane.getVerticalScrollBar().setValue(0);
     }
 
     /**
@@ -82,10 +83,9 @@ void clearViewer() {
      * @param thumbnailWrapper The object which contains the thumbnails which
      *                         will be displayed.
      */
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
     void addVideo(VideoThumbnailsWrapper thumbnailWrapper) {
-        synchronized (this) {
-            thumbnailListModel.addElement(thumbnailWrapper);
-        }
+        thumbnailListModel.addElement(thumbnailWrapper);
     }
 
     /**
diff --git a/Core/src/org/sleuthkit/autopsy/discovery/ui/WebHistoryDetailsPanel.form b/Core/src/org/sleuthkit/autopsy/discovery/ui/WebHistoryDetailsPanel.form
new file mode 100644
index 0000000000000000000000000000000000000000..5f3eab1a5f91bb18ae28ed94f4701042de7d9b17
--- /dev/null
+++ b/Core/src/org/sleuthkit/autopsy/discovery/ui/WebHistoryDetailsPanel.form
@@ -0,0 +1,28 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+
+<Form version="1.4" maxVersion="1.9" type="org.netbeans.modules.form.forminfo.JPanelFormInfo">
+  <AuxValues>
+    <AuxValue name="FormSettings_autoResourcing" type="java.lang.Integer" value="1"/>
+    <AuxValue name="FormSettings_autoSetComponentName" type="java.lang.Boolean" value="false"/>
+    <AuxValue name="FormSettings_generateFQN" type="java.lang.Boolean" value="true"/>
+    <AuxValue name="FormSettings_generateMnemonicsCode" type="java.lang.Boolean" value="true"/>
+    <AuxValue name="FormSettings_i18nAutoMode" type="java.lang.Boolean" value="true"/>
+    <AuxValue name="FormSettings_layoutCodeTarget" type="java.lang.Integer" value="1"/>
+    <AuxValue name="FormSettings_listenerGenerationStyle" type="java.lang.Integer" value="0"/>
+    <AuxValue name="FormSettings_variablesLocal" type="java.lang.Boolean" value="false"/>
+    <AuxValue name="FormSettings_variablesModifier" type="java.lang.Integer" value="2"/>
+  </AuxValues>
+
+  <Layout>
+    <DimensionLayout dim="0">
+      <Group type="103" groupAlignment="0" attributes="0">
+          <EmptySpace min="0" pref="400" max="32767" attributes="0"/>
+      </Group>
+    </DimensionLayout>
+    <DimensionLayout dim="1">
+      <Group type="103" groupAlignment="0" attributes="0">
+          <EmptySpace min="0" pref="300" max="32767" attributes="0"/>
+      </Group>
+    </DimensionLayout>
+  </Layout>
+</Form>
diff --git a/Core/src/org/sleuthkit/autopsy/discovery/ui/WebHistoryDetailsPanel.java b/Core/src/org/sleuthkit/autopsy/discovery/ui/WebHistoryDetailsPanel.java
new file mode 100644
index 0000000000000000000000000000000000000000..deaef104f5e61c8b4162b422a9d69961ffdcb17a
--- /dev/null
+++ b/Core/src/org/sleuthkit/autopsy/discovery/ui/WebHistoryDetailsPanel.java
@@ -0,0 +1,222 @@
+/*
+ * Autopsy
+ *
+ * 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.autopsy.discovery.ui;
+
+import java.awt.Component;
+import java.awt.GridBagConstraints;
+import java.awt.GridBagLayout;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.logging.Level;
+import javax.swing.JScrollPane;
+import org.openide.util.NbBundle;
+import org.openide.util.lookup.ServiceProvider;
+import org.sleuthkit.autopsy.contentviewers.artifactviewers.ArtifactContentViewer;
+import org.sleuthkit.autopsy.contentviewers.artifactviewers.CommunicationArtifactViewerHelper;
+import org.sleuthkit.autopsy.coreutils.Logger;
+import org.sleuthkit.autopsy.coreutils.ThreadConfined;
+import org.sleuthkit.datamodel.BlackboardArtifact;
+import org.sleuthkit.datamodel.BlackboardAttribute;
+import org.sleuthkit.datamodel.TskCoreException;
+
+/**
+ * Panel to display the details for a Web History Artifact.
+ */
+@ServiceProvider(service = ArtifactContentViewer.class)
+public class WebHistoryDetailsPanel extends AbstractArtifactDetailsPanel implements ArtifactContentViewer {
+
+    private static final long serialVersionUID = 1L;
+    private static final Logger logger = Logger.getLogger(WebHistoryDetailsPanel.class.getName());
+    private BlackboardArtifact webHistoryArtifact;
+    private final GridBagLayout gridBagLayout = new GridBagLayout();
+    private final List<BlackboardAttribute> urlList = new ArrayList<>();
+    private final List<BlackboardAttribute> dateAccessedList = new ArrayList<>();
+    private final List<BlackboardAttribute> referrerUrlList = new ArrayList<>();
+    private final List<BlackboardAttribute> titleList = new ArrayList<>();
+    private final List<BlackboardAttribute> programNameList = new ArrayList<>();
+    private final List<BlackboardAttribute> domainList = new ArrayList<>();
+    private final List<BlackboardAttribute> otherList = new ArrayList<>();
+    private final GridBagConstraints gridBagConstraints = new GridBagConstraints();
+    private String dataSourceName;
+    private String sourceFileName;
+
+    /**
+     * Creates new form WebHistoryDetailsPanel.
+     */
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
+    public WebHistoryDetailsPanel() {
+        initComponents();
+    }
+
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
+    @Override
+    public void setArtifact(BlackboardArtifact artifact) {
+        resetComponent();
+        if (artifact != null) {
+            try {
+                extractArtifactData(artifact);
+            } catch (TskCoreException ex) {
+                logger.log(Level.WARNING, "Unable to get attributes for artifact " + artifact.getArtifactID(), ex);
+            }
+            updateView();
+        }
+        this.setLayout(this.gridBagLayout);
+        this.revalidate();
+        this.repaint();
+    }
+
+    /**
+     * Extracts data from the artifact to be displayed in the panel.
+     *
+     * @param artifact Artifact to show.
+     *
+     * @throws TskCoreException
+     */
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
+    private void extractArtifactData(BlackboardArtifact artifact) throws TskCoreException {
+
+        webHistoryArtifact = artifact;
+        // Get all the attributes and group them by the section panels they go in
+        for (BlackboardAttribute bba : webHistoryArtifact.getAttributes()) {
+            if (bba.getAttributeType().getTypeName().startsWith("TSK_URL")) {
+                urlList.add(bba);
+            } else if (bba.getAttributeType().getTypeName().startsWith("TSK_PROG_NAME")) {
+                programNameList.add(bba);
+            } else if (bba.getAttributeType().getTypeName().startsWith("TSK_DOMAIN")) {
+                domainList.add(bba);
+            } else if (bba.getAttributeType().getTypeName().startsWith("TSK_REFERRER")) {
+                referrerUrlList.add(bba);
+            } else if (bba.getAttributeType().getTypeName().startsWith("TSK_DATETIME_ACCESSED")) {
+                dateAccessedList.add(bba);
+            } else if (bba.getAttributeType().getTypeName().startsWith("TSK_TITLE")) {
+                titleList.add(bba);
+            } else {
+                otherList.add(bba);
+            }
+
+        }
+
+        dataSourceName = webHistoryArtifact.getDataSource().getName();
+        sourceFileName = webHistoryArtifact.getParent().getName();
+    }
+
+    /**
+     * Reset the panel so that it is empty.
+     */
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
+    private void resetComponent() {
+        // clear the panel 
+        this.removeAll();
+        gridBagConstraints.anchor = GridBagConstraints.FIRST_LINE_START;
+        gridBagConstraints.gridy = 0;
+        gridBagConstraints.gridx = 0;
+        gridBagConstraints.weighty = 0.0;
+        gridBagConstraints.weightx = 0.0;    // keep components fixed horizontally.
+        gridBagConstraints.insets = new java.awt.Insets(0, 12, 0, 0);
+        gridBagConstraints.fill = GridBagConstraints.NONE;
+        webHistoryArtifact = null;
+        dataSourceName = null;
+        sourceFileName = null;
+        urlList.clear();
+        dateAccessedList.clear();
+        referrerUrlList.clear();
+        titleList.clear();
+        programNameList.clear();
+        domainList.clear();
+        otherList.clear();
+    }
+
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
+    @Override
+    public Component getComponent() {
+        // Slap a vertical scrollbar on the panel.
+        return new JScrollPane(this, JScrollPane.VERTICAL_SCROLLBAR_AS_NEEDED, JScrollPane.HORIZONTAL_SCROLLBAR_NEVER);
+    }
+
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
+    @Override
+    public boolean isSupported(BlackboardArtifact artifact) {
+        return (artifact != null)
+                && (artifact.getArtifactTypeID() == BlackboardArtifact.ARTIFACT_TYPE.TSK_WEB_HISTORY.getTypeID());
+    }
+
+    @NbBundle.Messages({"WebHistoryDetailsPanel.details.attrHeader=Attributes",
+        "WebHistoryDetailsPanel.details.sourceHeader=Source",
+        "WebHistoryDetailsPanel.details.dataSource=Data Source",
+        "WebHistoryDetailsPanel.details.file=File"})
+    /**
+     * This method is called from within the constructor to initialize the form.
+     * WARNING: Do NOT modify this code. The content of this method is always
+     * regenerated by the Form Editor.
+     */
+    @SuppressWarnings("unchecked")
+    // <editor-fold defaultstate="collapsed" desc="Generated Code">//GEN-BEGIN:initComponents
+    private void initComponents() {
+
+        javax.swing.GroupLayout layout = new javax.swing.GroupLayout(this);
+        this.setLayout(layout);
+        layout.setHorizontalGroup(
+            layout.createParallelGroup(javax.swing.GroupLayout.Alignment.LEADING)
+            .addGap(0, 400, Short.MAX_VALUE)
+        );
+        layout.setVerticalGroup(
+            layout.createParallelGroup(javax.swing.GroupLayout.Alignment.LEADING)
+            .addGap(0, 300, Short.MAX_VALUE)
+        );
+    }// </editor-fold>//GEN-END:initComponents
+
+    /**
+     * Update the view to reflect the current artifact's details.
+     */
+    @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
+    private void updateView() {
+        CommunicationArtifactViewerHelper.addHeader(this, gridBagLayout, gridBagConstraints, Bundle.WebHistoryDetailsPanel_details_attrHeader());
+
+        for (BlackboardAttribute bba : this.titleList) {
+            CommunicationArtifactViewerHelper.addNameValueRow(this, gridBagLayout, gridBagConstraints, bba.getAttributeType().getDisplayName(), bba.getDisplayString());
+        }
+        for (BlackboardAttribute bba : dateAccessedList) {
+            CommunicationArtifactViewerHelper.addNameValueRow(this, gridBagLayout, gridBagConstraints, bba.getAttributeType().getDisplayName(), bba.getDisplayString());
+        }
+        for (BlackboardAttribute bba : domainList) {
+            CommunicationArtifactViewerHelper.addNameValueRow(this, gridBagLayout, gridBagConstraints, bba.getAttributeType().getDisplayName(), bba.getDisplayString());
+        }
+        for (BlackboardAttribute bba : urlList) {
+            CommunicationArtifactViewerHelper.addNameValueRow(this, gridBagLayout, gridBagConstraints, bba.getAttributeType().getDisplayName(), bba.getDisplayString());
+        }
+        for (BlackboardAttribute bba : referrerUrlList) {
+            CommunicationArtifactViewerHelper.addNameValueRow(this, gridBagLayout, gridBagConstraints, bba.getAttributeType().getDisplayName(), bba.getDisplayString());
+        }
+        for (BlackboardAttribute bba : programNameList) {
+            CommunicationArtifactViewerHelper.addNameValueRow(this, gridBagLayout, gridBagConstraints, bba.getAttributeType().getDisplayName(), bba.getDisplayString());
+        }
+        for (BlackboardAttribute bba : otherList) {
+            CommunicationArtifactViewerHelper.addNameValueRow(this, gridBagLayout, gridBagConstraints, bba.getAttributeType().getDisplayName(), bba.getDisplayString());
+        }
+        CommunicationArtifactViewerHelper.addHeader(this, gridBagLayout, gridBagConstraints, Bundle.WebHistoryDetailsPanel_details_sourceHeader());
+        CommunicationArtifactViewerHelper.addNameValueRow(this, gridBagLayout, gridBagConstraints, Bundle.WebHistoryDetailsPanel_details_dataSource(), dataSourceName);
+        CommunicationArtifactViewerHelper.addNameValueRow(this, gridBagLayout, gridBagConstraints, Bundle.WebHistoryDetailsPanel_details_file(), sourceFileName);
+        // add veritcal glue at the end
+        CommunicationArtifactViewerHelper.addPageEndGlue(this, gridBagLayout, this.gridBagConstraints);
+    }
+
+
+    // Variables declaration - do not modify//GEN-BEGIN:variables
+    // End of variables declaration//GEN-END:variables
+}
diff --git a/Core/src/org/sleuthkit/autopsy/filesearch/Bundle.properties b/Core/src/org/sleuthkit/autopsy/filesearch/Bundle.properties
index 8960448da43aa80dc24939cefff229c89146d53b..034149f5ec80150ee4adfcf29204969b852b1fef 100644
--- a/Core/src/org/sleuthkit/autopsy/filesearch/Bundle.properties
+++ b/Core/src/org/sleuthkit/autopsy/filesearch/Bundle.properties
@@ -49,6 +49,7 @@ SizeSearchPanel.sizeCompareComboBox.lessThan=less than
 FileSearchPanel.searchButton.text=Search
 MimeTypePanel.mimeTypeCheckBox.text=MIME Type:
 HashSearchPanel.md5CheckBox.text=MD5:
+Sha256HashSearchPanel.sha256CheckBox.text=SHA-256:
 HashSearchPanel.emptyHashMsg.text=Must enter something for hash search.
 FileSearchPanel.errorLabel.text=\ 
 DataSourcePanel.dataSourceCheckBox.label=Data Source:
@@ -58,3 +59,5 @@ DataSourcePanel.dataSourceNoteLabel.text=*Note: Multiple data sources can be sel
 DateSearchPanel.noLimitLabel.text=*Empty fields mean "No Limit"
 DateSearchPanel.dateFormatLabel.text=*The date format is mm/dd/yyyy
 MimeTypePanel.noteLabel.text=*Note: Multiple MIME types can be selected
+HashSearchPanel.sha256CheckBox.text=SHA-256:
+HashSearchPanel.sha256TextField.text=
diff --git a/Core/src/org/sleuthkit/autopsy/filesearch/Bundle.properties-MERGED b/Core/src/org/sleuthkit/autopsy/filesearch/Bundle.properties-MERGED
index b304bc1342d314038aa13fe9bdb0cbe6fdb14530..93c5ed79876d005affedc1750760334bccda8189 100755
--- a/Core/src/org/sleuthkit/autopsy/filesearch/Bundle.properties-MERGED
+++ b/Core/src/org/sleuthkit/autopsy/filesearch/Bundle.properties-MERGED
@@ -5,7 +5,9 @@ FileSearchPanel.emptyNode.display.text=No results found.
 HashSearchFilter.errorMessage.emptyHash=Hash data is empty.
 HashSearchFilter.errorMessage.wrongCharacter=MD5 contains invalid hex characters.
 # {0} - hash data length
-HashSearchFilter.errorMessage.wrongLength=Input length({0}), doesn''t match the MD5 length(32).
+HashSearchFilter.errorMessage.wrongLengthMd5=Input length({0}), doesn''t match the MD5 length(32).
+# {0} - hash data length
+HashSearchFilter.errorMessage.wrongLengthSha256=Input length({0}), doesn''t match the SHA-256 length(64).
 KnownStatusSearchFilter.errorMessage.noKnownStatusCheckboxSelected=At least one known status checkbox must be selected.
 MimeTypeFilter.errorMessage.emptyMimeType=At least one MIME type must be selected.
 NameSearchFilter.errorMessage.emtpyName=Please input a name to search.
@@ -63,6 +65,7 @@ SizeSearchPanel.sizeCompareComboBox.lessThan=less than
 FileSearchPanel.searchButton.text=Search
 MimeTypePanel.mimeTypeCheckBox.text=MIME Type:
 HashSearchPanel.md5CheckBox.text=MD5:
+Sha256HashSearchPanel.sha256CheckBox.text=SHA-256:
 HashSearchPanel.emptyHashMsg.text=Must enter something for hash search.
 FileSearchPanel.errorLabel.text=\ 
 DataSourcePanel.dataSourceCheckBox.label=Data Source:
@@ -72,3 +75,5 @@ DataSourcePanel.dataSourceNoteLabel.text=*Note: Multiple data sources can be sel
 DateSearchPanel.noLimitLabel.text=*Empty fields mean "No Limit"
 DateSearchPanel.dateFormatLabel.text=*The date format is mm/dd/yyyy
 MimeTypePanel.noteLabel.text=*Note: Multiple MIME types can be selected
+HashSearchPanel.sha256CheckBox.text=SHA-256:
+HashSearchPanel.sha256TextField.text=
diff --git a/Core/src/org/sleuthkit/autopsy/filesearch/HashSearchFilter.java b/Core/src/org/sleuthkit/autopsy/filesearch/HashSearchFilter.java
index d77fe5826e45ba99e947367241a099e1ead74ac8..1585f1051e143c4d39dd28afa6f7f38470219879 100644
--- a/Core/src/org/sleuthkit/autopsy/filesearch/HashSearchFilter.java
+++ b/Core/src/org/sleuthkit/autopsy/filesearch/HashSearchFilter.java
@@ -41,18 +41,36 @@ public HashSearchFilter(HashSearchPanel component) {
 
     @Override
     public boolean isEnabled() {
-        return this.getComponent().getHashCheckBox().isSelected();
+        return (this.getComponent().getMd5HashCheckBox().isSelected()
+                || this.getComponent().getSha256HashCheckBox().isSelected());
     }
 
     @Override
     public String getPredicate() throws FilterValidationException {
-        String md5Hash = this.getComponent().getSearchTextField().getText();
+        String predicate = "";
+        if (this.getComponent().getMd5HashCheckBox().isSelected()) {
+            String md5Hash = this.getComponent().getMd5TextField().getText();
 
-        if (md5Hash.isEmpty()) {
-            throw new FilterValidationException(EMPTY_HASH_MESSAGE);
+            if (md5Hash.isEmpty()) {
+                throw new FilterValidationException(EMPTY_HASH_MESSAGE);
+            }
+            predicate = "md5 = '" + md5Hash.toLowerCase() + "'"; //NON-NLS
         }
+        
+        if (this.getComponent().getSha256HashCheckBox().isSelected()) {
+            String sha256Hash = this.getComponent().getSha256TextField().getText();
 
-        return "md5 = '" + md5Hash.toLowerCase() + "'"; //NON-NLS
+            if (sha256Hash.isEmpty()) {
+                throw new FilterValidationException(EMPTY_HASH_MESSAGE);
+            }
+            if (predicate.isEmpty()) {
+                predicate = "sha256 = '" + sha256Hash.toLowerCase() + "'"; //NON-NLS
+            } else {
+                predicate = "( " + predicate + " AND sha256 = '" + sha256Hash.toLowerCase() + "')"; //NON-NLS
+            }
+        }
+
+        return predicate;
     }
 
     @Override
@@ -63,23 +81,45 @@ public void addActionListener(ActionListener l) {
     @Override
     @Messages({
         "HashSearchFilter.errorMessage.emptyHash=Hash data is empty.",
-        "# {0} - hash data length", "HashSearchFilter.errorMessage.wrongLength=Input length({0}), doesn''t match the MD5 length(32).",
+        "# {0} - hash data length", 
+        "HashSearchFilter.errorMessage.wrongLengthMd5=Input length({0}), doesn''t match the MD5 length(32).",
+        "# {0} - hash data length", 
+        "HashSearchFilter.errorMessage.wrongLengthSha256=Input length({0}), doesn''t match the SHA-256 length(64).",
         "HashSearchFilter.errorMessage.wrongCharacter=MD5 contains invalid hex characters."
     })
     public boolean isValid() {
-        String inputHashData = this.getComponent().getSearchTextField().getText();
-        if (inputHashData.isEmpty()) {
-            setLastError(Bundle.HashSearchFilter_errorMessage_emptyHash());
-            return false;
-        }
-        if (inputHashData.length() != 32) {
-            setLastError(Bundle.HashSearchFilter_errorMessage_wrongLength(inputHashData.length()));
-            return false;
+        if (this.getComponent().getMd5HashCheckBox().isSelected()) {
+            String inputHashData = this.getComponent().getMd5TextField().getText();
+            if (inputHashData.isEmpty()) {
+                setLastError(Bundle.HashSearchFilter_errorMessage_emptyHash());
+                return false;
+            }
+            if (inputHashData.length() != 32) {
+                setLastError(Bundle.HashSearchFilter_errorMessage_wrongLengthMd5(inputHashData.length()));
+                return false;
+            }
+            if (!inputHashData.matches("[0-9a-fA-F]+")) {
+                setLastError(Bundle.HashSearchFilter_errorMessage_wrongCharacter());
+                return false;
+            }
         }
-        if (!inputHashData.matches("[0-9a-fA-F]+")) {
-            setLastError(Bundle.HashSearchFilter_errorMessage_wrongCharacter());
-            return false;
+        
+        if (this.getComponent().getSha256HashCheckBox().isSelected()) {
+            String inputHashData = this.getComponent().getSha256TextField().getText();
+            if (inputHashData.isEmpty()) {
+                setLastError(Bundle.HashSearchFilter_errorMessage_emptyHash());
+                return false;
+            }
+            if (inputHashData.length() != 64) {
+                setLastError(Bundle.HashSearchFilter_errorMessage_wrongLengthSha256(inputHashData.length()));
+                return false;
+            }
+            if (!inputHashData.matches("[0-9a-fA-F]+")) {
+                setLastError(Bundle.HashSearchFilter_errorMessage_wrongCharacter());
+                return false;
+            }
         }
+        
         return true;
     }
 }
diff --git a/Core/src/org/sleuthkit/autopsy/filesearch/HashSearchPanel.form b/Core/src/org/sleuthkit/autopsy/filesearch/HashSearchPanel.form
index 0ad53c965327195c2f10687163adf323e9b37c70..88fd9cabfcdfc7df6c46728be2b42733c00d3b81 100644
--- a/Core/src/org/sleuthkit/autopsy/filesearch/HashSearchPanel.form
+++ b/Core/src/org/sleuthkit/autopsy/filesearch/HashSearchPanel.form
@@ -56,34 +56,67 @@
       <Group type="103" groupAlignment="0" attributes="0">
           <Group type="102" alignment="0" attributes="0">
               <EmptySpace min="0" pref="0" max="-2" attributes="0"/>
-              <Component id="hashCheckBox" min="-2" max="-2" attributes="0"/>
+              <Group type="103" groupAlignment="0" attributes="0">
+                  <Group type="102" attributes="0">
+                      <Component id="sha256CheckBox" min="-2" max="-2" attributes="0"/>
+                      <EmptySpace max="-2" attributes="0"/>
+                      <Component id="sha256TextField" pref="254" max="32767" attributes="0"/>
+                  </Group>
+                  <Group type="102" attributes="0">
+                      <Component id="md5CheckBox" min="-2" max="-2" attributes="0"/>
+                      <EmptySpace max="-2" attributes="0"/>
+                      <Component id="md5TextField" max="32767" attributes="0"/>
+                  </Group>
+              </Group>
               <EmptySpace max="-2" attributes="0"/>
-              <Component id="searchTextField" min="-2" pref="247" max="-2" attributes="0"/>
-              <EmptySpace min="0" pref="0" max="-2" attributes="0"/>
           </Group>
       </Group>
     </DimensionLayout>
     <DimensionLayout dim="1">
       <Group type="103" groupAlignment="0" attributes="0">
-          <Group type="103" groupAlignment="3" attributes="0">
-              <Component id="hashCheckBox" alignment="3" min="-2" max="-2" attributes="0"/>
-              <Component id="searchTextField" alignment="3" min="-2" max="-2" attributes="0"/>
+          <Group type="102" attributes="0">
+              <Group type="103" groupAlignment="3" attributes="0">
+                  <Component id="md5CheckBox" alignment="3" min="-2" max="-2" attributes="0"/>
+                  <Component id="md5TextField" alignment="3" min="-2" max="-2" attributes="0"/>
+              </Group>
+              <EmptySpace max="-2" attributes="0"/>
+              <Group type="103" groupAlignment="3" attributes="0">
+                  <Component id="sha256CheckBox" alignment="3" min="-2" max="-2" attributes="0"/>
+                  <Component id="sha256TextField" alignment="3" min="-2" max="-2" attributes="0"/>
+              </Group>
           </Group>
       </Group>
     </DimensionLayout>
   </Layout>
   <SubComponents>
-    <Component class="javax.swing.JCheckBox" name="hashCheckBox">
+    <Component class="javax.swing.JCheckBox" name="md5CheckBox">
       <Properties>
         <Property name="text" type="java.lang.String" editor="org.netbeans.modules.i18n.form.FormI18nStringEditor">
           <ResourceString bundle="org/sleuthkit/autopsy/filesearch/Bundle.properties" key="HashSearchPanel.md5CheckBox.text" replaceFormat="org.openide.util.NbBundle.getMessage({sourceFileName}.class, &quot;{key}&quot;)"/>
         </Property>
       </Properties>
       <Events>
-        <EventHandler event="actionPerformed" listener="java.awt.event.ActionListener" parameters="java.awt.event.ActionEvent" handler="hashCheckBoxActionPerformed"/>
+        <EventHandler event="actionPerformed" listener="java.awt.event.ActionListener" parameters="java.awt.event.ActionEvent" handler="md5CheckBoxActionPerformed"/>
+      </Events>
+    </Component>
+    <Component class="javax.swing.JTextField" name="md5TextField">
+    </Component>
+    <Component class="javax.swing.JCheckBox" name="sha256CheckBox">
+      <Properties>
+        <Property name="text" type="java.lang.String" editor="org.netbeans.modules.i18n.form.FormI18nStringEditor">
+          <ResourceString bundle="org/sleuthkit/autopsy/filesearch/Bundle.properties" key="HashSearchPanel.sha256CheckBox.text" replaceFormat="org.openide.util.NbBundle.getMessage({sourceFileName}.class, &quot;{key}&quot;)"/>
+        </Property>
+      </Properties>
+      <Events>
+        <EventHandler event="actionPerformed" listener="java.awt.event.ActionListener" parameters="java.awt.event.ActionEvent" handler="sha256CheckBoxActionPerformed"/>
       </Events>
     </Component>
-    <Component class="javax.swing.JTextField" name="searchTextField">
+    <Component class="javax.swing.JTextField" name="sha256TextField">
+      <Properties>
+        <Property name="text" type="java.lang.String" editor="org.netbeans.modules.i18n.form.FormI18nStringEditor">
+          <ResourceString bundle="org/sleuthkit/autopsy/filesearch/Bundle.properties" key="HashSearchPanel.sha256TextField.text" replaceFormat="org.openide.util.NbBundle.getMessage({sourceFileName}.class, &quot;{key}&quot;)"/>
+        </Property>
+      </Properties>
     </Component>
   </SubComponents>
 </Form>
diff --git a/Core/src/org/sleuthkit/autopsy/filesearch/HashSearchPanel.java b/Core/src/org/sleuthkit/autopsy/filesearch/HashSearchPanel.java
index f166d01264de32dcdccb31fbb0090e95af93b47b..66d539642039ab30d87e15954baa326dc9693aca 100644
--- a/Core/src/org/sleuthkit/autopsy/filesearch/HashSearchPanel.java
+++ b/Core/src/org/sleuthkit/autopsy/filesearch/HashSearchPanel.java
@@ -45,19 +45,19 @@ class HashSearchPanel extends javax.swing.JPanel {
 
     private void customizeComponents() {
 
-        searchTextField.setComponentPopupMenu(rightClickMenu);
+        md5TextField.setComponentPopupMenu(rightClickMenu);
         ActionListener actList = new ActionListener() {
             @Override
             public void actionPerformed(ActionEvent e) {
                 JMenuItem jmi = (JMenuItem) e.getSource();
                 if (jmi.equals(cutMenuItem)) {
-                    searchTextField.cut();
+                    md5TextField.cut();
                 } else if (jmi.equals(copyMenuItem)) {
-                    searchTextField.copy();
+                    md5TextField.copy();
                 } else if (jmi.equals(pasteMenuItem)) {
-                    searchTextField.paste();
+                    md5TextField.paste();
                 } else if (jmi.equals(selectAllMenuItem)) {
-                    searchTextField.selectAll();
+                    md5TextField.selectAll();
                 }
             }
         };
@@ -65,7 +65,24 @@ public void actionPerformed(ActionEvent e) {
         copyMenuItem.addActionListener(actList);
         pasteMenuItem.addActionListener(actList);
         selectAllMenuItem.addActionListener(actList);
-        this.searchTextField.getDocument().addDocumentListener(new DocumentListener() {
+        this.md5TextField.getDocument().addDocumentListener(new DocumentListener() {
+            @Override
+            public void insertUpdate(DocumentEvent e) {
+                firePropertyChange(FileSearchPanel.EVENT.CHECKED.toString(), null, null);
+            }
+
+            @Override
+            public void removeUpdate(DocumentEvent e) {
+                firePropertyChange(FileSearchPanel.EVENT.CHECKED.toString(), null, null);
+            }
+
+            @Override
+            public void changedUpdate(DocumentEvent e) {
+                firePropertyChange(FileSearchPanel.EVENT.CHECKED.toString(), null, null);
+            }
+        });
+        
+        this.sha256TextField.getDocument().addDocumentListener(new DocumentListener() {
             @Override
             public void insertUpdate(DocumentEvent e) {
                 firePropertyChange(FileSearchPanel.EVENT.CHECKED.toString(), null, null);
@@ -84,17 +101,27 @@ public void changedUpdate(DocumentEvent e) {
 
     }
 
-    JCheckBox getHashCheckBox() {
-        return hashCheckBox;
+    JCheckBox getMd5HashCheckBox() {
+        return md5CheckBox;
     }
 
-    JTextField getSearchTextField() {
-        return searchTextField;
+    JTextField getMd5TextField() {
+        return md5TextField;
+    }
+    
+    JCheckBox getSha256HashCheckBox() {
+        return sha256CheckBox;
+    }
+
+    JTextField getSha256TextField() {
+        return sha256TextField;
     }
 
     void setComponentsEnabled() {
-        boolean enabled = hashCheckBox.isSelected();
-        this.searchTextField.setEnabled(enabled);
+        boolean md5Enabled = md5CheckBox.isSelected();
+        this.md5TextField.setEnabled(md5Enabled);
+        boolean sha256Enabled = sha256CheckBox.isSelected();
+        this.sha256TextField.setEnabled(sha256Enabled);
     }
 
     /**
@@ -111,8 +138,10 @@ private void initComponents() {
         copyMenuItem = new javax.swing.JMenuItem();
         pasteMenuItem = new javax.swing.JMenuItem();
         selectAllMenuItem = new javax.swing.JMenuItem();
-        hashCheckBox = new javax.swing.JCheckBox();
-        searchTextField = new javax.swing.JTextField();
+        md5CheckBox = new javax.swing.JCheckBox();
+        md5TextField = new javax.swing.JTextField();
+        sha256CheckBox = new javax.swing.JCheckBox();
+        sha256TextField = new javax.swing.JTextField();
 
         cutMenuItem.setText(org.openide.util.NbBundle.getMessage(HashSearchPanel.class, "NameSearchPanel.cutMenuItem.text")); // NOI18N
         rightClickMenu.add(cutMenuItem);
@@ -126,48 +155,75 @@ private void initComponents() {
         selectAllMenuItem.setText(org.openide.util.NbBundle.getMessage(HashSearchPanel.class, "NameSearchPanel.selectAllMenuItem.text")); // NOI18N
         rightClickMenu.add(selectAllMenuItem);
 
-        hashCheckBox.setText(org.openide.util.NbBundle.getMessage(HashSearchPanel.class, "HashSearchPanel.md5CheckBox.text")); // NOI18N
-        hashCheckBox.addActionListener(new java.awt.event.ActionListener() {
+        md5CheckBox.setText(org.openide.util.NbBundle.getMessage(HashSearchPanel.class, "HashSearchPanel.md5CheckBox.text")); // NOI18N
+        md5CheckBox.addActionListener(new java.awt.event.ActionListener() {
+            public void actionPerformed(java.awt.event.ActionEvent evt) {
+                md5CheckBoxActionPerformed(evt);
+            }
+        });
+
+        sha256CheckBox.setText(org.openide.util.NbBundle.getMessage(HashSearchPanel.class, "HashSearchPanel.sha256CheckBox.text")); // NOI18N
+        sha256CheckBox.addActionListener(new java.awt.event.ActionListener() {
             public void actionPerformed(java.awt.event.ActionEvent evt) {
-                hashCheckBoxActionPerformed(evt);
+                sha256CheckBoxActionPerformed(evt);
             }
         });
 
+        sha256TextField.setText(org.openide.util.NbBundle.getMessage(HashSearchPanel.class, "HashSearchPanel.sha256TextField.text")); // NOI18N
+
         javax.swing.GroupLayout layout = new javax.swing.GroupLayout(this);
         this.setLayout(layout);
         layout.setHorizontalGroup(
             layout.createParallelGroup(javax.swing.GroupLayout.Alignment.LEADING)
             .addGroup(layout.createSequentialGroup()
                 .addGap(0, 0, 0)
-                .addComponent(hashCheckBox)
-                .addPreferredGap(javax.swing.LayoutStyle.ComponentPlacement.RELATED)
-                .addComponent(searchTextField, javax.swing.GroupLayout.PREFERRED_SIZE, 247, javax.swing.GroupLayout.PREFERRED_SIZE)
-                .addGap(0, 0, 0))
+                .addGroup(layout.createParallelGroup(javax.swing.GroupLayout.Alignment.LEADING)
+                    .addGroup(layout.createSequentialGroup()
+                        .addComponent(sha256CheckBox)
+                        .addPreferredGap(javax.swing.LayoutStyle.ComponentPlacement.RELATED)
+                        .addComponent(sha256TextField, javax.swing.GroupLayout.DEFAULT_SIZE, 254, Short.MAX_VALUE))
+                    .addGroup(layout.createSequentialGroup()
+                        .addComponent(md5CheckBox)
+                        .addPreferredGap(javax.swing.LayoutStyle.ComponentPlacement.RELATED)
+                        .addComponent(md5TextField)))
+                .addContainerGap())
         );
         layout.setVerticalGroup(
             layout.createParallelGroup(javax.swing.GroupLayout.Alignment.LEADING)
-            .addGroup(layout.createParallelGroup(javax.swing.GroupLayout.Alignment.BASELINE)
-                .addComponent(hashCheckBox)
-                .addComponent(searchTextField, javax.swing.GroupLayout.PREFERRED_SIZE, javax.swing.GroupLayout.DEFAULT_SIZE, javax.swing.GroupLayout.PREFERRED_SIZE))
+            .addGroup(layout.createSequentialGroup()
+                .addGroup(layout.createParallelGroup(javax.swing.GroupLayout.Alignment.BASELINE)
+                    .addComponent(md5CheckBox)
+                    .addComponent(md5TextField, javax.swing.GroupLayout.PREFERRED_SIZE, javax.swing.GroupLayout.DEFAULT_SIZE, javax.swing.GroupLayout.PREFERRED_SIZE))
+                .addPreferredGap(javax.swing.LayoutStyle.ComponentPlacement.RELATED)
+                .addGroup(layout.createParallelGroup(javax.swing.GroupLayout.Alignment.BASELINE)
+                    .addComponent(sha256CheckBox)
+                    .addComponent(sha256TextField, javax.swing.GroupLayout.PREFERRED_SIZE, javax.swing.GroupLayout.DEFAULT_SIZE, javax.swing.GroupLayout.PREFERRED_SIZE)))
         );
     }// </editor-fold>//GEN-END:initComponents
 
-    private void hashCheckBoxActionPerformed(java.awt.event.ActionEvent evt) {//GEN-FIRST:event_hashCheckBoxActionPerformed
+    private void md5CheckBoxActionPerformed(java.awt.event.ActionEvent evt) {//GEN-FIRST:event_md5CheckBoxActionPerformed
+        setComponentsEnabled();
+        firePropertyChange(FileSearchPanel.EVENT.CHECKED.toString(), null, null);
+    }//GEN-LAST:event_md5CheckBoxActionPerformed
+
+    private void sha256CheckBoxActionPerformed(java.awt.event.ActionEvent evt) {//GEN-FIRST:event_sha256CheckBoxActionPerformed
         setComponentsEnabled();
         firePropertyChange(FileSearchPanel.EVENT.CHECKED.toString(), null, null);
-    }//GEN-LAST:event_hashCheckBoxActionPerformed
+    }//GEN-LAST:event_sha256CheckBoxActionPerformed
 
     // Variables declaration - do not modify//GEN-BEGIN:variables
     private javax.swing.JMenuItem copyMenuItem;
     private javax.swing.JMenuItem cutMenuItem;
-    private javax.swing.JCheckBox hashCheckBox;
+    private javax.swing.JCheckBox md5CheckBox;
+    private javax.swing.JTextField md5TextField;
     private javax.swing.JMenuItem pasteMenuItem;
     private javax.swing.JPopupMenu rightClickMenu;
-    private javax.swing.JTextField searchTextField;
     private javax.swing.JMenuItem selectAllMenuItem;
+    private javax.swing.JCheckBox sha256CheckBox;
+    private javax.swing.JTextField sha256TextField;
     // End of variables declaration//GEN-END:variables
 
     void addActionListener(ActionListener l) {
-        searchTextField.addActionListener(l);
+        md5TextField.addActionListener(l);
     }
 }
diff --git a/Core/src/org/sleuthkit/autopsy/ingest/IngestJobPipeline.java b/Core/src/org/sleuthkit/autopsy/ingest/IngestJobPipeline.java
index 5921071e6530f9d9aefb4e6f71c63c87d22331cb..8e2f7cf86d1adc1097131c7d813f5d4947361e4d 100644
--- a/Core/src/org/sleuthkit/autopsy/ingest/IngestJobPipeline.java
+++ b/Core/src/org/sleuthkit/autopsy/ingest/IngestJobPipeline.java
@@ -1274,6 +1274,9 @@ void cancel(IngestJob.CancellationReason reason) {
                 }
             }
         }
+        
+        // If a data source had no tasks in progress it may now be complete.
+        checkForStageCompleted();
     }
 
     /**
diff --git a/Core/src/org/sleuthkit/autopsy/modules/embeddedfileextractor/DocumentEmbeddedContentExtractor.java b/Core/src/org/sleuthkit/autopsy/modules/embeddedfileextractor/DocumentEmbeddedContentExtractor.java
index 35f27ca8f6053a25f930f221a01a8639c4134e44..a25dd9e1d76c1486087cc32e114882f7783baccc 100644
--- a/Core/src/org/sleuthkit/autopsy/modules/embeddedfileextractor/DocumentEmbeddedContentExtractor.java
+++ b/Core/src/org/sleuthkit/autopsy/modules/embeddedfileextractor/DocumentEmbeddedContentExtractor.java
@@ -491,7 +491,7 @@ private List<ExtractedFile> extractEmbeddedContentFromPDF(AbstractFile abstractF
         try {
             Path outputDirectory = Paths.get(getOutputFolderPath(parentFileName));
             //Get map of attachment name -> location disk.
-            Map<String, Path> extractedAttachments = pdfExtractor.extract(
+            Map<String, PDFAttachmentExtractor.NewResourceData> extractedAttachments = pdfExtractor.extract(
                     new ReadContentInputStream(abstractFile), abstractFile.getId(),
                     outputDirectory);
 
@@ -499,10 +499,11 @@ private List<ExtractedFile> extractEmbeddedContentFromPDF(AbstractFile abstractF
             List<ExtractedFile> extractedFiles = new ArrayList<>();
             extractedAttachments.entrySet().forEach((pathEntry) -> {
                 String fileName = pathEntry.getKey();
-                Path writeLocation = pathEntry.getValue();
+                Path writeLocation = pathEntry.getValue().getPath();
+                int fileSize = pathEntry.getValue().getLength();
                 extractedFiles.add(new ExtractedFile(fileName,
                         getFileRelativePath(writeLocation.getFileName().toString()),
-                        writeLocation.toFile().length()));
+                        fileSize));
             });
 
             return extractedFiles;
diff --git a/Core/src/org/sleuthkit/autopsy/modules/embeddedfileextractor/EmbeddedFileExtractorIngestModule.java b/Core/src/org/sleuthkit/autopsy/modules/embeddedfileextractor/EmbeddedFileExtractorIngestModule.java
index dc9e7c6d1df8f6f82227d50a958671bd08fefa30..b1481ddc015323ea31c0be3d5dbb69cf764410d6 100644
--- a/Core/src/org/sleuthkit/autopsy/modules/embeddedfileextractor/EmbeddedFileExtractorIngestModule.java
+++ b/Core/src/org/sleuthkit/autopsy/modules/embeddedfileextractor/EmbeddedFileExtractorIngestModule.java
@@ -117,6 +117,12 @@ public void startUp(IngestJobContext context) throws IngestModuleException {
              * while processing archive files.
              */
             mapOfDepthTrees.put(jobId, new ConcurrentHashMap<>());
+            /**
+             * Initialize Java's Image I/O API so that image reading and writing
+             * (needed for image extraction) happens consistently through the
+             * same providers. See JIRA-6951 for more details.
+             */
+            initializeImageIO();
         }
         /*
          * Construct an embedded content extractor for processing Microsoft
@@ -127,14 +133,6 @@ public void startUp(IngestJobContext context) throws IngestModuleException {
         } catch (NoCurrentCaseException ex) {
             throw new IngestModuleException(Bundle.EmbeddedFileExtractorIngestModule_UnableToGetMSOfficeExtractor_errMsg(), ex);
         }
-        
-        /**
-         * Initialize Java's Image I/O API so that image reading and writing 
-         * (needed for image extraction) happens consistently through the 
-         * same providers. See JIRA-6951 for more details.
-         */
-        initializeImageIO();        
-
     }
     
     /**
diff --git a/Core/src/org/sleuthkit/autopsy/modules/embeddedfileextractor/PDFAttachmentExtractor.java b/Core/src/org/sleuthkit/autopsy/modules/embeddedfileextractor/PDFAttachmentExtractor.java
index 9cea0f63e7d533fd7a82ebf357d978baba44b278..d898aaa1ca9c5c08a4796022453289724d0751d9 100755
--- a/Core/src/org/sleuthkit/autopsy/modules/embeddedfileextractor/PDFAttachmentExtractor.java
+++ b/Core/src/org/sleuthkit/autopsy/modules/embeddedfileextractor/PDFAttachmentExtractor.java
@@ -73,7 +73,7 @@ public PDFAttachmentExtractor(AutoDetectParser parser) {
      * @throws SAXException
      * @throws TikaException 
      */
-    public Map<String, Path> extract(InputStream input, long parentID, Path outputDir) throws IOException, SAXException, TikaException {
+    public Map<String, NewResourceData> extract(InputStream input, long parentID, Path outputDir) throws IOException, SAXException, TikaException {
         ExtractionPreconditions.checkArgument(Files.exists(outputDir), 
                 String.format("Output directory: %s, does not exist.", outputDir.toString())); //NON-NLS
 
@@ -139,8 +139,8 @@ public void parseEmbedded(InputStream in, ContentHandler ch, Metadata mtdt, bool
 
             try (EncodedFileOutputStream outputStream = new EncodedFileOutputStream(
                     new FileOutputStream(outputFile.toFile()), TskData.EncodingType.XOR1)){
-                IOUtils.copy(in, outputStream);
-                watcher.notify(name, outputFile);
+                int bytesCopied = IOUtils.copy(in, outputStream);
+                watcher.notify(name, outputFile, bytesCopied);
             } catch (IOException ex) {
                 logger.log(Level.WARNING, String.format("Could not extract attachment %s into directory %s", //NON-NLS
                         uniqueExtractedName, outputFile), ex);
@@ -148,6 +148,29 @@ public void parseEmbedded(InputStream in, ContentHandler ch, Metadata mtdt, bool
         }
     }
 
+    /**
+     * Utility class to hold an extracted file's path and length.
+     * Note that we can not use the length of the file on disk because
+     * the XOR header has been added to it.
+     */
+    static class NewResourceData {
+        private final Path path;
+        private final int length;
+        
+        NewResourceData(Path path, int length) {
+            this.path = path;
+            this.length = length;
+        }
+        
+        Path getPath() {
+            return path;
+        }
+        
+        int getLength() {
+            return length;
+        }
+    }
+    
     /**
      * Convenient wrapper for keeping track of new resource paths and the display
      * name for each of these resources.
@@ -157,17 +180,17 @@ public void parseEmbedded(InputStream in, ContentHandler ch, Metadata mtdt, bool
      */
     static class NewResourceWatcher {
 
-        private final Map<String, Path> newResourcePaths;
+        private final Map<String, NewResourceData> newResourcePaths;
 
         public NewResourceWatcher() {
             newResourcePaths = new HashMap<>();
         }
 
-        public void notify(String name, Path newResource) {
-            newResourcePaths.put(name, newResource);
+        public void notify(String name, Path localPath, int length) {
+            newResourcePaths.put(name, new NewResourceData(localPath, length));
         }
 
-        public Map<String, Path> getSnapshot() {
+        public Map<String, NewResourceData> getSnapshot() {
             return newResourcePaths;
         }
     }
diff --git a/Core/src/org/sleuthkit/autopsy/modules/hashdatabase/HashDbIngestModule.java b/Core/src/org/sleuthkit/autopsy/modules/hashdatabase/HashDbIngestModule.java
index 65bf047fa1ca358e7fcf3be16ea92eafc20e34db..19b9ff28b7b0ff9b493a8b227b0ebea6cda912c9 100644
--- a/Core/src/org/sleuthkit/autopsy/modules/hashdatabase/HashDbIngestModule.java
+++ b/Core/src/org/sleuthkit/autopsy/modules/hashdatabase/HashDbIngestModule.java
@@ -18,8 +18,8 @@
  */
 package org.sleuthkit.autopsy.modules.hashdatabase;
 
-import java.io.IOException;
 import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.Collection;
 import java.util.HashMap;
 import java.util.List;
@@ -208,10 +208,17 @@ public ProcessResult process(AbstractFile file) {
         // Safely get a reference to the totalsForIngestJobs object
         IngestJobTotals totals = getTotalsForIngestJobs(jobId);
 
-        // calc hash value        
-        String md5Hash = getHash(file, totals);
-        if (md5Hash == null) {
-            return ProcessResult.ERROR;
+        // calc hash values
+        try {
+            calculateHashes(file, totals);
+        } catch (TskCoreException ex) {
+            logger.log(Level.WARNING, String.format("Error calculating hash of file '%s' (id=%d).", file.getName(), file.getId()), ex); //NON-NLS
+            services.postMessage(IngestMessage.createErrorMessage(
+                    HashLookupModuleFactory.getModuleName(),
+                    NbBundle.getMessage(this.getClass(), "HashDbIngestModule.fileReadErrorMsg", file.getName()),
+                    NbBundle.getMessage(this.getClass(), "HashDbIngestModule.calcHashValueErr",
+                            file.getParentPath() + file.getName(),
+                            file.isMetaFlagSet(TskData.TSK_FS_META_FLAG_ENUM.ALLOC) ? "Allocated File" : "Deleted File")));
         }
 
         // the processing result of handling this file
@@ -451,50 +458,46 @@ private boolean createArtifactIfNotExists(String hashSetName, AbstractFile file,
     }
 
     /**
-     * Retrieves the md5 hash for a file or generates one if no one exists on
-     * the file.
+     * Generates hashes for the given file if they haven't already been set. 
+     * Hashes are saved to the AbstractFile object.
      *
      * @param file   The file in order to determine the hash.
      * @param totals The timing metrics for this process.
-     *
-     * @return The found or determined md5 hash or null if none could be
-     *         determined.
      */
-    private String getHash(AbstractFile file, IngestJobTotals totals) {
+    private void calculateHashes(AbstractFile file, IngestJobTotals totals) throws TskCoreException {
+        
+        // First check if we've already calculated the hashes.
         String md5Hash = file.getMd5Hash();
-        if (md5Hash != null && md5Hash.isEmpty()) {
-            return md5Hash;
+        String sha256Hash = file.getSha256Hash();
+        if ((md5Hash != null && ! md5Hash.isEmpty())
+                && (sha256Hash != null && ! sha256Hash.isEmpty())) {
+            return;
         }
 
-        try {
-            TimingMetric metric = HealthMonitor.getTimingMetric("Disk Reads: Hash calculation");
-            long calcstart = System.currentTimeMillis();
-            md5Hash = HashUtility.calculateMd5Hash(file);
-            if (file.getSize() > 0) {
-                // Surprisingly, the hash calculation does not seem to be correlated that
-                // strongly with file size until the files get large.
-                // Only normalize if the file size is greater than ~1MB.
-                if (file.getSize() < 1000000) {
-                    HealthMonitor.submitTimingMetric(metric);
-                } else {
-                    // In testing, this normalization gave reasonable resuls
-                    HealthMonitor.submitNormalizedTimingMetric(metric, file.getSize() / 500000);
-                }
+        TimingMetric metric = HealthMonitor.getTimingMetric("Disk Reads: Hash calculation");
+        long calcstart = System.currentTimeMillis();
+        List<HashUtility.HashResult> newHashResults = 
+                HashUtility.calculateHashes(file, Arrays.asList(HashUtility.HashType.MD5,HashUtility.HashType.SHA256 ));
+        if (file.getSize() > 0) {
+            // Surprisingly, the hash calculation does not seem to be correlated that
+            // strongly with file size until the files get large.
+            // Only normalize if the file size is greater than ~1MB.
+            if (file.getSize() < 1000000) {
+                HealthMonitor.submitTimingMetric(metric);
+            } else {
+                // In testing, this normalization gave reasonable resuls
+                HealthMonitor.submitNormalizedTimingMetric(metric, file.getSize() / 500000);
+            }
+        }
+        for (HashUtility.HashResult hash : newHashResults) {
+            if (hash.getType().equals(HashUtility.HashType.MD5)) {
+                file.setMd5Hash(hash.getValue());
+            } else if (hash.getType().equals(HashUtility.HashType.SHA256)) {
+                file.setSha256Hash(hash.getValue());
             }
-            file.setMd5Hash(md5Hash);
-            long delta = (System.currentTimeMillis() - calcstart);
-            totals.totalCalctime.addAndGet(delta);
-            return md5Hash;
-        } catch (IOException ex) {
-            logger.log(Level.WARNING, String.format("Error calculating hash of file '%s' (id=%d).", file.getName(), file.getId()), ex); //NON-NLS
-            services.postMessage(IngestMessage.createErrorMessage(
-                    HashLookupModuleFactory.getModuleName(),
-                    NbBundle.getMessage(this.getClass(), "HashDbIngestModule.fileReadErrorMsg", file.getName()),
-                    NbBundle.getMessage(this.getClass(), "HashDbIngestModule.calcHashValueErr",
-                            file.getParentPath() + file.getName(),
-                            file.isMetaFlagSet(TskData.TSK_FS_META_FLAG_ENUM.ALLOC) ? "Allocated File" : "Deleted File")));
-            return null;
         }
+        long delta = (System.currentTimeMillis() - calcstart);
+        totals.totalCalctime.addAndGet(delta);
     }
 
     /**
diff --git a/Core/src/org/sleuthkit/autopsy/modules/vmextractor/VMExtractorIngestModule.java b/Core/src/org/sleuthkit/autopsy/modules/vmextractor/VMExtractorIngestModule.java
index 843b07a3d68ed62e85f6dbc287e93ae4111e6903..56e6b3fbdb686b17c2b05ac9d398fe6d8764817c 100644
--- a/Core/src/org/sleuthkit/autopsy/modules/vmextractor/VMExtractorIngestModule.java
+++ b/Core/src/org/sleuthkit/autopsy/modules/vmextractor/VMExtractorIngestModule.java
@@ -198,7 +198,6 @@ public ProcessResult process(Content dataSource, DataSourceIngestModuleProgress
                     // for extracted virtual machines there is no manifest XML file to read data source ID from so use parent data source ID.
                     // ingest the data sources  
                     ingestVirtualMachineImage(Paths.get(folder, file));
-                    logger.log(Level.INFO, "Ingest complete for virtual machine file {0} in folder {1}", new Object[]{file, folder}); //NON-NLS
                 } catch (InterruptedException ex) {
                     logger.log(Level.INFO, "Interrupted while ingesting virtual machine file " + file + " in folder " + folder, ex); //NON-NLS
                 } catch (IOException ex) {
@@ -287,8 +286,8 @@ private void ingestVirtualMachineImage(Path vmFile) throws InterruptedException,
         }
 
         /*
-         * If the image was added, analyze it with the ingest modules for this
-         * ingest context.
+         * If the image was added, start analysis on it with the ingest modules for this
+         * ingest context. Note that this does not wait for ingest to complete.
          */
         if (!dspCallback.vmDataSources.isEmpty()) {
             Case.getCurrentCaseThrows().notifyDataSourceAdded(dspCallback.vmDataSources.get(0), taskId);
@@ -300,7 +299,7 @@ private void ingestVirtualMachineImage(Path vmFile) throws InterruptedException,
             IngestServices.getInstance().postMessage(IngestMessage.createMessage(IngestMessage.MessageType.INFO,
                     VMExtractorIngestModuleFactory.getModuleName(),
                     NbBundle.getMessage(this.getClass(), "VMExtractorIngestModule.addedVirtualMachineImage.message", vmFile.toString())));
-            IngestManager.getInstance().queueIngestJob(dataSourceContent, ingestJobSettings);
+            IngestManager.getInstance().beginIngestJob(dataSourceContent, ingestJobSettings);
         } else {
             Case.getCurrentCaseThrows().notifyFailedAddingDataSource(taskId);
         }
diff --git a/Core/src/org/sleuthkit/autopsy/report/modules/portablecase/PortableCaseReportModule.java b/Core/src/org/sleuthkit/autopsy/report/modules/portablecase/PortableCaseReportModule.java
index 83bfb2fdae050478a88e142415ff07aaa9c423cc..67b253a1fa66b6e420ecd4742aaa8af73a582838 100644
--- a/Core/src/org/sleuthkit/autopsy/report/modules/portablecase/PortableCaseReportModule.java
+++ b/Core/src/org/sleuthkit/autopsy/report/modules/portablecase/PortableCaseReportModule.java
@@ -1117,7 +1117,7 @@ private long copyContent(Content content) throws TskCoreException {
 
                                 newContent = portableSkCase.addLocalFile(abstractFile.getName(), relativePath, abstractFile.getSize(),
                                         abstractFile.getCtime(), abstractFile.getCrtime(), abstractFile.getAtime(), abstractFile.getMtime(),
-                                        abstractFile.getMd5Hash(), abstractFile.getKnown(), abstractFile.getMIMEType(),
+                                        abstractFile.getMd5Hash(), abstractFile.getSha256Hash(), abstractFile.getKnown(), abstractFile.getMIMEType(),
                                         true, TskData.EncodingType.NONE,
                                         newParent, trans);
                             } catch (IOException ex) {
diff --git a/Core/src/org/sleuthkit/autopsy/report/modules/stix/STIXReportModule.java b/Core/src/org/sleuthkit/autopsy/report/modules/stix/STIXReportModule.java
index e300171cf11024df1f7d03fafed2101b6f8fbe94..5935dbf89e8a8d074fca646f8193adf9f5252e14 100644
--- a/Core/src/org/sleuthkit/autopsy/report/modules/stix/STIXReportModule.java
+++ b/Core/src/org/sleuthkit/autopsy/report/modules/stix/STIXReportModule.java
@@ -228,12 +228,19 @@ private void processFile(String stixFile, ReportProgressPanel progressPanel, Buf
      */
     private STIXPackage loadSTIXFile(String stixFileName) throws JAXBException {
         // Create STIXPackage object from xml.
-        File file = new File(stixFileName);
-        JAXBContext jaxbContext = JAXBContext.newInstance("org.mitre.stix.stix_1:org.mitre.stix.common_1:org.mitre.stix.indicator_2:" //NON-NLS
-                + "org.mitre.cybox.objects:org.mitre.cybox.cybox_2:org.mitre.cybox.common_2"); //NON-NLS
-        Unmarshaller jaxbUnmarshaller = jaxbContext.createUnmarshaller();
-        STIXPackage stix = (STIXPackage) jaxbUnmarshaller.unmarshal(file);
-        return stix;
+        // See JIRA-6958 for details about class loading and jaxb.
+        ClassLoader original = Thread.currentThread().getContextClassLoader();
+        try {
+            Thread.currentThread().setContextClassLoader(STIXReportModule.class.getClassLoader());
+            File file = new File(stixFileName);
+            JAXBContext jaxbContext = JAXBContext.newInstance("org.mitre.stix.stix_1:org.mitre.stix.common_1:org.mitre.stix.indicator_2:" //NON-NLS
+                    + "org.mitre.cybox.objects:org.mitre.cybox.cybox_2:org.mitre.cybox.common_2"); //NON-NLS
+            Unmarshaller jaxbUnmarshaller = jaxbContext.createUnmarshaller();
+            STIXPackage stix = (STIXPackage) jaxbUnmarshaller.unmarshal(file);
+            return stix;
+        } finally {
+            Thread.currentThread().setContextClassLoader(original);
+        }
     }
     
     /**
diff --git a/Core/test/qa-functional/src/org/sleuthkit/autopsy/README.txt b/Core/test/qa-functional/src/org/sleuthkit/autopsy/README.txt
new file mode 100644
index 0000000000000000000000000000000000000000..592bb50d728c19776d28c7fd70503def2480a82f
--- /dev/null
+++ b/Core/test/qa-functional/src/org/sleuthkit/autopsy/README.txt
@@ -0,0 +1,3 @@
+Netbeans platform does not properly scope classloaders while running qa-functional test code.  The result is that NoClassDefError's occur in instances where an external jar (i.e. importing a class from common-io) is referenced in test code and the same external jar is referenced in multiple NBM's.  Importing from external jars in qa-functional should be avoided.  See jira issue 6954 for more information.
+
+Many of the functional tests require external data sources.  The ant target 'getTestDataFiles' must be run successfully to download the files.  This should occur as a part of the 'test-init' ant target in build.xml.
\ No newline at end of file
diff --git a/Core/test/qa-functional/src/org/sleuthkit/autopsy/centralrepository/datamodel/CentralRepoAccountsTest.java b/Core/test/qa-functional/src/org/sleuthkit/autopsy/centralrepository/datamodel/CentralRepoAccountsTest.java
index ff8bd34250a18244b06e5020360aefef0e6a8c40..a87c2959badf359285f1e25271cc84660c47f092 100755
--- a/Core/test/qa-functional/src/org/sleuthkit/autopsy/centralrepository/datamodel/CentralRepoAccountsTest.java
+++ b/Core/test/qa-functional/src/org/sleuthkit/autopsy/centralrepository/datamodel/CentralRepoAccountsTest.java
@@ -25,11 +25,10 @@
 import junit.framework.Assert;
 import junit.framework.TestCase;
 import junit.framework.Test;
-import org.apache.commons.io.FileUtils;
 
 import org.netbeans.junit.NbModuleSuite;
-import org.openide.util.Exceptions;
 import org.sleuthkit.autopsy.centralrepository.datamodel.CentralRepoAccount.CentralRepoAccountType;
+import org.sleuthkit.autopsy.coreutils.FileUtil;
 import org.sleuthkit.datamodel.Account;
 import org.sleuthkit.datamodel.InvalidAccountIDException;
 
@@ -95,7 +94,8 @@ public void tearDown() throws CentralRepoException, IOException {
         if (CentralRepository.isEnabled()) {
             CentralRepository.getInstance().shutdownConnections();
         }
-        FileUtils.deleteDirectory(testDirectory.toFile());
+
+        FileUtil.deleteDir(testDirectory.toFile());
     }
 
     public void testPredefinedAccountTypes() {
diff --git a/Core/test/qa-functional/src/org/sleuthkit/autopsy/centralrepository/datamodel/CentralRepoDatamodelTest.java b/Core/test/qa-functional/src/org/sleuthkit/autopsy/centralrepository/datamodel/CentralRepoDatamodelTest.java
index bf71e5f6a7be0a1dae04083894f2a475bd583177..65907e8e879d6e828497d343b673310d7ee2303c 100755
--- a/Core/test/qa-functional/src/org/sleuthkit/autopsy/centralrepository/datamodel/CentralRepoDatamodelTest.java
+++ b/Core/test/qa-functional/src/org/sleuthkit/autopsy/centralrepository/datamodel/CentralRepoDatamodelTest.java
@@ -35,7 +35,6 @@
 import java.util.stream.IntStream;
 import junit.framework.Test;
 import junit.framework.TestCase;
-import org.apache.commons.io.FileUtils;
 import org.netbeans.junit.NbModuleSuite;
 import org.openide.util.Exceptions;
 import junit.framework.Assert;
@@ -47,6 +46,7 @@
 import static junit.framework.Assert.assertFalse;
 import static junit.framework.Assert.assertTrue;
 import org.sleuthkit.autopsy.casemodule.NoCurrentCaseException;
+import org.sleuthkit.autopsy.coreutils.FileUtil;
 
 /**
  * Functional tests for the Central Repository data model.
@@ -100,8 +100,8 @@ public void setUp() {
                 if (CentralRepository.isEnabled()) {
                     CentralRepository.getInstance().shutdownConnections();
                 }
-                FileUtils.deleteDirectory(testDirectory.toFile());
-            } catch (IOException | CentralRepoException ex) {
+                FileUtil.deleteDir(testDirectory.toFile());
+            } catch (CentralRepoException ex) {
                 Assert.fail(ex.getMessage());
             }
         }
@@ -194,8 +194,8 @@ public void tearDown() {
             if (CentralRepository.isEnabled()) {
                 CentralRepository.getInstance().shutdownConnections();
             }
-            FileUtils.deleteDirectory(testDirectory.toFile());
-        } catch (CentralRepoException | IOException ex) {
+            FileUtil.deleteDir(testDirectory.toFile());
+        } catch (CentralRepoException ex) {
             Exceptions.printStackTrace(ex);
             Assert.fail(ex.getMessage());
         }
@@ -1256,7 +1256,8 @@ public void testCorrelationTypes() {
             List<CorrelationAttributeInstance.Type> types = CentralRepository.getInstance().getDefinedCorrelationTypes();
 
             // We expect 11 total - 10 default and the custom one made earlier
-            assertTrue("getDefinedCorrelationTypes returned " + types.size() + " entries - expected 11", types.size() == 11);
+            // Note: this test will need to be updated based on the current default items defined in the correlation_types table
+            assertTrue("getDefinedCorrelationTypes returned " + types.size() + " entries - expected 28", types.size() == 28);
         } catch (CentralRepoException ex) {
             Exceptions.printStackTrace(ex);
             Assert.fail(ex.getMessage());
@@ -1267,7 +1268,8 @@ public void testCorrelationTypes() {
             List<CorrelationAttributeInstance.Type> types = CentralRepository.getInstance().getEnabledCorrelationTypes();
 
             // We expect 10 - the custom type is disabled
-            assertTrue("getDefinedCorrelationTypes returned " + types.size() + " enabled entries - expected 10", types.size() == 10);
+            // Note: this test will need to be updated based on the current default items defined in the correlation_types table
+            assertTrue("getDefinedCorrelationTypes returned " + types.size() + " enabled entries - expected 27", types.size() == 27);
         } catch (CentralRepoException ex) {
             Exceptions.printStackTrace(ex);
             Assert.fail(ex.getMessage());
@@ -1278,7 +1280,8 @@ public void testCorrelationTypes() {
             List<CorrelationAttributeInstance.Type> types = CentralRepository.getInstance().getSupportedCorrelationTypes();
 
             // We expect 10 - the custom type is not supported
-            assertTrue("getDefinedCorrelationTypes returned " + types.size() + " supported entries - expected 10", types.size() == 10);
+            // Note: this test will need to be updated based on the current default items defined in the correlation_types table
+            assertTrue("getDefinedCorrelationTypes returned " + types.size() + " supported entries - expected 27", types.size() == 27);
         } catch (CentralRepoException ex) {
             Exceptions.printStackTrace(ex);
             Assert.fail(ex.getMessage());
diff --git a/Core/test/qa-functional/src/org/sleuthkit/autopsy/centralrepository/datamodel/CentralRepoPersonasTest.java b/Core/test/qa-functional/src/org/sleuthkit/autopsy/centralrepository/datamodel/CentralRepoPersonasTest.java
index bbe5ae58f5ad797866212191920eb34b8bed19bb..60636e5f54b5f11793cf5c9fe1d225db88e7f2b9 100644
--- a/Core/test/qa-functional/src/org/sleuthkit/autopsy/centralrepository/datamodel/CentralRepoPersonasTest.java
+++ b/Core/test/qa-functional/src/org/sleuthkit/autopsy/centralrepository/datamodel/CentralRepoPersonasTest.java
@@ -28,10 +28,10 @@
 import static junit.framework.Assert.assertTrue;
 import junit.framework.TestCase;
 import junit.framework.Test;
-import org.apache.commons.io.FileUtils;
 
 import org.netbeans.junit.NbModuleSuite;
 import org.openide.util.Exceptions;
+import org.sleuthkit.autopsy.coreutils.FileUtil;
 import org.sleuthkit.datamodel.Account;
 import org.sleuthkit.datamodel.InvalidAccountIDException;
 import org.sleuthkit.datamodel.TskData;
@@ -257,12 +257,12 @@ public void setUp() throws CentralRepoException, IOException {
     // This function is run after every test, NOT after the entire collection of 
     // tests defined in the class are run.
     @Override
-    public void tearDown() throws CentralRepoException, IOException {
+    public void tearDown() throws CentralRepoException {
         // Close and delete the test case and central repo db
         if (CentralRepository.isEnabled()) {
             CentralRepository.getInstance().shutdownConnections();
         }
-        FileUtils.deleteDirectory(testDirectory.toFile());
+        FileUtil.deleteDir(testDirectory.toFile());
     }
     
     
diff --git a/Core/test/qa-functional/src/org/sleuthkit/autopsy/commonpropertiessearch/InterCaseTestUtils.java b/Core/test/qa-functional/src/org/sleuthkit/autopsy/commonpropertiessearch/InterCaseTestUtils.java
index 85fe4d91c7e01650bf160572b4316d739fb177bf..f76676cd2593994d0c967b9a92acf95dfd719fc9 100644
--- a/Core/test/qa-functional/src/org/sleuthkit/autopsy/commonpropertiessearch/InterCaseTestUtils.java
+++ b/Core/test/qa-functional/src/org/sleuthkit/autopsy/commonpropertiessearch/InterCaseTestUtils.java
@@ -28,7 +28,6 @@
 import java.util.Collection;
 import java.util.HashMap;
 import java.util.Map;
-import org.apache.commons.io.FileUtils;
 import org.netbeans.junit.NbTestCase;
 import org.openide.util.Exceptions;
 import org.sleuthkit.autopsy.casemodule.Case;
@@ -63,6 +62,7 @@
 import org.sleuthkit.datamodel.AbstractFile;
 import org.sleuthkit.autopsy.centralrepository.datamodel.CentralRepository;
 import org.sleuthkit.autopsy.centralrepository.datamodel.RdbmsCentralRepoFactory;
+import org.sleuthkit.autopsy.coreutils.FileUtil;
 import org.sleuthkit.autopsy.modules.pictureanalyzer.PictureAnalyzerIngestModuleFactory;
 
 /**
@@ -218,7 +218,9 @@ class InterCaseTestUtils {
 //        kitchenSink.add(keywordSearchTemplate);
 
         this.kitchenShink = new IngestJobSettings(InterCaseTestUtils.class.getCanonicalName(), IngestType.ALL_MODULES, kitchenSink);
+    }
 
+    void setupCorrelationTypes() {
         try {
             Collection<CorrelationAttributeInstance.Type> types = CentralRepository.getInstance().getDefinedCorrelationTypes();
 
@@ -247,23 +249,23 @@ void clearTestDir() {
                 if (CentralRepository.isEnabled()) {
                     CentralRepository.getInstance().shutdownConnections();
                 }
-                FileUtils.deleteDirectory(CENTRAL_REPO_DIRECTORY_PATH.toFile());
-            } catch (IOException | CentralRepoException ex) {
+                FileUtil.deleteDir(CENTRAL_REPO_DIRECTORY_PATH.toFile());
+            } catch (CentralRepoException ex) {
                 Exceptions.printStackTrace(ex);
                 Assert.fail(ex.getMessage());
             }
         }
     }
-    
+
     Map<Long, String> getDataSourceMap() throws NoCurrentCaseException, TskCoreException, SQLException {
         return DataSourceLoader.getAllDataSources();
-    }    
+    }
 
     Map<String, Integer> getCaseMap() throws CentralRepoException {
 
         if (CentralRepository.isEnabled()) {
             Map<String, Integer> mapOfCaseIdsToCase = new HashMap<>();
-            
+
             for (CorrelationCase correlationCase : CentralRepository.getInstance().getCases()) {
                 mapOfCaseIdsToCase.put(correlationCase.getDisplayName(), correlationCase.getID());
             }
@@ -300,7 +302,7 @@ void enableCentralRepo() throws CentralRepoException {
         RdbmsCentralRepoFactory centralRepoSchemaFactory = new RdbmsCentralRepoFactory(CentralRepoPlatforms.SQLITE, crSettings);
         centralRepoSchemaFactory.initializeDatabaseSchema();
         centralRepoSchemaFactory.insertDefaultDatabaseContent();
-        
+
         crSettings.saveSettings();
         CentralRepoDbManager.saveDbChoice(CentralRepoDbChoice.SQLITE);
     }
@@ -313,10 +315,10 @@ void enableCentralRepo() throws CentralRepoException {
      * The length of caseNames and caseDataSourcePaths should be the same, and
      * cases should appear in the same order.
      *
-     * @param caseNames            list case names
-     * @param caseDataSourcePaths  two dimensional array listing the datasources
-     *                             in each case
-     * @param ingestJobSettings    HashLookup FileType etc...
+     * @param caseNames list case names
+     * @param caseDataSourcePaths two dimensional array listing the datasources
+     * in each case
+     * @param ingestJobSettings HashLookup FileType etc...
      * @param caseReferenceToStore
      */
     Case createCases(String[] caseNames, Path[][] caseDataSourcePaths, IngestJobSettings ingestJobSettings, String caseReferenceToStore) throws TskCoreException {
diff --git a/Core/test/qa-functional/src/org/sleuthkit/autopsy/ingest/IngestFileFiltersTest.java b/Core/test/qa-functional/src/org/sleuthkit/autopsy/ingest/IngestFileFiltersTest.java
index 262e54774ffde28af61957f5ffefa8db22b93e13..ce0ffb15262c7496a011c3b6dcec5f9ac86b955f 100755
--- a/Core/test/qa-functional/src/org/sleuthkit/autopsy/ingest/IngestFileFiltersTest.java
+++ b/Core/test/qa-functional/src/org/sleuthkit/autopsy/ingest/IngestFileFiltersTest.java
@@ -253,7 +253,7 @@ public void testCarvingWithExtRuleAndUnallocSpace() {
             IngestUtils.runIngestJob(currentCase.getDataSources(), ingestJobSettings); 
             FileManager fileManager = currentCase.getServices().getFileManager();
             List<AbstractFile> results = fileManager.findFiles("%%");
-            assertEquals(70, results.size()); 
+            assertEquals(71, results.size()); 
             int carvedJpgGifFiles = 0;
             for (AbstractFile file : results) {
                 if (file.getNameExtension().equalsIgnoreCase("jpg") || file.getNameExtension().equalsIgnoreCase("gif")) { //Unalloc file and .jpg files in dir1, dir2, $CarvedFiles, root directory should have MIME type
diff --git a/Core/test/unit/src/org/sleuthkit/autopsy/datasourcesummary/datamodel/GetArtifactsTest.java b/Core/test/unit/src/org/sleuthkit/autopsy/datasourcesummary/datamodel/DataSourceInfoUtilitiesTest.java
similarity index 73%
rename from Core/test/unit/src/org/sleuthkit/autopsy/datasourcesummary/datamodel/GetArtifactsTest.java
rename to Core/test/unit/src/org/sleuthkit/autopsy/datasourcesummary/datamodel/DataSourceInfoUtilitiesTest.java
index ebb7db601a4d80793ce8f2a341e5fb4f4b92bd36..28b55155c15b8816a6124bf494f06fd08e1d3312 100644
--- a/Core/test/unit/src/org/sleuthkit/autopsy/datasourcesummary/datamodel/GetArtifactsTest.java
+++ b/Core/test/unit/src/org/sleuthkit/autopsy/datasourcesummary/datamodel/DataSourceInfoUtilitiesTest.java
@@ -21,6 +21,7 @@
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collections;
+import java.util.Date;
 import java.util.List;
 import org.junit.Assert;
 import org.junit.Rule;
@@ -35,6 +36,7 @@
 import org.sleuthkit.datamodel.TskCoreException;
 import org.sleuthkit.autopsy.testutils.TskMockUtils;
 import static org.mockito.Mockito.*;
+import org.sleuthkit.autopsy.testutils.RandomizationUtils;
 import org.sleuthkit.datamodel.BlackboardArtifact.ARTIFACT_TYPE;
 import org.sleuthkit.datamodel.BlackboardAttribute.ATTRIBUTE_TYPE;
 import org.sleuthkit.datamodel.BlackboardAttribute.TSK_BLACKBOARD_ATTRIBUTE_VALUE_TYPE;
@@ -42,7 +44,7 @@
 /**
  * Unit tests for DataSourceInfoUtilities.getArtifacts
  */
-public class GetArtifactsTest {
+public class DataSourceInfoUtilitiesTest {
 
     @Rule
     public ExpectedException thrown = ExpectedException.none();
@@ -145,38 +147,13 @@ private <T> List<BlackboardArtifact> getArtifacts(ARTIFACT_TYPE artifactType, Bl
 
         List<BlackboardArtifact> toRet = new ArrayList<>();
         for (int i = 0; i < values.size(); i++) {
-            toRet.add(TskMockUtils.mockArtifact(new BlackboardArtifact.Type(artifactType), 1000 + i, dataSource,
+            toRet.add(TskMockUtils.getArtifact(new BlackboardArtifact.Type(artifactType), 1000 + i, dataSource,
                     attrMaker.make(attrType, "TEST SOURCE", values.get(i))));
         }
 
         return toRet;
     }
 
-    /**
-     * Returns list in 0, n-1, 1, n-2 ... order. Deterministic so same results
-     * each time, but not in original order.
-     *
-     * @return Mixed up list.
-     */
-    private <T> List<T> getMixedUp(List<T> list) {
-        int forward = 0;
-        int backward = list.size() - 1;
-
-        List<T> newList = new ArrayList<>();
-        while (forward <= backward) {
-            newList.add(list.get(forward));
-
-            if (forward < backward) {
-                newList.add(list.get(backward));
-            }
-
-            forward++;
-            backward--;
-        }
-
-        return newList;
-    }
-
     /**
      * Does a basic test passing a list of generated artifacts in mixed up order
      * to DataSourceInfoUtilities.getArtifacts and expecting a sorted list to be
@@ -194,11 +171,11 @@ private <T> List<T> getMixedUp(List<T> list) {
     private <T> void testSorted(ARTIFACT_TYPE artifactType, ATTRIBUTE_TYPE attrType, List<T> values,
             AttrMaker<T> attrMaker, SortOrder sortOrder, int count) throws TskCoreException {
 
-        DataSource dataSource = TskMockUtils.mockDataSource(1);
+        DataSource dataSource = TskMockUtils.getDataSource(1);
         List<BlackboardArtifact> sortedArtifacts = getArtifacts(artifactType, new BlackboardAttribute.Type(attrType),
                 dataSource, values, attrMaker);
 
-        List<BlackboardArtifact> mixedUpArtifacts = getMixedUp(sortedArtifacts);
+        List<BlackboardArtifact> mixedUpArtifacts = RandomizationUtils.getMixedUp(sortedArtifacts);
 
         List<BlackboardArtifact> expectedArtifacts = count == 0
                 ? sortedArtifacts
@@ -250,17 +227,17 @@ private void testAscDesc(SortOrder sortOrder) throws TskCoreException {
     }
 
     @Test
-    public void testSortAscending() throws TskCoreException {
+    public void getArtifacts_sortAscending() throws TskCoreException {
         testAscDesc(SortOrder.ASCENDING);
     }
 
     @Test
-    public void testSortDescending() throws TskCoreException {
+    public void getArtifacts_sortDescending() throws TskCoreException {
         testAscDesc(SortOrder.DESCENDING);
     }
 
     @Test
-    public void testLimits() throws TskCoreException {
+    public void getArtifacts_limits() throws TskCoreException {
         List<Integer> integers = Arrays.asList(22, 31, 42, 50, 60);
         testSorted(ARTIFACT_TYPE.TSK_PROG_RUN, ATTRIBUTE_TYPE.TSK_COUNT, integers, BlackboardAttribute::new, SortOrder.ASCENDING, 3);
         testSorted(ARTIFACT_TYPE.TSK_PROG_RUN, ATTRIBUTE_TYPE.TSK_COUNT, integers, BlackboardAttribute::new, SortOrder.ASCENDING, 5);
@@ -281,11 +258,11 @@ public void testLimits() throws TskCoreException {
     private <T> void testFailOnBadAttrType(BlackboardArtifact.Type artifactType, BlackboardAttribute.Type attributeType, T val,
             AttrMaker<T> attrMaker) throws TskCoreException {
 
-        DataSource dataSource = TskMockUtils.mockDataSource(1);
+        DataSource dataSource = TskMockUtils.getDataSource(1);
 
         List<BlackboardArtifact> artifacts = Arrays.asList(
-                TskMockUtils.mockArtifact(artifactType, 2, dataSource, attrMaker.make(attributeType, "TEST SOURCE", val)),
-                TskMockUtils.mockArtifact(artifactType, 3, dataSource, attrMaker.make(attributeType, "TEST SOURCE", val))
+                TskMockUtils.getArtifact(artifactType, 2, dataSource, attrMaker.make(attributeType, "TEST SOURCE", val)),
+                TskMockUtils.getArtifact(artifactType, 3, dataSource, attrMaker.make(attributeType, "TEST SOURCE", val))
         );
         test(artifactType,
                 dataSource,
@@ -299,7 +276,7 @@ private <T> void testFailOnBadAttrType(BlackboardArtifact.Type artifactType, Bla
     }
 
     @Test
-    public void testFailOnJson() throws TskCoreException {
+    public void getArtifacts_failOnJson() throws TskCoreException {
         testFailOnBadAttrType(
                 new BlackboardArtifact.Type(ARTIFACT_TYPE.TSK_GPS_ROUTE),
                 new BlackboardAttribute.Type(ATTRIBUTE_TYPE.TSK_GEO_WAYPOINTS),
@@ -308,7 +285,7 @@ public void testFailOnJson() throws TskCoreException {
     }
 
     @Test
-    public void testFailOnBytes() throws TskCoreException {
+    public void getArtifacts_failOnBytes() throws TskCoreException {
         testFailOnBadAttrType(
                 new BlackboardArtifact.Type(999, "BYTE_ARRAY_TYPE", "Byte Array Type"),
                 new BlackboardAttribute.Type(999, "BYTE_ARR_ATTR_TYPE", "Byte Array Attribute Type", TSK_BLACKBOARD_ATTRIBUTE_VALUE_TYPE.BYTE),
@@ -317,20 +294,20 @@ public void testFailOnBytes() throws TskCoreException {
     }
 
     @Test
-    public void testPurgeAttrNotPresent() throws TskCoreException {
+    public void getArtifacts_purgeAttrNotPresent() throws TskCoreException {
         long day = 24 * 60 * 60;
-        DataSource dataSource = TskMockUtils.mockDataSource(1);
+        DataSource dataSource = TskMockUtils.getDataSource(1);
 
         BlackboardArtifact.Type ART_TYPE = new BlackboardArtifact.Type(ARTIFACT_TYPE.TSK_PROG_RUN);
 
-        BlackboardArtifact mock1 = TskMockUtils.mockArtifact(ART_TYPE, 10, dataSource,
+        BlackboardArtifact mock1 = TskMockUtils.getArtifact(ART_TYPE, 10, dataSource,
                 new BlackboardAttribute(ATTRIBUTE_TYPE.TSK_COUNT, "TEST SOURCE", 5),
                 new BlackboardAttribute(ATTRIBUTE_TYPE.TSK_DATETIME, "TEST SOURCE", day));
 
-        BlackboardArtifact mock2 = TskMockUtils.mockArtifact(ART_TYPE, 20, dataSource,
+        BlackboardArtifact mock2 = TskMockUtils.getArtifact(ART_TYPE, 20, dataSource,
                 new BlackboardAttribute(ATTRIBUTE_TYPE.TSK_COUNT, "TEST SOURCE", 6));
 
-        BlackboardArtifact mock3 = TskMockUtils.mockArtifact(ART_TYPE, 30, dataSource,
+        BlackboardArtifact mock3 = TskMockUtils.getArtifact(ART_TYPE, 30, dataSource,
                 new BlackboardAttribute(ATTRIBUTE_TYPE.TSK_COUNT, "TEST SOURCE", 7),
                 new BlackboardAttribute(ATTRIBUTE_TYPE.TSK_DATETIME, "TEST SOURCE", 3 * day));
 
@@ -346,20 +323,20 @@ public void testPurgeAttrNotPresent() throws TskCoreException {
     }
 
     @Test
-    public void testMultAttrsPresent() throws TskCoreException {
+    public void getArtifacts_multipleAttrsPresent() throws TskCoreException {
         long day = 24 * 60 * 60;
-        DataSource dataSource = TskMockUtils.mockDataSource(1);
+        DataSource dataSource = TskMockUtils.getDataSource(1);
 
         BlackboardArtifact.Type ART_TYPE = new BlackboardArtifact.Type(ARTIFACT_TYPE.TSK_PROG_RUN);
 
-        BlackboardArtifact mock1 = TskMockUtils.mockArtifact(ART_TYPE, 10, dataSource,
+        BlackboardArtifact mock1 = TskMockUtils.getArtifact(ART_TYPE, 10, dataSource,
                 new BlackboardAttribute(ATTRIBUTE_TYPE.TSK_COUNT, "TEST SOURCE", 7),
                 new BlackboardAttribute(ATTRIBUTE_TYPE.TSK_DATETIME, "TEST SOURCE", day));
 
-        BlackboardArtifact mock2 = TskMockUtils.mockArtifact(ART_TYPE, 20, dataSource,
+        BlackboardArtifact mock2 = TskMockUtils.getArtifact(ART_TYPE, 20, dataSource,
                 new BlackboardAttribute(ATTRIBUTE_TYPE.TSK_COUNT, "TEST SOURCE", 6));
 
-        BlackboardArtifact mock3 = TskMockUtils.mockArtifact(ART_TYPE, 30, dataSource,
+        BlackboardArtifact mock3 = TskMockUtils.getArtifact(ART_TYPE, 30, dataSource,
                 new BlackboardAttribute(ATTRIBUTE_TYPE.TSK_COUNT, "TEST SOURCE", 5),
                 new BlackboardAttribute(ATTRIBUTE_TYPE.TSK_DATETIME, "TEST SOURCE", 3 * day));
 
@@ -375,9 +352,9 @@ public void testMultAttrsPresent() throws TskCoreException {
     }
 
     @Test
-    public void testTskCoreExceptionThrown() throws TskCoreException {
+    public void getArtifacts_tskCoreExceptionThrown() throws TskCoreException {
         test(new BlackboardArtifact.Type(ARTIFACT_TYPE.TSK_ACCOUNT),
-                TskMockUtils.mockDataSource(1),
+                TskMockUtils.getDataSource(1),
                 new BlackboardAttribute.Type(ATTRIBUTE_TYPE.TSK_ACCOUNT_TYPE),
                 SortOrder.ASCENDING,
                 0,
@@ -388,9 +365,9 @@ public void testTskCoreExceptionThrown() throws TskCoreException {
     }
 
     @Test
-    public void testThrowOnLessThan0() throws TskCoreException {
+    public void getArtifacts_throwOnLessThan0() throws TskCoreException {
         test(new BlackboardArtifact.Type(ARTIFACT_TYPE.TSK_ACCOUNT),
-                TskMockUtils.mockDataSource(1),
+                TskMockUtils.getDataSource(1),
                 new BlackboardAttribute.Type(ATTRIBUTE_TYPE.TSK_ACCOUNT_TYPE),
                 SortOrder.ASCENDING,
                 -1,
@@ -401,9 +378,9 @@ public void testThrowOnLessThan0() throws TskCoreException {
     }
 
     @Test
-    public void testEmptyListReturned() throws TskCoreException {
+    public void getArtifacts_emptyListReturned() throws TskCoreException {
         test(new BlackboardArtifact.Type(ARTIFACT_TYPE.TSK_ACCOUNT),
-                TskMockUtils.mockDataSource(1),
+                TskMockUtils.getDataSource(1),
                 new BlackboardAttribute.Type(ATTRIBUTE_TYPE.TSK_ACCOUNT_TYPE),
                 SortOrder.ASCENDING,
                 0,
@@ -412,4 +389,76 @@ public void testEmptyListReturned() throws TskCoreException {
                 Collections.emptyList(),
                 null);
     }
+
+    /**
+     * Retrieves the value of an artifact.
+     */
+    private interface GetAttrVal<T> {
+        /**
+         * A method for retrieving the value of an artifact.
+         * @param artifact The artifact.
+         * @param type The type of attribute.
+         * @return The value.
+         */
+        T getOrNull(BlackboardArtifact artifact, BlackboardAttribute.Type type);
+    }
+
+    private <T> void testNullAttrValue(String id, GetAttrVal<T> getter, ARTIFACT_TYPE artifactType,
+            ATTRIBUTE_TYPE attributeType, T nonNullVal)
+            throws TskCoreException {
+
+        BlackboardAttribute.Type attrType = new BlackboardAttribute.Type(attributeType);
+        BlackboardArtifact.Type artType = new BlackboardArtifact.Type(artifactType);
+
+        BlackboardArtifact noAttribute = TskMockUtils.getArtifact(artType, 1000,
+                TskMockUtils.getDataSource(1), new ArrayList<>());
+
+        T nullValue = getter.getOrNull(noAttribute, attrType);
+        Assert.assertNull(String.format("Expected function %s to return null when no attribute present", id), nullValue);
+
+        BlackboardArtifact hasAttribute = TskMockUtils.getArtifact(artType, 1000,
+                TskMockUtils.getDataSource(1), TskMockUtils.getAttribute(attributeType, nonNullVal));
+
+        T valueReceived = getter.getOrNull(hasAttribute, attrType);
+
+        Assert.assertEquals(String.format("%s did not return the same value present in the attribute", id), nonNullVal, valueReceived);
+    }
+
+    @Test
+    public void getStringOrNull_handlesNull() throws TskCoreException {
+        testNullAttrValue("getStringOrNull", DataSourceInfoUtilities::getStringOrNull,
+                ARTIFACT_TYPE.TSK_ACCOUNT, ATTRIBUTE_TYPE.TSK_ACCOUNT_TYPE, "Skype");
+    }
+
+    @Test
+    public void getIntOrNull_handlesNull() throws TskCoreException {
+        testNullAttrValue("getIntOrNull", DataSourceInfoUtilities::getIntOrNull,
+                ARTIFACT_TYPE.TSK_PROG_RUN, ATTRIBUTE_TYPE.TSK_COUNT, 16);
+    }
+
+    @Test
+    public void getLongOrNull_handlesNull() throws TskCoreException {
+        testNullAttrValue("getLongOrNull", DataSourceInfoUtilities::getLongOrNull,
+                ARTIFACT_TYPE.TSK_ASSOCIATED_OBJECT, ATTRIBUTE_TYPE.TSK_ASSOCIATED_ARTIFACT, 1001L);
+    }
+
+    @Test
+    public void getDateOrNull_handlesNull() throws TskCoreException {
+        BlackboardAttribute.Type attrType = new BlackboardAttribute.Type(ATTRIBUTE_TYPE.TSK_DATETIME);
+        BlackboardArtifact.Type artType = new BlackboardArtifact.Type(ARTIFACT_TYPE.TSK_BLUETOOTH_PAIRING);
+
+        long dateTime = 24 * 60 * 60 * 42;
+
+        BlackboardArtifact noAttribute = TskMockUtils.getArtifact(artType, 1000,
+                TskMockUtils.getDataSource(1), new ArrayList<>());
+
+        Date nullValue = DataSourceInfoUtilities.getDateOrNull(noAttribute, attrType);
+        Assert.assertNull(nullValue);
+
+        BlackboardArtifact hasAttribute = TskMockUtils.getArtifact(artType, 1000,
+                TskMockUtils.getDataSource(1), TskMockUtils.getAttribute(ATTRIBUTE_TYPE.TSK_DATETIME, dateTime));
+
+        Date curVal = DataSourceInfoUtilities.getDateOrNull(hasAttribute, attrType);
+        Assert.assertEquals(dateTime, curVal.getTime() / 1000);
+    }
 }
diff --git a/Core/test/unit/src/org/sleuthkit/autopsy/datasourcesummary/datamodel/DataSourceSummaryMockUtils.java b/Core/test/unit/src/org/sleuthkit/autopsy/datasourcesummary/datamodel/DataSourceSummaryMockUtils.java
new file mode 100644
index 0000000000000000000000000000000000000000..d73c440e96849423a165272754ffff9dcdae1e70
--- /dev/null
+++ b/Core/test/unit/src/org/sleuthkit/autopsy/datasourcesummary/datamodel/DataSourceSummaryMockUtils.java
@@ -0,0 +1,57 @@
+/*
+ * Autopsy Forensic Browser
+ *
+ * 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.autopsy.datasourcesummary.datamodel;
+
+import java.util.List;
+import org.apache.commons.lang3.tuple.Pair;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+import org.sleuthkit.datamodel.Blackboard;
+import org.sleuthkit.datamodel.BlackboardArtifact;
+import org.sleuthkit.datamodel.SleuthkitCase;
+import org.sleuthkit.datamodel.TskCoreException;
+
+/**
+ * Common tools for mocking in data source summary.
+ */
+public final class DataSourceSummaryMockUtils {
+
+    /**
+     * Creates a pair of a mock SleuthkitCase and mock Blackboard.
+     *
+     * @param returnArr The return result when calling getArtifacts on the
+     *                  blackboard.
+     *
+     * @return The pair of a mock SleuthkitCase and mock Blackboard.
+     *
+     * @throws TskCoreException
+     */
+    static Pair<SleuthkitCase, Blackboard> getArtifactsTSKMock(List<BlackboardArtifact> returnArr) throws TskCoreException {
+        SleuthkitCase mockCase = mock(SleuthkitCase.class);
+        Blackboard mockBlackboard = mock(Blackboard.class);
+        when(mockCase.getBlackboard()).thenReturn(mockBlackboard);
+        when(mockBlackboard.getArtifacts(anyInt(), anyLong())).thenReturn(returnArr);
+        return Pair.of(mockCase, mockBlackboard);
+    }
+
+    private DataSourceSummaryMockUtils() {
+    }
+}
diff --git a/Core/test/unit/src/org/sleuthkit/autopsy/datasourcesummary/datamodel/RecentFilesSummaryTest.java b/Core/test/unit/src/org/sleuthkit/autopsy/datasourcesummary/datamodel/RecentFilesSummaryTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..068f0c3904f8e517842d60500d4f12de018cb4ce
--- /dev/null
+++ b/Core/test/unit/src/org/sleuthkit/autopsy/datasourcesummary/datamodel/RecentFilesSummaryTest.java
@@ -0,0 +1,681 @@
+/*
+ * Autopsy Forensic Browser
+ *
+ * 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.autopsy.datasourcesummary.datamodel;
+
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+import java.util.stream.IntStream;
+import java.util.stream.Stream;
+import org.apache.commons.lang3.tuple.Pair;
+import org.junit.Assert;
+import static org.junit.Assert.fail;
+import org.junit.Test;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import org.sleuthkit.autopsy.datasourcesummary.datamodel.RecentFilesSummary.RecentAttachmentDetails;
+import org.sleuthkit.autopsy.datasourcesummary.datamodel.RecentFilesSummary.RecentDownloadDetails;
+import org.sleuthkit.autopsy.datasourcesummary.datamodel.RecentFilesSummary.RecentFileDetails;
+import org.sleuthkit.autopsy.datasourcesummary.datamodel.SleuthkitCaseProvider.SleuthkitCaseProviderException;
+import org.sleuthkit.autopsy.testutils.RandomizationUtils;
+import org.sleuthkit.autopsy.testutils.TskMockUtils;
+import org.sleuthkit.datamodel.Blackboard;
+import org.sleuthkit.datamodel.BlackboardArtifact;
+import org.sleuthkit.datamodel.BlackboardArtifact.ARTIFACT_TYPE;
+import org.sleuthkit.datamodel.BlackboardAttribute;
+import org.sleuthkit.datamodel.BlackboardAttribute.ATTRIBUTE_TYPE;
+import org.sleuthkit.datamodel.Content;
+import org.sleuthkit.datamodel.DataSource;
+import org.sleuthkit.datamodel.SleuthkitCase;
+import org.sleuthkit.datamodel.TskCoreException;
+
+/**
+ * Tests for RecentFilesSummaryTest
+ */
+public class RecentFilesSummaryTest {
+
+    /**
+     * An interface for calling methods in RecentFilesSummary in a uniform
+     * manner.
+     */
+    private interface RecentFilesMethod<T> {
+
+        /**
+         * Means of acquiring data from a method in RecentFilesSummary.
+         *
+         * @param recentFilesSummary The RecentFilesSummary object.
+         * @param dataSource         The datasource.
+         * @param count              The number of items to retrieve.
+         *
+         * @return The method's return data.
+         *
+         * @throws SleuthkitCaseProviderException
+         * @throws TskCoreException
+         */
+        List<T> fetch(RecentFilesSummary recentFilesSummary, DataSource dataSource, int count)
+                throws SleuthkitCaseProviderException, TskCoreException;
+    }
+
+    private static final RecentFilesMethod<RecentFileDetails> RECENT_DOCS_FUNCT
+            = (summary, dataSource, count) -> summary.getRecentlyOpenedDocuments(dataSource, count);
+
+    private static final RecentFilesMethod<RecentDownloadDetails> RECENT_DOWNLOAD_FUNCT
+            = (summary, dataSource, count) -> summary.getRecentDownloads(dataSource, count);
+
+    private static final RecentFilesMethod<RecentAttachmentDetails> RECENT_ATTACHMENT_FUNCT
+            = (summary, dataSource, count) -> summary.getRecentAttachments(dataSource, count);
+
+    /**
+     * If -1 count passed to method, should throw IllegalArgumentException.
+     *
+     * @param method     The method to call.
+     * @param methodName The name of the metho
+     *
+     * @throws TskCoreException
+     * @throws SleuthkitCaseProviderException
+     */
+    private <T> void testNonPositiveCount_ThrowsError(RecentFilesMethod<T> method, String methodName)
+            throws TskCoreException, SleuthkitCaseProviderException {
+        Pair<SleuthkitCase, Blackboard> casePair = DataSourceSummaryMockUtils.getArtifactsTSKMock(null);
+        DataSource dataSource = TskMockUtils.getDataSource(1);
+        RecentFilesSummary summary = new RecentFilesSummary(() -> casePair.getLeft());
+
+        try {
+            method.fetch(summary, dataSource, -1);
+            fail("Expected method " + methodName + " to fail on negative count.");
+        } catch (IllegalArgumentException ignored) {
+            verify(casePair.getRight(),
+                    never().description("Expected negative count for " + methodName + " to not call any methods in SleuthkitCase."))
+                    .getArtifacts(anyInt(), anyLong());
+        }
+    }
+
+    @Test
+    public void getRecentlyOpenedDocuments_nonPositiveCount_ThrowsError() throws TskCoreException, SleuthkitCaseProviderException {
+        testNonPositiveCount_ThrowsError(RECENT_DOCS_FUNCT, "getRecentlyOpenedDocuments");
+    }
+
+    @Test
+    public void getRecentDownloads_nonPositiveCount_ThrowsError() throws TskCoreException, SleuthkitCaseProviderException {
+        testNonPositiveCount_ThrowsError(RECENT_DOWNLOAD_FUNCT, "getRecentDownloads");
+    }
+
+    @Test
+    public void getRecentAttachments_nonPositiveCount_ThrowsError() throws TskCoreException, SleuthkitCaseProviderException {
+        testNonPositiveCount_ThrowsError(RECENT_ATTACHMENT_FUNCT, "getRecentAttachments");
+    }
+
+    /**
+     * Tests that if no data source provided, an empty list is returned and
+     * SleuthkitCase isn't called.
+     *
+     * @param recentFilesMethod The method to call.
+     * @param methodName        The name of the method
+     *
+     * @throws SleuthkitCaseProviderException
+     * @throws TskCoreException
+     */
+    private <T> void testNoDataSource_ReturnsEmptyList(RecentFilesMethod<T> recentFilesMethod, String methodName)
+            throws SleuthkitCaseProviderException, TskCoreException {
+
+        Pair<SleuthkitCase, Blackboard> casePair = DataSourceSummaryMockUtils.getArtifactsTSKMock(null);
+        RecentFilesSummary summary = new RecentFilesSummary(() -> casePair.getLeft());
+
+        List<? extends T> items = recentFilesMethod.fetch(summary, null, 10);
+        Assert.assertNotNull("Expected method " + methodName + " to return an empty list.", items);
+        Assert.assertEquals("Expected method " + methodName + " to return an empty list.", 0, items.size());
+        verify(casePair.getRight(),
+                never().description("Expected null datasource for " + methodName + " to not call any methods in SleuthkitCase."))
+                .getArtifacts(anyInt(), anyLong());
+    }
+
+    @Test
+    public void getRecentlyOpenedDocuments_noDataSource_ReturnsEmptyList() throws TskCoreException, SleuthkitCaseProviderException {
+        testNoDataSource_ReturnsEmptyList(RECENT_DOCS_FUNCT, "getRecentlyOpenedDocuments");
+    }
+
+    @Test
+    public void getRecentDownloads_noDataSource_ReturnsEmptyList() throws TskCoreException, SleuthkitCaseProviderException {
+        testNoDataSource_ReturnsEmptyList(RECENT_DOWNLOAD_FUNCT, "getRecentDownloads");
+    }
+
+    @Test
+    public void getRecentAttachments_noDataSource_ReturnsEmptyList() throws TskCoreException, SleuthkitCaseProviderException {
+        testNonPositiveCount_ThrowsError(RECENT_ATTACHMENT_FUNCT, "getRecentAttachments");
+    }
+
+    /**
+     * If SleuthkitCase returns no results, an empty list is returned.
+     *
+     * @param recentFilesMethod The method to call.
+     * @param methodName        The name of the method.
+     *
+     * @throws SleuthkitCaseProviderException
+     * @throws TskCoreException
+     */
+    private <T> void testNoReturnedResults_ReturnsEmptyList(RecentFilesMethod<T> recentFilesMethod, String methodName)
+            throws SleuthkitCaseProviderException, TskCoreException {
+
+        Pair<SleuthkitCase, Blackboard> casePair = DataSourceSummaryMockUtils.getArtifactsTSKMock(Collections.emptyList());
+        RecentFilesSummary summary = new RecentFilesSummary(() -> casePair.getLeft());
+        DataSource dataSource = TskMockUtils.getDataSource(1);
+        List<? extends T> items = recentFilesMethod.fetch(summary, dataSource, 10);
+        Assert.assertNotNull("Expected method " + methodName + " to return an empty list.", items);
+        Assert.assertEquals("Expected method " + methodName + " to return an empty list.", 0, items.size());
+        verify(casePair.getRight(),
+                times(1).description("Expected " + methodName + " to call Blackboard once."))
+                .getArtifacts(anyInt(), anyLong());
+    }
+
+    @Test
+    public void getRecentlyOpenedDocuments_noReturnedResults_ReturnsEmptyList() throws TskCoreException, SleuthkitCaseProviderException {
+        testNoReturnedResults_ReturnsEmptyList(RECENT_DOCS_FUNCT, "getRecentlyOpenedDocuments");
+    }
+
+    @Test
+    public void getRecentDownloads_noReturnedResults_ReturnsEmptyList() throws TskCoreException, SleuthkitCaseProviderException {
+        testNoReturnedResults_ReturnsEmptyList(RECENT_DOWNLOAD_FUNCT, "getRecentDownloads");
+    }
+
+    @Test
+    public void getRecentAttachments_testNoDataSource_ReturnsEmptyList() throws TskCoreException, SleuthkitCaseProviderException {
+        testNoReturnedResults_ReturnsEmptyList(RECENT_ATTACHMENT_FUNCT, "getRecentAttachments");
+    }
+
+    private static final long DAY_SECONDS = 24 * 60 * 60;
+
+    /**
+     * A means of creating a number representing seconds from epoch where the
+     * lower the idx, the more recent the time.
+     */
+    private static final Function<Integer, Long> dateTimeRetriever = (idx) -> (365 - idx) * DAY_SECONDS + 1;
+
+    /**
+     * Gets a mock BlackboardArtifact.
+     *
+     * @param ds            The data source to which the artifact belongs.
+     * @param artifactId    The artifact id.
+     * @param artType       The artifact type.
+     * @param attributeArgs The mapping of attribute type to value for each
+     *                      attribute in the artifact.
+     *
+     * @return The mock artifact.
+     */
+    private BlackboardArtifact getArtifact(DataSource ds, long artifactId, ARTIFACT_TYPE artType, List<Pair<ATTRIBUTE_TYPE, Object>> attributeArgs) {
+        try {
+            List<BlackboardAttribute> attributes = attributeArgs.stream()
+                    .filter((arg) -> arg != null && arg.getLeft() != null && arg.getRight() != null)
+                    .map((arg) -> {
+                        return TskMockUtils.getAttribute(arg.getLeft(), arg.getRight());
+                    })
+                    .collect(Collectors.toList());
+
+            return TskMockUtils.getArtifact(new BlackboardArtifact.Type(artType), artifactId, ds, attributes);
+        } catch (TskCoreException ex) {
+            fail("There was an error mocking an artifact.");
+            return null;
+        }
+    }
+
+    /**
+     * Returns a mock artifact for getRecentlyOpenedDocuments.
+     *
+     * @param ds         The datasource for the artifact.
+     * @param artifactId The artifact id.
+     * @param dateTime   The time in seconds from epoch.
+     * @param path       The path for the document.
+     *
+     * @return The mock artifact with pertinent attributes.
+     */
+    private BlackboardArtifact getRecentDocumentArtifact(DataSource ds, long artifactId, Long dateTime, String path) {
+        return getArtifact(ds, artifactId, ARTIFACT_TYPE.TSK_RECENT_OBJECT, Arrays.asList(
+                Pair.of(ATTRIBUTE_TYPE.TSK_DATETIME, dateTime),
+                Pair.of(ATTRIBUTE_TYPE.TSK_PATH, path)
+        ));
+    }
+
+    @Test
+    public void getRecentlyOpenedDocuments_sortedByDateTimeAndLimited() throws SleuthkitCaseProviderException, TskCoreException {
+        Function<Integer, String> pathRetriever = (idx) -> "/path/to/downloads/" + idx;
+        DataSource dataSource = TskMockUtils.getDataSource(1);
+
+        int countRequest = 10;
+        for (int countToGenerate : new int[]{1, 9, 10, 11}) {
+            // generate artifacts for each artifact
+            List<BlackboardArtifact> artifacts = new ArrayList<>();
+            for (int idx = 0; idx < countToGenerate; idx++) {
+                BlackboardArtifact artifact = getRecentDocumentArtifact(dataSource,
+                        1000 + idx, dateTimeRetriever.apply(idx), pathRetriever.apply(idx));
+                artifacts.add(artifact);
+            }
+
+            // run through method
+            Pair<SleuthkitCase, Blackboard> casePair = DataSourceSummaryMockUtils.getArtifactsTSKMock(RandomizationUtils.getMixedUp(artifacts));
+            RecentFilesSummary summary = new RecentFilesSummary(() -> casePair.getLeft());
+            List<RecentFileDetails> results = summary.getRecentlyOpenedDocuments(dataSource, countRequest);
+
+            // verify results
+            int expectedCount = Math.min(countRequest, countToGenerate);
+            Assert.assertNotNull(results);
+            Assert.assertEquals(expectedCount, results.size());
+            for (int i = 0; i < expectedCount; i++) {
+                Assert.assertEquals(dateTimeRetriever.apply(i), results.get(i).getDateAsLong());
+                Assert.assertEquals(pathRetriever.apply(i), results.get(i).getPath());
+            }
+        }
+    }
+
+    @Test
+    public void getRecentlyOpenedDocuments_filtersMissingData() throws SleuthkitCaseProviderException, TskCoreException {
+        DataSource dataSource = TskMockUtils.getDataSource(1);
+
+        BlackboardArtifact successItem = getRecentDocumentArtifact(dataSource, 1001, DAY_SECONDS, "/a/path");
+        BlackboardArtifact nullTime = getRecentDocumentArtifact(dataSource, 1002, null, "/a/path2");
+        BlackboardArtifact zeroTime = getRecentDocumentArtifact(dataSource, 10021, 0L, "/a/path2a");      
+        List<BlackboardArtifact> artifacts = Arrays.asList(nullTime, zeroTime, successItem);
+
+        Pair<SleuthkitCase, Blackboard> casePair = DataSourceSummaryMockUtils.getArtifactsTSKMock(RandomizationUtils.getMixedUp(artifacts));
+        RecentFilesSummary summary = new RecentFilesSummary(() -> casePair.getLeft());
+        List<RecentFileDetails> results = summary.getRecentlyOpenedDocuments(dataSource, 10);
+
+        // verify results (only successItem)
+        Assert.assertNotNull(results);
+        Assert.assertEquals(1, results.size());
+        Assert.assertEquals((Long) DAY_SECONDS, results.get(0).getDateAsLong());
+        Assert.assertTrue("/a/path".equalsIgnoreCase(results.get(0).getPath()));
+    }
+
+    /**
+     * Creates a mock blackboard artifact for getRecentDownloads.
+     *
+     * @param ds         The datasource.
+     * @param artifactId The artifact id.
+     * @param dateTime   The time in seconds from epoch.
+     * @param domain     The domain.
+     * @param path       The path for the download.
+     *
+     * @return The mock artifact.
+     */
+    private BlackboardArtifact getRecentDownloadArtifact(DataSource ds, long artifactId, Long dateTime, String domain, String path) {
+        return getArtifact(ds, artifactId, ARTIFACT_TYPE.TSK_WEB_DOWNLOAD, Arrays.asList(
+                Pair.of(ATTRIBUTE_TYPE.TSK_DATETIME_ACCESSED, dateTime),
+                Pair.of(ATTRIBUTE_TYPE.TSK_DOMAIN, domain),
+                Pair.of(ATTRIBUTE_TYPE.TSK_PATH, path)
+        ));
+    }
+
+    @Test
+    public void getRecentDownloads_sortedByDateTimeAndLimited() throws SleuthkitCaseProviderException, TskCoreException {
+        Function<Integer, String> domainRetriever = (idx) -> String.format("www.domain%d.com", idx);
+        Function<Integer, String> pathRetriever = (idx) -> "/path/to/downloads/doc" + idx + ".pdf";
+
+        // run through method
+        DataSource dataSource = TskMockUtils.getDataSource(1);
+
+        int countRequest = 10;
+        for (int countToGenerate : new int[]{1, 9, 10, 11}) {
+            // generate artifacts for each artifact
+            List<BlackboardArtifact> artifacts = new ArrayList<>();
+            for (int idx = 0; idx < countToGenerate; idx++) {
+                BlackboardArtifact artifact = getRecentDownloadArtifact(dataSource,
+                        1000 + idx, dateTimeRetriever.apply(idx), domainRetriever.apply(idx),
+                        pathRetriever.apply(idx));
+
+                artifacts.add(artifact);
+            }
+
+            // call method
+            Pair<SleuthkitCase, Blackboard> casePair = DataSourceSummaryMockUtils.getArtifactsTSKMock(RandomizationUtils.getMixedUp(artifacts));
+            RecentFilesSummary summary = new RecentFilesSummary(() -> casePair.getLeft());
+            List<RecentDownloadDetails> results = summary.getRecentDownloads(dataSource, countRequest);
+
+            // verify results
+            int expectedCount = Math.min(countRequest, countToGenerate);
+            Assert.assertNotNull(results);
+            Assert.assertEquals(expectedCount, results.size());
+            for (int i = 0; i < expectedCount; i++) {
+                Assert.assertEquals(dateTimeRetriever.apply(i), results.get(i).getDateAsLong());
+                Assert.assertEquals(pathRetriever.apply(i), results.get(i).getPath());
+                Assert.assertEquals(domainRetriever.apply(i), results.get(i).getWebDomain());
+            }
+        }
+    }
+
+    @Test
+    public void getRecentDownloads_filtersMissingData() throws SleuthkitCaseProviderException, TskCoreException {
+        DataSource dataSource = TskMockUtils.getDataSource(1);
+
+        BlackboardArtifact successItem = getRecentDownloadArtifact(dataSource, 1001, DAY_SECONDS, "domain1.com", "/a/path1");
+        BlackboardArtifact nullTime = getRecentDownloadArtifact(dataSource, 1002, null, "domain2.com", "/a/path2");
+        BlackboardArtifact zeroTime = getRecentDownloadArtifact(dataSource, 10021, 0L, "domain2a.com", "/a/path2a");
+        List<BlackboardArtifact> artifacts = Arrays.asList(nullTime, zeroTime, successItem);
+
+        Pair<SleuthkitCase, Blackboard> casePair = DataSourceSummaryMockUtils.getArtifactsTSKMock(RandomizationUtils.getMixedUp(artifacts));
+        RecentFilesSummary summary = new RecentFilesSummary(() -> casePair.getLeft());
+
+        // call method
+        List<RecentDownloadDetails> results = summary.getRecentDownloads(dataSource, 10);
+
+        // verify results
+        Assert.assertNotNull(results);
+        Assert.assertEquals(1, results.size());
+        Assert.assertEquals((Long) DAY_SECONDS, results.get(0).getDateAsLong());
+        Assert.assertTrue("/a/path1".equalsIgnoreCase(results.get(0).getPath()));
+    }
+
+    /**
+     * getRecentAttachments method has special setup conditions. This class
+     * encapsulates all the SleuthkitCase/BlackboardArtifact setup for on
+     * possible return item.
+     */
+    private class AttachmentArtifactItem {
+
+        private final Integer messageArtifactTypeId;
+        private final boolean associatedAttrFormed;
+        private final String emailFrom;
+        private final Long messageTime;
+        private final boolean isParent;
+        private final String fileParentPath;
+        private final String fileName;
+
+        /**
+         * Constructor with all parameters.
+         *
+         * @param messageArtifactTypeId The type id for the artifact or null if
+         *                              no message artifact to be created.
+         * @param emailFrom             Who the message is from or null not to
+         *                              include attribute.
+         * @param messageTime           Time in seconds from epoch or null not
+         *                              to include attribute.
+         * @param fileParentPath        The parent AbstractFile's path value.
+         * @param fileName              The parent AbstractFile's filename
+         *                              value.
+         * @param associatedAttrFormed  If false, the TSK_ASSOCIATED_OBJECT
+         *                              artifact has no attribute (even though
+         *                              it is required).
+         * @param hasParent             Whether or not the artifact has a parent
+         *                              AbstractFile.
+         */
+        AttachmentArtifactItem(Integer messageArtifactTypeId, String emailFrom, Long messageTime,
+                String fileParentPath, String fileName,
+                boolean associatedAttrFormed, boolean hasParent) {
+
+            this.messageArtifactTypeId = messageArtifactTypeId;
+            this.associatedAttrFormed = associatedAttrFormed;
+            this.emailFrom = emailFrom;
+            this.messageTime = messageTime;
+            this.isParent = hasParent;
+            this.fileParentPath = fileParentPath;
+            this.fileName = fileName;
+        }
+
+        /**
+         * Convenience constructor where defaults of required attributes and
+         * SleuthkitCase assumed.
+         *
+         * @param messageArtifactTypeId The type id for the artifact or null if
+         *                              no message artifact to be created.
+         * @param emailFrom             Who the message is from or null not to
+         *                              include attribute.
+         * @param messageTime           Time in seconds from epoch or null not
+         *                              to include attribute.
+         * @param fileParentPath        The parent AbstractFile's path value.
+         * @param fileName              The parent AbstractFile's filename
+         *                              value.
+         */
+        AttachmentArtifactItem(Integer messageArtifactTypeId, String emailFrom, Long messageTime, String fileParentPath, String fileName) {
+            this(messageArtifactTypeId, emailFrom, messageTime, fileParentPath, fileName, true, true);
+        }
+
+        boolean isAssociatedAttrFormed() {
+            return associatedAttrFormed;
+        }
+
+        String getEmailFrom() {
+            return emailFrom;
+        }
+
+        Long getMessageTime() {
+            return messageTime;
+        }
+
+        boolean hasParent() {
+            return isParent;
+        }
+
+        String getFileParentPath() {
+            return fileParentPath;
+        }
+
+        String getFileName() {
+            return fileName;
+        }
+
+        Integer getMessageArtifactTypeId() {
+            return messageArtifactTypeId;
+        }
+    }
+
+    /**
+     * Sets up the associated artifact message for the TSK_ASSOCIATED_OBJECT.
+     *
+     * @param artifacts    The mapping of artifact id to artifact.
+     * @param item         The record to setup.
+     * @param dataSource   The datasource.
+     * @param associatedId The associated attribute id.
+     * @param artifactId   The artifact id.
+     *
+     * @return The associated Artifact blackboard attribute.
+     *
+     * @throws TskCoreException
+     */
+    private BlackboardAttribute setupAssociatedMessage(Map<Long, BlackboardArtifact> artifacts, AttachmentArtifactItem item,
+            DataSource dataSource, Long associatedId, Long artifactId) throws TskCoreException {
+
+        BlackboardAttribute associatedAttr = TskMockUtils.getAttribute(ATTRIBUTE_TYPE.TSK_ASSOCIATED_ARTIFACT, associatedId);
+
+        if (item.getMessageArtifactTypeId() == null) {
+            return associatedAttr;
+        }
+        
+        // find the artifact type or null if not found
+        ARTIFACT_TYPE messageType = Stream.of(ARTIFACT_TYPE.values())
+                .filter((artType) -> artType.getTypeID() == item.getMessageArtifactTypeId())
+                .findFirst()
+                .orElse(null);
+
+        // if there is a message type, create the artifact
+        if (messageType != null) {
+            List<BlackboardAttribute> attributes = new ArrayList<>();
+            if (item.getEmailFrom() != null) {
+                attributes.add(TskMockUtils.getAttribute(ATTRIBUTE_TYPE.TSK_EMAIL_FROM, item.getEmailFrom()));
+            }
+
+            if (item.getMessageTime() != null) {
+                attributes.add(TskMockUtils.getAttribute(ATTRIBUTE_TYPE.TSK_DATETIME_SENT, item.getMessageTime()));
+            }
+
+            artifacts.put(associatedId, TskMockUtils.getArtifact(
+                    new BlackboardArtifact.Type(messageType), artifactId, dataSource, attributes));
+        }
+        return associatedAttr;
+    }
+
+    /**
+     * Since getRecentAttachments does not simply query one type of artifact and
+     * return results, this method sets up a mock SleuthkitCase and Blackboard
+     * to return pertinent data.
+     *
+     * @param items Each attachment item where each item could represent a
+     *              return result if fully formed.
+     *
+     * @return The mock SleuthkitCase and Blackboard.
+     */
+    private Pair<SleuthkitCase, Blackboard> getRecentAttachmentArtifactCase(List<AttachmentArtifactItem> items) {
+        SleuthkitCase skCase = mock(SleuthkitCase.class);
+        Blackboard blackboard = mock(Blackboard.class);
+        when(skCase.getBlackboard()).thenReturn(blackboard);
+
+        DataSource dataSource = TskMockUtils.getDataSource(1);
+
+        long objIdCounter = 100;
+        Map<Long, BlackboardArtifact> artifacts = new HashMap<>();
+        try {
+            for (AttachmentArtifactItem item : items) {
+                BlackboardAttribute associatedAttr = null;
+                // if the associated attribute is fully formed, 
+                // create the associated attribute and related artifact
+                if (item.isAssociatedAttrFormed()) {
+                    associatedAttr = setupAssociatedMessage(artifacts, item, dataSource, ++objIdCounter, ++objIdCounter);
+                }
+
+                // create the content parent for the associated object if one should be present
+                Content parent = (item.hasParent())
+                        ? TskMockUtils.getAbstractFile(++objIdCounter, item.getFileParentPath(), item.getFileName())
+                        : null;
+
+                Long associatedId = ++objIdCounter;
+                artifacts.put(associatedId, TskMockUtils.getArtifact(
+                        new BlackboardArtifact.Type(ARTIFACT_TYPE.TSK_ASSOCIATED_OBJECT),
+                        parent, associatedId, dataSource, associatedAttr));
+            }
+
+            // set up the blackboard to return artifacts that match the type id.
+            when(blackboard.getArtifacts(anyInt(), anyLong())).thenAnswer((inv) -> {
+                Object[] args = inv.getArguments();
+                int artifactType = (Integer) args[0];
+                return artifacts.values().stream()
+                        .filter(art -> art.getArtifactTypeID() == artifactType)
+                        .collect(Collectors.toList());
+            });
+
+            // also set up the sleuthkitcase to return the artifact with the matching id or null.
+            when(skCase.getBlackboardArtifact(anyLong())).thenAnswer((inv2) -> {
+                Object[] args2 = inv2.getArguments();
+                long id = (Long) args2[0];
+                return artifacts.get(id);
+            });
+
+            return Pair.of(skCase, blackboard);
+        } catch (TskCoreException ex) {
+            fail("There was an error while creating SleuthkitCase for getRecentAttachments");
+            return null;
+        }
+    }
+
+    @Test
+    public void getRecentAttachments_sortedByDateTimeAndLimited() throws SleuthkitCaseProviderException, TskCoreException {
+        DataSource dataSource = TskMockUtils.getDataSource(1);
+        // a deterministic means of transforming an index into a particular attribute type so that they can be created 
+        // and compared on return
+        Function<Integer, String> emailFromRetriever = (idx) -> String.format("person%d@basistech.com", idx);
+        Function<Integer, String> pathRetriever = (idx) -> "/path/to/attachment/" + idx;
+        Function<Integer, String> fileNameRetriever = (idx) -> String.format("%d-filename.png", idx);
+
+        int countRequest = 10;
+        for (int countToGenerate : new int[]{1, 9, 10, 11}) {
+            // set up the items in the sleuthkit case
+            List<AttachmentArtifactItem> items = IntStream.range(0, countToGenerate)
+                    .mapToObj((idx) -> new AttachmentArtifactItem(ARTIFACT_TYPE.TSK_MESSAGE.getTypeID(),
+                    emailFromRetriever.apply(idx), dateTimeRetriever.apply(idx),
+                    pathRetriever.apply(idx), fileNameRetriever.apply(idx)))
+                    .collect(Collectors.toList());
+
+            List<AttachmentArtifactItem> mixedUpItems = RandomizationUtils.getMixedUp(items);
+            Pair<SleuthkitCase, Blackboard> casePair = getRecentAttachmentArtifactCase(mixedUpItems);
+            RecentFilesSummary summary = new RecentFilesSummary(() -> casePair.getLeft());
+
+            // retrieve results
+            List<RecentAttachmentDetails> results = summary.getRecentAttachments(dataSource, countRequest);
+
+            // verify results
+            int expectedCount = Math.min(countRequest, countToGenerate);
+            Assert.assertNotNull(results);
+            Assert.assertEquals(expectedCount, results.size());
+
+            for (int i = 0; i < expectedCount; i++) {
+                RecentAttachmentDetails result = results.get(i);
+                Assert.assertEquals(dateTimeRetriever.apply(i), result.getDateAsLong());
+                Assert.assertTrue(emailFromRetriever.apply(i).equalsIgnoreCase(result.getSender()));
+                Assert.assertTrue(Paths.get(pathRetriever.apply(i), fileNameRetriever.apply(i)).toString()
+                        .equalsIgnoreCase(result.getPath()));
+            }
+        }
+    }
+
+    @Test
+    public void getRecentAttachments_filterData() throws SleuthkitCaseProviderException, TskCoreException {
+        // setup data
+        DataSource dataSource = TskMockUtils.getDataSource(1);
+
+        AttachmentArtifactItem successItem = new AttachmentArtifactItem(ARTIFACT_TYPE.TSK_EMAIL_MSG.getTypeID(),
+                "person@sleuthkit.com", DAY_SECONDS, "/parent/path", "msg.pdf");
+        AttachmentArtifactItem successItem2 = new AttachmentArtifactItem(ARTIFACT_TYPE.TSK_MESSAGE.getTypeID(),
+                "person_on_skype", DAY_SECONDS + 1, "/parent/path/to/skype", "skype.png");
+        AttachmentArtifactItem wrongArtType = new AttachmentArtifactItem(ARTIFACT_TYPE.TSK_CALLLOG.getTypeID(),
+                "5555675309", DAY_SECONDS + 2, "/path/to/callog/info", "callog.dat");
+        AttachmentArtifactItem missingTimeStamp = new AttachmentArtifactItem(ARTIFACT_TYPE.TSK_EMAIL_MSG.getTypeID(),
+                "person2@sleuthkit.com", null, "/parent/path", "msg2.pdf");
+        AttachmentArtifactItem zeroTimeStamp = new AttachmentArtifactItem(ARTIFACT_TYPE.TSK_EMAIL_MSG.getTypeID(),
+                "person2a@sleuthkit.com", 0L, "/parent/path", "msg2a.png");
+        AttachmentArtifactItem noParentFile = new AttachmentArtifactItem(ARTIFACT_TYPE.TSK_EMAIL_MSG.getTypeID(),
+                "person4@sleuthkit.com", DAY_SECONDS + 4, "/parent/path", "msg4.jpg", true, false);
+        AttachmentArtifactItem noAssocAttr = new AttachmentArtifactItem(ARTIFACT_TYPE.TSK_EMAIL_MSG.getTypeID(),
+                "person3@sleuthkit.com", DAY_SECONDS + 5, "/parent/path", "msg5.gif", false, true);
+        AttachmentArtifactItem missingAssocArt = new AttachmentArtifactItem(null,
+                "person3@sleuthkit.com", DAY_SECONDS + 6, "/parent/path", "msg6.pdf");
+
+        List<AttachmentArtifactItem> items = Arrays.asList(successItem, successItem2,
+                wrongArtType, missingTimeStamp, zeroTimeStamp,
+                noParentFile, noAssocAttr, missingAssocArt);
+
+        Pair<SleuthkitCase, Blackboard> casePair = getRecentAttachmentArtifactCase(items);
+        RecentFilesSummary summary = new RecentFilesSummary(() -> casePair.getLeft());
+
+        // get data
+        List<RecentAttachmentDetails> results = summary.getRecentAttachments(dataSource, 10);
+
+        // verify results
+        Assert.assertNotNull(results);
+        Assert.assertEquals(2, results.size());
+        RecentAttachmentDetails successItem2Details = results.get(0);
+        RecentAttachmentDetails successItemDetails = results.get(1);
+
+        Assert.assertEquals(successItemDetails.getDateAsLong(), (Long) DAY_SECONDS);
+        Assert.assertTrue(Paths.get(successItem.getFileParentPath(), successItem.getFileName())
+                .toString().equalsIgnoreCase(successItemDetails.getPath()));
+        Assert.assertTrue(successItem.getEmailFrom().equalsIgnoreCase(successItemDetails.getSender()));
+
+        Assert.assertEquals(successItem2Details.getDateAsLong(), (Long) (DAY_SECONDS + 1));
+        Assert.assertTrue(Paths.get(successItem2.getFileParentPath(), successItem2.getFileName())
+                .toString().equalsIgnoreCase(successItem2Details.getPath()));
+        Assert.assertTrue(successItem2.getEmailFrom().equalsIgnoreCase(successItem2Details.getSender()));
+    }
+}
diff --git a/Core/test/unit/src/org/sleuthkit/autopsy/datasourcesummary/datamodel/UserActivitySummaryTest.java b/Core/test/unit/src/org/sleuthkit/autopsy/datasourcesummary/datamodel/UserActivitySummaryTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..0aff8a56b5b2c8c3d41ec828ab757c8e97933166
--- /dev/null
+++ b/Core/test/unit/src/org/sleuthkit/autopsy/datasourcesummary/datamodel/UserActivitySummaryTest.java
@@ -0,0 +1,1243 @@
+/*
+ * Autopsy Forensic Browser
+ *
+ * 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.autopsy.datasourcesummary.datamodel;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+import java.util.stream.IntStream;
+import org.apache.commons.lang.StringUtils;
+import org.apache.commons.lang3.tuple.Pair;
+import org.junit.Assert;
+import static org.junit.Assert.fail;
+import org.junit.Test;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import static org.sleuthkit.autopsy.datasourcesummary.datamodel.DataSourceSummaryMockUtils.getArtifactsTSKMock;
+import org.sleuthkit.autopsy.datasourcesummary.datamodel.SleuthkitCaseProvider.SleuthkitCaseProviderException;
+import org.sleuthkit.autopsy.datasourcesummary.datamodel.UserActivitySummary.TopAccountResult;
+import org.sleuthkit.autopsy.datasourcesummary.datamodel.UserActivitySummary.TopDeviceAttachedResult;
+import org.sleuthkit.autopsy.datasourcesummary.datamodel.UserActivitySummary.TopDomainsResult;
+import org.sleuthkit.autopsy.datasourcesummary.datamodel.UserActivitySummary.TopProgramsResult;
+import org.sleuthkit.autopsy.datasourcesummary.datamodel.UserActivitySummary.TopWebSearchResult;
+import org.sleuthkit.autopsy.testutils.TskMockUtils;
+import org.sleuthkit.autopsy.texttranslation.NoServiceProviderException;
+import org.sleuthkit.autopsy.texttranslation.TextTranslationService;
+import org.sleuthkit.autopsy.texttranslation.TranslationException;
+import org.sleuthkit.datamodel.Blackboard;
+import org.sleuthkit.datamodel.BlackboardArtifact;
+import org.sleuthkit.datamodel.BlackboardArtifact.ARTIFACT_TYPE;
+import org.sleuthkit.datamodel.BlackboardAttribute;
+import org.sleuthkit.datamodel.BlackboardAttribute.ATTRIBUTE_TYPE;
+import org.sleuthkit.datamodel.DataSource;
+import org.sleuthkit.datamodel.SleuthkitCase;
+import org.sleuthkit.datamodel.TskCoreException;
+
+/**
+ * Tests for UserActivitySummary.
+ */
+public class UserActivitySummaryTest {
+    /**
+     *  Function to retrieve data from UserActivitySummary with the provided arguments.
+     */
+    private interface DataFunction<T> {
+        /**
+         * A UserActivitySummary method encapsulated in a uniform manner.
+         * @param userActivitySummary The UserActivitySummary class to use.
+         * @param datasource The data source.
+         * @param count The count.
+         * @return The list of objects to return.
+         * @throws SleuthkitCaseProviderException
+         * @throws TskCoreException 
+         */
+        List<T> retrieve(UserActivitySummary userActivitySummary, DataSource datasource, int count) throws
+                SleuthkitCaseProviderException, TskCoreException;
+    }
+
+    private static final DataFunction<TopWebSearchResult> WEB_SEARCH_QUERY
+            = (userActivity, dataSource, count) -> userActivity.getMostRecentWebSearches(dataSource, count);
+
+    private static final DataFunction<TopAccountResult> ACCOUNT_QUERY
+            = (userActivity, dataSource, count) -> userActivity.getRecentAccounts(dataSource, count);
+
+    private static final DataFunction<TopDomainsResult> DOMAINS_QUERY
+            = (userActivity, dataSource, count) -> userActivity.getRecentDomains(dataSource, count);
+
+    private static final DataFunction<TopDeviceAttachedResult> DEVICE_QUERY
+            = (userActivity, dataSource, count) -> userActivity.getRecentDevices(dataSource, count);
+
+    private static final DataFunction<TopProgramsResult> PROGRAMS_QUERY
+            = (userActivity, dataSource, count) -> userActivity.getTopPrograms(dataSource, count);
+
+    private static final Map<String, DataFunction<?>> USER_ACTIVITY_METHODS = new HashMap<String, DataFunction<?>>() {
+        {
+            put("getMostRecentWebSearches", WEB_SEARCH_QUERY);
+            put("getRecentAccounts", ACCOUNT_QUERY);
+            put("getRecentDomains", DOMAINS_QUERY);
+            put("getRecentDevices", DEVICE_QUERY);
+            put("getTopPrograms", PROGRAMS_QUERY);
+        }
+    };
+
+    private static final long DAY_SECONDS = 24 * 60 * 60;
+
+    private static void verifyCalled(Blackboard mockBlackboard, int artifactType, long datasourceId, String failureMessage) throws TskCoreException {
+        verify(mockBlackboard, times(1).description(failureMessage)).getArtifacts(artifactType, datasourceId);
+    }
+
+    /**
+     * Gets a UserActivitySummary class to test.
+     *
+     * @param tskCase           The SleuthkitCase.
+     * @param hasTranslation    Whether the translation service is functional.
+     * @param translateFunction Function for translation.
+     *
+     * @return The UserActivitySummary class to use for testing.
+     *
+     * @throws NoServiceProviderException
+     * @throws TranslationException
+     */
+    private static UserActivitySummary getTestClass(SleuthkitCase tskCase, boolean hasTranslation, Function<String, String> translateFunction)
+            throws NoServiceProviderException, TranslationException {
+
+        return new UserActivitySummary(
+                () -> tskCase,
+                TskMockUtils.getTextTranslationService(translateFunction, hasTranslation),
+                TskMockUtils.getJavaLogger("UNIT TEST LOGGER")
+        );
+    }
+
+    private <T> void testMinCount(DataFunction<T> funct, String id)
+            throws TskCoreException, NoServiceProviderException, TranslationException, SleuthkitCaseProviderException {
+
+        for (int count : new int[]{0, -1}) {
+            Pair<SleuthkitCase, Blackboard> tskPair = getArtifactsTSKMock(null);
+            UserActivitySummary summary = getTestClass(tskPair.getLeft(), false, null);
+
+            try {
+                funct.retrieve(summary, TskMockUtils.getDataSource(1), -1);
+            } catch (IllegalArgumentException ignored) {
+                // this exception is expected so continue if getArtifacts never called
+                verify(tskPair.getRight(), never().description(
+                        String.format("Expected %s would not call getArtifacts for count %d", id, count)))
+                        .getArtifacts(anyInt(), anyLong());
+
+                continue;
+            }
+            fail(String.format("Expected an Illegal argument exception to be thrown in method %s with count of %d", id, count));
+        }
+    }
+
+    /**
+     * Ensures that passing a non-positive count causes
+     * IllegalArgumentException.
+     *
+     * @throws TskCoreException
+     * @throws NoServiceProviderException
+     * @throws TranslationException
+     * @throws SleuthkitCaseProviderException
+     */
+    @Test
+    public void testMinCountInvariant()
+            throws TskCoreException, NoServiceProviderException, TranslationException, SleuthkitCaseProviderException {
+
+        for (Entry<String, DataFunction<?>> query : USER_ACTIVITY_METHODS.entrySet()) {
+            testMinCount(query.getValue(), query.getKey());
+        }
+    }
+
+    private <T> void testNullDataSource(DataFunction<T> funct, String id)
+            throws TskCoreException, NoServiceProviderException, TranslationException, SleuthkitCaseProviderException {
+
+        Pair<SleuthkitCase, Blackboard> tskPair = getArtifactsTSKMock(null);
+        UserActivitySummary summary = getTestClass(tskPair.getLeft(), false, null);
+        List<T> retArr = funct.retrieve(summary, null, 10);
+        verify(tskPair.getRight(), never()
+                .description(String.format("Expected method %s to return empty list for null data source and not call SleuthkitCase", id)))
+                .getArtifacts(anyInt(), anyLong());
+
+        String errorMessage = String.format("Expected %s would return empty list for null data source", id);
+        Assert.assertTrue(errorMessage, retArr != null);
+        Assert.assertTrue(errorMessage, retArr.isEmpty());
+    }
+
+    /**
+     * If datasource is null, all methods return an empty list.
+     *
+     * @throws TskCoreException
+     * @throws NoServiceProviderException
+     * @throws TranslationException
+     * @throws SleuthkitCaseProviderException
+     */
+    @Test
+    public void testNullDataSource()
+            throws TskCoreException, NoServiceProviderException, TranslationException, SleuthkitCaseProviderException {
+
+        for (Entry<String, DataFunction<?>> query : USER_ACTIVITY_METHODS.entrySet()) {
+            testNullDataSource(query.getValue(), query.getKey());
+        }
+    }
+
+    private <T> void testNoResultsReturned(DataFunction<T> funct, String id)
+            throws TskCoreException, NoServiceProviderException, TranslationException, SleuthkitCaseProviderException {
+        long dataSourceId = 1;
+        int count = 10;
+        Pair<SleuthkitCase, Blackboard> tskPair = getArtifactsTSKMock(new ArrayList<>());
+        UserActivitySummary summary = getTestClass(tskPair.getLeft(), false, null);
+        List<T> retArr = funct.retrieve(summary, TskMockUtils.getDataSource(dataSourceId), count);
+
+        Assert.assertTrue(String.format("Expected non null empty list returned from %s", id), retArr != null);
+        Assert.assertTrue(String.format("Expected non null empty list returned from %s", id), retArr.isEmpty());
+    }
+
+    /**
+     * If no artifacts in SleuthkitCase, all data returning methods return an
+     * empty list.
+     *
+     * @throws TskCoreException
+     * @throws NoServiceProviderException
+     * @throws TranslationException
+     * @throws SleuthkitCaseProviderException
+     */
+    @Test
+    public void testNoResultsReturned()
+            throws TskCoreException, NoServiceProviderException, TranslationException, SleuthkitCaseProviderException {
+
+        for (Entry<String, DataFunction<?>> query : USER_ACTIVITY_METHODS.entrySet()) {
+            testNoResultsReturned(query.getValue(), query.getKey());
+        }
+    }
+
+    private static List<String> EXCLUDED_DEVICES = Arrays.asList("ROOT_HUB", "ROOT_HUB20");
+
+    private static BlackboardArtifact getRecentDeviceArtifact(long artifactId, DataSource dataSource,
+            String deviceId, String deviceMake, String deviceModel, Long date) {
+
+        try {
+            return TskMockUtils.getArtifact(new BlackboardArtifact.Type(ARTIFACT_TYPE.TSK_DEVICE_ATTACHED), artifactId, dataSource,
+                    TskMockUtils.getAttribute(ATTRIBUTE_TYPE.TSK_DEVICE_ID, deviceId),
+                    TskMockUtils.getAttribute(ATTRIBUTE_TYPE.TSK_DATETIME, date),
+                    TskMockUtils.getAttribute(ATTRIBUTE_TYPE.TSK_DEVICE_MAKE, deviceMake),
+                    TskMockUtils.getAttribute(ATTRIBUTE_TYPE.TSK_DEVICE_MODEL, deviceModel)
+            );
+        } catch (TskCoreException e) {
+            fail("Something went wrong while mocking");
+            return null;
+        }
+    }
+
+    /**
+     * Tests that UserActivitySummary.getRecentDevices removes things like
+     * ROOT_HUB. See EXCLUDED_DEVICES for excluded items.
+     *
+     * @throws TskCoreException
+     * @throws NoServiceProviderException
+     * @throws SleuthkitCaseProviderException
+     * @throws TranslationException
+     */
+    @Test
+    public void getRecentDevices_appropriateFiltering() throws TskCoreException, NoServiceProviderException,
+            SleuthkitCaseProviderException, TranslationException {
+
+        long dataSourceId = 1;
+        int count = 10;
+        long time = DAY_SECONDS * 42;
+        String acceptedDevice = "ACCEPTED DEVICE";
+
+        DataSource ds = TskMockUtils.getDataSource(dataSourceId);
+
+        List<String> allKeys = new ArrayList<>(EXCLUDED_DEVICES);
+        allKeys.add(acceptedDevice);
+
+        List<BlackboardArtifact> artifacts = IntStream.range(0, allKeys.size())
+                .mapToObj((idx) -> {
+                    String key = allKeys.get(idx);
+                    return getRecentDeviceArtifact(1000L + idx, ds, "ID " + key, "MAKE " + key, key, time);
+                })
+                .collect(Collectors.toList());
+
+        Pair<SleuthkitCase, Blackboard> tskPair = getArtifactsTSKMock(artifacts);
+        UserActivitySummary summary = getTestClass(tskPair.getLeft(), false, null);
+
+        List<TopDeviceAttachedResult> results = summary.getRecentDevices(ds, count);
+
+        verifyCalled(tskPair.getRight(), ARTIFACT_TYPE.TSK_DEVICE_ATTACHED.getTypeID(), dataSourceId,
+                "Expected getRecentDevices to call getArtifacts with correct arguments.");
+        Assert.assertEquals(1, results.size());
+        Assert.assertEquals(acceptedDevice, results.get(0).getDeviceModel());
+        Assert.assertEquals("MAKE " + acceptedDevice, results.get(0).getDeviceMake());
+        Assert.assertEquals("ID " + acceptedDevice, results.get(0).getDeviceId());
+        Assert.assertEquals(time, results.get(0).getDateAccessed().getTime() / 1000);
+    }
+
+    /**
+     * Ensures that UserActivitySummary.getRecentDevices limits returned entries
+     * to count provided.
+     *
+     * @throws TskCoreException
+     * @throws NoServiceProviderException
+     * @throws SleuthkitCaseProviderException
+     * @throws TskCoreException
+     * @throws TranslationException
+     */
+    @Test
+    public void getRecentDevices_limitedToCount()
+            throws TskCoreException, NoServiceProviderException, SleuthkitCaseProviderException, TskCoreException, TranslationException {
+
+        int countRequested = 10;
+        for (int returnedCount : new int[]{1, 9, 10, 11}) {
+            long dataSourceId = 1L;
+            DataSource dataSource = TskMockUtils.getDataSource(dataSourceId);
+
+            List<BlackboardArtifact> returnedArtifacts = IntStream.range(0, returnedCount)
+                    .mapToObj((idx) -> getRecentDeviceArtifact(1000 + idx, dataSource, "ID" + idx, "MAKE" + idx, "MODEL" + idx, DAY_SECONDS * idx))
+                    .collect(Collectors.toList());
+
+            Pair<SleuthkitCase, Blackboard> tskPair = getArtifactsTSKMock(returnedArtifacts);
+            UserActivitySummary summary = getTestClass(tskPair.getLeft(), false, null);
+
+            List<TopDeviceAttachedResult> results = summary.getRecentDevices(dataSource, countRequested);
+            verifyCalled(tskPair.getRight(), ARTIFACT_TYPE.TSK_DEVICE_ATTACHED.getTypeID(), dataSourceId,
+                    "Expected getRecentDevices to call getArtifacts with correct arguments.");
+
+            Assert.assertEquals(Math.min(countRequested, returnedCount), results.size());
+        }
+    }
+
+    private static BlackboardArtifact getWebSearchArtifact(long artifactId, DataSource dataSource, String query, Long date) {
+        try {
+            return TskMockUtils.getArtifact(new BlackboardArtifact.Type(ARTIFACT_TYPE.TSK_WEB_SEARCH_QUERY), artifactId, dataSource,
+                    TskMockUtils.getAttribute(ATTRIBUTE_TYPE.TSK_TEXT, query),
+                    TskMockUtils.getAttribute(ATTRIBUTE_TYPE.TSK_DATETIME_ACCESSED, date)
+            );
+        } catch (TskCoreException e) {
+            fail("Something went wrong while mocking");
+            return null;
+        }
+    }
+
+    /**
+     * Ensures that UserActivitySummary.getMostRecentWebSearches groups
+     * artifacts appropriately (i.e. queries with the same name).
+     *
+     * @throws TskCoreException
+     * @throws NoServiceProviderException
+     * @throws TranslationException
+     * @throws SleuthkitCaseProviderException
+     */
+    @Test
+    public void getMostRecentWebSearches_grouping() throws TskCoreException, NoServiceProviderException, TranslationException, SleuthkitCaseProviderException {
+        long dataSourceId = 1;
+        DataSource ds = TskMockUtils.getDataSource(dataSourceId);
+
+        String query1 = "This is Query 1";
+        String query2 = "This is Query 2";
+        BlackboardArtifact art1a = getWebSearchArtifact(1001, ds, query1, DAY_SECONDS * 1);
+        BlackboardArtifact art2a = getWebSearchArtifact(1002, ds, query2, DAY_SECONDS * 2);
+        BlackboardArtifact art2b = getWebSearchArtifact(1003, ds, query2.toUpperCase(), DAY_SECONDS * 3);
+        BlackboardArtifact art1b = getWebSearchArtifact(1004, ds, query1.toUpperCase(), DAY_SECONDS * 4);
+        BlackboardArtifact art1c = getWebSearchArtifact(1005, ds, query1.toLowerCase(), DAY_SECONDS * 5);
+
+        List<BlackboardArtifact> artList = Arrays.asList(art1a, art2a, art2b, art1b, art1c);
+
+        Pair<SleuthkitCase, Blackboard> tskPair = getArtifactsTSKMock(artList);
+        UserActivitySummary summary = getTestClass(tskPair.getLeft(), false, null);
+        List<TopWebSearchResult> results = summary.getMostRecentWebSearches(ds, 10);
+        verifyCalled(tskPair.getRight(), ARTIFACT_TYPE.TSK_WEB_SEARCH_QUERY.getTypeID(), dataSourceId,
+                "Expected getRecentDevices to call getArtifacts with correct arguments.");
+
+        Assert.assertEquals("Expected two different search queries", 2, results.size());
+        Assert.assertTrue(query1.equalsIgnoreCase(results.get(0).getSearchString()));
+        Assert.assertEquals(DAY_SECONDS * 5, results.get(0).getDateAccessed().getTime() / 1000);
+        Assert.assertTrue(query2.equalsIgnoreCase(results.get(1).getSearchString()));
+        Assert.assertEquals(DAY_SECONDS * 3, results.get(1).getDateAccessed().getTime() / 1000);
+    }
+
+    private void webSearchTranslationTest(List<String> queries, boolean hasProvider, String translationSuffix)
+            throws SleuthkitCaseProviderException, TskCoreException, NoServiceProviderException, TranslationException {
+
+        long dataSourceId = 1;
+        DataSource ds = TskMockUtils.getDataSource(dataSourceId);
+
+        // create artifacts for each query where first query in the list will have most recent time.
+        List<BlackboardArtifact> artList = IntStream.range(0, queries.size())
+                .mapToObj((idx) -> getWebSearchArtifact(1000 + idx, ds, queries.get(idx), DAY_SECONDS * (queries.size() - idx)))
+                .collect(Collectors.toList());
+
+        Pair<SleuthkitCase, Blackboard> tskPair = getArtifactsTSKMock(artList);
+
+        // return name with suffix if original exists and suffix is not null.
+        Function<String, String> translator = (orig) -> {
+            if (orig == null || translationSuffix == null) {
+                return null;
+            } else {
+                return orig + translationSuffix;
+            }
+        };
+
+        // set up a mock TextTranslationService returning a translation
+        TextTranslationService translationService = TskMockUtils.getTextTranslationService(translator, hasProvider);
+
+        UserActivitySummary summary = new UserActivitySummary(
+                () -> tskPair.getLeft(),
+                translationService,
+                TskMockUtils.getJavaLogger("UNIT TEST LOGGER")
+        );
+
+        List<TopWebSearchResult> results = summary.getMostRecentWebSearches(ds, queries.size());
+
+        // verify translation service only called if hasProvider
+        if (hasProvider) {
+            verify(translationService,
+                    times(queries.size()).description("Expected translation to be called for each query"))
+                    .translate(anyString());
+        } else {
+            verify(translationService,
+                    never().description("Expected translation not to be called because no provider"))
+                    .translate(anyString());
+        }
+
+        Assert.assertEquals(queries.size(), results.size());
+
+        // verify the translation if there should be one
+        for (int i = 0; i < queries.size(); i++) {
+            String query = queries.get(i);
+            TopWebSearchResult result = results.get(i);
+
+            Assert.assertTrue(query.equalsIgnoreCase(result.getSearchString()));
+            if (hasProvider) {
+                if (StringUtils.isBlank(translationSuffix)) {
+                    Assert.assertNull(result.getTranslatedResult());
+                } else {
+                    Assert.assertTrue((query + translationSuffix).equalsIgnoreCase(result.getTranslatedResult()));
+                }
+            } else {
+                Assert.assertNull(result.getTranslatedResult());
+            }
+        }
+    }
+
+    /**
+     * Verify that UserActivitySummary.getMostRecentWebSearches handles
+     * translation appropriately.
+     *
+     * @throws SleuthkitCaseProviderException
+     * @throws TskCoreException
+     * @throws NoServiceProviderException
+     * @throws TranslationException
+     */
+    @Test
+    public void getMostRecentWebSearches_handlesTranslation()
+            throws SleuthkitCaseProviderException, TskCoreException, NoServiceProviderException, TranslationException {
+
+        List<String> queryList = Arrays.asList("query1", "query2", "query3");
+        String translationSuffix = " [TRANSLATED]";
+        // if no provider.
+        webSearchTranslationTest(queryList, false, translationSuffix);
+
+        // if no translation.
+        webSearchTranslationTest(queryList, true, null);
+
+        // if translation is the same (translation suffix doesn't change the trimmed string value)
+        webSearchTranslationTest(queryList, true, "");
+        webSearchTranslationTest(queryList, true, "    ");
+
+        // if there is an actual translation
+        webSearchTranslationTest(queryList, true, translationSuffix);
+    }
+
+    /**
+     * Ensure that UserActivitySummary.getMostRecentWebSearches results limited
+     * to count.
+     *
+     * @throws TskCoreException
+     * @throws NoServiceProviderException
+     * @throws SleuthkitCaseProviderException
+     * @throws TskCoreException
+     * @throws TranslationException
+     */
+    @Test
+    public void getMostRecentWebSearches_limitedToCount()
+            throws TskCoreException, NoServiceProviderException, SleuthkitCaseProviderException, TskCoreException, TranslationException {
+
+        int countRequested = 10;
+        for (int returnedCount : new int[]{1, 9, 10, 11}) {
+            long dataSourceId = 1L;
+            DataSource dataSource = TskMockUtils.getDataSource(dataSourceId);
+
+            List<BlackboardArtifact> returnedArtifacts = IntStream.range(0, returnedCount)
+                    .mapToObj((idx) -> getWebSearchArtifact(1000 + idx, dataSource, "Query" + idx, DAY_SECONDS * idx + 1))
+                    .collect(Collectors.toList());
+
+            Pair<SleuthkitCase, Blackboard> tskPair = getArtifactsTSKMock(returnedArtifacts);
+            UserActivitySummary summary = getTestClass(tskPair.getLeft(), false, null);
+
+            List<TopWebSearchResult> results = summary.getMostRecentWebSearches(dataSource, countRequested);
+            verifyCalled(tskPair.getRight(), ARTIFACT_TYPE.TSK_WEB_SEARCH_QUERY.getTypeID(), dataSourceId,
+                    "Expected getRecentDevices to call getArtifacts with correct arguments.");
+
+            Assert.assertEquals(Math.min(countRequested, returnedCount), results.size());
+        }
+    }
+
+    private BlackboardArtifact getDomainsArtifact(DataSource dataSource, long id, String domain, Long time) {
+        List<BlackboardAttribute> attributes = new ArrayList<>();
+        if (domain != null) {
+            attributes.add(TskMockUtils.getAttribute(ATTRIBUTE_TYPE.TSK_DOMAIN, domain));
+        }
+
+        if (time != null) {
+            attributes.add(TskMockUtils.getAttribute(ATTRIBUTE_TYPE.TSK_DATETIME_ACCESSED, time));
+        }
+
+        try {
+            return TskMockUtils.getArtifact(
+                    new BlackboardArtifact.Type(ARTIFACT_TYPE.TSK_WEB_HISTORY), id, dataSource,
+                    attributes);
+        } catch (TskCoreException e) {
+            fail("TskCoreException occurred while trying to mock a blackboard artifact");
+            return null;
+        }
+    }
+
+    private static final long DOMAIN_WINDOW_DAYS = 30;
+
+    /**
+     * UserActivitySummary.getRecentDomains should return results within 30 days
+     * of the most recent access.
+     *
+     * @throws TskCoreException
+     * @throws SleuthkitCaseProviderException
+     * @throws NoServiceProviderException
+     * @throws TranslationException
+     */
+    @Test
+    public void getRecentDomains_withinTimeWIndow() throws TskCoreException, SleuthkitCaseProviderException, NoServiceProviderException, TranslationException {
+        long dataSourceId = 1;
+        DataSource dataSource = TskMockUtils.getDataSource(dataSourceId);
+        String domain1 = "www.google.com";
+        String domain2 = "www.basistech.com";
+        String domain3 = "www.github.com";
+        String domain4 = "www.stackoverflow.com";
+
+        BlackboardArtifact artifact1 = getDomainsArtifact(dataSource, 1000, domain1, DAY_SECONDS * DOMAIN_WINDOW_DAYS * 2);
+        BlackboardArtifact artifact1a = getDomainsArtifact(dataSource, 10001, domain1, DAY_SECONDS * DOMAIN_WINDOW_DAYS);
+
+        BlackboardArtifact artifact2 = getDomainsArtifact(dataSource, 1001, domain2, DAY_SECONDS * DOMAIN_WINDOW_DAYS - 1);
+
+        BlackboardArtifact artifact3 = getDomainsArtifact(dataSource, 1002, domain3, DAY_SECONDS * DOMAIN_WINDOW_DAYS);
+        BlackboardArtifact artifact3a = getDomainsArtifact(dataSource, 10021, domain3, 1L);
+
+        BlackboardArtifact artifact4 = getDomainsArtifact(dataSource, 1003, domain4, 1L);
+
+        List<BlackboardArtifact> retArr = Arrays.asList(artifact1, artifact1a, artifact2, artifact3, artifact3a, artifact4);
+
+        Pair<SleuthkitCase, Blackboard> tskPair = getArtifactsTSKMock(retArr);
+
+        UserActivitySummary summary = getTestClass(tskPair.getLeft(), false, null);
+
+        List<TopDomainsResult> domains = summary.getRecentDomains(dataSource, 10);
+
+        verifyCalled(tskPair.getRight(), ARTIFACT_TYPE.TSK_WEB_HISTORY.getTypeID(), dataSourceId,
+                "Expected getRecentDomains to call getArtifacts with correct arguments.");
+
+        Assert.assertEquals(2, domains.size());
+
+        Assert.assertTrue("Expected " + domain1 + " to be first domain", domain1.equalsIgnoreCase(domains.get(0).getDomain()));
+        Assert.assertEquals(DAY_SECONDS * DOMAIN_WINDOW_DAYS * 2, domains.get(0).getLastVisit().getTime() / 1000);
+        Assert.assertEquals((Long) 2L, domains.get(0).getVisitTimes());
+
+        Assert.assertTrue("Expected " + domain3 + " to be second domain", domain3.equalsIgnoreCase(domains.get(1).getDomain()));
+        Assert.assertEquals(DAY_SECONDS * DOMAIN_WINDOW_DAYS, domains.get(1).getLastVisit().getTime() / 1000);
+        Assert.assertEquals((Long) 1L, domains.get(1).getVisitTimes());
+    }
+
+    /**
+     * Ensure that items like localhost and 127.0.0.1 are removed from results.
+     *
+     * @throws TskCoreException
+     * @throws NoServiceProviderException
+     * @throws TranslationException
+     * @throws SleuthkitCaseProviderException
+     */
+    @Test
+    public void getRecentDomains_appropriatelyFiltered() throws TskCoreException, NoServiceProviderException, TranslationException, SleuthkitCaseProviderException {
+        long dataSourceId = 1;
+        DataSource dataSource = TskMockUtils.getDataSource(dataSourceId);
+        String domain1 = "www.google.com";
+
+        // excluded
+        String domain2 = "localhost";
+        String domain3 = "127.0.0.1";
+
+        BlackboardArtifact artifact1 = getDomainsArtifact(dataSource, 1000, domain1, DAY_SECONDS);
+        BlackboardArtifact artifact2 = getDomainsArtifact(dataSource, 1001, domain2, DAY_SECONDS * 2);
+        BlackboardArtifact artifact3 = getDomainsArtifact(dataSource, 1002, domain3, DAY_SECONDS * 3);
+
+        List<BlackboardArtifact> retArr = Arrays.asList(artifact1, artifact2, artifact3);
+
+        Pair<SleuthkitCase, Blackboard> tskPair = getArtifactsTSKMock(retArr);
+
+        UserActivitySummary summary = getTestClass(tskPair.getLeft(), false, null);
+
+        List<TopDomainsResult> domains = summary.getRecentDomains(dataSource, 10);
+
+        verifyCalled(tskPair.getRight(), ARTIFACT_TYPE.TSK_WEB_HISTORY.getTypeID(), dataSourceId,
+                "Expected getRecentDomains to call getArtifacts with correct arguments.");
+
+        Assert.assertEquals(1, domains.size());
+
+        Assert.assertTrue("Expected " + domain1 + " to be most recent domain", domain1.equalsIgnoreCase(domains.get(0).getDomain()));
+        Assert.assertEquals(DAY_SECONDS, domains.get(0).getLastVisit().getTime() / 1000);
+    }
+
+    /**
+     * Ensure domains are grouped by name.
+     *
+     * @throws TskCoreException
+     * @throws NoServiceProviderException
+     * @throws TranslationException
+     * @throws SleuthkitCaseProviderException
+     */
+    @Test
+    public void getRecentDomains_groupedAppropriately() throws TskCoreException, NoServiceProviderException, TranslationException, SleuthkitCaseProviderException {
+        long dataSourceId = 1;
+        DataSource dataSource = TskMockUtils.getDataSource(dataSourceId);
+        String domain1 = "www.google.com";
+        String domain2 = "www.basistech.com";
+
+        BlackboardArtifact artifact1 = getDomainsArtifact(dataSource, 1000, domain1, 1L);
+        BlackboardArtifact artifact1a = getDomainsArtifact(dataSource, 1001, domain1, 6L);
+        BlackboardArtifact artifact2 = getDomainsArtifact(dataSource, 1002, domain2, 2L);
+        BlackboardArtifact artifact2a = getDomainsArtifact(dataSource, 1003, domain2, 3L);
+        BlackboardArtifact artifact2b = getDomainsArtifact(dataSource, 1004, domain2, 4L);
+
+        List<BlackboardArtifact> retArr = Arrays.asList(artifact1, artifact1a, artifact2, artifact2a, artifact2b);
+
+        Pair<SleuthkitCase, Blackboard> tskPair = getArtifactsTSKMock(retArr);
+        UserActivitySummary summary = getTestClass(tskPair.getLeft(), false, null);
+
+        List<TopDomainsResult> domains = summary.getRecentDomains(dataSource, 10);
+
+        verifyCalled(tskPair.getRight(), ARTIFACT_TYPE.TSK_WEB_HISTORY.getTypeID(), dataSourceId,
+                "Expected getRecentDomains to call getArtifacts with correct arguments.");
+
+        Assert.assertEquals(2, domains.size());
+
+        Assert.assertTrue(domain1.equalsIgnoreCase(domains.get(1).getDomain()));
+        Assert.assertEquals(6L, domains.get(1).getLastVisit().getTime() / 1000);
+        Assert.assertEquals((Long) 2L, domains.get(1).getVisitTimes());
+
+        Assert.assertTrue(domain2.equalsIgnoreCase(domains.get(0).getDomain()));
+        Assert.assertEquals(4L, domains.get(0).getLastVisit().getTime() / 1000);
+        Assert.assertEquals((Long) 3L, domains.get(0).getVisitTimes());
+    }
+
+    /**
+     * Ensure that UserActivitySummary.getRecentDomains limits to count
+     * appropriately.
+     *
+     * @throws TskCoreException
+     * @throws NoServiceProviderException
+     * @throws TranslationException
+     * @throws SleuthkitCaseProviderException
+     */
+    @Test
+    public void getRecentDomains_limitedAppropriately()
+            throws TskCoreException, NoServiceProviderException, TranslationException, SleuthkitCaseProviderException {
+
+        int countRequested = 10;
+        for (int returnedCount : new int[]{1, 9, 10, 11}) {
+            long dataSourceId = 1L;
+            DataSource dataSource = TskMockUtils.getDataSource(dataSourceId);
+
+            // create a list where there are 1 accesses for first, 2 for second, etc.
+            List<BlackboardArtifact> returnedArtifacts = IntStream.range(0, returnedCount)
+                    .mapToObj((idx) -> {
+                        return IntStream.range(0, idx + 1)
+                                .mapToObj((numIdx) -> {
+                                    int hash = 100 * idx + numIdx;
+                                    return getDomainsArtifact(dataSource, 1000 + hash, "Domain " + idx, 10L);
+                                });
+                    })
+                    .flatMap((s) -> s)
+                    .collect(Collectors.toList());
+
+            Pair<SleuthkitCase, Blackboard> tskPair = getArtifactsTSKMock(returnedArtifacts);
+            UserActivitySummary summary = getTestClass(tskPair.getLeft(), false, null);
+
+            List<TopDomainsResult> results = summary.getRecentDomains(dataSource, countRequested);
+            verifyCalled(tskPair.getRight(), ARTIFACT_TYPE.TSK_WEB_HISTORY.getTypeID(), dataSourceId,
+                    "Expected getRecentDevices to call getArtifacts with correct arguments.");
+
+            Assert.assertEquals(Math.min(countRequested, returnedCount), results.size());
+        }
+    }
+
+    /**
+     * Get email artifact to be used with getRecentAccounts
+     *
+     * @param artifactId The artifact id.
+     * @param dataSource The datasource.
+     * @param dateRcvd   The date received in seconds or null to exclude.
+     * @param dateSent   The date sent in seconds or null to exclude.
+     *
+     * @return The mock artifact.
+     */
+    private static BlackboardArtifact getEmailArtifact(long artifactId, DataSource dataSource, Long dateRcvd, Long dateSent) {
+        List<BlackboardAttribute> attributes = new ArrayList<>();
+
+        if (dateRcvd != null) {
+            attributes.add(TskMockUtils.getAttribute(ATTRIBUTE_TYPE.TSK_DATETIME_RCVD, dateRcvd));
+        }
+
+        if (dateSent != null) {
+            attributes.add(TskMockUtils.getAttribute(ATTRIBUTE_TYPE.TSK_DATETIME_SENT, dateSent));
+        }
+
+        try {
+            return TskMockUtils.getArtifact(new BlackboardArtifact.Type(ARTIFACT_TYPE.TSK_EMAIL_MSG),
+                    artifactId, dataSource, attributes);
+        } catch (TskCoreException ignored) {
+            fail("Something went wrong while mocking");
+            return null;
+        }
+    }
+
+    /**
+     * Get calllog artifact to be used with getRecentAccounts
+     *
+     * @param artifactId The artifact id.
+     * @param dataSource The datasource.
+     * @param dateStart  The date start in seconds or null to exclude.
+     * @param dateEnd    The date end in seconds or null to exclude.
+     *
+     * @return The mock artifact.
+     */
+    private static BlackboardArtifact getCallogArtifact(long artifactId, DataSource dataSource, Long dateStart, Long dateEnd) {
+        List<BlackboardAttribute> attributes = new ArrayList<>();
+
+        if (dateStart != null) {
+            attributes.add(TskMockUtils.getAttribute(ATTRIBUTE_TYPE.TSK_DATETIME_START, dateStart));
+        }
+
+        if (dateEnd != null) {
+            attributes.add(TskMockUtils.getAttribute(ATTRIBUTE_TYPE.TSK_DATETIME_END, dateEnd));
+        }
+
+        try {
+            return TskMockUtils.getArtifact(new BlackboardArtifact.Type(ARTIFACT_TYPE.TSK_CALLLOG),
+                    artifactId, dataSource, attributes);
+        } catch (TskCoreException ignored) {
+            fail("Something went wrong while mocking");
+            return null;
+        }
+    }
+
+    /**
+     * Get message artifact to be used with getRecentAccounts
+     *
+     * @param artifactId The artifact id.
+     * @param dataSource The datasource.
+     * @param type       The account type.
+     * @param dateSent   The date of the message in seconds.
+     */
+    private static BlackboardArtifact getMessageArtifact(long artifactId, DataSource dataSource, String type, Long dateTime) {
+        List<BlackboardAttribute> attributes = new ArrayList<>();
+
+        if (type != null) {
+            attributes.add(TskMockUtils.getAttribute(ATTRIBUTE_TYPE.TSK_MESSAGE_TYPE, type));
+        }
+
+        if (dateTime != null) {
+            attributes.add(TskMockUtils.getAttribute(ATTRIBUTE_TYPE.TSK_DATETIME, dateTime));
+        }
+
+        try {
+            return TskMockUtils.getArtifact(new BlackboardArtifact.Type(ARTIFACT_TYPE.TSK_MESSAGE),
+                    artifactId, dataSource, attributes);
+        } catch (TskCoreException ignored) {
+            fail("Something went wrong while mocking");
+            return null;
+        }
+    }
+
+    /**
+     * Performs a test on UserActivitySummary.getRecentAccounts.
+     *
+     * @param dataSource      The datasource to use as parameter.
+     * @param count           The count to use as a parameter.
+     * @param retArtifacts    The artifacts to return from
+     *                        SleuthkitCase.getArtifacts. This method filters
+     *                        based on artifact type from the call.
+     * @param expectedResults The expected results.
+     *
+     * @throws TskCoreException
+     * @throws NoServiceProviderException
+     * @throws TranslationException
+     * @throws
+     * org.sleuthkit.autopsy.datasourcesummary.datamodel.SleuthkitCaseProvider.SleuthkitCaseProviderException
+     */
+    private void getRecentAccountsTest(DataSource dataSource, int count,
+            List<BlackboardArtifact> retArtifacts, List<TopAccountResult> expectedResults)
+            throws TskCoreException, NoServiceProviderException, TranslationException, SleuthkitCaseProviderException {
+
+        SleuthkitCase mockCase = mock(SleuthkitCase.class);
+        Blackboard mockBlackboard = mock(Blackboard.class);
+        when(mockCase.getBlackboard()).thenReturn(mockBlackboard);
+
+        when(mockBlackboard.getArtifacts(anyInt(), anyLong())).thenAnswer((invocation) -> {
+            Object[] args = invocation.getArguments();
+            int artifactType = (Integer) args[0];
+            return retArtifacts.stream()
+                    .filter((art) -> art.getArtifactTypeID() == artifactType)
+                    .collect(Collectors.toList());
+        });
+
+        UserActivitySummary summary = getTestClass(mockCase, false, null);
+
+        List<TopAccountResult> receivedResults = summary.getRecentAccounts(dataSource, count);
+
+        verifyCalled(mockBlackboard, ARTIFACT_TYPE.TSK_MESSAGE.getTypeID(), dataSource.getId(),
+                "Expected getRecentAccounts to call getArtifacts requesting TSK_MESSAGE.");
+
+        verifyCalled(mockBlackboard, ARTIFACT_TYPE.TSK_EMAIL_MSG.getTypeID(), dataSource.getId(),
+                "Expected getRecentAccounts to call getArtifacts requesting TSK_EMAIL_MSG.");
+
+        verifyCalled(mockBlackboard, ARTIFACT_TYPE.TSK_CALLLOG.getTypeID(), dataSource.getId(),
+                "Expected getRecentAccounts to call getArtifacts requesting TSK_CALLLOG.");
+
+        Assert.assertEquals(expectedResults.size(), receivedResults.size());
+        for (int i = 0; i < expectedResults.size(); i++) {
+            TopAccountResult expectedItem = expectedResults.get(i);
+            TopAccountResult receivedItem = receivedResults.get(i);
+
+            // since this may be somewhat variable
+            Assert.assertTrue(expectedItem.getAccountType().equalsIgnoreCase(receivedItem.getAccountType()));
+            Assert.assertEquals(expectedItem.getLastAccess().getTime(), receivedItem.getLastAccess().getTime());
+        }
+    }
+
+    private void getRecentAccountsOneArtTest(DataSource dataSource, BlackboardArtifact retArtifact, TopAccountResult expectedResult)
+            throws TskCoreException, NoServiceProviderException, TranslationException, SleuthkitCaseProviderException {
+        getRecentAccountsTest(dataSource, 10, Arrays.asList(retArtifact), expectedResult != null ? Arrays.asList(expectedResult) : Collections.emptyList());
+    }
+
+    /**
+     * Verify that UserActivitySummary.getRecentAccounts attempts to find a date
+     * but if none present, the artifact is excluded.
+     *
+     * @throws TskCoreException
+     * @throws NoServiceProviderException
+     * @throws TranslationException
+     * @throws SleuthkitCaseProviderException
+     */
+    @Test
+    public void getRecentAccounts_filtersNoDate()
+            throws TskCoreException, NoServiceProviderException, TranslationException, SleuthkitCaseProviderException {
+
+        DataSource ds1 = TskMockUtils.getDataSource(1);
+        BlackboardArtifact email1 = getEmailArtifact(31, ds1, DAY_SECONDS, null);
+        getRecentAccountsOneArtTest(ds1, email1,
+                new TopAccountResult(
+                        Bundle.DataSourceUserActivitySummary_getRecentAccounts_emailMessage(),
+                        new Date(DAY_SECONDS * 1000)));
+
+        BlackboardArtifact email2 = getEmailArtifact(2, ds1, null, DAY_SECONDS);
+        getRecentAccountsOneArtTest(ds1, email2,
+                new TopAccountResult(
+                        Bundle.DataSourceUserActivitySummary_getRecentAccounts_emailMessage(),
+                        new Date(DAY_SECONDS * 1000)));
+
+        BlackboardArtifact email3 = getEmailArtifact(3, ds1, null, null);
+        getRecentAccountsOneArtTest(ds1, email3, null);
+
+        BlackboardArtifact email4 = getEmailArtifact(4, ds1, DAY_SECONDS, DAY_SECONDS * 2);
+        getRecentAccountsOneArtTest(ds1, email4,
+                new TopAccountResult(
+                        Bundle.DataSourceUserActivitySummary_getRecentAccounts_emailMessage(),
+                        new Date(DAY_SECONDS * 2 * 1000)));
+
+        BlackboardArtifact callog1 = getCallogArtifact(11, ds1, DAY_SECONDS, null);
+        getRecentAccountsOneArtTest(ds1, callog1,
+                new TopAccountResult(
+                        Bundle.DataSourceUserActivitySummary_getRecentAccounts_calllogMessage(),
+                        new Date(DAY_SECONDS * 1000)));
+
+        BlackboardArtifact callog2 = getCallogArtifact(12, ds1, null, DAY_SECONDS);
+        getRecentAccountsOneArtTest(ds1, callog2,
+                new TopAccountResult(
+                        Bundle.DataSourceUserActivitySummary_getRecentAccounts_calllogMessage(),
+                        new Date(DAY_SECONDS * 1000)));
+
+        BlackboardArtifact callog3 = getCallogArtifact(13, ds1, null, null);
+        getRecentAccountsOneArtTest(ds1, callog3, null);
+
+        BlackboardArtifact callog4 = getCallogArtifact(14, ds1, DAY_SECONDS, DAY_SECONDS * 2);
+        getRecentAccountsOneArtTest(ds1, callog4,
+                new TopAccountResult(
+                        Bundle.DataSourceUserActivitySummary_getRecentAccounts_calllogMessage(),
+                        new Date(DAY_SECONDS * 2 * 1000)));
+
+        BlackboardArtifact message1 = getMessageArtifact(21, ds1, "Skype", null);
+        getRecentAccountsOneArtTest(ds1, message1, null);
+
+        BlackboardArtifact message2 = getMessageArtifact(22, ds1, null, DAY_SECONDS);
+        getRecentAccountsOneArtTest(ds1, message2, null);
+
+        BlackboardArtifact message3 = getMessageArtifact(23, ds1, null, null);
+        getRecentAccountsOneArtTest(ds1, message3, null);
+
+        BlackboardArtifact message4 = getMessageArtifact(24, ds1, "Skype", DAY_SECONDS);
+        getRecentAccountsOneArtTest(ds1, message4, new TopAccountResult("Skype", new Date(DAY_SECONDS * 1000)));
+
+    }
+
+    /**
+     * Verifies that UserActivitySummary.getRecentAccounts groups appropriately
+     * by account type.
+     *
+     * @throws TskCoreException
+     * @throws NoServiceProviderException
+     * @throws TranslationException
+     * @throws SleuthkitCaseProviderException
+     */
+    @Test
+    public void getRecentAccounts_rightGrouping()
+            throws TskCoreException, NoServiceProviderException, TranslationException, SleuthkitCaseProviderException {
+        DataSource ds1 = TskMockUtils.getDataSource(1);
+        BlackboardArtifact email1 = getEmailArtifact(11, ds1, DAY_SECONDS - 11, null);
+        BlackboardArtifact email2 = getEmailArtifact(12, ds1, DAY_SECONDS - 12, null);
+        BlackboardArtifact email3 = getEmailArtifact(13, ds1, DAY_SECONDS + 13, null);
+
+        BlackboardArtifact callog1 = getCallogArtifact(21, ds1, DAY_SECONDS - 21, null);
+        BlackboardArtifact callog2 = getCallogArtifact(22, ds1, DAY_SECONDS + 22, null);
+
+        BlackboardArtifact message1a = getMessageArtifact(31, ds1, "Skype", DAY_SECONDS - 31);
+        BlackboardArtifact message1b = getMessageArtifact(32, ds1, "Skype", DAY_SECONDS + 32);
+
+        BlackboardArtifact message2a = getMessageArtifact(41, ds1, "Facebook", DAY_SECONDS - 41);
+        BlackboardArtifact message2b = getMessageArtifact(41, ds1, "Facebook", DAY_SECONDS + 42);
+
+        getRecentAccountsTest(ds1, 10,
+                Arrays.asList(email1, email2, email3, callog1, callog2, message1a, message1b, message2a, message2b),
+                Arrays.asList(
+                        new TopAccountResult("Facebook", new Date((DAY_SECONDS + 42) * 1000)),
+                        new TopAccountResult("Skype", new Date((DAY_SECONDS + 32) * 1000)),
+                        new TopAccountResult(Bundle.DataSourceUserActivitySummary_getRecentAccounts_calllogMessage(), new Date((DAY_SECONDS + 22) * 1000)),
+                        new TopAccountResult(Bundle.DataSourceUserActivitySummary_getRecentAccounts_emailMessage(), new Date((DAY_SECONDS + 13) * 1000))
+                ));
+    }
+
+    /**
+     * Verifies that UserActivitySummary.getRecentAccounts properly limits
+     * results returned.
+     *
+     * @throws TskCoreException
+     * @throws NoServiceProviderException
+     * @throws TranslationException
+     * @throws SleuthkitCaseProviderException
+     */
+    @Test
+    public void getRecentAccounts_rightLimit()
+            throws TskCoreException, NoServiceProviderException, TranslationException, SleuthkitCaseProviderException {
+        int countRequested = 10;
+        for (int returnedCount : new int[]{1, 9, 10, 11}) {
+            long dataSourceId = 1L;
+            DataSource dataSource = TskMockUtils.getDataSource(dataSourceId);
+
+            List<BlackboardArtifact> returnedArtifacts = IntStream.range(0, returnedCount)
+                    .mapToObj((idx) -> getMessageArtifact(1000 + idx, dataSource, "Message Type " + idx, DAY_SECONDS * idx + 1))
+                    .collect(Collectors.toList());
+
+            Pair<SleuthkitCase, Blackboard> tskPair = getArtifactsTSKMock(returnedArtifacts);
+            UserActivitySummary summary = getTestClass(tskPair.getLeft(), false, null);
+
+            List<TopAccountResult> results = summary.getRecentAccounts(dataSource, countRequested);
+            verifyCalled(tskPair.getRight(), ARTIFACT_TYPE.TSK_MESSAGE.getTypeID(), dataSource.getId(),
+                    "Expected getRecentAccounts to call getArtifacts requesting TSK_MESSAGE.");
+
+            verifyCalled(tskPair.getRight(), ARTIFACT_TYPE.TSK_EMAIL_MSG.getTypeID(), dataSource.getId(),
+                    "Expected getRecentAccounts to call getArtifacts requesting TSK_EMAIL_MSG.");
+
+            verifyCalled(tskPair.getRight(), ARTIFACT_TYPE.TSK_CALLLOG.getTypeID(), dataSource.getId(),
+                    "Expected getRecentAccounts to call getArtifacts requesting TSK_CALLLOG.");
+
+            Assert.assertEquals(Math.min(countRequested, returnedCount), results.size());
+        }
+    }
+
+    /**
+     * Ensures that UserActivity.getShortFolderName handles paths appropriately
+     * including Program Files and AppData folders.
+     *
+     * @throws NoServiceProviderException
+     * @throws TskCoreException
+     * @throws TranslationException
+     */
+    @Test
+    public void getShortFolderName_rightConversions() throws NoServiceProviderException, TskCoreException, TranslationException {
+        Map<String, String> expected = new HashMap<>();
+        expected.put("/Program Files/Item/Item.exe", "Item");
+        expected.put("/Program Files (x86)/Item/Item.exe", "Item");
+        expected.put("/Program_Files/Item/Item.exe", "");
+
+        expected.put("/User/test_user/item/AppData/Item/Item.exe", "AppData");
+        expected.put("/User/test_user/item/Application Data/Item/Item.exe", "AppData");
+
+        expected.put("/Other Path/Item/Item.exe", "");
+
+        Pair<SleuthkitCase, Blackboard> tskPair = getArtifactsTSKMock(null);
+        UserActivitySummary summary = getTestClass(tskPair.getLeft(), false, null);
+
+        for (Entry<String, String> path : expected.entrySet()) {
+            Assert.assertTrue(path.getValue().equalsIgnoreCase(summary.getShortFolderName(path.getKey(), "Item.exe")));
+            Assert.assertTrue(path.getValue().equalsIgnoreCase(summary.getShortFolderName(path.getKey().toUpperCase(), "Item.exe".toUpperCase())));
+            Assert.assertTrue(path.getValue().equalsIgnoreCase(summary.getShortFolderName(path.getKey().toLowerCase(), "Item.exe".toLowerCase())));
+        }
+    }
+
+    private static BlackboardArtifact getProgramArtifact(long artifactId, DataSource dataSource, String programName, String path, Integer count, Long dateTime) {
+        List<BlackboardAttribute> attributes = new ArrayList<>();
+
+        if (programName != null) {
+            attributes.add(TskMockUtils.getAttribute(ATTRIBUTE_TYPE.TSK_PROG_NAME, programName));
+        }
+
+        if (path != null) {
+            attributes.add(TskMockUtils.getAttribute(ATTRIBUTE_TYPE.TSK_PATH, path));
+        }
+
+        if (dateTime != null) {
+            attributes.add(TskMockUtils.getAttribute(ATTRIBUTE_TYPE.TSK_DATETIME, dateTime));
+        }
+
+        if (count != null) {
+            attributes.add(TskMockUtils.getAttribute(ATTRIBUTE_TYPE.TSK_COUNT, count));
+        }
+
+        try {
+            return TskMockUtils.getArtifact(new BlackboardArtifact.Type(ARTIFACT_TYPE.TSK_PROG_RUN),
+                    artifactId, dataSource, attributes);
+        } catch (TskCoreException ignored) {
+            fail("Something went wrong while mocking");
+            return null;
+        }
+    }
+
+    /**
+     * Ensures that getTopPrograms filters results like ntosboot programs or
+     * /Windows folders.
+     *
+     * @throws TskCoreException
+     * @throws NoServiceProviderException
+     * @throws TranslationException
+     * @throws SleuthkitCaseProviderException
+     */
+    @Test
+    public void getTopPrograms_filtered()
+            throws TskCoreException, NoServiceProviderException, TranslationException, SleuthkitCaseProviderException {
+
+        DataSource ds1 = TskMockUtils.getDataSource(1);
+        BlackboardArtifact ntosToRemove = getProgramArtifact(1, ds1, "ntosboot", "/Program Files/etc/", 21, 21L);
+        BlackboardArtifact windowsToRemove = getProgramArtifact(2, ds1, "Program.exe", "/Windows/", 21, 21L);
+        BlackboardArtifact windowsToRemove2 = getProgramArtifact(3, ds1, "Program.exe", "/Windows/Nested/", 21, 21L);
+        BlackboardArtifact noProgramNameToRemove = getProgramArtifact(4, ds1, null, "/Program Files/", 21, 21L);
+        BlackboardArtifact noProgramNameToRemove2 = getProgramArtifact(5, ds1, "   ", "/Program Files/", 21, 21L);
+        BlackboardArtifact successful = getProgramArtifact(6, ds1, "ProgramSuccess.exe", "/AppData/Success/", null, null);
+        BlackboardArtifact successful2 = getProgramArtifact(7, ds1, "ProgramSuccess2.exe", "/AppData/Success/", 22, 22L);
+
+        Pair<SleuthkitCase, Blackboard> tskPair = getArtifactsTSKMock(Arrays.asList(
+                ntosToRemove,
+                windowsToRemove,
+                windowsToRemove2,
+                noProgramNameToRemove,
+                noProgramNameToRemove2,
+                successful,
+                successful2
+        ));
+        UserActivitySummary summary = getTestClass(tskPair.getLeft(), false, null);
+        List<TopProgramsResult> results = summary.getTopPrograms(ds1, 10);
+
+        Assert.assertEquals(2, results.size());
+        Assert.assertTrue("ProgramSuccess2.exe".equalsIgnoreCase(results.get(0).getProgramName()));
+        Assert.assertTrue("ProgramSuccess.exe".equalsIgnoreCase(results.get(1).getProgramName()));
+    }
+
+    /**
+     * Ensures proper grouping of programs with index of program name and path.
+     *
+     * @throws TskCoreException
+     * @throws NoServiceProviderException
+     * @throws TranslationException
+     * @throws SleuthkitCaseProviderException
+     */
+    @Test
+    public void getTopPrograms_correctGrouping()
+            throws TskCoreException, NoServiceProviderException, TranslationException, SleuthkitCaseProviderException {
+
+        DataSource ds1 = TskMockUtils.getDataSource(1);
+        BlackboardArtifact prog1 = getProgramArtifact(1, ds1, "program1.exe", "/Program Files/etc/", 21, 21L);
+        BlackboardArtifact prog1a = getProgramArtifact(1, ds1, "program1.exe", "/Program Files/etc/", 1, 31L);
+        BlackboardArtifact prog1b = getProgramArtifact(1, ds1, "program1.exe", "/Program Files/etc/", 2, 11L);
+
+        BlackboardArtifact prog2 = getProgramArtifact(1, ds1, "program1.exe", "/Program Files/another/", 31, 21L);
+        BlackboardArtifact prog2a = getProgramArtifact(1, ds1, "program1.exe", "/Program Files/another/", 1, 31L);
+        BlackboardArtifact prog2b = getProgramArtifact(1, ds1, "program1.exe", "/Program Files/another/", 2, 11L);
+
+        BlackboardArtifact prog3 = getProgramArtifact(1, ds1, "program2.exe", "/Program Files/another/", 10, 21L);
+        BlackboardArtifact prog3a = getProgramArtifact(1, ds1, "program2.exe", "/Program Files/another/", 1, 22L);
+        BlackboardArtifact prog3b = getProgramArtifact(1, ds1, "program2.exe", "/Program Files/another/", 2, 11L);
+
+        Pair<SleuthkitCase, Blackboard> tskPair = getArtifactsTSKMock(Arrays.asList(
+                prog1, prog1a, prog1b,
+                prog2, prog2a, prog2b,
+                prog3, prog3a, prog3b
+        ));
+        UserActivitySummary summary = getTestClass(tskPair.getLeft(), false, null);
+        List<TopProgramsResult> results = summary.getTopPrograms(ds1, 10);
+
+        Assert.assertEquals(3, results.size());
+        Assert.assertTrue("program1.exe".equalsIgnoreCase(results.get(0).getProgramName()));
+        Assert.assertTrue("/Program Files/another/".equalsIgnoreCase(results.get(0).getProgramPath()));
+        Assert.assertEquals((Long) 31L, results.get(0).getRunTimes());
+        Assert.assertEquals((Long) 31L, (Long) (results.get(0).getLastRun().getTime() / 1000));
+
+        Assert.assertTrue("program1.exe".equalsIgnoreCase(results.get(1).getProgramName()));
+        Assert.assertTrue("/Program Files/etc/".equalsIgnoreCase(results.get(1).getProgramPath()));
+        Assert.assertEquals((Long) 21L, results.get(1).getRunTimes());
+        Assert.assertEquals((Long) 31L, (Long) (results.get(1).getLastRun().getTime() / 1000));
+
+        Assert.assertTrue("program2.exe".equalsIgnoreCase(results.get(2).getProgramName()));
+        Assert.assertTrue("/Program Files/another/".equalsIgnoreCase(results.get(2).getProgramPath()));
+        Assert.assertEquals((Long) 10L, results.get(2).getRunTimes());
+        Assert.assertEquals((Long) 22L, (Long) (results.get(2).getLastRun().getTime() / 1000));
+    }
+
+    private void assertProgramOrder(DataSource ds1, List<BlackboardArtifact> artifacts, List<String> programNamesReturned)
+            throws TskCoreException, NoServiceProviderException, TranslationException, SleuthkitCaseProviderException {
+
+        Pair<SleuthkitCase, Blackboard> tskPair = getArtifactsTSKMock(artifacts);
+        UserActivitySummary summary = getTestClass(tskPair.getLeft(), false, null);
+        List<TopProgramsResult> results = summary.getTopPrograms(ds1, 10);
+
+        Assert.assertEquals(programNamesReturned.size(), results.size());
+        for (int i = 0; i < programNamesReturned.size(); i++) {
+            Assert.assertTrue(programNamesReturned.get(i).equalsIgnoreCase(results.get(i).getProgramName()));
+        }
+    }
+
+    /**
+     * Ensure that UserActivitySummary.getTopPrograms properly orders results
+     * (first by run count, then date, then program name).
+     *
+     * @throws TskCoreException
+     * @throws NoServiceProviderException
+     * @throws TranslationException
+     * @throws SleuthkitCaseProviderException
+     */
+    @Test
+    public void getTopPrograms_correctOrdering()
+            throws TskCoreException, NoServiceProviderException, TranslationException, SleuthkitCaseProviderException {
+
+        DataSource ds1 = TskMockUtils.getDataSource(1);
+        BlackboardArtifact sortByRunsCount1 = getProgramArtifact(1001, ds1, "Program1.exe", "/Program Files/Folder/", 8, 1L);
+        BlackboardArtifact sortByRunsCount2 = getProgramArtifact(1002, ds1, "Program2.exe", "/Program Files/Folder/", 9, 2L);
+        BlackboardArtifact sortByRunsCount3 = getProgramArtifact(1003, ds1, "Program3.exe", "/Program Files/Folder/", 10, 3L);
+        assertProgramOrder(ds1, Arrays.asList(sortByRunsCount1, sortByRunsCount2, sortByRunsCount3), Arrays.asList("Program3.exe", "Program2.exe", "Program1.exe"));
+
+        BlackboardArtifact sortByRunDate1 = getProgramArtifact(1011, ds1, "Program1.exe", "/Program Files/Folder/", null, 1L);
+        BlackboardArtifact sortByRunDate2 = getProgramArtifact(1012, ds1, "Program2.exe", "/Program Files/Folder/", null, 3L);
+        BlackboardArtifact sortByRunDate3 = getProgramArtifact(1013, ds1, "Program3.exe", "/Program Files/Folder/", null, 2L);
+        assertProgramOrder(ds1, Arrays.asList(sortByRunDate1, sortByRunDate2, sortByRunDate3), Arrays.asList("Program2.exe", "Program3.exe", "Program1.exe"));
+
+        BlackboardArtifact sortByProgName1 = getProgramArtifact(1021, ds1, "cProgram.exe", "/Program Files/Folder/", null, null);
+        BlackboardArtifact sortByProgName2 = getProgramArtifact(1022, ds1, "BProgram.exe", "/Program Files/Folder/", null, null);
+        BlackboardArtifact sortByProgName3 = getProgramArtifact(1023, ds1, "aProgram.exe", "/Program Files/Folder/", null, null);
+        assertProgramOrder(ds1, Arrays.asList(sortByProgName1, sortByProgName2, sortByProgName3), Arrays.asList("aProgram.exe", "BProgram.exe", "cProgram.exe"));
+    }
+
+    /**
+     * Ensure that UserActivitySummary.getTopPrograms properly limits results
+     * (if no run count and no run date, then no limit).
+     *
+     * @throws TskCoreException
+     * @throws NoServiceProviderException
+     * @throws TranslationException
+     * @throws SleuthkitCaseProviderException
+     */
+    @Test
+    public void getTopPrograms_limited()
+            throws TskCoreException, NoServiceProviderException,
+            TranslationException, SleuthkitCaseProviderException {
+
+        int countRequested = 10;
+        for (int returnedCount : new int[]{1, 9, 10, 11}) {
+            long dataSourceId = 1L;
+            DataSource dataSource = TskMockUtils.getDataSource(dataSourceId);
+
+            // if data is present for counts and dates, the results are limited
+            List<BlackboardArtifact> returnedArtifacts = IntStream.range(0, returnedCount)
+                    .mapToObj((idx) -> getProgramArtifact(1000 + idx, dataSource, "Program" + idx,
+                    "/Program Files/Folder/", idx + 1, DAY_SECONDS * idx + 1))
+                    .collect(Collectors.toList());
+
+            Pair<SleuthkitCase, Blackboard> tskPair = getArtifactsTSKMock(returnedArtifacts);
+            UserActivitySummary summary = getTestClass(tskPair.getLeft(), false, null);
+
+            List<TopProgramsResult> results = summary.getTopPrograms(dataSource, countRequested);
+            verifyCalled(tskPair.getRight(), ARTIFACT_TYPE.TSK_PROG_RUN.getTypeID(), dataSourceId,
+                    "Expected getRecentDevices to call getArtifacts with correct arguments.");
+
+            Assert.assertEquals(Math.min(countRequested, returnedCount), results.size());
+
+            // if that data is not present, it is not limited
+            List<BlackboardArtifact> returnedArtifactsAlphabetical = IntStream.range(0, returnedCount)
+                    .mapToObj((idx) -> getProgramArtifact(1000 + idx, dataSource, "Program" + idx, null, null, null))
+                    .collect(Collectors.toList());
+
+            Pair<SleuthkitCase, Blackboard> tskPairAlphabetical = getArtifactsTSKMock(returnedArtifactsAlphabetical);
+            UserActivitySummary summaryAlphabetical = getTestClass(tskPairAlphabetical.getLeft(), false, null);
+
+            List<TopProgramsResult> resultsAlphabetical = summaryAlphabetical.getTopPrograms(dataSource, countRequested);
+            verifyCalled(tskPairAlphabetical.getRight(), ARTIFACT_TYPE.TSK_PROG_RUN.getTypeID(), dataSourceId,
+                    "Expected getRecentDevices to call getArtifacts with correct arguments.");
+
+            // ensure alphabetical by name
+            for (int i = 0; i < resultsAlphabetical.size() - 1; i++) {
+                Assert.assertTrue(resultsAlphabetical.get(i).getProgramName().compareToIgnoreCase(resultsAlphabetical.get(i + 1).getProgramName()) < 0);
+            }
+
+            Assert.assertEquals(returnedArtifacts.size(), resultsAlphabetical.size());
+        }
+    }
+}
diff --git a/Core/test/unit/src/org/sleuthkit/autopsy/discovery/search/DomainSearchTestUtils.java b/Core/test/unit/src/org/sleuthkit/autopsy/discovery/search/DomainSearchTestUtils.java
index ffadb3a16b554d2eeb4e1fc11d9bd9aba03353e3..9596ae432f3d9eee61ef89ac398dfcb6739a7f8a 100755
--- a/Core/test/unit/src/org/sleuthkit/autopsy/discovery/search/DomainSearchTestUtils.java
+++ b/Core/test/unit/src/org/sleuthkit/autopsy/discovery/search/DomainSearchTestUtils.java
@@ -32,7 +32,7 @@ private DomainSearchTestUtils() {
         
     public static ResultDomain mockDomainResult(String domain, long start, long end, 
             long totalVisits, long visits, long filesDownloaded, long dataSourceId) {
-        Content dataSource = TskMockUtils.mockDataSource(dataSourceId);
+        Content dataSource = TskMockUtils.getDataSource(dataSourceId);
         return new ResultDomain(domain, start, end, totalVisits,
                 visits, filesDownloaded, dataSource);
     }
diff --git a/Core/test/unit/src/org/sleuthkit/autopsy/testutils/RandomizationUtils.java b/Core/test/unit/src/org/sleuthkit/autopsy/testutils/RandomizationUtils.java
new file mode 100644
index 0000000000000000000000000000000000000000..848830284b6a8e56e202ad47dd3afb1f722f7c00
--- /dev/null
+++ b/Core/test/unit/src/org/sleuthkit/autopsy/testutils/RandomizationUtils.java
@@ -0,0 +1,56 @@
+/*
+ * Autopsy Forensic Browser
+ *
+ * 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.autopsy.testutils;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Tools for pseudo-randomization.
+ */
+public final class RandomizationUtils {
+
+    /**
+     * Returns list in 0, n-1, 1, n-2 ... order. Deterministic so same results
+     * each time, but not in original order.
+     *
+     * @return Mixed up list.
+     */
+    public static <T> List<T> getMixedUp(List<T> list) {
+        int forward = 0;
+        int backward = list.size() - 1;
+
+        List<T> newList = new ArrayList<>();
+        while (forward <= backward) {
+            newList.add(list.get(forward));
+
+            if (forward < backward) {
+                newList.add(list.get(backward));
+            }
+
+            forward++;
+            backward--;
+        }
+
+        return newList;
+    }
+
+    private RandomizationUtils() {
+    }
+}
diff --git a/Core/test/unit/src/org/sleuthkit/autopsy/testutils/TskMockUtils.java b/Core/test/unit/src/org/sleuthkit/autopsy/testutils/TskMockUtils.java
index b47ecdece354878c4e481764dc24c0968fb82350..b6a832acb79343ac9ba48a2570e292df130935af 100644
--- a/Core/test/unit/src/org/sleuthkit/autopsy/testutils/TskMockUtils.java
+++ b/Core/test/unit/src/org/sleuthkit/autopsy/testutils/TskMockUtils.java
@@ -19,15 +19,26 @@
 package org.sleuthkit.autopsy.testutils;
 
 import java.util.ArrayList;
+import java.util.List;
 import java.util.Map;
 import java.util.function.Function;
+import java.util.logging.ConsoleHandler;
+import java.util.logging.Handler;
+import java.util.logging.Logger;
 import java.util.stream.Collectors;
 import java.util.stream.Stream;
 import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.when;
+import org.sleuthkit.autopsy.texttranslation.NoServiceProviderException;
+import org.sleuthkit.autopsy.texttranslation.TextTranslationService;
+import org.sleuthkit.autopsy.texttranslation.TranslationException;
+import org.sleuthkit.datamodel.AbstractFile;
 import org.sleuthkit.datamodel.BlackboardArtifact;
 import org.sleuthkit.datamodel.BlackboardAttribute;
+import org.sleuthkit.datamodel.BlackboardAttribute.ATTRIBUTE_TYPE;
+import org.sleuthkit.datamodel.Content;
 import org.sleuthkit.datamodel.DataSource;
 import org.sleuthkit.datamodel.TskCoreException;
 
@@ -44,7 +55,7 @@ public class TskMockUtils {
      *
      * @return The mocked datasource.
      */
-    public static DataSource mockDataSource(long dataSourceId) {
+    public static DataSource getDataSource(long dataSourceId) {
         DataSource dataSource = mock(DataSource.class);
         when(dataSource.getName()).thenReturn("");
         when(dataSource.getId()).thenReturn(dataSourceId);
@@ -65,14 +76,35 @@ public static DataSource mockDataSource(long dataSourceId) {
      *
      * @throws TskCoreException
      */
-    public static BlackboardArtifact mockArtifact(BlackboardArtifact.Type artifactType, long artifactId,
+    public static BlackboardArtifact getArtifact(BlackboardArtifact.Type artifactType, long artifactId,
+            DataSource dataSource, BlackboardAttribute... attributes) throws TskCoreException {
+        return getArtifact(artifactType, null, artifactId, dataSource, attributes);
+    }
+
+    /**
+     * Gets a mock Blackboard artifact.
+     *
+     * @param artifactType The artifact type for the artifact.
+     * @param parent       The parent file of the artifact.
+     * @param artifactId   The artifact id.
+     * @param dataSource   The datasource.
+     * @param attributes   The attributes for the artifact.
+     *
+     * @return The mocked artifact.
+     *
+     * @throws TskCoreException
+     */
+    public static BlackboardArtifact getArtifact(BlackboardArtifact.Type artifactType, Content parent, long artifactId,
             DataSource dataSource, BlackboardAttribute... attributes) throws TskCoreException {
 
         BlackboardArtifact artifact = mock(BlackboardArtifact.class);
 
         final Map<BlackboardAttribute.Type, BlackboardAttribute> attributeTypes = Stream.of(attributes)
+                .filter(attr -> attr != null)
                 .collect(Collectors.toMap((attr) -> attr.getAttributeType(), Function.identity()));
 
+        when(artifact.getParent()).thenReturn(parent);
+
         when(artifact.getArtifactID()).thenReturn(artifactId);
 
         when(artifact.getArtifactTypeID()).thenReturn(artifactType.getTypeID());
@@ -89,6 +121,146 @@ public static BlackboardArtifact mockArtifact(BlackboardArtifact.Type artifactTy
         return artifact;
     }
 
+    public static BlackboardArtifact getArtifact(BlackboardArtifact.Type artifactType, long artifactId,
+            DataSource dataSource, List<BlackboardAttribute> attributes) throws TskCoreException {
+
+        return getArtifact(artifactType, artifactId, dataSource, attributes.toArray(new BlackboardAttribute[0]));
+    }
+
+    private static final String DEFAULT_ATTR_SOURCE = "TEST SOURCE";
+
+    public static BlackboardAttribute getAttribute(ATTRIBUTE_TYPE attrType, Object value) {
+
+        return getAttribute(new BlackboardAttribute.Type(attrType), DEFAULT_ATTR_SOURCE, value);
+    }
+
+    public static BlackboardAttribute getAttribute(BlackboardAttribute.Type attrType, String source, Object value) {
+        switch (attrType.getValueType()) {
+            case STRING:
+            case JSON:
+                if (value instanceof String) {
+                    return new BlackboardAttribute(attrType, source, (String) value);
+                }
+                break;
+            case DATETIME:
+            case LONG:
+                if (value instanceof Long) {
+                    return new BlackboardAttribute(attrType, source, (Long) value);
+                }
+                break;
+            case INTEGER:
+                if (value instanceof Integer) {
+                    return new BlackboardAttribute(attrType, source, (Integer) value);
+                }
+                break;
+            case DOUBLE:
+                if (value instanceof Double) {
+                    return new BlackboardAttribute(attrType, source, (Double) value);
+                }
+                break;
+            case BYTE:
+                if (value instanceof byte[]) {
+                    return new BlackboardAttribute(attrType, source, (byte[]) value);
+                }
+                break;
+            default:
+                throw new IllegalArgumentException(String.format("Unknown attribute value type: %s", attrType.getValueType()));
+        }
+
+        throw new IllegalArgumentException(String.format("Attribute type expected type of %s but received argument of %s", attrType.getValueType(), value));
+    }
+
+    /**
+     * Returns a mock TextTranslationService.
+     *
+     * @param onTranslate A function that performs the translation. If null, a
+     *                    null result is always returned for .translate method.
+     * @param hasProvider What to return for the hasProvider method.
+     *
+     * @return The mocked text translation service.
+     *
+     * @throws NoServiceProviderException
+     * @throws TranslationException
+     */
+    public static TextTranslationService getTextTranslationService(Function<String, String> onTranslate, boolean hasProvider)
+            throws NoServiceProviderException, TranslationException {
+        TextTranslationService translationService = mock(TextTranslationService.class);
+        when(translationService.hasProvider()).thenReturn(hasProvider);
+
+        when(translationService.translate(anyString())).thenAnswer((invocation) -> {
+            if (onTranslate == null) {
+                throw new NoServiceProviderException("No onTranslate function provided");
+            }
+
+            Object[] args = invocation.getArguments();
+            String input = (String) args[0];
+            return (input == null) ? null : onTranslate.apply(input);
+        });
+
+        return translationService;
+    }
+
+    /**
+     * Returns an AbstractFile mocking getPath and getName.
+     *
+     * @param objId The object id.
+     * @param path  The path for the file.
+     * @param name  The name
+     *
+     * @return
+     */
+    public static AbstractFile getAbstractFile(long objId, String path, String name) {
+        AbstractFile mocked = mock(AbstractFile.class);
+        when(mocked.getId()).thenReturn(objId);
+        when(mocked.getName()).thenReturn(name);
+        when(mocked.getParentPath()).thenReturn(path);
+        return mocked;
+    }
+
+    private static void setConsoleHandler(Logger logger) {
+        // taken from https://stackoverflow.com/a/981230
+        // Handler for console (reuse it if it already exists)
+        Handler consoleHandler = null;
+
+        //see if there is already a console handler
+        for (Handler handler : logger.getHandlers()) {
+            if (handler instanceof ConsoleHandler) {
+                //found the console handler
+                consoleHandler = handler;
+                break;
+            }
+        }
+
+        if (consoleHandler == null) {
+            //there was no console handler found, create a new one
+            consoleHandler = new ConsoleHandler();
+            logger.addHandler(consoleHandler);
+        }
+
+        //set the console handler to fine:
+        consoleHandler.setLevel(java.util.logging.Level.FINEST);
+    }
+
+    /**
+     * Retrieves an autopsy logger that does not write to disk.
+     *
+     * @param loggerName The name of the logger.
+     *
+     * @return The autopsy logger for the console
+     *
+     * @throws InstantiationException
+     * @throws IllegalStateException
+     */
+    public static Logger getJavaLogger(String loggerName) {
+        // The logger doesn't appear to respond well to mocking with mockito.
+        // It appears that the issue may have to do with mocking methods in the java.* packages
+        // since the autopsy logger extends the java.util.logging.Logger class:
+        // https://javadoc.io/static/org.mockito/mockito-core/3.5.13/org/mockito/Mockito.html#39
+        Logger logger = Logger.getLogger(loggerName);
+        setConsoleHandler(logger);
+        return logger;
+    }
+
     private TskMockUtils() {
     }
 }
diff --git a/Experimental/nbproject/project.xml b/Experimental/nbproject/project.xml
index e6cf918c7538dd066d103ff90d2bc57720ec95fd..953292b3c5f6c5b7a2bb3cfbe52dad747c1dc563 100644
--- a/Experimental/nbproject/project.xml
+++ b/Experimental/nbproject/project.xml
@@ -134,7 +134,8 @@
                     <build-prerequisite/>
                     <compile-dependency/>
                     <run-dependency>
-                        <specification-version>1.0</specification-version>
+                        <release-version>1</release-version>
+                        <specification-version>23</specification-version>
                     </run-dependency>
                 </dependency>
                 <dependency>
diff --git a/Experimental/src/org/sleuthkit/autopsy/experimental/autoingest/AutoIngestManager.java b/Experimental/src/org/sleuthkit/autopsy/experimental/autoingest/AutoIngestManager.java
index a6ece45667317b25ed88bd0c2310099cafd0dd0c..b3a25be6774606aceb76f92b62d974ab07acea46 100644
--- a/Experimental/src/org/sleuthkit/autopsy/experimental/autoingest/AutoIngestManager.java
+++ b/Experimental/src/org/sleuthkit/autopsy/experimental/autoingest/AutoIngestManager.java
@@ -2930,7 +2930,9 @@ public void propertyChange(PropertyChangeEvent event) {
                     String eventType = event.getPropertyName();
                     if (eventType.equals(IngestManager.IngestJobEvent.COMPLETED.toString()) || eventType.equals(IngestManager.IngestJobEvent.CANCELLED.toString())) {
                         synchronized (ingestLock) {
-                            ingestLock.notify();
+                            if (! IngestManager.getInstance().isIngestRunning()) {
+                                ingestLock.notify();
+                            }
                         }
                     }
                 }
diff --git a/Experimental/src/org/sleuthkit/autopsy/experimental/autoingest/Bundle.properties-MERGED b/Experimental/src/org/sleuthkit/autopsy/experimental/autoingest/Bundle.properties-MERGED
index 56a675e256e41fb7f89b9aa01eb7059a07ec8096..823399e0d018c827352747704d53deb468de25ce 100755
--- a/Experimental/src/org/sleuthkit/autopsy/experimental/autoingest/Bundle.properties-MERGED
+++ b/Experimental/src/org/sleuthkit/autopsy/experimental/autoingest/Bundle.properties-MERGED
@@ -205,7 +205,9 @@ DeleteCaseTask.progress.parsingManifest=Parsing manifest file {0}...
 DeleteCaseTask.progress.releasingManifestLock=Releasing lock on the manifest file {0}...
 DeleteCaseTask.progress.startMessage=Starting deletion...
 DeleteOrphanCaseNodesAction.progressDisplayName=Cleanup Case Znodes
+# {0} - item count
 DeleteOrphanCaseNodesDialog.additionalInit.lblNodeCount.text=Znodes found: {0}
+# {0} - item count
 DeleteOrphanCaseNodesDialog.additionalInit.znodesTextArea.countMessage=ZNODES FOUND: {0}
 DeleteOrphanCaseNodesTask.progress.connectingToCoordSvc=Connecting to the coordination service
 # {0} - node path
diff --git a/Experimental/src/org/sleuthkit/autopsy/experimental/configuration/SharedConfiguration.java b/Experimental/src/org/sleuthkit/autopsy/experimental/configuration/SharedConfiguration.java
index fbbbd1999b9f73a11b4ed07e411adc4459a74f05..f7c9c6d8ff5ac7f38622b9bd8fa4e624a12beb68 100644
--- a/Experimental/src/org/sleuthkit/autopsy/experimental/configuration/SharedConfiguration.java
+++ b/Experimental/src/org/sleuthkit/autopsy/experimental/configuration/SharedConfiguration.java
@@ -1,7 +1,7 @@
 /*
  * Autopsy Forensic Browser
  *
- * Copyright 2015 Basis Technology Corp.
+ * Copyright 2015 - 2020 Basis Technology Corp.
  * Contact: carrier <at> sleuthkit <dot> org
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
@@ -151,6 +151,8 @@ private void publishTask(String task) {
     /**
      * Upload the current multi-user ingest settings to a shared folder.
      *
+     * @return
+     *
      * @throws SharedConfigurationException
      * @throws CoordinationServiceException
      * @throws InterruptedException
@@ -208,6 +210,7 @@ public SharedConfigResult uploadConfiguration() throws SharedConfigurationExcept
             uploadCentralRepositorySettings(remoteFolder);
             uploadObjectDetectionClassifiers(remoteFolder);
             uploadPythonModules(remoteFolder);
+            uploadYARASetting(remoteFolder);
 
             try {
                 Files.deleteIfExists(uploadInProgress.toPath());
@@ -222,6 +225,8 @@ public SharedConfigResult uploadConfiguration() throws SharedConfigurationExcept
     /**
      * Download the multi-user settings from a shared folder.
      *
+     * @return
+     *
      * @throws SharedConfigurationException
      * @throws InterruptedException
      */
@@ -252,13 +257,16 @@ public synchronized SharedConfigResult downloadConfiguration() throws SharedConf
             }
 
             try {
-                /* Make sure all recent changes are saved to the preference file. 
-                 This also releases open file handles to the preference files. If this 
-                 is not done, then occasionally downloading of shared configuration 
-                 fails silently, likely because Java/OS is still holding the file handle.
-                 The problem manifests itself by some of the old/original configuration files 
-                 sticking around after shared configuration has seemingly been successfully 
-                 updated. */
+                /*
+                 * Make sure all recent changes are saved to the preference
+                 * file. This also releases open file handles to the preference
+                 * files. If this is not done, then occasionally downloading of
+                 * shared configuration fails silently, likely because Java/OS
+                 * is still holding the file handle. The problem manifests
+                 * itself by some of the old/original configuration files
+                 * sticking around after shared configuration has seemingly been
+                 * successfully updated.
+                 */
                 UserPreferences.saveToStorage();
             } catch (BackingStoreException ex) {
                 throw new SharedConfigurationException("Failed to save shared configuration settings", ex);
@@ -275,6 +283,7 @@ public synchronized SharedConfigResult downloadConfiguration() throws SharedConf
             downloadCentralRepositorySettings(remoteFolder);
             downloadObjectDetectionClassifiers(remoteFolder);
             downloadPythonModules(remoteFolder);
+            downloadYARASettings(remoteFolder);
 
             // Download general settings, then restore the current
             // values for the unshared fields
@@ -344,7 +353,7 @@ private boolean isServiceUp(String serviceName) {
     private void saveNonSharedSettings() {
         sharedConfigMaster = AutoIngestUserPreferences.getSharedConfigMaster();
         sharedConfigFolder = AutoIngestUserPreferences.getSharedConfigFolder();
-	showToolsWarning = AutoIngestUserPreferences.getShowToolsWarning();
+        showToolsWarning = AutoIngestUserPreferences.getShowToolsWarning();
         displayLocalTime = UserPreferences.displayTimesInLocalTime();
         hideKnownFilesInDataSource = UserPreferences.hideKnownFilesInDataSourcesTree();
         hideKnownFilesInViews = UserPreferences.hideKnownFilesInViewsTree();
@@ -360,7 +369,7 @@ private void saveNonSharedSettings() {
     private void restoreNonSharedSettings() {
         AutoIngestUserPreferences.setSharedConfigFolder(sharedConfigFolder);
         AutoIngestUserPreferences.setSharedConfigMaster(sharedConfigMaster);
-	AutoIngestUserPreferences.setShowToolsWarning(showToolsWarning);
+        AutoIngestUserPreferences.setShowToolsWarning(showToolsWarning);
         UserPreferences.setDisplayTimesInLocalTime(displayLocalTime);
         UserPreferences.setHideKnownFilesInDataSourcesTree(hideKnownFilesInDataSource);
         UserPreferences.setHideKnownFilesInViewsTree(hideKnownFilesInViews);
@@ -515,21 +524,23 @@ private static void copyToLocalFolder(String fileName, String localFolder, File
             throw new SharedConfigurationException(String.format("Failed to copy %s to %s", remoteFile.getAbsolutePath(), localSettingsFolder.getAbsolutePath()), ex);
         }
     }
-    
+
     /**
-     * Copy an entire local settings folder to the remote folder, deleting any existing files.
-     * 
+     * Copy an entire local settings folder to the remote folder, deleting any
+     * existing files.
+     *
      * @param localFolder      The local folder to copy
-     * @param remoteBaseFolder The remote folder that will hold a copy of the original folder
-     * 
-     * @throws SharedConfigurationException 
+     * @param remoteBaseFolder The remote folder that will hold a copy of the
+     *                         original folder
+     *
+     * @throws SharedConfigurationException
      */
     private void copyLocalFolderToRemoteFolder(File localFolder, File remoteBaseFolder) throws SharedConfigurationException {
         logger.log(Level.INFO, "Uploading {0} to {1}", new Object[]{localFolder.getAbsolutePath(), remoteBaseFolder.getAbsolutePath()});
-        
+
         File newRemoteFolder = new File(remoteBaseFolder, localFolder.getName());
-        
-        if(newRemoteFolder.exists()) {
+
+        if (newRemoteFolder.exists()) {
             try {
                 FileUtils.deleteDirectory(newRemoteFolder);
             } catch (IOException ex) {
@@ -537,29 +548,30 @@ private void copyLocalFolderToRemoteFolder(File localFolder, File remoteBaseFold
                 throw new SharedConfigurationException(String.format("Failed to delete remote folder {0}", newRemoteFolder.getAbsolutePath()), ex);
             }
         }
-        
+
         try {
             FileUtils.copyDirectoryToDirectory(localFolder, remoteBaseFolder);
         } catch (IOException ex) {
             throw new SharedConfigurationException(String.format("Failed to copy %s to %s", localFolder, remoteBaseFolder.getAbsolutePath()), ex);
-        } 
+        }
     }
-    
+
     /**
-     * Copy an entire remote settings folder to the local folder, deleting any existing files.
-     * No error if the remote folder does not exist.
-     * 
+     * Copy an entire remote settings folder to the local folder, deleting any
+     * existing files. No error if the remote folder does not exist.
+     *
      * @param localFolder      The local folder that will be overwritten.
-     * @param remoteBaseFolder The remote folder holding the folder that will be copied
-     * 
-     * @throws SharedConfigurationException 
+     * @param remoteBaseFolder The remote folder holding the folder that will be
+     *                         copied
+     *
+     * @throws SharedConfigurationException
      */
     private void copyRemoteFolderToLocalFolder(File localFolder, File remoteBaseFolder) throws SharedConfigurationException {
         logger.log(Level.INFO, "Downloading {0} from {1}", new Object[]{localFolder.getAbsolutePath(), remoteBaseFolder.getAbsolutePath()});
-        
+
         // Clean out the local folder regardless of whether the remote version exists. leave the 
         // folder in place since Autopsy expects it to exist.
-        if(localFolder.exists()) {
+        if (localFolder.exists()) {
             try {
                 FileUtils.cleanDirectory(localFolder);
             } catch (IOException ex) {
@@ -567,19 +579,19 @@ private void copyRemoteFolderToLocalFolder(File localFolder, File remoteBaseFold
                 throw new SharedConfigurationException(String.format("Failed to delete files from local folder {0}", localFolder.getAbsolutePath()), ex);
             }
         }
-        
+
         File remoteSubFolder = new File(remoteBaseFolder, localFolder.getName());
-        if(! remoteSubFolder.exists()) {
+        if (!remoteSubFolder.exists()) {
             logger.log(Level.INFO, "{0} does not exist", remoteSubFolder.getAbsolutePath());
             return;
         }
-        
+
         try {
             FileUtils.copyDirectory(remoteSubFolder, localFolder);
         } catch (IOException ex) {
             throw new SharedConfigurationException(String.format("Failed to copy %s from %s", localFolder, remoteBaseFolder.getAbsolutePath()), ex);
-        } 
-    }    
+        }
+    }
 
     /**
      * Upload the basic set of auto-ingest settings to the shared folder.
@@ -899,56 +911,56 @@ private void downloadMultiUserAndGeneralSettings(File remoteFolder) throws Share
 
     /**
      * Upload the object detection classifiers.
-     * 
+     *
      * @param remoteFolder Shared settings folder
-     * 
-     * @throws SharedConfigurationException 
+     *
+     * @throws SharedConfigurationException
      */
     private void uploadObjectDetectionClassifiers(File remoteFolder) throws SharedConfigurationException {
         publishTask("Uploading object detection classfiers");
         File classifiersFolder = new File(PlatformUtil.getObjectDetectionClassifierPath());
         copyLocalFolderToRemoteFolder(classifiersFolder, remoteFolder);
     }
-    
+
     /**
      * Download the object detection classifiers.
-     * 
+     *
      * @param remoteFolder Shared settings folder
-     * 
-     * @throws SharedConfigurationException 
+     *
+     * @throws SharedConfigurationException
      */
     private void downloadObjectDetectionClassifiers(File remoteFolder) throws SharedConfigurationException {
         publishTask("Downloading object detection classfiers");
         File classifiersFolder = new File(PlatformUtil.getObjectDetectionClassifierPath());
         copyRemoteFolderToLocalFolder(classifiersFolder, remoteFolder);
     }
-    
-        /**
+
+    /**
      * Upload the Python modules.
-     * 
+     *
      * @param remoteFolder Shared settings folder
-     * 
-     * @throws SharedConfigurationException 
+     *
+     * @throws SharedConfigurationException
      */
     private void uploadPythonModules(File remoteFolder) throws SharedConfigurationException {
         publishTask("Uploading python modules");
         File classifiersFolder = new File(PlatformUtil.getUserPythonModulesPath());
         copyLocalFolderToRemoteFolder(classifiersFolder, remoteFolder);
     }
-    
+
     /**
      * Download the Python modules.
-     * 
+     *
      * @param remoteFolder Shared settings folder
-     * 
-     * @throws SharedConfigurationException 
+     *
+     * @throws SharedConfigurationException
      */
     private void downloadPythonModules(File remoteFolder) throws SharedConfigurationException {
         publishTask("Downloading python modules");
         File classifiersFolder = new File(PlatformUtil.getUserPythonModulesPath());
         copyRemoteFolderToLocalFolder(classifiersFolder, remoteFolder);
     }
-    
+
     /**
      * Upload settings and hash databases to the shared folder. The general
      * algorithm is: - Copy the general settings in hashsets.xml - For each hash
@@ -1093,12 +1105,10 @@ private void downloadHashDbSettings(File remoteFolder) throws SharedConfiguratio
         Map<String, String> remoteVersions = readVersionsFromFile(remoteVersionFile);
 
         /*
-        Iterate through remote list
-            If local needs it, download
-
-        Download remote settings files to local
-        Download remote versions file to local
-        HashDbManager reload
+         * Iterate through remote list If local needs it, download
+         *
+         * Download remote settings files to local Download remote versions file
+         * to local HashDbManager reload
          */
         File localDb = new File("");
         File sharedDb = new File("");
@@ -1247,7 +1257,7 @@ private static List<String> getHashFileNamesFromSettingsFile() throws SharedConf
                 if (hashDb.getIndexPath().isEmpty() && hashDb.getDatabasePath().isEmpty()) {
                     continue;
                 }
-				
+
                 if (hashDb.hasIndexOnly()) {
                     results.add(hashDb.getIndexPath());
                 } else {
@@ -1356,4 +1366,41 @@ private static String calculateCRC(String filePath) throws SharedConfigurationEx
             throw new SharedConfigurationException(String.format("Failed to calculate CRC for %s", file.getAbsolutePath()), ex);
         }
     }
+
+    /**
+     * Copy the YARA settings directory from the local directory to the remote
+     * directory.
+     *
+     * @param remoteFolder Shared settings folder
+     *
+     * @throws
+     * org.sleuthkit.autopsy.experimental.configuration.SharedConfiguration.SharedConfigurationException
+     */
+    private void uploadYARASetting(File remoteFolder) throws SharedConfigurationException {
+        publishTask("Uploading YARA module configuration");
+
+        File localYara = Paths.get(PlatformUtil.getUserDirectory().getAbsolutePath(), "yara").toFile();
+
+        if (!localYara.exists()) {
+            return;
+        }
+
+        copyLocalFolderToRemoteFolder(localYara, remoteFolder);
+    }
+
+    /**
+     * Downloads the YARA settings folder from the remote directory to the local
+     * one.
+     *
+     * @param remoteFolder Shared settings folder
+     *
+     * @throws
+     * org.sleuthkit.autopsy.experimental.configuration.SharedConfiguration.SharedConfigurationException
+     */
+    private void downloadYARASettings(File remoteFolder) throws SharedConfigurationException {
+        publishTask("Downloading YARA module configuration");
+        File localYara = Paths.get(PlatformUtil.getUserDirectory().getAbsolutePath(), "yara").toFile();
+
+        copyRemoteFolderToLocalFolder(localYara, remoteFolder);
+    }
 }
diff --git a/KeywordSearch/nbproject/project.xml b/KeywordSearch/nbproject/project.xml
index aa8cb0cf8efaab9e157feef06cfde644ed9710c7..c6f056f5a4dee72f4f5d4ea76fd53fe93edf76c4 100644
--- a/KeywordSearch/nbproject/project.xml
+++ b/KeywordSearch/nbproject/project.xml
@@ -118,7 +118,8 @@
                     <build-prerequisite/>
                     <compile-dependency/>
                     <run-dependency>
-                        <specification-version>1.0</specification-version>
+                        <release-version>1</release-version>
+                        <specification-version>23</specification-version>
                     </run-dependency>
                 </dependency>
                 <dependency>
diff --git a/KeywordSearch/src/org/sleuthkit/autopsy/keywordsearch/Chunker.java b/KeywordSearch/src/org/sleuthkit/autopsy/keywordsearch/Chunker.java
index 08ca0ab5110d975598e6f216ab3f08e514b15919..fd0f11ab74a6e4393d88424b1d62769d3af9f85a 100644
--- a/KeywordSearch/src/org/sleuthkit/autopsy/keywordsearch/Chunker.java
+++ b/KeywordSearch/src/org/sleuthkit/autopsy/keywordsearch/Chunker.java
@@ -187,7 +187,6 @@ private static StringBuilder replaceInvalidUTF16(String s) {
     private static StringBuilder sanitize(String s) {
         String normStr = Normalizer.normalize(s, Normalizer.Form.NFKC);
         return sanitizeToUTF8(replaceInvalidUTF16(normStr));
-
     }
 
     @Override
@@ -336,8 +335,9 @@ private void readToWhiteSpaceHelper(int maxBytes, StringBuilder currentChunk, St
                 String chunkSegment;
                 if (Character.isHighSurrogate(ch)) {
                     //read another char into the buffer.
-                    charsRead = reader.read(tempChunkBuf, 1, 1);
-                    if (charsRead == -1) {
+                    int surrogateCharsRead = reader.read(tempChunkBuf, 1, 1);
+                    charsRead += surrogateCharsRead;
+                    if (surrogateCharsRead == -1) {
                         //this is the last chunk, so just drop the unpaired surrogate
                         endOfReaderReached = true;
                         return;
@@ -352,17 +352,32 @@ private void readToWhiteSpaceHelper(int maxBytes, StringBuilder currentChunk, St
 
                 //cleanup any invalid utf-16 sequences
                 StringBuilder sanitizedChunkSegment = sanitize(chunkSegment);
-                //check for whitespace.
-                whitespaceFound = Character.isWhitespace(sanitizedChunkSegment.codePointAt(0));
-                //add read chars to the chunk and update the length.
-                currentChunk.append(sanitizedChunkSegment);
-                chunkSizeBytes += sanitizedChunkSegment.toString().getBytes(UTF_8).length;
-
+                //get the length in utf8 bytes of the read chars
+                int segmentSize = chunkSegment.getBytes(UTF_8).length;
+                
                 // lower case the string and get it's size. NOTE: lower casing can 
                 // change the size of the string.
                 String lowerCasedSegment = sanitizedChunkSegment.toString().toLowerCase();
-                lowerCasedChunk.append(lowerCasedSegment);
-                lowerCasedChunkSizeBytes += lowerCasedSegment.getBytes(UTF_8).length;
+                int lowerCasedSegmentSize = lowerCasedSegment.getBytes(UTF_8).length;
+                
+                //if it will not put us past maxBytes
+                if ((chunkSizeBytes + segmentSize < maxBytes - MAX_CHAR_SIZE_INCREASE_IN_BYTES)
+                        && (lowerCasedChunkSizeBytes + lowerCasedSegmentSize < maxBytes - MAX_CHAR_SIZE_INCREASE_IN_BYTES)) {
+
+                    //add read chars to the chunk and update the length.
+                    currentChunk.append(sanitizedChunkSegment);
+                    chunkSizeBytes += segmentSize;
+
+                    lowerCasedChunk.append(lowerCasedSegment);
+                    lowerCasedChunkSizeBytes += lowerCasedSegmentSize;
+                    
+                    //check for whitespace.
+                    whitespaceFound = Character.isWhitespace(sanitizedChunkSegment.codePointAt(0));
+                } else {
+                    //unread it, and break out of read loop.
+                    reader.unread(tempChunkBuf, 0, charsRead);
+                    return;
+                }
             }
         }
     }
diff --git a/Tika/manifest.mf b/Tika/manifest.mf
index 770d374a5dae80ad25a8523b23518be57616a903..d5e404d1e321247c95869053adb97024d560007c 100755
--- a/Tika/manifest.mf
+++ b/Tika/manifest.mf
@@ -1,6 +1,7 @@
 Manifest-Version: 1.0
 AutoUpdate-Show-In-Client: true
-OpenIDE-Module: org.sleuthkit.autopsy.Tika
+OpenIDE-Module: org.sleuthkit.autopsy.Tika/1
+OpenIDE-Module-Implementation-Version: 1
 OpenIDE-Module-Localizing-Bundle: org/sleuthkit/autopsy/Tika/Bundle.properties
-OpenIDE-Module-Specification-Version: 1.0
+OpenIDE-Module-Specification-Version: 23
 
diff --git a/ManifestTool/ManifestGenerationAlgorithms.au3 b/Tools/ManifestTool/ManifestGenerationAlgorithms.au3
similarity index 100%
rename from ManifestTool/ManifestGenerationAlgorithms.au3
rename to Tools/ManifestTool/ManifestGenerationAlgorithms.au3
diff --git a/ManifestTool/ManifestTool.au3 b/Tools/ManifestTool/ManifestTool.au3
similarity index 100%
rename from ManifestTool/ManifestTool.au3
rename to Tools/ManifestTool/ManifestTool.au3
diff --git a/ManifestTool/ManifestTool.exe b/Tools/ManifestTool/ManifestTool.exe
similarity index 100%
rename from ManifestTool/ManifestTool.exe
rename to Tools/ManifestTool/ManifestTool.exe
diff --git a/ZookeeperNodeMigration/.gitignore b/Tools/ZookeeperNodeMigration/.gitignore
similarity index 62%
rename from ZookeeperNodeMigration/.gitignore
rename to Tools/ZookeeperNodeMigration/.gitignore
index 0e56a449c757c3333320e858ada0ebab0df8aff1..46cba4a7dd698d3709a826334085e3365667fbad 100755
--- a/ZookeeperNodeMigration/.gitignore
+++ b/Tools/ZookeeperNodeMigration/.gitignore
@@ -1,3 +1,2 @@
 /nbproject/private/
-/build/
-
+/build/
\ No newline at end of file
diff --git a/ZookeeperNodeMigration/build.xml b/Tools/ZookeeperNodeMigration/build.xml
similarity index 100%
rename from ZookeeperNodeMigration/build.xml
rename to Tools/ZookeeperNodeMigration/build.xml
diff --git a/ZookeeperNodeMigration/dist/README.TXT b/Tools/ZookeeperNodeMigration/dist/README.TXT
similarity index 100%
rename from ZookeeperNodeMigration/dist/README.TXT
rename to Tools/ZookeeperNodeMigration/dist/README.TXT
diff --git a/ZookeeperNodeMigration/dist/ZookeeperNodeMigration.jar b/Tools/ZookeeperNodeMigration/dist/ZookeeperNodeMigration.jar
similarity index 95%
rename from ZookeeperNodeMigration/dist/ZookeeperNodeMigration.jar
rename to Tools/ZookeeperNodeMigration/dist/ZookeeperNodeMigration.jar
index 92b11bc86ac07058ee7d31ad5a4a2028869611ca..b2bb4f8e60e391a085f784853532cb63e49d26f4 100755
Binary files a/ZookeeperNodeMigration/dist/ZookeeperNodeMigration.jar and b/Tools/ZookeeperNodeMigration/dist/ZookeeperNodeMigration.jar differ
diff --git a/ZookeeperNodeMigration/dist/lib/curator-client-2.8.0.jar b/Tools/ZookeeperNodeMigration/dist/lib/curator-client-2.8.0.jar
similarity index 100%
rename from ZookeeperNodeMigration/dist/lib/curator-client-2.8.0.jar
rename to Tools/ZookeeperNodeMigration/dist/lib/curator-client-2.8.0.jar
diff --git a/ZookeeperNodeMigration/dist/lib/curator-framework-2.8.0.jar b/Tools/ZookeeperNodeMigration/dist/lib/curator-framework-2.8.0.jar
similarity index 100%
rename from ZookeeperNodeMigration/dist/lib/curator-framework-2.8.0.jar
rename to Tools/ZookeeperNodeMigration/dist/lib/curator-framework-2.8.0.jar
diff --git a/ZookeeperNodeMigration/dist/lib/curator-recipes-2.8.0.jar b/Tools/ZookeeperNodeMigration/dist/lib/curator-recipes-2.8.0.jar
similarity index 100%
rename from ZookeeperNodeMigration/dist/lib/curator-recipes-2.8.0.jar
rename to Tools/ZookeeperNodeMigration/dist/lib/curator-recipes-2.8.0.jar
diff --git a/ZookeeperNodeMigration/dist/lib/guava-17.0.jar b/Tools/ZookeeperNodeMigration/dist/lib/guava-17.0.jar
similarity index 100%
rename from ZookeeperNodeMigration/dist/lib/guava-17.0.jar
rename to Tools/ZookeeperNodeMigration/dist/lib/guava-17.0.jar
diff --git a/ZookeeperNodeMigration/dist/lib/log4j-1.2.17.jar b/Tools/ZookeeperNodeMigration/dist/lib/log4j-1.2.17.jar
similarity index 100%
rename from ZookeeperNodeMigration/dist/lib/log4j-1.2.17.jar
rename to Tools/ZookeeperNodeMigration/dist/lib/log4j-1.2.17.jar
diff --git a/ZookeeperNodeMigration/dist/lib/slf4j-api-1.7.24.jar b/Tools/ZookeeperNodeMigration/dist/lib/slf4j-api-1.7.24.jar
similarity index 100%
rename from ZookeeperNodeMigration/dist/lib/slf4j-api-1.7.24.jar
rename to Tools/ZookeeperNodeMigration/dist/lib/slf4j-api-1.7.24.jar
diff --git a/ZookeeperNodeMigration/dist/lib/slf4j-log4j12-1.7.6.jar b/Tools/ZookeeperNodeMigration/dist/lib/slf4j-log4j12-1.7.6.jar
similarity index 100%
rename from ZookeeperNodeMigration/dist/lib/slf4j-log4j12-1.7.6.jar
rename to Tools/ZookeeperNodeMigration/dist/lib/slf4j-log4j12-1.7.6.jar
diff --git a/ZookeeperNodeMigration/dist/lib/zookeeper-3.4.6.jar b/Tools/ZookeeperNodeMigration/dist/lib/zookeeper-3.4.6.jar
similarity index 100%
rename from ZookeeperNodeMigration/dist/lib/zookeeper-3.4.6.jar
rename to Tools/ZookeeperNodeMigration/dist/lib/zookeeper-3.4.6.jar
diff --git a/ZookeeperNodeMigration/docs/README.TXT b/Tools/ZookeeperNodeMigration/docs/README.TXT
similarity index 100%
rename from ZookeeperNodeMigration/docs/README.TXT
rename to Tools/ZookeeperNodeMigration/docs/README.TXT
diff --git a/ZookeeperNodeMigration/manifest.mf b/Tools/ZookeeperNodeMigration/manifest.mf
similarity index 100%
rename from ZookeeperNodeMigration/manifest.mf
rename to Tools/ZookeeperNodeMigration/manifest.mf
diff --git a/ZookeeperNodeMigration/nbproject/build-impl.xml b/Tools/ZookeeperNodeMigration/nbproject/build-impl.xml
similarity index 100%
rename from ZookeeperNodeMigration/nbproject/build-impl.xml
rename to Tools/ZookeeperNodeMigration/nbproject/build-impl.xml
diff --git a/ZookeeperNodeMigration/nbproject/project.properties b/Tools/ZookeeperNodeMigration/nbproject/project.properties
similarity index 100%
rename from ZookeeperNodeMigration/nbproject/project.properties
rename to Tools/ZookeeperNodeMigration/nbproject/project.properties
diff --git a/ZookeeperNodeMigration/nbproject/project.xml b/Tools/ZookeeperNodeMigration/nbproject/project.xml
similarity index 100%
rename from ZookeeperNodeMigration/nbproject/project.xml
rename to Tools/ZookeeperNodeMigration/nbproject/project.xml
diff --git a/ZookeeperNodeMigration/release/curator-client-2.8.0.jar b/Tools/ZookeeperNodeMigration/release/curator-client-2.8.0.jar
similarity index 100%
rename from ZookeeperNodeMigration/release/curator-client-2.8.0.jar
rename to Tools/ZookeeperNodeMigration/release/curator-client-2.8.0.jar
diff --git a/ZookeeperNodeMigration/release/curator-framework-2.8.0.jar b/Tools/ZookeeperNodeMigration/release/curator-framework-2.8.0.jar
similarity index 100%
rename from ZookeeperNodeMigration/release/curator-framework-2.8.0.jar
rename to Tools/ZookeeperNodeMigration/release/curator-framework-2.8.0.jar
diff --git a/ZookeeperNodeMigration/release/curator-recipes-2.8.0.jar b/Tools/ZookeeperNodeMigration/release/curator-recipes-2.8.0.jar
similarity index 100%
rename from ZookeeperNodeMigration/release/curator-recipes-2.8.0.jar
rename to Tools/ZookeeperNodeMigration/release/curator-recipes-2.8.0.jar
diff --git a/ZookeeperNodeMigration/release/guava-17.0.jar b/Tools/ZookeeperNodeMigration/release/guava-17.0.jar
similarity index 100%
rename from ZookeeperNodeMigration/release/guava-17.0.jar
rename to Tools/ZookeeperNodeMigration/release/guava-17.0.jar
diff --git a/ZookeeperNodeMigration/release/log4j-1.2.17.jar b/Tools/ZookeeperNodeMigration/release/log4j-1.2.17.jar
similarity index 100%
rename from ZookeeperNodeMigration/release/log4j-1.2.17.jar
rename to Tools/ZookeeperNodeMigration/release/log4j-1.2.17.jar
diff --git a/ZookeeperNodeMigration/release/slf4j-api-1.7.24.jar b/Tools/ZookeeperNodeMigration/release/slf4j-api-1.7.24.jar
similarity index 100%
rename from ZookeeperNodeMigration/release/slf4j-api-1.7.24.jar
rename to Tools/ZookeeperNodeMigration/release/slf4j-api-1.7.24.jar
diff --git a/ZookeeperNodeMigration/release/slf4j-log4j12-1.7.6.jar b/Tools/ZookeeperNodeMigration/release/slf4j-log4j12-1.7.6.jar
similarity index 100%
rename from ZookeeperNodeMigration/release/slf4j-log4j12-1.7.6.jar
rename to Tools/ZookeeperNodeMigration/release/slf4j-log4j12-1.7.6.jar
diff --git a/ZookeeperNodeMigration/release/zookeeper-3.4.6.jar b/Tools/ZookeeperNodeMigration/release/zookeeper-3.4.6.jar
similarity index 100%
rename from ZookeeperNodeMigration/release/zookeeper-3.4.6.jar
rename to Tools/ZookeeperNodeMigration/release/zookeeper-3.4.6.jar
diff --git a/ZookeeperNodeMigration/src/zookeepernodemigration/AutoIngestJobNodeData.java b/Tools/ZookeeperNodeMigration/src/zookeepernodemigration/AutoIngestJobNodeData.java
similarity index 100%
rename from ZookeeperNodeMigration/src/zookeepernodemigration/AutoIngestJobNodeData.java
rename to Tools/ZookeeperNodeMigration/src/zookeepernodemigration/AutoIngestJobNodeData.java
diff --git a/ZookeeperNodeMigration/src/zookeepernodemigration/TimeStampUtils.java b/Tools/ZookeeperNodeMigration/src/zookeepernodemigration/TimeStampUtils.java
similarity index 100%
rename from ZookeeperNodeMigration/src/zookeepernodemigration/TimeStampUtils.java
rename to Tools/ZookeeperNodeMigration/src/zookeepernodemigration/TimeStampUtils.java
diff --git a/ZookeeperNodeMigration/src/zookeepernodemigration/ZookeeperNodeMigration.java b/Tools/ZookeeperNodeMigration/src/zookeepernodemigration/ZookeeperNodeMigration.java
similarity index 100%
rename from ZookeeperNodeMigration/src/zookeepernodemigration/ZookeeperNodeMigration.java
rename to Tools/ZookeeperNodeMigration/src/zookeepernodemigration/ZookeeperNodeMigration.java
diff --git a/appveyor.yml b/appveyor.yml
index bc3c7fa82dedea596719db6878779aad4a2e674a..fe70c368501d751568ab14a070bd69117d3a52a8 100644
--- a/appveyor.yml
+++ b/appveyor.yml
@@ -6,14 +6,13 @@ cache:
   - C:\ProgramData\chocolatey\lib
   - '%APPVEYOR_BUILD_FOLDER%\Core\test\qa-functional\data'
 
-
 image: Visual Studio 2015
 platform: x64
 environment:
-    global:
-      TSK_HOME: "C:\\sleuthkit"
-      JDK_HOME: C:\Program Files\Java\jdk1.8.0
-      PYTHON: "C:\\Python36-x64"
+  global:
+    TSK_HOME: "C:\\sleuthkit"
+    JDK_HOME: C:\Program Files\Java\jdk1.8.0
+    PYTHON: "C:\\Python36-x64"
 
 install:
   - ps: choco install nuget.commandline
@@ -42,4 +41,6 @@ build_script:
   - cd %APPVEYOR_BUILD_FOLDER%
   - cmd: ant -q build
 
-test: off
+test_script:
+  - cd %APPVEYOR_BUILD_FOLDER%
+  - cmd: ant -q test-no-regression
diff --git a/build.xml b/build.xml
index 7377d59c04f238240221b2ae1bb1b1fdf88f299c..fc25cba14d208609f58c6359ab677885079827b3 100644
--- a/build.xml
+++ b/build.xml
@@ -24,7 +24,7 @@
     <!-- Verify that the java version running is . -->
     <fail message="Unsupported Java version: ${ant.java.version}. 
             Make sure that the Java version is 1.8.0_66 or higher."
-    unless="supported-java-versions"/>
+          unless="supported-java-versions"/>
 
     <!-- Determine platform and include specific file -->
     <condition property="os.family" value="unix">  
@@ -68,22 +68,54 @@
     </target>
 
     <target name="clean" depends="suite.clean">
-	<delete includeEmptyDirs="true" failonerror="false">
+        <delete includeEmptyDirs="true" failonerror="false">
             <fileset dir="docs\doxygen-user\user-docs" includes="**/*"/>
-	</delete>
+        </delete>
 
         <delete includeEmptyDirs="true" failonerror="false">
             <fileset dir="docs\doxygen\doxygen_docs\api-docs" includes="**/*"/>
-	</delete>
+        </delete>
         
         <delete includeemptydirs="true" failonerror="false">
             <fileset dir="${basedir}/docs/doxygen-dev/build-docs" includes="**/*"/>
         </delete>
     </target>
+    
+    <!-- This target is similar to the regular test target that calls test on all nbm's, 
+    but this target excludes the Testing nbm which runs the regression tests -->
+    <target name="test-no-regression" depends="build" description="Runs tests for all modules in the suite excluding the regression tests of the Testing NBM.">
+        <!--taken from https://stackoverflow.com/a/10859103; remove "Testing" from the modules and provide 'modulesNoTesting' as result. -->
+        <property name="modulesBeforeChange" value="${modules}"/>
+        <script language="javascript">
+            <![CDATA[ 
+                var before = project.getProperty("modulesBeforeChange");
+                var separator = ":";
+                var testingNbm = "Testing";
+                var beforeSplit = before.split(separator);
+                var items = [];
+                for (var i = 0; i < beforeSplit.length; i++) {
+                    if (beforeSplit[i].toUpperCase() !== testingNbm.toUpperCase()) {
+                        items.push(beforeSplit[i]);
+                    }
+                }
+                var itemsJoined = items.join(separator);
+                project.setNewProperty("modulesNoTesting", itemsJoined);
+            ]]>
+        </script>
+
+        <sortsuitemodules unsortedmodules="${modulesNoTesting}" sortedmodulesproperty="modules.test.sorted" sorttests="true"/>
+        <!-- continue on fail -->
+        <property name="continue.after.failing.tests" value="true"/>
+        <subant target="test" buildpath="${modules.test.sorted}" inheritrefs="false" inheritall="false">
+            <property name="cluster.path.evaluated" value="${cluster.path.evaluated}"/> <!-- Just for speed of pre-7.0 projects -->
+            <property name="harness.taskdefs.done" value="${harness.taskdefs.done}"/> <!-- optimization -->
+            <property name="continue.after.failing.tests" value="${continue.after.failing.tests}"/>
+        </subant>
+    </target>
 
     <!-- This target will create a custom ZIP file for us.  It first uses the general
-      ZIP target and then opens it up and adds in any files that we want.  This is where we customize the
-      version number. -->
+    ZIP target and then opens it up and adds in any files that we want.  This is where we customize the
+    version number. -->
     <target name="build-zip" depends="doxygen, suite.build-zip"> <!--,findJRE" -->
         
         <property name="release.dir" value="${nbdist.dir}/${app.name}"/>
@@ -106,7 +138,7 @@
         <copy file="${basedir}/NEWS.txt" tofile="${zip-tmp}/${app.name}/NEWS.txt"/>
         <copy file="${basedir}/Running_Linux_OSX.txt" tofile="${zip-tmp}/${app.name}/Running_Linux_OSX.txt"/>
         <copy file="${basedir}/unix_setup.sh" tofile="${zip-tmp}/${app.name}/unix_setup.sh"/>
-        <copy file="${basedir}/ManifestTool/ManifestTool.exe" todir="${zip-tmp}/${app.name}/bin"/>        
+        <copy file="${basedir}/Tools/ManifestTool/ManifestTool.exe" todir="${zip-tmp}/${app.name}/bin"/>        
         
         <copy file="${basedir}/icons/icon.ico" tofile="${zip-tmp}/${app.name}/icon.ico" overwrite="true"/>
         
@@ -117,10 +149,10 @@
         
         <!-- Copy the ZooKeeper migration tool, it's JAR files, and documentation -->
         <copy flatten="false" todir="${zip-tmp}/${app.name}/autopsy/ZookeeperNodeMigration">
-            <fileset dir="${basedir}/ZookeeperNodeMigration/dist"/>
+            <fileset dir="${basedir}/Tools/ZookeeperNodeMigration/dist"/>
         </copy>
         <copy flatten="false" todir="${zip-tmp}/${app.name}/autopsy/ZookeeperNodeMigration" overwrite="true">
-            <fileset dir="${basedir}/ZookeeperNodeMigration/docs"/>
+            <fileset dir="${basedir}/Tools/ZookeeperNodeMigration/docs"/>
         </copy>
 
         <property name="app.property.file" value="${zip-tmp}/${app.name}/etc/${app.name}.conf" />
@@ -128,15 +160,15 @@
         <!-- for Japanese localized version add option:  -Duser.language=ja -->
         
             
-            <if>
-                <equals arg1="${os.family}" arg2="mac"/>
-                <then>
-                    <property name="jvm.options" value="&quot;${jvm-value} -J-Xdock:name=${app.title}&quot;"/>
-                </then>
-                <else>
-                    <property name="jvm.options" value="&quot;${jvm-value}&quot;"/>
-                </else>
-            </if>
+        <if>
+            <equals arg1="${os.family}" arg2="mac"/>
+            <then>
+                <property name="jvm.options" value="&quot;${jvm-value} -J-Xdock:name=${app.title}&quot;"/>
+            </then>
+            <else>
+                <property name="jvm.options" value="&quot;${jvm-value}&quot;"/>
+            </else>
+        </if>
                 
         <propertyfile file="${app.property.file}">
             <!-- Note: can be higher on 64 bit systems, should be in sync with project.properties -->
@@ -146,9 +178,9 @@
         <replace file="${app.property.file}" token="@JVM_OPTIONS" value="${jvm.options}" />
         
         <!-- We want to remove the dlls in autopsy/modules/lib because they will
-             shadow the files in the autopsy/modules/lib/ARCHITECTURE folder in the JAR.
-             These files are legacy from when we used to copy the dlls to this location. 
-             This check should do away in the future. Added Sept '13-->
+        shadow the files in the autopsy/modules/lib/ARCHITECTURE folder in the JAR.
+        These files are legacy from when we used to copy the dlls to this location. 
+        This check should do away in the future. Added Sept '13-->
         <delete failonerror="false">
             <fileset dir="${zip-tmp}/${app.name}/autopsy/modules/lib">
                 <include name="libtsk_jni.dll" />
@@ -188,9 +220,9 @@
     
     <target name="input-build-type" unless="build.type">
         <input addProperty="build.type"
-            message="Enter the desired build type:"
-                validargs="DEVELOPMENT,RELEASE"
-                defaultvalue="DEVELOPMENT"/>
+               message="Enter the desired build type:"
+               validargs="DEVELOPMENT,RELEASE"
+               defaultvalue="DEVELOPMENT"/>
     </target>
     
     <target name="input-version" unless="app.version">
@@ -228,21 +260,21 @@
         <echo>${app.name} branding</echo>
           
         <propertyfile
-        file="${branding.dir}/core/core.jar/org/netbeans/core/startup/Bundle.properties" 
-        comment="Updated by build script">
+            file="${branding.dir}/core/core.jar/org/netbeans/core/startup/Bundle.properties" 
+            comment="Updated by build script">
             <entry key="currentVersion" value="${app.title} ${app.version}" />
         </propertyfile>
  
         <propertyfile
-        file="${branding.dir}/modules/org-netbeans-core-windows.jar/org/netbeans/core/windows/view/ui/Bundle.properties"
-        comment="Updated by build script">
+            file="${branding.dir}/modules/org-netbeans-core-windows.jar/org/netbeans/core/windows/view/ui/Bundle.properties"
+            comment="Updated by build script">
             <entry key="CTL_MainWindow_Title" value="${app.title} ${app.version}" />
             <entry key="CTL_MainWindow_Title_No_Project" value="${app.title} ${app.version}" />
         </propertyfile>
         
         <propertyfile
-        file="${basedir}/Core/src/org/sleuthkit/autopsy/coreutils/Version.properties"
-        comment="Updated by build script">
+            file="${basedir}/Core/src/org/sleuthkit/autopsy/coreutils/Version.properties"
+            comment="Updated by build script">
             <entry key="app.name" value="${app.title}" />
             <entry key="app.version" value="${app.version}" />
             <entry key="build.type" value="${build.type}" />
@@ -329,7 +361,7 @@
         <antcall target="build-installer-${os.family}" />
     </target>
     
-     <target name="chmod_executables" >
+    <target name="chmod_executables" >
         <chmod perm="a+x">
             <fileset dir="${cluster}/markmckinnon" casesensitive="no" id="mm">
                 <include name="*_linux"/>
diff --git a/test/script/tskdbdiff.py b/test/script/tskdbdiff.py
index f152cd923a5d08675172042edc3deba19d6e0675..e202c3e111a490cb7bb3006a062d8f95fd1b4a10 100644
--- a/test/script/tskdbdiff.py
+++ b/test/script/tskdbdiff.py
@@ -464,6 +464,16 @@ def normalize_db_entry(line, files_table, vs_parts_table, vs_info_table, fs_info
 
     # remove object ID
     if files_index:
+    
+        # Ignore TIFF size and hash if extracted from PDFs.
+        # See JIRA-6951 for more details.
+        # index -1 = last element in the list, which is extension
+        # index -3 = 3rd from the end, which is the parent path.
+        if fields_list[-1] == "'tif'" and fields_list[-3].endswith(".pdf/'"):
+            fields_list[15] = "'SIZE_IGNORED'"
+            fields_list[23] = "'MD5_IGNORED'"
+            fields_list[24] = "'SHA256_IGNORED'"
+            
         newLine = ('INSERT INTO "tsk_files" VALUES(' + ', '.join(fields_list[1:]) + ');') 
         # Remove object ID from Unalloc file name
         newLine = re.sub('Unalloc_[0-9]+_', 'Unalloc_', newLine)
diff --git a/thirdparty/rr-full/plugins/shellactivities.pl b/thirdparty/rr-full/plugins/shellactivities.pl
index 8b1691753625c29e8b7c50a94984baa1bbcf4fee..5df9f5615f6c41b2fe722dd32407d14b6327b533 100644
--- a/thirdparty/rr-full/plugins/shellactivities.pl
+++ b/thirdparty/rr-full/plugins/shellactivities.pl
@@ -70,6 +70,7 @@ sub processShellActivities {
 	::rptMsg("");
 	
 	while ($offset < ($sz - 10)) {
+        
 # Code to locate the appropriate identifier		
 		$tag = 1;
 		while ($tag) {
@@ -78,9 +79,15 @@ sub processShellActivities {
 			}
 			else {
 				$offset++;
+                # Check if at end of file and exit loop if it is
+                last if ($offset >= $sz ); 
 			}
 		}
-		
+
+		# Check if at end of file and exit loop if it is
+        last if ($offset >= $sz ); 
+
+        
 		$offset += 2;
 		$l = unpack("C",substr($data,$offset,1));
 #		::rptMsg("String Length: ".sprintf "0x%x",$l);
diff --git a/thunderbirdparser/nbproject/project.xml b/thunderbirdparser/nbproject/project.xml
index 6643e72e0a6806340b57caf42e754fdc545cbdd9..b4d9ca8cbfeed2614ead6c8667fc28d308f890a6 100644
--- a/thunderbirdparser/nbproject/project.xml
+++ b/thunderbirdparser/nbproject/project.xml
@@ -44,7 +44,8 @@
                     <build-prerequisite/>
                     <compile-dependency/>
                     <run-dependency>
-                        <specification-version>1.0</specification-version>
+                        <release-version>1</release-version>
+                        <specification-version>23</specification-version>
                     </run-dependency>
                 </dependency>
                 <dependency>
diff --git a/thunderbirdparser/src/org/sleuthkit/autopsy/thunderbirdparser/MimeJ4MessageParser.java b/thunderbirdparser/src/org/sleuthkit/autopsy/thunderbirdparser/MimeJ4MessageParser.java
index 6a95e8729af7189a7701e160e72bb638d77f7fa4..828968d750be880ace3de728974ff955c2876d08 100755
--- a/thunderbirdparser/src/org/sleuthkit/autopsy/thunderbirdparser/MimeJ4MessageParser.java
+++ b/thunderbirdparser/src/org/sleuthkit/autopsy/thunderbirdparser/MimeJ4MessageParser.java
@@ -318,8 +318,10 @@ private static void handleAttachment(EmailMessage email, Entity e, long fileID,
         
         Body body = e.getBody();
         if (body instanceof SingleBody) {
+            long fileLength;
             try (EncodedFileOutputStream fos = new EncodedFileOutputStream(new FileOutputStream(outPath), TskData.EncodingType.XOR1)) {
                 ((SingleBody) body).writeTo(fos);
+                fileLength = fos.getBytesWritten();
             } catch (IOException ex) {
                 logger.log(Level.WARNING, "Failed to create file output stream for: " + outPath, ex); //NON-NLS
                 return;
@@ -328,7 +330,7 @@ private static void handleAttachment(EmailMessage email, Entity e, long fileID,
             EmailMessage.Attachment attach = new EmailMessage.Attachment();
             attach.setName(filename);
             attach.setLocalPath(relModuleOutputPath + uniqueFilename);
-            attach.setSize(new File(outPath).length());
+            attach.setSize(fileLength);
             attach.setEncodingType(TskData.EncodingType.XOR1);
             email.addAttachment(attach);
         } 
diff --git a/thunderbirdparser/src/org/sleuthkit/autopsy/thunderbirdparser/ThunderbirdMboxFileIngestModule.java b/thunderbirdparser/src/org/sleuthkit/autopsy/thunderbirdparser/ThunderbirdMboxFileIngestModule.java
index 0f9759819f9adc50616ed9f0c7a93ab8b838dd92..12f3342daca83eb74205b7dfa23c9cc204d10a5a 100644
--- a/thunderbirdparser/src/org/sleuthkit/autopsy/thunderbirdparser/ThunderbirdMboxFileIngestModule.java
+++ b/thunderbirdparser/src/org/sleuthkit/autopsy/thunderbirdparser/ThunderbirdMboxFileIngestModule.java
@@ -23,9 +23,11 @@
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
+import java.util.HashMap;
 import java.util.HashSet;
 import java.util.Iterator;
 import java.util.List;
+import java.util.Map;
 import java.util.Set;
 import java.util.logging.Level;
 import java.util.regex.Matcher;
@@ -139,6 +141,10 @@ public ProcessResult process(AbstractFile abstractFile) {
         boolean isPstFile = PstParser.isPstFile(abstractFile);
         boolean isVcardFile = VcardParser.isVcardFile(abstractFile);
         
+        if (context.fileIngestIsCancelled()) {
+            return ProcessResult.OK;
+        }
+        
         if (isMbox || isEMLFile || isPstFile || isVcardFile  ) {
             try {
                 communicationArtifactsHelper = new CommunicationArtifactsHelper(currentCase.getSleuthkitCase(),
@@ -148,7 +154,7 @@ public ProcessResult process(AbstractFile abstractFile) {
                 return ProcessResult.ERROR;
             }
         }
-        
+
         if (isMbox) {
             return processMBox(abstractFile);
         }
@@ -164,7 +170,7 @@ public ProcessResult process(AbstractFile abstractFile) {
         if (isVcardFile) {
             return processVcard(abstractFile);
         }
-
+        
         return ProcessResult.OK;
     }
 
@@ -207,12 +213,16 @@ private ProcessResult processPst(AbstractFile abstractFile) {
 
         PstParser parser = new PstParser(services);
         PstParser.ParseResult result = parser.open(file, abstractFile.getId());
-
+        
+        
         switch( result) {
             case OK:
                 Iterator<EmailMessage> pstMsgIterator = parser.getEmailMessageIterator();
                 if (pstMsgIterator != null) {
-                    processEmails(parser.getPartialEmailMessages(), pstMsgIterator , abstractFile);
+                    processEmails(parser.getPartialEmailMessages(), pstMsgIterator, abstractFile);
+                    if (context.fileIngestIsCancelled()) {
+                        return ProcessResult.OK;
+                    }
                 } else {
                     // sometimes parser returns ParseResult=OK but there are no messages
                     postErrorMessage(
@@ -265,7 +275,7 @@ private ProcessResult processPst(AbstractFile abstractFile) {
         if (file.delete() == false) {
             logger.log(Level.INFO, "Failed to delete temp file: {0}", file.getName()); //NON-NLS
         }
-
+        
         return ProcessResult.OK;
     }
 
@@ -321,6 +331,9 @@ private ProcessResult processMBox(AbstractFile abstractFile) {
             }
 
             processMboxFile(file, abstractFile, emailFolder);
+            if (context.fileIngestIsCancelled()) {
+                return ProcessResult.OK;
+            }
             
             if (file.delete() == false) {
                 logger.log(Level.INFO, "Failed to delete temp file: {0}", file.getName()); //NON-NLS
@@ -349,7 +362,9 @@ private ProcessResult processMBox(AbstractFile abstractFile) {
                 if (splitFile.delete() == false) {
                     logger.log(Level.INFO, "Failed to delete temp file: {0}", splitFile); //NON-NLS
                 }
-
+                if (context.fileIngestIsCancelled()) {
+                    return ProcessResult.OK;
+                }
             }
         }                 
             
@@ -385,6 +400,9 @@ private void processMboxFile(File file, AbstractFile abstractFile, String emailF
         List<EmailMessage> emails = new ArrayList<>();
         if(emailIterator != null) {
             while(emailIterator.hasNext()) {
+                if (context.fileIngestIsCancelled()) {
+                    return;
+                }
                 EmailMessage emailMessage = emailIterator.next();
                 if(emailMessage != null) {
                     emails.add(emailMessage);
@@ -436,7 +454,9 @@ private ProcessResult processEMLFile(AbstractFile abstractFile) {
 
             List<AbstractFile> derivedFiles = new ArrayList<>();
 
-            BlackboardArtifact msgArtifact = addEmailArtifact(message, abstractFile);
+            AccountFileInstanceCache accountFileInstanceCache = new AccountFileInstanceCache(abstractFile, currentCase);
+            BlackboardArtifact msgArtifact = addEmailArtifact(message, abstractFile, accountFileInstanceCache);
+            accountFileInstanceCache.clear();
 
             if ((msgArtifact != null) && (message.hasAttachment())) {
                 derivedFiles.addAll(handleAttachments(message.getAttachments(), abstractFile, msgArtifact));
@@ -512,7 +532,11 @@ static String getRelModuleOutputPath() throws NoCurrentCaseException {
      * @param fullMessageIterator
      * @param abstractFile
      */
-    private void processEmails(List<EmailMessage> partialEmailsForThreading, Iterator<EmailMessage> fullMessageIterator, AbstractFile abstractFile) {
+    private void processEmails(List<EmailMessage> partialEmailsForThreading, Iterator<EmailMessage> fullMessageIterator, 
+            AbstractFile abstractFile) {
+        
+        // Create cache for accounts
+        AccountFileInstanceCache accountFileInstanceCache = new AccountFileInstanceCache(abstractFile, currentCase);
         
         // Putting try/catch around this to catch any exception and still allow
         // the creation of the artifacts to continue.
@@ -526,6 +550,10 @@ private void processEmails(List<EmailMessage> partialEmailsForThreading, Iterato
 
         int msgCnt = 0;
         while(fullMessageIterator.hasNext()) {
+            if (context.fileIngestIsCancelled()) {
+                return;
+            }
+            
             EmailMessage current = fullMessageIterator.next();
             
             if(current == null) {
@@ -541,7 +569,7 @@ private void processEmails(List<EmailMessage> partialEmailsForThreading, Iterato
                 }
             }
             
-            BlackboardArtifact msgArtifact = addEmailArtifact(current, abstractFile);
+            BlackboardArtifact msgArtifact = addEmailArtifact(current, abstractFile, accountFileInstanceCache);
             
             if ((msgArtifact != null) && (current.hasAttachment()))  {
                 derivedFiles.addAll(handleAttachments(current.getAttachments(), abstractFile, msgArtifact ));
@@ -550,6 +578,9 @@ private void processEmails(List<EmailMessage> partialEmailsForThreading, Iterato
 
         if (derivedFiles.isEmpty() == false) {
             for (AbstractFile derived : derivedFiles) {
+                if (context.fileIngestIsCancelled()) {
+                    return;
+                }
                 services.fireModuleContentEvent(new ModuleContentEvent(derived));
             }
         }
@@ -654,7 +685,7 @@ private Set<String> findEmailAddresess(String input) {
      * @return The generated e-mail message artifact.
      */
     @Messages({"ThunderbirdMboxFileIngestModule.addArtifact.indexError.message=Failed to index email message detected artifact for keyword search."})
-    private BlackboardArtifact addEmailArtifact(EmailMessage email, AbstractFile abstractFile) {
+    private BlackboardArtifact addEmailArtifact(EmailMessage email, AbstractFile abstractFile, AccountFileInstanceCache accountFileInstanceCache) {
         BlackboardArtifact bbart = null;
         List<BlackboardAttribute> bbattributes = new ArrayList<>();
         String to = email.getRecipients();
@@ -675,12 +706,16 @@ private BlackboardArtifact addEmailArtifact(EmailMessage email, AbstractFile abs
         String senderAddress;
         senderAddressList.addAll(findEmailAddresess(from));
         
+        if (context.fileIngestIsCancelled()) {
+            return null;
+        }
+        
         AccountFileInstance senderAccountInstance = null;
 
         if (senderAddressList.size() == 1) {
             senderAddress = senderAddressList.get(0);
             try {
-                senderAccountInstance = currentCase.getSleuthkitCase().getCommunicationsManager().createAccountFileInstance(Account.Type.EMAIL, senderAddress, EmailParserModuleFactory.getModuleName(), abstractFile);
+                senderAccountInstance = accountFileInstanceCache.getAccountInstance(senderAddress);
             }
             catch(TskCoreException ex) {
                  logger.log(Level.WARNING, "Failed to create account for email address  " + senderAddress, ex); //NON-NLS
@@ -690,23 +725,28 @@ private BlackboardArtifact addEmailArtifact(EmailMessage email, AbstractFile abs
              logger.log(Level.WARNING, "Failed to find sender address, from  = {0}", from); //NON-NLS
         }
         
+        if (context.fileIngestIsCancelled()) {
+            return null;
+        }
+        
         List<String> recipientAddresses = new ArrayList<>();
         recipientAddresses.addAll(findEmailAddresess(to));
         recipientAddresses.addAll(findEmailAddresess(cc));
         recipientAddresses.addAll(findEmailAddresess(bcc));
         
         List<AccountFileInstance> recipientAccountInstances = new ArrayList<>();
-        recipientAddresses.forEach((addr) -> {
+        for (String addr : recipientAddresses) {
+            if (context.fileIngestIsCancelled()) {
+                return null;
+            }
             try {
-                AccountFileInstance recipientAccountInstance = 
-                currentCase.getSleuthkitCase().getCommunicationsManager().createAccountFileInstance(Account.Type.EMAIL, addr,
-                        EmailParserModuleFactory.getModuleName(), abstractFile);
+                AccountFileInstance recipientAccountInstance = accountFileInstanceCache.getAccountInstance(addr);
                 recipientAccountInstances.add(recipientAccountInstance);
             }
             catch(TskCoreException ex) {
                 logger.log(Level.WARNING, "Failed to create account for email address  " + addr, ex); //NON-NLS
             }
-        });
+        }
                 
         addArtifactAttribute(headers, ATTRIBUTE_TYPE.TSK_HEADERS, bbattributes);
         addArtifactAttribute(from, ATTRIBUTE_TYPE.TSK_EMAIL_FROM, bbattributes);
@@ -731,12 +771,23 @@ private BlackboardArtifact addEmailArtifact(EmailMessage email, AbstractFile abs
         
    
         try {
+            if (context.fileIngestIsCancelled()) {
+                return null;
+            }
             
             bbart = abstractFile.newArtifact(BlackboardArtifact.ARTIFACT_TYPE.TSK_EMAIL_MSG);
             bbart.addAttributes(bbattributes);
 
+            if (context.fileIngestIsCancelled()) {
+                return null;
+            }
+            
             // Add account relationships
             currentCase.getSleuthkitCase().getCommunicationsManager().addRelationships(senderAccountInstance, recipientAccountInstances, bbart,Relationship.Type.MESSAGE, dateL);
+
+            if (context.fileIngestIsCancelled()) {
+                return null;
+            }
             
             try {
                 // index the artifact for keyword search
@@ -791,6 +842,56 @@ static void addArtifactAttribute(long longVal, ATTRIBUTE_TYPE attrType, Collecti
         }
     }
     
+    /**
+     * Cache for storing AccountFileInstance.
+     * The idea is that emails will be used multiple times in a file and
+     * we shouldn't do a database lookup each time.
+     */
+    static private class AccountFileInstanceCache {
+        private final Map<String, AccountFileInstance> cacheMap;
+        private final AbstractFile file;
+        private final Case currentCase;
+        
+        /**
+         * Create a new cache. Caches are linked to a specific file.
+         * @param file
+         * @param currentCase 
+         */
+        AccountFileInstanceCache(AbstractFile file, Case currentCase) {
+            cacheMap= new HashMap<>();
+            this.file = file;
+            this.currentCase = currentCase;
+        }
+        
+        /**
+         * Get the account file instance from the cache or the database.
+         * 
+         * @param email The email for this account.
+         * 
+         * @return The corresponding AccountFileInstance
+         * 
+         * @throws TskCoreException 
+         */
+        AccountFileInstance getAccountInstance(String email) throws TskCoreException {
+            if (cacheMap.containsKey(email)) {
+                return cacheMap.get(email);
+            }
+            
+            AccountFileInstance accountInstance = 
+                currentCase.getSleuthkitCase().getCommunicationsManager().createAccountFileInstance(Account.Type.EMAIL, email,
+                        EmailParserModuleFactory.getModuleName(), file);
+            cacheMap.put(email, accountInstance);
+            return accountInstance;
+        }
+        
+        /**
+         * Clears the cache.
+         */
+        void clear() {
+            cacheMap.clear();
+        }
+    }
+    
     /**
      * Post an error message for the user.
      * 
diff --git a/travis_build.sh b/travis_build.sh
index 5f574d57263e0a0570ba40f412f644278fff6b9d..d3753fd934fde4121d06960d7ccdd7035bb66aaf 100755
--- a/travis_build.sh
+++ b/travis_build.sh
@@ -8,5 +8,19 @@ pushd bindings/java && ant -q dist && popd
 
 echo "Building Autopsy..." && echo -en 'travis_fold:start:script.build\\r'
 cd $TRAVIS_BUILD_DIR/
-ant build
+ant -q build
 echo -en 'travis_fold:end:script.build\\r'
+
+echo "Testing Autopsy..." && echo -en 'travis_fold:start:script.tests\\r'
+echo "Free Space:"
+echo `df -h .` 
+
+if [ "${TRAVIS_OS_NAME}" = "osx" ]; then
+    # if os x, just run it
+    ant -q test-no-regression
+elif [ "${TRAVIS_OS_NAME}" = "linux" ]; then
+    # if linux use xvfb
+    xvfb-run ant -q test-no-regression
+fi
+
+echo -en 'travis_fold:end:script.tests\\r'