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
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
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) {
442 futures.add(executorService.submit(() -> {
443 processFileGroup(entry.getValue());
444 return null;
445 }));
446 } else {
447
448 incrementProcessedFiles();
449 }
450 }
451
452
453 for (Future<?> future : futures) {
454 try {
455 future.get();
456 } catch (InterruptedException e) {
457 logger.error("Search interrupted", e);
458 currentThread().interrupt();
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
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 }