From f912cab07b7aeb7f0e0886d4f44367f6c1b0e08e Mon Sep 17 00:00:00 2001 From: ericek111 Date: Thu, 13 Aug 2026 07:22:33 +0000 Subject: [PATCH] Implement channel file transfers Add file up/downloads and directory operations for channel file repositories, reachable from a channel's right-click "Browse files". Core (com.ts3client.net.filetransfer): - FileTransferManager drives the TS3 file protocol on the ts3j socket. ftinitupload/ftinitdownload results arrive out-of-band as notifystartupload/notifystartdownload (success) or notifystatusfiletransfer (failure) events, not as command responses, so negotiation correlates them by clientftfid via a CompletableFuture. The byte phase opens a raw TCP socket, sends the ftkey, then streams. - FileTransfer: observable per-transfer handle (state, progress, cancel). - RemoteFile: UI-facing listing DTO, keeping core free of ts3j API types. TeamspeakConnection exposes list/mkdir/delete/rename and up/download and manages the manager's lifecycle. Swing FileBrowserDialog provides the browser UI with a live per-transfer progress strip. Co-Authored-By: Claude Opus 4.8 --- .../ts3client/net/TeamspeakConnection.java | 69 +++ .../net/filetransfer/FileTransfer.java | 156 ++++++ .../net/filetransfer/FileTransferManager.java | 422 ++++++++++++++++ .../net/filetransfer/RemoteFile.java | 49 ++ .../com/ts3client/ui/FileBrowserDialog.java | 467 ++++++++++++++++++ .../main/java/com/ts3client/ui/MainFrame.java | 6 + .../com/ts3client/ui/ServerTreePanel.java | 7 + 7 files changed, 1176 insertions(+) create mode 100644 ts3-client/core/src/main/java/com/ts3client/net/filetransfer/FileTransfer.java create mode 100644 ts3-client/core/src/main/java/com/ts3client/net/filetransfer/FileTransferManager.java create mode 100644 ts3-client/core/src/main/java/com/ts3client/net/filetransfer/RemoteFile.java create mode 100644 ts3-client/swing/src/main/java/com/ts3client/ui/FileBrowserDialog.java diff --git a/ts3-client/core/src/main/java/com/ts3client/net/TeamspeakConnection.java b/ts3-client/core/src/main/java/com/ts3client/net/TeamspeakConnection.java index a39c905..fa23d74 100644 --- a/ts3-client/core/src/main/java/com/ts3client/net/TeamspeakConnection.java +++ b/ts3-client/core/src/main/java/com/ts3client/net/TeamspeakConnection.java @@ -15,6 +15,9 @@ import com.ts3client.audio.AudioBackend; import com.ts3client.audio.VoiceInput; import com.ts3client.audio.VoiceOutput; import com.ts3client.config.Settings; +import com.ts3client.net.filetransfer.FileTransfer; +import com.ts3client.net.filetransfer.FileTransferManager; +import com.ts3client.net.filetransfer.RemoteFile; import java.io.File; import java.net.InetSocketAddress; @@ -38,10 +41,12 @@ public final class TeamspeakConnection implements TS3Listener { private VoiceInput microphone; private VoiceOutput playback; private LocalIdentity identity; + private FileTransferManager fileTransfers; private volatile boolean connected; private volatile int selfClientId = -1; private volatile long connectedAtMs; + private volatile String serverHost; /** Outstanding {@code getconnectioninfo} requests awaiting a server report, keyed by client id. */ private final Map pendingConnInfo = new ConcurrentHashMap<>(); @@ -143,6 +148,8 @@ public final class TeamspeakConnection implements TS3Listener { // Protocol connection established; anything past this point is best-effort. selfClientId = client.getClientId(); + serverHost = address; + fileTransfers = new FileTransferManager(client, () -> serverHost); client.setMicrophone(microphone); connected = true; connectedAtMs = System.currentTimeMillis(); @@ -235,12 +242,21 @@ public final class TeamspeakConnection implements TS3Listener { VoiceInput mic = microphone; VoiceOutput out = playback; LocalTeamspeakClientSocket sock = client; + FileTransferManager ft = fileTransfers; microphone = null; playback = null; client = null; + fileTransfers = null; selfClientId = -1; pendingConnInfo.clear(); + if (ft != null) { + try { + ft.shutdown(); + } catch (Exception ignored) { + } + } + if (mic != null) { try { mic.stop(); @@ -531,6 +547,59 @@ public final class TeamspeakConnection implements TS3Listener { }, "ts3j-clientinfo").start(); } + // ---- file transfers ---- + + /** Whether a live connection capable of file transfers is available. */ + public boolean canTransferFiles() { + return connected && fileTransfers != null; + } + + /** + * Lists the files under {@code path} in a channel's repository. Blocking; call + * off the UI thread. Throws if not connected or the server denies the request. + */ + public java.util.List listFiles(int channelId, String channelPassword, String path) + throws Exception { + FileTransferManager ft = fileTransfers; + if (ft == null) throw new IllegalStateException("Not connected"); + return ft.list(channelId, channelPassword, path); + } + + public void createDirectory(int channelId, String channelPassword, String dirPath) throws Exception { + FileTransferManager ft = fileTransfers; + if (ft == null) throw new IllegalStateException("Not connected"); + ft.createDirectory(channelId, channelPassword, dirPath); + } + + public void deleteFile(int channelId, String channelPassword, String fullPath) throws Exception { + FileTransferManager ft = fileTransfers; + if (ft == null) throw new IllegalStateException("Not connected"); + ft.delete(channelId, channelPassword, fullPath); + } + + public void renameFile(int channelId, String channelPassword, String oldFullPath, String newFullPath) + throws Exception { + FileTransferManager ft = fileTransfers; + if (ft == null) throw new IllegalStateException("Not connected"); + ft.rename(channelId, channelPassword, oldFullPath, newFullPath); + } + + /** Begins downloading a channel file to {@code target}; progress arrives via {@code listener}. */ + public FileTransfer downloadFile(int channelId, String channelPassword, String remoteFullPath, + File target, FileTransfer.Listener listener) { + FileTransferManager ft = fileTransfers; + if (ft == null) throw new IllegalStateException("Not connected"); + return ft.download(channelId, channelPassword, remoteFullPath, target, listener); + } + + /** Begins uploading {@code source} into a channel; progress arrives via {@code listener}. */ + public FileTransfer uploadFile(int channelId, String channelPassword, String remoteFullPath, + File source, boolean overwrite, FileTransfer.Listener listener) { + FileTransferManager ft = fileTransfers; + if (ft == null) throw new IllegalStateException("Not connected"); + return ft.upload(channelId, channelPassword, remoteFullPath, source, overwrite, listener); + } + // ---- connection info ---- /** diff --git a/ts3-client/core/src/main/java/com/ts3client/net/filetransfer/FileTransfer.java b/ts3-client/core/src/main/java/com/ts3client/net/filetransfer/FileTransfer.java new file mode 100644 index 0000000..2dfda15 --- /dev/null +++ b/ts3-client/core/src/main/java/com/ts3client/net/filetransfer/FileTransfer.java @@ -0,0 +1,156 @@ +package com.ts3client.net.filetransfer; + +import java.io.File; + +/** + * An observable handle for a single in-flight (or finished) file transfer. Created + * by {@link FileTransferManager}; the actual byte copy runs on a background thread + * and mutates this object, notifying a {@link Listener} on every change so a UI can + * render live progress. All mutating methods are package-private — callers only read. + */ +public final class FileTransfer { + + public enum Direction {UPLOAD, DOWNLOAD} + + public enum State { + /** Announced to the server, file connection not yet established. */ + PENDING, + /** Bytes are actively being transferred. */ + ACTIVE, + COMPLETED, + FAILED, + CANCELLED + } + + /** Notified (off the UI thread) whenever this transfer's progress or state changes. */ + public interface Listener { + void onTransferChanged(FileTransfer transfer); + } + + private final Direction direction; + private final int channelId; + private final String remoteName; + private final File localFile; + private final Listener listener; + + private volatile long totalBytes; + private volatile long transferredBytes; + private volatile State state = State.PENDING; + private volatile String errorMessage; + private volatile boolean cancelRequested; + + private final long startedAtMs = System.currentTimeMillis(); + private volatile long finishedAtMs; + + FileTransfer(Direction direction, int channelId, String remoteName, File localFile, + long totalBytes, Listener listener) { + this.direction = direction; + this.channelId = channelId; + this.remoteName = remoteName; + this.localFile = localFile; + this.totalBytes = totalBytes; + this.listener = listener; + } + + // ---- read-only accessors ---- + + public Direction getDirection() { + return direction; + } + + public int getChannelId() { + return channelId; + } + + public String getRemoteName() { + return remoteName; + } + + public File getLocalFile() { + return localFile; + } + + public long getTotalBytes() { + return totalBytes; + } + + public long getTransferredBytes() { + return transferredBytes; + } + + public State getState() { + return state; + } + + public String getErrorMessage() { + return errorMessage; + } + + public boolean isDone() { + State s = state; + return s == State.COMPLETED || s == State.FAILED || s == State.CANCELLED; + } + + /** Completion fraction in [0, 1], or 0 when the total size is unknown. */ + public double getProgress() { + long total = totalBytes; + if (total <= 0) return isDone() && state == State.COMPLETED ? 1.0 : 0.0; + double p = (double) transferredBytes / total; + return p < 0 ? 0 : (p > 1 ? 1 : p); + } + + /** Average speed in bytes per second over the transfer's lifetime, or 0 if not measurable. */ + public double getAverageSpeed() { + long end = isDone() ? finishedAtMs : System.currentTimeMillis(); + long elapsed = Math.max(1, end - startedAtMs); + return transferredBytes * 1000.0 / elapsed; + } + + /** Requests cancellation; the transfer thread aborts at the next chunk boundary. */ + public void cancel() { + cancelRequested = true; + } + + boolean isCancelRequested() { + return cancelRequested; + } + + // ---- package-private mutators, called by FileTransferManager ---- + + void setTotalBytes(long totalBytes) { + this.totalBytes = totalBytes; + } + + void addTransferred(long delta) { + transferredBytes += delta; + fireChanged(); + } + + void markActive() { + state = State.ACTIVE; + fireChanged(); + } + + void markCompleted() { + finishedAtMs = System.currentTimeMillis(); + state = State.COMPLETED; + fireChanged(); + } + + void markCancelled() { + finishedAtMs = System.currentTimeMillis(); + state = State.CANCELLED; + fireChanged(); + } + + void markFailed(String message) { + finishedAtMs = System.currentTimeMillis(); + errorMessage = message; + state = State.FAILED; + fireChanged(); + } + + private void fireChanged() { + if (listener != null) listener.onTransferChanged(this); + } +} diff --git a/ts3-client/core/src/main/java/com/ts3client/net/filetransfer/FileTransferManager.java b/ts3-client/core/src/main/java/com/ts3client/net/filetransfer/FileTransferManager.java new file mode 100644 index 0000000..c8d1d43 --- /dev/null +++ b/ts3-client/core/src/main/java/com/ts3client/net/filetransfer/FileTransferManager.java @@ -0,0 +1,422 @@ +package com.ts3client.net.filetransfer; + +import com.github.manevolent.ts3j.api.FileTransferParameters; +import com.github.manevolent.ts3j.command.SingleCommand; +import com.github.manevolent.ts3j.command.parameter.CommandSingleParameter; +import com.github.manevolent.ts3j.event.TS3Listener; +import com.github.manevolent.ts3j.event.UnknownTeamspeakEvent; +import com.github.manevolent.ts3j.protocol.ProtocolRole; +import com.github.manevolent.ts3j.protocol.socket.client.LocalTeamspeakClientSocket; + +import java.io.BufferedInputStream; +import java.io.BufferedOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.net.Socket; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Supplier; + +/** + * Implements the TeamSpeak 3 channel file-transfer protocol on top of a connected + * {@link LocalTeamspeakClientSocket}. + * + *

Directory operations ({@link #list}, {@link #createDirectory}, {@link #delete}, + * {@link #rename}) are ordinary control commands and run synchronously on the caller's + * thread. Byte transfers ({@link #download}, {@link #upload}) are two-phase: an + * {@code ftinit*} control command negotiates a one-time key, host and port, after which + * a raw TCP connection to the server's file port carries the bytes. That copy runs on a + * background thread and reports through the returned {@link FileTransfer} handle. + */ +public final class FileTransferManager { + + private static final int CONNECT_TIMEOUT_MS = 15_000; + private static final int BUFFER_SIZE = 64 * 1024; + /** How long to wait for the server's {@code notifystart*} reply after an {@code ftinit*}. */ + private static final long NEGOTIATE_TIMEOUT_MS = 15_000; + + /** TeamSpeak error id returned by {@code ftgetfilelist} for an empty directory. */ + private static final int ERROR_DATABASE_EMPTY_RESULT = 0x0501; + + private final LocalTeamspeakClientSocket socket; + /** Host we are connected to, used when the server reports no dedicated file-transfer host. */ + private final Supplier fallbackHost; + private final AtomicInteger clientTransferId = new AtomicInteger(1); + private final ExecutorService transferPool = Executors.newCachedThreadPool(r -> { + Thread t = new Thread(r, "ts3j-filetransfer"); + t.setDaemon(true); + return t; + }); + + /** + * In-flight {@code ftinit*} negotiations keyed by our {@code clientftfid}. The server + * answers an init out-of-band with a {@code notifystart*} (success) or a + * {@code notifystatusfiletransfer} (failure) event, which {@link #ftEventListener} + * routes back to the waiting transfer thread. + */ + private final Map>> pendingInits = new ConcurrentHashMap<>(); + private final TS3Listener ftEventListener = new TS3Listener() { + @Override + public void onUnknownEvent(UnknownTeamspeakEvent e) { + handleFileTransferEvent(e); + } + }; + + public FileTransferManager(LocalTeamspeakClientSocket socket, Supplier fallbackHost) { + this.socket = socket; + this.fallbackHost = fallbackHost; + socket.addListener(ftEventListener); + } + + // ---- directory operations ---- + + /** + * Lists the files and subdirectories directly under {@code path} in the given + * channel's repository. Returns an empty list for an empty directory. + */ + public List list(int channelId, String channelPassword, String path) + throws Exception { + SingleCommand cmd = new SingleCommand("ftgetfilelist", ProtocolRole.CLIENT, + new CommandSingleParameter("cid", Integer.toString(channelId)), + new CommandSingleParameter("cpw", channelPassword == null ? "" : channelPassword), + new CommandSingleParameter("path", path == null || path.isEmpty() ? "/" : path)); + + List files = new ArrayList<>(); + Iterable rows; + try { + rows = socket.executeCommand(cmd).get(); + } catch (com.github.manevolent.ts3j.command.CommandException e) { + if (e.getErrorId() == ERROR_DATABASE_EMPTY_RESULT) return files; + throw e; + } + for (SingleCommand row : rows) { + Map m = row.toMap(); + String name = m.get("name"); + if (name == null || name.isEmpty()) continue; + files.add(new RemoteFile( + name, + m.getOrDefault("path", path), + parseLong(m.get("size")), + "0".equals(m.get("type")), + parseLong(m.get("datetime")))); + } + return files; + } + + /** Creates a new directory at {@code dirPath} (a full path such as {@code /new}). */ + public void createDirectory(int channelId, String channelPassword, String dirPath) + throws Exception { + SingleCommand cmd = new SingleCommand("ftcreatedir", ProtocolRole.CLIENT, + new CommandSingleParameter("cid", Integer.toString(channelId)), + new CommandSingleParameter("cpw", channelPassword == null ? "" : channelPassword), + new CommandSingleParameter("dirname", dirPath)); + socket.executeCommand(cmd).complete(); + } + + /** Deletes a file or (recursively) a directory at {@code fullPath}. */ + public void delete(int channelId, String channelPassword, String fullPath) + throws Exception { + SingleCommand cmd = new SingleCommand("ftdeletefile", ProtocolRole.CLIENT, + new CommandSingleParameter("cid", Integer.toString(channelId)), + new CommandSingleParameter("cpw", channelPassword == null ? "" : channelPassword), + new CommandSingleParameter("name", fullPath)); + socket.executeCommand(cmd).complete(); + } + + /** Renames or moves a file within the same channel. */ + public void rename(int channelId, String channelPassword, String oldFullPath, String newFullPath) + throws Exception { + SingleCommand cmd = new SingleCommand("ftrenamefile", ProtocolRole.CLIENT, + new CommandSingleParameter("cid", Integer.toString(channelId)), + new CommandSingleParameter("cpw", channelPassword == null ? "" : channelPassword), + new CommandSingleParameter("oldname", oldFullPath), + new CommandSingleParameter("newname", newFullPath)); + socket.executeCommand(cmd).complete(); + } + + // ---- byte transfers ---- + + /** + * Starts downloading {@code remoteFullPath} from the channel to {@code target}. + * Returns immediately with a handle whose progress the caller can observe; the + * copy proceeds on a background thread. + */ + public FileTransfer download(int channelId, String channelPassword, String remoteFullPath, + File target, FileTransfer.Listener listener) { + FileTransfer transfer = new FileTransfer(FileTransfer.Direction.DOWNLOAD, channelId, + fileName(remoteFullPath), target, -1, listener); + transferPool.execute(() -> runDownload(channelId, channelPassword, remoteFullPath, transfer)); + return transfer; + } + + /** + * Starts uploading {@code source} into the channel at {@code remoteFullPath}. + * Returns immediately with a handle whose progress the caller can observe. + */ + public FileTransfer upload(int channelId, String channelPassword, String remoteFullPath, + File source, boolean overwrite, FileTransfer.Listener listener) { + FileTransfer transfer = new FileTransfer(FileTransfer.Direction.UPLOAD, channelId, + fileName(remoteFullPath), source, source.length(), listener); + transferPool.execute(() -> runUpload(channelId, channelPassword, remoteFullPath, source, overwrite, transfer)); + return transfer; + } + + private void runDownload(int channelId, String channelPassword, String remoteFullPath, + FileTransfer transfer) { + try { + int ftfid = clientTransferId.getAndIncrement(); + SingleCommand cmd = new SingleCommand("ftinitdownload", ProtocolRole.CLIENT, + new CommandSingleParameter("clientftfid", Integer.toString(ftfid)), + new CommandSingleParameter("name", remoteFullPath), + new CommandSingleParameter("cid", Integer.toString(channelId)), + new CommandSingleParameter("cpw", channelPassword == null ? "" : channelPassword), + new CommandSingleParameter("seekpos", "0"), + new CommandSingleParameter("proto", "0")); + + FileTransferParameters params = negotiate(ftfid, cmd); + long size = params.getFileSize(); + transfer.setTotalBytes(size); + + try (Socket fileSocket = openFileSocket(params); + InputStream in = new BufferedInputStream(fileSocket.getInputStream()); + OutputStream fileOut = new BufferedOutputStream(new FileOutputStream(transfer.getLocalFile()))) { + + sendKey(fileSocket, params); + transfer.markActive(); + + byte[] buffer = new byte[BUFFER_SIZE]; + long remaining = size; + while (remaining > 0) { + if (transfer.isCancelRequested()) { + transfer.markCancelled(); + deleteQuietly(transfer.getLocalFile()); + return; + } + int want = (int) Math.min(buffer.length, remaining); + int read = in.read(buffer, 0, want); + if (read < 0) throw new IOException("File server closed the connection early"); + fileOut.write(buffer, 0, read); + remaining -= read; + transfer.addTransferred(read); + } + } + transfer.markCompleted(); + } catch (Exception e) { + transfer.markFailed(rootMessage(e)); + deleteQuietly(transfer.getLocalFile()); + } + } + + private void runUpload(int channelId, String channelPassword, String remoteFullPath, + File source, boolean overwrite, FileTransfer transfer) { + try { + long size = source.length(); + int ftfid = clientTransferId.getAndIncrement(); + SingleCommand cmd = new SingleCommand("ftinitupload", ProtocolRole.CLIENT, + new CommandSingleParameter("clientftfid", Integer.toString(ftfid)), + new CommandSingleParameter("name", remoteFullPath), + new CommandSingleParameter("cid", Integer.toString(channelId)), + new CommandSingleParameter("cpw", channelPassword == null ? "" : channelPassword), + new CommandSingleParameter("size", Long.toString(size)), + new CommandSingleParameter("overwrite", overwrite ? "1" : "0"), + new CommandSingleParameter("resume", "0"), + new CommandSingleParameter("proto", "0")); + + FileTransferParameters params = negotiate(ftfid, cmd); + // The server may already hold a prefix of the file (resume); start after it. + long seekpos = params.getMap().containsKey("seekpos") ? params.getLong("seekpos") : 0; + if (seekpos < 0 || seekpos > size) seekpos = 0; + + try (Socket fileSocket = openFileSocket(params); + InputStream fileIn = new BufferedInputStream(new FileInputStream(source)); + OutputStream out = new BufferedOutputStream(fileSocket.getOutputStream())) { + + sendKey(fileSocket, params); + transfer.markActive(); + + if (seekpos > 0) { + skipFully(fileIn, seekpos); + transfer.addTransferred(seekpos); + } + + byte[] buffer = new byte[BUFFER_SIZE]; + long remaining = size - seekpos; + while (remaining > 0) { + if (transfer.isCancelRequested()) { + transfer.markCancelled(); + return; + } + int want = (int) Math.min(buffer.length, remaining); + int read = fileIn.read(buffer, 0, want); + if (read < 0) break; // file shorter than announced; stop + out.write(buffer, 0, read); + remaining -= read; + transfer.addTransferred(read); + } + out.flush(); + } + transfer.markCompleted(); + } catch (Exception e) { + transfer.markFailed(rootMessage(e)); + } + } + + /** + * Sends an {@code ftinit*} command and returns the negotiated parameters. The server + * acknowledges the command itself, then reports the transfer's key/host/port + * asynchronously via a {@code notifystart*} event (or a {@code notifystatusfiletransfer} + * on refusal), which {@link #handleFileTransferEvent} feeds back through {@code pendingInits}. + */ + private FileTransferParameters negotiate(int clientTransferId, SingleCommand cmd) throws Exception { + CompletableFuture> future = new CompletableFuture<>(); + pendingInits.put(clientTransferId, future); + try { + // Await the command-level acknowledgement; a hard error (e.g. bad path) throws here. + socket.executeCommand(cmd).complete(); + Map result; + try { + result = future.get(NEGOTIATE_TIMEOUT_MS, TimeUnit.MILLISECONDS); + } catch (ExecutionException e) { + Throwable cause = e.getCause(); + throw cause instanceof Exception ? (Exception) cause : e; + } catch (TimeoutException e) { + throw new IOException("Timed out waiting for the server to start the transfer"); + } + FileTransferParameters params = new FileTransferParameters(result); + String key = params.getFileTransferKey(); + if (key == null || key.isEmpty()) throw new IOException("Server refused the file transfer"); + return params; + } finally { + pendingInits.remove(clientTransferId); + } + } + + /** + * Routes an incoming file-transfer notification to the negotiation that is waiting for + * it. {@code notifystartdownload}/{@code notifystartupload} carry the key, host and port; + * {@code notifystatusfiletransfer} signals a non-zero status (a refusal) before the + * transfer starts. + */ + private void handleFileTransferEvent(UnknownTeamspeakEvent e) { + String command = e.getCommand(); + if (command == null) return; + Map map = e.getMap(); + Integer ftfid = tryParseInt(map.get("clientftfid")); + if (ftfid == null) return; + CompletableFuture> future = pendingInits.get(ftfid); + if (future == null) return; + + if (command.equals("notifystartdownload") || command.equals("notifystartupload")) { + future.complete(map); + } else if (command.equals("notifystatusfiletransfer")) { + int status = (int) parseLong(map.get("status")); + if (status != 0) { + String msg = map.get("msg"); + future.completeExceptionally( + new IOException(msg != null && !msg.isEmpty() ? msg : "File transfer failed (status " + status + ")")); + } + } + } + + private Socket openFileSocket(FileTransferParameters params) throws IOException { + Socket socket = new Socket(); + socket.connect(new InetSocketAddress(resolveHost(params.getFileServerHost()), + params.getFileServerPort()), CONNECT_TIMEOUT_MS); + return socket; + } + + /** Sends the negotiated one-time key that authorises this connection to the file port. */ + private static void sendKey(Socket fileSocket, FileTransferParameters params) throws IOException { + OutputStream out = fileSocket.getOutputStream(); + out.write(params.getFileTransferKey().getBytes(StandardCharsets.US_ASCII)); + out.flush(); + } + + /** + * Picks the host to reach the file server. The {@code ip} the server reports may be + * a comma-separated list, empty, or a wildcard; in those cases fall back to the host + * we are already connected to. + */ + private String resolveHost(String reported) { + if (reported != null) { + String first = reported.split(",")[0].trim(); + if (!first.isEmpty() && !first.startsWith("0.0.0.0")) return first; + } + return fallbackHost.get(); + } + + private static void deleteQuietly(File file) { + if (file != null && file.isFile()) { + //noinspection ResultOfMethodCallIgnored + file.delete(); + } + } + + private static void skipFully(InputStream in, long n) throws IOException { + long remaining = n; + while (remaining > 0) { + long skipped = in.skip(remaining); + if (skipped <= 0) { + if (in.read() < 0) throw new IOException("Unexpected end of file while seeking"); + skipped = 1; + } + remaining -= skipped; + } + } + + public void shutdown() { + socket.removeListener(ftEventListener); + for (CompletableFuture> future : pendingInits.values()) { + future.completeExceptionally(new IOException("Disconnected")); + } + pendingInits.clear(); + transferPool.shutdownNow(); + } + + // ---- helpers ---- + + private static String fileName(String fullPath) { + int slash = fullPath.lastIndexOf('/'); + return slash < 0 ? fullPath : fullPath.substring(slash + 1); + } + + private static long parseLong(String s) { + if (s == null || s.isEmpty()) return 0; + try { + return Long.parseLong(s.trim()); + } catch (NumberFormatException e) { + return 0; + } + } + + private static Integer tryParseInt(String s) { + if (s == null || s.isEmpty()) return null; + try { + return Integer.parseInt(s.trim()); + } catch (NumberFormatException e) { + return null; + } + } + + private static String rootMessage(Throwable t) { + Throwable r = t; + while (r.getCause() != null && r.getCause() != r) r = r.getCause(); + String m = r.getMessage(); + return m != null ? m : r.getClass().getSimpleName(); + } +} diff --git a/ts3-client/core/src/main/java/com/ts3client/net/filetransfer/RemoteFile.java b/ts3-client/core/src/main/java/com/ts3client/net/filetransfer/RemoteFile.java new file mode 100644 index 0000000..52eb45e --- /dev/null +++ b/ts3-client/core/src/main/java/com/ts3client/net/filetransfer/RemoteFile.java @@ -0,0 +1,49 @@ +package com.ts3client.net.filetransfer; + +/** + * One entry in a channel's file repository, as returned by {@code ftgetfilelist}. + * A UI-facing value type that keeps the reusable core free of ts3j API classes. + */ +public final class RemoteFile { + + private final String name; + /** Parent directory path, always ending in {@code /} (e.g. {@code /} or {@code /sub/}). */ + private final String parentPath; + private final long size; + private final boolean directory; + private final long lastModifiedEpochSec; + + public RemoteFile(String name, String parentPath, long size, boolean directory, long lastModifiedEpochSec) { + this.name = name; + this.parentPath = parentPath == null || parentPath.isEmpty() ? "/" : parentPath; + this.size = size; + this.directory = directory; + this.lastModifiedEpochSec = lastModifiedEpochSec; + } + + public String getName() { + return name; + } + + public String getParentPath() { + return parentPath; + } + + /** Full server path including the file name, e.g. {@code /sub/clip.ogg}. */ + public String getFullPath() { + String base = parentPath.endsWith("/") ? parentPath : parentPath + "/"; + return base + name; + } + + public long getSize() { + return size; + } + + public boolean isDirectory() { + return directory; + } + + public long getLastModifiedEpochSec() { + return lastModifiedEpochSec; + } +} diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/FileBrowserDialog.java b/ts3-client/swing/src/main/java/com/ts3client/ui/FileBrowserDialog.java new file mode 100644 index 0000000..c6138d4 --- /dev/null +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/FileBrowserDialog.java @@ -0,0 +1,467 @@ +package com.ts3client.ui; + +import com.ts3client.net.ChannelNode; +import com.ts3client.net.TeamspeakConnection; +import com.ts3client.net.filetransfer.FileTransfer; +import com.ts3client.net.filetransfer.RemoteFile; + +import javax.swing.BorderFactory; +import javax.swing.Box; +import javax.swing.BoxLayout; +import javax.swing.JButton; +import javax.swing.JDialog; +import javax.swing.JFileChooser; +import javax.swing.JLabel; +import javax.swing.JOptionPane; +import javax.swing.JPanel; +import javax.swing.JProgressBar; +import javax.swing.JScrollPane; +import javax.swing.JTable; +import javax.swing.ListSelectionModel; +import javax.swing.SwingUtilities; +import javax.swing.table.AbstractTableModel; +import java.awt.BorderLayout; +import java.awt.Component; +import java.awt.Dimension; +import java.awt.Frame; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.io.File; +import java.text.DecimalFormat; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.Date; +import java.util.List; + +/** + * Browses a channel's file repository and drives uploads and downloads. The table + * lists the current directory; the toolbar navigates and acts on the selection; the + * strip at the bottom shows live progress for each active transfer. + * + *

All server calls run on background threads so the dialog never blocks the EDT; + * results are marshalled back with {@link SwingUtilities#invokeLater}. + */ +public final class FileBrowserDialog extends JDialog { + + private final TeamspeakConnection conn; + private final int channelId; + private final String channelPassword; + + private final FileTableModel tableModel = new FileTableModel(); + private final JTable table = new JTable(tableModel); + private final JLabel pathLabel = new JLabel("/"); + private final JButton upButton = new JButton("Up"); + private final JButton downloadButton = new JButton("Download"); + private final JButton deleteButton = new JButton("Delete"); + private final JPanel transfersPanel = new JPanel(); + + private volatile String currentPath = "/"; + + public FileBrowserDialog(Frame owner, TeamspeakConnection conn, ChannelNode channel) { + super(owner, "Files — " + channel.name, false); + this.conn = conn; + this.channelId = channel.id; + this.channelPassword = ""; + + JPanel content = new JPanel(new BorderLayout(0, 8)); + content.setBackground(Theme.WINDOW_BG); + content.setBorder(BorderFactory.createEmptyBorder(10, 12, 10, 12)); + content.add(buildToolbar(), BorderLayout.NORTH); + content.add(buildTable(), BorderLayout.CENTER); + content.add(buildTransfers(), BorderLayout.SOUTH); + setContentPane(content); + + setPreferredSize(new Dimension(640, 480)); + pack(); + setLocationRelativeTo(owner); + + refresh(); + } + + // ---- construction ---- + + private JPanel buildToolbar() { + JPanel bar = new JPanel(); + bar.setLayout(new BoxLayout(bar, BoxLayout.X_AXIS)); + bar.setBackground(Theme.WINDOW_BG); + + upButton.addActionListener(e -> navigateUp()); + JButton refresh = new JButton("Refresh"); + refresh.addActionListener(e -> refresh()); + JButton mkdir = new JButton("New folder"); + mkdir.addActionListener(e -> createDirectory()); + JButton upload = new JButton("Upload…"); + upload.addActionListener(e -> chooseUpload()); + downloadButton.addActionListener(e -> downloadSelected()); + deleteButton.addActionListener(e -> deleteSelected()); + + bar.add(upButton); + bar.add(Box.createHorizontalStrut(6)); + bar.add(refresh); + bar.add(Box.createHorizontalStrut(16)); + bar.add(pathLabel); + bar.add(Box.createHorizontalGlue()); + bar.add(mkdir); + bar.add(Box.createHorizontalStrut(6)); + bar.add(upload); + bar.add(Box.createHorizontalStrut(6)); + bar.add(downloadButton); + bar.add(Box.createHorizontalStrut(6)); + bar.add(deleteButton); + + pathLabel.setFont(Theme.UI_FONT); + pathLabel.setForeground(Theme.TREE_TEXT); + return bar; + } + + private JScrollPane buildTable() { + table.setFont(Theme.UI_FONT); + table.setRowHeight(20); + table.setSelectionMode(ListSelectionModel.MULTIPLE_INTERVAL_SELECTION); + table.setFillsViewportHeight(true); + table.getColumnModel().getColumn(0).setPreferredWidth(300); + table.getColumnModel().getColumn(1).setPreferredWidth(90); + table.getColumnModel().getColumn(2).setPreferredWidth(70); + table.getColumnModel().getColumn(3).setPreferredWidth(150); + table.getSelectionModel().addListSelectionListener(e -> updateButtons()); + table.addMouseListener(new MouseAdapter() { + @Override + public void mouseClicked(MouseEvent e) { + if (SwingUtilities.isLeftMouseButton(e) && e.getClickCount() == 2) { + int row = table.rowAtPoint(e.getPoint()); + if (row >= 0) openEntry(tableModel.get(row)); + } + } + }); + JScrollPane scroll = new JScrollPane(table); + scroll.getViewport().setBackground(Theme.TREE_BG); + return scroll; + } + + private JScrollPane buildTransfers() { + transfersPanel.setLayout(new BoxLayout(transfersPanel, BoxLayout.Y_AXIS)); + transfersPanel.setBackground(Theme.WINDOW_BG); + JScrollPane scroll = new JScrollPane(transfersPanel); + scroll.setBorder(BorderFactory.createTitledBorder("Transfers")); + scroll.setPreferredSize(new Dimension(10, 120)); + scroll.getViewport().setBackground(Theme.WINDOW_BG); + return scroll; + } + + // ---- navigation ---- + + private void navigateUp() { + String path = currentPath; + if (path.equals("/")) return; + String trimmed = path.substring(0, path.length() - 1); // drop trailing slash + int slash = trimmed.lastIndexOf('/'); + currentPath = slash <= 0 ? "/" : trimmed.substring(0, slash + 1); + refresh(); + } + + private void openEntry(RemoteFile file) { + if (file == null) return; + if (file.isDirectory()) { + currentPath = currentPath + file.getName() + "/"; + refresh(); + } else { + download(file); + } + } + + /** Reloads the current directory listing from the server. */ + private void refresh() { + pathLabel.setText(currentPath); + upButton.setEnabled(!currentPath.equals("/")); + final String path = currentPath; + new Thread(() -> { + try { + List files = conn.listFiles(channelId, channelPassword, path); + Collections.sort(files, DIRECTORIES_FIRST); + SwingUtilities.invokeLater(() -> { + if (path.equals(currentPath)) tableModel.setFiles(files); + updateButtons(); + }); + } catch (Exception ex) { + SwingUtilities.invokeLater(() -> { + if (path.equals(currentPath)) tableModel.setFiles(new ArrayList<>()); + error("Could not list files", ex); + }); + } + }, "ts3j-ft-list").start(); + } + + private void updateButtons() { + boolean hasSelection = table.getSelectedRowCount() > 0; + deleteButton.setEnabled(hasSelection); + boolean fileSelected = false; + for (int row : table.getSelectedRows()) { + if (!tableModel.get(row).isDirectory()) { + fileSelected = true; + break; + } + } + downloadButton.setEnabled(fileSelected); + } + + // ---- actions ---- + + private void downloadSelected() { + List files = new ArrayList<>(); + for (int row : table.getSelectedRows()) { + RemoteFile f = tableModel.get(row); + if (!f.isDirectory()) files.add(f); + } + if (files.isEmpty()) return; + if (files.size() == 1) { + download(files.get(0)); + return; + } + // Multiple files: pick a target directory once. + JFileChooser chooser = new JFileChooser(); + chooser.setFileSelectionMode(JFileChooser.DIRECTORIES_ONLY); + chooser.setDialogTitle("Download " + files.size() + " files to…"); + if (chooser.showSaveDialog(this) != JFileChooser.APPROVE_OPTION) return; + File dir = chooser.getSelectedFile(); + for (RemoteFile f : files) { + startDownload(f, new File(dir, f.getName())); + } + } + + private void download(RemoteFile file) { + JFileChooser chooser = new JFileChooser(); + chooser.setSelectedFile(new File(file.getName())); + chooser.setDialogTitle("Download " + file.getName()); + if (chooser.showSaveDialog(this) != JFileChooser.APPROVE_OPTION) return; + startDownload(file, chooser.getSelectedFile()); + } + + private void startDownload(RemoteFile file, File target) { + TransferRow row = new TransferRow(); + FileTransfer transfer = conn.downloadFile(channelId, channelPassword, file.getFullPath(), target, + t -> SwingUtilities.invokeLater(() -> row.update(t))); + row.attach(transfer); + addTransferRow(row); + } + + private void chooseUpload() { + JFileChooser chooser = new JFileChooser(); + chooser.setMultiSelectionEnabled(true); + chooser.setDialogTitle("Upload to " + currentPath); + if (chooser.showOpenDialog(this) != JFileChooser.APPROVE_OPTION) return; + for (File source : chooser.getSelectedFiles()) { + if (!source.isFile()) continue; + String remotePath = currentPath + source.getName(); + TransferRow row = new TransferRow(); + FileTransfer transfer = conn.uploadFile(channelId, channelPassword, remotePath, source, true, + t -> SwingUtilities.invokeLater(() -> { + row.update(t); + if (t.getState() == FileTransfer.State.COMPLETED) refresh(); + })); + row.attach(transfer); + addTransferRow(row); + } + } + + private void createDirectory() { + String name = JOptionPane.showInputDialog(this, "New folder name:", "New folder", + JOptionPane.PLAIN_MESSAGE); + if (name == null || name.trim().isEmpty()) return; + final String dirPath = currentPath + name.trim(); + new Thread(() -> { + try { + conn.createDirectory(channelId, channelPassword, dirPath); + SwingUtilities.invokeLater(this::refresh); + } catch (Exception ex) { + SwingUtilities.invokeLater(() -> error("Could not create folder", ex)); + } + }, "ts3j-ft-mkdir").start(); + } + + private void deleteSelected() { + List files = new ArrayList<>(); + for (int row : table.getSelectedRows()) files.add(tableModel.get(row)); + if (files.isEmpty()) return; + int choice = JOptionPane.showConfirmDialog(this, + "Delete " + (files.size() == 1 ? "\"" + files.get(0).getName() + "\"" : files.size() + " items") + + "?", "Delete", JOptionPane.OK_CANCEL_OPTION, JOptionPane.WARNING_MESSAGE); + if (choice != JOptionPane.OK_OPTION) return; + new Thread(() -> { + Exception failure = null; + for (RemoteFile f : files) { + try { + conn.deleteFile(channelId, channelPassword, f.getFullPath()); + } catch (Exception ex) { + failure = ex; + } + } + final Exception shown = failure; + SwingUtilities.invokeLater(() -> { + refresh(); + if (shown != null) error("Could not delete some items", shown); + }); + }, "ts3j-ft-delete").start(); + } + + private void addTransferRow(TransferRow row) { + transfersPanel.add(row); + transfersPanel.revalidate(); + transfersPanel.repaint(); + } + + private void error(String title, Exception ex) { + JOptionPane.showMessageDialog(this, rootMessage(ex), title, JOptionPane.ERROR_MESSAGE); + } + + // ---- one transfer's row ---- + + private final class TransferRow extends JPanel { + private final JLabel label = new JLabel(); + private final JProgressBar bar = new JProgressBar(0, 1000); + private final JButton action = new JButton("Cancel"); + private FileTransfer transfer; + + TransferRow() { + setLayout(new BorderLayout(8, 0)); + setBackground(Theme.WINDOW_BG); + setBorder(BorderFactory.createEmptyBorder(3, 2, 3, 2)); + setMaximumSize(new Dimension(Integer.MAX_VALUE, 44)); + label.setFont(Theme.UI_FONT); + bar.setStringPainted(true); + add(label, BorderLayout.NORTH); + add(bar, BorderLayout.CENTER); + add(action, BorderLayout.EAST); + } + + void attach(FileTransfer t) { + this.transfer = t; + action.addActionListener(e -> { + if (transfer.isDone()) { + transfersPanel.remove(this); + transfersPanel.revalidate(); + transfersPanel.repaint(); + } else { + transfer.cancel(); + } + }); + update(t); + } + + void update(FileTransfer t) { + String arrow = t.getDirection() == FileTransfer.Direction.UPLOAD ? "↑" : "↓"; + String head = arrow + " " + t.getRemoteName(); + switch (t.getState()) { + case PENDING: + label.setText(head + " — starting…"); + break; + case ACTIVE: + label.setText(head + " — " + formatBytes(t.getTransferredBytes()) + " / " + + formatBytes(t.getTotalBytes()) + " (" + formatRate(t.getAverageSpeed()) + ")"); + break; + case COMPLETED: + label.setText(head + " — done (" + formatBytes(t.getTotalBytes()) + ")"); + break; + case CANCELLED: + label.setText(head + " — cancelled"); + break; + case FAILED: + label.setText(head + " — failed: " + orEmpty(t.getErrorMessage())); + break; + default: + break; + } + int permille = (int) Math.round(t.getProgress() * 1000); + bar.setValue(permille); + bar.setString(t.getTotalBytes() > 0 ? (permille / 10) + "%" : ""); + if (t.isDone()) { + bar.setValue(t.getState() == FileTransfer.State.COMPLETED ? 1000 : bar.getValue()); + action.setText("Clear"); + } + } + } + + // ---- table model ---- + + private static final Comparator DIRECTORIES_FIRST = (a, b) -> { + if (a.isDirectory() != b.isDirectory()) return a.isDirectory() ? -1 : 1; + return a.getName().compareToIgnoreCase(b.getName()); + }; + + private static final class FileTableModel extends AbstractTableModel { + private static final String[] COLUMNS = {"Name", "Size", "Type", "Modified"}; + private final SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm"); + private List files = new ArrayList<>(); + + void setFiles(List files) { + this.files = files; + fireTableDataChanged(); + } + + RemoteFile get(int row) { + return files.get(row); + } + + @Override + public int getRowCount() { + return files.size(); + } + + @Override + public int getColumnCount() { + return COLUMNS.length; + } + + @Override + public String getColumnName(int column) { + return COLUMNS[column]; + } + + @Override + public Object getValueAt(int rowIndex, int columnIndex) { + RemoteFile f = files.get(rowIndex); + switch (columnIndex) { + case 0: + return f.getName(); + case 1: + return f.isDirectory() ? "" : formatBytes(f.getSize()); + case 2: + return f.isDirectory() ? "Folder" : "File"; + case 3: + long sec = f.getLastModifiedEpochSec(); + return sec > 0 ? dateFormat.format(new Date(sec * 1000)) : ""; + default: + return ""; + } + } + } + + // ---- formatting ---- + + private static String formatBytes(long bytes) { + if (bytes < 0) return "—"; + if (bytes < 1024) return bytes + " B"; + double kib = bytes / 1024.0; + if (kib < 1024) return new DecimalFormat("0.0").format(kib) + " KiB"; + double mib = kib / 1024.0; + if (mib < 1024) return new DecimalFormat("0.00").format(mib) + " MiB"; + return new DecimalFormat("0.00").format(mib / 1024.0) + " GiB"; + } + + private static String formatRate(double bytesPerSecond) { + if (bytesPerSecond <= 0) return "—"; + return formatBytes(Math.round(bytesPerSecond)) + "/s"; + } + + private static String orEmpty(String s) { + return s == null ? "" : s; + } + + private static String rootMessage(Throwable t) { + Throwable r = t; + while (r.getCause() != null && r.getCause() != r) r = r.getCause(); + String m = r.getMessage(); + return m != null ? m : r.getClass().getSimpleName(); + } +} diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/MainFrame.java b/ts3-client/swing/src/main/java/com/ts3client/ui/MainFrame.java index 4490cfa..68b35af 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/MainFrame.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/MainFrame.java @@ -469,6 +469,12 @@ public final class MainFrame extends JFrame implements ConnectionListener, Serve new ConnectionInfoDialog(this, conn, client.id, client.nickname).setVisible(true); } + @Override + public void browseFiles(ChannelNode channel) { + if (!conn.canTransferFiles()) return; + new FileBrowserDialog(this, conn, channel).setVisible(true); + } + @Override public boolean isClientLocallyMuted(int clientId) { return conn.getPlayback() != null && conn.getPlayback().isClientMuted(clientId); diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTreePanel.java b/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTreePanel.java index f60d265..4ba6276 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTreePanel.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTreePanel.java @@ -37,6 +37,9 @@ public final class ServerTreePanel extends JScrollPane { void showConnectionInfo(ClientEntry client); + /** Open the file repository browser for a channel. */ + void browseFiles(ChannelNode channel); + boolean isClientLocallyMuted(int clientId); /** A channel or client node was selected (or {@code null} when cleared). */ @@ -152,6 +155,10 @@ public final class ServerTreePanel extends JScrollPane { JMenuItem join = new JMenuItem("Join channel"); join.addActionListener(a -> actions.joinChannel(channel.id)); menu.add(join); + menu.addSeparator(); + JMenuItem files = new JMenuItem("Browse files"); + files.addActionListener(a -> actions.browseFiles(channel)); + menu.add(files); menu.show(tree, e.getX(), e.getY()); }