558 lines
28 KiB
Java

/*
* Autopsy Forensic Browser
*
* Copyright 2013-2019 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.imagegallery;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.logging.Level;
import java.util.stream.Collectors;
import javafx.application.Platform;
import javafx.beans.Observable;
import javafx.concurrent.Task;
import javafx.embed.swing.JFXPanel;
import javafx.geometry.Insets;
import javafx.scene.Node;
import javafx.scene.Scene;
import javafx.scene.control.Alert;
import javafx.scene.control.ButtonType;
import javafx.scene.control.ChoiceDialog;
import javafx.scene.control.ComboBox;
import javafx.scene.control.ListView;
import javafx.scene.control.ProgressIndicator;
import javafx.scene.control.SplitPane;
import javafx.scene.control.TabPane;
import javafx.scene.layout.Background;
import javafx.scene.layout.BackgroundFill;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.CornerRadii;
import javafx.scene.layout.Priority;
import javafx.scene.layout.Region;
import javafx.scene.layout.StackPane;
import javafx.scene.layout.VBox;
import javafx.scene.paint.Color;
import javafx.stage.Modality;
import javax.annotation.concurrent.GuardedBy;
import javax.swing.JComponent;
import javax.swing.SwingUtilities;
import static org.apache.commons.collections4.CollectionUtils.isNotEmpty;
import static org.apache.commons.lang3.ObjectUtils.notEqual;
import org.openide.explorer.ExplorerManager;
import org.openide.explorer.ExplorerUtils;
import org.openide.util.Lookup;
import org.openide.util.NbBundle;
import org.openide.util.NbBundle.Messages;
import org.openide.windows.Mode;
import org.openide.windows.RetainLocation;
import org.openide.windows.TopComponent;
import org.openide.windows.WindowManager;
import org.sleuthkit.autopsy.coreutils.Logger;
import org.sleuthkit.autopsy.coreutils.ThreadConfined;
import org.sleuthkit.autopsy.directorytree.ExternalViewerShortcutAction;
import org.sleuthkit.autopsy.imagegallery.datamodel.DrawableAttribute;
import org.sleuthkit.autopsy.imagegallery.datamodel.grouping.GroupManager;
import org.sleuthkit.autopsy.imagegallery.gui.DataSourceCell;
import org.sleuthkit.autopsy.imagegallery.gui.GuiUtils;
import org.sleuthkit.autopsy.imagegallery.gui.NoGroupsDialog;
import org.sleuthkit.autopsy.imagegallery.gui.StatusBar;
import org.sleuthkit.autopsy.imagegallery.gui.SummaryTablePane;
import org.sleuthkit.autopsy.imagegallery.gui.Toolbar;
import org.sleuthkit.autopsy.imagegallery.gui.drawableviews.GroupPane;
import org.sleuthkit.autopsy.imagegallery.gui.drawableviews.MetaDataPane;
import org.sleuthkit.autopsy.imagegallery.gui.navpanel.GroupTree;
import org.sleuthkit.autopsy.imagegallery.gui.navpanel.HashHitGroupList;
import org.sleuthkit.autopsy.ingest.IngestManager;
import org.sleuthkit.datamodel.DataSource;
import org.sleuthkit.datamodel.TskCoreException;
/**
* The singleton Image Gallery top component.
*/
@TopComponent.Description(
preferredID = "ImageGalleryTopComponent",
//iconBase = "org/sleuthkit/autopsy/imagegallery/images/lightbulb.png", /*use this to put icon in window title area*/
persistenceType = TopComponent.PERSISTENCE_NEVER)
@RetainLocation("ImageGallery")
@TopComponent.Registration(mode = "ImageGallery", openAtStartup = false)
@Messages({
"CTL_ImageGalleryAction=Image/Video Gallery",
"CTL_ImageGalleryTopComponent=Image/Video Gallery"
})
@SuppressWarnings("PMD.SingularField") // UI widgets cause lots of false positives
public final class ImageGalleryTopComponent extends TopComponent implements ExplorerManager.Provider, Lookup.Provider {
private static final long serialVersionUID = 1L;
public final static String PREFERRED_ID = "ImageGalleryTopComponent"; // NON-NLS // RJCTODO: This should not be public, clients should call getTopComponent instead
private static final Logger logger = Logger.getLogger(ImageGalleryTopComponent.class.getName());
private final ExplorerManager em = new ExplorerManager();
private final Lookup lookup = (ExplorerUtils.createLookup(em, getActionMap()));
private final Object controllerLock = new Object();
@GuardedBy("controllerLock")
private ImageGalleryController controller;
private SplitPane splitPane;
private StackPane centralStack;
private final BorderPane borderPane = new BorderPane();
private StackPane fullUIStack;
private MetaDataPane metaDataTable;
private GroupPane groupPane;
private GroupTree groupTree;
private HashHitGroupList hashHitList;
private VBox leftPane;
private Scene myScene;
private Node infoOverlay;
private final Region infoOverLayBackground = new TranslucentRegion();
/**
* Queries whether the singleton Image Gallery top component's window is
* open. Note that calling this method will cause the top component to be
* constructed if it does not already exist.
*
* @return True or false.
*/
public static boolean isImageGalleryOpen() {
return getTopComponent().isOpened();
}
/**
* Gets the singleton Image Gallery top component. Note that calling this
* method will cause the top component to be constructed if it does not
* already exist.
*
* @return The top component.
*/
public static ImageGalleryTopComponent getTopComponent() {
return (ImageGalleryTopComponent) WindowManager.getDefault().findTopComponent(PREFERRED_ID);
}
/**
* Creates the Image Gallery top component if it does not already exist and
* opens its window.
*
* @throws TskCoreException If there is a problem opening the top component.
*/
@ThreadConfined(type = ThreadConfined.ThreadType.AWT)
public static void openTopComponent() throws TskCoreException {
final ImageGalleryTopComponent topComponent = getTopComponent();
if (topComponent.isOpened()) {
showTopComponent();
} else {
topComponent.getCurrentControllerAndOpen();
}
}
/**
* Configures the groups manager for the selected data source(s) and opens
* this top component's window.
*
* @param selectedDataSource The data source selected, null if all data
* sources are selected.
* @param dataSourcesTooManyFiles A map of data sources to flags indicating
* whether or not the data source has to many
* files to actually be displayed.
*/
private void openWithSelectedDataSources(DataSource selectedDataSource, Map<DataSource, Boolean> dataSourcesTooManyFiles) {
if (dataSourcesTooManyFiles.get(selectedDataSource)) {
Platform.runLater(ImageGalleryTopComponent::showTooManyFiles);
} else {
SwingUtilities.invokeLater(() -> showTopComponent());
synchronized (controllerLock) {
GroupManager groupManager = controller.getGroupManager();
// RJCTODO: Why are there potentially hazardous nested synchronized
// blocks here (note: method used to be synchronized, my
// dedicated controllerLock lock just makes the nesting more obvious)?
// Why is the groups manager not taking responsibility for its own thread
// safety policy?
synchronized (groupManager) {
groupManager.regroup(selectedDataSource, groupManager.getGroupBy(), groupManager.getSortBy(), groupManager.getSortOrder(), true);
}
}
}
}
/**
* Displays a dialog box informing the user that the data source(s) selected
* to have their images displayed have too many image files and will not be
* displayed.
*/
@NbBundle.Messages({"ImageGallery.dialogTitle=Image Gallery",
"ImageGallery.showTooManyFiles.contentText=There are too many files in the selected datasource(s) to ensure reasonable performance.",
"ImageGallery.showTooManyFiles.headerText="})
private static void showTooManyFiles() {
Alert dialog = new Alert(Alert.AlertType.INFORMATION, Bundle.ImageGallery_showTooManyFiles_contentText(), ButtonType.OK);
dialog.initModality(Modality.APPLICATION_MODAL);
dialog.setTitle(Bundle.ImageGallery_dialogTitle());
GuiUtils.setDialogIcons(dialog);
dialog.setHeaderText(Bundle.ImageGallery_showTooManyFiles_headerText());
dialog.showAndWait();
}
/**
* Opens the singleton top component's window, brings it to the front and
* gives it focus. Note that calling this method will cause the top
* component to be constructed if it does not already exist.
*/
@ThreadConfined(type = ThreadConfined.ThreadType.AWT)
private static void showTopComponent() {
final ImageGalleryTopComponent topComponent = getTopComponent();
if (topComponent.isOpened() == false) {
topComponent.open();
}
topComponent.toFront();
topComponent.requestActive();
}
/*
* Closes the singleton Image Gallery top component. Note that calling this
* method will cause the top component to be constructed if it does not
* already exist.
*/
public static void closeTopComponent() {
// RJCTODO: Could add the flag that used to be used for the busy wait on
// the initial JavaFX thread task to avoid superfluous construction here.
getTopComponent().close();
}
/**
* Contructs the singleton Image Gallery top component. Called by the
* NetBeans WindowManager.
*/
public ImageGalleryTopComponent() {
setName(Bundle.CTL_ImageGalleryTopComponent());
initComponents();
getInputMap(JComponent.WHEN_ANCESTOR_OF_FOCUSED_COMPONENT).put(ExternalViewerShortcutAction.EXTERNAL_VIEWER_SHORTCUT, "useExternalViewer"); //NON-NLS
getActionMap().put("useExternalViewer", ExternalViewerShortcutAction.getInstance()); //NON-NLS
}
/**
* Gets the current controller, allows the user to select the data sources
* for which images are to be displayed and opens the top component's
* window.
*
* @throws TskCoreException If there is an error getting the current
* controller.
*/
@Messages({
"ImageGalleryTopComponent.chooseDataSourceDialog.headerText=Choose a data source to view.",
"ImageGalleryTopComponent.chooseDataSourceDialog.contentText=Data source:",
"ImageGalleryTopComponent.chooseDataSourceDialog.all=All",
"ImageGalleryTopComponent.chooseDataSourceDialog.titleText=Image Gallery",})
private void getCurrentControllerAndOpen() throws TskCoreException {
ImageGalleryController currentController = ImageGalleryModule.getController();
/*
* Dispatch a task to run in the JavaFX thread. This task will swap the
* new controller, if there is one, into this top component and its
* child UI components. This task also queues another JavaFX thread task
* to check for analyzed groups, which has the side effect of starting
* the spinner(s) that take the place of a wait cursor. Finally, this
* task starts a background thread to query the case database. This
* background task may dispatch a JavaFX thread task to do a data source
* selection dialog. Ultimately, there is a final task that either opens
* the window in the AWT EDT or displays a "too many files" dialog in
* the JFX thread.
*/
// RJCTODO: Verify the side effect remark above.
Platform.runLater(new Runnable() {
@Override
public void run() {
synchronized (controllerLock) {
if (notEqual(controller, currentController)) {
controller = currentController;
/*
* Create or re-create the top component's child UI
* components. This is currently done every time a new
* controller is created (i.e., a new case is opened).
* It could be done by resetting the controller in the
* child UI components instead.
*/
// RJCTODO: Construction of these components can perhaps
// be separated from opening the window again so that
// a setController implementation could be called from
// the case opened event handler in the ImageGalleryModule
// object.
fullUIStack = new StackPane();
myScene = new Scene(fullUIStack);
jfxPanel.setScene(myScene);
groupPane = new GroupPane(controller);
centralStack = new StackPane(groupPane);
fullUIStack.getChildren().add(borderPane);
splitPane = new SplitPane();
borderPane.setCenter(splitPane);
Toolbar toolbar = new Toolbar(controller);
borderPane.setTop(toolbar);
borderPane.setBottom(new StatusBar(controller));
metaDataTable = new MetaDataPane(controller);
groupTree = new GroupTree(controller);
hashHitList = new HashHitGroupList(controller);
TabPane tabPane = new TabPane(groupTree, hashHitList);
tabPane.setPrefWidth(TabPane.USE_COMPUTED_SIZE);
tabPane.setMinWidth(TabPane.USE_PREF_SIZE);
VBox.setVgrow(tabPane, Priority.ALWAYS);
leftPane = new VBox(tabPane, new SummaryTablePane(controller));
SplitPane.setResizableWithParent(leftPane, Boolean.FALSE);
SplitPane.setResizableWithParent(groupPane, Boolean.TRUE);
SplitPane.setResizableWithParent(metaDataTable, Boolean.FALSE);
splitPane.getItems().addAll(leftPane, centralStack, metaDataTable);
splitPane.setDividerPositions(0.1, 1.0);
/*
* Set up for a call to checkForGroups to happen
* whenever the controller's regrouping disabled
* property or the group manager's analyzed groups
* property changes.
*/
controller.regroupDisabledProperty().addListener((Observable unused) -> Platform.runLater(() -> checkForAnalyzedGroups()));
controller.getGroupManager().getAnalyzedGroups().addListener((Observable unused) -> Platform.runLater(() -> checkForAnalyzedGroups()));
/*
* Dispatch a later task to call check for groups. Note
* that this method displays one or more spinner(s) that
* take the place of a wait cursor if there are no
* analyzed groups yet, ingest is running, etc.
*/
// RJCTODO: Is there a race condition here, since this task could be
// executed before the task to actually open the top component window?
// It seems like this might be a sort of a hack and I am wondering
// why this can't be done in openWithSelectedDataSources instead.
Platform.runLater(() -> checkForAnalyzedGroups());
}
/*
* Kick off a background task to query the case database for
* data sources. This task may queue another task for the
* JavaFX thread to allow the user to select which data
* sources for which to display images. Ultimately, a task
* will be queued for the AWT EDT that will show the top
* component window.
*/
new Thread(new Task<Void>() {
@Override
protected Void call() throws Exception {
synchronized (controllerLock) {
/*
* If there is only one datasource or the
* grouping criterion is already set to
* something other than by path (the default),
* proceed to open this top component.
* Otherwise, do a dialog to allow the user to
* select the data sources for which images are
* to be displayed, then open the top component.
*/
List<DataSource> dataSources = controller.getSleuthKitCase().getDataSources();
Map<DataSource, Boolean> dataSourcesWithTooManyFiles = new HashMap<>();
// RJCTODO: At least some of this designation of "all data sources" with null seems uneccessary;
// in any case, the use of nulls and zeros here is
// very confusing and should be reworked.
if (dataSources.size() <= 1
|| controller.getGroupManager().getGroupBy() != DrawableAttribute.PATH) {
dataSourcesWithTooManyFiles.put(null, controller.hasTooManyFiles(null));
openWithSelectedDataSources(null, dataSourcesWithTooManyFiles);
} else {
dataSources.add(0, null);
for (DataSource dataSource : dataSources) {
dataSourcesWithTooManyFiles.put(dataSource, controller.hasTooManyFiles(dataSource));
}
Platform.runLater(() -> {
List<Optional<DataSource>> dataSourceOptionals = dataSources.stream().map(Optional::ofNullable).collect(Collectors.toList());
ChoiceDialog<Optional<DataSource>> datasourceDialog = new ChoiceDialog<>(null, dataSourceOptionals);
datasourceDialog.setTitle(Bundle.ImageGalleryTopComponent_chooseDataSourceDialog_titleText());
datasourceDialog.setHeaderText(Bundle.ImageGalleryTopComponent_chooseDataSourceDialog_headerText());
datasourceDialog.setContentText(Bundle.ImageGalleryTopComponent_chooseDataSourceDialog_contentText());
datasourceDialog.initModality(Modality.APPLICATION_MODAL);
GuiUtils.setDialogIcons(datasourceDialog);
@SuppressWarnings(value = "unchecked")
ComboBox<Optional<DataSource>> comboBox = (ComboBox<Optional<DataSource>>) datasourceDialog.getDialogPane().lookup(".combo-box");
comboBox.setCellFactory((ListView<Optional<DataSource>> unused) -> new DataSourceCell(dataSourcesWithTooManyFiles, controller.getAllDataSourcesDrawableDBStatus()));
comboBox.setButtonCell(new DataSourceCell(dataSourcesWithTooManyFiles, controller.getAllDataSourcesDrawableDBStatus()));
DataSource dataSource = datasourceDialog.showAndWait().orElse(Optional.empty()).orElse(null);
openWithSelectedDataSources(dataSource, dataSourcesWithTooManyFiles);
});
}
return null;
}
}
}).start();
}
}
});
}
/**
* 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() {
jfxPanel = new JFXPanel();
javax.swing.GroupLayout layout = new javax.swing.GroupLayout(this);
this.setLayout(layout);
layout.setHorizontalGroup(
layout.createParallelGroup(javax.swing.GroupLayout.Alignment.LEADING)
.addComponent(jfxPanel, javax.swing.GroupLayout.DEFAULT_SIZE, 532, Short.MAX_VALUE)
);
layout.setVerticalGroup(
layout.createParallelGroup(javax.swing.GroupLayout.Alignment.LEADING)
.addComponent(jfxPanel, javax.swing.GroupLayout.DEFAULT_SIZE, 389, Short.MAX_VALUE)
);
}// </editor-fold>//GEN-END:initComponents
// Variables declaration - do not modify//GEN-BEGIN:variables
private javafx.embed.swing.JFXPanel jfxPanel;
// End of variables declaration//GEN-END:variables
@Override
public List<Mode> availableModes(List<Mode> modes) {
/*
* This looks like the right thing to do, but online discussions seems
* to indicate this method is effectively deprecated. A break point
* placed here was never hit.
*/
return modes.stream().filter(mode -> mode.getName().equals("timeline") || mode.getName().equals("ImageGallery"))
.collect(Collectors.toList());
}
@Override
public void componentOpened() {
super.componentOpened();
WindowManager.getDefault().setTopComponentFloating(this, true);
}
@Override
public ExplorerManager getExplorerManager() {
return em;
}
@Override
public Lookup getLookup() {
return lookup;
}
/**
* Checks if there are any fully analyzed groups available from the groups
* manager and removes the blocking progress spinner if there are analyzed
* groups; otherwise adds a blocking progress spinner with an appropriate
* message.
*/
@ThreadConfined(type = ThreadConfined.ThreadType.JFX)
@NbBundle.Messages({
"ImageGalleryController.noGroupsDlg.msg1=No groups are fully analyzed; but listening to ingest is disabled. "
+ " No groups will be available until ingest is finished and listening is re-enabled.",
"ImageGalleryController.noGroupsDlg.msg2=No groups are fully analyzed yet, but ingest is still ongoing. Please Wait.",
"ImageGalleryController.noGroupsDlg.msg3=No groups are fully analyzed yet, but image / video data is still being populated. Please Wait.",
"ImageGalleryController.noGroupsDlg.msg4=There are no images/videos available from the added datasources; but listening to ingest is disabled. "
+ " No groups will be available until ingest is finished and listening is re-enabled.",
"ImageGalleryController.noGroupsDlg.msg5=There are no images/videos in the added datasources.",
"ImageGalleryController.noGroupsDlg.msg6=There are no fully analyzed groups to display:"
+ " the current Group By setting resulted in no groups, "
+ "or no groups are fully analyzed but ingest is not running."})
private void checkForAnalyzedGroups() {
synchronized (controllerLock) {
GroupManager groupManager = controller.getGroupManager();
// if there are groups to display, then display them
// @@@ Need to check timing on this and make sure we have only groups for the selected DS. Seems like rebuild can cause groups to be created for a DS that is not later selected...
// RJCTODO: Get Brian's TODO resolved.
if (isNotEmpty(groupManager.getAnalyzedGroups())) {
clearNotification();
return;
}
// display a message based on if ingest is running and/or listening
if (IngestManager.getInstance().isIngestRunning()) {
if (controller.isListeningEnabled()) {
replaceNotification(centralStack,
new NoGroupsDialog(Bundle.ImageGalleryController_noGroupsDlg_msg2(),
new ProgressIndicator()));
} else {
replaceNotification(fullUIStack,
new NoGroupsDialog(Bundle.ImageGalleryController_noGroupsDlg_msg1()));
}
return;
}
// display a message about stuff still being in the queue
if (controller.getDBTasksQueueSizeProperty().get() > 0) {
replaceNotification(fullUIStack,
new NoGroupsDialog(Bundle.ImageGalleryController_noGroupsDlg_msg3(),
new ProgressIndicator()));
return;
}
// are there are files in the DB?
try {
if (controller.getDatabase().countAllFiles() <= 0) {
// there are no files in db
if (controller.isListeningEnabled()) {
replaceNotification(fullUIStack,
new NoGroupsDialog(Bundle.ImageGalleryController_noGroupsDlg_msg5()));
} else {
replaceNotification(fullUIStack,
new NoGroupsDialog(Bundle.ImageGalleryController_noGroupsDlg_msg4()));
}
return;
}
} catch (TskCoreException tskCoreException) {
logger.log(Level.SEVERE, "Error counting files in the database.", tskCoreException);
}
if (false == groupManager.isRegrouping()) {
replaceNotification(centralStack,
new NoGroupsDialog(Bundle.ImageGalleryController_noGroupsDlg_msg6()));
}
}
}
@ThreadConfined(type = ThreadConfined.ThreadType.JFX)
private void replaceNotification(StackPane stackPane, Node newNode) {
clearNotification();
infoOverlay = new StackPane(infoOverLayBackground, newNode);
if (stackPane != null) {
stackPane.getChildren().add(infoOverlay);
}
}
/**
* Removes the spinner(s).
*/
@ThreadConfined(type = ThreadConfined.ThreadType.JFX)
private void clearNotification() {
fullUIStack.getChildren().remove(infoOverlay);
centralStack.getChildren().remove(infoOverlay);
}
/**
* A partially opaque region used to block out parts of the UI behind a
* pseudo dialog.
*/
static final private class TranslucentRegion extends Region {
TranslucentRegion() {
setBackground(new Background(new BackgroundFill(Color.GREY, CornerRadii.EMPTY, Insets.EMPTY)));
setOpacity(.4);
}
}
}