diff --git a/src/main/java/core/packetproxy/DuplexFactory.java b/src/main/java/core/packetproxy/DuplexFactory.java index bb2c816e..17dc0b80 100644 --- a/src/main/java/core/packetproxy/DuplexFactory.java +++ b/src/main/java/core/packetproxy/DuplexFactory.java @@ -86,17 +86,14 @@ public int onServerPacketReceived(byte[] data) throws Exception { @Override public byte[] onClientChunkReceived(byte[] data) throws Exception { + long initialGroupId = UniqueID.getInstance().createId(); client_packet = new Packet(0, client_addr, server_addr, server_endpoint.getName(), use_ssl, - encoder_name, ALPN, Packet.Direction.CLIENT, duplex.hashCode(), - UniqueID.getInstance().createId()); - packets.update(client_packet); + encoder_name, ALPN, Packet.Direction.CLIENT, duplex.hashCode(), initialGroupId); client_packet.setReceivedData(data); - if (data.length < SKIP_LENGTH) { - - packets.update(client_packet); - } byte[] decoded_data = encoder.decodeClientRequest(client_packet); client_packet.setDecodedData(decoded_data); + // groupIdはencoder.setGroupId()で変更される可能性があるため、 + // GUIHistoryへの通知(packets.update)はgroupId確定後に行う encoder.setGroupId(client_packet); /* 実行するのはsetDecodedDataのあと */ if (data.length < SKIP_LENGTH) { @@ -408,6 +405,7 @@ public byte[] onClientChunkSend(byte[] data) throws Exception { oneshot.getUseSSL(), oneshot.getEncoder(), oneshot.getAlpn(), Packet.Direction.CLIENT, duplex.hashCode(), UniqueID.getInstance().createId()); client_packet.setModified(); + client_packet.setReceivedData(data); client_packet.setDecodedData(data); client_packet.setModifiedData(data); if (data.length < SKIP_LENGTH) { diff --git a/src/main/java/core/packetproxy/encode/EncodeHTTPBase.java b/src/main/java/core/packetproxy/encode/EncodeHTTPBase.java index d012d2f9..fae4522a 100644 --- a/src/main/java/core/packetproxy/encode/EncodeHTTPBase.java +++ b/src/main/java/core/packetproxy/encode/EncodeHTTPBase.java @@ -313,6 +313,21 @@ public String getContentType(byte[] input_data) throws Exception { return http.getFirstHeader("Content-Type"); } + /** + * レスポンスからContent-Typeを取得し、存在しない場合はリクエストのContent-Typeをフォールバックとして使用する。 + * gRPC等のプロトコルでは、レスポンスにContent-Typeが含まれないことがあるため、 + * リクエストのContent-Typeを使用することで、History一覧のType列に適切な値を表示する。 + */ + @Override + public String getContentType(Packet client_packet, Packet server_packet) throws Exception { + String contentType = getContentType(server_packet.getDecodedData()); + if (contentType.isEmpty() && client_packet != null && client_packet.getDecodedData().length > 0) { + + contentType = getContentType(client_packet.getDecodedData()); + } + return contentType; + } + @Override public String getSummarizedResponse(Packet packet) { String summary = ""; diff --git a/src/main/java/core/packetproxy/gui/CloseButtonTabbedPane.java b/src/main/java/core/packetproxy/gui/CloseButtonTabbedPane.java index b0a2c58e..fc709fb8 100644 --- a/src/main/java/core/packetproxy/gui/CloseButtonTabbedPane.java +++ b/src/main/java/core/packetproxy/gui/CloseButtonTabbedPane.java @@ -70,6 +70,7 @@ public void mouseExited(MouseEvent e) { button.setIcon(icon); } }); + main_panel.add(javax.swing.Box.createHorizontalStrut(8)); main_panel.add(label); // 数字とバツボタンの間に余白を追加 main_panel.add(Box.createHorizontalStrut(7)); diff --git a/src/main/java/core/packetproxy/gui/GUIData.java b/src/main/java/core/packetproxy/gui/GUIData.java index 00cb1424..3c3cd8fa 100644 --- a/src/main/java/core/packetproxy/gui/GUIData.java +++ b/src/main/java/core/packetproxy/gui/GUIData.java @@ -20,6 +20,8 @@ import java.awt.Color; import java.awt.Dimension; +import java.awt.FlowLayout; +import java.awt.Rectangle; import java.awt.Toolkit; import java.awt.datatransfer.Clipboard; import java.awt.datatransfer.StringSelection; @@ -29,17 +31,19 @@ import java.awt.event.ComponentEvent; import java.awt.event.MouseAdapter; import java.awt.event.MouseEvent; +import java.util.function.Supplier; import javax.swing.BoxLayout; import javax.swing.JButton; import javax.swing.JComboBox; import javax.swing.JComponent; import javax.swing.JFrame; import javax.swing.JLabel; +import javax.swing.JOptionPane; import javax.swing.JPanel; +import javax.swing.JScrollBar; import javax.swing.JScrollPane; import javax.swing.ScrollPaneConstants; -import javax.swing.UIManager; -import javax.swing.border.EmptyBorder; +import javax.swing.Scrollable; import javax.swing.border.LineBorder; import packetproxy.controller.ResendController; import packetproxy.controller.SinglePacketAttackController; @@ -72,12 +76,33 @@ public class GUIData { int origIndex; Color origColor; private JComboBox charSetCombo = new JComboBox(charSetUtility.getAvailableCharSetList().toArray()); + private Supplier dataProvider = null; + private Supplier bodyDataProvider = null; + private Supplier responseDataProvider = null; public GUIData(JFrame owner) { this.owner = owner; } + public void setDataProvider(Supplier provider) { + this.dataProvider = provider; + } + + public void setBodyDataProvider(Supplier provider) { + this.bodyDataProvider = provider; + } + + public void setResponseDataProvider(Supplier provider) { + this.responseDataProvider = provider; + } + public JComponent createPanel() throws Exception { + createTabsPanel(); + main_panel.add(createButtonPanel()); + return main_panel; + } + + public JComponent createTabsPanel() throws Exception { main_panel = new JPanel(); main_panel.setLayout(new BoxLayout(main_panel, BoxLayout.Y_AXIS)); @@ -85,6 +110,86 @@ public JComponent createPanel() throws Exception { main_panel.add(tabs.getTabPanel()); + initButtons(); + return main_panel; + } + + public JComponent createButtonPanel() { + JPanel diff_panel = new JPanel(); + diff_panel.add(diff_orig_button); + diff_panel.add(diff_button); + diff_panel.add(stop_diff_button); + diff_panel.setBorder(new LineBorder(Color.black, 1, true)); + diff_panel.setLayout(new BoxLayout(diff_panel, BoxLayout.LINE_AXIS)); + + JPanel button_panel = new JPanel(); + button_panel.add(charSetCombo); + button_panel.add(copy_url_body_button); + button_panel.add(copy_body_button); + button_panel.add(copy_url_button); + button_panel.add(resend_button); + button_panel.add(resend_multiple_button); + button_panel.add(attack_button); + button_panel.add(send_to_resender_button); + button_panel.add(new JLabel(" diff: ")); + button_panel.add(diff_panel); + button_panel.setLayout(new BoxLayout(button_panel, BoxLayout.LINE_AXIS)); + + ScrollableCenteredPanel centered_panel = new ScrollableCenteredPanel(); + centered_panel.add(button_panel); + return createButtonScrollPane(centered_panel); + } + + /** + * ビューポートが十分に広い場合はボタンを中央寄せし、 狭い場合は横スクロールバーを表示するためのパネル。 + * getScrollableTracksViewportWidth() でビューポート幅に追従するかを切り替える。 + */ + private static class ScrollableCenteredPanel extends JPanel implements Scrollable { + + ScrollableCenteredPanel() { + super(new FlowLayout(FlowLayout.CENTER)); + } + + @Override + public Dimension getPreferredScrollableViewportSize() { + return getPreferredSize(); + } + + @Override + public int getScrollableUnitIncrement(Rectangle visibleRect, int orientation, int direction) { + return 20; + } + + @Override + public int getScrollableBlockIncrement(Rectangle visibleRect, int orientation, int direction) { + return 100; + } + + @Override + public boolean getScrollableTracksViewportWidth() { + return getParent() != null && getParent().getWidth() >= getPreferredSize().width; + } + + @Override + public boolean getScrollableTracksViewportHeight() { + return true; + } + } + + private byte[] getActiveData() { + if (dataProvider != null) { + return dataProvider.get(); + } + int index = tabs.getSelectedIndex(); + if (index == 0) { + return tabs.getRaw().getData(); + } else if (index == 1) { + return tabs.getBinary().getData(); + } + return null; + } + + private void initButtons() throws Exception { copy_url_body_button = new JButton("copy Method+URL+Body"); copy_url_body_button.addActionListener(new ActionListener() { @@ -92,9 +197,12 @@ public JComponent createPanel() throws Exception { public void actionPerformed(ActionEvent actionEvent) { try { + byte[] data = getActiveData(); + if (data == null || data.length == 0) + return; int id = GUIHistory.getInstance().getSelectedPacketId(); Packet packet = Packets.getInstance().query(id); - Http http = Http.create(tabs.getRaw().getData()); + Http http = Http.create(data); String copyData = http.getMethod() + "\t" + http.getURL(packet.getServerPort(), packet.getUseSSL()) + "\t" + new String(http.getBody(), "UTF-8"); Clipboard clipboard = Toolkit.getDefaultToolkit().getSystemClipboard(); @@ -114,10 +222,11 @@ public void actionPerformed(ActionEvent actionEvent) { public void actionPerformed(ActionEvent e) { try { - int id = GUIHistory.getInstance().getSelectedPacketId(); - Packet packet = Packets.getInstance().query(id); - Http http = Http.create(tabs.getRaw().getData()); - String body = new String(http.getBody(), "UTF-8"); // http.getURL(packet.getServerPort()); + byte[] data = resolveDataForCopyBody(); + if (data == null || data.length == 0) + return; + Http http = Http.create(data); + String body = new String(http.getBody(), "UTF-8"); Clipboard clipboard = Toolkit.getDefaultToolkit().getSystemClipboard(); StringSelection selection = new StringSelection(body); clipboard.setContents(selection, selection); @@ -136,9 +245,12 @@ public void actionPerformed(ActionEvent e) { public void actionPerformed(ActionEvent e) { try { + byte[] data = getActiveData(); + if (data == null || data.length == 0) + return; int id = GUIHistory.getInstance().getSelectedPacketId(); Packet packet = Packets.getInstance().query(id); - Http http = Http.create(tabs.getRaw().getData()); + Http http = Http.create(data); String url = http.getURL(packet.getServerPort(), packet.getUseSSL()); Clipboard clipboard = Toolkit.getDefaultToolkit().getSystemClipboard(); StringSelection selection = new StringSelection(url); @@ -159,15 +271,8 @@ public void actionPerformed(ActionEvent e) { public void actionPerformed(ActionEvent e) { try { - byte[] data = null; - int index = tabs.getSelectedIndex(); - if (index == 0) { - - data = tabs.getRaw().getData(); - } else if (index == 1) { - data = tabs.getBinary().getData(); - } - if (data != null) { + byte[] data = getActiveData(); + if (data != null && data.length > 0) { int id = GUIHistory.getInstance().getSelectedPacketId(); Packet packet = Packets.getInstance().query(id); @@ -192,15 +297,8 @@ public void actionPerformed(ActionEvent e) { public void actionPerformed(ActionEvent e) { try { - byte[] data = null; - int index = tabs.getSelectedIndex(); - if (index == 0) { - - data = tabs.getRaw().getData(); - } else if (index == 1) { - data = tabs.getBinary().getData(); - } - if (data != null) { + byte[] data = getActiveData(); + if (data != null && data.length > 0) { int id = GUIHistory.getInstance().getSelectedPacketId(); Packet packet = Packets.getInstance().query(id); @@ -222,14 +320,8 @@ public void actionPerformed(ActionEvent e) { @Override public void actionPerformed(ActionEvent e) { try { - byte[] data = null; - int index = tabs.getSelectedIndex(); - if (index == 0) { - data = tabs.getRaw().getData(); - } else if (index == 1) { - data = tabs.getBinary().getData(); - } - if (data != null) { + byte[] data = getActiveData(); + if (data != null && data.length > 0) { int id = GUIHistory.getInstance().getSelectedPacketId(); Packet packet = Packets.getInstance().query(id); new SinglePacketAttackController(packet.getOneShotPacket(data)).attack(20); @@ -251,15 +343,8 @@ public void actionPerformed(ActionEvent e) { public void actionPerformed(ActionEvent actionEvent) { try { - byte[] data = null; - int index = tabs.getSelectedIndex(); - if (index == 0) { - - data = tabs.getRaw().getData(); - } else if (index == 1) { - data = tabs.getBinary().getData(); - } - if (data != null) { + byte[] data = getActiveData(); + if (data != null && data.length > 0) { int id = GUIHistory.getInstance().getSelectedPacketId(); Packet packet = Packets.getInstance().query(id); @@ -313,9 +398,12 @@ public void actionPerformed(ActionEvent e) { public void actionPerformed(ActionEvent e) { try { - Diff.getInstance().markAsTarget(tabs.getRaw().getData()); - DiffBinary.getInstance().markAsTarget(tabs.getBinary().getData()); - DiffJson.getInstance().markAsTarget(tabs.getJson().getData()); + byte[] data = resolveDataForDiff(); + if (data == null) + return; + Diff.getInstance().markAsTarget(data); + DiffBinary.getInstance().markAsTarget(data); + DiffJson.getInstance().markAsTarget(data); GUIDiffDialogParent dlg = new GUIDiffDialogParent(owner); dlg.showDialog(); } catch (Exception e1) { @@ -333,6 +421,9 @@ public void actionPerformed(ActionEvent e) { public void actionPerformed(ActionEvent e) { try { + byte[] data = resolveDataForDiff(); + if (data == null) + return; if (isDiff) { Diff.getInstance().clearAsOriginal(); @@ -348,9 +439,9 @@ public void actionPerformed(ActionEvent e) { } } isDiff = true; - Diff.getInstance().markAsOriginal(tabs.getRaw().getData()); - DiffBinary.getInstance().markAsOriginal(tabs.getBinary().getData()); - DiffJson.getInstance().markAsOriginal(tabs.getJson().getData()); + Diff.getInstance().markAsOriginal(data); + DiffBinary.getInstance().markAsOriginal(data); + DiffJson.getInstance().markAsOriginal(data); if (GUIHistory.getInstance().containsColor()) { origColor = GUIHistory.getInstance().getColor(); @@ -391,30 +482,6 @@ public void mousePressed(MouseEvent e) { }); charSetCombo.setSelectedItem(charSetUtility.getInstance().getCharSetForGUIComponent()); - - JPanel diff_panel = new JPanel(); - diff_panel.add(diff_orig_button); - diff_panel.add(diff_button); - diff_panel.add(stop_diff_button); - diff_panel.setBorder(new LineBorder(Color.black, 1, true)); - diff_panel.setLayout(new BoxLayout(diff_panel, BoxLayout.LINE_AXIS)); - - JPanel button_panel = new JPanel(); - button_panel.add(charSetCombo); - button_panel.add(copy_url_body_button); - button_panel.add(copy_body_button); - button_panel.add(copy_url_button); - button_panel.add(resend_button); - button_panel.add(resend_multiple_button); - button_panel.add(attack_button); - button_panel.add(send_to_resender_button); - button_panel.add(new JLabel(" diff: ")); - button_panel.add(diff_panel); - button_panel.setLayout(new BoxLayout(button_panel, BoxLayout.LINE_AXIS)); - - var button_scroll_pane = createButtonScrollPane(button_panel); - main_panel.add(button_scroll_pane); - return main_panel; } public void updateCharSetCombo() { @@ -431,12 +498,20 @@ public void updateCharSetCombo() { } static JScrollPane createButtonScrollPane(JPanel buttonPanel) { - var scrollBarThickness = UIManager.getInt("ScrollBar.width"); - if (scrollBarThickness > 0) { - buttonPanel.setBorder(new EmptyBorder(0, 0, scrollBarThickness, 0)); - } - var scrollPane = new JScrollPane(buttonPanel, ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER, - ScrollPaneConstants.HORIZONTAL_SCROLLBAR_AS_NEEDED); + JScrollPane scrollPane = new JScrollPane(buttonPanel, ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER, + ScrollPaneConstants.HORIZONTAL_SCROLLBAR_AS_NEEDED) { + @Override + public Dimension getPreferredSize() { + Dimension d = super.getPreferredSize(); + JScrollBar hBar = getHorizontalScrollBar(); + // スクロールバーが表示されている場合のみ高さを加算することで、 + // 非表示時の余分なスペースを排除しつつ、表示時はレイアウトを押し下げて領域を確保する + if (hBar != null && hBar.isVisible()) { + d.height += hBar.getPreferredSize().height; + } + return d; + } + }; scrollPane.setBorder(null); scrollPane.getViewport().addComponentListener(new ComponentAdapter() { @Override @@ -469,4 +544,44 @@ public byte[] getData() { } return new byte[]{}; } + + /** + * マージ行(Request+Response両方ある行)の場合はどちらのBodyをコピーするか + * ユーザに選択させる。単一パケット行の場合はRequestデータをそのまま返す。 ダイアログでキャンセルされた場合は null を返す。 + */ + private byte[] resolveDataForCopyBody() throws Exception { + if (!GUIHistory.getInstance().isSelectedRowMerged()) { + return bodyDataProvider != null ? bodyDataProvider.get() : getActiveData(); + } + // macOS の JOptionPane はボタンを右から左に描画するため、 + // 視覚的に左から「Request | Response」の順にするには逆順で定義する。 + String[] options = {"Response", "Request"}; + int choice = JOptionPane.showOptionDialog(owner, "Which body do you want to copy?", "Select Copy Target", + JOptionPane.DEFAULT_OPTION, JOptionPane.QUESTION_MESSAGE, null, options, null); + if (choice == JOptionPane.CLOSED_OPTION) + return null; + if (choice == 0) + return responseDataProvider != null ? responseDataProvider.get() : null; + return bodyDataProvider != null ? bodyDataProvider.get() : getActiveData(); + } + + /** + * マージ行(Request+Response両方ある行)の場合はどちらのデータをDiffに使うか + * ユーザに選択させる。単一パケット行の場合はRequestデータをそのまま返す。 ダイアログでキャンセルされた場合は null を返す。 + */ + private byte[] resolveDataForDiff() throws Exception { + if (!GUIHistory.getInstance().isSelectedRowMerged()) { + return tabs.getRaw().getData(); + } + // macOS の JOptionPane はボタンを右から左に描画するため、 + // 視覚的に左から「Request | Response」の順にするには逆順で定義する。 + String[] options = {"Response", "Request"}; + int choice = JOptionPane.showOptionDialog(owner, "Which data do you want to use for Diff?", + "Select Diff Target", JOptionPane.DEFAULT_OPTION, JOptionPane.QUESTION_MESSAGE, null, options, null); + if (choice == JOptionPane.CLOSED_OPTION) + return null; + if (choice == 0) + return GUIPacket.getInstance().getResponsePacket().getReceivedData(); + return tabs.getRaw().getData(); + } } diff --git a/src/main/java/core/packetproxy/gui/GUIHistory.java b/src/main/java/core/packetproxy/gui/GUIHistory.java index c792960c..a1d13131 100644 --- a/src/main/java/core/packetproxy/gui/GUIHistory.java +++ b/src/main/java/core/packetproxy/gui/GUIHistory.java @@ -83,6 +83,12 @@ public class GUIHistory implements PropertyChangeListener { + private static final int COL_ID = 0; + private static final int COL_SERVER_RESPONSE = 2; + private static final int COL_LENGTH = 3; + private static final int COL_MODIFIED = 10; + private static final int COL_CONTENT_TYPE = 11; + private static GUIHistory instance; private static JFrame owner; @@ -129,6 +135,7 @@ public static GUIHistory restoreLastInstance(JFrame frame) throws Exception { private boolean dialogOnce = false; private GUIHistoryAutoScroll autoScroll; private JPopupMenu menu; + private PacketPairingService pairingService; private Color packetColorGreen = new Color(0x7f, 0xff, 0xd4); private Color packetColorBrown = new Color(0xd2, 0x69, 0x1e); @@ -139,6 +146,7 @@ private GUIHistory(boolean restore) throws Exception { packets.addPropertyChangeListener(this); ResenderPackets.getInstance().initTable(restore); Filters.getInstance().addPropertyChangeListener(this); + pairingService = new PacketPairingService(); gui_packet = GUIPacket.getInstance(); colorManager = new TableCustomColorManager(); preferredPosition = 0; @@ -157,7 +165,7 @@ public void filter() { for (int i = 0; i < table.getRowCount(); i++) { - int id = (int) table.getValueAt(i, 0); + int id = (int) table.getValueAt(i, COL_ID); if (id == preferredPosition) { table.changeSelection(i, 0, false, false); @@ -368,7 +376,7 @@ public Component prepareRenderer(TableCellRenderer tcr, int row, int column) { selected = (table.getSelectedRow() == row); first_selected = selected; } - int packetId = (int) table.getValueAt(row, 0); + int packetId = (int) table.getValueAt(row, COL_ID); boolean modified = (boolean) table.getValueAt(row, table.getColumnModel().getColumnIndex("Modified")); boolean resend = (boolean) table.getValueAt(row, table.getColumnModel().getColumnIndex("Resend")); @@ -543,7 +551,7 @@ public void componentResized(ComponentEvent e) { Thread.sleep(100); packet = packets.query(packetId); } - gui_packet.setPacket(packet); + resolveAndShowPacket(packet, false); } } catch (Exception e1) { @@ -589,7 +597,7 @@ public int getSelectedPacketId() { int idx = table.getSelectedRow(); if (0 <= idx && idx < table.getRowCount()) { - return (Integer) table.getValueAt(idx, 0); + return (Integer) table.getValueAt(idx, COL_ID); } else { return 0; @@ -683,14 +691,189 @@ private void handleBooleanPacketValue(boolean value) { } private void handleIntegerPacketValue(int value) throws Exception { - if (value < 0) { + if (value >= 0) { - int positiveValue = value * -1; - tableModel.addRow(makeRowDataFromPacket(packets.query(positiveValue))); - id_row.put(positiveValue, tableModel.getRowCount() - 1); + updateRequestOne(value); + return; + } + + int packetId = value * -1; + Packet packet = packets.query(packetId); + long groupId = packet.getGroup(); + boolean isResponse = packet.getDirection() == Packet.Direction.SERVER; + int packetCount = countAndTrackPacket(packet); + if (shouldUnmergeExisting(packetCount, groupId)) { + unmergeExistingPairing(groupId); + } + if (shouldMergeResponse(groupId, isResponse)) { + mergeResponseIntoRequestRow(packet, groupId, packetId, true); } else { + addNewRowWithGroupTracking(packet, packetId, isResponse, groupId); + } + } - updateRequestOne(value); + private int countAndTrackPacket(Packet packet) { + long groupId = packet.getGroup(); + if (groupId == 0) { + return 0; + } + int packetCount = pairingService.incrementGroupPacketCount(groupId); + if (packet.getDirection() == Packet.Direction.CLIENT) { + pairingService.incrementGroupClientPacketCount(groupId); + } + return packetCount; + } + + private boolean shouldMergeResponse(long groupId, boolean isResponse) { + if (!isResponse || groupId == 0) { + return false; + } + return pairingService.containsGroup(groupId) && !pairingService.hasResponse(groupId) + && pairingService.isGroupMergeable(groupId); + } + + private boolean shouldUnmergeExisting(int packetCount, long groupId) { + return packetCount == 3 && pairingService.containsGroup(groupId) && pairingService.hasResponse(groupId); + } + + private byte[] getDisplayData(Packet packet) { + if (packet.getDecodedData().length > 0) { + return packet.getDecodedData(); + } + return packet.getModifiedData(); + } + + private String resolveContentType(Packet requestPacket, Packet responsePacket) { + String contentType = requestPacket.getContentType(); + if (contentType == null || contentType.isEmpty()) { + contentType = responsePacket.getContentType(); + } + return contentType; + } + + private void mergeResponseIntoRequestRow(Packet responsePacket, long groupId, int responsePacketId, + boolean refreshSelection) throws Exception { + Integer rowIndex = pairingService.getRowForGroup(groupId); + if (rowIndex == null) { + return; + } + int requestPacketId = (Integer) tableModel.getValueAt(rowIndex, COL_ID); + tableModel.setValueAt(responsePacket.getSummarizedResponse(), rowIndex, COL_SERVER_RESPONSE); + int currentLength = (Integer) tableModel.getValueAt(rowIndex, COL_LENGTH); + tableModel.setValueAt(currentLength + getDisplayData(responsePacket).length, rowIndex, COL_LENGTH); + Packet requestPacket = packets.query(requestPacketId); + tableModel.setValueAt(resolveContentType(requestPacket, responsePacket), rowIndex, COL_CONTENT_TYPE); + boolean currentModified = (boolean) tableModel.getValueAt(rowIndex, COL_MODIFIED); + tableModel.setValueAt(currentModified || responsePacket.getModified(), rowIndex, COL_MODIFIED); + pairingService.markGroupHasResponse(groupId); + pairingService.registerPairing(responsePacketId, requestPacketId); + id_row.put(responsePacketId, rowIndex); + if (refreshSelection && requestPacketId == getSelectedPacketId()) { + resolveAndShowPacket(requestPacket, true); + } + } + + private void mergeResponseMappingOnly(int responsePacketId, long groupId) { + Integer rowIndex = pairingService.getRowForGroup(groupId); + if (rowIndex == null) { + return; + } + int requestPacketId = (Integer) tableModel.getValueAt(rowIndex, COL_ID); + pairingService.markGroupHasResponse(groupId); + pairingService.registerPairing(responsePacketId, requestPacketId); + id_row.put(responsePacketId, rowIndex); + } + + private void addNewRowWithGroupTracking(Packet packet, int packetId, boolean isResponse, long groupId) + throws Exception { + tableModel.addRow(makeRowDataFromPacket(packet)); + int rowIndex = tableModel.getRowCount() - 1; + id_row.put(packetId, rowIndex); + if (!isResponse && groupId != 0) { + pairingService.registerGroupRow(groupId, rowIndex); + } + } + + private void addNewAsyncPlaceholderRowWithGroupTracking(int packetId, boolean isResponse, long groupId) { + tableModel.addRow(new Object[]{packetId, "Loading...", "Loading...", 0, "Loading...", "", "Loading...", "", + "00:00:00 1900/01/01 Z", false, false, "", "", "", (long) -1}); + int rowIndex = tableModel.getRowCount() - 1; + id_row.put(packetId, rowIndex); + if (!isResponse && groupId != 0) { + pairingService.registerGroupRow(groupId, rowIndex); + } + } + + private void trackRequestGroupIfNeeded(int packetId, long groupId) { + if (groupId == 0) { + return; + } + Integer rowIndex = id_row.get(packetId); + if (rowIndex == null) { + return; + } + pairingService.ensureGroupTracked(groupId, rowIndex); + } + + private void resolveAndShowPacket(Packet packet, boolean forceRefresh) throws Exception { + int responsePacketId = pairingService.getResponsePacketIdForRequest(packet.getId()); + if (responsePacketId != -1) { + Packet responsePacket = packets.query(responsePacketId); + gui_packet.setPackets(packet, responsePacket, forceRefresh); + return; + } + if (pairingService.containsResponsePairing(packet.getId())) { + int requestPacketId = pairingService.getRequestIdForResponse(packet.getId()); + Packet requestPacket = packets.query(requestPacketId); + gui_packet.setPackets(requestPacket, packet, forceRefresh); + return; + } + long groupId = packet.getGroup(); + boolean isStreaming = groupId != 0 && pairingService.isGroupStreaming(groupId); + if (isStreaming || packet.getDirection() == Packet.Direction.SERVER) { + gui_packet.setSinglePacket(packet, forceRefresh); + return; + } + gui_packet.setPackets(packet, null, forceRefresh); + } + + /** + * 既存のマージを解除する(ストリーミング通信で3つ目以降のパケットが来た場合) + * + * @param groupId + * グループID + */ + private void unmergeExistingPairing(long groupId) throws Exception { + Integer rowIndex = pairingService.getRowForGroup(groupId); + if (rowIndex == null) { + return; + } + + int requestPacketId = (Integer) tableModel.getValueAt(rowIndex, COL_ID); + + // 以前マージされていたレスポンスパケットIDを取得してペアリングを解除 + int responsePacketId = pairingService.unregisterPairingByRequestId(requestPacketId); + pairingService.unmergeGroup(groupId); + + if (responsePacketId == -1) { + return; + } + + // リクエスト行を元に戻す(Server Response列をクリア、Lengthを再計算) + Packet requestPacket = packets.query(requestPacketId); + byte[] requestData = getDisplayData(requestPacket); + tableModel.setValueAt("", rowIndex, COL_SERVER_RESPONSE); // Server Response列をクリア + tableModel.setValueAt(requestData.length, rowIndex, COL_LENGTH); // Length列を再計算 + + // 以前マージされていたレスポンスパケット用の新しい行を追加 + Packet responsePacket = packets.query(responsePacketId); + tableModel.addRow(makeRowDataFromPacket(responsePacket)); + int newRowIndex = tableModel.getRowCount() - 1; + id_row.put(responsePacketId, newRowIndex); + + // 選択中のパケットだった場合は詳細表示を更新 + if (requestPacketId == getSelectedPacketId()) { + resolveAndShowPacket(requestPacket, true); } } @@ -780,7 +963,7 @@ protected void done() { Packet packet = get(); if (packet != null) { - gui_packet.setPacket(packet); + resolveAndShowPacket(packet, false); } // sortByText(gui_filter.getText()); } catch (Exception e) { @@ -797,10 +980,22 @@ protected void done() { public void updateAll() throws Exception { List packetList = packets.queryAll(); tableModel.setRowCount(0); + id_row.clear(); + pairingService.clear(); + for (Packet packet : packetList) { - tableModel.addRow(makeRowDataFromPacket(packet)); - id_row.put(packet.getId(), tableModel.getRowCount() - 1); + long groupId = packet.getGroup(); + boolean isResponse = packet.getDirection() == Packet.Direction.SERVER; + int packetCount = countAndTrackPacket(packet); + if (shouldUnmergeExisting(packetCount, groupId)) { + unmergeExistingPairing(groupId); + } + if (shouldMergeResponse(groupId, isResponse)) { + mergeResponseIntoRequestRow(packet, groupId, packet.getId(), false); + } else { + addNewRowWithGroupTracking(packet, packet.getId(), isResponse, groupId); + } } update_packet_ids.clear(); } @@ -809,14 +1004,24 @@ public void updateAllAsync() throws Exception { List packetList = packets.queryAllIdsAndColors(); tableModel.setRowCount(0); colorManager.clear(); + id_row.clear(); + pairingService.clear(); + for (Packet packet : packetList) { int id = packet.getId(); String color = packet.getColor(); - - tableModel.addRow(new Object[]{packet.getId(), "Loading...", "Loading...", 0, "Loading...", "", - "Loading...", "", "00:00:00 1900/01/01 Z", false, false, "", "", "", (long) -1}); - id_row.put(id, tableModel.getRowCount() - 1); + long groupId = packet.getGroup(); + boolean isResponse = packet.getDirection() == Packet.Direction.SERVER; + int packetCount = countAndTrackPacket(packet); + if (shouldUnmergeExisting(packetCount, groupId)) { + unmergeExistingPairingInAsyncModel(groupId); + } + if (shouldMergeResponse(groupId, isResponse)) { + mergeResponseMappingOnly(id, groupId); + } else { + addNewAsyncPlaceholderRowWithGroupTracking(id, isResponse, groupId); + } if (Objects.equals(color, "green")) { @@ -893,16 +1098,89 @@ public Runnable set(GUIHistory history, int count) { }.set(this, packetList.size())).start(); } + /** + * updateAllAsync用のマージ解除処理。 + * + *

+ * updateAllAsyncはIDと色のみを先に読み込み、実データは後続のupdateOneで補完するため、 + * ここではペアリング情報の解除と、マージされていたレスポンス用のプレースホルダ行追加のみを行う。 + */ + private void unmergeExistingPairingInAsyncModel(long groupId) { + Integer rowIndex = pairingService.getRowForGroup(groupId); + if (rowIndex == null) { + return; + } + + int requestPacketId = (Integer) tableModel.getValueAt(rowIndex, COL_ID); + + int responsePacketId = pairingService.unregisterPairingByRequestId(requestPacketId); + pairingService.unmergeGroup(groupId); + + if (responsePacketId == -1) { + return; + } + + // 以前マージされていたレスポンスパケット用のプレースホルダ行を追加(実データはupdateOneで更新される) + tableModel.addRow(new Object[]{responsePacketId, "Loading...", "Loading...", 0, "Loading...", "", "Loading...", + "", "00:00:00 1900/01/01 Z", false, false, "", "", "", (long) -1}); + int newRowIndex = tableModel.getRowCount() - 1; + id_row.put(responsePacketId, newRowIndex); + } + private void updateOne(Packet packet) throws Exception { if (id_row == null || packet == null) { return; } - Integer row_index = id_row.getOrDefault(packet.getId(), tableModel.getRowCount() - 1); + + int packetId = packet.getId(); + boolean isResponse = packet.getDirection() == Packet.Direction.SERVER; + long groupId = packet.getGroup(); + + if (!isResponse && groupId != 0) { + trackRequestGroupIfNeeded(packetId, groupId); + } + + // マージされたレスポンスパケットの場合、リクエスト行を更新 + if (isResponse && pairingService.containsResponsePairing(packetId)) { + + Integer row_index = id_row.get(packetId); + if (row_index != null) { + + // Server Response列を更新 + tableModel.setValueAt(packet.getSummarizedResponse(), row_index, COL_SERVER_RESPONSE); + // Length列を再計算 + int requestPacketId = pairingService.getRequestIdForResponse(packetId); + Packet requestPacket = packets.query(requestPacketId); + byte[] requestData = getDisplayData(requestPacket); + byte[] responseData = getDisplayData(packet); + tableModel.setValueAt(requestData.length + responseData.length, row_index, COL_LENGTH); + // Type列を更新 + tableModel.setValueAt(resolveContentType(requestPacket, packet), row_index, COL_CONTENT_TYPE); + // Modified列を更新(リクエストまたはレスポンスのどちらかが改ざんされていれば true) + boolean currentModified = (boolean) tableModel.getValueAt(row_index, COL_MODIFIED); + tableModel.setValueAt(currentModified || packet.getModified(), row_index, COL_MODIFIED); + } + return; + } + + Integer row_index = id_row.get(packetId); + if (row_index == null || row_index < 0 || row_index >= tableModel.getRowCount()) { + return; + } Object[] row_data = makeRowDataFromPacket(packet); + // マージされたリクエスト行の場合、Server Response / Length はレスポンス側で更新するため保持する + boolean isMergedRequestRow = pairingService.isMergedRow(packetId); + for (int i = 0; i < columnNames.length; i++) { + // マージされた行のServer Response列(2)とLength列(3)はスキップ + if (isMergedRequestRow && (i == COL_SERVER_RESPONSE || i == COL_LENGTH)) { + + continue; + } + if (row_data[i] == tableModel.getValueAt(row_index, i)) { continue; @@ -993,4 +1271,25 @@ public void addMenu(JMenuItem menuItem) { public void removeMenu(JMenuItem menuItem) { menu.remove(menuItem); } + + /** + * リクエストパケットIDに対応するレスポンスパケットIDを取得する マージされた行の場合のみ有効 + * + * @param requestPacketId + * リクエストパケットID + * @return レスポンスパケットID、存在しない場合は-1 + */ + public int getResponsePacketIdForRequest(int requestPacketId) { + return pairingService.getResponsePacketIdForRequest(requestPacketId); + } + + /** + * 選択された行がマージされた行(リクエスト+レスポンス)かどうかを判定 + * + * @return マージされた行の場合true + */ + public boolean isSelectedRowMerged() { + int packetId = getSelectedPacketId(); + return pairingService.isMergedRow(packetId); + } } diff --git a/src/main/java/core/packetproxy/gui/GUIHistoryContextMenuFactory.java b/src/main/java/core/packetproxy/gui/GUIHistoryContextMenuFactory.java index 539acba4..60e2dd59 100644 --- a/src/main/java/core/packetproxy/gui/GUIHistoryContextMenuFactory.java +++ b/src/main/java/core/packetproxy/gui/GUIHistoryContextMenuFactory.java @@ -290,9 +290,17 @@ public void onError() { try { int[] selected_rows = table.getSelectedRows(); for (int i = 0; i < selected_rows.length; i++) { - Integer id = (Integer) table.getValueAt(selected_rows[i], 0); - colorManager.clear(id); - packets.delete(packets.query(id)); + int requestPacketId = (Integer) table.getValueAt(selected_rows[i], 0); + colorManager.clear(requestPacketId); + + // マージされた行の場合、レスポンスパケットも一緒に削除する(DB残留を防ぐ) + int responsePacketId = context.getResponsePacketIdForRequest(requestPacketId); + if (responsePacketId != -1) { + colorManager.clear(responsePacketId); + packets.delete(packets.query(responsePacketId)); + } + + packets.delete(packets.query(requestPacketId)); } context.updateAll(); } catch (Exception ex) { diff --git a/src/main/java/core/packetproxy/gui/GUIPacket.java b/src/main/java/core/packetproxy/gui/GUIPacket.java index 5b35bcc9..4178e3df 100644 --- a/src/main/java/core/packetproxy/gui/GUIPacket.java +++ b/src/main/java/core/packetproxy/gui/GUIPacket.java @@ -15,39 +15,17 @@ */ package packetproxy.gui; -import static packetproxy.util.Logging.errWithStackTrace; - import javax.swing.JComponent; import javax.swing.JFrame; -import javax.swing.JTabbedPane; -import javax.swing.event.ChangeEvent; -import javax.swing.event.ChangeListener; import packetproxy.model.Packet; public class GUIPacket { private static GUIPacket instance; private JFrame owner; - private GUIDataAll all_panel; - private JTabbedPane packet_pane; - private GUIData received_panel; - private GUIData decoded_panel; - private GUIData modified_panel; - private GUIData sent_panel; + private GUIRequestResponsePanel request_response_panel; private Packet showing_packet; - - // public static void main(String args[]) - // { - // try { - // GUIPacket gui = new GUIPacket(); - // String s = "ABgNBHJfb2sAAAJhbANtc2cAB4NoAmEMYQANCg0KeyJlbXB0eSI6N30="; - // byte[] data = Base64.getDecoder().decode(s.getBytes()); - // byte[] result = gui.prettyFormatJSONInRawData(data, "hoge"); - // Logging.log(new String(result)); - // } catch (Exception e) { - // errWithStackTrace(e); - // } - // } + private Packet showing_response_packet; public static GUIPacket getInstance() throws Exception { if (instance == null) { @@ -60,90 +38,91 @@ public static GUIPacket getInstance() throws Exception { private GUIPacket() throws Exception { this.owner = GUIHistory.getOwner(); this.showing_packet = null; + this.showing_response_packet = null; } public JComponent createPanel() throws Exception { - received_panel = new GUIData(this.owner); - decoded_panel = new GUIData(this.owner); - modified_panel = new GUIData(this.owner); - sent_panel = new GUIData(this.owner); - all_panel = new GUIDataAll(); - - packet_pane = new JTabbedPane(); - packet_pane.addTab("Received Packet", received_panel.createPanel()); - packet_pane.addTab("Decoded", decoded_panel.createPanel()); - packet_pane.addTab("Modified", modified_panel.createPanel()); - packet_pane.addTab("Encoded (Sent Packet)", sent_panel.createPanel()); - packet_pane.addTab("All", all_panel.createPanel()); - packet_pane.addChangeListener(new ChangeListener() { - - @Override - public void stateChanged(ChangeEvent e) { - try { - - update(); - } catch (Exception e1) { - - errWithStackTrace(e1); - } - } - }); - packet_pane.setSelectedIndex(1); /* decoded */ - return packet_pane; + request_response_panel = new GUIRequestResponsePanel(this.owner); + return request_response_panel.createPanel(); } public byte[] getData() { - switch (packet_pane.getSelectedIndex()) { - case 0 : - return received_panel.getData(); - case 1 : - return decoded_panel.getData(); - case 2 : - return modified_panel.getData(); - case 3 : - return sent_panel.getData(); - default : - return modified_panel.getData(); - } + return request_response_panel.getRequestData(); } public void update() { - if (showing_packet == null) { + if (showing_packet == null && showing_response_packet == null) { return; } - switch (packet_pane.getSelectedIndex()) { - case 0 : - received_panel.setData(showing_packet.getReceivedData()); - break; - case 1 : - decoded_panel.setData(showing_packet.getDecodedData()); - break; - case 2 : - modified_panel.setData(showing_packet.getModifiedData()); - break; - case 3 : - sent_panel.setData(showing_packet.getSentData()); - break; - case 4 : - all_panel.setPacket(showing_packet); - break; - default : - } + request_response_panel.setRequestPacket(showing_packet); + request_response_panel.setResponsePacket(showing_response_packet); } public void setPacket(Packet packet) { - if (showing_packet != null && showing_packet.getId() == packet.getId()) { + setSinglePacket(packet, false); + } + + /** + * パケットを設定して表示を更新する + * + * @param packet + * 表示するパケット + * @param forceRefresh + * trueの場合、同じパケットIDでも強制的に再描画する + */ + public void setPacket(Packet packet, boolean forceRefresh) { + setSinglePacket(packet, forceRefresh); + } + + public void setPackets(Packet requestPacket, Packet responsePacket) { + setPackets(requestPacket, responsePacket, false); + } + public void setPackets(Packet requestPacket, Packet responsePacket, boolean forceRefresh) { + if (!forceRefresh && isSameRequestResponse(requestPacket, responsePacket)) { return; - } else { + } + showing_packet = requestPacket; + showing_response_packet = responsePacket; + request_response_panel.setPackets(requestPacket, responsePacket); + } - showing_packet = packet; + public void setSinglePacket(Packet packet) { + setSinglePacket(packet, false); + } + + public void setSinglePacket(Packet packet, boolean forceRefresh) { + if (!forceRefresh && isSameSinglePacket(packet)) { + return; } - update(); + showing_packet = packet; + showing_response_packet = null; + request_response_panel.setSinglePacket(packet); } public Packet getPacket() { return showing_packet; } + + public Packet getResponsePacket() { + return showing_response_packet; + } + + private boolean isSameSinglePacket(Packet packet) { + return showing_packet != null && showing_response_packet == null && showing_packet.getId() == packet.getId(); + } + + private boolean isSameRequestResponse(Packet requestPacket, Packet responsePacket) { + if (showing_packet == null || requestPacket == null) { + return false; + } + if (showing_packet.getId() != requestPacket.getId()) { + return false; + } + if (showing_response_packet == null || responsePacket == null) { + return showing_response_packet == null && responsePacket == null; + } + return showing_response_packet.getId() == responsePacket.getId(); + } } diff --git a/src/main/java/core/packetproxy/model/Packets.java b/src/main/java/core/packetproxy/model/Packets.java index 3da16152..f00e96da 100644 --- a/src/main/java/core/packetproxy/model/Packets.java +++ b/src/main/java/core/packetproxy/model/Packets.java @@ -150,7 +150,8 @@ public Packet query(int id) throws Exception { } public List queryAllIdsAndColors() throws Exception { - return dao.queryBuilder().selectColumns("id", "color").orderBy("id", true).query(); + return dao.queryBuilder().selectColumns("id", "color", "direction", "group", "encoder_name").orderBy("id", true) + .query(); } public List queryRange(long offset, long limit) throws Exception { diff --git a/src/main/java/core/packetproxy/util/SearchBox.java b/src/main/java/core/packetproxy/util/SearchBox.java index f2081139..0d34f68f 100644 --- a/src/main/java/core/packetproxy/util/SearchBox.java +++ b/src/main/java/core/packetproxy/util/SearchBox.java @@ -32,6 +32,8 @@ @SuppressWarnings("serial") public class SearchBox extends JPanel { + private static final int MAX_TEXT_LENGTH_FOR_HIGHLIGHTING = 1_000_000; + private JTextPane baseText; private Range emphasisArea = null; private JTextField search_text; @@ -138,7 +140,7 @@ public int coloringSearchText() { javax.swing.text.StyledDocument document = baseText.getStyledDocument(); String str = baseText.getText(); String search_string = search_text.getText(); - if (str.length() > 1000000) { + if (str.length() > MAX_TEXT_LENGTH_FOR_HIGHLIGHTING) { // Logging.err("[Warning] coloringSearchText: too long string. Skipping // Highlight"); @@ -200,7 +202,7 @@ public void coloringBackgroundClear() { public void coloringHTTPText() { javax.swing.text.StyledDocument document = baseText.getStyledDocument(); String str = baseText.getText(); - if (str.length() > 1000000) { + if (str.length() > MAX_TEXT_LENGTH_FOR_HIGHLIGHTING) { // Logging.err("[Warning] coloringHTTPText: too long string. Skipping // Highlight"); diff --git a/src/main/kotlin/core/packetproxy/gui/GUIRequestResponsePanel.kt b/src/main/kotlin/core/packetproxy/gui/GUIRequestResponsePanel.kt new file mode 100644 index 00000000..47dde3ed --- /dev/null +++ b/src/main/kotlin/core/packetproxy/gui/GUIRequestResponsePanel.kt @@ -0,0 +1,375 @@ +/* + * 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.awt.AWTEvent +import java.awt.BorderLayout +import java.awt.CardLayout +import java.awt.Color +import java.awt.Component +import java.awt.Dimension +import java.awt.GridLayout +import java.awt.Toolkit +import java.awt.event.MouseEvent +import javax.swing.BorderFactory +import javax.swing.BoxLayout +import javax.swing.JComponent +import javax.swing.JFrame +import javax.swing.JLabel +import javax.swing.JPanel +import javax.swing.JScrollPane +import javax.swing.JSplitPane +import javax.swing.JTabbedPane +import javax.swing.ScrollPaneConstants +import javax.swing.SwingUtilities +import javax.swing.border.TitledBorder +import javax.swing.event.ChangeListener +import packetproxy.common.I18nString +import packetproxy.model.Packet +import packetproxy.util.Logging.errWithStackTrace + +/** + * リクエストとレスポンスを左右に並べて表示するパネル 各パネルにReceived Packet, Decoded, Modified, Encoded, Allのタブを持つ + * HTTP以外の通信では単一パケット表示モードに切り替わる + */ +class GUIRequestResponsePanel(private val owner: JFrame) { + private companion object { + private const val SPLIT_PANE_DIVIDER_SIZE = 8 + private const val ALL_PANEL_ROWS = 1 + private const val ALL_PANEL_COLUMNS = 4 + private const val LABEL_ALIGNMENT_CENTER = 0.5f + private const val MIN_PANEL_SIZE = 100 + private const val SPLIT_PANE_RESIZE_WEIGHT = 0.5 + private val REQUEST_BORDER_COLOR = Color(0x33, 0x99, 0xff) + private val RESPONSE_BORDER_COLOR = Color(0x99, 0x33, 0x33) + private val SINGLE_BORDER_COLOR = Color(0x66, 0x66, 0x99) + private val EMPTY_DATA = ByteArray(0) + } + + private enum class ViewType { + SPLIT, + SINGLE, + } + + private enum class TabType(val index: Int) { + RECEIVED(0), + DECODED(1), + MODIFIED(2), + ENCODED(3), + ALL(4); + + companion object { + fun fromIndex(index: Int): TabType? { + return values().firstOrNull { it.index == index } + } + } + } + + private lateinit var mainPanel: JPanel + private lateinit var cardLayout: CardLayout + private lateinit var splitPane: JSplitPane + + private lateinit var buttonPanel: JPanel + private lateinit var buttonCardLayout: CardLayout + + private lateinit var requestPane: PacketDetailPane + private lateinit var responsePane: PacketDetailPane + private lateinit var singlePane: PacketDetailPane + + // 現在表示中のパケット + private var showingRequestPacket: Packet? = null + private var showingResponsePacket: Packet? = null + private var showingSinglePacket: Packet? = null + private var currentView: ViewType = ViewType.SPLIT + + // Copy Body のデータ取得元(最後にクリックされたペイン)。null のときは requestPane を使う + private var activePaneForBody: PacketDetailPane? = null + + @Throws(Exception::class) + fun createPanel(): JComponent { + cardLayout = CardLayout() + mainPanel = JPanel(cardLayout) + + // === 分割ビュー(HTTP用)=== + requestPane = PacketDetailPane("Request", REQUEST_BORDER_COLOR, true) + responsePane = PacketDetailPane("Response", RESPONSE_BORDER_COLOR, true) + requestPane.addChangeListener(ChangeListener { updateRequestPanel() }) + responsePane.addChangeListener(ChangeListener { updateResponsePanel() }) + + // 左右に分割 + splitPane = JSplitPane(JSplitPane.HORIZONTAL_SPLIT, requestPane.panel, responsePane.panel) + splitPane.resizeWeight = SPLIT_PANE_RESIZE_WEIGHT + splitPane.isContinuousLayout = true + splitPane.dividerSize = SPLIT_PANE_DIVIDER_SIZE + + mainPanel.add(splitPane, ViewType.SPLIT.name) + + // === 単一パケットビュー(非HTTP用)=== + singlePane = PacketDetailPane("Streaming Packet", SINGLE_BORDER_COLOR, false) + singlePane.addChangeListener(ChangeListener { updateSinglePacketPanel() }) + mainPanel.add(singlePane.panel, ViewType.SINGLE.name) + + // === 共有ボタンパネル(分割表示の対象外・下部に1つだけ表示)=== + buttonCardLayout = CardLayout() + buttonPanel = JPanel(buttonCardLayout) + buttonPanel.add(requestPane.receivedPanel.createButtonPanel(), ViewType.SPLIT.name) + // ボタンが現在アクティブな外側タブ(Decoded/Modified等)のデータを読むようにサプライヤを注入する + requestPane.receivedPanel.setDataProvider { requestPane.getActiveData() } + buttonPanel.add(singlePane.receivedPanel.createButtonPanel(), ViewType.SINGLE.name) + singlePane.receivedPanel.setDataProvider { singlePane.getActiveData() } + + requestPane.receivedPanel.setBodyDataProvider { getBodyData() } + requestPane.receivedPanel.setResponseDataProvider { responsePane.getActiveData() } + + registerBodyFocusTracker() + + val wrapper = JPanel(BorderLayout()) + wrapper.add(mainPanel, BorderLayout.CENTER) + wrapper.add(buttonPanel, BorderLayout.SOUTH) + return wrapper + } + + private fun getBodyData(): ByteArray = (activePaneForBody ?: requestPane).getActiveData() + + private fun registerBodyFocusTracker() { + Toolkit.getDefaultToolkit() + .addAWTEventListener( + { event -> + if (event is MouseEvent && event.id == MouseEvent.MOUSE_PRESSED) { + val source = event.source as? Component ?: return@addAWTEventListener + when { + SwingUtilities.isDescendingFrom(source, requestPane.panel) -> + activePaneForBody = requestPane + SwingUtilities.isDescendingFrom(source, responsePane.panel) -> + activePaneForBody = responsePane + } + } + }, + AWTEvent.MOUSE_EVENT_MASK, + ) + } + + private inner class PacketDetailPane( + private val title: String, + private val borderColor: Color, + private val shouldSetMinimumSize: Boolean, + ) { + val panel: JPanel = JPanel() + private val tabs = JTabbedPane() + private val decodedTabs = TabSet(true, false) + val receivedPanel = GUIData(owner) + private val modifiedPanel = GUIData(owner) + private val sentPanel = GUIData(owner) + private lateinit var allReceived: RawTextPane + private lateinit var allDecoded: RawTextPane + private lateinit var allModified: RawTextPane + private lateinit var allSent: RawTextPane + + init { + panel.layout = BorderLayout() + panel.border = + BorderFactory.createTitledBorder( + BorderFactory.createLineBorder(borderColor, 2), + title, + TitledBorder.LEFT, + TitledBorder.TOP, + null, + borderColor, + ) + if (shouldSetMinimumSize) { + panel.minimumSize = Dimension(MIN_PANEL_SIZE, MIN_PANEL_SIZE) + } + + tabs.addTab("Received Packet", receivedPanel.createTabsPanel()) + tabs.addTab("Decoded", decodedTabs.tabPanel) + tabs.addTab("Modified", modifiedPanel.createTabsPanel()) + tabs.addTab("Encoded (Sent Packet)", sentPanel.createTabsPanel()) + tabs.addTab("All", createAllPanel()) + tabs.selectedIndex = TabType.DECODED.index + + panel.add(tabs, BorderLayout.CENTER) + } + + fun addChangeListener(listener: ChangeListener) { + tabs.addChangeListener(listener) + } + + fun update(packet: Packet?) { + if (packet == null) { + clear() + return + } + try { + when (TabType.fromIndex(tabs.selectedIndex)) { + TabType.DECODED -> decodedTabs.setData(resolveDecodedData(packet)) + TabType.RECEIVED -> receivedPanel.setData(packet.getReceivedData()) + TabType.MODIFIED -> modifiedPanel.setData(packet.getModifiedData()) + TabType.ENCODED -> sentPanel.setData(packet.getSentData()) + TabType.ALL -> { + allReceived.setData(packet.getReceivedData(), true) + allReceived.caretPosition = 0 + allDecoded.setData(packet.getDecodedData(), true) + allDecoded.caretPosition = 0 + allModified.setData(packet.getModifiedData(), true) + allModified.caretPosition = 0 + allSent.setData(packet.getSentData(), true) + allSent.caretPosition = 0 + } + null -> error("Unknown tab index: ${tabs.selectedIndex}") + } + } catch (e: Exception) { + errWithStackTrace(e) + } + } + + fun clear() { + try { + decodedTabs.setData(EMPTY_DATA) + receivedPanel.setData(EMPTY_DATA) + modifiedPanel.setData(EMPTY_DATA) + sentPanel.setData(EMPTY_DATA) + allReceived.setData(EMPTY_DATA, true) + allDecoded.setData(EMPTY_DATA, true) + allModified.setData(EMPTY_DATA, true) + allSent.setData(EMPTY_DATA, true) + } catch (e: Exception) { + errWithStackTrace(e) + } + } + + fun getDecodedData(): ByteArray { + return decodedTabs.getData() + } + + fun getActiveData(): ByteArray { + return when (TabType.fromIndex(tabs.selectedIndex)) { + TabType.RECEIVED -> receivedPanel.getData() + TabType.DECODED -> decodedTabs.getData() + TabType.MODIFIED -> modifiedPanel.getData() + TabType.ENCODED -> sentPanel.getData() + TabType.ALL -> decodedTabs.getData() + null -> EMPTY_DATA + } + } + + private fun createAllPanel(): JComponent { + val panel = JPanel() + panel.layout = GridLayout(ALL_PANEL_ROWS, ALL_PANEL_COLUMNS) + + allReceived = createTextPaneForAll(panel, I18nString.get("Received")) + allDecoded = createTextPaneForAll(panel, I18nString.get("Decoded")) + allModified = createTextPaneForAll(panel, I18nString.get("Modified")) + allSent = createTextPaneForAll(panel, I18nString.get("Encoded")) + + return panel + } + } + + @Throws(Exception::class) + private fun createTextPaneForAll(parentPanel: JPanel, labelName: String): RawTextPane { + val panel = JPanel() + panel.layout = BoxLayout(panel, BoxLayout.Y_AXIS) + + val label = JLabel(labelName) + label.alignmentX = LABEL_ALIGNMENT_CENTER + + val text = RawTextPane() + text.isEditable = false + panel.add(label) + val scroll = JScrollPane(text) + scroll.verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED + scroll.horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_AS_NEEDED + panel.add(scroll) + parentPanel.add(panel) + return text + } + + fun setRequestPacket(packet: Packet) { + showingRequestPacket = packet + updateRequestPanel() + } + + fun setResponsePacket(packet: Packet) { + showingResponsePacket = packet + updateResponsePanel() + } + + /** 単一パケット表示モード用:パケットを設定 Streaming通信で使用 */ + fun setSinglePacket(packet: Packet) { + activePaneForBody = null + showingSinglePacket = packet + switchToSingleView() + updateSinglePacketPanel() + } + + /** リクエスト/レスポンス分割表示モード用:両方のパケットを設定 HTTP通信で使用 */ + fun setPackets(requestPacket: Packet, responsePacket: Packet?) { + activePaneForBody = null + showingRequestPacket = requestPacket + showingResponsePacket = responsePacket + switchToSplitView() + updateRequestPanel() + updateResponsePanel() + } + + private fun switchToSplitView() { + if (currentView != ViewType.SPLIT) { + currentView = ViewType.SPLIT + cardLayout.show(mainPanel, ViewType.SPLIT.name) + buttonCardLayout.show(buttonPanel, ViewType.SPLIT.name) + } + } + + private fun switchToSingleView() { + if (currentView != ViewType.SINGLE) { + currentView = ViewType.SINGLE + cardLayout.show(mainPanel, ViewType.SINGLE.name) + buttonCardLayout.show(buttonPanel, ViewType.SINGLE.name) + } + } + + private fun resolveDecodedData(packet: Packet): ByteArray { + var decodedData = packet.getDecodedData() + if (decodedData == null || decodedData.isEmpty()) { + decodedData = packet.getModifiedData() + } + return decodedData ?: EMPTY_DATA + } + + private fun updateRequestPanel() { + requestPane.update(showingRequestPacket) + } + + private fun updateResponsePanel() { + responsePane.update(showingResponsePacket) + } + + private fun updateSinglePacketPanel() { + singlePane.update(showingSinglePacket) + } + + fun getRequestData(): ByteArray { + if (showingRequestPacket == null) return EMPTY_DATA + // Decodedタブからデータを取得 + return requestPane.getDecodedData() + } + + fun getResponseData(): ByteArray { + if (showingResponsePacket == null) return EMPTY_DATA + // Decodedタブからデータを取得 + return responsePane.getDecodedData() + } +} diff --git a/src/main/kotlin/core/packetproxy/gui/PacketPairingService.kt b/src/main/kotlin/core/packetproxy/gui/PacketPairingService.kt new file mode 100644 index 00000000..291b0124 --- /dev/null +++ b/src/main/kotlin/core/packetproxy/gui/PacketPairingService.kt @@ -0,0 +1,257 @@ +/* + * 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 PacketPairingService { + private companion object { + private const val NO_RESPONSE_PACKET_ID = -1 + private const val NO_REQUEST_PACKET_ID = -1 + } + + // グループIDと行番号のマッピング(リクエスト行を追跡) + private val groupRow: MutableMap = HashMap() + // レスポンスが既にマージされているグループID + private val groupHasResponse = mutableSetOf() + // レスポンスパケットIDとリクエストパケットIDのマッピング(マージされた行用) + private val responseToRequestId: MutableMap = HashMap() + // リクエストパケットIDとレスポンスパケットIDのマッピング(マージされた行用) + private val requestToResponseId: MutableMap = HashMap() + // グループIDごとのパケット数(3個以上でマージしない) + private val groupPacketCount: MutableMap = HashMap() + // グループIDごとのCLIENTパケット数(2個以上でストリーミングと判定) + private val groupClientPacketCount: MutableMap = HashMap() + + /** すべてのペアリング情報をクリアする */ + fun clear() { + groupRow.clear() + groupHasResponse.clear() + responseToRequestId.clear() + requestToResponseId.clear() + groupPacketCount.clear() + groupClientPacketCount.clear() + } + + /** + * グループIDに対応する行インデックスを登録する + * + * @param groupId グループID + * @param rowIndex 行インデックス + */ + fun registerGroupRow(groupId: Long, rowIndex: Int) { + groupRow[groupId] = rowIndex + } + + /** + * グループIDに対応する行インデックスを取得する + * + * @param groupId グループID + * @return 行インデックス、存在しない場合はnull + */ + fun getRowForGroup(groupId: Long): Int? { + return groupRow[groupId] + } + + /** + * グループIDが登録されているか確認する + * + * @param groupId グループID + * @return 登録されている場合true + */ + fun containsGroup(groupId: Long): Boolean { + return groupRow.containsKey(groupId) + } + + /** + * グループにレスポンスがマージされたことを記録する + * + * @param groupId グループID + */ + fun markGroupHasResponse(groupId: Long) { + groupHasResponse.add(groupId) + } + + /** + * グループにレスポンスがマージされているか確認する + * + * @param groupId グループID + * @return マージされている場合true + */ + fun hasResponse(groupId: Long): Boolean { + return groupHasResponse.contains(groupId) + } + + /** + * レスポンスパケットIDとリクエストパケットIDのペアリングを登録する + * + * @param responsePacketId レスポンスパケットID + * @param requestPacketId リクエストパケットID + */ + fun registerPairing(responsePacketId: Int, requestPacketId: Int) { + responseToRequestId[responsePacketId] = requestPacketId + requestToResponseId[requestPacketId] = responsePacketId + } + + /** + * レスポンスパケットIDに対応するリクエストパケットIDを取得する + * + * @param responsePacketId レスポンスパケットID + * @return リクエストパケットID、存在しない場合は-1 + */ + fun getRequestIdForResponse(responsePacketId: Int): Int { + return responseToRequestId[responsePacketId] ?: NO_REQUEST_PACKET_ID + } + + /** + * レスポンスパケットIDがペアリングに登録されているか確認する + * + * @param responsePacketId レスポンスパケットID + * @return 登録されている場合true + */ + fun containsResponsePairing(responsePacketId: Int): Boolean { + return responseToRequestId.containsKey(responsePacketId) + } + + /** + * リクエストパケットIDに対応するレスポンスパケットIDを取得する マージされた行の場合のみ有効 + * + * @param requestPacketId リクエストパケットID + * @return レスポンスパケットID、存在しない場合は-1 + */ + fun getResponsePacketIdForRequest(requestPacketId: Int): Int { + return requestToResponseId[requestPacketId] ?: NO_RESPONSE_PACKET_ID + } + + /** + * 選択された行がマージされた行(リクエスト+レスポンス)かどうかを判定 + * + * @param packetId パケットID + * @return マージされた行の場合true + */ + fun isMergedRow(packetId: Int): Boolean { + return getResponsePacketIdForRequest(packetId) != NO_RESPONSE_PACKET_ID + } + + /** + * グループのパケット数をインクリメントする + * + * @param groupId グループID + * @return インクリメント後のパケット数 + */ + fun incrementGroupPacketCount(groupId: Long): Int { + return groupPacketCount.compute(groupId) { _, currentCount -> (currentCount ?: 0) + 1 } ?: 0 + } + + /** + * グループのパケット数を取得する + * + * @param groupId グループID + * @return パケット数 + */ + fun getGroupPacketCount(groupId: Long): Int { + return groupPacketCount[groupId] ?: 0 + } + + /** + * グループがマージ可能かどうかを判定する + * + * 以下の両方を満たす場合のみマージ可能: + * - 総パケット数が2以下(3個以上はストリーミング等) + * - CLIENTパケット数が1以下(2個以上はストリーミングと判断し、マージしない) + * - gRPCストリーミングでは同一グループ内にHEADERSフレームとDATAフレームで2つのCLIENTパケットが存在するため + * + * @param groupId グループID + * @return マージ可能な場合true + */ + fun isGroupMergeable(groupId: Long): Boolean { + return getGroupPacketCount(groupId) <= 2 && getGroupClientPacketCount(groupId) < 2 + } + + /** + * グループのマージ状態を解除する(ストリーミング通信で3つ目以降のパケットが来た場合に使用) + * + * @param groupId グループID + */ + fun unmergeGroup(groupId: Long) { + groupHasResponse.remove(groupId) + } + + /** + * 指定されたリクエストパケットIDに対応するレスポンスのペアリングを解除する + * + * @param requestPacketId リクエストパケットID + * @return 解除されたレスポンスパケットID、存在しない場合は-1 + */ + fun unregisterPairingByRequestId(requestPacketId: Int): Int { + val responsePacketId = getResponsePacketIdForRequest(requestPacketId) + if (responsePacketId != NO_RESPONSE_PACKET_ID) { + responseToRequestId.remove(responsePacketId) + requestToResponseId.remove(requestPacketId) + } + return responsePacketId + } + + /** + * グループのCLIENTパケット数をインクリメントする + * + * @param groupId グループID + * @return インクリメント後のCLIENTパケット数 + */ + fun incrementGroupClientPacketCount(groupId: Long): Int { + return groupClientPacketCount.compute(groupId) { _, currentCount -> (currentCount ?: 0) + 1 } + ?: 0 + } + + /** + * グループのCLIENTパケット数を取得する + * + * @param groupId グループID + * @return CLIENTパケット数 + */ + fun getGroupClientPacketCount(groupId: Long): Int { + return groupClientPacketCount[groupId] ?: 0 + } + + /** + * グループがストリーミングかどうかを判定する CLIENTパケット数が2以上の場合はストリーミングと判定 + * + * @param groupId グループID + * @return ストリーミングの場合true + */ + fun isGroupStreaming(groupId: Long): Boolean { + return getGroupClientPacketCount(groupId) >= 2 + } + + /** + * groupId が後から確定したリクエストを追跡対象に追加する。 + * + * encoder.setGroupId() 後の遅延登録で使用するため、 groupRow と groupPacketCount の初期化を同一メソッドに集約する。 + */ + fun ensureGroupTracked(groupId: Long, rowIndex: Int) { + if (!containsGroup(groupId)) { + registerGroupRow(groupId, rowIndex) + } + if (getGroupPacketCount(groupId) == 0) { + incrementGroupPacketCount(groupId) + } + } +} diff --git a/src/test/kotlin/packetproxy/gui/PacketPairingServiceTest.kt b/src/test/kotlin/packetproxy/gui/PacketPairingServiceTest.kt new file mode 100644 index 00000000..0a6486d4 --- /dev/null +++ b/src/test/kotlin/packetproxy/gui/PacketPairingServiceTest.kt @@ -0,0 +1,215 @@ +package packetproxy.gui + +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + +class PacketPairingServiceTest { + @Test + fun registerPairing_registersBidirectionalMappings() { + // registerPairing() を呼ぶと、レスポンス→リクエスト・リクエスト→レスポンスの双方向マッピングが登録され、 + // リクエスト側のパケットが「マージ済み行」として扱われること + val service = PacketPairingService() + + val requestPacketId = 100 + val responsePacketId = 200 + service.registerPairing(responsePacketId, requestPacketId) + + assertThat(service.getRequestIdForResponse(responsePacketId)).isEqualTo(requestPacketId) + assertThat(service.getResponsePacketIdForRequest(requestPacketId)).isEqualTo(responsePacketId) + assertThat(service.containsResponsePairing(responsePacketId)).isTrue() + assertThat(service.isMergedRow(requestPacketId)).isTrue() + assertThat(service.isMergedRow(responsePacketId)).isFalse() + } + + @Test + fun unregisterPairingByRequestId_removesMappingsAndReturnsResponseId() { + // unregisterPairingByRequestId() を呼ぶと、双方向マッピングがすべて削除され、 + // 削除前のレスポンスIDが返り値として得られること + val service = PacketPairingService() + + val requestPacketId = 101 + val responsePacketId = 201 + service.registerPairing(responsePacketId, requestPacketId) + + val removedResponsePacketId = service.unregisterPairingByRequestId(requestPacketId) + + assertThat(removedResponsePacketId).isEqualTo(responsePacketId) + assertThat(service.containsResponsePairing(responsePacketId)).isFalse() + assertThat(service.getRequestIdForResponse(responsePacketId)).isEqualTo(-1) + assertThat(service.getResponsePacketIdForRequest(requestPacketId)).isEqualTo(-1) + assertThat(service.isMergedRow(requestPacketId)).isFalse() + } + + @Test + fun groupPacketCount_andMergeableBoundary() { + // グループ内のパケット数が2以下ならマージ可能、3以上になるとマージ不可になること + // (3パケット目はgRPCストリーミングなど複数レスポンスを持つストリーミング通信の可能性があるため) + val service = PacketPairingService() + val groupId = 1L + + assertThat(service.incrementGroupPacketCount(groupId)).isEqualTo(1) + assertThat(service.isGroupMergeable(groupId)).isTrue() + + assertThat(service.incrementGroupPacketCount(groupId)).isEqualTo(2) + assertThat(service.isGroupMergeable(groupId)).isTrue() + + assertThat(service.incrementGroupPacketCount(groupId)).isEqualTo(3) + assertThat(service.getGroupPacketCount(groupId)).isEqualTo(3) + assertThat(service.isGroupMergeable(groupId)).isFalse() + } + + @Test + fun twoClientPacketsInSameGroup_notMergeable() { + val service = PacketPairingService() + val groupId = 9L + + // 1つ目のCLIENTパケット:まだマージ可能 + service.incrementGroupPacketCount(groupId) + service.incrementGroupClientPacketCount(groupId) + assertThat(service.isGroupMergeable(groupId)).isTrue() + + // 2つ目のCLIENTパケット:同一グループに2つのRequestが存在 → ストリーミング扱い、マージ不可 + service.incrementGroupPacketCount(groupId) + service.incrementGroupClientPacketCount(groupId) + assertThat(service.getGroupPacketCount(groupId)).isEqualTo(2) + assertThat(service.getGroupClientPacketCount(groupId)).isEqualTo(2) + assertThat(service.isGroupMergeable(groupId)).isFalse() + assertThat(service.isGroupStreaming(groupId)).isTrue() + } + + @Test + fun groupClientPacketCount_andStreamingBoundary() { + // 同一グループでCLIENTパケット数が1ならストリーミングではなく、2以上になるとストリーミング扱いになること + // gRPC-Streamingのエンコーダを使用した場合、HEADERSフレームとDATAフレームで2つのCLIENTパケットが存在するため + val service = PacketPairingService() + val groupId = 2L + + assertThat(service.incrementGroupClientPacketCount(groupId)).isEqualTo(1) + assertThat(service.isGroupStreaming(groupId)).isFalse() + + assertThat(service.incrementGroupClientPacketCount(groupId)).isEqualTo(2) + assertThat(service.getGroupClientPacketCount(groupId)).isEqualTo(2) + assertThat(service.isGroupStreaming(groupId)).isTrue() + } + + @Test + fun clear_resetsAllState() { + // clear() を呼ぶと、グループ行マッピング・レスポンス有無フラグ・パケットペアリング・ + // パケットカウントなどすべての内部状態が初期値にリセットされること + val service = PacketPairingService() + + service.registerGroupRow(10L, 3) + service.markGroupHasResponse(10L) + service.registerPairing(210, 110) + service.incrementGroupPacketCount(10L) + service.incrementGroupClientPacketCount(10L) + + service.clear() + + assertThat(service.getRowForGroup(10L)).isNull() + assertThat(service.hasResponse(10L)).isFalse() + assertThat(service.containsResponsePairing(210)).isFalse() + assertThat(service.getGroupPacketCount(10L)).isEqualTo(0) + assertThat(service.getGroupClientPacketCount(10L)).isEqualTo(0) + assertThat(service.isGroupStreaming(10L)).isFalse() + } + + @Test + fun unmergeGroup_onlyClearsHasResponse() { + // unmergeGroup() は「レスポンス受信済み」フラグのみをクリアし、 + // パケットIDのペアリングマッピングは unregisterPairingByRequestId() が別途呼ばれるまで保持されること + val service = PacketPairingService() + val groupId = 5L + + service.markGroupHasResponse(groupId) + service.registerPairing(responsePacketId = 301, requestPacketId = 201) + + service.unmergeGroup(groupId) + + assertThat(service.hasResponse(groupId)).isFalse() + // ペアリングIDのマッピングは unregisterPairingByRequestId() が明示的に呼ばれるまで保持される + assertThat(service.getResponsePacketIdForRequest(201)).isEqualTo(301) + assertThat(service.getRequestIdForResponse(301)).isEqualTo(201) + } + + @Test + fun isGroupMergeable_normalHttpPair_returnsTrue() { + // 通常のHTTP通信(CLIENTが1つ、SERVERが1つ)はマージ可能 + val service = PacketPairingService() + val groupId = 20L + + service.incrementGroupPacketCount(groupId) // CLIENT + service.incrementGroupClientPacketCount(groupId) + service.incrementGroupPacketCount(groupId) // SERVER + + assertThat(service.getGroupPacketCount(groupId)).isEqualTo(2) + assertThat(service.getGroupClientPacketCount(groupId)).isEqualTo(1) + assertThat(service.isGroupMergeable(groupId)).isTrue() + assertThat(service.isGroupStreaming(groupId)).isFalse() + } + + @Test + fun getters_returnDefaultValues_whenGroupNotRegistered() { + // 未登録のgroupIdに対して各getterがデフォルト値を返すこと + val service = PacketPairingService() + val unknownGroupId = 999L + + assertThat(service.getRowForGroup(unknownGroupId)).isNull() + assertThat(service.containsGroup(unknownGroupId)).isFalse() + assertThat(service.hasResponse(unknownGroupId)).isFalse() + assertThat(service.getGroupPacketCount(unknownGroupId)).isEqualTo(0) + assertThat(service.getGroupClientPacketCount(unknownGroupId)).isEqualTo(0) + assertThat(service.isGroupMergeable(unknownGroupId)).isTrue() + assertThat(service.isGroupStreaming(unknownGroupId)).isFalse() + } + + @Test + fun getters_returnDefaultValues_whenPacketNotPaired() { + // ペアリング未登録のパケットIDに対して各getterがデフォルト値を返すこと + val service = PacketPairingService() + val unknownPacketId = 999 + + assertThat(service.getRequestIdForResponse(unknownPacketId)).isEqualTo(-1) + assertThat(service.getResponsePacketIdForRequest(unknownPacketId)).isEqualTo(-1) + assertThat(service.containsResponsePairing(unknownPacketId)).isFalse() + assertThat(service.isMergedRow(unknownPacketId)).isFalse() + } + + @Test + fun unregisterPairingByRequestId_returnsMinusOne_whenNotPaired() { + // ペアリングが存在しないリクエストIDに対して -1 が返ること + val service = PacketPairingService() + + val result = service.unregisterPairingByRequestId(999) + + assertThat(result).isEqualTo(-1) + } + + @Test + fun ensureGroupTracked_registersGroupAndCount_whenNotYetTracked() { + // 未登録のgroupIdに対して呼ぶと行番号とカウントが初期化される + val service = PacketPairingService() + val groupId = 30L + val rowIndex = 5 + + service.ensureGroupTracked(groupId, rowIndex) + + assertThat(service.containsGroup(groupId)).isTrue() + assertThat(service.getRowForGroup(groupId)).isEqualTo(rowIndex) + assertThat(service.getGroupPacketCount(groupId)).isEqualTo(1) + } + + @Test + fun ensureGroupTracked_isIdempotent_whenCalledTwice() { + // 同じgroupIdで2回呼んでも行番号・カウントが重複登録されないこと + val service = PacketPairingService() + val groupId = 31L + val rowIndex = 7 + + service.ensureGroupTracked(groupId, rowIndex) + service.ensureGroupTracked(groupId, rowIndex) + + assertThat(service.getRowForGroup(groupId)).isEqualTo(rowIndex) + assertThat(service.getGroupPacketCount(groupId)).isEqualTo(1) + } +}