View Javadoc
1   package edu.jiangxin.apktoolbox.file.duplicate;
2   
3   import edu.jiangxin.apktoolbox.utils.DateUtils;
4   import edu.jiangxin.apktoolbox.swing.extend.FileListPanel;
5   import edu.jiangxin.apktoolbox.swing.extend.EasyPanel;
6   import edu.jiangxin.apktoolbox.utils.Constants;
7   import edu.jiangxin.apktoolbox.utils.ExcelExporter;
8   import edu.jiangxin.apktoolbox.utils.FileUtils;
9   import edu.jiangxin.apktoolbox.utils.RevealFileUtils;
10  import org.apache.commons.codec.digest.DigestUtils;
11  import org.apache.commons.io.FilenameUtils;
12  import org.apache.commons.lang3.StringUtils;
13  
14  import javax.swing.*;
15  import javax.swing.table.DefaultTableModel;
16  import java.awt.event.ActionEvent;
17  import java.awt.event.ActionListener;
18  import java.awt.event.MouseAdapter;
19  import java.awt.event.MouseEvent;
20  import java.io.*;
21  import java.util.List;
22  import java.util.*;
23  import java.util.concurrent.ExecutorService;
24  import java.util.concurrent.Executors;
25  import java.util.concurrent.Future;
26  import java.util.concurrent.atomic.AtomicInteger;
27  
28  public class DuplicateSearchPanel extends EasyPanel {
29  
30      @Serial
31      private static final long serialVersionUID = 1L;
32  
33      private JTabbedPane tabbedPane;
34  
35      private JPanel optionPanel;
36  
37      private FileListPanel fileListPanel;
38  
39      private JCheckBox isFileNameChecked;
40      private JCheckBox isMD5Checked;
41      private JCheckBox isModifiedTimeChecked;
42  
43      private JCheckBox isHiddenFileSearched;
44      private JCheckBox isRecursiveSearched;
45      private JTextField suffixTextField;
46  
47      private JPanel resultPanel;
48  
49      private JTable resultTable;
50  
51      private DefaultTableModel resultTableModel;
52  
53      private JButton searchButton;
54      private JButton cancelButton;
55  
56      private JProgressBar progressBar;
57  
58      private JMenuItem openDirMenuItem;
59      private JMenuItem deleteFileMenuItem;
60      private JMenuItem deleteFilesInSameDirMenuItem;
61      private JMenuItem deleteFilesInSameDirRecursiveMenuItem;
62  
63      private transient SearchThread searchThread;
64  
65      private transient final Map<String, List<File>> duplicateFileGroupMap = new HashMap<>();
66  
67      @Override
68      public void initUI() {
69          tabbedPane = new JTabbedPane();
70          add(tabbedPane);
71  
72          createOptionPanel();
73          tabbedPane.addTab("Option", null, optionPanel, "Show Search Options");
74  
75          createResultPanel();
76          tabbedPane.addTab("Result", null, resultPanel, "Show Search Result");
77      }
78  
79      private void createOptionPanel() {
80          optionPanel = new JPanel();
81          optionPanel.setLayout(new BoxLayout(optionPanel, BoxLayout.Y_AXIS));
82  
83          fileListPanel = new FileListPanel();
84          fileListPanel.initialize();
85  
86          JPanel checkOptionPanel = new JPanel();
87          checkOptionPanel.setLayout(new BoxLayout(checkOptionPanel, BoxLayout.X_AXIS));
88          checkOptionPanel.setBorder(BorderFactory.createTitledBorder("Check Options"));
89  
90          JCheckBox isSizeChecked = new JCheckBox("Size");
91          isSizeChecked.setSelected(true);
92          isSizeChecked.setEnabled(false);
93          isFileNameChecked = new JCheckBox("Filename");
94          isMD5Checked = new JCheckBox("MD5");
95          isModifiedTimeChecked = new JCheckBox("Last Modified Time");
96          checkOptionPanel.add(isSizeChecked);
97          checkOptionPanel.add(Box.createHorizontalStrut(Constants.DEFAULT_X_BORDER));
98          checkOptionPanel.add(isFileNameChecked);
99          checkOptionPanel.add(Box.createHorizontalStrut(Constants.DEFAULT_X_BORDER));
100         checkOptionPanel.add(isMD5Checked);
101         checkOptionPanel.add(Box.createHorizontalStrut(Constants.DEFAULT_X_BORDER));
102         checkOptionPanel.add(isModifiedTimeChecked);
103         checkOptionPanel.add(Box.createHorizontalGlue());
104 
105         JPanel searchOptionPanel = new JPanel();
106         searchOptionPanel.setLayout(new BoxLayout(searchOptionPanel, BoxLayout.X_AXIS));
107         searchOptionPanel.setBorder(BorderFactory.createTitledBorder("Search Options"));
108 
109         isHiddenFileSearched = new JCheckBox("Hidden Files");
110         isRecursiveSearched = new JCheckBox("Recursive");
111         isRecursiveSearched.setSelected(true);
112         JLabel suffixLabel = new JLabel("Suffix: ");
113         suffixTextField = new JTextField();
114         suffixTextField.setToolTipText("an array of extensions, ex. {\"java\",\"xml\"}. If this parameter is empty, all files are returned.");
115         searchOptionPanel.add(isHiddenFileSearched);
116         searchOptionPanel.add(Box.createHorizontalStrut(Constants.DEFAULT_X_BORDER));
117         searchOptionPanel.add(isRecursiveSearched);
118         searchOptionPanel.add(Box.createHorizontalStrut(Constants.DEFAULT_X_BORDER));
119         searchOptionPanel.add(suffixLabel);
120         searchOptionPanel.add(suffixTextField);
121         searchOptionPanel.add(Box.createHorizontalGlue());
122 
123         JPanel operationPanel = new JPanel();
124         operationPanel.setLayout(new BoxLayout(operationPanel, BoxLayout.X_AXIS));
125         operationPanel.setBorder(BorderFactory.createTitledBorder("Operations"));
126 
127         JPanel buttonPanel = new JPanel();
128         buttonPanel.setLayout(new BoxLayout(buttonPanel, BoxLayout.X_AXIS));
129 
130         searchButton = new JButton("Search");
131         cancelButton = new JButton("Cancel");
132         cancelButton.setEnabled(false);
133         searchButton.addActionListener(new OperationButtonActionListener());
134         cancelButton.addActionListener(new OperationButtonActionListener());
135         operationPanel.add(searchButton);
136         operationPanel.add(Box.createHorizontalStrut(Constants.DEFAULT_X_BORDER));
137         operationPanel.add(Box.createHorizontalStrut(Constants.DEFAULT_X_BORDER));
138         operationPanel.add(cancelButton);
139         operationPanel.add(Box.createHorizontalGlue());
140 
141         progressBar = new JProgressBar();
142         progressBar.setStringPainted(true);
143         progressBar.setString("Ready");
144 
145         optionPanel.add(fileListPanel);
146         optionPanel.add(Box.createVerticalStrut(Constants.DEFAULT_Y_BORDER));
147         optionPanel.add(checkOptionPanel);
148         optionPanel.add(Box.createVerticalStrut(Constants.DEFAULT_Y_BORDER));
149         optionPanel.add(searchOptionPanel);
150         optionPanel.add(Box.createVerticalStrut(Constants.DEFAULT_Y_BORDER));
151         optionPanel.add(operationPanel);
152 		optionPanel.add(Box.createVerticalStrut(Constants.DEFAULT_Y_BORDER));
153         optionPanel.add(progressBar);
154     }
155 
156     private void createResultPanel() {
157         resultPanel = new JPanel();
158         resultPanel.setLayout(new BoxLayout(resultPanel, BoxLayout.Y_AXIS));
159 
160         resultTableModel = new DuplicateFilesTableModel(new Vector<>(), DuplicateFilesConstants.COLUMN_NAMES);
161         resultTable = new JTable(resultTableModel);
162 
163         resultTable.setDefaultRenderer(Vector.class, new DuplicateFilesTableCellRenderer());
164 
165         for (int i = 0; i < resultTable.getColumnCount(); i++) {
166             resultTable.getColumn(resultTable.getColumnName(i)).setCellRenderer(new DuplicateFilesTableCellRenderer());
167         }
168 
169         resultTable.addMouseListener(new MyMouseListener());
170 
171         resultTable.setSelectionMode(ListSelectionModel.SINGLE_INTERVAL_SELECTION);
172 
173         JScrollPane scrollPane = new JScrollPane(resultTable);
174         resultPanel.add(scrollPane);
175     }
176 
177     private String getComparedKey(File file) {
178         StringBuilder sb = new StringBuilder();
179         sb.append("[Size][");
180         sb.append(DigestUtils.md5Hex(String.valueOf(file.length())));
181         sb.append("]");
182         
183         if (isFileNameChecked.isSelected()) {
184             sb.append("[Filename][");
185             sb.append(DigestUtils.md5Hex(file.getName()));
186             sb.append("]");
187         }
188         if (isMD5Checked.isSelected()) {
189             sb.append("[MD5][");
190             try (InputStream is = new FileInputStream(file)) {
191                 sb.append(DigestUtils.md5Hex(is));
192             } catch (FileNotFoundException e) {
193                 logger.error("getComparedKey FileNotFoundException");
194             } catch (IOException e) {
195                 logger.error("getComparedKey IOException");
196             }
197             sb.append("]");
198         }
199         if (isModifiedTimeChecked.isSelected()) {
200             sb.append("[ModifiedTime][");
201             sb.append(DigestUtils.md5Hex(String.valueOf(file.lastModified())));
202             sb.append("]");
203         }
204         logger.info("path: " + file.getAbsolutePath() + ", key: " + sb);
205         return sb.toString();
206     }
207 
208     class MyMouseListener extends MouseAdapter {
209         @Override
210         public void mouseReleased(MouseEvent e) {
211             super.mouseReleased(e);
212             int r = resultTable.rowAtPoint(e.getPoint());
213             if (r >= 0 && r < resultTable.getRowCount()) {
214                 resultTable.setRowSelectionInterval(r, r);
215             } else {
216                 resultTable.clearSelection();
217             }
218             int rowIndex = resultTable.getSelectedRow();
219             if (rowIndex < 0) {
220                 return;
221             }
222             if (e.isPopupTrigger() && e.getComponent() instanceof JTable) {
223                 JPopupMenu popupmenu = new JPopupMenu();
224                 MyMenuActionListener menuActionListener = new MyMenuActionListener();
225 
226                 openDirMenuItem = new JMenuItem("Open parent folder of this file");
227                 openDirMenuItem.addActionListener(menuActionListener);
228                 popupmenu.add(openDirMenuItem);
229 
230                 deleteFileMenuItem = new JMenuItem("Delete this duplicate file");
231                 deleteFileMenuItem.addActionListener(menuActionListener);
232                 popupmenu.add(deleteFileMenuItem);
233 
234                 deleteFilesInSameDirMenuItem = new JMenuItem("Delete these duplicate files in the same directory");
235                 deleteFilesInSameDirMenuItem.addActionListener(menuActionListener);
236                 popupmenu.add(deleteFilesInSameDirMenuItem);
237 
238                 deleteFilesInSameDirRecursiveMenuItem = new JMenuItem("Delete these duplicate files in the same directory(Recursive)");
239                 deleteFilesInSameDirRecursiveMenuItem.addActionListener(menuActionListener);
240                 popupmenu.add(deleteFilesInSameDirRecursiveMenuItem);
241 
242                 JMenuItem exportMenuItem = new JMenuItem("导出到 Excel");
243                 exportMenuItem.addActionListener(ev ->
244                     ExcelExporter.export(resultTableModel, "duplicate_files_export.xlsx", DuplicateSearchPanel.this));
245                 popupmenu.add(exportMenuItem);
246 
247                 popupmenu.show(e.getComponent(), e.getX(), e.getY());
248             }
249         }
250     }
251 
252     class MyMenuActionListener implements ActionListener {
253         @Override
254         public void actionPerformed(ActionEvent actionEvent) {
255             Object source = actionEvent.getSource();
256             if (source.equals(openDirMenuItem)) {
257                 onOpenDir();
258             } else if (source.equals(deleteFileMenuItem)) {
259                 onDeleteFile();
260             } else if (source.equals(deleteFilesInSameDirMenuItem)) {
261                 onDeleteFilesInSameDir();
262             } else if (source.equals(deleteFilesInSameDirRecursiveMenuItem)) {
263                 onDeleteFilesInSameDirRecursive();
264             } else {
265                 logger.error("invalid source");
266             }
267         }
268 
269         private void onOpenDir() {
270             int rowIndex = resultTable.getSelectedRow();
271             String parentPath = resultTableModel.getValueAt(rowIndex, resultTable.getColumn(DuplicateFilesConstants.COLUMN_NAME_FILE_PARENT).getModelIndex()).toString();
272             File parent = new File(parentPath);
273             RevealFileUtils.revealDirectory(parent);
274         }
275 
276         private void onDeleteFile() {
277             int rowIndex = resultTable.getSelectedRow();
278             String parentPath = resultTableModel.getValueAt(rowIndex, resultTable.getColumn(DuplicateFilesConstants.COLUMN_NAME_FILE_PARENT).getModelIndex()).toString();
279             String name = resultTableModel.getValueAt(rowIndex, resultTable.getColumn(DuplicateFilesConstants.COLUMN_NAME_FILE_NAME).getModelIndex()).toString();
280             File selectedFile = new File(parentPath, name);
281             String key = getComparedKey(selectedFile);
282             List<File> files = duplicateFileGroupMap.get(key);
283             for (File file : files) {
284                 if (selectedFile.equals(file)) {
285                     files.remove(file);
286                     boolean isSuccessful = file.delete();
287                     logger.info("delete file: " + file.getAbsolutePath() + ", result: " + isSuccessful);
288                     break;
289                 }
290             }
291             resultTableModel.setRowCount(0);
292             showResult();
293         }
294 
295         private void onDeleteFilesInSameDir() {
296             int rowIndex = resultTable.getSelectedRow();
297             String parentPath = resultTableModel.getValueAt(rowIndex, resultTable.getColumn(DuplicateFilesConstants.COLUMN_NAME_FILE_PARENT).getModelIndex()).toString();
298             for (Map.Entry<String, List<File>> entry : duplicateFileGroupMap.entrySet()) {
299                 List<File> duplicateFileGroup = entry.getValue();
300                 for (File duplicateFile : duplicateFileGroup) {
301                     String parentPathTmp = duplicateFile.getParent();
302                     if (Objects.equals(parentPath, parentPathTmp)) {
303                         duplicateFileGroup.remove(duplicateFile);
304                         boolean isSuccessful = duplicateFile.delete();
305                         logger.info("delete file: " + duplicateFile.getAbsolutePath() + ", result: " + isSuccessful);
306                         break;
307                     }
308                 }
309             }
310             resultTableModel.setRowCount(0);
311             showResult();
312         }
313 
314         private void onDeleteFilesInSameDirRecursive() {
315             int rowIndex = resultTable.getSelectedRow();
316             String parentPath = resultTableModel.getValueAt(rowIndex, resultTable.getColumn(DuplicateFilesConstants.COLUMN_NAME_FILE_PARENT).getModelIndex()).toString();
317             for (Map.Entry<String, List<File>> entry : duplicateFileGroupMap.entrySet()) {
318                 List<File> duplicateFileGroup = entry.getValue();
319                 for (File duplicateFile : duplicateFileGroup) {
320                     String parentPathTmp = duplicateFile.getParent();
321                     if (Objects.equals(parentPath, parentPathTmp) || FilenameUtils.directoryContains(parentPath, parentPathTmp)) {
322                         duplicateFileGroup.remove(duplicateFile);
323                         boolean isSuccessful = duplicateFile.delete();
324                         logger.info("delete file: " + duplicateFile.getAbsolutePath() + ", result: " + isSuccessful);
325                         break;
326                     }
327                 }
328             }
329             resultTableModel.setRowCount(0);
330             showResult();
331         }
332     }
333 
334     class OperationButtonActionListener implements ActionListener {
335         @Override
336         public void actionPerformed(ActionEvent e) {
337             Object source = e.getSource();
338             if (source.equals(searchButton)) {
339                 searchButton.setEnabled(false);
340                 cancelButton.setEnabled(true);
341                 String[] extensions = null;
342                 if (StringUtils.isNotEmpty(suffixTextField.getText())) {
343                     extensions = suffixTextField.getText().split(",");
344                 }
345                 searchThread = new SearchThread(extensions, isRecursiveSearched.isSelected(), isHiddenFileSearched.isSelected(), duplicateFileGroupMap);
346                 searchThread.start();
347             } else if (source.equals(cancelButton)) {
348                 searchButton.setEnabled(true);
349                 cancelButton.setEnabled(false);
350                 if (searchThread.isAlive()) {
351                     searchThread.interrupt();
352                     searchThread.executorService.shutdownNow();
353                 }
354             }
355 
356         }
357     }
358 
359     private void showResult() {
360         SwingUtilities.invokeLater(() -> {
361             int groupIndex = 0;
362             for (Map.Entry<String, List<File>> entry : duplicateFileGroupMap.entrySet()) {
363                 List<File> duplicateFileGroup = entry.getValue();
364                 if (duplicateFileGroup.size() < 2) {
365                     continue;
366                 }
367                 groupIndex++;
368                 for (File duplicateFile : duplicateFileGroup) {
369                     Vector<Object> rowData = getRowVector(groupIndex, duplicateFile);
370                     resultTableModel.addRow(rowData);
371                 }
372             }
373             tabbedPane.setSelectedIndex(1);
374         });
375     }
376 
377     private Vector<Object> getRowVector(int groupIndex, File file) {
378         Vector<Object> rowData = new Vector<>();
379         rowData.add(groupIndex);
380         rowData.add(file.getParent());
381         rowData.add(file.getName());
382         rowData.add(FilenameUtils.getExtension(file.getName()));
383         rowData.add(FileUtils.sizeOfInHumanFormat(file));
384         rowData.add(DateUtils.millisecondToHumanFormat(file.lastModified()));
385         return rowData;
386     }
387 
388     class SearchThread extends Thread {
389         private final ExecutorService executorService;
390         private final AtomicInteger processedFiles = new AtomicInteger(0);
391         private int totalFiles = 0;
392         private final String[] extensions;
393         private final boolean isRecursiveSearched;
394         private final boolean isHiddenFileSearched;
395         private final Map<String, List<File>> duplicateFileGroupMap;
396 
397         public SearchThread(String[] extensions, boolean isRecursiveSearched, boolean isHiddenFileSearched, Map<String, List<File>> duplicateFileGroupMap) {
398             super();
399             this.extensions = extensions;
400             this.isRecursiveSearched = isRecursiveSearched;
401             this.isHiddenFileSearched = isHiddenFileSearched;
402             this.duplicateFileGroupMap = duplicateFileGroupMap;
403             this.executorService = Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors());
404 
405             SwingUtilities.invokeLater(() -> {
406                 progressBar.setValue(0);
407                 progressBar.setString("Starting search...");
408             });
409         }
410 
411         @Override
412         public void run() {
413             try {
414                 duplicateFileGroupMap.clear();
415                 SwingUtilities.invokeLater(() -> resultTableModel.setRowCount(0));
416 
417                 List<File> fileList = fileListPanel.getFileList();
418                 Set<File> fileSet = new TreeSet<>(fileList);
419                 for (File file : fileList) {
420                     fileSet.addAll(org.apache.commons.io.FileUtils.listFiles(file, extensions, isRecursiveSearched));
421                 }
422 
423                 // 1. Group files by size first
424                 Map<Long, List<File>> sizeGroups = new HashMap<>();
425                 for (File file : fileSet) {
426                     if (currentThread().isInterrupted()) {
427                         return;
428                     }
429                     if (file.isHidden() && !isHiddenFileSearched) {
430                         continue;
431                     }
432                     sizeGroups.computeIfAbsent(file.length(), k -> new ArrayList<>()).add(file);
433                 }
434 
435                 // 2. Only process groups with duplicate sizes
436                 List<Future<?>> futures = new ArrayList<>();
437                 totalFiles = fileSet.size();
438                 updateProgress();
439 
440                 for (Map.Entry<Long, List<File>> entry : sizeGroups.entrySet()) {
441                     if (entry.getValue().size() > 1) { // Only process groups with duplicates
442                         futures.add(executorService.submit(() -> {
443                             processFileGroup(entry.getValue());
444                             return null;
445                         }));
446                     } else {
447                         // Count single files directly
448                         incrementProcessedFiles();
449                     }
450                 }
451 
452                 // Wait for all tasks to complete
453                 for (Future<?> future : futures) {
454                     try {
455                         future.get();
456                     } catch (InterruptedException e) {
457                         logger.error("Search interrupted", e);
458                         currentThread().interrupt(); // Restore interrupted status
459                         return;
460                     }
461                 }
462 
463                 showResult();
464             } catch (Exception e) {
465                 logger.error("Search failed", e);
466                 SwingUtilities.invokeLater(() -> progressBar.setString("Search failed"));
467             } finally {
468                 executorService.shutdown();
469                 SwingUtilities.invokeLater(() -> {
470                     searchButton.setEnabled(true);
471                     cancelButton.setEnabled(false);
472                 });
473             }
474         }
475 
476         private void processFileGroup(List<File> files) {
477             Map<String, List<File>> groupMap = new HashMap<>();
478             for (File file : files) {
479                 if (currentThread().isInterrupted()) {
480                     return;
481                 }
482                 String key = getComparedKey(file);
483                 groupMap.computeIfAbsent(key, k -> new ArrayList<>()).add(file);
484                 incrementProcessedFiles();
485             }
486 
487             // Merge results to main map
488             synchronized (duplicateFileGroupMap) {
489                 for (Map.Entry<String, List<File>> entry : groupMap.entrySet()) {
490                     if (entry.getValue().size() > 1) {
491                         duplicateFileGroupMap.put(entry.getKey(), entry.getValue());
492                     }
493                 }
494             }
495         }
496 
497         private void incrementProcessedFiles() {
498             processedFiles.incrementAndGet();
499             updateProgress();
500         }
501 
502         private void updateProgress() {
503             if (totalFiles > 0) {
504                 SwingUtilities.invokeLater(() -> {
505                     int processed = processedFiles.get();
506                     int percentage = (int) (processed * 100.0 / totalFiles);
507                     progressBar.setValue(percentage);
508                     progressBar.setString(String.format("Processing: %d/%d files (%d%%)", 
509                         processed, totalFiles, percentage));
510                 });
511             }
512         }
513     }
514 }