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