From 4ac3c6fa697c017909983905fe53672259565055 Mon Sep 17 00:00:00 2001 From: Rudi Date: Tue, 21 Apr 2026 15:35:03 -0400 Subject: [PATCH] IND$FILE receiving --- .../java/org/pubvm/j3270/ft/FileTransfer.java | 236 +++++++ .../pubvm/j3270/ft/FileTransferDialog.java | 305 +++++++++ .../main/java/org/lib3270j/ft/FTConfig.java | 310 +++++++++ .../java/org/lib3270j/ft/FTConstants.java | 200 ++++++ .../src/main/java/org/lib3270j/ft/FTCut.java | 587 ++++++++++++++++++ .../src/main/java/org/lib3270j/ft/FTDft.java | 496 +++++++++++++++ 6 files changed, 2134 insertions(+) create mode 100644 j3270/src/main/java/org/pubvm/j3270/ft/FileTransfer.java create mode 100644 j3270/src/main/java/org/pubvm/j3270/ft/FileTransferDialog.java create mode 100644 lib3270j/src/main/java/org/lib3270j/ft/FTConfig.java create mode 100644 lib3270j/src/main/java/org/lib3270j/ft/FTConstants.java create mode 100644 lib3270j/src/main/java/org/lib3270j/ft/FTCut.java create mode 100644 lib3270j/src/main/java/org/lib3270j/ft/FTDft.java diff --git a/j3270/src/main/java/org/pubvm/j3270/ft/FileTransfer.java b/j3270/src/main/java/org/pubvm/j3270/ft/FileTransfer.java new file mode 100644 index 0000000..02cbb33 --- /dev/null +++ b/j3270/src/main/java/org/pubvm/j3270/ft/FileTransfer.java @@ -0,0 +1,236 @@ +package org.pubvm.j3270.ft; + +import org.lib3270j.Telnet3270Client; +import org.lib3270j.ft.FTConfig; +import org.lib3270j.ft.FTConstants.FTState; +import org.lib3270j.ft.FTCut; +import org.lib3270j.ft.FTDft; + +import java.io.File; +import java.io.IOException; +import java.util.Timer; +import java.util.TimerTask; +import java.util.logging.Logger; +import javax.swing.SwingUtilities; + +/** + * High-level coordinator for IND$FILE file transfers. + * Manages the transfer state machine, timeouts, and coordinates between + * the GUI, Telnet3270Client, and the lower-level CUT/DFT protocol handlers. + */ +public class FileTransfer implements FTCut.FTCutListener, FTDft.FTDftListener { + + private static final Logger log = Logger.getLogger(FileTransfer.class.getName()); + + public interface FileTransferCallback { + void onTransferStarted(); + void onTransferRunning(); + void onBytesTransferred(long bytes); + void onTransferComplete(String message); + void onTransferAborted(String error); + } + + private final Telnet3270Client client; + private final FileTransferCallback callback; + + private FTConfig currentConfig; + private File localFile; + private FTState state = FTState.NONE; + + private FTCut cutHandler; + private FTDft dftHandler; + + private Timer timeoutTimer; + private static final long START_TIMEOUT_MS = 30000; // 30 seconds + + public FileTransfer(Telnet3270Client client, FileTransferCallback callback) { + this.client = client; + this.callback = callback; + } + + /** + * Start a new file transfer with the given configuration. + * @return null if started successfully, error message otherwise. + */ + public String startTransfer(FTConfig config) { + if (state != FTState.NONE) { + return "A transfer is already in progress."; + } + + String validationError = config.validate(); + if (validationError != null) { + return validationError; + } + + this.currentConfig = config; + this.localFile = new File(config.getLocalFilename()); + + // Check overwrite + if (config.isReceive() && !config.isAppend() && !config.isOverwrite()) { + if (localFile.exists()) { + return "Local file already exists and overwrite is not permitted."; + } + } + + // Initialize protocol handlers lazily + if (cutHandler == null) { + cutHandler = new FTCut(client.getScreenBuffer(), client.getInputProcessor(), + client.getTranslator(), this); + } + if (dftHandler == null) { + dftHandler = new FTDft(client.getInputProcessor(), + client.getTranslator(), this); + client.getDataStreamProcessor().setFTDft(dftHandler); + } + + try { + cutHandler.initTransfer(localFile); + dftHandler.initTransfer(localFile); + } catch (IOException e) { + cleanupHandlers(false); + return "Failed to open local file: " + e.getMessage(); + } + + // Build and type the IND$FILE command + String command = config.buildCommand(); + log.info("Starting IND$FILE transfer with command: " + command); + + // Erase field and verify it can hold the command + int capacity = client.getInputProcessor().kybdPrime(); + if (capacity < 0) { + cleanupHandlers(false); + switch (capacity) { + case -1: return "Keyboard is locked."; + case -3: return "No unprotected input field found."; + default: return "Cannot start transfer from current screen state."; + } + } + if (capacity < command.length()) { + cleanupHandlers(false); + return "Current input field is too small for the IND$FILE command (" + capacity + " chars max)."; + } + + setState(FTState.AWAIT_ACK); + client.emulateInput(command + "\n"); + + startTimeout(); + callback.onTransferStarted(); + + return null; + } + + public void cancel() { + if (state == FTState.RUNNING || state == FTState.AWAIT_ACK) { + log.info("User cancelled transfer"); + setState(FTState.ABORT_WAIT); // Signal handlers to abort at next chance + } else if (state != FTState.NONE) { + log.info("Forcing cancel from state " + state); + completeTransfer("Transfer cancelled."); + callback.onTransferAborted("Cancelled by user."); + } + } + + /** Must be called after every screen update to drive CUT mode. */ + public void onScreenUpdated() { + if (state == FTState.AWAIT_ACK || state == FTState.RUNNING || state == FTState.ABORT_WAIT) { + cutHandler.processScreenUpdate(); + } + } + + private void startTimeout() { + cancelTimeout(); + timeoutTimer = new Timer("FTTimeout", true); + timeoutTimer.schedule(new TimerTask() { + @Override + public void run() { + SwingUtilities.invokeLater(() -> { + if (state == FTState.AWAIT_ACK) { + log.warning("Transfer start timeout"); + completeTransfer("Transfer failed to start within 30 seconds."); + callback.onTransferAborted("Transfer start timeout."); + } + }); + } + }, START_TIMEOUT_MS); + } + + private void cancelTimeout() { + if (timeoutTimer != null) { + timeoutTimer.cancel(); + timeoutTimer = null; + } + } + + private void cleanupHandlers(boolean success) { + if (cutHandler != null) cutHandler.cleanup(); + if (dftHandler != null) dftHandler.cleanup(); + + // Remove incomplete files if we were receiving and not appending + if (!success && currentConfig != null && currentConfig.isReceive() && !currentConfig.isAppend()) { + if (state != FTState.NONE && state != FTState.AWAIT_ACK && localFile != null && localFile.exists()) { + log.info("Cleaning up incomplete download: " + localFile.getAbsolutePath()); + localFile.delete(); + } + } + } + + private void completeTransfer(String errorMessage) { + cancelTimeout(); + boolean success = (errorMessage == null); + cleanupHandlers(success); + setState(FTState.NONE); + currentConfig = null; + } + + // ========== FTCutListener / FTDftListener Callbacks ========== + + @Override + public void onTransferRunning() { + cancelTimeout(); + setState(FTState.RUNNING); + SwingUtilities.invokeLater(callback::onTransferRunning); + } + + @Override + public void onTransferComplete(String errorMessage) { + completeTransfer(null); + SwingUtilities.invokeLater(() -> { + if (errorMessage == null) { + callback.onTransferComplete("Transfer complete."); + } else { + callback.onTransferAborted(errorMessage); + } + }); + } + + @Override + public void onTransferAborted(String errorMessage) { + completeTransfer(errorMessage); + SwingUtilities.invokeLater(() -> callback.onTransferAborted(errorMessage)); + } + + @Override + public void onBytesTransferred(long bytes) { + SwingUtilities.invokeLater(() -> callback.onBytesTransferred(bytes)); + } + + @Override + public FTState getCurrentState() { + return state; + } + + @Override + public void setState(FTState state) { + this.state = state; + } + + @Override + public FTConfig getConfig() { + return currentConfig; + } + + @Override + public File getLocalFile() { + return localFile; + } +} diff --git a/j3270/src/main/java/org/pubvm/j3270/ft/FileTransferDialog.java b/j3270/src/main/java/org/pubvm/j3270/ft/FileTransferDialog.java new file mode 100644 index 0000000..c6f7ffd --- /dev/null +++ b/j3270/src/main/java/org/pubvm/j3270/ft/FileTransferDialog.java @@ -0,0 +1,305 @@ +package org.pubvm.j3270.ft; + +import org.lib3270j.ft.FTConfig; + +import javax.swing.*; +import javax.swing.border.EmptyBorder; +import java.awt.*; +import java.io.File; + +public class FileTransferDialog extends JDialog { + + private final FileTransfer coordinator; + private final Frame owner; + + private JComboBox hostTypeCombo; + private JRadioButton sendRadio; + private JRadioButton receiveRadio; + private JTextField localFileField; + private JButton browseLocalButton; + private JTextField hostFileField; + + // Options + private JRadioButton asciiRadio; + private JRadioButton binaryRadio; + private JCheckBox crCheck; + private JCheckBox remapCheck; + private JCheckBox appendCheck; + private JCheckBox overwriteCheck; + + // TSO specific + private JTextField recfmField; + private JTextField lreclField; + private JTextField blksizeField; + private JTextField spaceField; + + // VM specific + private JTextField optionsField; + + private JButton transferButton; + private JButton cancelButton; + + public FileTransferDialog(Frame owner, FileTransfer coordinator) { + super(owner, "File Transfer (IND$FILE)", false); // non-modal to see progress + this.owner = owner; + this.coordinator = coordinator; + + buildUI(); + pack(); + setLocationRelativeTo(owner); + } + + private void buildUI() { + JPanel mainPanel = new JPanel(new BorderLayout(10, 10)); + mainPanel.setBorder(new EmptyBorder(10, 10, 10, 10)); + mainPanel.setBackground(new Color(25, 25, 25)); + + // Create form panel + JPanel formPanel = new JPanel(); + formPanel.setLayout(new BoxLayout(formPanel, BoxLayout.Y_AXIS)); + formPanel.setOpaque(false); + + // Host Type + hostTypeCombo = new JComboBox<>(FTConfig.HostType.values()); + formPanel.add(createRow("Host Environment:", hostTypeCombo)); + + // Direction + sendRadio = new JRadioButton("Send to Host"); + receiveRadio = new JRadioButton("Receive from Host"); + receiveRadio.setSelected(true); + ButtonGroup dirGroup = new ButtonGroup(); + dirGroup.add(sendRadio); + dirGroup.add(receiveRadio); + JPanel dirPanel = new JPanel(new FlowLayout(FlowLayout.LEFT, 0, 0)); + dirPanel.setOpaque(false); + dirPanel.add(receiveRadio); + dirPanel.add(Box.createRigidArea(new Dimension(15, 0))); + dirPanel.add(sendRadio); + + // Disable append/overwrite when sending + sendRadio.addActionListener(e -> updateOptionStates()); + receiveRadio.addActionListener(e -> updateOptionStates()); + + formPanel.add(createRow("Direction:", dirPanel)); + + // Local File + localFileField = new JTextField(20); + browseLocalButton = new JButton("Browse..."); + browseLocalButton.addActionListener(e -> browseLocalFile()); + JPanel localPanel = new JPanel(new BorderLayout(5, 0)); + localPanel.setOpaque(false); + localPanel.add(localFileField, BorderLayout.CENTER); + localPanel.add(browseLocalButton, BorderLayout.EAST); + formPanel.add(createRow("Local File:", localPanel)); + + // Host File + hostFileField = new JTextField(20); + formPanel.add(createRow("Host File:", hostFileField)); + + // Mode + asciiRadio = new JRadioButton("ASCII"); + asciiRadio.setSelected(true); + binaryRadio = new JRadioButton("Binary"); + ButtonGroup modeGroup = new ButtonGroup(); + modeGroup.add(asciiRadio); + modeGroup.add(binaryRadio); + JPanel modePanel = new JPanel(new FlowLayout(FlowLayout.LEFT, 0, 0)); + modePanel.setOpaque(false); + modePanel.add(asciiRadio); + modePanel.add(Box.createRigidArea(new Dimension(15, 0))); + modePanel.add(binaryRadio); + + asciiRadio.addActionListener(e -> updateOptionStates()); + binaryRadio.addActionListener(e -> updateOptionStates()); + + formPanel.add(createRow("Transfer Mode:", modePanel)); + + // Options (checkboxes) + crCheck = new JCheckBox("Add/Remove CR"); + crCheck.setSelected(true); + remapCheck = new JCheckBox("Remap Character Set"); + remapCheck.setSelected(true); + appendCheck = new JCheckBox("Append to file"); + overwriteCheck = new JCheckBox("Overwrite existing"); + + JPanel optionsPanel1 = new JPanel(new FlowLayout(FlowLayout.LEFT, 0, 0)); + optionsPanel1.setOpaque(false); + optionsPanel1.add(crCheck); + optionsPanel1.add(Box.createRigidArea(new Dimension(15, 0))); + optionsPanel1.add(remapCheck); + + JPanel optionsPanel2 = new JPanel(new FlowLayout(FlowLayout.LEFT, 0, 0)); + optionsPanel2.setOpaque(false); + optionsPanel2.add(appendCheck); + optionsPanel2.add(Box.createRigidArea(new Dimension(15, 0))); + optionsPanel2.add(overwriteCheck); + + formPanel.add(createRow("Text Options:", optionsPanel1)); + formPanel.add(createRow("File Options:", optionsPanel2)); + + // Host-specific options panel (TSO dataset allocation) + JPanel hostOptsPanel = new JPanel(new GridLayout(2, 4, 5, 5)); + hostOptsPanel.setOpaque(false); + hostOptsPanel.setBorder(BorderFactory.createTitledBorder( + BorderFactory.createLineBorder(new Color(60, 60, 60)), + "TSO Allocation Options (Send Only)")); + ((javax.swing.border.TitledBorder)hostOptsPanel.getBorder()).setTitleColor(new Color(180, 180, 180)); + + hostOptsPanel.add(new JLabel("RECFM:")); + recfmField = new JTextField(5); + hostOptsPanel.add(recfmField); + + hostOptsPanel.add(new JLabel("LRECL:")); + lreclField = new JTextField(5); + hostOptsPanel.add(lreclField); + + hostOptsPanel.add(new JLabel("BLKSIZE:")); + blksizeField = new JTextField(5); + hostOptsPanel.add(blksizeField); + + hostOptsPanel.add(new JLabel("SPACE:")); + spaceField = new JTextField(15); + hostOptsPanel.add(spaceField); + + formPanel.add(Box.createRigidArea(new Dimension(0, 10))); + formPanel.add(hostOptsPanel); + + // CMS options + JPanel vmOptsPanel = new JPanel(new BorderLayout(5, 0)); + vmOptsPanel.setOpaque(false); + vmOptsPanel.setBorder(BorderFactory.createTitledBorder( + BorderFactory.createLineBorder(new Color(60, 60, 60)), + "CMS Options")); + ((javax.swing.border.TitledBorder)vmOptsPanel.getBorder()).setTitleColor(new Color(180, 180, 180)); + + optionsField = new JTextField(20); + vmOptsPanel.add(new JLabel("Additional Options: "), BorderLayout.WEST); + vmOptsPanel.add(optionsField, BorderLayout.CENTER); + + formPanel.add(Box.createRigidArea(new Dimension(0, 10))); + formPanel.add(vmOptsPanel); + + mainPanel.add(formPanel, BorderLayout.CENTER); + + // Buttons + JPanel buttonPanel = new JPanel(new FlowLayout(FlowLayout.RIGHT)); + buttonPanel.setOpaque(false); + + transferButton = new JButton("Start Transfer"); + transferButton.addActionListener(e -> startTransfer()); + + cancelButton = new JButton("Close"); + cancelButton.addActionListener(e -> dispose()); + + buttonPanel.add(cancelButton); + buttonPanel.add(transferButton); + + mainPanel.add(buttonPanel, BorderLayout.SOUTH); + + setContentPane(mainPanel); + + // Theme styling for components + applyTheme(mainPanel); + + // Initialize state + updateOptionStates(); + } + + private void updateOptionStates() { + boolean isSend = sendRadio.isSelected(); + boolean isAscii = asciiRadio.isSelected(); + + appendCheck.setEnabled(!isSend); + overwriteCheck.setEnabled(!isSend); + + crCheck.setEnabled(isAscii); + remapCheck.setEnabled(isAscii); + + recfmField.setEnabled(isSend); + lreclField.setEnabled(isSend); + blksizeField.setEnabled(isSend); + spaceField.setEnabled(isSend); + } + + private JPanel createRow(String labelText, Component comp) { + JPanel row = new JPanel(new BorderLayout(10, 0)); + row.setOpaque(false); + JLabel label = new JLabel(labelText); + label.setPreferredSize(new Dimension(130, 25)); + row.add(label, BorderLayout.WEST); + row.add(comp, BorderLayout.CENTER); + row.setBorder(new EmptyBorder(0, 0, 5, 0)); + return row; + } + + private void browseLocalFile() { + JFileChooser chooser = new JFileChooser(); + if (chooser.showOpenDialog(this) == JFileChooser.APPROVE_OPTION) { + localFileField.setText(chooser.getSelectedFile().getAbsolutePath()); + + // Auto-fill host file if empty + if (hostFileField.getText().trim().isEmpty()) { + hostFileField.setText(chooser.getSelectedFile().getName()); + } + } + } + + private void startTransfer() { + if (localFileField.getText().trim().isEmpty() || hostFileField.getText().trim().isEmpty()) { + JOptionPane.showMessageDialog(this, "Local and Host filenames are required.", + "Error", JOptionPane.ERROR_MESSAGE); + return; + } + + FTConfig config = new FTConfig(); + config.setHostType((FTConfig.HostType) hostTypeCombo.getSelectedItem()); + config.setLocalFilename(localFileField.getText().trim()); + config.setHostFilename(hostFileField.getText().trim()); + config.setReceive(receiveRadio.isSelected()); + config.setAscii(asciiRadio.isSelected()); + config.setCrFlag(crCheck.isSelected()); + config.setRemapFlag(remapCheck.isSelected()); + config.setAppend(appendCheck.isSelected()); + config.setOverwrite(overwriteCheck.isSelected()); + + config.setRecfm(recfmField.getText().trim()); + config.setLrecl(lreclField.getText().trim()); + config.setBlksize(blksizeField.getText().trim()); + config.setSpace(spaceField.getText().trim()); + config.setOptions(optionsField.getText().trim()); + + String error = coordinator.startTransfer(config); + if (error != null) { + JOptionPane.showMessageDialog(this, error, "Transfer Error", JOptionPane.ERROR_MESSAGE); + } else { + // Success, close dialog (progress will be shown separately) + dispose(); + } + } + + private void applyTheme(Container container) { + Color fg = new Color(200, 200, 200); + Color bg = new Color(40, 40, 40); + for (Component c : container.getComponents()) { + c.setForeground(fg); + if (c instanceof JTextField || c instanceof JComboBox) { + c.setBackground(bg); + if (c instanceof JTextField) { + ((JTextField) c).setCaretColor(fg); + ((JTextField) c).setBorder(BorderFactory.createCompoundBorder( + BorderFactory.createLineBorder(new Color(60, 60, 60)), + new EmptyBorder(2, 5, 2, 5))); + } + } else if (c instanceof JButton) { + c.setBackground(new Color(50, 50, 50)); + c.setFocusable(false); + } else if (c instanceof JRadioButton || c instanceof JCheckBox) { + ((JComponent) c).setOpaque(false); + c.setFocusable(false); + } + if (c instanceof Container) { + applyTheme((Container) c); + } + } + } +} diff --git a/lib3270j/src/main/java/org/lib3270j/ft/FTConfig.java b/lib3270j/src/main/java/org/lib3270j/ft/FTConfig.java new file mode 100644 index 0000000..97d1703 --- /dev/null +++ b/lib3270j/src/main/java/org/lib3270j/ft/FTConfig.java @@ -0,0 +1,310 @@ +package org.lib3270j.ft; + +/** + * Configuration for an IND$FILE file transfer session. + * Ported from x3270's ft_conf_t (ft_private.h). + */ +public class FTConfig { + + /** Host operating system type */ + public enum HostType { + TSO, CMS, CICS + } + + /** Record format for datasets (TSO/CMS sends only) */ + public enum RecordFormat { + DEFAULT, FIXED, VARIABLE, UNDEFINED + } + + /** Space allocation units (TSO sends only) */ + public enum AllocationUnit { + DEFAULT, TRACKS, CYLINDERS, AVBLOCK + } + + /** Transfer direction */ + public enum Direction { + RECEIVE, SEND + } + + /** Transfer mode */ + public enum TransferMode { + ASCII, BINARY + } + + /** CR/LF handling */ + public enum CrAction { + ADD, REMOVE, KEEP + } + + /** Behavior when destination file already exists */ + public enum ExistAction { + KEEP, REPLACE, APPEND + } + + // ========== Transfer Parameters ========== + + private String hostFilename; + private String localFilename; + private Direction direction = Direction.RECEIVE; + private HostType hostType = HostType.TSO; + private TransferMode transferMode = TransferMode.ASCII; + private CrAction crAction = CrAction.REMOVE; + private boolean remapFlag = true; + private ExistAction existAction = ExistAction.KEEP; + private RecordFormat recfm = RecordFormat.DEFAULT; + private AllocationUnit units = AllocationUnit.DEFAULT; + private int lrecl = 0; + private int blksize = 0; + private int primarySpace = 0; + private int secondarySpace = 0; + private int avblock = 0; + private int dftBufferSize = FTConstants.DFT_BUF; + private String otherOptions = null; + + // ========== Derived convenience getters ========== + + public boolean isReceive() { return direction == Direction.RECEIVE; } + public boolean isSend() { return direction == Direction.SEND; } + public boolean isAscii() { return transferMode == TransferMode.ASCII; } + public boolean isBinary() { return transferMode == TransferMode.BINARY; } + public boolean isCrFlag() { + // CR processing is only applicable for ASCII transfers + return isAscii() && crAction != CrAction.KEEP; + } + public boolean isAppend() { return existAction == ExistAction.APPEND; } + public boolean isOverwrite() { return existAction == ExistAction.REPLACE; } + + // ========== Standard getters and setters ========== + + public String getHostFilename() { return hostFilename; } + public void setHostFilename(String hostFilename) { this.hostFilename = hostFilename; } + + public String getLocalFilename() { return localFilename; } + public void setLocalFilename(String localFilename) { this.localFilename = localFilename; } + + public Direction getDirection() { return direction; } + public void setDirection(Direction direction) { this.direction = direction; } + + public HostType getHostType() { return hostType; } + public void setHostType(HostType hostType) { this.hostType = hostType; } + + public TransferMode getTransferMode() { return transferMode; } + public void setTransferMode(TransferMode transferMode) { this.transferMode = transferMode; } + + public CrAction getCrAction() { return crAction; } + public void setCrAction(CrAction crAction) { this.crAction = crAction; } + + public boolean isRemapFlag() { return remapFlag; } + public void setRemapFlag(boolean remapFlag) { this.remapFlag = remapFlag; } + + public ExistAction getExistAction() { return existAction; } + public void setExistAction(ExistAction existAction) { this.existAction = existAction; } + + public RecordFormat getRecfm() { return recfm; } + public void setRecfm(RecordFormat recfm) { this.recfm = recfm; } + + public AllocationUnit getUnits() { return units; } + public void setUnits(AllocationUnit units) { this.units = units; } + + public int getLrecl() { return lrecl; } + public void setLrecl(int lrecl) { this.lrecl = lrecl; } + + public int getBlksize() { return blksize; } + public void setBlksize(int blksize) { this.blksize = blksize; } + + public int getPrimarySpace() { return primarySpace; } + public void setPrimarySpace(int primarySpace) { this.primarySpace = primarySpace; } + + public int getSecondarySpace() { return secondarySpace; } + public void setSecondarySpace(int secondarySpace) { this.secondarySpace = secondarySpace; } + + public int getAvblock() { return avblock; } + public void setAvblock(int avblock) { this.avblock = avblock; } + + public int getDftBufferSize() { return dftBufferSize; } + public void setDftBufferSize(int dftBufferSize) { + this.dftBufferSize = Math.max(FTConstants.DFT_MIN_BUF, + Math.min(FTConstants.DFT_MAX_BUF, dftBufferSize)); + } + + public String getOtherOptions() { return otherOptions; } + public void setOtherOptions(String otherOptions) { + this.otherOptions = (otherOptions != null && !otherOptions.trim().isEmpty()) + ? otherOptions.trim() : null; + } + + // ========== Convenience UI setters ========== + + public void setAppend(boolean append) { + if (append) this.existAction = ExistAction.APPEND; + } + + public void setOverwrite(boolean overwrite) { + if (overwrite) this.existAction = ExistAction.REPLACE; + } + + public void setReceive(boolean receive) { + setDirection(receive ? Direction.RECEIVE : Direction.SEND); + } + + public void setAscii(boolean ascii) { + setTransferMode(ascii ? TransferMode.ASCII : TransferMode.BINARY); + } + + public void setCrFlag(boolean cr) { + setCrAction(cr ? CrAction.REMOVE : CrAction.KEEP); + } + + public void setRecfm(String r) { + if (r == null || r.trim().isEmpty()) { + this.recfm = RecordFormat.DEFAULT; + } else { + String u = r.trim().toUpperCase(); + if (u.equals("F") || u.startsWith("FIXED")) this.recfm = RecordFormat.FIXED; + else if (u.equals("V") || u.startsWith("VAR")) this.recfm = RecordFormat.VARIABLE; + else if (u.equals("U") || u.startsWith("UNDEF")) this.recfm = RecordFormat.UNDEFINED; + else this.recfm = RecordFormat.DEFAULT; + } + } + + public void setLrecl(String l) { + try { this.lrecl = Integer.parseInt(l.trim()); } catch (NumberFormatException e) { this.lrecl = 0; } + } + + public void setBlksize(String b) { + try { this.blksize = Integer.parseInt(b.trim()); } catch (NumberFormatException e) { this.blksize = 0; } + } + + public void setSpace(String s) { + // Space string like "10,5" for primary/secondary + if (s == null || s.trim().isEmpty()) return; + String[] parts = s.split(","); + try { + if (parts.length > 0) this.primarySpace = Integer.parseInt(parts[0].trim()); + if (parts.length > 1) this.secondarySpace = Integer.parseInt(parts[1].trim()); + } catch (NumberFormatException e) { + // ignore + } + } + + public void setOptions(String opts) { + setOtherOptions(opts); + } + + // ========== Validation ========== + + /** + * Validate the configuration before starting a transfer. + * @return null if valid, or an error message string + */ + public String validate() { + if (hostFilename == null || hostFilename.trim().isEmpty()) { + return "Host file name is required"; + } + if (localFilename == null || localFilename.trim().isEmpty()) { + return "Local file name is required"; + } + if (hostType == HostType.TSO && isSend() && + units != AllocationUnit.DEFAULT && primarySpace <= 0) { + return "Primary space is required when allocation is specified"; + } + if (hostType == HostType.TSO && isSend() && + units == AllocationUnit.AVBLOCK && avblock <= 0) { + return "Avblock value is required when allocation is AVBLOCK"; + } + return null; + } + + /** + * Build the IND$FILE command string to send to the host. + * Ported from x3270's ft_go() in ft.c. + */ + public String buildCommand() { + StringBuilder cmd = new StringBuilder(); + + // IND$FILE GET/PUT hostfile + cmd.append("IND$FILE "); + cmd.append(isReceive() ? "GET " : "PUT "); + cmd.append(hostFilename); + + // CMS/CICS options use ( prefix + if (hostType != HostType.TSO) { + cmd.append(" ("); + } else { + cmd.append(" "); + } + + // Mode + if (isAscii()) { + cmd.append("ASCII"); + } else if (hostType == HostType.CICS) { + cmd.append("BINARY"); + } + + // CR/LF handling + if (isAscii() && isCrFlag()) { + cmd.append(" CRLF"); + } else if (hostType == HostType.CICS) { + cmd.append(" NOCRLF"); + } + + // Append (send only) + if (isAppend() && isSend()) { + cmd.append(" APPEND"); + } + + // TSO-specific send options + if (isSend()) { + if (hostType == HostType.TSO) { + if (recfm != RecordFormat.DEFAULT) { + cmd.append(" RECFM("); + switch (recfm) { + case FIXED: cmd.append("F"); break; + case VARIABLE: cmd.append("V"); break; + case UNDEFINED: cmd.append("U"); break; + default: break; + } + cmd.append(")"); + if (lrecl > 0) { + cmd.append(" LRECL(").append(lrecl).append(")"); + } + if (blksize > 0) { + cmd.append(" BLKSIZE(").append(blksize).append(")"); + } + } + if (units != AllocationUnit.DEFAULT) { + cmd.append(" SPACE(").append(primarySpace); + if (secondarySpace > 0) { + cmd.append(",").append(secondarySpace); + } + cmd.append(")"); + switch (units) { + case TRACKS: cmd.append(" TRACKS"); break; + case CYLINDERS: cmd.append(" CYLINDERS"); break; + case AVBLOCK: cmd.append(" AVBLOCK(").append(avblock).append(")"); break; + default: break; + } + } + } else if (hostType == HostType.CMS) { + if (recfm != RecordFormat.DEFAULT) { + cmd.append(" RECFM "); + switch (recfm) { + case FIXED: cmd.append("F"); break; + case VARIABLE: cmd.append("V"); break; + default: break; + } + if (lrecl > 0) { + cmd.append(" LRECL ").append(lrecl); + } + } + } + } + + // Additional options + if (otherOptions != null) { + cmd.append(" ").append(otherOptions); + } + + return cmd.toString().trim(); + } +} diff --git a/lib3270j/src/main/java/org/lib3270j/ft/FTConstants.java b/lib3270j/src/main/java/org/lib3270j/ft/FTConstants.java new file mode 100644 index 0000000..62d12ec --- /dev/null +++ b/lib3270j/src/main/java/org/lib3270j/ft/FTConstants.java @@ -0,0 +1,200 @@ +package org.lib3270j.ft; + +/** + * Constants for IND$FILE file transfer protocol. + * Ported from x3270: ft_cut_ds.h, ft_dft_ds.h, ft.c + */ +public final class FTConstants { + + private FTConstants() {} // utility class + + // ========== CUT Mode Frame Layout ========== + + /** Offset to the CUT structured field at the end of the screen */ + public static final int O_SF = 1919; + + // Primary area offsets + public static final int O_FRAME_TYPE = 0; + + // Control Code frame (host → terminal) + public static final int FT_CONTROL_CODE = 0xC3; + public static final int O_CC_FRAME_SEQ = 1; + public static final int O_CC_STATUS_CODE = 2; + public static final int O_CC_MESSAGE = 4; + + // Control Code status codes + public static final int SC_HOST_ACK = 0x8181; + public static final int SC_XFER_COMPLETE = 0x8189; + public static final int SC_ABORT_FILE = 0x8194; + public static final int SC_ABORT_XMIT = 0x8198; + + // Data Request frame (host → terminal, for uploads) + public static final int FT_DATA_REQUEST = 0xC2; + public static final int O_DR_SF = 1; + public static final int O_DR_DATA_CODE = 2; + public static final int O_DR_FRAME_SEQ = 3; + + // Retransmit frame + public static final int FT_RETRANSMIT = 0x4C; + + // Data frame (bidirectional) + public static final int FT_DATA = 0xC1; + public static final int O_DT_FRAME_SEQ = 1; + public static final int O_DT_CSUM = 2; + public static final int O_DT_LEN = 3; + public static final int O_DT_DATA = 5; + + // Response Area (near end of screen) + public static final int O_RESPONSE = O_SF - 5; + public static final int RO_FRAME_TYPE = O_RESPONSE + 1; + public static final int RO_FRAME_SEQ = O_RESPONSE + 2; + public static final int RO_REASON_CODE = O_RESPONSE + 3; + + // Response frame types + public static final int RFT_RETRANSMIT = 0x4C; + public static final int RFT_CONTROL_CODE = 0xC3; + + // Special EOF data markers + public static final int EOF_DATA1 = 0x5C; + public static final int EOF_DATA2 = 0xA9; + + // Upload data area offsets + public static final int O_UP_DATA_CODE = 2; + public static final int O_UP_FRAME_SEQ = 3; + public static final int O_UP_CSUM = 4; + public static final int O_UP_LEN = 5; + public static final int O_UP_DATA = 7; + public static final int O_UP_MAX = O_SF - O_UP_DATA; + + // ========== CUT Mode AID codes ========== + + public static final int ACK_OK = 0x7D; // AID_ENTER + public static final int ACK_RETRANSMIT = 0xF1; // AID_PF1 + public static final int ACK_RESYNC_VM = 0x6D; // AID_CLEAR + public static final int ACK_RESYNC_TSO = 0x6E; // AID_PA2 + public static final int ACK_ABORT = 0xF2; // AID_PF2 + + // ========== DFT Mode Structured Field Codes ========== + + /** Structured field type for file transfer data */ + public static final int SF_TRANSFER_DATA = 0xD0; + + // Host requests + public static final int TR_OPEN_REQ = 0x0012; + public static final int TR_CLOSE_REQ = 0x4112; + public static final int TR_SET_CUR_REQ = 0x4511; + public static final int TR_GET_REQ = 0x4611; + public static final int TR_INSERT_REQ = 0x4711; + public static final int TR_DATA_INSERT = 0x4704; + + // PC replies + public static final int TR_GET_REPLY = 0x4605; + public static final int TR_NORMAL_REPLY = 0x4705; + public static final int TR_ERROR_REPLY = 0x08; // low 8 bits + public static final int TR_CLOSE_REPLY = 0x4109; + + // Other headers + public static final int TR_RECNUM_HDR = 0x6306; + public static final int TR_ERROR_HDR = 0x6904; + public static final int TR_NOT_COMPRESSED = 0xC080; + public static final int TR_BEGIN_DATA = 0x61; + + // Error codes + public static final int TR_ERR_EOF = 0x2200; + public static final int TR_ERR_CMDFAIL = 0x0100; + + // DFT buffer size limits + public static final int DFT_MIN_BUF = 256; + public static final int DFT_MAX_BUF = 32768; + public static final int DFT_BUF = 4096; + + // AID for structured field response + public static final int AID_SF = 0x88; + + // ========== Transfer State ========== + + public enum FTState { + NONE, // No transfer in progress + AWAIT_ACK, // IND$FILE sent, awaiting acknowledgement + RUNNING, // Ack received, data flowing + ABORT_WAIT, // Awaiting chance to send an abort + ABORT_SENT // Abort sent; awaiting response + } + + // ========== Encoding Tables ========== + + /** + * Base-64-like encoding table used by CUT mode for lengths and checksums. + * 64 characters: a-z, &, -, ., :, +, A-Z, 0-5 + */ + public static final String TABLE6 = + "abcdefghijklmnopqrstuvwxyz&-.,:+ABCDEFGHIJKLMNOPQRSTUVWXYZ012345"; + + /** + * IND$FILE's fixed ASCII-to-EBCDIC translation table (i_asc2ft). + * This is NOT the standard CP037 mapping — it's IND$FILE's own table. + * Used when remap=true to invert IND$FILE's built-in translation. + */ + public static final int[] ASC2FT = { + 0x00,0x01,0x02,0x03,0x37,0x2d,0x2e,0x2f,0x16,0x05,0x0a,0x0b,0x0c,0x0d,0x0e,0x0f, + 0x10,0x11,0x12,0x13,0x3c,0x3d,0x32,0x26,0x18,0x19,0x3f,0x27,0x1c,0x1d,0x1e,0x1f, + 0x40,0x5a,0x7f,0x7b,0x5b,0x6c,0x50,0x7d,0x4d,0x5d,0x5c,0x4e,0x6b,0x60,0x4b,0x61, + 0xf0,0xf1,0xf2,0xf3,0xf4,0xf5,0xf6,0xf7,0xf8,0xf9,0x7a,0x5e,0x4c,0x7e,0x6e,0x6f, + 0x7c,0xc1,0xc2,0xc3,0xc4,0xc5,0xc6,0xc7,0xc8,0xc9,0xd1,0xd2,0xd3,0xd4,0xd5,0xd6, + 0xd7,0xd8,0xd9,0xe2,0xe3,0xe4,0xe5,0xe6,0xe7,0xe8,0xe9,0x4a,0xe0,0x4f,0x5f,0x6d, + 0x79,0x81,0x82,0x83,0x84,0x85,0x86,0x87,0x88,0x89,0x91,0x92,0x93,0x94,0x95,0x96, + 0x97,0x98,0x99,0xa2,0xa3,0xa4,0xa5,0xa6,0xa7,0xa8,0xa9,0xc0,0x6a,0xd0,0xa1,0x07, + 0x20,0x21,0x22,0x23,0x24,0x15,0x06,0x17,0x28,0x29,0x2a,0x2b,0x2c,0x09,0x0a,0x1b, + 0x30,0x31,0x1a,0x33,0x34,0x35,0x36,0x08,0x38,0x39,0x3a,0x3b,0x04,0x14,0x3e,0xe1, + 0x41,0x42,0x43,0x44,0x45,0x46,0x47,0x48,0x49,0x51,0x52,0x53,0x54,0x55,0x56,0x57, + 0x58,0x59,0x62,0x63,0x64,0x65,0x66,0x67,0x68,0x69,0x70,0x71,0x72,0x73,0x74,0x75, + 0x76,0x77,0x78,0x80,0x8a,0x8b,0x8c,0x8d,0x8e,0x8f,0x90,0x9a,0x9b,0x9c,0x9d,0x9e, + 0x9f,0xa0,0xaa,0xab,0xac,0xad,0xae,0xaf,0xb0,0xb1,0xb2,0xb3,0xb4,0xb5,0xb6,0xb7, + 0xb8,0xb9,0xba,0xbb,0xbc,0xbd,0xbe,0xbf,0xca,0xcb,0xcc,0xcd,0xce,0xcf,0xda,0xdb, + 0xdc,0xdd,0xde,0xdf,0xea,0xeb,0xec,0xed,0xee,0xef,0xfa,0xfb,0xfc,0xfd,0xfe,0xff + }; + + /** + * IND$FILE's fixed EBCDIC-to-ASCII translation table (i_ft2asc). + * The inverse of ASC2FT. + */ + public static final int[] FT2ASC = { + 0x00,0x01,0x02,0x03,0x9c,0x09,0x86,0x7f,0x97,0x8d,0x0a,0x0b,0x0c,0x0d,0x0e,0x0f, + 0x10,0x11,0x12,0x13,0x9d,0x85,0x08,0x87,0x18,0x19,0x92,0x8f,0x1c,0x1d,0x1e,0x1f, + 0x80,0x81,0x82,0x83,0x84,0x00,0x17,0x1b,0x88,0x89,0x8a,0x8b,0x8c,0x05,0x06,0x07, + 0x90,0x91,0x16,0x93,0x94,0x95,0x96,0x04,0x98,0x99,0x9a,0x9b,0x14,0x15,0x9e,0x1a, + 0x20,0xa0,0xa1,0xa2,0xa3,0xa4,0xa5,0xa6,0xa7,0xa8,0x5b,0x2e,0x3c,0x28,0x2b,0x5d, + 0x26,0xa9,0xaa,0xab,0xac,0xad,0xae,0xaf,0xb0,0xb1,0x21,0x24,0x2a,0x29,0x3b,0x5e, + 0x2d,0x2f,0xb2,0xb3,0xb4,0xb5,0xb6,0xb7,0xb8,0xb9,0x7c,0x2c,0x25,0x5f,0x3e,0x3f, + 0xba,0xbb,0xbc,0xbd,0xbe,0xbf,0xc0,0xc1,0xc2,0x60,0x3a,0x23,0x40,0x27,0x3d,0x22, + 0xc3,0x61,0x62,0x63,0x64,0x65,0x66,0x67,0x68,0x69,0xc4,0xc5,0xc6,0xc7,0xc8,0xc9, + 0xca,0x6a,0x6b,0x6c,0x6d,0x6e,0x6f,0x70,0x71,0x72,0xcb,0xcc,0xcd,0xce,0xcf,0xd0, + 0xd1,0x7e,0x73,0x74,0x75,0x76,0x77,0x78,0x79,0x7a,0xd2,0xd3,0xd4,0xd5,0xd6,0xd7, + 0xd8,0xd9,0xda,0xdb,0xdc,0xdd,0xde,0xdf,0xe0,0xe1,0xe2,0xe3,0xe4,0xe5,0xe6,0xe7, + 0x7b,0x41,0x42,0x43,0x44,0x45,0x46,0x47,0x48,0x49,0xe8,0xe9,0xea,0xeb,0xec,0xed, + 0x7d,0x4a,0x4b,0x4c,0x4d,0x4e,0x4f,0x50,0x51,0x52,0xee,0xef,0xf0,0xf1,0xf2,0xf3, + 0x5c,0x9f,0x53,0x54,0x55,0x56,0x57,0x58,0x59,0x5a,0xf4,0xf5,0xf6,0xf7,0xf8,0xf9, + 0x30,0x31,0x32,0x33,0x34,0x35,0x36,0x37,0x38,0x39,0xfa,0xfb,0xfc,0xfd,0xfe,0xff + }; + + // ========== Utility Methods ========== + + /** + * Decode a CUT-mode base-64 encoded integer from EBCDIC. + * Converts a table6-encoded EBCDIC character to its 6-bit value. + */ + public static int from6(int ebcdicByte) { + // First convert EBCDIC to ASCII via IND$FILE's table + int ascii = FT2ASC[ebcdicByte & 0xFF]; + int idx = TABLE6.indexOf((char) ascii); + return idx >= 0 ? idx : 0; + } + + /** + * Encode a 6-bit value into a table6-encoded EBCDIC character. + */ + public static int to6(int value) { + char ascii = TABLE6.charAt(value & 0x3F); + return ASC2FT[ascii & 0xFF]; + } +} diff --git a/lib3270j/src/main/java/org/lib3270j/ft/FTCut.java b/lib3270j/src/main/java/org/lib3270j/ft/FTCut.java new file mode 100644 index 0000000..8733608 --- /dev/null +++ b/lib3270j/src/main/java/org/lib3270j/ft/FTCut.java @@ -0,0 +1,587 @@ +package org.lib3270j.ft; + +import org.lib3270j.screen.ScreenBuffer; +import org.lib3270j.input.InputProcessor; +import org.lib3270j.charset.EbcdicTranslator; +import static org.lib3270j.ft.FTConstants.*; +import static org.lib3270j.protocol.DS3270Constants.*; + +import java.io.*; +import java.util.logging.Logger; + +/** + * CUT (Character Unit Transfer) mode file transfer handler. + * Data flows through the screen buffer using a framed protocol. + * Ported from x3270's ft_cut.c. + */ +public class FTCut { + + private static final Logger log = Logger.getLogger(FTCut.class.getName()); + + /** Callback for transfer events */ + public interface FTCutListener { + void onTransferRunning(); + void onTransferComplete(String errorMessage); + void onTransferAborted(String errorMessage); + void onBytesTransferred(long bytes); + FTConstants.FTState getCurrentState(); + void setState(FTConstants.FTState state); + FTConfig getConfig(); + File getLocalFile(); + } + + private final ScreenBuffer screen; + private final InputProcessor input; + private final EbcdicTranslator translator; + private final FTCutListener listener; + + // CUT mode state + private boolean xferInProgress = false; + private long expandedLength = 0; + private int quadrant = -1; + private boolean cutEof = false; + + // Upload translation buffer + private static final int XLATE_NBUF = 32; + private int xlateBuffered = 0; + private int xlateBufIx = 0; + private final int[] xlateBuf = new int[XLATE_NBUF]; + + private static final String ALPHAS = " ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789%&_()<+,-./:>?"; + + private static final int SELECTOR_0 = 0x5E; // ';' + private static final int SELECTOR_1 = 0x7E; // '=' + private static final int SELECTOR_2 = 0x5C; // '*' + private static final int SELECTOR_3 = 0x7D; // '\'' + + private static final int[] XLATE_0 = { + 0x40,0xc1,0xc2,0xc3, 0xc4,0xc5,0xc6,0xc7, 0xc8,0xc9,0xd1,0xd2, + 0xd3,0xd4,0xd5,0xd6, 0xd7,0xd8,0xd9,0xe2, 0xe3,0xe4,0xe5,0xe6, + 0xe7,0xe8,0xe9,0x81, 0x82,0x83,0x84,0x85, 0x86,0x87,0x88,0x89, + 0x91,0x92,0x93,0x94, 0x95,0x96,0x97,0x98, 0x99,0xa2,0xa3,0xa4, + 0xa5,0xa6,0xa7,0xa8, 0xa9,0xf0,0xf1,0xf2, 0xf3,0xf4,0xf5,0xf6, + 0xf7,0xf8,0xf9,0x6c, 0x50,0x6d,0x4d,0x5d, 0x4c,0x4e,0x6b,0x60, + 0x4b,0x61,0x7a,0x6e, 0x6f + }; + + private static final int[] XLATE_1 = { + 0x20,0x41,0x42,0x43, 0x44,0x45,0x46,0x47, 0x48,0x49,0x4a,0x4b, + 0x4c,0x4d,0x4e,0x4f, 0x50,0x51,0x52,0x53, 0x54,0x55,0x56,0x57, + 0x58,0x59,0x5a,0x61, 0x62,0x63,0x64,0x65, 0x66,0x67,0x68,0x69, + 0x6a,0x6b,0x6c,0x6d, 0x6e,0x6f,0x70,0x71, 0x72,0x73,0x74,0x75, + 0x76,0x77,0x78,0x79, 0x7a,0x30,0x31,0x32, 0x33,0x34,0x35,0x36, + 0x37,0x38,0x39,0x25, 0x26,0x27,0x28,0x29, 0x2a,0x2b,0x2c,0x2d, + 0x2e,0x2f,0x3a,0x3b, 0x3f + }; + + private static final int[] XLATE_2 = { + 0x00,0x00,0x01,0x02, 0x03,0x04,0x05,0x06, 0x07,0x08,0x09,0x0a, + 0x0b,0x0c,0x0d,0x0e, 0x0f,0x10,0x11,0x12, 0x13,0x14,0x15,0x16, + 0x17,0x18,0x19,0x00, 0x00,0x00,0x00,0x00, 0x00,0x00,0x00,0x00, + 0x00,0x00,0x00,0x00, 0x00,0x00,0x00,0x00, 0x00,0x00,0x00,0x00, + 0x00,0x00,0x00,0x00, 0x00,0x3c,0x3d,0x3e, 0x00,0xfa,0xfb,0xfc, + 0xfd,0xfe,0xff,0x7b, 0x7c,0x7d,0x7e,0x7f, 0x1a,0x1b,0x1c,0x1d, + 0x1e,0x1f,0x00,0x00, 0x00 + }; + + private static final int[] XLATE_3 = { + 0x00,0xa0,0xa1,0xea, 0xeb,0xec,0xed,0xee, 0xef,0xe0,0xe1,0xaa, + 0xab,0xac,0xad,0xae, 0xaf,0xb0,0xb1,0xb2, 0xb3,0xb4,0xb5,0xb6, + 0xb7,0xb8,0xb9,0x80, 0x00,0xca,0xcb,0xcc, 0xcd,0xce,0xcf,0xc0, + 0x00,0x8a,0x8b,0x8c, 0x8d,0x8e,0x8f,0x90, 0x00,0xda,0xdb,0xdc, + 0xdd,0xde,0xdf,0xd0, 0x00,0x00,0x21,0x22, 0x23,0x24,0x5b,0x5c, + 0x00,0x5e,0x5f,0x00, 0x9c,0x9d,0x9e,0x9f, 0xba,0xbb,0xbc,0xbd, + 0xbe,0xbf,0x9a,0x9b, 0x00 + }; + + private static final int[][] QUADS = { XLATE_0, XLATE_1, XLATE_2, XLATE_3 }; + private static final int[] SELECTORS = { SELECTOR_0, SELECTOR_1, SELECTOR_2, SELECTOR_3 }; + + // File I/O + private InputStream inputStream; + private OutputStream outputStream; + private boolean lastCr = false; + + public FTCut(ScreenBuffer screen, InputProcessor input, + EbcdicTranslator translator, FTCutListener listener) { + this.screen = screen; + this.input = input; + this.translator = translator; + this.listener = listener; + } + + /** + * Process a CUT-mode screen update. + * Called by the transfer coordinator when the screen changes during a transfer. + */ + public void processScreenUpdate() { + if (listener.getCurrentState() == FTState.NONE) return; + + // CUT frames MUST have a skip field attribute at O_SF (1919) + byte sfAttr = screen.getCellFAByte(O_SF); + if (sfAttr == 0 || !isSkip(sfAttr)) { + // Not a CUT frame (likely local echo or intermediate screen), ignore + return; + } + + int frameType = screen.getCellEC(O_FRAME_TYPE); + + switch (frameType) { + case FT_CONTROL_CODE: + cutControlCode(); + break; + case FT_DATA_REQUEST: + cutDataRequest(); + break; + case FT_RETRANSMIT: + cutRetransmit(); + break; + case FT_DATA: + cutData(); + break; + default: + log.fine("Ignoring non-CUT frame type 0x" + Integer.toHexString(frameType)); + return; + } + } + + private boolean isSkip(byte attr) { + return (attr & FA_PROTECT) != 0 && (attr & FA_NUMERIC) != 0; + } + + /** + * Initialize CUT mode for a new transfer. + */ + public void initTransfer(File localFile) throws IOException { + FTConfig config = listener.getConfig(); + xferInProgress = false; + expandedLength = 0; + quadrant = -1; + xlateBuffered = 0; + xlateBufIx = 0; + cutEof = false; + lastCr = false; + + if (config.isReceive()) { + boolean append = config.isAppend(); + outputStream = new FileOutputStream(localFile, append); + inputStream = null; + } else { + inputStream = new FileInputStream(localFile); + outputStream = null; + } + } + + /** + * Clean up CUT mode resources. + */ + public void cleanup() { + xferInProgress = false; + try { + if (inputStream != null) { inputStream.close(); inputStream = null; } + if (outputStream != null) { outputStream.close(); outputStream = null; } + } catch (IOException e) { + log.warning("Error closing file: " + e.getMessage()); + } + } + + // ========== Control Code Processing ========== + + private void cutControlCode() { + int code = (screen.getCellEC(O_CC_STATUS_CODE) << 8) | + screen.getCellEC(O_CC_STATUS_CODE + 1); + log.fine("CUT: CONTROL_CODE 0x" + Integer.toHexString(code)); + + switch (code) { + case SC_HOST_ACK: + log.info("CUT: HOST_ACK received — transfer running"); + xferInProgress = true; + expandedLength = 0; + quadrant = -1; + xlateBuffered = 0; + xlateBufIx = 0; + cutEof = false; + cutAck(); + listener.onTransferRunning(); + break; + + case SC_XFER_COMPLETE: + log.info("CUT: Transfer complete"); + cutAck(); + xferInProgress = false; + listener.onTransferComplete(null); + break; + + case SC_ABORT_FILE: + case SC_ABORT_XMIT: + log.warning("CUT: ABORT received"); + xferInProgress = false; + cutAck(); + + // Extract error message from the host (positions 4-83) + String msg = extractHostMessage(); + listener.onTransferAborted(msg); + break; + + default: + log.warning("CUT: Unknown control code 0x" + Integer.toHexString(code)); + cutAbort("Unknown CUT control code", SC_ABORT_XMIT); + break; + } + } + + /** + * Extract the error message from the host's control code frame. + * The message starts at O_CC_MESSAGE and is up to 80 EBCDIC characters. + */ + private String extractHostMessage() { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < 80; i++) { + int ebc = screen.getCellEC(O_CC_MESSAGE + i); + if (ebc == 0) continue; + char ch = translator.ebcdicToUnicode(ebc); + sb.append(ch); + } + // Trim trailing spaces and '$' + String msg = sb.toString().stripTrailing(); + if (msg.endsWith("$")) { + msg = msg.substring(0, msg.length() - 1).stripTrailing(); + } + return msg.isEmpty() ? "Host cancelled transfer" : msg; + } + + // ========== Data Request (Upload) ========== + + /** + * Process a data request from the host (upload: send data to host). + */ + private void cutDataRequest() { + int seq = screen.getCellEC(O_DR_FRAME_SEQ); + log.fine("CUT: DATA_REQUEST seq=" + from6(seq)); + + if (listener.getCurrentState() == FTState.ABORT_WAIT) { + cutAbort("Transfer cancelled by user", SC_ABORT_FILE); + return; + } + + FTConfig config = listener.getConfig(); + + // Read data from local file into screen buffer + int count = 0; + try { + while (count < O_UP_MAX && !cutEof) { + int c = xlateGetc(config); + if (c == -1) { + cutEof = true; + break; + } + screen.setCell(O_UP_DATA + count, c); + count++; + } + } catch (IOException e) { + log.warning("CUT: Read error: " + e.getMessage()); + cutAbort("Read error: " + e.getMessage(), SC_ABORT_FILE); + return; + } + + // EOF with no data → send EOF marker + if (count == 0 && cutEof) { + screen.setCell(O_UP_DATA, EOF_DATA1); + screen.setCell(O_UP_DATA + 1, EOF_DATA2); + count = 2; + } + + // Compute frame fields + screen.setCell(O_UP_FRAME_SEQ, seq); + + // Checksum + int cs = 0; + for (int i = 0; i < count; i++) { + cs ^= screen.getCellEC(O_UP_DATA + i); + } + screen.setCell(O_UP_CSUM, to6(cs)); + screen.setCell(O_UP_LEN, to6((count >> 6) & 0x3F)); + screen.setCell(O_UP_LEN + 1, to6(count & 0x3F)); + + // Hide the data field (change SF attribute to zero intensity) + byte attr = screen.getCellFAByte(O_DR_SF); + attr = (byte) ((attr & ~FA_INTENSITY) | FA_INT_ZERO_NSEL); + screen.setCellFA(O_DR_SF, attr); + + // Send it + log.fine("CUT: > DATA seq=" + from6(seq) + " len=" + count); + expandedLength += count; + listener.onBytesTransferred(expandedLength); + input.sendAidForFT(AID_ENTER); + } + + // ========== Data (Download) ========== + + /** + * Process data from the host (download: receive data from host). + */ + private void cutData() { + log.fine("CUT: DATA"); + + if (listener.getCurrentState() == FTState.ABORT_WAIT) { + cutAbort("Transfer cancelled by user", SC_ABORT_FILE); + return; + } + + FTConfig config = listener.getConfig(); + + // Extract raw data + int rawLength = (from6(screen.getCellEC(O_DT_LEN)) << 6) | + from6(screen.getCellEC(O_DT_LEN + 1)); + + if (rawLength > O_RESPONSE - O_DT_DATA) { + cutAbort("Oversized CUT data frame", SC_ABORT_XMIT); + return; + } + + byte[] rawData = new byte[rawLength]; + for (int i = 0; i < rawLength; i++) { + rawData[i] = (byte) screen.getCellEC(O_DT_DATA + i); + } + + // Check for EOF marker + if (rawLength == 2 && (rawData[0] & 0xFF) == EOF_DATA1 && + (rawData[1] & 0xFF) == EOF_DATA2) { + log.fine("CUT: EOF marker received"); + cutAck(); + return; + } + + // Convert and write to local file + try { + byte[] converted = convertDownload(rawData, rawLength, config); + if (outputStream != null) { + outputStream.write(converted); + expandedLength += converted.length; + listener.onBytesTransferred(expandedLength); + } + cutAck(); + } catch (IOException e) { + log.warning("CUT: Write error: " + e.getMessage()); + cutAbort("Write error: " + e.getMessage(), SC_ABORT_FILE); + } + } + + // ========== Retransmit ========== + + private void cutRetransmit() { + log.warning("CUT: RETRANSMIT (not supported, aborting)"); + cutAbort("Retransmit not supported", SC_ABORT_XMIT); + } + + // ========== Acknowledge ========== + + private void cutAck() { + log.fine("CUT: > ACK (Enter)"); + input.sendAidForFT(AID_ENTER); + } + + // ========== Abort ========== + + private void cutAbort(String message, int reason) { + log.warning("CUT: ABORT — " + message); + + // Write abort frame to the response area + screen.setCell(RO_FRAME_TYPE, RFT_CONTROL_CODE); + screen.setCell(RO_FRAME_SEQ, screen.getCellEC(O_DT_FRAME_SEQ)); + screen.setCell(RO_REASON_CODE, (reason >> 8) & 0xFF); + screen.setCell(RO_REASON_CODE + 1, reason & 0xFF); + + // Send PF2 to signal abort + input.sendAidForFT(AID_PF2); + + listener.onTransferAborted(message); + } + + // ========== Character Translation ========== + + /** + * Convert received EBCDIC data to local format. + * Handles ASCII mode (with optional CR processing and remapping) + * and binary mode (raw passthrough). + * + * The quadrant tables decode wire bytes into IND$FILE's "pseudo-ASCII" + * representation. For the remap path, control codes (< 0x20 and + * 0x80-0x9E) are treated as direct Unicode codepoints, while printable + * characters are re-mapped through ASC2FT (inverting IND$FILE's built-in + * EBCDIC→ASCII table) back to real EBCDIC, then to Unicode. + * This matches x3270's upload_convert() logic. + */ + private byte[] convertDownload(byte[] rawData, int length, FTConfig config) + throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(length * 2); + + for (int i = 0; i < length; i++) { + int c = rawData[i] & 0xFF; + + while (true) { + if (quadrant < 0) { + // Find the quadrant + for (quadrant = 0; quadrant < 4; quadrant++) { + if (c == SELECTORS[quadrant]) break; + } + if (quadrant >= 4) { + throw new IOException("CUT conversion error (quadrant selector not found)"); + } + break; // continue outer loop + } + + if (c < 0x40 || c > 0xF9) { + throw new IOException("CUT conversion error (data out of bounds)"); + } + + char asciiChar = translator.ebcdicToUnicode(c); + int ix = ALPHAS.indexOf(asciiChar); + if (ix < 0) { + // Try a different quadrant + quadrant = -1; + continue; // retry loop + } + + if (quadrant != 2 && c != 0xC1 && QUADS[quadrant][ix] == 0) { + // Try a different quadrant + quadrant = -1; + continue; // retry + } + + // Map the character — this produces IND$FILE's pseudo-ASCII byte + int decoded = QUADS[quadrant][ix]; + + if (config.isAscii() && config.isCrFlag() && (decoded == 0x0D || decoded == 0x1A)) { + break; // Ignore bare CR and EOF markers in ASCII stream + } + + if (!config.isAscii() || !config.isRemapFlag()) { + // Binary or ASCII-no-remap: emit the decoded byte as-is + out.write(decoded); + break; + } + + /* + * ASCII with remap: invert IND$FILE's EBCDIC→ASCII translation. + * + * Control codes (< 0x20) and high control range (0x80-0x9E, + * except 0x9F) are treated as direct Unicode codepoints. + * IND$FILE maps EBCDIC 0xE1 to pseudo-ASCII 0x9F, so + * 0xFF is the special marker that maps back to U+009F. + * Everything else is a printable character that needs + * the ASC2FT→ebcdicToUnicode remap chain. + */ + if (decoded < 0x20 || (decoded >= 0x80 && decoded < 0xA0 && decoded != 0x9F)) { + // Control code — emit as its Unicode codepoint directly + out.write(String.valueOf((char) decoded).getBytes( + java.nio.charset.StandardCharsets.UTF_8)); + } else if (decoded == 0xFF) { + // Special case: 0xFF → U+009F + out.write(String.valueOf((char) 0x9F).getBytes( + java.nio.charset.StandardCharsets.UTF_8)); + } else { + // Displayable character: invert IND$FILE's table + int ebc = FTConstants.ASC2FT[decoded & 0xFF]; + char unicodeChar = translator.ebcdicToUnicode(ebc); + out.write(String.valueOf(unicodeChar).getBytes( + java.nio.charset.StandardCharsets.UTF_8)); + } + break; + } // end while(true) chunk + } + + return out.toByteArray(); + } + + private int xlateGetc(FTConfig config) throws IOException { + // Return buffered data first + if (xlateBuffered > 0) { + int r = xlateBuf[xlateBufIx++]; + xlateBuffered--; + return r; + } + + if (inputStream == null) return -1; + + int c = inputStream.read(); + if (c == -1) return -1; + + int localByte = c & 0xFF; + int nc = 0; + int[] cbuf = new int[4]; // max 4 bytes (2 for \r + 2 for \n if quadrant encoded) + + if (config.isAscii()) { + if (config.isCrFlag() && !lastCr && localByte == '\n') { + nc += uploadConvert('\r', cbuf, nc, config); + } + lastCr = (localByte == '\r'); + } + + nc += uploadConvert(localByte, cbuf, nc, config); + + if (nc > 1) { + for (int i = 1; i < nc; i++) { + xlateBuf[xlateBuffered++] = cbuf[i]; + } + xlateBufIx = 0; + } + return cbuf[0]; + } + + private int uploadConvert(int localByte, int[] cbuf, int offset, FTConfig config) { + int ebc; + if (localByte == 0) { + // Nulls are special in the 'OTHER_2' quadrant + if (quadrant != 2) { + quadrant = 2; // OTHER_2 + cbuf[offset] = SELECTORS[quadrant]; + cbuf[offset + 1] = 0xC1; // XLATE_NULL + return 2; + } else { + cbuf[offset] = 0xC1; + return 1; + } + } + + if (!config.isAscii() || !config.isRemapFlag()) { + // Binary or Ascii without remap: treat byte directly + ebc = localByte & 0xFF; + } else { + // Ascii with remap: locally read ASCII translates to actual EBCDIC, then maps to IND$FILE's pseudo-ASCII + int standardEbc = translator.unicodeToEbcdic((char) localByte); + if (standardEbc < 0) { + standardEbc = 0x40; // Space as fallback + } + ebc = org.lib3270j.ft.FTConstants.FT2ASC[standardEbc & 0xFF]; + } + + return storeUpload(ebc, cbuf, offset); + } + + private int storeUpload(int ebc, int[] obBuf, int offset) { + if (quadrant >= 0) { + for (int i = 0; i < 77; i++) { + if (QUADS[quadrant][i] == ebc) { + obBuf[offset] = translator.unicodeToEbcdic(ALPHAS.charAt(i)); + return 1; + } + } + } + int oq = quadrant; + for (quadrant = 0; quadrant < 4; quadrant++) { + if (quadrant == oq) continue; + for (int i = 0; i < 77; i++) { + if (QUADS[quadrant][i] == ebc) { + obBuf[offset] = SELECTORS[quadrant]; + obBuf[offset + 1] = translator.unicodeToEbcdic(ALPHAS.charAt(i)); + return 2; + } + } + } + quadrant = -1; + // Fallback safety measure + obBuf[offset] = translator.unicodeToEbcdic('?'); + return 1; + } +} diff --git a/lib3270j/src/main/java/org/lib3270j/ft/FTDft.java b/lib3270j/src/main/java/org/lib3270j/ft/FTDft.java new file mode 100644 index 0000000..9aa5c6a --- /dev/null +++ b/lib3270j/src/main/java/org/lib3270j/ft/FTDft.java @@ -0,0 +1,496 @@ +package org.lib3270j.ft; + +import org.lib3270j.input.InputProcessor; +import org.lib3270j.charset.EbcdicTranslator; +import static org.lib3270j.ft.FTConstants.*; + +import java.io.*; +import java.util.logging.Logger; + +/** + * DFT (Distributed Function Terminal) mode file transfer handler. + * Uses Structured Fields (SF_TRANSFER_DATA = 0xD0) for data exchange. + * Offers better performance than CUT mode with configurable buffer sizes. + * Ported from x3270's ft_dft.c. + */ +public class FTDft { + + private static final Logger log = Logger.getLogger(FTDft.class.getName()); + + /** Callback for transfer events (shared interface with CUT) */ + public interface FTDftListener { + void onTransferRunning(); + void onTransferComplete(String errorMessage); + void onTransferAborted(String errorMessage); + void onBytesTransferred(long bytes); + FTConstants.FTState getCurrentState(); + void setState(FTConstants.FTState state); + FTConfig getConfig(); + File getLocalFile(); + } + + private final InputProcessor input; + private final EbcdicTranslator translator; + private final FTDftListener listener; + + // DFT state + private long recnum = 0; + private boolean dftEof = false; + private boolean messageFlag = false; + private long bytesTransferred = 0; + + // Savebuf for Read Modified retransmit + private byte[] dftSaveBuf = null; + private int dftSaveBufLen = 0; + + // File I/O + private InputStream inputStream; + private OutputStream outputStream; + private boolean lastCr = false; + + public FTDft(InputProcessor input, EbcdicTranslator translator, + FTDftListener listener) { + this.input = input; + this.translator = translator; + this.listener = listener; + } + + /** + * Initialize DFT mode for a new transfer. + */ + public void initTransfer(File localFile) throws IOException { + FTConfig config = listener.getConfig(); + recnum = 0; + dftEof = false; + messageFlag = false; + bytesTransferred = 0; + dftSaveBuf = null; + dftSaveBufLen = 0; + lastCr = false; + + if (config.isReceive()) { + outputStream = new FileOutputStream(localFile, config.isAppend()); + inputStream = null; + } else { + inputStream = new FileInputStream(localFile); + outputStream = null; + } + } + + /** + * Clean up DFT mode resources. + */ + public void cleanup() { + try { + if (inputStream != null) { inputStream.close(); inputStream = null; } + if (outputStream != null) { outputStream.close(); outputStream = null; } + } catch (IOException e) { + log.warning("Error closing file: " + e.getMessage()); + } + dftSaveBuf = null; + dftSaveBufLen = 0; + } + + /** + * Process a DFT structured field from the host. + * Called by DataStreamProcessor when SF type is SF_TRANSFER_DATA (0xD0). + * + * @param data raw structured field data + * @param offset start of the SF (after length + type byte) + * @param length total SF length + */ + public void processStructuredField(byte[] data, int offset, int length) { + // The SF payload starts after the 2-byte length + 1-byte SF type + int payloadStart = offset + 3; + if (payloadStart + 2 > offset + length) { + log.warning("DFT: SF too short"); + return; + } + + // First 2 bytes of payload are the DFT request code + int requestCode = ((data[payloadStart] & 0xFF) << 8) | + (data[payloadStart + 1] & 0xFF); + + log.fine("DFT: request code 0x" + Integer.toHexString(requestCode)); + + switch (requestCode) { + case TR_OPEN_REQ: + dftOpenRequest(); + break; + case TR_INSERT_REQ: + dftInsertRequest(data, payloadStart, offset + length - payloadStart); + break; + case TR_DATA_INSERT: + dftDataInsert(data, payloadStart, offset + length - payloadStart); + break; + case TR_SET_CUR_REQ: + // No-op, same as x3270 + log.fine("DFT: SetCursor (ignored)"); + break; + case TR_GET_REQ: + dftGetRequest(); + break; + case TR_CLOSE_REQ: + dftCloseRequest(); + break; + default: + log.warning("DFT: Unknown request code 0x" + Integer.toHexString(requestCode)); + dftAbort("Unknown DFT request", TR_DATA_INSERT); + break; + } + } + + // ========== Open Request ========== + + private void dftOpenRequest() { + log.fine("DFT: Open"); + listener.onTransferRunning(); + // Send acknowledgement + dftDataAck(); + } + + // ========== Insert Request (host sending data for download) ========== + + private void dftInsertRequest(byte[] data, int offset, int length) { + log.fine("DFT: Insert"); + dftDataInsert(data, offset, length); + } + + private void dftDataInsert(byte[] data, int offset, int length) { + FTConfig config = listener.getConfig(); + + if (listener.getCurrentState() == FTState.ABORT_WAIT) { + dftAbort("Transfer cancelled by user", TR_DATA_INSERT); + return; + } + + // Parse the SF payload to find data + // Skip the 2-byte request code + int pos = offset + 2; + int end = offset + length; + + // Look for TR_BEGIN_DATA marker + while (pos < end) { + int headerCode = data[pos] & 0xFF; + + if (headerCode == TR_BEGIN_DATA) { + // Next 2 bytes are data length (including the 3-byte header) + if (pos + 3 > end) break; + int dataLen = ((data[pos + 1] & 0xFF) << 8) | (data[pos + 2] & 0xFF); + int actualDataLen = dataLen - 3; // subtract header + pos += 3; + + if (actualDataLen > 0 && pos + actualDataLen <= end) { + try { + writeDownloadData(data, pos, actualDataLen, config); + bytesTransferred += actualDataLen; + listener.onBytesTransferred(bytesTransferred); + } catch (IOException e) { + dftAbort("Write error: " + e.getMessage(), TR_DATA_INSERT); + return; + } + } + pos += Math.max(0, actualDataLen); + } else if (pos + 1 < end) { + // Skip other headers (2-byte code + length-based skip) + // Most headers are 4-6 bytes, but we just skip based on known patterns + int hdrCode16 = ((data[pos] & 0xFF) << 8) | (data[pos + 1] & 0xFF); + if (hdrCode16 == TR_RECNUM_HDR) { + pos += 6; // 2-byte code + 4-byte record number + } else if (hdrCode16 == TR_NOT_COMPRESSED) { + pos += 2; + } else { + pos += 2; // skip unknown 2-byte header + } + } else { + pos++; + } + } + + // Send acknowledgement + dftDataAck(); + } + + /** + * Write download data to the local file, handling ASCII conversion. + */ + private void writeDownloadData(byte[] data, int offset, int length, + FTConfig config) throws IOException { + if (outputStream == null) return; + + if (!config.isAscii()) { + // Binary: write raw + outputStream.write(data, offset, length); + return; + } + + // ASCII mode with optional CR stripping and remapping + for (int i = 0; i < length; i++) { + int b = data[offset + i] & 0xFF; + + if (config.isRemapFlag()) { + // Use IND$FILE's EBCDIC→ASCII table + int ascii = FT2ASC[b]; + + if (config.isCrFlag()) { + if (ascii == '\r') { + lastCr = true; + continue; + } + if (lastCr) { + lastCr = false; + if (ascii == '\n') { + outputStream.write('\n'); + continue; + } + outputStream.write('\r'); + } + } + outputStream.write(ascii); + } else { + // Standard EBCDIC→Unicode + char ch = translator.ebcdicToUnicode(b); + if (config.isCrFlag()) { + if (ch == '\r') { lastCr = true; continue; } + if (lastCr) { + lastCr = false; + if (ch == '\n') { outputStream.write('\n'); continue; } + outputStream.write('\r'); + } + } + outputStream.write(String.valueOf(ch).getBytes( + java.nio.charset.StandardCharsets.UTF_8)); + } + } + } + + // ========== Get Request (host wants data for upload) ========== + + private void dftGetRequest() { + FTConfig config = listener.getConfig(); + log.fine("DFT: Get"); + + if (!messageFlag && listener.getCurrentState() == FTState.ABORT_WAIT) { + dftAbort("Transfer cancelled by user", TR_GET_REQ); + return; + } + + int bufferSize = config.getDftBufferSize(); + int numbytes = bufferSize - 27; // reserve space for headers + byte[] readBuf = new byte[numbytes]; + int totalRead = 0; + + try { + while (!dftEof && totalRead < numbytes) { + if (config.isAscii() && (config.isRemapFlag() || config.isCrFlag())) { + int b = dftAsciiRead(config); + if (b == -1) { + dftEof = true; + break; + } + readBuf[totalRead++] = (byte) b; + } else { + // Binary read + if (inputStream == null) { dftEof = true; break; } + int n = inputStream.read(readBuf, totalRead, numbytes - totalRead); + if (n <= 0) { + dftEof = true; + break; + } + totalRead += n; + } + } + } catch (IOException e) { + dftAbort("Read error: " + e.getMessage(), TR_GET_REQ); + return; + } + + // Build SF response + ByteArrayOutputStream out = new ByteArrayOutputStream(bufferSize); + + out.write(AID_SF); // AID byte + + // Placeholder for SF length (2 bytes, filled in later) + int sfLenPos = out.size(); + out.write(0); out.write(0); + + out.write(SF_TRANSFER_DATA); // SF type + + if (totalRead > 0) { + log.fine("DFT: > GetReply rec=" + recnum + " " + totalRead + " bytes"); + + // TR_GET_REPLY + out.write((TR_GET_REPLY >> 8) & 0xFF); + out.write(TR_GET_REPLY & 0xFF); + + // Record number header + out.write((TR_RECNUM_HDR >> 8) & 0xFF); + out.write(TR_RECNUM_HDR & 0xFF); + out.write((int) ((recnum >> 24) & 0xFF)); + out.write((int) ((recnum >> 16) & 0xFF)); + out.write((int) ((recnum >> 8) & 0xFF)); + out.write((int) (recnum & 0xFF)); + recnum++; + + // Not compressed + out.write((TR_NOT_COMPRESSED >> 8) & 0xFF); + out.write(TR_NOT_COMPRESSED & 0xFF); + + // Begin data + out.write(TR_BEGIN_DATA); + int dataFieldLen = totalRead + 5; + out.write((dataFieldLen >> 8) & 0xFF); + out.write(dataFieldLen & 0xFF); + + // The actual data + out.write(readBuf, 0, totalRead); + + bytesTransferred += totalRead; + } else { + log.fine("DFT: > GetReply EOF"); + + // EOF reply + out.write((TR_GET_REQ >> 8) & 0xFF); + out.write(TR_ERROR_REPLY & 0xFF); + + // Error header + out.write((TR_ERROR_HDR >> 8) & 0xFF); + out.write(TR_ERROR_HDR & 0xFF); + + // EOF error code + out.write((TR_ERR_EOF >> 8) & 0xFF); + out.write(TR_ERR_EOF & 0xFF); + + dftEof = true; + } + + // Set the SF length + byte[] result = out.toByteArray(); + int sfLen = result.length - 1; // exclude AID byte + result[sfLenPos] = (byte) ((sfLen >> 8) & 0xFF); + result[sfLenPos + 1] = (byte) (sfLen & 0xFF); + + // Save for potential Read Modified retransmit + dftSaveBuf = result.clone(); + dftSaveBufLen = result.length; + + // Send it + input.sendStructuredFieldData(result); + listener.onBytesTransferred(bytesTransferred); + } + + /** + * Read a byte from the local file in ASCII mode with CR expansion and remapping. + * Returns -1 for EOF. + */ + private int dftAsciiRead(FTConfig config) throws IOException { + if (inputStream == null) return -1; + + int c = inputStream.read(); + if (c == -1) return -1; + + if (config.isRemapFlag()) { + // CR expansion: insert \r before \n + if (config.isCrFlag() && !lastCr && c == '\n') { + // We need to return \r now and buffer \n + lastCr = false; + // Can't easily buffer here, so return \r and push \n back + // Actually, let's handle this inline + // Return the CR equivalent, then handle NL next call + return ASC2FT['\r' & 0xFF]; + // Note: the \n will be read on next call naturally + } + lastCr = (c == '\r'); + return ASC2FT[c & 0xFF]; + } else { + // No remap — convert to standard EBCDIC + if (config.isCrFlag() && !lastCr && c == '\n') { + lastCr = false; + int ebc = translator.unicodeToEbcdic('\r'); + return ebc >= 0 ? ebc : 0x0D; + } + lastCr = (c == '\r'); + int ebc = translator.unicodeToEbcdic((char) c); + return ebc >= 0 ? ebc : 0x40; + } + } + + // ========== Close Request ========== + + private void dftCloseRequest() { + log.fine("DFT: Close"); + + // Send close acknowledgement + ByteArrayOutputStream out = new ByteArrayOutputStream(6); + out.write(AID_SF); + + // SF length + out.write(0); out.write(5); + + out.write(SF_TRANSFER_DATA); + + // TR_CLOSE_REPLY + out.write((TR_CLOSE_REPLY >> 8) & 0xFF); + out.write(TR_CLOSE_REPLY & 0xFF); + + input.sendStructuredFieldData(out.toByteArray()); + } + + // ========== Data Acknowledgement ========== + + private void dftDataAck() { + ByteArrayOutputStream out = new ByteArrayOutputStream(6); + out.write(AID_SF); + + // SF length + out.write(0); out.write(5); + + out.write(SF_TRANSFER_DATA); + + // TR_NORMAL_REPLY + out.write((TR_NORMAL_REPLY >> 8) & 0xFF); + out.write(TR_NORMAL_REPLY & 0xFF); + + input.sendStructuredFieldData(out.toByteArray()); + } + + // ========== Abort ========== + + private void dftAbort(String message, int code) { + log.warning("DFT: ABORT — " + message); + + ByteArrayOutputStream out = new ByteArrayOutputStream(10); + out.write(AID_SF); + + // SF length + out.write(0); out.write(9); + + out.write(SF_TRANSFER_DATA); + + // Error reply code + out.write((code >> 8) & 0xFF); + out.write(TR_ERROR_REPLY & 0xFF); + + // Error header + out.write((TR_ERROR_HDR >> 8) & 0xFF); + out.write(TR_ERROR_HDR & 0xFF); + + // Command failed + out.write((TR_ERR_CMDFAIL >> 8) & 0xFF); + out.write(TR_ERR_CMDFAIL & 0xFF); + + input.sendStructuredFieldData(out.toByteArray()); + listener.onTransferAborted(message); + } + + /** + * Handle a Read Modified command when upload data is pending. + * Retransmits the last saved buffer. + */ + public void readModified() { + if (dftSaveBuf != null && dftSaveBufLen > 0) { + log.fine("DFT: Retransmitting saved buffer"); + input.sendStructuredFieldData(dftSaveBuf); + } + } +}