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 org.apache.commons.codec.digest.DigestUtils;
9   import org.apache.commons.io.FilenameUtils;
10  import org.apache.commons.lang3.StringUtils;
11  
12  import javax.swing.*;
13  import javax.swing.table.DefaultTableModel;
14  import java.awt.*;
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 Thread searchThread;
63  
64      final private Map<String, List<File>> duplicateFileGroupMap = new HashMap<>();
65  
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  
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         searchButton.addActionListener(new OperationButtonActionListener());
132         cancelButton.addActionListener(new OperationButtonActionListener());
133         operationPanel.add(searchButton);
134         operationPanel.add(Box.createHorizontalStrut(Constants.DEFAULT_X_BORDER));
135         operationPanel.add(Box.createHorizontalStrut(Constants.DEFAULT_X_BORDER));
136         operationPanel.add(cancelButton);
137         operationPanel.add(Box.createHorizontalGlue());
138 
139         progressBar = new JProgressBar();
140         progressBar.setStringPainted(true);
141         progressBar.setString("Ready");
142 
143         optionPanel.add(fileListPanel);
144         optionPanel.add(Box.createVerticalStrut(Constants.DEFAULT_Y_BORDER));
145         optionPanel.add(checkOptionPanel);
146         optionPanel.add(Box.createVerticalStrut(Constants.DEFAULT_Y_BORDER));
147         optionPanel.add(searchOptionPanel);
148         optionPanel.add(Box.createVerticalStrut(Constants.DEFAULT_Y_BORDER));
149         optionPanel.add(operationPanel);
150 		optionPanel.add(Box.createVerticalStrut(Constants.DEFAULT_Y_BORDER));
151         optionPanel.add(progressBar);
152     }
153 
154     private void createResultPanel() {
155         resultPanel = new JPanel();
156         resultPanel.setLayout(new BoxLayout(resultPanel, BoxLayout.Y_AXIS));
157 
158         resultTableModel = new DuplicateFilesTableModel(new Vector<>(), DuplicateFilesConstants.COLUMN_NAMES);
159         resultTable = new JTable(resultTableModel);
160 
161         resultTable.setDefaultRenderer(Vector.class, new DuplicateFilesTableCellRenderer());
162 
163         for (int i = 0; i < resultTable.getColumnCount(); i++) {
164             resultTable.getColumn(resultTable.getColumnName(i)).setCellRenderer(new DuplicateFilesTableCellRenderer());
165         }
166 
167         resultTable.addMouseListener(new MyMouseListener());
168 
169         resultTable.setSelectionMode(ListSelectionModel.SINGLE_INTERVAL_SELECTION);
170 
171         JScrollPane scrollPane = new JScrollPane(resultTable);
172         resultPanel.add(scrollPane);
173     }
174 
175     public String getComparedKey(File file) {
176         StringBuilder sb = new StringBuilder();
177         sb.append("[Size][");
178         sb.append(DigestUtils.md5Hex(String.valueOf(file.length())));
179         sb.append("]");
180         
181         if (isFileNameChecked.isSelected()) {
182             sb.append("[Filename][");
183             sb.append(DigestUtils.md5Hex(file.getName()));
184             sb.append("]");
185         }
186         if (isMD5Checked.isSelected()) {
187             sb.append("[MD5][");
188             try (InputStream is = new FileInputStream(file)) {
189                 sb.append(DigestUtils.md5Hex(is));
190             } catch (FileNotFoundException e) {
191                 logger.error("getComparedKey FileNotFoundException");
192             } catch (IOException e) {
193                 logger.error("getComparedKey IOException");
194             }
195             sb.append("]");
196         }
197         if (isModifiedTimeChecked.isSelected()) {
198             sb.append("[ModifiedTime][");
199             sb.append(DigestUtils.md5Hex(String.valueOf(file.lastModified())));
200             sb.append("]");
201         }
202         logger.info("path: " + file.getAbsolutePath() + ", key: " + sb);
203         return sb.toString();
204     }
205 
206     class MyMouseListener extends MouseAdapter {
207         @Override
208         public void mouseReleased(MouseEvent e) {
209             super.mouseReleased(e);
210             int r = resultTable.rowAtPoint(e.getPoint());
211             if (r >= 0 && r < resultTable.getRowCount()) {
212                 resultTable.setRowSelectionInterval(r, r);
213             } else {
214                 resultTable.clearSelection();
215             }
216             int rowIndex = resultTable.getSelectedRow();
217             if (rowIndex < 0) {
218                 return;
219             }
220             if (e.isPopupTrigger() && e.getComponent() instanceof JTable) {
221                 JPopupMenu popupmenu = new JPopupMenu();
222                 MyMenuActionListener menuActionListener = new MyMenuActionListener();
223 
224                 openDirMenuItem = new JMenuItem("Open parent folder of this file");
225                 openDirMenuItem.addActionListener(menuActionListener);
226                 popupmenu.add(openDirMenuItem);
227 
228                 deleteFileMenuItem = new JMenuItem("Delete this duplicate file");
229                 deleteFileMenuItem.addActionListener(menuActionListener);
230                 popupmenu.add(deleteFileMenuItem);
231 
232                 deleteFilesInSameDirMenuItem = new JMenuItem("Delete these duplicate files in the same directory");
233                 deleteFilesInSameDirMenuItem.addActionListener(menuActionListener);
234                 popupmenu.add(deleteFilesInSameDirMenuItem);
235 
236                 deleteFilesInSameDirRecursiveMenuItem = new JMenuItem("Delete these duplicate files in the same directory(Recursive)");
237                 deleteFilesInSameDirRecursiveMenuItem.addActionListener(menuActionListener);
238                 popupmenu.add(deleteFilesInSameDirRecursiveMenuItem);
239 
240                 popupmenu.show(e.getComponent(), e.getX(), e.getY());
241             }
242         }
243     }
244 
245     class MyMenuActionListener implements ActionListener {
246         @Override
247         public void actionPerformed(ActionEvent actionEvent) {
248             Object source = actionEvent.getSource();
249             if (source.equals(openDirMenuItem)) {
250                 onOpenDir();
251             } else if (source.equals(deleteFileMenuItem)) {
252                 onDeleteFile();
253             } else if (source.equals(deleteFilesInSameDirMenuItem)) {
254                 onDeleteFilesInSameDir();
255             } else if (source.equals(deleteFilesInSameDirRecursiveMenuItem)) {
256                 onDeleteFilesInSameDirRecursive();
257             } else {
258                 logger.error("invalid source");
259             }
260         }
261 
262         private void onOpenDir() {
263             int rowIndex = resultTable.getSelectedRow();
264             String parentPath = resultTableModel.getValueAt(rowIndex, resultTable.getColumn(DuplicateFilesConstants.COLUMN_NAME_FILE_PARENT).getModelIndex()).toString();
265             File parent = new File(parentPath);
266             if (parent.isDirectory()) {
267                 try {
268                     Desktop.getDesktop().open(parent);
269                 } catch (IOException e) {
270                     logger.error("open parent failed: " + parent.getPath());
271                 }
272             }
273         }
274 
275         private void onDeleteFile() {
276             int rowIndex = resultTable.getSelectedRow();
277             String parentPath = resultTableModel.getValueAt(rowIndex, resultTable.getColumn(DuplicateFilesConstants.COLUMN_NAME_FILE_PARENT).getModelIndex()).toString();
278             String name = resultTableModel.getValueAt(rowIndex, resultTable.getColumn(DuplicateFilesConstants.COLUMN_NAME_FILE_NAME).getModelIndex()).toString();
279             File selectedFile = new File(parentPath, name);
280             String key = getComparedKey(selectedFile);
281             List<File> files = duplicateFileGroupMap.get(key);
282             for (File file : files) {
283                 if (!selectedFile.equals(file)) {
284                     continue;
285                 }
286                 files.remove(file);
287                 boolean isSuccessful = file.delete();
288                 logger.info("delete file: " + file.getAbsolutePath() + ", result: " + isSuccessful);
289                 break;
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                 String[] extensions = null;
340                 if (StringUtils.isNotEmpty(suffixTextField.getText())) {
341                     extensions = suffixTextField.getText().split(",");
342                 }
343                 searchThread = new SearchThread(extensions, isRecursiveSearched.isSelected(), isHiddenFileSearched.isSelected(), duplicateFileGroupMap);
344                 searchThread.start();
345             } else if (source.equals(cancelButton)) {
346                 if (searchThread.isAlive()) {
347                     searchThread.interrupt();
348                 }
349             }
350 
351         }
352     }
353 
354     private void showResult() {
355         SwingUtilities.invokeLater(() -> {
356             int groupIndex = 0;
357             for (Map.Entry<String, List<File>> entry : duplicateFileGroupMap.entrySet()) {
358                 List<File> duplicateFileGroup = entry.getValue();
359                 if (duplicateFileGroup.size() < 2) {
360                     continue;
361                 }
362                 groupIndex++;
363                 for (File duplicateFile : duplicateFileGroup) {
364                     Vector<Object> rowData = getRowVector(groupIndex, duplicateFile);
365                     resultTableModel.addRow(rowData);
366                 }
367             }
368             tabbedPane.setSelectedIndex(1);
369         });
370     }
371 
372     private Vector<Object> getRowVector(int groupIndex, File file) {
373         Vector<Object> rowData = new Vector<>();
374         rowData.add(groupIndex);
375         rowData.add(file.getParent());
376         rowData.add(file.getName());
377         rowData.add(FilenameUtils.getExtension(file.getName()));
378         rowData.add(FileUtils.sizeOfInHumanFormat(file));
379         rowData.add(DateUtils.millisecondToHumanFormat(file.lastModified()));
380         return rowData;
381     }
382 
383     class SearchThread extends Thread {
384         private final ExecutorService executorService;
385         private final AtomicInteger processedFiles = new AtomicInteger(0);
386         private int totalFiles = 0;
387         private final String[] extensions;
388         private final boolean isRecursiveSearched;
389         private final boolean isHiddenFileSearched;
390         private final Map<String, List<File>> duplicateFileGroupMap;
391 
392         public SearchThread(String[] extensions, boolean isRecursiveSearched, boolean isHiddenFileSearched, Map<String, List<File>> duplicateFileGroupMap) {
393             super();
394             this.extensions = extensions;
395             this.isRecursiveSearched = isRecursiveSearched;
396             this.isHiddenFileSearched = isHiddenFileSearched;
397             this.duplicateFileGroupMap = duplicateFileGroupMap;
398             this.executorService = Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors());
399 
400             SwingUtilities.invokeLater(() -> {
401                 progressBar.setValue(0);
402                 progressBar.setString("Starting search...");
403             });
404         }
405 
406         @Override
407         public void run() {
408             try {
409                 duplicateFileGroupMap.clear();
410                 SwingUtilities.invokeLater(() -> resultTableModel.setRowCount(0));
411 
412                 List<File> fileList = fileListPanel.getFileList();
413                 Set<File> fileSet = new TreeSet<>(fileList);
414                 for (File file : fileList) {
415                     fileSet.addAll(org.apache.commons.io.FileUtils.listFiles(file, extensions, isRecursiveSearched));
416                 }
417 
418                 // 1. Group files by size first
419                 Map<Long, List<File>> sizeGroups = new HashMap<>();
420                 for (File file : fileSet) {
421                     if (Thread.currentThread().isInterrupted()) {
422                         return;
423                     }
424                     if (file.isHidden() && !isHiddenFileSearched) {
425                         continue;
426                     }
427                     sizeGroups.computeIfAbsent(file.length(), k -> new ArrayList<>()).add(file);
428                 }
429 
430                 // 2. Only process groups with duplicate sizes
431                 List<Future<?>> futures = new ArrayList<>();
432                 totalFiles = fileSet.size();
433                 updateProgress();
434 
435                 for (Map.Entry<Long, List<File>> entry : sizeGroups.entrySet()) {
436                     if (entry.getValue().size() > 1) { // Only process groups with duplicates
437                         futures.add(executorService.submit(() -> {
438                             processFileGroup(entry.getValue());
439                             return null;
440                         }));
441                     } else {
442                         // Count single files directly
443                         incrementProcessedFiles();
444                     }
445                 }
446 
447                 // Wait for all tasks to complete
448                 for (Future<?> future : futures) {
449                     try {
450                         future.get();
451                     } catch (InterruptedException e) {
452                         logger.error("Search interrupted", e);
453                         Thread.currentThread().interrupt(); // Restore interrupted status
454                         return;
455                     }
456                 }
457 
458                 showResult();
459             } catch (Exception e) {
460                 logger.error("Search failed", e);
461                 SwingUtilities.invokeLater(() -> progressBar.setString("Search failed"));
462             } finally {
463                 executorService.shutdown();
464             }
465         }
466 
467         private void processFileGroup(List<File> files) {
468             Map<String, List<File>> groupMap = new HashMap<>();
469             for (File file : files) {
470                 if (Thread.currentThread().isInterrupted()) {
471                     return;
472                 }
473                 String key = getComparedKey(file);
474                 groupMap.computeIfAbsent(key, k -> new ArrayList<>()).add(file);
475                 incrementProcessedFiles();
476             }
477 
478             // Merge results to main map
479             synchronized (duplicateFileGroupMap) {
480                 for (Map.Entry<String, List<File>> entry : groupMap.entrySet()) {
481                     if (entry.getValue().size() > 1) {
482                         duplicateFileGroupMap.put(entry.getKey(), entry.getValue());
483                     }
484                 }
485             }
486         }
487 
488         private void incrementProcessedFiles() {
489             processedFiles.incrementAndGet();
490             updateProgress();
491         }
492 
493         private void updateProgress() {
494             if (totalFiles > 0) {
495                 SwingUtilities.invokeLater(() -> {
496                     int processed = processedFiles.get();
497                     int percentage = (int) ((processed * 100.0) / totalFiles);
498                     progressBar.setValue(percentage);
499                     progressBar.setString(String.format("Processing: %d/%d files (%d%%)", 
500                         processed, totalFiles, percentage));
501                 });
502             }
503         }
504     }
505 }