From c9f31c228630902955253b2e1798e939ab81b886 Mon Sep 17 00:00:00 2001 From: Hiroki Tokunaga Date: Thu, 2 Jul 2026 19:02:35 +0900 Subject: [PATCH 1/2] feat: fold bulk resent requests in History Group send x 20 results into collapsible batches with summary rows so bulk resends no longer flood the History table. Co-authored-by: Cursor --- .../java/core/packetproxy/DuplexFactory.java | 11 ++ .../controller/ResendController.java | 16 +- .../SinglePacketAttackController.java | 11 ++ .../java/core/packetproxy/gui/GUIHistory.java | 168 +++++++++++++++++- .../core/packetproxy/model/OneShotPacket.java | 18 ++ .../java/core/packetproxy/model/Packet.java | 27 ++- .../java/core/packetproxy/model/Packets.java | 16 +- .../packetproxy/gui/ResendBatchService.kt | 166 +++++++++++++++++ .../packetproxy/gui/ResendBatchServiceTest.kt | 88 +++++++++ 9 files changed, 509 insertions(+), 12 deletions(-) create mode 100644 src/main/kotlin/core/packetproxy/gui/ResendBatchService.kt create mode 100644 src/test/kotlin/packetproxy/gui/ResendBatchServiceTest.kt diff --git a/src/main/java/core/packetproxy/DuplexFactory.java b/src/main/java/core/packetproxy/DuplexFactory.java index 566e3e18..a119778d 100644 --- a/src/main/java/core/packetproxy/DuplexFactory.java +++ b/src/main/java/core/packetproxy/DuplexFactory.java @@ -28,6 +28,15 @@ public class DuplexFactory { // 1MB以上のパケットは最後のタイミングだけHistoryに記録する、それ未満はパケットが更新されるたびにHistoryを更新する + + private static void applyResendBatchFromOneshot(OneShotPacket oneshot, Packet packet) { + if (oneshot.getResendBatchId() == 0) { + return; + } + packet.setResendBatchId(oneshot.getResendBatchId()); + packet.setResendSourceId(oneshot.getResendSourceId()); + } + static final int SKIP_LENGTH = 1 * 1024 * 1024; // 10MB以上のパケットはHistoryには記録しない static final int TOO_LARGE_LENGTH = 10 * 1024 * 1024; @@ -412,6 +421,7 @@ public byte[] onClientChunkSend(byte[] data) throws Exception { client_packet = new Packet(0, oneshot.getClient(), oneshot.getServer(), oneshot.getServerName(), oneshot.getUseSSL(), oneshot.getEncoder(), oneshot.getAlpn(), Packet.Direction.CLIENT, duplex.hashCode(), UniqueID.getInstance().createId()); + applyResendBatchFromOneshot(oneshot, client_packet); client_packet.setModified(); client_packet.setReceivedData(data); client_packet.setDecodedData(data); @@ -559,6 +569,7 @@ public byte[] onServerChunkReceived(byte[] data) throws Exception { client_packet = new Packet(0, oneshot.getClient(), oneshot.getServer(), oneshot.getServerName(), oneshot.getUseSSL(), oneshot.getEncoder(), oneshot.getAlpn(), Packet.Direction.CLIENT, duplex.hashCode(), packetproxy.common.UniqueID.getInstance().createId()); + applyResendBatchFromOneshot(oneshot, client_packet); client_packet.setDecodedData(oneshot.getData()); client_packet.setModifiedData(oneshot.getData()); client_packet.setResend(); diff --git a/src/main/java/core/packetproxy/controller/ResendController.java b/src/main/java/core/packetproxy/controller/ResendController.java index f3d362ca..5a1202f9 100644 --- a/src/main/java/core/packetproxy/controller/ResendController.java +++ b/src/main/java/core/packetproxy/controller/ResendController.java @@ -29,8 +29,10 @@ import packetproxy.DuplexManager; import packetproxy.EncoderManager; import packetproxy.common.I18nString; +import packetproxy.common.UniqueID; import packetproxy.encode.EncodeHTTPBase; import packetproxy.encode.Encoder; +import packetproxy.gui.ResendBatchService; import packetproxy.http.Http; import packetproxy.model.OneShotPacket; import packetproxy.model.Packet; @@ -113,11 +115,23 @@ protected Object doInBackground() throws Exception { try { ArrayList list = new ArrayList(); + long batchId = 0; + if (this.oneshot != null && this.count > 1) { + + batchId = UniqueID.getInstance().createId(); + ResendBatchService.getInstance().registerPendingBatch(batchId, this.oneshot.getId(), this.count); + } if (this.oneshot != null && this.count > 0) { for (int i = 0; i < this.count; i++) { - DataToBeSend sendData = new DataToBeSend(this.oneshot, result -> { + var sendOneshot = (OneShotPacket) this.oneshot.clone(); + if (batchId != 0) { + + sendOneshot.setResendBatchId(batchId); + sendOneshot.setResendSourceId(this.oneshot.getId()); + } + DataToBeSend sendData = new DataToBeSend(sendOneshot, result -> { publish(result); }); list.add(sendData); diff --git a/src/main/java/core/packetproxy/controller/SinglePacketAttackController.java b/src/main/java/core/packetproxy/controller/SinglePacketAttackController.java index a27a3d00..50496272 100644 --- a/src/main/java/core/packetproxy/controller/SinglePacketAttackController.java +++ b/src/main/java/core/packetproxy/controller/SinglePacketAttackController.java @@ -26,6 +26,8 @@ import packetproxy.DuplexFactory; import packetproxy.DuplexSync; import packetproxy.EncoderManager; +import packetproxy.common.UniqueID; +import packetproxy.gui.ResendBatchService; import packetproxy.http2.frames.DataFrame; import packetproxy.http2.frames.Frame; import packetproxy.http2.frames.FrameUtils; @@ -34,6 +36,7 @@ import packetproxy.model.Packet; public class SinglePacketAttackController { + private final OneShotPacket oneshot; private final AttackFrames baseAttackFrames; private final DuplexSync attackConnection; private final int sleepTimeMs; @@ -60,6 +63,7 @@ public SinglePacketAttackController(final OneShotPacket oneshot, final int sleep "GET requests are not supported by Single Packet Attack because they cannot have DATA frames in HTTP/2."); } + this.oneshot = oneshot; this.attackConnection = DuplexFactory.createDuplexSyncForSinglePacketAttack(oneshot); this.baseAttackFrames = generateAttackFrames(oneshot); this.sleepTimeMs = sleepTimeMs; @@ -70,6 +74,13 @@ public void attack(final int count) throws Exception { return; } + if (count > 1) { + var batchId = UniqueID.getInstance().createId(); + oneshot.setResendBatchId(batchId); + oneshot.setResendSourceId(oneshot.getId()); + ResendBatchService.getInstance().registerPendingBatch(batchId, oneshot.getId(), count); + } + sendConnectionPreface(); launchAttack(count); } diff --git a/src/main/java/core/packetproxy/gui/GUIHistory.java b/src/main/java/core/packetproxy/gui/GUIHistory.java index f137a5a4..014e6131 100644 --- a/src/main/java/core/packetproxy/gui/GUIHistory.java +++ b/src/main/java/core/packetproxy/gui/GUIHistory.java @@ -61,11 +61,13 @@ import javax.swing.JTable; import javax.swing.JToggleButton; import javax.swing.KeyStroke; +import javax.swing.RowFilter; import javax.swing.SwingUtilities; import javax.swing.SwingWorker; import javax.swing.event.ListSelectionEvent; import javax.swing.event.TableModelEvent; import javax.swing.event.TableModelListener; +import javax.swing.table.DefaultTableCellRenderer; import javax.swing.table.DefaultTableModel; import javax.swing.table.TableCellRenderer; import javax.swing.table.TableRowSorter; @@ -84,6 +86,7 @@ public class GUIHistory implements PropertyChangeListener { private static final int COL_ID = 0; + private static final int COL_CLIENT_REQUEST = 1; private static final int COL_SERVER_RESPONSE = 2; private static final int COL_LENGTH = 3; private static final int COL_MODIFIED = 10; @@ -136,6 +139,7 @@ public static GUIHistory restoreLastInstance(JFrame frame) throws Exception { private GUIHistoryAutoScroll autoScroll; private JPopupMenu menu; private PacketPairingService pairingService; + private ResendBatchService resendBatchService; private Color packetColorGreen = new Color(0x7f, 0xff, 0xd4); private Color packetColorBrown = new Color(0xd2, 0x69, 0x1e); @@ -147,6 +151,7 @@ private GUIHistory(boolean restore) throws Exception { ResenderPackets.getInstance().initTable(restore); Filters.getInstance().addPropertyChangeListener(this); pairingService = new PacketPairingService(); + resendBatchService = ResendBatchService.getInstance(); gui_packet = GUIPacket.getInstance(); colorManager = new TableCustomColorManager(); preferredPosition = 0; @@ -302,12 +307,39 @@ public void actionPerformed(ActionEvent e) { } }); + JToggleButton expandResendBatches = new JToggleButton("Expand resent batches"); + expandResendBatches.setPreferredSize(new Dimension(180, gui_filter.getMaximumSize().height)); + expandResendBatches.setMaximumSize(new Dimension(180, gui_filter.getMaximumSize().height)); + expandResendBatches.setMinimumSize(new Dimension(180, gui_filter.getMaximumSize().height)); + expandResendBatches.addActionListener(new ActionListener() { + + @Override + public void actionPerformed(ActionEvent e) { + if (expandResendBatches.isSelected()) { + + resendBatchService.expandAll(); + } else { + + resendBatchService.collapseAll(); + } + try { + + applyRowFilters(); + } catch (Exception ex) { + + errWithStackTrace(ex); + } + table.repaint(); + } + }); + JPanel filter_panel = new JPanel(); filter_panel.setLayout(new BoxLayout(filter_panel, BoxLayout.X_AXIS)); filter_panel.add(gui_filter); filter_panel.add(filterDropDown); filter_panel.add(filterConfigAdd); filter_panel.add(filterConfig); + filter_panel.add(expandResendBatches); return filter_panel; } @@ -446,6 +478,57 @@ public void valueChanged(ListSelectionEvent e) { sorter.setSortsOnUpdates(true); sorter.toggleSortOrder(14); /* 14 is 'group' column */ table.setRowSorter(sorter); + table.getColumnModel().getColumn(COL_CLIENT_REQUEST).setCellRenderer(new DefaultTableCellRenderer() { + + private static final long serialVersionUID = 1L; + + @Override + public Component getTableCellRendererComponent(JTable table, Object value, boolean isSelected, + boolean hasFocus, int row, int column) { + Component component = super.getTableCellRendererComponent(table, value, isSelected, hasFocus, row, + column); + int packetId = (Integer) table.getValueAt(row, COL_ID); + long batchId = resendBatchService.getBatchIdForPacket(packetId); + if (batchId != 0 && resendBatchService.isRepresentativePacket(packetId)) { + + String label = resendBatchService.getSummaryLabel(batchId); + String original = value == null ? "" : value.toString(); + setText(label + " " + original); + } + return component; + } + }); + table.addMouseListener(new MouseAdapter() { + + @Override + public void mouseClicked(MouseEvent e) { + int row = table.rowAtPoint(e.getPoint()); + if (row < 0) { + return; + } + int col = table.columnAtPoint(e.getPoint()); + int packetId = (Integer) table.getValueAt(row, COL_ID); + long batchId = resendBatchService.getBatchIdForPacket(packetId); + if (batchId == 0) { + return; + } + if (!resendBatchService.isRepresentativePacket(packetId)) { + return; + } + if (col != COL_CLIENT_REQUEST && e.getClickCount() < 2) { + return; + } + resendBatchService.toggleCollapsed(batchId); + try { + + applyRowFilters(); + } catch (Exception ex) { + + errWithStackTrace(ex); + } + table.repaint(); + } + }); GUIHistoryContextMenuFactory.Handles handles = GUIHistoryContextMenuFactory.build(this, owner, table, gui_packet, packets, colorManager, packetColorGreen, packetColorBrown, packetColorYellow); @@ -576,6 +659,13 @@ public void componentResized(ComponentEvent e) { main_panel.setLayout(new BoxLayout(main_panel, BoxLayout.Y_AXIS)); main_panel.add(createFilterPanel()); main_panel.add(split_panel); + try { + + applyRowFilters(); + } catch (Exception e) { + + errWithStackTrace(e); + } return main_panel; } @@ -710,6 +800,22 @@ private void handleIntegerPacketValue(int value) throws Exception { } else { addNewRowWithGroupTracking(packet, packetId, isResponse, groupId); } + registerResendBatchPacket(packet, packetId, isResponse); + } + + private void registerResendBatchPacket(Packet packet, int packetId, boolean isResponse) { + if (isResponse || packet.getResendBatchId() == 0) { + return; + } + resendBatchService.registerPacket(packet.getResendBatchId(), packetId, packet.getResendSourceId()); + try { + + applyRowFilters(); + } catch (Exception e) { + + errWithStackTrace(e); + } + table.repaint(); } private int countAndTrackPacket(Packet packet) { @@ -982,6 +1088,7 @@ public void updateAll() throws Exception { tableModel.setRowCount(0); id_row.clear(); pairingService.clear(); + resendBatchService.clear(); for (Packet packet : packetList) { @@ -997,6 +1104,7 @@ public void updateAll() throws Exception { addNewRowWithGroupTracking(packet, packet.getId(), isResponse, groupId); } } + rebuildResendBatches(packetList); update_packet_ids.clear(); } @@ -1006,6 +1114,7 @@ public void updateAllAsync() throws Exception { colorManager.clear(); id_row.clear(); pairingService.clear(); + resendBatchService.clear(); for (Packet packet : packetList) { @@ -1034,6 +1143,7 @@ public void updateAllAsync() throws Exception { colorManager.add(id, packetColorYellow); } } + rebuildResendBatches(packetList); update_packet_ids.clear(); new Thread(new Runnable() { @@ -1214,14 +1324,9 @@ private Object[] makeRowDataFromPacket(Packet packet) throws Exception { } private boolean sortByText(String text) { - if (text.isEmpty()) { - - sorter.setRowFilter(null); - return true; - } try { - sorter.setRowFilter(FilterTextParser.parse(text)); + applyRowFilters(); return true; } catch (ParseException e) { @@ -1238,6 +1343,57 @@ private boolean sortByText(String text) { return false; } + private RowFilter createResendBatchRowFilter() { + return new RowFilter() { + + @Override + public boolean include(Entry entry) { + int packetId = (Integer) entry.getValue(COL_ID); + return !resendBatchService.shouldHidePacket(packetId); + } + }; + } + + private void applyRowFilters() throws Exception { + String text = gui_filter == null ? "" : gui_filter.getText(); + if (text.isEmpty()) { + + sorter.setRowFilter(createResendBatchRowFilter()); + return; + } + final RowFilter textFilter = FilterTextParser.parse(text); + sorter.setRowFilter(new RowFilter() { + + @Override + public boolean include(Entry entry) { + int packetId = (Integer) entry.getValue(COL_ID); + return textFilter.include(entry) && !resendBatchService.shouldHidePacket(packetId); + } + }); + } + + private void rebuildResendBatches(List packetList) { + List ids = new ArrayList(); + List batchIds = new ArrayList(); + List sourceIds = new ArrayList(); + List directions = new ArrayList(); + for (Packet packet : packetList) { + + ids.add(packet.getId()); + batchIds.add(packet.getResendBatchId()); + sourceIds.add(packet.getResendSourceId()); + directions.add(packet.getDirection()); + } + resendBatchService.rebuildFromPackets(ids, batchIds, sourceIds, directions); + try { + + applyRowFilters(); + } catch (Exception e) { + + errWithStackTrace(e); + } + } + public void resetCustomColoring() { colorManager.clear(); table.repaint(); diff --git a/src/main/java/core/packetproxy/model/OneShotPacket.java b/src/main/java/core/packetproxy/model/OneShotPacket.java index 8c199a7d..22c361e8 100644 --- a/src/main/java/core/packetproxy/model/OneShotPacket.java +++ b/src/main/java/core/packetproxy/model/OneShotPacket.java @@ -42,6 +42,8 @@ public class OneShotPacket implements PacketInfo, Cloneable { private boolean auto_modified; private int conn; private long group; + private long resend_batch_id; + private int resend_source_id; public OneShotPacket() { } @@ -175,6 +177,22 @@ public long getGroup() { return this.group; } + public long getResendBatchId() { + return this.resend_batch_id; + } + + public void setResendBatchId(long resend_batch_id) { + this.resend_batch_id = resend_batch_id; + } + + public int getResendSourceId() { + return this.resend_source_id; + } + + public void setResendSourceId(int resend_source_id) { + this.resend_source_id = resend_source_id; + } + public void encode() { } diff --git a/src/main/java/core/packetproxy/model/Packet.java b/src/main/java/core/packetproxy/model/Packet.java index f3a15a8b..bd65bd9a 100644 --- a/src/main/java/core/packetproxy/model/Packet.java +++ b/src/main/java/core/packetproxy/model/Packet.java @@ -84,6 +84,10 @@ public enum Direction { private long group; @DatabaseField private String color; + @DatabaseField + private long resend_batch_id; + @DatabaseField + private int resend_source_id; public Packet() { // ORMLite needs a no-arg constructor @@ -135,8 +139,11 @@ public int getId() { } public OneShotPacket getOneShotPacket(byte[] data) { - return new OneShotPacket(getId(), getListenPort(), getClient(), getServer(), getServerName(), getUseSSL(), data, - getEncoder(), getAlpn(), getDirection(), getConn(), getGroup()); + var oneshot = new OneShotPacket(getId(), getListenPort(), getClient(), getServer(), getServerName(), + getUseSSL(), data, getEncoder(), getAlpn(), getDirection(), getConn(), getGroup()); + oneshot.setResendBatchId(getResendBatchId()); + oneshot.setResendSourceId(getResendSourceId()); + return oneshot; } public void setModifiedData(byte[] data) { @@ -278,6 +285,22 @@ public void setColor(String color) { this.color = color; } + public long getResendBatchId() { + return this.resend_batch_id; + } + + public void setResendBatchId(long resend_batch_id) { + this.resend_batch_id = resend_batch_id; + } + + public int getResendSourceId() { + return this.resend_source_id; + } + + public void setResendSourceId(int resend_source_id) { + this.resend_source_id = resend_source_id; + } + public String getSummarizedRequest() throws Exception { Encoder encoder = EncoderManager.getInstance().createInstance(encoder_name, null); if (encoder == null) { diff --git a/src/main/java/core/packetproxy/model/Packets.java b/src/main/java/core/packetproxy/model/Packets.java index f00e96da..eccbf912 100644 --- a/src/main/java/core/packetproxy/model/Packets.java +++ b/src/main/java/core/packetproxy/model/Packets.java @@ -150,8 +150,8 @@ public Packet query(int id) throws Exception { } public List queryAllIdsAndColors() throws Exception { - return dao.queryBuilder().selectColumns("id", "color", "direction", "group", "encoder_name").orderBy("id", true) - .query(); + return dao.queryBuilder().selectColumns("id", "color", "direction", "group", "encoder_name", "resend_batch_id", + "resend_source_id").orderBy("id", true).query(); } public List queryRange(long offset, long limit) throws Exception { @@ -228,6 +228,16 @@ public void handleDatabaseMessage(DatabaseMessage message) { dao.executeRaw("ALTER TABLE `packets` ADD COLUMN color VARCHAR"); } + result = dao.queryRaw("SELECT sql FROM sqlite_master WHERE name='packets'").getFirstResult()[0]; + if (!result.contains("`resend_batch_id` BIGINT")) { + + dao.executeRaw("ALTER TABLE `packets` ADD COLUMN resend_batch_id BIGINT DEFAULT 0"); + } + result = dao.queryRaw("SELECT sql FROM sqlite_master WHERE name='packets'").getFirstResult()[0]; + if (!result.contains("`resend_source_id` INTEGER")) { + + dao.executeRaw("ALTER TABLE `packets` ADD COLUMN resend_source_id INTEGER DEFAULT 0"); + } firePropertyChange(message); break; case RECREATE : @@ -247,7 +257,7 @@ private boolean isLatestVersion() throws Exception { String result = dao.queryRaw("SELECT sql FROM sqlite_master WHERE name='packets'").getFirstResult()[0]; // Logging.log(result); return result.equals( - "CREATE TABLE `packets` (`id` INTEGER PRIMARY KEY AUTOINCREMENT , `direction` VARCHAR , `decoded_data` BLOB , `modified_data` BLOB , `sent_data` BLOB , `received_data` BLOB , `listen_port` INTEGER , `client_ip` VARCHAR , `client_port` INTEGER , `server_ip` VARCHAR , `server_name` VARCHAR , `server_port` INTEGER , `use_ssl` BOOLEAN , `content_type` VARCHAR , `encoder_name` VARCHAR , `alpn` VARCHAR , `modified` BOOLEAN , `resend` BOOLEAN , `date` BIGINT , `conn` INTEGER , `group` BIGINT , `color` VARCHAR )"); + "CREATE TABLE `packets` (`id` INTEGER PRIMARY KEY AUTOINCREMENT , `direction` VARCHAR , `decoded_data` BLOB , `modified_data` BLOB , `sent_data` BLOB , `received_data` BLOB , `listen_port` INTEGER , `client_ip` VARCHAR , `client_port` INTEGER , `server_ip` VARCHAR , `server_name` VARCHAR , `server_port` INTEGER , `use_ssl` BOOLEAN , `content_type` VARCHAR , `encoder_name` VARCHAR , `alpn` VARCHAR , `modified` BOOLEAN , `resend` BOOLEAN , `date` BIGINT , `conn` INTEGER , `group` BIGINT , `color` VARCHAR , `resend_batch_id` BIGINT , `resend_source_id` INTEGER )"); } private void RecreateTable() throws Exception { diff --git a/src/main/kotlin/core/packetproxy/gui/ResendBatchService.kt b/src/main/kotlin/core/packetproxy/gui/ResendBatchService.kt new file mode 100644 index 00000000..c0f6df6f --- /dev/null +++ b/src/main/kotlin/core/packetproxy/gui/ResendBatchService.kt @@ -0,0 +1,166 @@ +/* + * Copyright 2026 DeNA Co., Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package packetproxy.gui + +import java.util.HashMap + +/** + * 一括再送バッチの折りたたみ状態を管理するサービス。 + * + * GUIHistory から EDT 上でのみ呼び出される前提のため、同期コレクションは使用しない。 + */ +class ResendBatchService private constructor() { + private data class BatchInfo( + val sourceId: Int, + val expectedCount: Int, + var representativePacketId: Int = 0, + val requestPacketIds: MutableList = mutableListOf(), + var collapsed: Boolean = true, + ) + + private val batches: MutableMap = HashMap() + private val packetToBatchId: MutableMap = HashMap() + + companion object { + @Volatile private var instance: ResendBatchService? = null + + @JvmStatic + fun getInstance(): ResendBatchService { + return instance + ?: synchronized(this) { instance ?: ResendBatchService().also { instance = it } } + } + } + + fun clear() { + batches.clear() + packetToBatchId.clear() + } + + fun registerPendingBatch(batchId: Long, sourceId: Int, expectedCount: Int) { + if (batchId == 0L || expectedCount <= 1) { + return + } + batches.compute(batchId) { _, existing -> + existing ?: BatchInfo(sourceId = sourceId, expectedCount = expectedCount) + } + } + + fun registerPacket(batchId: Long, requestPacketId: Int, sourceId: Int) { + if (batchId == 0L) { + return + } + val batch = + batches.compute(batchId) { _, existing -> + existing ?: BatchInfo(sourceId = sourceId, expectedCount = 0) + } ?: return + + if (!batch.requestPacketIds.contains(requestPacketId)) { + batch.requestPacketIds.add(requestPacketId) + } + if (batch.representativePacketId == 0) { + batch.representativePacketId = requestPacketId + } + packetToBatchId[requestPacketId] = batchId + } + + fun getBatchIdForPacket(packetId: Int): Long { + return packetToBatchId[packetId] ?: 0L + } + + fun isRepresentativePacket(packetId: Int): Boolean { + val batchId = getBatchIdForPacket(packetId) + if (batchId == 0L) { + return false + } + return batches[batchId]?.representativePacketId == packetId + } + + fun isCollapsed(batchId: Long): Boolean { + return batches[batchId]?.collapsed ?: true + } + + fun toggleCollapsed(batchId: Long) { + val batch = batches[batchId] ?: return + batch.collapsed = !batch.collapsed + } + + fun expandAll() { + batches.values.forEach { batch -> batch.collapsed = false } + } + + fun collapseAll() { + batches.values.forEach { batch -> batch.collapsed = true } + } + + fun areAllExpanded(): Boolean { + if (batches.isEmpty()) { + return true + } + return batches.values.all { batch -> !batch.collapsed } + } + + fun shouldHidePacket(packetId: Int): Boolean { + val batchId = getBatchIdForPacket(packetId) + if (batchId == 0L) { + return false + } + val batch = batches[batchId] ?: return false + if (!batch.collapsed) { + return false + } + return batch.representativePacketId != packetId + } + + fun getSummaryPrefix(batchId: Long): String { + return if (isCollapsed(batchId)) "[+]" else "[-]" + } + + fun getSummaryLabel(batchId: Long): String { + val batch = batches[batchId] ?: return "" + val receivedCount = batch.requestPacketIds.size + val countLabel = + if (batch.expectedCount > 0) { + "$receivedCount/${batch.expectedCount}" + } else { + "$receivedCount" + } + val sourceLabel = + if (batch.sourceId > 0) { + " from #${batch.sourceId}" + } else { + "" + } + return "${getSummaryPrefix(batchId)} Resent x$countLabel$sourceLabel" + } + + fun rebuildFromPackets( + packetIds: List, + batchIds: List, + sourceIds: List, + directions: List, + ) { + clear() + for (i in packetIds.indices) { + if (batchIds[i] == 0L) { + continue + } + if (directions[i] != packetproxy.model.Packet.Direction.CLIENT) { + continue + } + registerPacket(batchIds[i], packetIds[i], sourceIds[i]) + } + } +} diff --git a/src/test/kotlin/packetproxy/gui/ResendBatchServiceTest.kt b/src/test/kotlin/packetproxy/gui/ResendBatchServiceTest.kt new file mode 100644 index 00000000..986e7b8d --- /dev/null +++ b/src/test/kotlin/packetproxy/gui/ResendBatchServiceTest.kt @@ -0,0 +1,88 @@ +package packetproxy.gui + +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import packetproxy.model.Packet + +class ResendBatchServiceTest { + private lateinit var service: ResendBatchService + + @BeforeEach + fun setUp() { + service = ResendBatchService.getInstance() + service.clear() + } + + @Test + fun registerPendingBatch_andRegisterPacket_setsRepresentative() { + service.registerPendingBatch(100L, 10, 20) + service.registerPacket(100L, 201, 10) + + assertThat(service.isRepresentativePacket(201)).isTrue() + assertThat(service.isCollapsed(100L)).isTrue() + assertThat(service.getSummaryLabel(100L)).isEqualTo("[+] Resent x1/20 from #10") + } + + @Test + fun shouldHidePacket_hidesNonRepresentativeRowsWhenCollapsed() { + service.registerPendingBatch(200L, 11, 20) + service.registerPacket(200L, 301, 11) + service.registerPacket(200L, 302, 11) + + assertThat(service.shouldHidePacket(301)).isFalse() + assertThat(service.shouldHidePacket(302)).isTrue() + } + + @Test + fun toggleCollapsed_showsAllBatchRowsWhenExpanded() { + service.registerPendingBatch(300L, 12, 20) + service.registerPacket(300L, 401, 12) + service.registerPacket(300L, 402, 12) + + service.toggleCollapsed(300L) + + assertThat(service.isCollapsed(300L)).isFalse() + assertThat(service.shouldHidePacket(402)).isFalse() + assertThat(service.getSummaryLabel(300L)).startsWith("[-]") + } + + @Test + fun expandAll_andCollapseAll_updateBatchStates() { + service.registerPendingBatch(400L, 13, 20) + service.registerPacket(400L, 501, 13) + + service.expandAll() + assertThat(service.areAllExpanded()).isTrue() + + service.collapseAll() + assertThat(service.isCollapsed(400L)).isTrue() + } + + @Test + fun clear_resetsBatchState() { + service.registerPendingBatch(500L, 14, 20) + service.registerPacket(500L, 601, 14) + + service.clear() + + assertThat(service.getBatchIdForPacket(601)).isEqualTo(0L) + assertThat(service.shouldHidePacket(601)).isFalse() + } + + @Test + fun rebuildFromPackets_restoresBatchMembership() { + service.rebuildFromPackets( + listOf(701, 702, 703), + listOf(600L, 600L, 0L), + listOf(15, 15, 0), + listOf(Packet.Direction.CLIENT, Packet.Direction.CLIENT, Packet.Direction.SERVER), + ) + + assertThat(service.getBatchIdForPacket(701)).isEqualTo(600L) + assertThat(service.getBatchIdForPacket(702)).isEqualTo(600L) + assertThat(service.getBatchIdForPacket(703)).isEqualTo(0L) + assertThat(service.isRepresentativePacket(701)).isTrue() + assertThat(service.shouldHidePacket(702)).isTrue() + } +} From d615ba7455f0ec9157261a511d85238a6f141d83 Mon Sep 17 00:00:00 2001 From: Hiroki Tokunaga Date: Thu, 16 Jul 2026 09:57:29 +0900 Subject: [PATCH 2/2] refactor(gui): use tree icons for resent batch fold UI Replace text fold markers and the expand-all button label with Tree expand/collapse icons so History stays readable. Co-authored-by: Cursor --- .../java/core/packetproxy/gui/GUIHistory.java | 33 +++++++++++++++---- .../packetproxy/gui/ResendBatchService.kt | 27 ++++++++------- .../packetproxy/gui/ResendBatchServiceTest.kt | 6 ++-- 3 files changed, 47 insertions(+), 19 deletions(-) diff --git a/src/main/java/core/packetproxy/gui/GUIHistory.java b/src/main/java/core/packetproxy/gui/GUIHistory.java index 014e6131..84c8473a 100644 --- a/src/main/java/core/packetproxy/gui/GUIHistory.java +++ b/src/main/java/core/packetproxy/gui/GUIHistory.java @@ -48,6 +48,7 @@ import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import javax.swing.BoxLayout; +import javax.swing.Icon; import javax.swing.ImageIcon; import javax.swing.JButton; import javax.swing.JComponent; @@ -64,6 +65,7 @@ import javax.swing.RowFilter; import javax.swing.SwingUtilities; import javax.swing.SwingWorker; +import javax.swing.UIManager; import javax.swing.event.ListSelectionEvent; import javax.swing.event.TableModelEvent; import javax.swing.event.TableModelListener; @@ -307,10 +309,13 @@ public void actionPerformed(ActionEvent e) { } }); - JToggleButton expandResendBatches = new JToggleButton("Expand resent batches"); - expandResendBatches.setPreferredSize(new Dimension(180, gui_filter.getMaximumSize().height)); - expandResendBatches.setMaximumSize(new Dimension(180, gui_filter.getMaximumSize().height)); - expandResendBatches.setMinimumSize(new Dimension(180, gui_filter.getMaximumSize().height)); + Icon treeCollapsedIcon = UIManager.getIcon("Tree.collapsedIcon"); + Icon treeExpandedIcon = UIManager.getIcon("Tree.expandedIcon"); + JToggleButton expandResendBatches = new JToggleButton(treeCollapsedIcon); + expandResendBatches.setPreferredSize(new Dimension(buttonWidth, gui_filter.getMaximumSize().height)); + expandResendBatches.setMaximumSize(new Dimension(buttonWidth, gui_filter.getMaximumSize().height)); + expandResendBatches.setMinimumSize(new Dimension(buttonWidth, gui_filter.getMaximumSize().height)); + expandResendBatches.setToolTipText("Expand resent batches"); expandResendBatches.addActionListener(new ActionListener() { @Override @@ -318,9 +323,13 @@ public void actionPerformed(ActionEvent e) { if (expandResendBatches.isSelected()) { resendBatchService.expandAll(); + expandResendBatches.setIcon(treeExpandedIcon); + expandResendBatches.setToolTipText("Collapse resent batches"); } else { resendBatchService.collapseAll(); + expandResendBatches.setIcon(treeCollapsedIcon); + expandResendBatches.setToolTipText("Expand resent batches"); } try { @@ -481,19 +490,31 @@ public void valueChanged(ListSelectionEvent e) { table.getColumnModel().getColumn(COL_CLIENT_REQUEST).setCellRenderer(new DefaultTableCellRenderer() { private static final long serialVersionUID = 1L; + private final Icon collapsedIcon = UIManager.getIcon("Tree.collapsedIcon"); + private final Icon expandedIcon = UIManager.getIcon("Tree.expandedIcon"); @Override public Component getTableCellRendererComponent(JTable table, Object value, boolean isSelected, boolean hasFocus, int row, int column) { Component component = super.getTableCellRendererComponent(table, value, isSelected, hasFocus, row, column); + setIcon(null); + setToolTipText(null); int packetId = (Integer) table.getValueAt(row, COL_ID); long batchId = resendBatchService.getBatchIdForPacket(packetId); if (batchId != 0 && resendBatchService.isRepresentativePacket(packetId)) { - String label = resendBatchService.getSummaryLabel(batchId); + setIcon(resendBatchService.isCollapsed(batchId) ? collapsedIcon : expandedIcon); + setToolTipText(resendBatchService.getSummaryTooltip(batchId)); String original = value == null ? "" : value.toString(); - setText(label + " " + original); + String countLabel = resendBatchService.getCompactCountLabel(batchId); + if (countLabel.isEmpty()) { + + setText(original); + } else { + + setText(countLabel + " " + original); + } } return component; } diff --git a/src/main/kotlin/core/packetproxy/gui/ResendBatchService.kt b/src/main/kotlin/core/packetproxy/gui/ResendBatchService.kt index c0f6df6f..b2bdea67 100644 --- a/src/main/kotlin/core/packetproxy/gui/ResendBatchService.kt +++ b/src/main/kotlin/core/packetproxy/gui/ResendBatchService.kt @@ -124,26 +124,31 @@ class ResendBatchService private constructor() { return batch.representativePacketId != packetId } - fun getSummaryPrefix(batchId: Long): String { - return if (isCollapsed(batchId)) "[+]" else "[-]" + fun getCompactCountLabel(batchId: Long): String { + val countLabel = getCountLabel(batchId) ?: return "" + return "×$countLabel" } - fun getSummaryLabel(batchId: Long): String { + fun getSummaryTooltip(batchId: Long): String { val batch = batches[batchId] ?: return "" - val receivedCount = batch.requestPacketIds.size - val countLabel = - if (batch.expectedCount > 0) { - "$receivedCount/${batch.expectedCount}" - } else { - "$receivedCount" - } + val countLabel = getCountLabel(batchId) ?: return "" val sourceLabel = if (batch.sourceId > 0) { " from #${batch.sourceId}" } else { "" } - return "${getSummaryPrefix(batchId)} Resent x$countLabel$sourceLabel" + return "Resent x$countLabel$sourceLabel" + } + + private fun getCountLabel(batchId: Long): String? { + val batch = batches[batchId] ?: return null + val receivedCount = batch.requestPacketIds.size + return if (batch.expectedCount > 0) { + "$receivedCount/${batch.expectedCount}" + } else { + "$receivedCount" + } } fun rebuildFromPackets( diff --git a/src/test/kotlin/packetproxy/gui/ResendBatchServiceTest.kt b/src/test/kotlin/packetproxy/gui/ResendBatchServiceTest.kt index 986e7b8d..c5728545 100644 --- a/src/test/kotlin/packetproxy/gui/ResendBatchServiceTest.kt +++ b/src/test/kotlin/packetproxy/gui/ResendBatchServiceTest.kt @@ -21,7 +21,8 @@ class ResendBatchServiceTest { assertThat(service.isRepresentativePacket(201)).isTrue() assertThat(service.isCollapsed(100L)).isTrue() - assertThat(service.getSummaryLabel(100L)).isEqualTo("[+] Resent x1/20 from #10") + assertThat(service.getCompactCountLabel(100L)).isEqualTo("×1/20") + assertThat(service.getSummaryTooltip(100L)).isEqualTo("Resent x1/20 from #10") } @Test @@ -44,7 +45,8 @@ class ResendBatchServiceTest { assertThat(service.isCollapsed(300L)).isFalse() assertThat(service.shouldHidePacket(402)).isFalse() - assertThat(service.getSummaryLabel(300L)).startsWith("[-]") + assertThat(service.getCompactCountLabel(300L)).isEqualTo("×2/20") + assertThat(service.getSummaryTooltip(300L)).isEqualTo("Resent x2/20 from #12") } @Test