diff --git a/jadx-gui/src/main/java/jadx/gui/ui/FilterableTreeModel.java b/jadx-gui/src/main/java/jadx/gui/ui/FilterableTreeModel.java new file mode 100644 index 00000000000..81f7ed53adb --- /dev/null +++ b/jadx-gui/src/main/java/jadx/gui/ui/FilterableTreeModel.java @@ -0,0 +1,282 @@ +package jadx.gui.ui; + +import java.awt.Rectangle; +import java.util.ArrayList; +import java.util.Enumeration; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.concurrent.Future; + +import javax.swing.JTree; +import javax.swing.SwingUtilities; +import javax.swing.tree.DefaultTreeModel; +import javax.swing.tree.TreeNode; +import javax.swing.tree.TreePath; + +import org.jetbrains.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import jadx.api.ResourceType; +import jadx.gui.jobs.SimpleTask; +import jadx.gui.jobs.TaskStatus; +import jadx.gui.treemodel.JClass; +import jadx.gui.treemodel.JMethod; +import jadx.gui.treemodel.JNode; +import jadx.gui.treemodel.JResource; +import jadx.gui.treemodel.JRoot; +import jadx.gui.treemodel.TextNode; +import jadx.gui.utils.UiUtils; + +/** + * A FilterableTreeModel provides dynamic filtering over the decompilation package tree. + * This filtering is done at the model level, to prevent oddities such as zero-width rows disrupting + * a keyboard user experience or otherwise causing problems. + *

+ * This class is specialized to filter the main UI pane displaying the decompilation tree. If the + * fully qualified path of a tree element is obtainable, filtering will check against that, falling + * back to the standard name if the former is unavailable. + */ +class FilterableTreeModel extends DefaultTreeModel { + private static final Logger LOG = LoggerFactory.getLogger(FilterableTreeModel.class); + + private final MainWindow mainWindow; + + /** + * The filter string + */ + private volatile String filter; + + /** + * All nodes to filtered paths (including middle nodes) + */ + private final Set filteredTreeNodes; + + /** + * Store nodes expanded automatically to prevent re-expand for nodes collapsed by user + */ + private final Set autoExpandedNodes; + + /** + * Very often 'getChild' method called for all children after 'getChildCount' call. + * Save children list for fast child return. + */ + private final CacheNode cacheNode = new CacheNode(); + + public FilterableTreeModel(MainWindow mainWindow, TreeNode root) { + super(root); + this.mainWindow = mainWindow; + this.filter = ""; + this.filteredTreeNodes = new HashSet<>(); + this.autoExpandedNodes = new HashSet<>(); + } + + /** + * Sets the filter of the tree by pre-computing tree paths matching the filter and refreshing the + * tree's node structure. + *

+ * This calls `nodeStructureChanged` on the root of the tree, implying a complete refresh of the + * tree data. Unfortunately the declarative nature of the filtering makes it infeasible to give a + * more specific refresh, which would potentially preserve open more of the current state of the + * tree. + * + * @param newFilter the new filter string, or "" to unset the filter. + */ + public synchronized void setFilter(String newFilter) { + this.filter = newFilter; + this.autoExpandedNodes.clear(); + this.cacheNode.clear(); + LOG.debug("New tree filter '{}'", newFilter); + applyFilterFieldOutline(""); + collectFilteredPaths(); + SwingUtilities.invokeLater(() -> this.nodeStructureChanged((TreeNode) getRoot())); + SwingUtilities.invokeLater(() -> expandVisibleFilteredNodes(mainWindow.getTree())); + } + + /** + * Filter thread safe nodeStructureChanged event. + * This should ensure that all treeListeners get the same filter value per event. + */ + @Override + public void nodeStructureChanged(TreeNode node) { + super.nodeStructureChanged(node); + } + + /** + * If filter enabled, expand all visible nodes. + */ + public void expandVisibleFilteredNodes(JTree tree) { + if (filter.isEmpty()) { + return; + } + Rectangle rect = tree.getVisibleRect(); + int startRow = tree.getClosestRowForLocation(0, rect.y); + int bottom = rect.y + rect.height - 1; + while (true) { + int lastRow = tree.getClosestRowForLocation(0, bottom); + if (lastRow <= startRow) { + break; + } + // limit updates for one iteration + int last = Math.min(startRow + 20, lastRow); + for (int i = startRow; i <= lastRow; i++) { + TreePath path = tree.getPathForRow(i); + JNode node = (JNode) path.getLastPathComponent(); + if (node instanceof JClass) { + // don't auto expand methods + } else { + if (autoExpandedNodes.add(node)) { + tree.expandPath(path); + } + } + } + startRow = last; + } + } + + private void applyFilterFieldOutline(String outlineType) { + UiUtils.uiRun(() -> mainWindow.getTreeFilterField().putClientProperty("JComponent.outline", outlineType)); + } + + private void collectFilteredPaths() { + UiUtils.notUiThreadGuard(); + filteredTreeNodes.clear(); + if (filter.isEmpty()) { + return; + } + + Object rootNode = this.getRoot(); + if (!(rootNode instanceof JRoot)) { + return; // root node is null or of a different type + } + int nodesCount = 0; + int filteredCount = 0; + Enumeration en = ((JRoot) rootNode).depthFirstEnumeration(); + while (en.hasMoreElements()) { + TreeNode node = en.nextElement(); + nodesCount++; + if (matchesFilter(node)) { + addPathNodes(node); + filteredCount++; + } + } + if (LOG.isDebugEnabled()) { + LOG.debug("Total nodes: {}, filtered: {}", nodesCount, filteredCount); + } + if (filteredTreeNodes.isEmpty()) { + applyFilterFieldOutline("error"); + } + } + + private void addPathNodes(TreeNode node) { + if (!filteredTreeNodes.add(node)) { + return; + } + TreeNode parent = node.getParent(); + while (parent != null) { + if (!filteredTreeNodes.add(parent)) { + break; + } + parent = parent.getParent(); + } + } + + /** + * Determines if a given node matches the current filter. + * + * @param node the node in question + * @return true if the filter is considered matched and the node should be displayed in the tree. + */ + private boolean matchesFilter(Object node) { + if (node instanceof TextNode || node instanceof JMethod) { + return false; + } + if (node instanceof JResource) { + JResource res = (JResource) node; + if (res.getType() == JResource.JResType.FILE && res.getResFile().getType() == ResourceType.ARSC) { + loadInnerResources(res); + } + } + if (node instanceof JNode) { + JNode jNode = (JNode) node; + String name = jNode.makeString(); + if (name == null) { + LOG.warn("Node {} has null UI string", node); + return false; + } + return name.toLowerCase().contains(filter.toLowerCase()); + } + return false; + } + + private void loadInnerResources(JResource res) { + // load inner resource of resource.arsc + SimpleTask loadTask = res.getLoadTask(); + if (loadTask != null) { + try { + Future load = mainWindow.getBackgroundExecutor().executeWithFuture(loadTask); + load.get(); // wait for completion + } catch (Exception e) { + LOG.warn("Failed to load resource", e); + } + } + } + + private static final class CacheNode { + private @Nullable TreeNode parent; + private final List children = new ArrayList<>(); + + public void clear() { + parent = null; + children.clear(); + } + } + + @Override + public Object getChild(Object parent, int index) { + if (cacheNode.parent == parent) { + return cacheNode.children.get(index); + } + if (filter.isEmpty() || parent instanceof JClass /* allow to expand and view all methods */) { + return super.getChild(parent, index); + } + int i = 0; + Enumeration en = ((TreeNode) parent).children(); + while (en.hasMoreElements()) { + TreeNode child = en.nextElement(); + if (filteredTreeNodes.contains(child)) { + if (i == index) { + return child; + } + i++; + } + } + throw new IllegalArgumentException("No child at index " + index); + } + + @Override + public int getChildCount(Object parent) { + if (filter.isEmpty()) { + return super.getChildCount(parent); + } + TreeNode parentNode = (TreeNode) parent; + if (!filteredTreeNodes.contains(parentNode)) { + return 0; + } + cacheNode.parent = parentNode; + List children = cacheNode.children; + children.clear(); + + int count = 0; + Enumeration en = parentNode.children(); + while (en.hasMoreElements()) { + TreeNode child = en.nextElement(); + if (filteredTreeNodes.contains(child)) { + children.add(child); + count++; + } + } + return count; + } +} diff --git a/jadx-gui/src/main/java/jadx/gui/ui/MainWindow.java b/jadx-gui/src/main/java/jadx/gui/ui/MainWindow.java index e609d4089ad..de69f94a8a1 100644 --- a/jadx-gui/src/main/java/jadx/gui/ui/MainWindow.java +++ b/jadx-gui/src/main/java/jadx/gui/ui/MainWindow.java @@ -32,13 +32,16 @@ import java.util.Locale; import java.util.Timer; import java.util.TimerTask; +import java.util.concurrent.TimeUnit; import java.util.function.Consumer; import javax.swing.AbstractAction; import javax.swing.Action; +import javax.swing.BorderFactory; import javax.swing.Box; import javax.swing.JCheckBox; import javax.swing.JCheckBoxMenuItem; +import javax.swing.JComponent; import javax.swing.JFrame; import javax.swing.JLabel; import javax.swing.JMenu; @@ -48,9 +51,11 @@ import javax.swing.JPopupMenu; import javax.swing.JScrollPane; import javax.swing.JSplitPane; +import javax.swing.JTextField; import javax.swing.JToggleButton; import javax.swing.JToolBar; import javax.swing.JTree; +import javax.swing.KeyStroke; import javax.swing.SwingUtilities; import javax.swing.ToolTipManager; import javax.swing.UIManager; @@ -59,7 +64,6 @@ import javax.swing.event.TreeWillExpandListener; import javax.swing.tree.DefaultMutableTreeNode; import javax.swing.tree.DefaultTreeCellRenderer; -import javax.swing.tree.DefaultTreeModel; import javax.swing.tree.TreeNode; import javax.swing.tree.TreePath; import javax.swing.tree.TreeSelectionModel; @@ -70,12 +74,16 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import com.formdev.flatlaf.FlatClientProperties; import com.formdev.flatlaf.FlatLaf; import com.formdev.flatlaf.extras.FlatInspector; import com.formdev.flatlaf.extras.FlatUIDefaultsInspector; import com.formdev.flatlaf.util.UIScale; import ch.qos.logback.classic.Level; +import hu.akarnokd.rxjava3.swing.SwingSchedulers; +import io.reactivex.rxjava3.disposables.Disposable; +import io.reactivex.rxjava3.schedulers.Schedulers; import jadx.api.JadxArgs; import jadx.api.JavaClass; @@ -172,9 +180,11 @@ import jadx.gui.utils.LafManager; import jadx.gui.utils.Link; import jadx.gui.utils.NLS; +import jadx.gui.utils.TextStandardActions; import jadx.gui.utils.UiUtils; import jadx.gui.utils.dbg.UIWatchDog; import jadx.gui.utils.fileswatcher.LiveReloadWorker; +import jadx.gui.utils.rx.RxUtils; import jadx.gui.utils.shortcut.ShortcutsController; import jadx.gui.utils.ui.ActionHandler; import jadx.gui.utils.ui.FileOpenerHelper; @@ -214,7 +224,7 @@ public class MainWindow extends JFrame { private transient JSplitPane quickTabsAndCodeSplitPane; private JTree tree; - private DefaultTreeModel treeModel; + private FilterableTreeModel treeModel; private JRoot treeRoot; private TabbedPane tabbedPane; private HeapUsageBar heapUsageBar; @@ -251,6 +261,10 @@ public class MainWindow extends JFrame { private final transient RenameMappingsGui renameMappings; + private JTextField treeFilterField; + private Disposable treeFilterDisposable; + private Disposable treeScrollDisposable; + public MainWindow(JadxSettings settings) { this.settings = settings; this.project = new JadxProject(this); @@ -813,6 +827,7 @@ public void initTree() { treeRoot = new JRoot(this); treeRoot.setFlatPackages(isFlattenPackage); treeModel.setRoot(treeRoot); + treeFilterField.setText(""); addTreeCustomNodes(); treeRoot.update(); reloadTree(); @@ -1370,9 +1385,12 @@ private void initUI() { mainPanel.add(treeSplitPane); DefaultMutableTreeNode treeRootNode = new DefaultMutableTreeNode(NLS.str("msg.open_file")); - treeModel = new DefaultTreeModel(treeRootNode); + + treeModel = new FilterableTreeModel(this, treeRootNode); tree = new JTree(treeModel); + tree.setLargeModel(true); ToolTipManager.sharedInstance().registerComponent(tree); + tree.getSelectionModel().setSelectionMode(TreeSelectionModel.SINGLE_TREE_SELECTION); tree.setFocusable(false); tree.addFocusListener(new FocusAdapter() { @@ -1454,16 +1472,38 @@ public void treeWillCollapse(TreeExpansionEvent event) { progressPane = new ProgressPanel(this, true); issuesPanel = new IssuesPanel(this); + treeFilterField = new JTextField(); + TextStandardActions.attach(treeFilterField); + treeFilterField.setToolTipText(NLS.str("tree.filter")); + treeFilterField.putClientProperty(FlatClientProperties.TEXT_FIELD_SHOW_CLEAR_BUTTON, true); + treeFilterField.putClientProperty(FlatClientProperties.PLACEHOLDER_TEXT, NLS.str("tree.filter")); + treeFilterField.registerKeyboardAction(ev -> treeFilterField.setText(""), + KeyStroke.getKeyStroke(KeyEvent.VK_ESCAPE, 0), JComponent.WHEN_FOCUSED); + + treeFilterDisposable = RxUtils.textFieldChanges(treeFilterField) + .debounce(300, TimeUnit.MILLISECONDS) + .observeOn(Schedulers.newThread()) + .subscribe(t -> treeModel.setFilter(treeFilterField.getText())); + + JPanel filterPanel = new JPanel(new BorderLayout()); + filterPanel.setBorder(BorderFactory.createEmptyBorder(5, 2, 2, 2)); + filterPanel.add(treeFilterField, BorderLayout.CENTER); + JPanel leftPane = new JPanel(new BorderLayout()); JScrollPane treeScrollPane = new JScrollPane(tree); treeScrollPane.setMinimumSize(new Dimension(100, 150)); + treeScrollDisposable = RxUtils.scrollBarEvents(treeScrollPane.getVerticalScrollBar()) + .observeOn(SwingSchedulers.edt()) + .subscribe(v -> treeModel.expandVisibleFilteredNodes(tree)); JPanel bottomPane = new JPanel(new BorderLayout()); bottomPane.add(issuesPanel, BorderLayout.PAGE_START); bottomPane.add(progressPane, BorderLayout.PAGE_END); + leftPane.add(filterPanel, BorderLayout.PAGE_START); leftPane.add(treeScrollPane, BorderLayout.CENTER); leftPane.add(bottomPane, BorderLayout.PAGE_END); + treeSplitPane.setLeftComponent(leftPane); tabbedPane = new TabbedPane(this, tabsController); @@ -1603,9 +1643,14 @@ private void closeWindow() { closeAll(); UiUtils.uiRunAndWait(() -> { - heapUsageBar.reset(); - editorThemeManager.unload(); - dispose(); + try { + heapUsageBar.reset(); + editorThemeManager.unload(); + treeFilterDisposable.dispose(); + treeScrollDisposable.dispose(); + } finally { + dispose(); + } }); } catch (Exception e) { LOG.error("Close window error", e); @@ -1700,10 +1745,18 @@ public BackgroundExecutor getBackgroundExecutor() { return backgroundExecutor; } + public JTree getTree() { + return tree; + } + public JRoot getTreeRoot() { return treeRoot; } + public JTextField getTreeFilterField() { + return treeFilterField; + } + public JDebuggerPanel getDebuggerPanel() { initDebuggerPanel(); return debuggerPanel; diff --git a/jadx-gui/src/main/java/jadx/gui/utils/rx/RxUtils.java b/jadx-gui/src/main/java/jadx/gui/utils/rx/RxUtils.java index f602350898f..67f7f03e9c0 100644 --- a/jadx-gui/src/main/java/jadx/gui/utils/rx/RxUtils.java +++ b/jadx-gui/src/main/java/jadx/gui/utils/rx/RxUtils.java @@ -1,10 +1,12 @@ package jadx.gui.utils.rx; +import java.awt.event.AdjustmentListener; import java.awt.event.KeyAdapter; import java.awt.event.KeyEvent; import java.awt.event.KeyListener; import java.util.function.Supplier; +import javax.swing.JScrollBar; import javax.swing.JSpinner; import javax.swing.JTextField; import javax.swing.event.ChangeListener; @@ -57,6 +59,16 @@ public static Flowable spinnerEnterPress(final JSpinner spinner) { return Flowable.create(source, BackpressureStrategy.LATEST).distinctUntilChanged(); } + public static Flowable scrollBarEvents(JScrollBar scrollBar) { + FlowableOnSubscribe source = emitter -> { + AdjustmentListener listener = e -> emitter.onNext(e.getValue()); + scrollBar.addAdjustmentListener(listener); + emitter.setDisposable(new CustomDisposable(() -> scrollBar.removeAdjustmentListener(listener))); + }; + return Flowable.create(source, BackpressureStrategy.LATEST) + .distinctUntilChanged(Integer::equals); + } + private static @NotNull KeyListener enterKeyListener(FlowableEmitter emitter, Supplier supplier) { return new KeyAdapter() { @Override diff --git a/jadx-gui/src/main/resources/i18n/Messages_de_DE.properties b/jadx-gui/src/main/resources/i18n/Messages_de_DE.properties index 3de943da065..8befb72d06f 100644 --- a/jadx-gui/src/main/resources/i18n/Messages_de_DE.properties +++ b/jadx-gui/src/main/resources/i18n/Messages_de_DE.properties @@ -68,6 +68,7 @@ tree.loading=Laden… tree.pinned_tabs=Angepinnte Registerkarten tree.open_tabs=Offene Registerkarten tree.bookmarked_tabs=Mit Lesezeichen versehene Registerkarten +#tree.filter=Tree filter progress.load=Laden progress.save_mappings=Zuordnungen werden gespeichert diff --git a/jadx-gui/src/main/resources/i18n/Messages_en_US.properties b/jadx-gui/src/main/resources/i18n/Messages_en_US.properties index 12384ae8d38..1e97f0763d5 100644 --- a/jadx-gui/src/main/resources/i18n/Messages_en_US.properties +++ b/jadx-gui/src/main/resources/i18n/Messages_en_US.properties @@ -68,6 +68,7 @@ tree.loading=Loading... tree.pinned_tabs=Pinned Tabs tree.open_tabs=Open Tabs tree.bookmarked_tabs=Bookmarked Tabs +tree.filter=Tree filter progress.load=Loading progress.save_mappings=Saving mappings diff --git a/jadx-gui/src/main/resources/i18n/Messages_es_ES.properties b/jadx-gui/src/main/resources/i18n/Messages_es_ES.properties index 5a1cca87bf0..47ff3c0fcd1 100644 --- a/jadx-gui/src/main/resources/i18n/Messages_es_ES.properties +++ b/jadx-gui/src/main/resources/i18n/Messages_es_ES.properties @@ -68,6 +68,7 @@ tree.loading=Cargando... #tree.pinned_tabs=Pinned Tabs #tree.open_tabs=Open Tabs #tree.bookmarked_tabs=Bookmarked Tabs +#tree.filter=Tree filter progress.load=Cargando #progress.save_mappings=Saving mappings diff --git a/jadx-gui/src/main/resources/i18n/Messages_id_ID.properties b/jadx-gui/src/main/resources/i18n/Messages_id_ID.properties index 26278394fe1..23f4b81df05 100644 --- a/jadx-gui/src/main/resources/i18n/Messages_id_ID.properties +++ b/jadx-gui/src/main/resources/i18n/Messages_id_ID.properties @@ -68,6 +68,7 @@ tree.loading=Memuat... #tree.pinned_tabs=Pinned Tabs #tree.open_tabs=Open Tabs #tree.bookmarked_tabs=Bookmarked Tabs +#tree.filter=Tree filter progress.load=Memuat progress.save_mappings=Menyimpan pemetaan diff --git a/jadx-gui/src/main/resources/i18n/Messages_ko_KR.properties b/jadx-gui/src/main/resources/i18n/Messages_ko_KR.properties index d289cbb340d..9ecc31aba88 100644 --- a/jadx-gui/src/main/resources/i18n/Messages_ko_KR.properties +++ b/jadx-gui/src/main/resources/i18n/Messages_ko_KR.properties @@ -68,6 +68,7 @@ tree.loading=로딩중... #tree.pinned_tabs=Pinned Tabs #tree.open_tabs=Open Tabs #tree.bookmarked_tabs=Bookmarked Tabs +#tree.filter=Tree filter progress.load=로딩중 progress.save_mappings=매핑 내보내는 중 diff --git a/jadx-gui/src/main/resources/i18n/Messages_pt_BR.properties b/jadx-gui/src/main/resources/i18n/Messages_pt_BR.properties index feeae7290e9..e578cbce23c 100644 --- a/jadx-gui/src/main/resources/i18n/Messages_pt_BR.properties +++ b/jadx-gui/src/main/resources/i18n/Messages_pt_BR.properties @@ -68,6 +68,7 @@ tree.loading=Carregando... #tree.pinned_tabs=Pinned Tabs #tree.open_tabs=Open Tabs #tree.bookmarked_tabs=Bookmarked Tabs +#tree.filter=Tree filter progress.load=Carregando #progress.save_mappings=Saving mappings diff --git a/jadx-gui/src/main/resources/i18n/Messages_ru_RU.properties b/jadx-gui/src/main/resources/i18n/Messages_ru_RU.properties index 3827ff350a8..2b0c3b8d0b8 100644 --- a/jadx-gui/src/main/resources/i18n/Messages_ru_RU.properties +++ b/jadx-gui/src/main/resources/i18n/Messages_ru_RU.properties @@ -68,6 +68,7 @@ tree.loading=Загрузка... #tree.pinned_tabs=Pinned Tabs #tree.open_tabs=Open Tabs #tree.bookmarked_tabs=Bookmarked Tabs +#tree.filter=Tree filter progress.load=Загрузка progress.save_mappings=Сохранить маппинги diff --git a/jadx-gui/src/main/resources/i18n/Messages_zh_CN.properties b/jadx-gui/src/main/resources/i18n/Messages_zh_CN.properties index b153850a73c..8db6b95248f 100644 --- a/jadx-gui/src/main/resources/i18n/Messages_zh_CN.properties +++ b/jadx-gui/src/main/resources/i18n/Messages_zh_CN.properties @@ -68,6 +68,7 @@ tree.loading=加载中… tree.pinned_tabs=固定标签 tree.open_tabs=打开标签 tree.bookmarked_tabs=收藏标签 +#tree.filter=Tree filter progress.load=正在加载 progress.save_mappings=导出映射 diff --git a/jadx-gui/src/main/resources/i18n/Messages_zh_TW.properties b/jadx-gui/src/main/resources/i18n/Messages_zh_TW.properties index e260f80511d..196859217f8 100644 --- a/jadx-gui/src/main/resources/i18n/Messages_zh_TW.properties +++ b/jadx-gui/src/main/resources/i18n/Messages_zh_TW.properties @@ -68,6 +68,7 @@ tree.loading=載入中... tree.pinned_tabs=已釘選的分頁 tree.open_tabs=已開啟的分頁 tree.bookmarked_tabs=已加入書籤的分頁 +#tree.filter=Tree filter progress.load=載入中 progress.save_mappings=正在匯出對應