/*
* Autopsy Forensic Browser
*
* Copyright 2015-16 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.datamodel;
import com.google.common.cache.CacheBuilder;
import com.google.common.cache.CacheLoader;
import com.google.common.cache.LoadingCache;
import com.google.common.collect.ImmutableSet;
import com.google.common.eventbus.AsyncEventBus;
import com.google.common.eventbus.EventBus;
import com.google.common.eventbus.Subscribe;
import java.util.Collection;
import java.util.Collections;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.LongAdder;
import java.util.logging.Level;
import javax.annotation.concurrent.Immutable;
import org.apache.commons.lang3.concurrent.BasicThreadFactory;
import org.sleuthkit.autopsy.casemodule.events.ContentTagAddedEvent;
import org.sleuthkit.autopsy.casemodule.events.ContentTagDeletedEvent;
import org.sleuthkit.autopsy.coreutils.Logger;
import org.sleuthkit.autopsy.imagegallery.ImageGalleryController;
import org.sleuthkit.datamodel.ContentTag;
import org.sleuthkit.datamodel.TagName;
import org.sleuthkit.datamodel.TskCoreException;
/**
* Provides a cached view of the number of files per category, and fires
* {@link CategoryChangeEvent}s when files are categorized.
*
* To receive CategoryChangeEvents, a listener must register itself, and
* implement a public method annotated with {@link Subscribe} that accepts one
* argument of type CategoryChangeEvent
*
* TODO: currently these two functions (cached counts and events) are separate
* although they are related. Can they be integrated more?
*
*/
public class CategoryManager {
private static final Logger LOGGER = Logger.getLogger(CategoryManager.class.getName());
private final ImageGalleryController controller;
/**
* the DrawableDB that backs the category counts cache. The counts are
* initialized from this, and the counting of CAT-0 is always delegated to
* this db.
*/
private DrawableDB db;
/**
* Used to distribute {@link CategoryChangeEvent}s
*/
private final EventBus categoryEventBus = new AsyncEventBus(Executors.newSingleThreadExecutor(
new BasicThreadFactory.Builder().namingPattern("Category Event Bus").uncaughtExceptionHandler((Thread t, Throwable e) -> { //NON-NLS
LOGGER.log(Level.SEVERE, "Uncaught exception in category event bus handler", e); //NON-NLS
}).build()
));
/**
* For performance reasons, keep current category counts in memory. All of
* the count related methods go through this cache, which loads initial
* values from the database if needed.
*/
private final LoadingCache<Category, LongAdder> categoryCounts =
CacheBuilder.newBuilder().build(CacheLoader.from(this::getCategoryCountHelper));
/**
* cached TagNames corresponding to Categories, looked up from
* autopsyTagManager at initial request or if invalidated by case change.
*/
private final LoadingCache<Category, TagName> catTagNameMap =
CacheBuilder.newBuilder().build(CacheLoader.from(
cat -> getController().getTagsManager().getTagName(cat)
));
public CategoryManager(ImageGalleryController controller) {
this.controller = controller;
}
private ImageGalleryController getController() {
return controller;
}
/**
* assign a new db. the counts cache is invalidated and all subsequent db
* lookups go to the new db.
*
* Also clears the Category TagNames
*
* @param db
*/
synchronized public void setDb(DrawableDB db) {
this.db = db;
invalidateCaches();
}
synchronized public void invalidateCaches() {
categoryCounts.invalidateAll();
catTagNameMap.invalidateAll();
fireChange(Collections.emptyList(), null);
}
/**
* get the number of file with the given {@link Category}
*
* @param cat get the number of files with Category = cat
*
* @return the number of files with the given Category
*/
synchronized public long getCategoryCount(Category cat) {
if (cat == Category.ZERO) {
// Keeping track of the uncategorized files is a bit tricky while ingest
// is going on, so always use the list of file IDs we already have along with the
// other category counts instead of trying to track it separately.
long allOtherCatCount = getCategoryCount(Category.ONE) + getCategoryCount(Category.TWO) + getCategoryCount(Category.THREE) + getCategoryCount(Category.FOUR) + getCategoryCount(Category.FIVE);
return db.getNumberOfImageFilesInList() - allOtherCatCount;
} else {
return categoryCounts.getUnchecked(cat).sum();
}
}
/**
* increment the cached value for the number of files with the given
* {@link Category}
*
* @param cat the Category to increment
*/
synchronized public void incrementCategoryCount(Category cat) {
if (cat != Category.ZERO) {
categoryCounts.getUnchecked(cat).increment();
}
}
/**
* decrement the cached value for the number of files with the given
* {@link Category}
*
* @param cat the Category to decrement
*/
synchronized public void decrementCategoryCount(Category cat) {
if (cat != Category.ZERO) {
categoryCounts.getUnchecked(cat).decrement();
}
}
/**
* helper method that looks up the number of files with the given Category
* from the db and wraps it in a long adder to use in the cache
*
*
* @param cat the Category to count
*
* @return a LongAdder whose value is set to the number of file with the
* given Category
*/
synchronized private LongAdder getCategoryCountHelper(Category cat) {
LongAdder longAdder = new LongAdder();
longAdder.decrement();
try {
longAdder.add(db.getCategoryCount(cat));
longAdder.increment();
} catch (IllegalStateException ex) {
LOGGER.log(Level.WARNING, "Case closed while getting files"); //NON-NLS
}
return longAdder;
}
/**
* fire a CategoryChangeEvent with the given fileIDs
*
* @param fileIDs
*/
public void fireChange(Collection<Long> fileIDs, Category newCategory) {
categoryEventBus.post(new CategoryChangeEvent(fileIDs, newCategory));
}
/**
* register an object to receive CategoryChangeEvents
*
* @param listner
*/
public void registerListener(Object listner) {
categoryEventBus.register(listner);
}
/**
* unregister an object from receiving CategoryChangeEvents
*
* @param listener
*/
public void unregisterListener(Object listener) {
try {
categoryEventBus.unregister(listener);
} catch (IllegalArgumentException e) {
if (e.getMessage().contains("missing event subscriber for an annotated method. Is " + listener + " registered?")) { //NON-NLS
/*
* We don't fully understand why we are getting this exception
* when the groups should all be registered. To avoid cluttering
* the logs we have disabled recording this exception. This
* documented in issues 738 and 802.
*/
//LOGGER.log(Level.WARNING, "Attempted to unregister {0} for category change events, but it was not registered.", listener.toString()); //NON-NLS
} else {
throw e;
}
}
}
/**
* get the TagName used to store this Category in the main autopsy db.
*
* @return the TagName used for this Category
*/
synchronized public TagName getTagName(Category cat) {
return catTagNameMap.getUnchecked(cat);
}
public static Category categoryFromTagName(TagName tagName) {
return Category.fromDisplayName(tagName.getDisplayName());
}
public static boolean isCategoryTagName(TagName tName) {
return Category.isCategoryName(tName.getDisplayName());
}
public static boolean isNotCategoryTagName(TagName tName) {
return Category.isNotCategoryName(tName.getDisplayName());
}
@Subscribe
public void handleTagAdded(ContentTagAddedEvent event) {
final ContentTag addedTag = event.getAddedTag();
if (isCategoryTagName(addedTag.getName())) {
final DrawableTagsManager tagsManager = controller.getTagsManager();
try {
//remove old category tag(s) if necessary
for (ContentTag ct : tagsManager.getContentTags(addedTag.getContent())) {
if (ct.getId() != addedTag.getId()
&& CategoryManager.isCategoryTagName(ct.getName())) {
try {
tagsManager.deleteContentTag(ct);
} catch (TskCoreException tskException) {
LOGGER.log(Level.SEVERE, "Failed to delete content tag. Unable to maintain categories in a consistent state.", tskException); //NON-NLS
break;
}
}
}
} catch (TskCoreException tskException) {
LOGGER.log(Level.SEVERE, "Failed to get content tags for content. Unable to maintain category in a consistent state.", tskException); //NON-NLS
}
Category newCat = CategoryManager.categoryFromTagName(addedTag.getName());
if (newCat != Category.ZERO) {
incrementCategoryCount(newCat);
}
fireChange(Collections.singleton(addedTag.getContent().getId()), newCat);
}
}
@Subscribe
public void handleTagDeleted(ContentTagDeletedEvent event) {
final ContentTagDeletedEvent.DeletedContentTagInfo deletedTagInfo = event.getDeletedTagInfo();
TagName tagName = deletedTagInfo.getName();
if (isCategoryTagName(tagName)) {
Category deletedCat = CategoryManager.categoryFromTagName(tagName);
if (deletedCat != Category.ZERO) {
decrementCategoryCount(deletedCat);
}
fireChange(Collections.singleton(deletedTagInfo.getContentID()), null);
}
}
/**
* Event broadcast to various UI components when one or more files' category
* has been changed
*/
@Immutable
public static class CategoryChangeEvent {
private final ImmutableSet<Long> fileIDs;
private final Category newCategory;
public CategoryChangeEvent(Collection<Long> fileIDs, Category newCategory) {
super();
this.fileIDs = ImmutableSet.copyOf(fileIDs);
this.newCategory = newCategory;
}
public Category getNewCategory() {
return newCategory;
}
/**
* @return the fileIDs of the files whose categories have changed
*/
public ImmutableSet<Long> getFileIDs() {
return fileIDs;
}
}
}