+ * The media type is how these items get a category: they are not categorized by code, only by the mime → category
+ * map in CategoriesConfig.json.
+ *
+ *
+ * The type is built in one of two ways:
+ *
+ *
{@link #pluginSpecificSubtype}: a few plugins get a fixed type so all their rows share one category (e.g. every
+ * FCMQueued* plugin → {@code x-aleapp-notification});
+ *
{@link #genericSubtype}: any other plugin gets a type built from its artifact name.
+ *
+ *
+ *
+ * A category that depends on a cell value (e.g. "settingsSecure Name = bluetooth_address") cannot be set here: the type
+ * is decided once per plugin run, not per row.
+ */
+public final class AleappMediaTypeResolver {
+
+ private AleappMediaTypeResolver() {
+ }
+
+ /**
+ * Returns the media type for a row: the plugin-specific type when there is one, otherwise the generic type.
+ *
+ * @param moduleName the plugin module (python file stem, e.g. "chromeCookies")
+ * @param pluginName the artifact key (e.g. "get_fb_user_id")
+ * @param artifactName the artifact display name (e.g. "Gmail - App Emails")
+ */
+ public static MediaType resolveMediaType(String moduleName, String pluginName, String artifactName) {
+ String subtype = pluginSpecificSubtype(moduleName, pluginName, artifactName);
+ if (subtype == null) {
+ subtype = genericSubtype(pluginName, artifactName);
+ }
+ return MediaType.application(AleappTask.ALEAPP_APPLICATION_PREFIX + subtype);
+ }
+
+ /**
+ * Stable subtypes for plugins whose forensic category is driven by identity (module or artifact), so that
+ * CategoriesConfig.json can map the whole group with a single mime. Returns null when no specific rule applies.
+ */
+ private static String pluginSpecificSubtype(String moduleName, String pluginName, String artifactName) {
+
+ if (moduleName.startsWith("FCMQueued")) {
+ return "notification";
+ }
+ if (moduleName.equals("accounts_de")) {
+ return "account";
+ }
+ if (moduleName.equals("accounts_ce")) {
+ // accounts_ce.py declares two artifacts: "Accounts_ce" and "Authentication tokens"
+ return "Authentication tokens".equals(artifactName) ? "account-authtoken" : "account";
+ }
+ if (moduleName.equals("siminfo")) {
+ return "siminfo";
+ }
+ if (moduleName.equals("Cello")) {
+ return "gdrive-file-entry";
+ }
+ if (moduleName.equals("roles")) {
+ return "app-role";
+ }
+ if (moduleName.equals("frosting")) {
+ return "update-info";
+ }
+ if (moduleName.equals("gmailEmails") && "Gmail - App Emails".equals(artifactName)) {
+ return "email";
+ }
+ if (moduleName.equals("FacebookMessenger")) {
+ // artifact keys: get_fb_*_contacts, get_fb_user_id, get_fb_*_chats (chats are chat previews)
+ if (StringUtils.containsIgnoreCase(pluginName, "contacts")) {
+ return "facebook-contact";
+ }
+ if (StringUtils.containsIgnoreCase(pluginName, "user_id")) {
+ return "facebook-account";
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Generic per-artifact subtype. A module can register many plugins, so the plugin name (often prefixed with "get_",
+ * which is stripped) plus hints from the artifact name ("Call", "Chat", ...) are used to build a specific subtype.
+ */
+ private static String genericSubtype(String pluginName, String artifactName) {
+
+ String mimePluginName = pluginName.toLowerCase().replace(".", "");
+ mimePluginName = StringUtils.removeStart(mimePluginName, "get_");
+
+ // Facebook plugins share generic plugin names: the artifact name prefix (before "- ")
+ // is more specific, so use it instead
+ if (StringUtils.containsIgnoreCase(mimePluginName, "facebook")) {
+ mimePluginName = StringUtils.substringBefore(artifactName, "- ").toLowerCase();
+ }
+
+ // Chrome plugins are named per artifact already, so the artifact name alone is used
+ // (mimePluginName is intentionally ignored in this branch)
+ if (StringUtils.containsIgnoreCase(pluginName, "chrome")) {
+ return artifactNameToType(artifactName);
+ } else if (StringUtils.containsIgnoreCase(artifactName, "Call")) {
+ return mimePluginName + "-call";
+ } else if (StringUtils.containsIgnoreCase(artifactName, "Chat")) {
+ return mimePluginName + "-chat";
+ } else if (StringUtils.containsIgnoreCase(artifactName, "Message")) {
+ return mimePluginName + "-message";
+ } else if (StringUtils.containsAnyIgnoreCase(artifactName, "Activity", "Activities")) {
+ return mimePluginName + "-activity";
+ } else if (StringUtils.containsIgnoreCase(artifactName, "Contact")) {
+ return mimePluginName + "-contact";
+ } else if (StringUtils.containsIgnoreCase(artifactName, "Conversation")) {
+ return mimePluginName + "-conversation";
+ } else if (StringUtils.containsIgnoreCase(artifactName, "Autofill")) {
+ return mimePluginName + "-autofill";
+ } else {
+ return artifactNameToType(artifactName);
+ }
+ }
+
+ private static String artifactNameToType(String artifactName) {
+ String type = StringUtils.substringBefore(artifactName, " (");
+ type = type.replace(" - ", "-").replace(" ", "-").replace("--", "-");
+ return type;
+ }
+}
diff --git a/iped-engine/src/main/java/iped/engine/task/leapp/AleappTask.java b/iped-engine/src/main/java/iped/engine/task/leapp/AleappTask.java
new file mode 100644
index 0000000000..32a5cf8741
--- /dev/null
+++ b/iped-engine/src/main/java/iped/engine/task/leapp/AleappTask.java
@@ -0,0 +1,601 @@
+package iped.engine.task.leapp;
+
+import java.io.File;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.Set;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+
+import org.apache.commons.io.FilenameUtils;
+import org.apache.commons.io.file.PathUtils;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.commons.lang3.exception.ExceptionUtils;
+import org.apache.tika.mime.MediaType;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import iped.configuration.Configurable;
+import iped.data.IItem;
+import iped.data.IItemReader;
+import iped.datasource.IDataSource;
+import iped.engine.config.ALeappConfig;
+import iped.engine.config.ConfigurationManager;
+import iped.engine.core.Worker.ProcessTime;
+import iped.engine.data.Item;
+import iped.engine.datasource.UfedXmlReader;
+import iped.engine.task.AbstractTask;
+import iped.engine.task.ExportFileTask;
+import iped.engine.task.leapp.conversation.ConversationViewSpec;
+import iped.parsers.android.backup.AndroidBackupParser;
+import iped.parsers.python.PythonParser;
+import iped.properties.BasicProps;
+import iped.properties.ExtraProperties;
+import iped.search.IItemSearcher;
+import jep.Jep;
+import jep.python.PyObject;
+
+public class AleappTask extends AbstractTask {
+
+ protected static final Logger logger = LoggerFactory.getLogger(AleappTask.class);
+
+ public static final String ALEAPP_APPLICATION_PREFIX = "x-aleapp-";
+
+ public static final MediaType ALEAPP_CASE_MEDIATYPE = MediaType.application(ALEAPP_APPLICATION_PREFIX + "case");
+ public static final MediaType ALEAPP_CATEGORY_MEDIATYPE = MediaType.application(ALEAPP_APPLICATION_PREFIX + "category");
+ public static final MediaType ALEAPP_PLUGIN_RESULTS_MEDIATYPE = MediaType.application(ALEAPP_APPLICATION_PREFIX + "plugin-results");
+ public static final MediaType ALEAPP_ACTIVITY_MEDIATYPE = MediaType.application(ALEAPP_APPLICATION_PREFIX + "activity");
+ public static final MediaType ALEAPP_DEVICE_INFO_MEDIATYPE = MediaType.application(ALEAPP_APPLICATION_PREFIX + "deviceinfo");
+
+ public static final String ALEAPP_PLUGIN_CATEGORY_KEY = "aleapp_category";
+
+ // Temp attribute (survives the ProcessTime.LATER queue) carrying the "aleapp:" metadata keys of a row
+ // subitem whose values are device file paths. Resolving each path needs an index search, too costly to run per
+ // row inside the plugin call, so it is deferred: the subitem comes back through process() and each recorded path
+ // is resolved to its case item and added to LINKED_ITEMS (see processDeferredFilePathLinks).
+ public static final String ALEAPP_METADATA_PATHS = "aleapp_metadata_paths";
+
+ private static final String DEVICE_INFO_HTML = "DeviceInfo.html";
+
+ private static final String CASE_EVIDENCE_NAME = "ALEAPP_Results";
+
+ private static final String ZIP_EXT = "zip";
+ private static final String UFDR_EXT = "ufdr";
+
+ private static final Set DUMP_ROOT_FOLDER_NAMES = Set.of("Dump", "backup");
+
+ // top level folders of an android file system, used to tell if ALEAPP should run over a extraction (see isAndroidFileSystem)
+ private static final Set ANDROID_ROOT_FOLDER_NAMES = Set.of("data", "system");
+
+ // data_views is not copied to metadata: it is consumed by ConversationViewSpec
+ // to build chat-preview items (see LavaInsertSqliteDataInterceptor)
+ private static final Set ARTIFACT_INFO_KEYS_TO_IGNORE = Set
+ .of("function", "paths", "requirements", "output_types", "notes", "sample_data", "artifact_icon",
+ ConversationViewSpec.DATA_VIEWS_KEY);
+
+ public static final String ALEAPP_METADATA_PREFIX = "aleapp:";
+ public static final String ALEAPP_PLUGIN_METADATA_PREFIX = ALEAPP_METADATA_PREFIX + "plugin:";
+ public static final String ALEAPP_EXTRACTION_TYPE_META = ALEAPP_METADATA_PREFIX + "extractionType";
+ public static final String ALEAPP_PLUGIN_KEYNAME_META = ALEAPP_PLUGIN_METADATA_PREFIX + "key";
+
+ private static final String EXTRACTION_TYPE_ANDROID_BACKUP = "android-backup";
+ private static final String EXTRACTION_TYPE_ZIP = "zip";
+ private static final String EXTRACTION_TYPE_UFDR = "ufdr";
+ private static final String EXTRACTION_TYPE_DUMP = "dump";
+
+ /**
+ * Guards all access to the shared ilapfuncs.identifiers dict: device_info() writes (serialized by
+ * IlapfuncsDeviceInfoInterceptor) and the write_device_info() iteration in processDeviceInfoEvidence().
+ */
+ public static final Object DEVICE_INFO_LOCK = new Object();
+
+ private volatile boolean initialized = false;
+ private static volatile boolean interceptorsInstalled = false;
+
+ private Map selectedPlugins;
+ private ALeappConfig config;
+
+ private Path outputFolder;
+ private String outputFolderBase;
+
+ private Path exportFilesFolder;
+
+ // The per-worker-thread Jep interpreter shared with PythonParser/PythonTask
+ // (see PythonParser.getJep()). Jep is thread-confined, but every AleappTask
+ // instance belongs to a single worker and process()/finish() always run on
+ // that worker's thread, so no dedicated Python thread is needed. The
+ // interpreter's lifecycle is owned by PythonParser: never close it here.
+ private Jep jep;
+
+ public AleappTask() {
+ }
+
+ @Override
+ public List> getConfigurables() {
+ return Arrays.asList(new ALeappConfig());
+ }
+
+ @Override
+ public boolean isEnabled() {
+ return config.isEnabled();
+ }
+
+ @Override
+ public void init(ConfigurationManager configurationManager) throws Exception {
+
+ config = (ALeappConfig) configurationManager.findObject(ALeappConfig.class);
+
+ outputFolder = Files.createTempDirectory("aleapp-output-");
+ exportFilesFolder = outputFolder.resolve("data");
+ }
+
+ public void initialize() throws Exception {
+ if (!initialized) {
+ synchronized (this) {
+ if (!initialized) {
+ doSetup();
+ initialized = true;
+ }
+ }
+ }
+ }
+
+ private void doSetup() throws Exception {
+
+ // reuses the worker thread's interpreter, shared with PythonParser/PythonTask.
+ // NOTE: the interpreter namespace is shared with the python parsers/tasks
+ // running on this thread, so every global this integration creates is either
+ // prefixed with _iped_leapp_ or deleted right after use.
+ jep = PythonParser.getJep();
+ if (jep == null) {
+ logger.error("Python environment not available, ALeapp task disabled.");
+ return;
+ }
+
+ jep.exec("import sys");
+ jep.exec("sys.path.append('" + config.getAleappFolder().getCanonicalPath() + "')");
+
+ // SharedInterpreter instances all share the same Python module state, so interceptors are
+ // installed only ONCE globally, even though each worker
+ // thread has its own Jep instance
+ synchronized (AleappTask.class) {
+ if (!interceptorsInstalled) {
+ LeappInterceptors interceptors = new LeappInterceptors();
+ interceptors.install(jep);
+ interceptorsInstalled = true;
+ }
+ }
+
+ // load all available plugins
+ // (mimics https://github.com/abrignoni/ALEAPP/blob/v2026.1.0/aleapp.py#L181)
+ jep.exec("import scripts.plugin_loader");
+ jep.exec("_iped_leapp_plugins = list(scripts.plugin_loader.PluginLoader().plugins)");
+
+ @SuppressWarnings("unchecked")
+ List availablePlugins = (List) jep.getValue("_iped_leapp_plugins");
+
+ selectedPlugins = availablePlugins
+ .stream()
+ .map(PluginSpec::new)
+ .filter(plugin -> config.isPluginIncluded(plugin.getModuleName()))
+ .collect(Collectors.toMap(PluginSpec::getName, Function.identity()));
+
+ // the PyObjects held by PluginSpec keep their own references: the temp global can be removed
+ jep.exec("del _iped_leapp_plugins");
+
+ jep.exec("import scripts.ilapfuncs");
+ jep.exec("import scripts.context");
+
+ // mimics https://github.com/abrignoni/ALEAPP/blob/v2026.1.0/aleapp.py#L307
+ jep.exec("_iped_leapp_out_params = scripts.ilapfuncs.OutputParameters('" + outputFolder.toString() + "', 'ALEAPP_Reports')");
+ outputFolderBase = jep.getValue("_iped_leapp_out_params.output_folder_base", String.class);
+
+ // the Context keeps the reference: the temp global can be removed
+ jep.exec("scripts.context.Context.set_output_params(_iped_leapp_out_params)");
+ jep.exec("del _iped_leapp_out_params");
+ }
+
+ @Override
+ public void process(IItem item) throws Exception {
+
+ initialize();
+
+ if (jep == null) {
+ // python environment not available (see doSetup)
+ return;
+ }
+
+ if (isExtractionRoot(item)) {
+ processExtractionRoot(item);
+ } else if (isCaseEvidence(item)) {
+ processCaseEvidence(item);
+ } else if (isCategoryEvidence(item)) {
+ processCategoryEvidence(item);
+ } else if (isPluginEvidence(item)) {
+ processPluginEvidence(item);
+ } else if (isDeviceInfoEvidence(item)) {
+ processDeviceInfoEvidence(item);
+ } else if (isDeferredFilePathLinks(item)) {
+ processDeferredFilePathLinks(item);
+ }
+ }
+
+ private boolean isExtractionRoot(IItem evidence) {
+
+ if (AndroidBackupParser.SUPPORTED_TYPES.contains(evidence.getMediaType())) {
+ return true;
+ }
+
+ // in a ufdr the device tree does not hang from the ufdr item itself, but from its file system
+ // item, so the latter is the extraction root (see isUfdrFileSystem)
+ if (isUfdrFileSystem(evidence)) {
+ return true;
+ }
+
+ String realName = evidence.getName();
+ if (evidence.isRoot()) {
+ // if evidence is root, its realname can be changed via -dname parameter, so we
+ // need to get it from other source.
+ realName = evidence.getDataSource().getSourceFile().getName();
+ }
+
+ return DUMP_ROOT_FOLDER_NAMES.contains(realName);
+ }
+
+ /**
+ * Checks if the item is a file system item of a ufdr: the virtual folder UfedXmlReader creates for the "fs"
+ * attribute of report.xml entries (e.g. "extraction-001.ufdr/EXTRACTION_FFS.zip"). The device paths
+ * hang from it, so it is the extraction root, and not the ufdr item.
+ */
+ private boolean isUfdrFileSystem(IItem evidence) {
+
+ // file system items are always virtual folders, direct children of the ufdr item
+ if (evidence.isRoot() || !evidence.isDir()
+ || UfedXmlReader.DECODED_DATA_FOLDER_NAME.equals(evidence.getName())) {
+ return false;
+ }
+
+ String path = evidence.getPath();
+ String parentPath = StringUtils.substringBeforeLast(path, "/");
+ if (StringUtils.isEmpty(parentPath) || parentPath.equals(path)) {
+ return false;
+ }
+
+ // a ufdr processed as a subitem of another evidence keeps its file name in the path
+ if (UFDR_EXT.equalsIgnoreCase(FilenameUtils.getExtension(parentPath))) {
+ return true;
+ }
+
+ // a ufdr processed as a case root may have been renamed via -dname, so check the data source
+ // file instead, requiring a direct child of the root (whose path has no separator)
+ IDataSource dataSource = evidence.getDataSource();
+ File sourceFile = dataSource != null ? dataSource.getSourceFile() : null;
+
+ return sourceFile != null && !StringUtils.contains(parentPath, '/')
+ && UFDR_EXT.equalsIgnoreCase(FilenameUtils.getExtension(sourceFile.getName()));
+ }
+
+ private boolean isCaseEvidence(IItem evidence) {
+ return ALEAPP_CASE_MEDIATYPE.equals(evidence.getMediaType());
+ }
+
+ private boolean isCategoryEvidence(IItem evidence) {
+ return ALEAPP_CATEGORY_MEDIATYPE.equals(evidence.getMediaType());
+ }
+
+ private boolean isPluginEvidence(IItem evidence) {
+ return ALEAPP_PLUGIN_RESULTS_MEDIATYPE.equals(evidence.getMediaType());
+ }
+
+ private boolean isDeviceInfoEvidence(IItem evidence) {
+ return ALEAPP_DEVICE_INFO_MEDIATYPE.equals(evidence.getMediaType());
+ }
+
+ private boolean isDeferredFilePathLinks(IItem evidence) {
+ return evidence.getTempAttribute(ALEAPP_METADATA_PATHS) != null;
+ }
+
+ private void processExtractionRoot(IItem rootEvidence) {
+
+ if (selectedPlugins.isEmpty()) {
+ return;
+ }
+
+ // creates a subitem to represent the ALeapp report
+ Item caseEvidence = (Item) rootEvidence.createChildItem();
+ caseEvidence.setMediaType(ALEAPP_CASE_MEDIATYPE);
+
+ String name = CASE_EVIDENCE_NAME;
+ caseEvidence.setName(name);
+ caseEvidence.setPath(rootEvidence.getPath() + "/" + name);
+ caseEvidence.setIdInDataSource("");
+ caseEvidence.setHasChildren(true);
+ caseEvidence.setExtraAttribute(BasicProps.TREENODE, true);
+ caseEvidence.setExtraAttribute(ExtraProperties.DECODED_DATA, true);
+
+ String extractionType;
+ if (AndroidBackupParser.SUPPORTED_TYPES.contains(rootEvidence.getMediaType())) {
+ extractionType = EXTRACTION_TYPE_ANDROID_BACKUP;
+ } else if (isUfdrFileSystem(rootEvidence)) {
+ extractionType = EXTRACTION_TYPE_UFDR;
+ } else {
+ String realExt = rootEvidence.getExt();
+ if (rootEvidence.isRoot()) {
+ String realName = rootEvidence.getDataSource().getSourceFile().getName();
+ realExt = FilenameUtils.getExtension(realName);
+ }
+ extractionType = ZIP_EXT.equalsIgnoreCase(realExt) ? EXTRACTION_TYPE_ZIP : EXTRACTION_TYPE_DUMP;
+ }
+ caseEvidence.getMetadata().set(ALEAPP_EXTRACTION_TYPE_META, extractionType);
+
+ worker.processNewItem(caseEvidence, ProcessTime.LATER);
+ }
+
+ private void processCaseEvidence(IItem caseEvidence) throws Exception {
+
+ if (!isSupportedAndroidExtraction(caseEvidence)) {
+ caseEvidence.setToIgnore(true);
+ return;
+ }
+
+ Map categoryItems = new HashMap<>();
+
+ // creates one subitem for each plugin execution
+ // (PluginSpec getters call PyObject.getAttr on this worker's interpreter)
+ for (PluginSpec plugin : selectedPlugins.values()) {
+
+ Item categoryItem = categoryItems.computeIfAbsent(plugin.getCategory(), name -> {
+ Item categoryEvidence = (Item) caseEvidence.createChildItem();
+ categoryEvidence.setMediaType(ALEAPP_CATEGORY_MEDIATYPE);
+
+ categoryEvidence.setName(name);
+ categoryEvidence.setExtension("");
+ categoryEvidence.setPath(caseEvidence.getPath() + "/" + name);
+ categoryEvidence.setIdInDataSource("");
+ categoryEvidence.setExtraAttribute(ExtraProperties.DECODED_DATA, true);
+
+ worker.processNewItem(categoryEvidence, ProcessTime.LATER);
+
+ return categoryEvidence;
+ });
+
+ Item pluginEvidence = (Item) categoryItem.createChildItem();
+ pluginEvidence.setMediaType(ALEAPP_PLUGIN_RESULTS_MEDIATYPE);
+ pluginEvidence.setTempAttribute(ALEAPP_PLUGIN_CATEGORY_KEY, categoryItem);
+
+ String name = StringUtils.firstNonBlank((String) plugin.getArtifactInfo().get("name"), plugin.getName());
+ pluginEvidence.setName(name);
+ pluginEvidence.setExtension("");
+ pluginEvidence.setPath(categoryItem.getPath() + "/" + name);
+ pluginEvidence.setIdInDataSource("");
+ pluginEvidence.setExtraAttribute(ExtraProperties.DECODED_DATA, true);
+
+ pluginEvidence.getMetadata().set(ALEAPP_PLUGIN_KEYNAME_META, plugin.getName());
+ pluginEvidence.getMetadata().set(ALEAPP_PLUGIN_METADATA_PREFIX + "moduleName", plugin.getModuleName());
+ for (Entry entry : plugin.getArtifactInfo().entrySet()) {
+ if (ARTIFACT_INFO_KEYS_TO_IGNORE.contains(entry.getKey())) {
+ continue;
+ }
+ pluginEvidence.getMetadata().set(ALEAPP_PLUGIN_METADATA_PREFIX + entry.getKey(), entry.getValue().toString());
+ }
+
+ worker.processNewItem(pluginEvidence, ProcessTime.LATER);
+ }
+
+ // creates subitem to hold device info collected
+ Item deviceInfoEvidence = (Item) caseEvidence.createChildItem();
+ deviceInfoEvidence.setName(DEVICE_INFO_HTML);
+ deviceInfoEvidence.setMediaType(ALEAPP_DEVICE_INFO_MEDIATYPE);
+ deviceInfoEvidence.setPath(caseEvidence.getPath() + "/" + DEVICE_INFO_HTML);
+ deviceInfoEvidence.setIdInDataSource("");
+ worker.processNewItem(deviceInfoEvidence, ProcessTime.LATER);
+ }
+
+ /**
+ * Checks if the extraction root really holds an Android extraction the plugins can decode, applying the checks each
+ * extraction type needs.
+ */
+ private boolean isSupportedAndroidExtraction(IItem caseEvidence) {
+
+ String extractionType = caseEvidence.getMetadata().get(ALEAPP_EXTRACTION_TYPE_META);
+
+ switch (extractionType) {
+
+ case EXTRACTION_TYPE_ANDROID_BACKUP:
+ // expanded as a tar, holding a file system of its own ("apps/", "shared/0"...)
+ return true;
+
+ case EXTRACTION_TYPE_UFDR:
+ return isAndroidFileSystem(caseEvidence);
+
+ case EXTRACTION_TYPE_DUMP:
+ case EXTRACTION_TYPE_ZIP:
+ return isAndroidFileSystem(caseEvidence) && isInsideRealDump(caseEvidence);
+
+ default:
+ throw new IllegalStateException("Unexpected extraction type: " + extractionType);
+ }
+ }
+
+ /**
+ * Checks if the case evidence really corresponds to an Android dump, by looking for a folder every Android device
+ * has ("/data/data/com.android.vending"). Avoids running plugins over unrelated zips/folders that just happen to
+ * match the extraction root naming rules.
+ */
+ private boolean isInsideRealDump(IItem caseEvidence) {
+
+ String checkFolder = "/data/data/com.android.vending";
+
+ IItemSearcher searcher = (IItemSearcher) caseData.getCaseObject(IItemSearcher.class.getName());
+
+ return LeappUtils.findItemByPath(searcher, getExtractionRootPath(caseEvidence), checkFolder) != null;
+ }
+
+ /**
+ * Checks if the extraction root holds an Android file system, by looking for at least one of its top level folders
+ * ({@link #ANDROID_ROOT_FOLDER_NAMES}).
+ * A ufdr can hold an iOS, KeyChain or KeyStore extractions, whose file system have no plugin can decode.
+ */
+ @SuppressWarnings("resource")
+ private boolean isAndroidFileSystem(IItem caseEvidence) {
+
+ String rootPath = getExtractionRootPath(caseEvidence);
+
+ IItemSearcher searcher = (IItemSearcher) caseData.getCaseObject(IItemSearcher.class.getName());
+
+ // anyMatch short-circuits: the next folder is only searched if the previous one was not found
+ return ANDROID_ROOT_FOLDER_NAMES.stream()
+ .anyMatch(folder -> LeappUtils.findItemByPath(searcher, rootPath, "/" + folder) != null);
+ }
+
+ /** The in-case path of the extraction root, i.e. the path prefix shared by every item of this extraction. */
+ private String getExtractionRootPath(IItem aleappEvidence) {
+ return StringUtils.substringBeforeLast(aleappEvidence.getPath(), "/" + CASE_EVIDENCE_NAME);
+ }
+
+ private void processCategoryEvidence(IItem categoryEvidence) throws Exception {
+ if (!categoryEvidence.hasChildren()) {
+ logger.info("Ignoring Aleapp category {}: no children", categoryEvidence.getName());
+ categoryEvidence.setToIgnore(true);
+ }
+ }
+
+ private void processPluginEvidence(IItem pluginEvidence) throws Exception {
+
+ String pluginName = pluginEvidence.getMetadata().get(ALEAPP_PLUGIN_KEYNAME_META);
+ PluginSpec plugin = selectedPlugins.get(pluginName);
+ if (plugin == null) {
+ throw new IllegalStateException("Plugin should have been found: " + pluginName);
+ }
+
+ // look for the files the plugin needs
+ // (mimics https://github.com/abrignoni/ALEAPP/blob/v2026.1.0/aleapp.py#L386)
+ IItemSearcher searcher = (IItemSearcher) caseData.getCaseObject(IItemSearcher.class.getName());
+ FileSeeker seeker = new FileSeeker(getExtractionRootPath(pluginEvidence), exportFilesFolder, searcher);
+
+ try {
+
+ List filesFound = seeker.searchItems(plugin.getSearchGlobs());
+
+ if (filesFound.isEmpty()) {
+ logger.warn("Ignoring Aleapp {} plugin: no files found", pluginName);
+ pluginEvidence.setToIgnore(true);
+ return;
+ }
+
+ LeappContext.create(seeker, worker, jep, plugin, pluginEvidence, filesFound);
+
+ try {
+ // mimics https://github.com/abrignoni/ALEAPP/blob/v2026.1.0/aleapp.py#L409
+ Path categoryFolder = Paths.get(outputFolderBase, "_HTML", plugin.getCategory());
+ Files.createDirectories(categoryFolder);
+
+ // export all files found (and -wal/-journal companions of sqlite items)
+ ArrayList filesFoundStringList = seeker.exportItems(filesFound);
+
+ // call the plugin method
+ // plugin.method(files_found, report_folder, seeker, wrap_text)
+ //
+ // https://github.com/abrignoni/ALEAPP/blob/v2026.1.0/aleapp.py#L418
+ plugin.getMethod().call(filesFoundStringList, categoryFolder.toString(), seeker, false);
+
+ } catch (Exception e) {
+ logger.error("Aleapp {} plugin ended prematurely: {}", pluginName, ExceptionUtils.getMessage(e));
+ logger.warn(pluginName, e);
+ }
+
+ if (!pluginEvidence.hasChildren()) {
+ logger.warn("Ignoring Aleapp {} plugin: no children", pluginName);
+ pluginEvidence.setToIgnore(true);
+ }
+ } finally {
+ try {
+ // clears this thread's Python Context state, mirroring aleapp.py's per-artifact
+ // Context.clear() (state is thread-local, see context_thread_local_patch.py)
+ jep.exec("import scripts.context");
+ jep.exec("scripts.context.Context.clear()");
+ } catch (Exception e) {
+ logger.warn("Failed to clear Python Context after {} plugin", pluginName, e);
+ }
+ seeker.cleanup();
+ LeappContext.clear();
+ }
+ }
+
+ private void processDeviceInfoEvidence(IItem deviceInfoEvidence) throws Exception {
+
+ // https://github.com/abrignoni/ALEAPP/blob/v2026.1.0/aleapp.py#L432
+ jep.exec("import scripts.ilapfuncs");
+
+ Path deviceInfoPath = Files.createTempFile("screen_output_file_path_devinfo", ".html");
+ try {
+ // OutputParameters attributes are class-level and identifiers is a module
+ // global, both shared across all workers: serialize the path switch + write
+ // against concurrent device_info() calls (see IlapfuncsDeviceInfoInterceptor)
+ synchronized (DEVICE_INFO_LOCK) {
+ jep.exec("scripts.ilapfuncs.OutputParameters.screen_output_file_path_devinfo = '" + deviceInfoPath.toString() + "'");
+ jep.exec("scripts.ilapfuncs.write_device_info()");
+ }
+
+ byte[] deviceInfoBytes = Files.readAllBytes(deviceInfoPath);
+
+ if (deviceInfoBytes.length > 0) {
+ ExportFileTask.getLastInstance().insertIntoStorage(deviceInfoEvidence, deviceInfoBytes, deviceInfoBytes.length);
+ } else {
+ deviceInfoEvidence.setToIgnore(true);
+ }
+ } finally {
+ Files.deleteIfExists(deviceInfoPath);
+ }
+ }
+
+ /**
+ * Resolves the deferred file-path links of a plugin row subitem. The interceptor recorded the "aleapp:<header>"
+ * metadata keys whose values are device file paths in the {@link #ALEAPP_METADATA_PATHS} temp attribute; here each
+ * value is resolved to its case item (via {@link LeappUtils#findItemByPath}) and added to LINKED_ITEMS. Doing it here
+ * (instead of inside the plugin call) spreads the one-search-per-path cost across the worker pool.
+ */
+ private void processDeferredFilePathLinks(IItem item) {
+
+ @SuppressWarnings("unchecked")
+ List metadataKeys = (List) item.getTempAttribute(ALEAPP_METADATA_PATHS);
+
+ IItemSearcher searcher = (IItemSearcher) caseData.getCaseObject(IItemSearcher.class.getName());
+ String pathRoot = getExtractionRootPath(item);
+
+ for (String metadataKey : metadataKeys) {
+ String pathValue = StringUtils.trimToEmpty(item.getMetadata().get(metadataKey));
+ if (pathValue.isEmpty() || pathValue.equals("/")) {
+ continue;
+ }
+ IItemReader found = LeappUtils.findItemByPath(searcher, pathRoot, pathValue);
+ if (found != null) {
+ item.getMetadata().add(ExtraProperties.LINKED_ITEMS,
+ ExtraProperties.GLOBAL_ID + ":" + found.getExtraAttribute(ExtraProperties.GLOBAL_ID));
+ }
+ }
+ }
+
+ @Override
+ public void finish() throws Exception {
+ // the Jep interpreter is shared with PythonParser/PythonTask and owned by them: do NOT close it here
+ jep = null;
+
+ try {
+ if (outputFolder != null && Files.exists(outputFolder)) {
+ PathUtils.deleteDirectory(outputFolder);
+ }
+ } catch (Exception e) {
+ logger.warn("Failed to delete outputFolder: {}", outputFolder, e);
+ }
+ outputFolder = null;
+ exportFilesFolder = null;
+ }
+}
diff --git a/iped-engine/src/main/java/iped/engine/task/leapp/CallInterceptor.java b/iped-engine/src/main/java/iped/engine/task/leapp/CallInterceptor.java
new file mode 100644
index 0000000000..1c9f2c731e
--- /dev/null
+++ b/iped-engine/src/main/java/iped/engine/task/leapp/CallInterceptor.java
@@ -0,0 +1,151 @@
+package iped.engine.task.leapp;
+
+import java.util.Arrays;
+import java.util.Map;
+
+import org.apache.commons.lang3.StringUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import jep.Jep;
+import jep.PyMethod;
+import jep.python.PyCallable;
+
+/**
+ * A Java class that can be used as a Python function.
+ *
+ * It intercepts calls to Python function and then calls the original function.
+ */
+public class CallInterceptor {
+
+ protected static final Logger logger = LoggerFactory.getLogger(CallInterceptor.class);
+
+ private String pythonModule;
+ private String pythonFunction;
+ private boolean isClassMethod = false;
+
+ private PyCallable originalCall;
+
+ public CallInterceptor(String pythonModule, String pythonFunction, boolean isClassMethod) {
+ this.pythonModule = pythonModule;
+ this.pythonFunction = pythonFunction;
+ this.isClassMethod = isClassMethod;
+ }
+
+ public CallInterceptor(String pythonModule, String pythonFunction) {
+ this(pythonModule, pythonFunction, false);
+ }
+
+ public void install(Jep jep) {
+
+ if (StringUtils.isNotBlank(pythonModule)) {
+ jep.exec("import " + pythonModule);
+ }
+
+ // "_iped_leapp_interceptor" is only a temporary handoff variable used during
+ // install: both branches below capture this interceptor immediately, so the
+ // global is deleted at the end. The name is prefixed because the interpreter
+ // namespace is shared with PythonParser/PythonTask scripts.
+ jep.set("_iped_leapp_interceptor", this);
+
+ if (isClassMethod) {
+
+ String clazz = StringUtils.substringBeforeLast(pythonFunction, ".");
+ String method = StringUtils.substringAfterLast(pythonFunction, ".");
+
+ originalCall = jep.getValue("getattr(" + clazz + ", \"" + method + "\")", PyCallable.class);
+ if (originalCall == null) {
+ throw new IllegalStateException("Original call is null for: " + pythonFunction);
+ }
+
+ // The keyword-only default "_interceptor=..." captures the interceptor at def
+ // time. Referencing the global directly in the body would be resolved at CALL
+ // time (late binding), breaking once the temp global is deleted below.
+ jep.exec("def _iped_leapp_interceptor_method(self, *args, _interceptor=_iped_leapp_interceptor, **kwargs):"
+ + " return _interceptor.call(self, *args, **kwargs)");
+
+ // the class attribute keeps the function reference: the temp global can go
+ jep.exec("setattr(" + clazz + ", \"" + method + "\", _iped_leapp_interceptor_method)");
+ jep.exec("del _iped_leapp_interceptor_method");
+
+ } else {
+
+ originalCall = jep.getValue(pythonFunction, PyCallable.class);
+ if (originalCall == null) {
+ throw new IllegalStateException("Original call is null for: " + pythonFunction);
+ }
+
+ // "_iped_leapp_interceptor.call" is evaluated NOW, at exec time: the module
+ // attribute ends up holding a bound callable, independent of the global name.
+ jep.exec(pythonFunction + " = _iped_leapp_interceptor.call");
+ }
+
+ jep.exec("del _iped_leapp_interceptor");
+ }
+
+ // The three call() overloads below exist because Jep dispatches by the Python
+ // call shape (positional-only, kwargs-only, or both), selected via @PyMethod.
+ // Subclasses overriding call(Object[], Map) must repeat the @PyMethod
+ // annotation, otherwise Jep dispatch breaks.
+
+ @PyMethod(varargs = true, kwargs = true)
+ public Object call(Object[] args, Map kwargs) throws Exception {
+
+ if (logger.isDebugEnabled()) {
+ logger.debug("JAVA INTERCEPTOR: ---- 001 ----");
+ logger.debug("JAVA INTERCEPTOR: call: " + pythonFunction);
+ logger.debug("JAVA INTERCEPTOR: varargs: " + Arrays.toString(args));
+ logger.debug("JAVA INTERCEPTOR: kwargs: " + kwargs);
+ }
+
+ handleArgs(args, kwargs);
+
+ return this.originalCall.call(args, kwargs);
+ }
+
+ @PyMethod(varargs = true, kwargs = false)
+ public Object call(Object... args) throws Exception {
+ return call(args, null);
+ }
+
+ @PyMethod(varargs = false, kwargs = true)
+ public Object call(Object str, Map kwargs) throws Exception {
+ Object[] args = new Object[] { str };
+ return call(args, kwargs);
+ }
+
+ protected void handleArgs(Object[] args, Map kwargs) throws Exception {
+ }
+
+ protected Object getArgumentValue(String key, int index, Object[] args, Map kwargs) {
+ if (key != null && kwargs != null && kwargs.containsKey(key)) {
+ return kwargs.get(key);
+ }
+
+ if (index >= 0 && args != null && args.length > index) {
+ return args[index];
+ }
+
+ return null;
+ }
+
+ protected void setArgumentValue(String key, int index, Object value, Object[] args, Map kwargs) {
+
+ if (logger.isDebugEnabled()) {
+ logger.debug(String.format("Setting value: [%s,%d] <= %s", key, index, value));
+ }
+
+ if (key != null && kwargs != null && kwargs.containsKey(key)) {
+ kwargs.put(key, value);
+ return;
+ }
+
+ if (index >= 0 && args != null && args.length > index) {
+ args[index] = value;
+ return;
+ }
+
+ throw new IllegalArgumentException(String.format("Invalid key or index: [%s,%d] <= %s %s", key, index, Arrays.toString(args), kwargs));
+ }
+
+}
\ No newline at end of file
diff --git a/iped-engine/src/main/java/iped/engine/task/leapp/FileSeeker.java b/iped-engine/src/main/java/iped/engine/task/leapp/FileSeeker.java
new file mode 100644
index 0000000000..6799587b64
--- /dev/null
+++ b/iped-engine/src/main/java/iped/engine/task/leapp/FileSeeker.java
@@ -0,0 +1,270 @@
+package iped.engine.task.leapp;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardCopyOption;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import org.apache.commons.io.file.PathUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import iped.data.IItemReader;
+import iped.properties.BasicProps;
+import iped.search.IItemSearcher;
+import iped.utils.DateUtil;
+import iped.utils.IOUtil;
+
+public class FileSeeker {
+
+ protected static final Logger logger = LoggerFactory.getLogger(FileSeeker.class);
+
+ private String pathRoot;
+ private IItemSearcher searcher;
+ private Path exportFolder;
+
+ private Map exportedFiles = new HashMap<>();
+
+ // Public snake_case fields: LEAPP plugins access these directly on the Python
+ // side as attributes of the "seeker" object (e.g. seeker.data_folder), so the
+ // names MUST match FileSeekerBase attributes. Do not rename.
+ public String data_folder;
+ public HashMap file_infos = new HashMap<>();
+
+ public static class FileInfo {
+ public String source_path;
+ // epoch SECONDS, mirroring Python's FileInfo which stores os.stat()
+ // st_ctime/st_mtime floats (0 when unknown, like FileSeekerTar does).
+ // Plugins compare/convert these as numbers, so java.util.Date must not
+ // be used here.
+ public double creation_date;
+ public double modification_date;
+
+ public FileInfo(String source_path, Date creationDate, Date modificationDate) {
+ this.source_path = source_path;
+ this.creation_date = creationDate == null ? 0 : creationDate.getTime() / 1000.0;
+ this.modification_date = modificationDate == null ? 0 : modificationDate.getTime() / 1000.0;
+ }
+ }
+
+ public FileSeeker(String pathRoot, Path exportFolder, IItemSearcher searcher) {
+ this.pathRoot = pathRoot;
+ this.searcher = searcher;
+ this.exportFolder = exportFolder.toAbsolutePath().normalize();
+ this.data_folder = this.exportFolder.toString();
+ }
+
+ /**
+ * Python-facing API, mirroring ALEAPP's FileSeekerBase:
+ *
+ * def search(self, filepattern, return_on_first_hit=False): '''Returns a list of paths for files/folders that
+ * matched'''
+ *
+ * Unlike ALEAPP seekers, the matched files live inside the IPED case, so each hit is exported to the local export
+ * folder and the local paths are returned. For sqlite hits, -wal and -journal companion files are exported too, so
+ * the databases can be opened consistently.
+ *
+ * NOTE: Jep cannot map Python keyword arguments onto Java methods, so plugins must call seeker.search(pattern, True)
+ * positionally; the kwarg form return_on_first_hit=True is not supported.
+ *
+ * https://github.com/abrignoni/ALEAPP/blob/v2026.1.0/scripts/search_files.py#L57
+ */
+ public List search(String filepattern) throws IOException {
+ return search(filepattern, false);
+ }
+
+ public List search(String filepattern, boolean returnOnFirstHit) throws IOException {
+
+ List items = searchItems(List.of(filepattern));
+
+ if (returnOnFirstHit && !items.isEmpty()) {
+ // mimics ALEAPP semantics: a list containing only the first hit
+ items = items.subList(0, 1);
+ }
+
+ return exportItems(items);
+ }
+
+ /**
+ * Exports the given case items to the local export folder and returns their local paths, in the same order. For
+ * sqlite items, -wal and -journal companion files are exported too, so the databases can be opened consistently.
+ */
+ public ArrayList exportItems(List items) throws IOException {
+
+ ArrayList paths = new ArrayList<>();
+
+ // ids of the items themselves: avoids exporting a -wal/-journal companion a
+ // second time when the plugin's search pattern also matched it directly
+ Set itemIds = new HashSet<>();
+ for (IItemReader item : items) {
+ itemIds.add(item.getId());
+ }
+
+ for (IItemReader item : items) {
+
+ paths.add(exportItemToFile(item).toString());
+
+ if ("sqlite".equals(item.getType())) {
+ for (IItemReader walOrJournal : getJournalAndWalFiles(item)) {
+ if (!itemIds.contains(walOrJournal.getId())) {
+ exportItemToFile(walOrJournal);
+ }
+ }
+ }
+ }
+
+ return paths;
+ }
+
+ /**
+ * Java-facing search used by the task itself: returns the matched case items without exporting them.
+ */
+ public List searchItems(List globPatterns) {
+
+ String query = "("
+ + globPatterns.stream()
+ .map(glob -> LeappUtils.buildFileSearchQuery(pathRoot, glob))
+ .collect(Collectors.joining(") OR ("))
+ + ")";
+
+ logger.debug("query=[{}], patterns=[{}]", query, globPatterns);
+
+ // The Lucene query is a high-recall pre-filter and may return extra hits (see
+ // LeappUtils.globToLuceneQuery), so re-check each hit strictly: it must start
+ // with the evidence root path and fnmatch at least one of the glob patterns.
+ List hits = searcher
+ .search(query) //
+ .stream() //
+ .filter(item -> item.getPath().startsWith(pathRoot)) //
+ .filter(item -> !item.getPath().substring(pathRoot.length()).contains(">>")) //
+ .filter(item -> {
+ for (String glob : globPatterns) {
+ if (LeappUtils.matchesGlob(item, glob)) {
+ return true;
+ }
+ }
+ return false;
+ })
+ .collect(Collectors.toList());
+
+ // Two or more items can share the same path (e.g. an active file plus a carved
+ // copy or one recovered from deletion), which would collide in the export
+ // folder. Keep only the most significant item per path: active > deleted >
+ // carved; ties keep the first hit.
+ Map bestItemPerPath = new LinkedHashMap<>();
+ for (IItemReader item : hits) {
+ bestItemPerPath.merge(item.getPath(), item,
+ (current, candidate) -> significanceRank(candidate) < significanceRank(current) ? candidate : current);
+ }
+
+ return new ArrayList<>(bestItemPerPath.values());
+ }
+
+ /**
+ * Lower is better: an active file is preferred over one recovered from deletion, which is preferred over a carved one
+ */
+ private static int significanceRank(IItemReader item) {
+ if (item.isCarved()) {
+ return 2;
+ }
+ if (item.isDeleted()) {
+ return 1;
+ }
+ return 0;
+ }
+
+ public Path exportItemToFile(IItemReader item) throws IOException {
+
+ Path filePath;
+
+ if (IOUtil.hasFile(item)) {
+
+ filePath = IOUtil.getFile(item).toPath();
+
+ } else {
+
+ // we are sure that the item path starts with the evidence root path
+ // because searchItems() already filtered it, so this substring is safe
+ String fileRelativePathStr = item.getPath().substring(pathRoot.length() + 1);
+ filePath = exportFolder.resolve(fileRelativePathStr);
+
+ // create the folder for the item
+ Files.createDirectories(filePath.getParent());
+
+ if (item.isDir()) {
+ Files.createDirectories(filePath);
+ } else {
+ if (!Files.exists(filePath) || item.getLength() == null || Files.size(filePath) != item.getLength()) {
+
+ // copy the item to the folder
+ try (InputStream is = item.getBufferedInputStream()) {
+ Files.copy(is, filePath, StandardCopyOption.REPLACE_EXISTING);
+ }
+ }
+ }
+
+ DateUtil.updatePathTimes(filePath, item);
+ }
+
+ filePath = filePath.toAbsolutePath().normalize();
+
+ exportedFiles.put(filePath.toString(), item);
+
+ // keep file_infos in sync: some plugins read seeker.file_infos[path] to recover
+ // the original source path and timestamps of an exported file
+ file_infos.put(filePath.toString(), new FileInfo(item.getPath(), item.getCreationDate(), item.getModDate()));
+
+ return filePath;
+ }
+
+ public List getJournalAndWalFiles(IItemReader item) {
+ if (!"sqlite".equals(item.getType())) {
+ return Collections.emptyList();
+ }
+
+ String basePath = item.getPath();
+ String walPath = basePath + "-wal";
+ String journalPath = basePath + "-journal";
+
+ return searcher.search(BasicProps.PATH + ":(\"" + walPath + "\" \"" + journalPath + "\")")
+ .stream()
+ .filter(i -> i.getPath().equals(walPath) || i.getPath().equals(journalPath))
+ .collect(Collectors.toList());
+ }
+
+ // https://github.com/abrignoni/ALEAPP/blob/v2026.1.0/scripts/search_files.py#L61
+ public void cleanup() {
+ try {
+ if (Files.exists(exportFolder)) {
+ PathUtils.deleteDirectory(exportFolder);
+ }
+ } catch (IOException e) {
+ logger.warn("Failed to delete export folder: {}", exportFolder, e);
+ }
+ exportedFiles.clear();
+ file_infos.clear();
+ }
+
+ public Map getExportedFiles() {
+ return exportedFiles;
+ }
+
+ public String getPathRoot() {
+ return pathRoot;
+ }
+
+ public IItemSearcher getSearcher() {
+ return searcher;
+ }
+}
diff --git a/iped-engine/src/main/java/iped/engine/task/leapp/LeappContext.java b/iped-engine/src/main/java/iped/engine/task/leapp/LeappContext.java
new file mode 100644
index 0000000000..840272d2b2
--- /dev/null
+++ b/iped-engine/src/main/java/iped/engine/task/leapp/LeappContext.java
@@ -0,0 +1,69 @@
+package iped.engine.task.leapp;
+
+import java.util.List;
+
+import iped.data.IItem;
+import iped.data.IItemReader;
+import iped.engine.core.Worker;
+import jep.Jep;
+
+public class LeappContext {
+
+ private FileSeeker fileSeeker;
+ private Worker worker;
+ private PluginSpec plugin;
+ private Jep jep;
+ private IItem pluginItem;
+
+ private List foundFiles;
+
+ private static final ThreadLocal threadLocal = new ThreadLocal<>();
+
+ private LeappContext(FileSeeker seeker, Worker worker, Jep jep, PluginSpec plugin, IItem pluginItem, List foundFiles) {
+ this.fileSeeker = seeker;
+ this.worker = worker;
+ this.plugin = plugin;
+ this.jep = jep;
+ this.foundFiles = foundFiles;
+ this.pluginItem = pluginItem;
+ }
+
+ public static LeappContext create(FileSeeker seeker, Worker worker, Jep jep, PluginSpec plugin, IItem pluginItem, List foundFiles) {
+ LeappContext context = new LeappContext(seeker, worker, jep, plugin, pluginItem, foundFiles);
+ threadLocal.set(context);
+ return context;
+ }
+
+ public static void clear() {
+ threadLocal.remove();
+ }
+
+ public static LeappContext get() {
+ return threadLocal.get();
+ }
+
+ public FileSeeker getFileSeeker() {
+ return fileSeeker;
+ }
+
+ public Worker getWorker() {
+ return worker;
+ }
+
+ public PluginSpec getPlugin() {
+ return plugin;
+ }
+
+ public Jep getJep() {
+ return jep;
+ }
+
+ public IItem getPluginItem() {
+ return pluginItem;
+ }
+
+ public List getFoundFiles() {
+ return foundFiles;
+ }
+
+}
diff --git a/iped-engine/src/main/java/iped/engine/task/leapp/LeappInterceptors.java b/iped-engine/src/main/java/iped/engine/task/leapp/LeappInterceptors.java
new file mode 100644
index 0000000000..2a1a24ff6e
--- /dev/null
+++ b/iped-engine/src/main/java/iped/engine/task/leapp/LeappInterceptors.java
@@ -0,0 +1,160 @@
+package iped.engine.task.leapp;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.UncheckedIOException;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.List;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import iped.engine.task.leapp.interceptors.IlapfuncsDeviceInfoInterceptor;
+import iped.engine.task.leapp.interceptors.IlapfuncsLogfuncInterceptor;
+import iped.engine.task.leapp.interceptors.LavaInsertSqliteDataInterceptor;
+import jep.Jep;
+
+public class LeappInterceptors {
+
+ protected static final Logger logger = LoggerFactory.getLogger(LeappInterceptors.class);
+
+ private List interceptors = new ArrayList<>();
+
+ public LeappInterceptors() {
+ interceptors.add(new IlapfuncsLogfuncInterceptor());
+ // results are captured from lava_insert_sqlite_data (NOT tsv/timeline/html):
+ // it is the only output call receiving raw typed headers and raw values
+ interceptors.add(new LavaInsertSqliteDataInterceptor());
+ // serializes concurrent writes to the shared ilapfuncs.identifiers dict
+ interceptors.add(new IlapfuncsDeviceInfoInterceptor());
+ }
+
+ public void install(Jep jep) {
+
+ disableFunctions(jep);
+
+ // patches LEAPP's Context so its per-plugin-run state becomes thread-local,
+ // isolating concurrent plugin runs on different IPED workers sharing the same
+ // Python interpreter (full rationale in the resource file)
+ execPythonResource(jep, "context_thread_local_patch.py");
+
+ for (CallInterceptor interceptor : interceptors) {
+ interceptor.install(jep);
+ }
+ }
+
+ private void execPythonResource(Jep jep, String resourceName) {
+ String script;
+ try (InputStream is = getClass().getResourceAsStream(resourceName)) {
+ script = new String(is.readAllBytes(), StandardCharsets.UTF_8);
+ } catch (IOException e) {
+ throw new UncheckedIOException("Failed to load resource: " + resourceName, e);
+ }
+ jep.exec(script);
+ }
+
+ public void disableFunctions(Jep jep) {
+
+ disableLavaFuncs(jep);
+
+ // tsv, timeline and kml outputs are not used: results are captured from
+ // lava_insert_sqlite_data, which receives the raw typed data
+ disablePythonFunction(jep, "scripts.ilapfuncs", "scripts.ilapfuncs.tsv");
+ disablePythonFunction(jep, "scripts.ilapfuncs", "scripts.ilapfuncs.timeline");
+ disablePythonFunction(jep, "scripts.ilapfuncs", "scripts.ilapfuncs.kmlgen");
+ disablePythonFunction(jep, "scripts.ilapfuncs", "scripts.ilapfuncs.set_media_references");
+
+ // avoid to use backslash as path separator
+ disablePythonFunction(jep, "scripts.ilapfuncs", "scripts.ilapfuncs.is_platform_windows", "False");
+
+ // html output is not used
+ disablePythonClass(jep, "scripts.artifact_report", "scripts.artifact_report.ArtifactHtmlReport");
+
+ patchMediaFunctions(jep);
+ }
+
+ /**
+ * Media handling: plugins register media files through check_in_media, whose return value ends up in the
+ * data_list cells of 'media' typed columns.
+ *
+ * The original implementation copies files and registers them in the LAVA media database (disabled here). It is
+ * replaced by a version that simply returns the extraction path of the media file (resolved against the
+ * thread-local Context), which LavaInsertSqliteDataInterceptor then maps back to the original case item and adds
+ * to the subitem's linkedItems.
+ *
+ * get_data_list_with_media is also patched: artifact_processor calls it whenever a 'media' column exists (BEFORE
+ * checking output types) to build the html/tsv views, and it would crash against the disabled
+ * lava_get_full_media_info stub. Since html/tsv outputs are disabled, it just passes data through.
+ */
+ private void patchMediaFunctions(Jep jep) {
+ jep.exec("import scripts.ilapfuncs");
+ jep.exec("import scripts.context");
+
+ // the keyword-only default "_context=..." captures the Context class at def
+ // time, so no extra global is left behind after the temp names are deleted
+ jep.exec("def _iped_leapp_check_in_media(file_path, name='', *args, _context=scripts.context.Context, **kwargs):"
+ + " return _context.get_source_file_path(file_path)");
+ jep.exec("scripts.ilapfuncs.check_in_media = _iped_leapp_check_in_media");
+ jep.exec("del _iped_leapp_check_in_media");
+
+ // embedded media has no corresponding case item to link: keep it disabled
+ disablePythonFunction(jep, "scripts.ilapfuncs", "scripts.ilapfuncs.check_in_embedded_media");
+
+ jep.exec("scripts.ilapfuncs.get_data_list_with_media = lambda media_header_info, data_list: (data_list, data_list)");
+ }
+
+ private void disableLavaFuncs(Jep jep) {
+
+ // Important!! ---> lavafuncs MUST be disabled before import ilapfuncs
+
+ // disable lava init/finalize (not called by IPED explicitly)
+ // https://github.com/abrignoni/ALEAPP/blob/v2026.1.0/aleapp.py#L312
+ disablePythonFunction(jep, "scripts.lavafuncs", "scripts.lavafuncs.initialize_lava");
+ disablePythonFunction(jep, "scripts.lavafuncs", "scripts.lavafuncs.lava_finalize_output");
+
+ // artifact_processor unpacks lava_process_artifact's return: keep the shape.
+ // LavaInsertSqliteDataInterceptor does not need these values, the types come
+ // from the data_headers tuples
+ disablePythonFunction(jep, "scripts.lavafuncs", "scripts.lavafuncs.lava_process_artifact", "[None, None, None]");
+
+ // disabled here (BEFORE scripts.ilapfuncs is imported, so its from-import
+ // captures the no-op) as a safety net for plugins importing it directly from
+ // lavafuncs; the binding actually used by artifact_processor is
+ // scripts.ilapfuncs.lava_insert_sqlite_data, which is replaced later by
+ // LavaInsertSqliteDataInterceptor
+ disablePythonFunction(jep, "scripts.lavafuncs", "scripts.lavafuncs.lava_insert_sqlite_data");
+
+ // used in ilapfuncs.artifact_processor function (get_data_list_with_media)
+ // https://github.com/abrignoni/ALEAPP/blob/v2026.1.0/scripts/ilapfuncs.py#L402
+ disablePythonFunction(jep, "scripts.lavafuncs", "scripts.lavafuncs.lava_get_full_media_info", "['', '', '', '', '', '', '', '']");
+
+ // used by the original check_in_media/check_in_embedded_media (replaced in
+ // patchMediaFunctions, so these are just a safety net)
+ disablePythonFunction(jep, "scripts.lavafuncs", "scripts.lavafuncs.lava_insert_sqlite_media_item");
+ disablePythonFunction(jep, "scripts.lavafuncs", "scripts.lavafuncs.lava_insert_sqlite_media_references");
+ disablePythonFunction(jep, "scripts.lavafuncs", "scripts.lavafuncs.lava_get_media_references");
+ }
+
+ private void disablePythonFunction(Jep jep, String module, String function, String returnValue) {
+ jep.exec("import " + module);
+ jep.exec(function + " = lambda *args, **kwargs: " + returnValue);
+ }
+
+ private void disablePythonFunction(Jep jep, String module, String function) {
+ disablePythonFunction(jep, module, function, "None");
+ }
+
+ private void disablePythonClass(Jep jep, String module, String clazz) {
+ jep.exec("import " + module);
+ jep.exec("for name in dir(" + clazz + "):\n"
+ + " # We only want to replace public methods, not special ones like __init__\n"
+ + " if not name.startswith('__'):\n"
+ + " attr = getattr(" + clazz + ", name)\n"
+ + " \n"
+ + " # Check if the attribute is a callable method\n"
+ + " if callable(attr):\n"
+ + " # Replace the method with a function that does nothing\n"
+ + " setattr(" + clazz + ", name, lambda *args, **kwargs: None)");
+ }
+}
diff --git a/iped-engine/src/main/java/iped/engine/task/leapp/LeappUtils.java b/iped-engine/src/main/java/iped/engine/task/leapp/LeappUtils.java
new file mode 100644
index 0000000000..ef16fe97d1
--- /dev/null
+++ b/iped-engine/src/main/java/iped/engine/task/leapp/LeappUtils.java
@@ -0,0 +1,258 @@
+package iped.engine.task.leapp;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.regex.Pattern;
+import java.util.stream.Collectors;
+
+import org.apache.commons.lang3.StringUtils;
+
+import iped.data.IItemReader;
+import iped.properties.BasicProps;
+import iped.search.IItemSearcher;
+
+/**
+ * A utility class containing helper methods for ALEAPP.
+ */
+public final class LeappUtils {
+
+ // Cache to avoid recompiling the same glob patterns into Regex repeatedly
+ private static final ConcurrentHashMap PATTERN_CACHE = new ConcurrentHashMap<>();
+
+ private LeappUtils() {
+ }
+
+ /**
+ * Builds the complete Lucene query used by FileSeeker: the glob-derived clauses from
+ * {@link #globToLuceneQuery(String)}, anchored to the evidence root path and excluding carved file fragments.
+ */
+ public static String buildFileSearchQuery(String basePath, String globPattern) {
+ StringBuilder query = new StringBuilder();
+ query.append(BasicProps.PATH).append(":\"").append(basePath).append("\"");
+
+ String globQuery = globToLuceneQuery(globPattern);
+ if (StringUtils.isNotBlank(globQuery)) {
+ query.append(" && (").append(globQuery).append(")");
+ }
+
+ query.append(" && -fileFragment:true");
+
+ return query.toString();
+ }
+
+ /**
+ * Converts a glob pattern to a Lucene query combining exact path logic and a robust name pre-filter.
+ *
+ * IMPORTANT ARCHITECTURAL NOTE: This method acts as a high-recall "pre-filter". Because Lucene tokenizes text and this
+ * method strips certain wildcards/punctuation to maximize performance, the generated Lucene query WILL often match MORE
+ * files than the original glob.
+ *
+ * To guarantee 100% precision without missing any results, the hits returned by this Lucene query MUST be checked
+ * again in memory using a strict Java PathMatcher or Regex (e.g., via AleappUtils.matchesGlob, as FileSeeker.search
+ * does).
+ *
+ * @param globPattern The glob pattern to convert.
+ * @return A Lucene query string.
+ */
+ public static String globToLuceneQuery(String globPattern) {
+ if (StringUtils.isBlank(globPattern)) {
+ return "";
+ }
+
+ // Normalize separators and isolate path from filename
+ String normalizedPath = globPattern.replace("\\", "/");
+ int lastSlash = normalizedPath.lastIndexOf('/');
+
+ String parentPathString = lastSlash >= 0 ? normalizedPath.substring(0, lastSlash) : "";
+ String fileName = lastSlash >= 0 ? normalizedPath.substring(lastSlash + 1) : normalizedPath;
+
+ // 1. Build the exact path query logic provided
+ String pathQuery = "";
+ if (StringUtils.isNotBlank(parentPathString)) {
+ String[] cleanedPaths = StringUtils.split(parentPathString, '*');
+ pathQuery = Arrays
+ .stream(cleanedPaths)
+ .map(term -> StringUtils.strip(term, "/"))
+ .filter(StringUtils::isNotBlank)
+ .map(term -> String.format("%s:\"%s\"", BasicProps.PATH, term))
+ .collect(Collectors.joining(" && "));
+ }
+
+ // 2. Handle pure catch-all wildcards
+ if (StringUtils.equalsAny(fileName, "*", "*.*", "**", "")) {
+ return pathQuery;
+ }
+
+ // 3. Build the Name Query
+ String nameQuery = "";
+
+ // If there are absolutely no wildcards in the filename, we can safely quote it for an exact phrase match
+ if (!fileName.contains("*") && !fileName.contains("?")) {
+ nameQuery = String.format("%s:\"%s\"", BasicProps.NAME, fileName);
+ } else {
+ // Contains wildcards: Split by ALL punctuation (including hyphens) to ensure robust boolean queries.
+ // Hyphens unquoted in Lucene act as the NOT operator (e.g. test-profile -> test AND NOT profile),
+ // so we must split them into separate required terms to maximize recall.
+ String normalizedName = fileName.replaceAll("[^a-zA-Z0-9*?]+", " ");
+ String[] tokens = normalizedName.trim().split("\\s+");
+ List queryParts = new ArrayList<>();
+
+ for (String token : tokens) {
+ if (StringUtils.isBlank(token)) {
+ continue;
+ }
+
+ // Compress duplicate wildcards
+ String term = token.replaceAll("\\*+", "*");
+ term = term.replaceAll("\\?+", "?");
+
+ // Skip standalone wildcards to avoid unoptimized match-all name clauses (e.g., from splitting "*.")
+ if (term.equals("*") || term.equals("?")) {
+ continue;
+ }
+
+ queryParts.add(BasicProps.NAME + ":" + term);
+ }
+ nameQuery = String.join(" && ", queryParts);
+ }
+
+ // 4. Combine pathQuery and nameQuery
+ if (StringUtils.isNoneBlank(pathQuery, nameQuery)) {
+ return String.format("%s && %s", pathQuery, nameQuery);
+ } else if (StringUtils.isNotBlank(nameQuery)) {
+ return nameQuery;
+ } else {
+ return pathQuery;
+ }
+ }
+
+ /**
+ * Replicates the exact behavior of Python's fnmatch logic. Evaluates a file path against a glob pattern to guarantee
+ * 100% precision. Used as the strict in-memory re-check for hits returned by the high-recall Lucene pre-filter built
+ * by {@link #globToLuceneQuery(String)}.
+ *
+ * See: https://github.com/abrignoni/ALEAPP/blob/v2026.1.0/scripts/search_files.py#L111
+ */
+ public static boolean matchesGlob(IItemReader item, String globPattern) {
+ if (globPattern == null || item == null) {
+ return false;
+ }
+
+ // 1. Fetch or compile the fnmatch-equivalent regex pattern
+ Pattern pat = PATTERN_CACHE.computeIfAbsent(globPattern, LeappUtils::compileFnmatchPattern);
+
+ // 2. Normalize the path to use forward slashes (cross-platform safety)
+ String pathToCheck = item.getPath().replace('\\', '/');
+
+ // 3. Match against the full string exactly like Python
+ return pat.matcher(pathToCheck).matches();
+ }
+
+ /**
+ * Maps a shell-style glob pattern to a Java Regex exactly like Python's fnmatch. Unlike Java's PathMatcher, this treats
+ * '*' as matching across directory boundaries.
+ */
+ private static Pattern compileFnmatchPattern(String glob) {
+ StringBuilder sb = new StringBuilder("(?i)^"); // (?i) for case-insensitive, ^ for start
+ for (int i = 0; i < glob.length(); i++) {
+ char c = glob.charAt(i);
+ switch (c) {
+ case '*':
+ sb.append(".*"); // fnmatch treats * as .* (crosses slashes)
+ break;
+ case '?':
+ sb.append("."); // fnmatch treats ? as .
+ break;
+ // Escape all standard regex metacharacters
+ case '.':
+ case '+':
+ case '^':
+ case '$':
+ case '\\':
+ case '|':
+ case '{':
+ case '}':
+ case '(':
+ case ')':
+ case '[':
+ case ']':
+ sb.append('\\').append(c);
+ break;
+ default:
+ sb.append(c);
+ break;
+ }
+ }
+ sb.append("$"); // $ for end of string
+ return Pattern.compile(sb.toString());
+ }
+
+ // Android exposes the same storage under several symbolic links / bind mounts, so the file path an app
+ // records often differs from where the file physically lives in the extraction tree. Each entry maps an
+ // app-visible prefix (key) to the underlying extraction location (value). More specific keys come first.
+ private static final Map SYMLINK_PREFIXES = new LinkedHashMap<>();
+ static {
+ SYMLINK_PREFIXES.put("/storage/emulated", "/data/media");
+ SYMLINK_PREFIXES.put("/storage/self/primary", "/data/media/0");
+ SYMLINK_PREFIXES.put("/mnt/sdcard", "/data/media/0");
+ SYMLINK_PREFIXES.put("/mnt/user/0/primary", "/data/media/0");
+ SYMLINK_PREFIXES.put("/sdcard", "/data/media/0");
+ SYMLINK_PREFIXES.put("/data/user/0", "/data/data");
+ }
+
+ /**
+ * Locates the case item that corresponds to a device file path stored in plugin data (e.g. the "File Path" column of
+ * chromeOfflinePages). The item path is expected to be {@code pathRoot + pathValue}; since Android exposes the same
+ * storage under several symbolic links, the known {@link #SYMLINK_PREFIXES} variants of the value are tried too. The
+ * item whose in-case path exactly equals one of the candidate paths is returned, or {@code null} when none matches.
+ *
+ * @param searcher the case searcher
+ * @param pathRoot the evidence root path (the in-case prefix of every item under this extraction)
+ * @param pathValue the device file path stored in the plugin data cell
+ * @return the corresponding case item, or {@code null} when nothing matches
+ */
+ public static IItemReader findItemByPath(IItemSearcher searcher, String pathRoot, String pathValue) {
+ if (searcher == null || StringUtils.isBlank(pathValue)) {
+ return null;
+ }
+
+ String path = pathValue.trim();
+
+ // first try the path exactly as stored by the app
+ IItemReader item = searchItemByPath(searcher, pathRoot + path);
+ if (item != null) {
+ return item;
+ }
+
+ // then try the known Android storage symlink resolutions, one at a time
+ for (Map.Entry symlink : SYMLINK_PREFIXES.entrySet()) {
+ String prefix = symlink.getKey();
+ if (path.equals(prefix) || path.startsWith(prefix + "/")) {
+ String resolved = symlink.getValue() + path.substring(prefix.length());
+ item = searchItemByPath(searcher, pathRoot + resolved);
+ if (item != null) {
+ return item;
+ }
+ }
+ }
+
+ return null;
+ }
+
+ /** Returns the case item whose path exactly equals {@code itemPath}, or {@code null} when there is none. */
+ private static IItemReader searchItemByPath(IItemSearcher searcher, String itemPath) {
+ // the path query matches whole terms in any position, so it can hit the whole subtree of itemPath:
+ // iterating lazily loads only the items up to the exact one, instead of all the hits at once
+ for (IItemReader item : searcher.searchIterable(BasicProps.PATH + ":\"" + itemPath + "\"")) {
+ if (itemPath.equals(item.getPath())) {
+ return item;
+ }
+ }
+ return null;
+ }
+
+}
diff --git a/iped-engine/src/main/java/iped/engine/task/leapp/PluginSpec.java b/iped-engine/src/main/java/iped/engine/task/leapp/PluginSpec.java
new file mode 100644
index 0000000000..52be8920cc
--- /dev/null
+++ b/iped-engine/src/main/java/iped/engine/task/leapp/PluginSpec.java
@@ -0,0 +1,76 @@
+package iped.engine.task.leapp;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+
+import jep.python.PyCallable;
+import jep.python.PyObject;
+
+/**
+ * A Java representation of a Python PluginSpec object that retrieves attributes on-demand (lazily) when getter methods
+ * are called.
+ */
+public class PluginSpec {
+
+ private final PyObject pluginObject;
+
+ private List searchGlobs;
+
+ public PluginSpec(PyObject pluginObject) {
+ this.pluginObject = pluginObject;
+ }
+
+ // --- Getters that call getAttr() on-demand ---
+
+ public String getName() {
+ return this.pluginObject.getAttr("name", String.class);
+ }
+
+ public String getModuleName() {
+ return this.pluginObject.getAttr("module_name", String.class);
+ }
+
+ public String getCategory() {
+ return this.pluginObject.getAttr("category", String.class);
+ }
+
+ /**
+ * Returns the plugin's file search patterns. Despite ALEAPP calling them "regexes" in places, these are shell-style
+ * glob patterns (fnmatch semantics).
+ *
+ * mimics https://github.com/abrignoni/ALEAPP/blob/v2026.1.0/aleapp.py#L386
+ */
+ @SuppressWarnings("unchecked")
+ public List getSearchGlobs() {
+
+ if (searchGlobs == null) {
+ List list = new ArrayList<>();
+ Object search = this.pluginObject.getAttr("search");
+ if (search instanceof String) {
+ list.add((String) search);
+ } else if (search instanceof Collection) {
+ list.addAll((Collection) search);
+ } else if (search != null) {
+ throw new IllegalArgumentException("Invalid plugin search: " + search.getClass() + " > " + search);
+ }
+ searchGlobs = list;
+ }
+ return searchGlobs;
+ }
+
+ public PyCallable getMethod() {
+ return this.pluginObject.getAttr("method", PyCallable.class);
+ }
+
+ @SuppressWarnings("unchecked")
+ public Map getArtifactInfo() {
+ return this.pluginObject.getAttr("artifact_info", Map.class);
+ }
+
+ @Override
+ public String toString() {
+ return pluginObject.toString();
+ }
+}
\ No newline at end of file
diff --git a/iped-engine/src/main/java/iped/engine/task/leapp/conversation/Conversation.java b/iped-engine/src/main/java/iped/engine/task/leapp/conversation/Conversation.java
new file mode 100644
index 0000000000..fd93fbde68
--- /dev/null
+++ b/iped-engine/src/main/java/iped/engine/task/leapp/conversation/Conversation.java
@@ -0,0 +1,72 @@
+package iped.engine.task.leapp.conversation;
+
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Set;
+
+import org.apache.commons.lang3.StringUtils;
+
+/**
+ * A conversation assembled from the data_list rows sharing the same discriminator column value.
+ */
+public class Conversation {
+
+ /** Value of the conversationDiscriminatorColumn (e.g. a Discord channel id). */
+ private final String id;
+
+ /** Value of the conversationLabelColumn, when declared: a human-friendly conversation name. */
+ private final String label;
+
+ /** Artifact (display) name of the plugin that produced the rows. */
+ private final String artifactName;
+
+ private final List messages = new ArrayList<>();
+
+ public Conversation(String id, String label, String artifactName) {
+ this.id = id;
+ this.label = label;
+ this.artifactName = artifactName;
+ }
+
+ public String getId() {
+ return id;
+ }
+
+ public String getLabel() {
+ return label;
+ }
+
+ public String getArtifactName() {
+ return artifactName;
+ }
+
+ public String getTitle() {
+ return StringUtils.firstNonBlank(label, id, "?");
+ }
+
+ public List getMessages() {
+ return messages;
+ }
+
+ /** Distinct message senders, in first-seen order: the best participant information LEAPP rows can provide. */
+ public Set getParticipants() {
+ Set participants = new LinkedHashSet<>();
+ for (ConversationMessage message : messages) {
+ if (StringUtils.isNotBlank(message.getSender())) {
+ participants.add(message.getSender());
+ }
+ }
+ return participants;
+ }
+
+ /**
+ * Sorts messages chronologically (stable: rows without a parseable timestamp keep their original relative order,
+ * before dated ones, like a null date sorts first elsewhere in IPED).
+ */
+ public void sortMessages() {
+ messages.sort(Comparator.comparing(ConversationMessage::getTimestamp,
+ Comparator.nullsFirst(Comparator.naturalOrder())));
+ }
+}
diff --git a/iped-engine/src/main/java/iped/engine/task/leapp/conversation/ConversationCreator.java b/iped-engine/src/main/java/iped/engine/task/leapp/conversation/ConversationCreator.java
new file mode 100644
index 0000000000..f09f6fc74c
--- /dev/null
+++ b/iped-engine/src/main/java/iped/engine/task/leapp/conversation/ConversationCreator.java
@@ -0,0 +1,211 @@
+package iped.engine.task.leapp.conversation;
+
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import org.apache.commons.lang3.StringUtils;
+import org.apache.tika.metadata.Metadata;
+import org.apache.tika.mime.MediaType;
+
+import iped.data.IItem;
+import iped.engine.core.Worker.ProcessTime;
+import iped.engine.data.Item;
+import iped.engine.task.ExportFileTask;
+import iped.engine.task.leapp.AleappTask;
+import iped.engine.task.leapp.LeappContext;
+import iped.parsers.util.ConversationConstants;
+import iped.properties.ExtraProperties;
+import iped.utils.DateUtil;
+
+/**
+ * Turns the {@link Conversation}s of one plugin run into case items: for each conversation (or part of it, when the
+ * HTML is split by size) one chat-preview item is created as child of the plugin evidence, its HTML rendered by
+ * {@link ConversationHtmlReportGenerator} and stored via {@link ExportFileTask#insertIntoStorage}; the row subitems of
+ * the messages are then created as children of the part item they were rendered into, mirroring the UFED chat
+ * structure (chat preview → message subitems).
+ */
+public class ConversationCreator {
+
+ public static final MediaType ALEAPP_CONVERSATION_MEDIATYPE = MediaType
+ .application(AleappTask.ALEAPP_APPLICATION_PREFIX + "chat-preview");
+
+ /**
+ * App-specific chat-preview media types, mirroring UfedChatParser: they carry the source app in the mime so
+ * IconManager and CategoriesConfig can give conversations the proper app icon/category. All of them MUST be
+ * registered in CustomSignatures.xml as sub-class-of x-aleapp-chat-preview, whose parent x-preview-with-links
+ * routes them to HtmlLinkViewer (enabling the app.open/app.check javascript bridge).
+ */
+ private static final Map APP_PREVIEW_MEDIATYPES = Map.ofEntries( //
+ Map.entry("whatsapp", previewType("whatsapp")), //
+ Map.entry("telegram", previewType("telegram")), //
+ Map.entry("skype", previewType("skype")), //
+ Map.entry("facebook", previewType("facebook")), //
+ Map.entry("instagram", previewType("instagram")), //
+ Map.entry("signal", previewType("signal")), //
+ Map.entry("snapchat", previewType("snapchat")), //
+ Map.entry("threema", previewType("threema")), //
+ Map.entry("tiktok", previewType("tiktok")), //
+ Map.entry("viber", previewType("viber")), //
+ Map.entry("discord", previewType("discord")));
+
+ private static MediaType previewType(String app) {
+ return MediaType.application(AleappTask.ALEAPP_APPLICATION_PREFIX + "chat-preview-" + app);
+ }
+
+ /** Same default used by UfedChatParser/WhatsAppParser: bigger chats are split into multiple HTML parts. */
+ private static final int MIN_CHAT_SPLIT_SIZE = 6000000;
+
+ /**
+ * Creates the subitem of one data_list row. Implemented by LavaInsertSqliteDataInterceptor, which owns the
+ * row-to-metadata mapping: this class only decides WHERE in the item tree the subitem goes.
+ */
+ public interface MessageItemFactory {
+ Item create(IItem parent, int rowIndex, int subitemId);
+ }
+
+ private final LeappContext context;
+ private final ConversationViewSpec view;
+ private final MessageItemFactory messageItemFactory;
+
+ public ConversationCreator(LeappContext context, ConversationViewSpec view, MessageItemFactory messageItemFactory) {
+ this.context = context;
+ this.view = view;
+ this.messageItemFactory = messageItemFactory;
+ }
+
+ public void createConversations(List conversations, AtomicInteger subitemIdSeq) throws Exception {
+ for (Conversation conversation : conversations) {
+ createConversation(conversation, subitemIdSeq);
+ }
+ }
+
+ private void createConversation(Conversation conversation, AtomicInteger subitemIdSeq) throws Exception {
+
+ conversation.sortMessages();
+
+ ConversationHtmlReportGenerator generator = new ConversationHtmlReportGenerator(conversation, MIN_CHAT_SPLIT_SIZE);
+
+ byte[] bytes = generator.generateNextChatHtml();
+ int frag = 0;
+ int firstMsg = 0;
+
+ while (bytes != null) {
+ int nextMsg = generator.getNextMsgNum();
+ byte[] nextBytes = generator.generateNextChatHtml();
+
+ String name = conversation.getArtifactName() + " - " + conversation.getTitle();
+ if (frag > 0 || nextBytes != null) {
+ // fragment naming mirroring UfedChatParser/WhatsAppParser
+ name += "_" + (++frag);
+ }
+
+ List partMessages = conversation.getMessages().subList(firstMsg, nextMsg);
+
+ Item convItem = (Item) context.getPluginItem().createChildItem();
+ convItem.setMediaType(resolvePreviewMediaType());
+ convItem.setName(name);
+ convItem.setExtension("");
+ convItem.setPath(context.getPluginItem().getPath() + "/" + name);
+ convItem.setIdInDataSource("");
+ convItem.setSubItem(true);
+ convItem.setSubitemId(subitemIdSeq.getAndIncrement());
+ convItem.setExtraAttribute(ExtraProperties.DECODED_DATA, true);
+ convItem.setHasChildren(!partMessages.isEmpty());
+
+ // standard cross-parser "Conversation:" metadata, as set by other chat
+ // parsers (e.g. iped.parsers.ufed.handler.ChatHandler)
+ convItem.getMetadata().set(ExtraProperties.CONVERSATION_ID, conversation.getId());
+ convItem.getMetadata().set(ExtraProperties.CONVERSATION_NAME, conversation.getTitle());
+ convItem.getMetadata().set(ExtraProperties.CONVERSATION_MESSAGES_COUNT, partMessages.size());
+ Set participants = conversation.getParticipants();
+ for (String participant : participants) {
+ convItem.getMetadata().add(ExtraProperties.CONVERSATION_PARTICIPANTS, participant);
+ }
+ if (!participants.isEmpty()) {
+ convItem.getMetadata().add(ExtraProperties.CONVERSATION_PARTICIPANTS + ":count",
+ Integer.toString(participants.size()));
+ }
+
+ ExportFileTask.getLastInstance().insertIntoStorage(convItem, bytes, bytes.length);
+ context.getWorker().processNewItem(convItem, ProcessTime.LATER);
+
+ for (ConversationMessage message : partMessages) {
+ Item msgItem = messageItemFactory.create(convItem, message.getRowIndex(), subitemIdSeq.getAndIncrement());
+ setCommunicationMetadata(msgItem, message, conversation);
+ context.getWorker().processNewItem(msgItem, ProcessTime.LATER);
+ }
+
+ firstMsg = nextMsg;
+ bytes = nextBytes;
+ }
+ }
+
+ /**
+ * Standard cross-parser "Communication:" metadata on a message subitem, as set by other chat parsers (e.g.
+ * iped.parsers.ufed.handler.InstantMessageHandler). The row cells mapped by column classification
+ * (Communication:From/To/Date) may already be present: the data view mapping only fills the gaps, so nothing is
+ * overwritten.
+ */
+ private void setCommunicationMetadata(Item msgItem, ConversationMessage message, Conversation conversation) {
+
+ Metadata metadata = msgItem.getMetadata();
+
+ if (message.getOutgoing() != null) {
+ metadata.set(ExtraProperties.COMMUNICATION_DIRECTION, message.isOutgoing()
+ ? ConversationConstants.DIRECTION_OUTGOING
+ : ConversationConstants.DIRECTION_INCOMING);
+ removeAleappMetadata(metadata, view.getDirectionColumn());
+ }
+
+ if (metadata.get(ExtraProperties.COMMUNICATION_FROM) == null && StringUtils.isNotBlank(message.getSender())) {
+ metadata.set(ExtraProperties.COMMUNICATION_FROM, message.getSender());
+ removeAleappMetadata(metadata, view.getSenderColumn());
+ }
+
+ // no per-row recipient in LEAPP data: the conversation itself is the
+ // destination, mirroring what InstantMessageHandler does for group chats
+ if (metadata.get(ExtraProperties.COMMUNICATION_TO) == null) {
+ metadata.set(ExtraProperties.COMMUNICATION_TO, conversation.getTitle());
+ metadata.add(ExtraProperties.COMMUNICATION_TO + ExtraProperties.CONVERSATION_SUFFIX_ID, conversation.getId());
+ // the discriminator/label cells became the chat's Conversation:id/Name
+ // and this message's Communication:To
+ removeAleappMetadata(metadata, view.getDiscriminatorColumn());
+ removeAleappMetadata(metadata, view.getLabelColumn());
+ }
+
+ if (metadata.get(ExtraProperties.MESSAGE_DATE) == null && message.getTimestamp() != null) {
+ metadata.set(ExtraProperties.MESSAGE_DATE, DateUtil.dateToString(message.getTimestamp()));
+ removeAleappMetadata(metadata, view.getTimeColumn());
+ }
+
+ if (metadata.get(ExtraProperties.MESSAGE_BODY) == null && StringUtils.isNotBlank(message.getBody())) {
+ metadata.set(ExtraProperties.MESSAGE_BODY, message.getBody());
+ removeAleappMetadata(metadata, view.getTextColumn());
+ }
+ }
+
+ /** Values promoted to a standard property are not kept duplicated under the "aleapp:" prefix. */
+ private static void removeAleappMetadata(Metadata metadata, String column) {
+ if (column != null) {
+ metadata.remove(AleappTask.ALEAPP_METADATA_PREFIX + column);
+ }
+ }
+
+ /**
+ * Resolves the app-specific chat-preview media type by looking for a known app keyword in the plugin module name
+ * (e.g. "discordChats") or artifact name (e.g. "Discord Chats"); falls back to the generic type.
+ */
+ private MediaType resolvePreviewMediaType() {
+ String hint = (context.getPlugin().getModuleName() + " " + context.getPlugin().getName())
+ .toLowerCase(Locale.ROOT);
+ for (Map.Entry entry : APP_PREVIEW_MEDIATYPES.entrySet()) {
+ if (hint.contains(entry.getKey())) {
+ return entry.getValue();
+ }
+ }
+ return ALEAPP_CONVERSATION_MEDIATYPE;
+ }
+}
diff --git a/iped-engine/src/main/java/iped/engine/task/leapp/conversation/ConversationHtmlReportGenerator.java b/iped-engine/src/main/java/iped/engine/task/leapp/conversation/ConversationHtmlReportGenerator.java
new file mode 100644
index 0000000000..119e1e4045
--- /dev/null
+++ b/iped-engine/src/main/java/iped/engine/task/leapp/conversation/ConversationHtmlReportGenerator.java
@@ -0,0 +1,348 @@
+package iped.engine.task.leapp.conversation;
+
+import static j2html.TagCreator.attrs;
+import static j2html.TagCreator.b;
+import static j2html.TagCreator.br;
+import static j2html.TagCreator.div;
+import static j2html.TagCreator.img;
+import static j2html.TagCreator.table;
+import static j2html.TagCreator.td;
+import static j2html.TagCreator.tr;
+import static org.apache.commons.lang3.StringUtils.isNotBlank;
+
+import java.io.ByteArrayOutputStream;
+import java.io.OutputStreamWriter;
+import java.io.PrintWriter;
+import java.io.UnsupportedEncodingException;
+import java.nio.charset.StandardCharsets;
+import java.text.SimpleDateFormat;
+import java.util.List;
+
+import org.apache.commons.codec.binary.Base64;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.tika.metadata.XMPDM;
+
+import iped.data.IItemReader;
+import iped.parsers.util.ChildPornHashLookup;
+import iped.parsers.util.Messages;
+import iped.parsers.whatsapp.Util;
+import iped.properties.BasicProps;
+import iped.properties.ExtraProperties;
+import iped.utils.EmojiUtil;
+import iped.utils.SimpleHTMLEncoder;
+import j2html.tags.specialized.DivTag;
+
+/**
+ * Renders a LEAPP {@link Conversation} as a chat-style HTML, replicating the features of
+ * iped.parsers.ufed.ReportGenerator: WhatsApp-like css/js, topbar with the conversation title, date separator lines,
+ * incoming/outgoing bubbles with sender and time, media attachments with embedded base64 thumbs, audio/video player
+ * hooks (iped-audio/iped-video + data-src), links/checkboxes to the original case items, audio transcriptions, child
+ * porn hash set hits, location blocks and splitting of big chats into multiple HTML parts (with continuation markers
+ * and a loading modal for very long parts).
+ *
+ * Features of the UFED report that have no data source in LEAPP rows (reply/quote references, forwarded flags,
+ * message status ticks, edited/deleted states, shared contacts and contact photos) are intentionally absent.
+ */
+public class ConversationHtmlReportGenerator {
+
+ private static final int MIN_MESSAGES_TO_SHOW_MODAL = 500;
+
+ private final SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd");
+ private final SimpleDateFormat timeFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss Z");
+
+ private final Conversation conversation;
+ private final int minChatSplitSize;
+
+ private boolean firstHtml = true;
+ private int currentMsg = 0;
+
+ public ConversationHtmlReportGenerator(Conversation conversation, int minChatSplitSize) {
+ this.conversation = conversation;
+ this.minChatSplitSize = minChatSplitSize;
+ }
+
+ /**
+ * Index (into the conversation messages list) of the first message NOT yet rendered: after each
+ * {@link #generateNextChatHtml()} call, messages [previous, getNextMsgNum()) were written to that part.
+ */
+ public int getNextMsgNum() {
+ return currentMsg;
+ }
+
+ /**
+ * Generates the next HTML part, or null when all messages were already rendered. Mirrors the iteration contract of
+ * iped.parsers.ufed.ReportGenerator#generateNextChatHtml().
+ */
+ public byte[] generateNextChatHtml() throws UnsupportedEncodingException {
+
+ List messages = conversation.getMessages();
+
+ if ((!firstHtml && currentMsg == 0) || (currentMsg > 0 && currentMsg == messages.size())) {
+ return null;
+ }
+
+ ByteArrayOutputStream bout = new ByteArrayOutputStream();
+ PrintWriter out = new PrintWriter(new OutputStreamWriter(bout, StandardCharsets.UTF_8));
+
+ String title = conversation.getTitle();
+ boolean printModal = (messages.size() - currentMsg) > MIN_MESSAGES_TO_SHOW_MODAL;
+ printMessageFileHeader(out, title, printModal);
+
+ if (currentMsg > 0) {
+ printDateSeparator(out, Messages.getString("WhatsAppReport.ChatContinuation"));
+ }
+
+ String lastDate = null;
+ while (currentMsg < messages.size()) {
+ ConversationMessage m = messages.get(currentMsg);
+ String thisDate = m.getTimestamp() != null ? dateFormat.format(m.getTimestamp())
+ : Messages.getString("ReportGenerator.UnknownDate");
+ if (!thisDate.equals(lastDate)) {
+ printDateSeparator(out, thisDate);
+ lastDate = thisDate;
+ }
+ printMessage(out, m);
+
+ if (currentMsg++ != messages.size() - 1 && bout.size() >= minChatSplitSize) {
+ printDateSeparator(out, Messages.getString("WhatsAppReport.ChatContinues"));
+ break;
+ }
+ }
+
+ printMessageFileFooter(out);
+ out.flush();
+
+ firstHtml = false;
+
+ return EmojiUtil.replaceByImages(bout.toByteArray());
+ }
+
+ private static void printDateSeparator(PrintWriter out, String text) {
+ out.println("
+ *
+ * In ALEAPP this declaration is only copied into the LAVA JSON by lava_process_artifact (the rendering happens in the
+ * LAVA viewer app), so there is no need to enable that function: the raw dict is read here straight from the plugin's
+ * artifact_info, and the column names match the raw data_headers names received by LavaInsertSqliteDataInterceptor.
+ *
+ * The backward compatibility rules of lava_process_artifact are mirrored: a "chat" view is upgraded to "conversation"
+ * and thread*Column keys are remapped to conversation*Column.
+ *
+ * https://github.com/abrignoni/ALEAPP/blob/v2026.1.0/scripts/lavafuncs.py#L253
+ */
+public class ConversationViewSpec {
+
+ public static final String DATA_VIEWS_KEY = "data_views";
+
+ private final String discriminatorColumn;
+ private final String labelColumn;
+ private final String textColumn;
+ private final String directionColumn;
+ private final String directionSentValue;
+ private final String timeColumn;
+ private final String senderColumn;
+
+ private ConversationViewSpec(Map, ?> params) {
+ this.discriminatorColumn = str(params, "conversationDiscriminatorColumn", "threadDiscriminatorColumn");
+ this.labelColumn = str(params, "conversationLabelColumn", "threadLabelColumn");
+ this.textColumn = str(params, "textColumn");
+ this.directionColumn = str(params, "directionColumn");
+ this.directionSentValue = str(params, "directionSentValue");
+ this.timeColumn = str(params, "timeColumn");
+ this.senderColumn = str(params, "senderColumn");
+ }
+
+ /**
+ * Returns the conversation view declared in the given artifact_info, or null if there is none usable (a view
+ * without a discriminator column cannot group rows into conversations).
+ */
+ public static ConversationViewSpec from(Map artifactInfo) {
+
+ Object dataViews = artifactInfo.get(DATA_VIEWS_KEY);
+ if (!(dataViews instanceof Map)) {
+ return null;
+ }
+
+ Object view = ((Map, ?>) dataViews).get("conversation");
+ if (!(view instanceof Map)) {
+ // backward compatibility, mirroring lava_process_artifact
+ view = ((Map, ?>) dataViews).get("chat");
+ }
+ if (!(view instanceof Map)) {
+ return null;
+ }
+
+ ConversationViewSpec spec = new ConversationViewSpec((Map, ?>) view);
+ return spec.discriminatorColumn != null ? spec : null;
+ }
+
+ private static String str(Map, ?> params, String... keys) {
+ for (String key : keys) {
+ Object value = params.get(key);
+ if (value != null && StringUtils.isNotBlank(value.toString())) {
+ return value.toString();
+ }
+ }
+ return null;
+ }
+
+ public String getDiscriminatorColumn() {
+ return discriminatorColumn;
+ }
+
+ public String getLabelColumn() {
+ return labelColumn;
+ }
+
+ public String getTextColumn() {
+ return textColumn;
+ }
+
+ public String getDirectionColumn() {
+ return directionColumn;
+ }
+
+ public String getDirectionSentValue() {
+ return directionSentValue;
+ }
+
+ public String getTimeColumn() {
+ return timeColumn;
+ }
+
+ public String getSenderColumn() {
+ return senderColumn;
+ }
+}
diff --git a/iped-engine/src/main/java/iped/engine/task/leapp/interceptors/IlapfuncsDeviceInfoInterceptor.java b/iped-engine/src/main/java/iped/engine/task/leapp/interceptors/IlapfuncsDeviceInfoInterceptor.java
new file mode 100644
index 0000000000..94b0dd9f3c
--- /dev/null
+++ b/iped-engine/src/main/java/iped/engine/task/leapp/interceptors/IlapfuncsDeviceInfoInterceptor.java
@@ -0,0 +1,35 @@
+package iped.engine.task.leapp.interceptors;
+
+import java.util.Map;
+
+import iped.engine.task.leapp.AleappTask;
+import iped.engine.task.leapp.CallInterceptor;
+import jep.PyMethod;
+
+/**
+ * Intercepts ilapfuncs.device_info only to serialize it: ilapfuncs.identifiers is a shared module-global dict,
+ * populated by device_info with a read-modify-write sequence, and IPED workers run plugins concurrently, so
+ * unsynchronized calls could lose updates.
+ *
+ * The original Python function is called unchanged, under {@link AleappTask#DEVICE_INFO_LOCK} — the same monitor
+ * guarding write_device_info() in AleappTask.processDeviceInfoEvidence(), so the dict cannot be mutated while it is
+ * being iterated for the report.
+ *
+ * NOTE: intercepting in Java (instead of wrapping in Python) preserves device_info's caller attribution: it uses
+ * inspect.stack()[1] to record which plugin function reported the value, and the plugin -> Java -> original call
+ * round-trip pushes no intermediate Python frame.
+ */
+public class IlapfuncsDeviceInfoInterceptor extends CallInterceptor {
+
+ public IlapfuncsDeviceInfoInterceptor() {
+ super("scripts.ilapfuncs", "scripts.ilapfuncs.device_info");
+ }
+
+ @Override
+ @PyMethod(varargs = true, kwargs = true)
+ public Object call(Object[] args, Map kwargs) throws Exception {
+ synchronized (AleappTask.DEVICE_INFO_LOCK) {
+ return super.call(args, kwargs);
+ }
+ }
+}
diff --git a/iped-engine/src/main/java/iped/engine/task/leapp/interceptors/IlapfuncsLogfuncInterceptor.java b/iped-engine/src/main/java/iped/engine/task/leapp/interceptors/IlapfuncsLogfuncInterceptor.java
new file mode 100644
index 0000000000..b0ede5cbb2
--- /dev/null
+++ b/iped-engine/src/main/java/iped/engine/task/leapp/interceptors/IlapfuncsLogfuncInterceptor.java
@@ -0,0 +1,31 @@
+package iped.engine.task.leapp.interceptors;
+
+import java.util.Map;
+
+import iped.engine.task.leapp.CallInterceptor;
+import iped.engine.task.leapp.LeappContext;
+import jep.PyMethod;
+
+/**
+ * Replaces the ilapfuncs.logfunc function so ALEAPP plugin log messages are redirected to the IPED log instead of
+ * ALEAPP's own report log file.
+ */
+public class IlapfuncsLogfuncInterceptor extends CallInterceptor {
+
+ public IlapfuncsLogfuncInterceptor() {
+ super("scripts.ilapfuncs", "scripts.ilapfuncs.logfunc");
+ }
+
+ @Override
+ @PyMethod(varargs = true, kwargs = true)
+ public Object call(Object[] args, Map kwargs) throws Exception {
+
+ String message = (String) getArgumentValue("message", 0, args, kwargs);
+ String moduleName = LeappContext.get().getPlugin().getModuleName();
+ String pluginName = LeappContext.get().getPlugin().getName();
+
+ logger.info("{} [{}]: {}", moduleName, pluginName, message);
+
+ return null;
+ }
+}
diff --git a/iped-engine/src/main/java/iped/engine/task/leapp/interceptors/LavaInsertSqliteDataInterceptor.java b/iped-engine/src/main/java/iped/engine/task/leapp/interceptors/LavaInsertSqliteDataInterceptor.java
new file mode 100644
index 0000000000..9a4f2ac7d6
--- /dev/null
+++ b/iped-engine/src/main/java/iped/engine/task/leapp/interceptors/LavaInsertSqliteDataInterceptor.java
@@ -0,0 +1,532 @@
+package iped.engine.task.leapp.interceptors;
+
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import org.apache.commons.lang3.StringUtils;
+import org.apache.tika.metadata.Property;
+import org.apache.tika.mime.MediaType;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import iped.data.IItem;
+import iped.data.IItemReader;
+import iped.engine.core.Worker.ProcessTime;
+import iped.engine.data.Item;
+import iped.engine.task.leapp.AleappMediaTypeResolver;
+import iped.engine.task.leapp.AleappTask;
+import iped.engine.task.leapp.CallInterceptor;
+import iped.engine.task.leapp.LeappContext;
+import iped.engine.task.leapp.conversation.Conversation;
+import iped.engine.task.leapp.conversation.ConversationCreator;
+import iped.engine.task.leapp.conversation.ConversationMessage;
+import iped.engine.task.leapp.conversation.ConversationViewSpec;
+import iped.properties.ExtraProperties;
+import jep.PyMethod;
+
+/**
+ * Replaces the lava_insert_sqlite_data function. This is the main IPED-LEAPP integration point: instead of letting the
+ * plugin results be written to the LAVA sqlite database, each data row is turned into an IPED subitem of the current
+ * plugin evidence.
+ *
+ * lava_insert_sqlite_data is intercepted (rather than tsv/timeline/html) because it is the only output call that
+ * receives the RAW data_headers and data_list: headers keep their (name, type) tuples and values keep their original
+ * Python types — the other outputs receive stripped headers and stringified values.
+ *
+ * NOTE: the interception target is the binding INSIDE scripts.ilapfuncs ("from scripts.lavafuncs import
+ * lava_insert_sqlite_data" is captured at import time), which is the name artifact_processor actually calls.
+ */
+public class LavaInsertSqliteDataInterceptor extends CallInterceptor {
+
+ protected static final Logger logger = LoggerFactory.getLogger(LavaInsertSqliteDataInterceptor.class);
+
+ // Fallback classification for UNTYPED (plain string) headers, calibrated against
+ // ALEAPP v2026.1.0 sources. Date columns vary a lot across plugins, so dates use
+ // exact names plus a suffix family. Sender, recipient and body use exact matches
+ // only: mislabeling those is forensically costly (e.g. "Account" is usually the
+ // device owner, who is the receiver of incoming records, not the sender).
+ // "Date of Birth"-style personal dates must not become the record's event date,
+ // so there is no "date " prefix rule — "date *" event columns are listed explicitly.
+ private static final Set DATE_HEADERS = Set.of( //
+ "datetime", "date/time", "date", "created", "created at", "updated at", //
+ "time created", "last updated", "last login", "last modified", "last access", "last accessed", //
+ "date added", "date created", "date modified", "date sent", "date taken");
+ private static final Set FROM_HEADERS = Set.of("sender", "from", "author");
+ private static final Set TO_HEADERS = Set.of("recipient", "to", "receiver");
+ private static final Set BODY_HEADERS = Set.of("message", "body", "text", "content");
+
+ // UNTYPED columns whose value is a device file path but which ALEAPP did NOT declare as ('name', 'media').
+ // These cells are not rewritten by the media interception (check_in_media), so their value stays as the raw
+ // device path (e.g. chromeOfflinePages "File Path", WhatsApp "Local Path To Media"). When the seeker did not
+ // export the file, the fallback below still links it to its original case item via IItemSearcher.
+ // The suffix rules in isFilePathHeader cover the many "* Path" columns across plugins (Download Path, Source
+ // File Path, Save Path, Full Path, Original Path, Screenshot Path, Code Path, file_path, *_filepath, ...); this
+ // set only holds the few file-path column names those rules do not catch.
+ private static final Set FILE_PATH_HEADERS = Set.of("local path to media");
+
+ // Per-plugin exceptions: columns whose name matches the file-path rules but do NOT hold a filesystem path in
+ // that specific plugin, so the file lookup must be skipped. Kept plugin-scoped (keyed by the plugin module name)
+ // because the same column name can be a real file path elsewhere: "Path" is a cookie/URL path in the cookie
+ // plugins but a MediaStore file path in emulatedSmeta and a transfer path in Zapya. Module names and column
+ // names are written exactly as declared in the plugins.
+ private static final Map> NON_FILE_PATH_COLUMNS = Map.of( //
+ "chromeCookies", Set.of("Path"), //
+ "firefoxCookies", Set.of("Path"), //
+ "OrnetBrowser", Set.of("Path"), //
+ "FairEmail", Set.of("Return Path"), //
+ "DuckDuckGo", Set.of("Folder Path"), //
+ "libretorrentFR", Set.of("Length - Path"));
+
+ // types used in LEAPP data_headers tuples (see lavafuncs.get_sql_type and
+ // ilapfuncs.get_media_header_info)
+ private static final String TYPE_DATETIME = "datetime";
+ private static final String TYPE_DATE = "date";
+ private static final String TYPE_MEDIA = "media";
+
+ private enum StandardField {
+ DATE, FROM, TO, BODY, LATITUDE, LONGITUDE, MEDIA, FILE_PATH, NONE
+ }
+
+ /** A data_headers entry: plain string or (name, type[, style]) tuple. */
+ private static class Header {
+ final String name;
+ final String type;
+
+ Header(String name, String type) {
+ this.name = name;
+ this.type = type;
+ }
+ }
+
+ public LavaInsertSqliteDataInterceptor() {
+ super("scripts.ilapfuncs", "scripts.ilapfuncs.lava_insert_sqlite_data");
+ }
+
+ @SuppressWarnings("unchecked")
+ @Override
+ @PyMethod(varargs = true, kwargs = true)
+ public Object call(Object[] args, Map kwargs) throws Exception {
+
+ // lava_insert_sqlite_data(table_name, data, object_columns, headers, column_map)
+ // table_name/object_columns/column_map come from lava_process_artifact, which is
+ // disabled and returns None values: only data and headers are used here
+ List> dataList = (List>) getArgumentValue("data", 1, args, kwargs);
+ List