/*
* Autopsy Forensic Browser
*
* Copyright 2016 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.timeline.ui.detailview;
import com.google.common.collect.Lists;
import com.google.common.collect.Sets;
import com.google.common.eventbus.Subscribe;
import java.util.Arrays;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutionException;
import java.util.logging.Level;
import java.util.stream.Collectors;
import javafx.animation.KeyFrame;
import javafx.animation.KeyValue;
import javafx.animation.Timeline;
import javafx.application.Platform;
import javafx.concurrent.Task;
import javafx.event.EventHandler;
import javafx.geometry.Insets;
import javafx.scene.Node;
import javafx.scene.control.Button;
import javafx.scene.control.ButtonBase;
import javafx.scene.control.ContextMenu;
import javafx.scene.control.Label;
import javafx.scene.control.SeparatorMenuItem;
import javafx.scene.control.Tooltip;
import javafx.scene.effect.DropShadow;
import javafx.scene.effect.Effect;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.input.MouseButton;
import javafx.scene.input.MouseEvent;
import javafx.scene.layout.Background;
import javafx.scene.layout.BackgroundFill;
import javafx.scene.layout.Border;
import javafx.scene.layout.BorderStroke;
import javafx.scene.layout.BorderStrokeStyle;
import javafx.scene.layout.BorderWidths;
import javafx.scene.layout.HBox;
import javafx.scene.layout.StackPane;
import javafx.scene.paint.Color;
import javafx.util.Duration;
import org.apache.commons.lang3.StringUtils;
import org.controlsfx.control.action.Action;
import org.controlsfx.control.action.ActionUtils;
import org.joda.time.DateTime;
import org.openide.util.NbBundle;
import org.sleuthkit.autopsy.coreutils.Logger;
import org.sleuthkit.autopsy.coreutils.ThreadConfined;
import org.sleuthkit.autopsy.timeline.TimeLineController;
import org.sleuthkit.autopsy.timeline.datamodel.FilteredEventsModel;
import org.sleuthkit.autopsy.timeline.datamodel.SingleEvent;
import org.sleuthkit.autopsy.timeline.datamodel.TimeLineEvent;
import org.sleuthkit.autopsy.timeline.datamodel.eventtype.EventType;
import org.sleuthkit.autopsy.timeline.events.TagsAddedEvent;
import org.sleuthkit.autopsy.timeline.events.TagsDeletedEvent;
import org.sleuthkit.autopsy.timeline.ui.AbstractTimelineChart;
import org.sleuthkit.autopsy.timeline.ui.ContextMenuProvider;
import static org.sleuthkit.autopsy.timeline.ui.detailview.EventNodeBase.show;
import static org.sleuthkit.autopsy.timeline.ui.detailview.MultiEventNodeBase.CORNER_RADII_3;
import org.sleuthkit.autopsy.timeline.zooming.EventTypeZoomLevel;
import org.sleuthkit.datamodel.SleuthkitCase;
import org.sleuthkit.datamodel.TskCoreException;
/**
*
*/
public abstract class EventNodeBase<Type extends TimeLineEvent> extends StackPane implements ContextMenuProvider {
private static final Logger LOGGER = Logger.getLogger(EventNodeBase.class.getName());
private static final Image HASH_HIT = new Image("/org/sleuthkit/autopsy/images/hashset_hits.png"); //NOI18N NON-NLS
private static final Image TAG = new Image("/org/sleuthkit/autopsy/images/green-tag-icon-16.png"); // NON-NLS //NOI18N
private static final Image PIN = new Image("/org/sleuthkit/autopsy/timeline/images/marker--plus.png"); // NON-NLS //NOI18N
private static final Image UNPIN = new Image("/org/sleuthkit/autopsy/timeline/images/marker--minus.png"); // NON-NLS //NOI18N
private static final Map<EventType, Effect> dropShadowMap = new ConcurrentHashMap<>();
static void configureActionButton(ButtonBase b) {
b.setMinSize(16, 16);
b.setMaxSize(16, 16);
b.setPrefSize(16, 16);
}
static void show(Node b, boolean show) {
b.setVisible(show);
b.setManaged(show);
}
private final Type tlEvent;
private final EventNodeBase<?> parentNode;
final DetailsChartLane<?> chartLane;
final Background highlightedBackground;
final Background defaultBackground;
final Color evtColor;
final Label countLabel = new Label();
final Label descrLabel = new Label();
final ImageView hashIV = new ImageView(HASH_HIT);
final ImageView tagIV = new ImageView(TAG);
final ImageView eventTypeImageView = new ImageView();
final Tooltip tooltip = new Tooltip(Bundle.EventBundleNodeBase_toolTip_loading());
final HBox controlsHBox = new HBox(5);
final HBox infoHBox = new HBox(5, eventTypeImageView, hashIV, tagIV, descrLabel, countLabel, controlsHBox);
final SleuthkitCase sleuthkitCase;
final FilteredEventsModel eventsModel;
private Timeline timeline;
private Button pinButton;
private final Border SELECTION_BORDER;
EventNodeBase(Type tlEvent, EventNodeBase<?> parent, DetailsChartLane<?> chartLane) {
this.chartLane = chartLane;
this.tlEvent = tlEvent;
this.parentNode = parent;
sleuthkitCase = chartLane.getController().getAutopsyCase().getSleuthkitCase();
eventsModel = chartLane.getController().getEventsModel();
eventTypeImageView.setImage(getEventType().getFXImage());
if (tlEvent.getEventIDsWithHashHits().isEmpty()) {
show(hashIV, false);
}
if (tlEvent.getEventIDsWithTags().isEmpty()) {
show(tagIV, false);
}
if (chartLane.getController().getEventsModel().getEventTypeZoom() == EventTypeZoomLevel.SUB_TYPE) {
evtColor = getEventType().getColor();
} else {
evtColor = getEventType().getBaseType().getColor();
}
SELECTION_BORDER = new Border(new BorderStroke(evtColor.darker().desaturate(), BorderStrokeStyle.SOLID, CORNER_RADII_3, new BorderWidths(2)));
defaultBackground = new Background(new BackgroundFill(evtColor.deriveColor(0, 1, 1, .1), CORNER_RADII_3, Insets.EMPTY));
highlightedBackground = new Background(new BackgroundFill(evtColor.deriveColor(0, 1.1, 1.1, .3), CORNER_RADII_3, Insets.EMPTY));
setBackground(defaultBackground);
Tooltip.install(this, this.tooltip);
//set up mouse hover effect and tooltip
setOnMouseEntered(mouseEntered -> {
Tooltip.uninstall(chartLane, AbstractTimelineChart.getDefaultTooltip());
showHoverControls(true);
toFront();
});
setOnMouseExited(mouseExited -> {
showHoverControls(false);
if (parentNode != null) {
parentNode.showHoverControls(true);
} else {
Tooltip.install(chartLane, AbstractTimelineChart.getDefaultTooltip());
}
});
setOnMouseClicked(new ClickHandler());
show(controlsHBox, false);
}
public Type getEvent() {
return tlEvent;
}
@Override
public TimeLineController getController() {
return chartLane.getController();
}
public Optional<EventNodeBase<?>> getParentNode() {
return Optional.ofNullable(parentNode);
}
DetailsChartLane<?> getChartLane() {
return chartLane;
}
/**
* @param w the maximum width the description label should have
*/
public void setMaxDescriptionWidth(double w) {
descrLabel.setMaxWidth(w);
}
public abstract List<EventNodeBase<?>> getSubNodes();
/**
* apply the 'effect' to visually indicate selection
*
* @param applied true to apply the selection 'effect', false to remove it
*/
public void applySelectionEffect(boolean applied) {
setBorder(applied ? SELECTION_BORDER : null);
}
protected void layoutChildren() {
super.layoutChildren();
}
/**
* Install whatever buttons are visible on hover for this node. likes
* tooltips, this had a surprisingly large impact on speed of loading the
* chart
*/
void installActionButtons() {
if (pinButton == null) {
pinButton = new Button();
controlsHBox.getChildren().add(pinButton);
configureActionButton(pinButton);
}
}
final void showHoverControls(final boolean showControls) {
Effect dropShadow = dropShadowMap.computeIfAbsent(getEventType(),
eventType -> new DropShadow(-10, eventType.getColor()));
setEffect(showControls ? dropShadow : null);
installTooltip();
enableTooltip(showControls);
installActionButtons();
TimeLineController controller = getChartLane().getController();
if (controller.getPinnedEvents().contains(tlEvent)) {
pinButton.setOnAction(actionEvent -> {
new UnPinEventAction(controller, tlEvent).handle(actionEvent);
showHoverControls(true);
});
pinButton.setGraphic(new ImageView(UNPIN));
} else {
pinButton.setOnAction(actionEvent -> {
new PinEventAction(controller, tlEvent).handle(actionEvent);
showHoverControls(true);
});
pinButton.setGraphic(new ImageView(PIN));
}
show(controlsHBox, showControls);
if (parentNode != null) {
parentNode.showHoverControls(false);
}
}
/**
* defer tooltip content creation till needed, this had a surprisingly large
* impact on speed of loading the chart
*/
@NbBundle.Messages({"# {0} - counts",
"# {1} - event type",
"# {2} - description",
"# {3} - start date/time",
"# {4} - end date/time",
"EventNodeBase.tooltip.text={0} {1} events\n{2}\nbetween\t{3}\nand \t{4}",
"EventNodeBase.toolTip.loading2=loading tooltip",
"# {0} - hash set count string",
"EventNodeBase.toolTip.hashSetHits=\n\nHash Set Hits\n{0}",
"# {0} - tag count string",
"EventNodeBase.toolTip.tags=\n\nTags\n{0}"})
@ThreadConfined(type = ThreadConfined.ThreadType.JFX)
void installTooltip() {
if (tooltip.getText().equalsIgnoreCase(Bundle.EventBundleNodeBase_toolTip_loading())) {
final Task<String> tooltTipTask = new Task<String>() {
{
updateTitle(Bundle.EventNodeBase_toolTip_loading2());
}
@Override
protected String call() throws Exception {
HashMap<String, Long> hashSetCounts = new HashMap<>();
if (tlEvent.getEventIDsWithHashHits().isEmpty() == false) {
try {
//TODO:push this to DB
for (SingleEvent tle : eventsModel.getEventsById(tlEvent.getEventIDsWithHashHits())) {
Set<String> hashSetNames = sleuthkitCase.getAbstractFileById(tle.getFileID()).getHashSetNames();
for (String hashSetName : hashSetNames) {
hashSetCounts.merge(hashSetName, 1L, Long::sum);
}
}
} catch (TskCoreException ex) {
LOGGER.log(Level.SEVERE, "Error getting hashset hit info for event.", ex); //NON-NLS
}
}
String hashSetCountsString = hashSetCounts.entrySet().stream()
.map((Map.Entry<String, Long> t) -> t.getKey() + " : " + t.getValue())
.collect(Collectors.joining("\n"));
Map<String, Long> tagCounts = new HashMap<>();
if (tlEvent.getEventIDsWithTags().isEmpty() == false) {
tagCounts.putAll(eventsModel.getTagCountsByTagName(tlEvent.getEventIDsWithTags()));
}
String tagCountsString = tagCounts.entrySet().stream()
.map((Map.Entry<String, Long> t) -> t.getKey() + " : " + t.getValue())
.collect(Collectors.joining("\n"));
return Bundle.EventNodeBase_tooltip_text(getEventIDs().size(), getEventType(), getDescription(),
TimeLineController.getZonedFormatter().print(getStartMillis()),
TimeLineController.getZonedFormatter().print(getEndMillis() + 1000))
+ (hashSetCountsString.isEmpty() ? "" : Bundle.EventNodeBase_toolTip_hashSetHits(hashSetCountsString))
+ (tagCountsString.isEmpty() ? "" : Bundle.EventNodeBase_toolTip_tags(tagCountsString));
}
@Override
protected void succeeded() {
super.succeeded();
try {
tooltip.setText(get());
tooltip.setGraphic(null);
} catch (InterruptedException | ExecutionException ex) {
LOGGER.log(Level.SEVERE, "Tooltip generation failed.", ex); //NON-NLS
}
}
};
new Thread(tooltTipTask).start();
chartLane.getController().monitorTask(tooltTipTask);
}
}
void enableTooltip(boolean toolTipEnabled) {
if (toolTipEnabled) {
Tooltip.install(this, tooltip);
} else {
Tooltip.uninstall(this, tooltip);
}
}
final EventType getEventType() {
return tlEvent.getEventType();
}
long getStartMillis() {
return tlEvent.getStartMillis();
}
final long getEndMillis() {
return tlEvent.getEndMillis();
}
final double getLayoutXCompensation() {
return parentNode != null
? getChartLane().getXAxis().getDisplayPosition(new DateTime(parentNode.getStartMillis()))
: 0;
}
abstract String getDescription();
void animateTo(double xLeft, double yTop) {
if (timeline != null) {
timeline.stop();
Platform.runLater(this::requestChartLayout);
}
timeline = new Timeline(new KeyFrame(Duration.millis(100),
new KeyValue(layoutXProperty(), xLeft),
new KeyValue(layoutYProperty(), yTop))
);
timeline.setOnFinished(finished -> Platform.runLater(this::requestChartLayout));
timeline.play();
}
void requestChartLayout() {
getChartLane().requestChartLayout();
}
@ThreadConfined(type = ThreadConfined.ThreadType.JFX)
void setDescriptionVisibility(DescriptionVisibility descrVis) {
final int size = getEvent().getSize();
switch (descrVis) {
case HIDDEN:
hideDescription();
break;
case COUNT_ONLY:
showCountOnly(size);
break;
case SHOWN:
default:
showFullDescription(size);
break;
}
}
void showCountOnly(final int size) {
descrLabel.setText("");
countLabel.setText(String.valueOf(size));
}
void hideDescription() {
countLabel.setText("");
descrLabel.setText("");
}
/**
* apply the 'effect' to visually indicate highlighted nodes
*
* @param applied true to apply the highlight 'effect', false to remove it
*/
synchronized void applyHighlightEffect(boolean applied) {
if (applied) {
descrLabel.setStyle("-fx-font-weight: bold;"); // NON-NLS
setBackground(highlightedBackground);
} else {
descrLabel.setStyle("-fx-font-weight: normal;"); // NON-NLS
setBackground(defaultBackground);
}
}
void applyHighlightEffect() {
applyHighlightEffect(true);
}
void clearHighlightEffect() {
applyHighlightEffect(false);
}
abstract Collection<Long> getEventIDs();
abstract EventHandler<MouseEvent> getDoubleClickHandler();
Iterable<? extends Action> getActions() {
if (getController().getPinnedEvents().contains(getEvent())) {
return Arrays.asList(new UnPinEventAction(getController(), getEvent()));
} else {
return Arrays.asList(new PinEventAction(getController(), getEvent()));
}
}
@Deprecated
@Override
final public void clearContextMenu() {
}
public ContextMenu getContextMenu(MouseEvent mouseEvent) {
ContextMenu chartContextMenu = chartLane.getContextMenu(mouseEvent);
ContextMenu contextMenu = ActionUtils.createContextMenu(Lists.newArrayList(getActions()));
contextMenu.getItems().add(new SeparatorMenuItem());
contextMenu.getItems().addAll(chartContextMenu.getItems());
contextMenu.setAutoHide(true);
return contextMenu;
}
void showFullDescription(final int size) {
countLabel.setText((size == 1) ? "" : " (" + size + ")"); // NON-NLS
String description = getParentNode().map(pNode ->
" ..." + StringUtils.substringAfter(getEvent().getDescription(), parentNode.getDescription()))
.orElseGet(getEvent()::getDescription);
descrLabel.setText(description);
}
@Subscribe
public void handleTimeLineTagEvent(TagsAddedEvent event) {
if (false == Sets.intersection(getEvent().getEventIDs(), event.getUpdatedEventIDs()).isEmpty()) {
Platform.runLater(() -> {
show(tagIV, true);
});
}
}
/**
* TODO: this method implementation is wrong and just a place holder
*/
@Subscribe
public void handleTimeLineTagEvent(TagsDeletedEvent event) {
Sets.SetView<Long> difference = Sets.difference(getEvent().getEventIDs(), event.getUpdatedEventIDs());
if (false == difference.isEmpty()) {
Platform.runLater(() -> {
show(tagIV, true);
});
}
}
private static class PinEventAction extends Action {
@NbBundle.Messages({"PinEventAction.text=Pin"})
PinEventAction(TimeLineController controller, TimeLineEvent event) {
super(Bundle.PinEventAction_text());
setEventHandler(actionEvent -> controller.pinEvent(event));
setGraphic(new ImageView(PIN));
}
}
private static class UnPinEventAction extends Action {
@NbBundle.Messages({"UnPinEventAction.text=Unpin"})
UnPinEventAction(TimeLineController controller, TimeLineEvent event) {
super(Bundle.UnPinEventAction_text());
setEventHandler(actionEvent -> controller.unPinEvent(event));
setGraphic(new ImageView(UNPIN));
}
}
/**
* event handler used for mouse events on {@link EventNodeBase}s
*/
private class ClickHandler implements EventHandler<MouseEvent> {
@Override
public void handle(MouseEvent t) {
if (t.getButton() == MouseButton.PRIMARY) {
if (t.getClickCount() > 1) {
getDoubleClickHandler().handle(t);
} else if (t.isShiftDown()) {
chartLane.getSelectedNodes().add(EventNodeBase.this);
} else if (t.isShortcutDown()) {
chartLane.getSelectedNodes().removeAll(EventNodeBase.this);
} else {
chartLane.getSelectedNodes().setAll(EventNodeBase.this);
}
t.consume();
} else if (t.isPopupTrigger() && t.isStillSincePress()) {
getContextMenu(t).show(EventNodeBase.this, t.getScreenX(), t.getScreenY());
t.consume();
}
}
}
}