2025/11/17

JavaFX Architecture

JavaFX 是 Java 製作 Content-Rich Client application 的 API。

Architecture

整個 API 的架構如下,包含了 2D 3D Graphic 以及 Web Engine,還有 GUI 的 UI 元件

重要的 API package 如下:

  • javafx.animation

    產生 transition based animations,例如 javafx nodes 的 fill, fade, rotate, scale, translation

  • javafx.application

    控制 JavaFX application 的 life cycle

  • javafx.css

    對 JavaFX GUI application 增加類似 css styling 的功能

  • javafx.event

    傳遞及處理事件

  • javafx.geometry

    產生及控制 2D object

  • javafx.stage

    JavaFX application 的最上層的 container class

  • javafx.scene

    支援 scene graph 的類別,提供 canvas, chart, control, effect, image, input, layout, media, paint, shape, text, transform, web 等等功能

scene graph, node

JavaFX application 是以 Scene Graph 實作,建構 GUI application 的起點就是 scene graph,裡面是 nodes。

node 是一個 visual/graphical object,可能是

  • geometrical (graphical) objects

    2D/3D 物件,例如 circle, rectangle, polygon

  • UI controls

    例如 Button, Checkbox, Text Area

  • Containers

    layut panes,例如 Border Pane, Grid Pane, Flow Pane

  • Media elements

    例如 audio, video, image objects

scene graph 就是由很多 nodes 組合而成的,nodes 會有類似以下的階層式架構

類似這樣的架構,在實際上的 application 可能會是這樣

scene graph 裡面的每一個 node 都有單一一個 parent,唯一一個沒有 parents 的就是 root node。每一個 node 可以有多個 children,沒有 children 的 node 就是 leaf node,如果有 children 的 node 就稱為 branch node。

每一個 node instance 只能被加入到一個 scene graph 一次,node 可能會是 Effects, Opacity, Transforms, Event Handlers, Event Handlers, Application Specific States

Prism

prism 是高效硬體加速的 graphical pipeline,在 JavaFX 用來 render graphics,可 render 2D/3D graphics

為了產生 graphic,prism 會使用

  • DirectX 11 on Windows 7,D3D11
  • DirectX 12 on Windows 10/11,DX12 跟 DX11 相容,D3D11 最常見
  • OpenGL on Mac and Linux, Embedded Systems.

在啟動 JavaFX application 時,加上 -Dprism.verbose=true 參數,可列印 prism 資訊。如果遇到沒有硬體加速的系統,會自動使用軟體 render。

GWT (Glass Windowing Toolkit)

JavaFX 的底層視窗系統抽象層,對平台視窗、輸入事件、螢幕等系統資源提供抽象,供 JavaFX API 使用,負責處理平台相關的:

  • 視窗建立與管理(window creation)

  • 輸入事件(滑鼠、鍵盤、觸控)

  • 螢幕資訊(顯示器解析度、DPI 等)

  • 視窗裝飾(標題列、邊框)

  • 與作業系統的整合

針對不同 OS(Windows / macOS / Linux)有不同的實作

Quantum Toolkit

是 JavaFX 的主執行環境工具包:

  • 它提供了一個 UI 執行緒(JavaFX Application Thread)

  • 負責把 JavaFX API 呼叫分派到底層子系統(例如 Prism, Glass, Media, WebEngine 等),也就是抽象化 Prism, Glass, Media Engine, and Web Engine 底層元件

  • 實作事件分派、場景圖(Scene Graph)渲染與更新調度

會將 Prism 及 GWT 整合在一起

Quantum Toolkit 的主要任務包括:

功能 說明
管理 JavaFX 主執行緒 即 JavaFX Application Thread
事件循環(Event Loop)處理 包括滑鼠、鍵盤、動畫、定時器等事件
計畫場景更新(Pulse) 定時發出 pulse 信號來觸發重繪
安排渲染與場景圖(Scene Graph)同步 確保 UI 狀態與畫面一致

WebView

JavaFX 可嵌入 html content 到 scene graph,透過 WebView 使用 Web Kit 處理這種 content。Web Kit 是 open source web browser engine,可支援 HTML5, CSS, JavaScript, DOM and SVG

WebView 裡面可以使用以下功能

  • 從 local file 或是 remote URL render HTML content
  • 支援 history,可往前或往後瀏覽
  • reload content
  • 為 web component 套用 effects
  • 編輯 html
  • 執行 javascript
  • 處理事件

Media Engine

JavaFX Media engine 是使用 "Streamer" 這個 open source engine 實作,可支援 video/audio content playback

audio 支援 mp3, aac(*.acc *.m4a部分平台預設支援), wav, aiff,不支援 flac, ogg, wma,需要自己處理 GStreamer Plugin

video 支援 mp4 (*.mp4, *.m4v ) 視訊編碼是 h.264,聲音編碼 aac。flv 部分,視訊編碼 h.264 或 Sorenson,聲音編碼為 aac 或 mp3。不支援 WebM (vp8/vp9), mkv, avi, mov

提供三種 component

  • Media Object

  • Media Player

  • Media View

遇到不支援的格式時,會發生 MediaException

2025/11/10

JavaFX Markdown Viewer

在 Java 處理 Markdown 文件,可使用 flexmark 套件,而 JavaFX,是使用 openjfx,因為 jfx 並不在 openjdk 裡面。

這邊因為使用舊版的 macos,所以使用很舊版本的openjfx

        <!-- markdown -->
        <dependency>
            <groupId>com.vladsch.flexmark</groupId>
            <artifactId>flexmark-all</artifactId>
            <version>0.64.8</version>
        </dependency>

        <!-- java FX -->
        <dependency>
            <groupId>org.openjfx</groupId>
            <artifactId>javafx-controls</artifactId>
            <version>17.0.2</version>
        </dependency>
        <dependency>
            <groupId>org.openjfx</groupId>
            <artifactId>javafx-web</artifactId>
            <version>17.0.2</version>
        </dependency>

pom.xml下面的 plugin 這樣寫

    <build>
        <plugins>
            <!-- JavaFX Maven Plugin -->
            <plugin>
                <groupId>org.openjfx</groupId>
                <artifactId>javafx-maven-plugin</artifactId>
                <version>0.0.8</version>
                <configuration>
                    <mainClass>MarkdownViewerApp</mainClass>
                </configuration>
            </plugin>

            <!-- Build fat JAR -->
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-assembly-plugin</artifactId>
                <version>3.7.1</version>
                <configuration>
                    <archive>
                        <manifest>
                            <mainClass>${main.class}</mainClass>
                        </manifest>
                    </archive>
                    <descriptorRefs>
                        <descriptorRef>jar-with-dependencies</descriptorRef>
                    </descriptorRefs>
                </configuration>
                <executions>
                    <execution>
                        <id>make-assembly</id>
                        <phase>package</phase>
                        <goals>
                            <goal>single</goal>
                        </goals>
                    </execution>
                </executions>
            </plugin>

            <!-- Java compiler -->
            <plugin>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>3.14.0</version>
                <configuration>
                    <source>${maven.compiler.source}</source>
                    <target>${maven.compiler.target}</target>
                </configuration>
            </plugin>
        </plugins>
    </build>

MarkdownTable

在 flexmark,要處理 table 時,需要加入 extension,以下是一個處理 Markdown Table 的 sample

import com.vladsch.flexmark.parser.Parser;
import com.vladsch.flexmark.html.HtmlRenderer;
import com.vladsch.flexmark.util.data.MutableDataSet;
import com.vladsch.flexmark.ext.tables.TablesExtension;

import java.util.Arrays;

public class MarkdownTableExample {
    public static void main(String[] args) {
        String markdown =
                "| Name  | Age |\n" +
                "|-------|-----|\n" +
                "| John  | 30  |\n" +
                "| Alice | 25  |";

        MutableDataSet options = new MutableDataSet();
        options.set(Parser.EXTENSIONS, Arrays.asList(TablesExtension.create()));

        Parser parser = Parser.builder(options).build();
        HtmlRenderer renderer = HtmlRenderer.builder(options).build();

        String html = renderer.render(parser.parse(markdown));
        System.out.println(html);
    }
}

執行結果

<table>
<thead>
<tr><th>Name</th><th>Age</th></tr>
</thead>
<tbody>
<tr><td>John</td><td>30</td></tr>
<tr><td>Alice</td><td>25</td></tr>
</tbody>
</table>


Process finished with exit code 0

MarkdownViewer

html table 加上 css

import javafx.application.Application;
import javafx.scene.Scene;
import javafx.scene.control.TextArea;
import javafx.scene.layout.BorderPane;
import javafx.scene.web.WebView;
import javafx.stage.Stage;
import com.vladsch.flexmark.html.HtmlRenderer;
import com.vladsch.flexmark.parser.Parser;
import com.vladsch.flexmark.util.ast.Node;
import com.vladsch.flexmark.ext.tables.TablesExtension;
import com.vladsch.flexmark.util.data.MutableDataSet;

import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.Scanner;

public class MarkdownViewerApp extends Application {

    private MutableDataSet options;
    private Parser parser;
    private HtmlRenderer renderer;

    @Override
    public void start(Stage primaryStage) {
        options = new MutableDataSet();
        options.set(Parser.EXTENSIONS, Arrays.asList(TablesExtension.create()));
        parser = Parser.builder(options).build();
        renderer = HtmlRenderer.builder(options).build();

        // CSS
        String css = """
                body {
                    font-family: sans-serif;
                    margin: 20px;
                    line-height: 1.6;
                }
                table {
                    border-collapse: collapse;
                    width: 100%;
                }
                th, td {
                    border: 1px solid #ccc;
                    padding: 6px 10px;
                }
                th {
                    background-color: #f0f0f0;
                }
                """;

        TextArea markdownInput = new TextArea();
        WebView htmlView = new WebView();

        // Default text
//        markdownInput.setText("# Hello Markdown\n\nThis is **bold** and this is _italic_.");
        try (InputStream in = getClass().getResourceAsStream("/test.md")) {
            if (in != null) {
                Scanner scanner = new Scanner(in, StandardCharsets.UTF_8).useDelimiter("\\A");
                String htmlContent = scanner.hasNext() ? scanner.next() : "";

                markdownInput.setText(htmlContent);
            }
        } catch (Exception e) {
            e.printStackTrace();
        }

        // Convert and display Markdown as HTML
        markdownInput.textProperty().addListener((obs, oldVal, newVal) -> {
            Node document = parser.parse(newVal);
            String htmlBody = renderer.render(document);
            String html = """
                <html>
                <head>
                <style>%s</style>
                </head>
                <body>
                    %s
                </body>
                </html>
            """.formatted(css, htmlBody);
            htmlView.getEngine().loadContent(html);
        });

        // Initial render
        Node document = parser.parse(markdownInput.getText());
        String htmlBody = renderer.render(document);
        String html = """
                <html>
                <head>
                <style>%s</style>
                </head>
                <body>
                    %s
                </body>
                </html>
            """.formatted(css, htmlBody);
        htmlView.getEngine().loadContent(html);

        BorderPane root = new BorderPane();
        root.setLeft(markdownInput);
        root.setCenter(htmlView);

        Scene scene = new Scene(root, 800, 600);
        primaryStage.setTitle("JavaFX Markdown Viewer");
        primaryStage.setScene(scene);
        primaryStage.show();
    }

    public static void main(String[] args) {
        launch(args);
    }
}

可直接用 mvn 啟動

mvn javafx:run

獨立執行需下載 openjfx sdk,可到 JavaFX - Gluon 下載,openjfx-17.0.2_osx-x64_bin-sdk.zip

執行 mvn package 可產生 jar,然後就能這樣啟動

java --module-path ~/Downloads/javafx-sdk-17.0.2/lib/ --add-modules javafx.controls,javafx.web -jar target/markdownviewer-1.0-SNAPSHOT-jar-with-dependencies.jar MarkdownViewerApp

這是執行結果

2025/11/03

JSch

Jsch 是 Java 實作的 ssh, sftp library,這個函式庫原本是由 com.jcraft:jsch 實作,但在 2018 就停止更新,故現在要改使用 com.github.mwiede:jsch

只需要在 maven 加入 library 即可,最低可使用 Java 8

<dependency>
  <groupId>com.github.mwiede</groupId>
  <artifactId>jsch</artifactId>
  <version>2.27.0</version>
</dependency>

exec

jsch 最基本是使用 exec channel,一次執行一個指令,沒有 context,單獨執行的指令。

以下程式另外用 Scanner 包裝一個簡單的互動介面

import com.jcraft.jsch.ChannelExec;
import com.jcraft.jsch.JSch;
import com.jcraft.jsch.JSchException;
import com.jcraft.jsch.Session;

import org.apache.commons.io.IOUtils;

import java.io.*;
import java.nio.charset.StandardCharsets;
import java.util.Scanner;

public class JSchExecTest {
    private Session session;

    public void connect(String host, int port, String user, String pwd) throws JSchException {
        disconnect();
        JSch jsch = new JSch();
        Session session = jsch.getSession(user, host, port);
        session.setPassword(pwd);
        // yes / no / ask
        session.setConfig("StrictHostKeyChecking", "no");
        session.connect();
        this.session = session;
    }

    public void disconnect() throws JSchException {
        if( session!=null ) {
            session.disconnect();
            session = null;
        }
    }

    public void execCmd(String command) throws JSchException, IOException {
        if( session==null ) {
            return;
        }
//        ChannelExec    單指令,單獨執行(適合非交互式)
//        ChannelShell    多指令、有狀態、有上下文需求(如 cd)
        ChannelExec channelExec = (ChannelExec) session.openChannel("exec");
        channelExec.setCommand(command);

        // set error output stream
        // channelExec.setErrStream(System.err);
        ByteArrayOutputStream errorOutputStream = new ByteArrayOutputStream();
        channelExec.setErrStream(errorOutputStream);

        // get command output
        InputStream in = channelExec.getInputStream();

        // execute command
        channelExec.connect();

        //// get output content
//        BufferedReader reader = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8));
//        String line;
//        while ((line = reader.readLine()) != null) {
//            System.out.println(line);
//        }
//        reader.close();

        String output = IOUtils.toString(in, StandardCharsets.UTF_8);
        if (!output.isEmpty()) {
            System.out.println("Exec Output: " + command + "\r\n" + output);
        }

        // error
        String errorMsg = errorOutputStream.toString();
        if (!errorMsg.isEmpty()) {
            System.err.println("Error Output: " + errorMsg);
        }

        channelExec.disconnect();
    }

    public void printhelp() {
        System.out.println("connect host port username password");
        System.out.println("exec command");
        System.out.println("disconnect");
        System.out.println("");
    }

    public static void main(String[] args) {
        JSchExecTest jSchExecTest = new JSchExecTest();
        try {
//            session = JschTest1.connect("192.168.1.89", 22, "user", "pass");
//            JschTest1.execCmd(session, "ls -al");
//            JschTest1.execCmd(session, "ls -al ggg");
//            JschTest1.disconnect(session);

            Scanner scanner = new Scanner(System.in);

            System.out.println("jsch CLI: Enter a command (type 'exit' to quit)");
            while (true) {
                System.out.print("> ");
                String input = scanner.nextLine().trim();
                if (input.isEmpty()) continue;

                // 分割整行輸入,第一個為指令,其餘為參數
                String[] parts = input.split("\\s+", 2); // 最多分兩段:指令 和 其餘參數
                String command = parts[0];
                String arguments = parts.length > 1 ? parts[1] : "";

                if ("exit".equalsIgnoreCase(command)) {
                    System.out.println("Goodbye!");
                    break;
                }

                switch (command.toLowerCase()) {
                    case "h":
                        jSchExecTest.printhelp();
                        break;
                    case "help":
                        jSchExecTest.printhelp();
                        break;
                    case "?":
                        jSchExecTest.printhelp();
                        break;
                    case "connect":
                        String[] argparts = arguments.split("\\s+");
                        if( argparts.length == 4 ) {
                            try {
                                jSchExecTest.connect(argparts[0], Integer.parseInt(argparts[1]), argparts[2], argparts[3]);
                            } catch (JSchException je) {
                            }
                        } else {
                            System.out.println("connect host port username password");
                        }
                        break;
                    case "disconnect":
                        System.out.println("Current time: " + java.time.LocalTime.now());
                        jSchExecTest.disconnect();
                        break;
                    case "exec":
                        jSchExecTest.execCmd(arguments);
                        break;
                    default:
                        System.out.println("Unknown command: " + input);
                }
            }
            scanner.close();
        } catch (JSchException e) {
            e.printStackTrace();
        } catch (IOException ioe) {
            ioe.printStackTrace();
        }
    }
}

測試

jsch CLI: Enter a command (type 'exit' to quit)
> connect 192.168.1.89 22 user pass
> ls -al
Unknown command: ls -al
> exec ls -al
Exec Output: ls -al
總計 24
drwx------. 5 larzio larzio 159  6月 11 16:31 .
.....
> disconnect
Current time: 17:50:59.521429
> exit
Goodbye!

shell

像是 cd 指令,需要記錄目錄,就需要改用 shell channell

import com.jcraft.jsch.*;

import java.io.*;

public class JSchInteractiveShell {

    public static void main(String[] args) throws Exception {
        String user = "user";
        String host = "192.168.1.89";
        int port = 22;
        String password = "s2papago";
        String sudoPassword = "pass";

        JSch jsch = new JSch();
        Session session = jsch.getSession(user, host, port);
        session.setPassword(password);

        // 不驗證 host key(開發用,正式環境請換成嚴格檢查)
//        session.setConfig("StrictHostKeyChecking", "no");
        // 指定 known_hosts 檔案位置 (通常是 ~/.ssh/known_hosts)
        String knownHostsPath = System.getProperty("user.home") + "/.ssh/known_hosts";
        jsch.setKnownHosts(knownHostsPath);
        session.setConfig("StrictHostKeyChecking", "yes");

        session.connect();

        ChannelShell channel = (ChannelShell) session.openChannel("shell");

        InputStream in = channel.getInputStream();
        OutputStream out = channel.getOutputStream();

        PrintWriter writer = new PrintWriter(out, true);
        channel.connect();

        // thread 持續讀取遠端輸出
        Thread readerThread = new Thread(() -> {
            try (BufferedReader reader = new BufferedReader(new InputStreamReader(in))) {
                String line;
                while ((line = reader.readLine()) != null) {
                    System.out.println(line);
//                    // 判斷是否有 sudo 密碼提示 (常見字串,可視環境調整)
//                    if (line.toLowerCase().contains("[sudo] password") ||
//                        line.toLowerCase().contains("password for " + user.toLowerCase()) || line.toLowerCase().contains("密碼:")) {
//                        System.out.println("[INFO] Detected sudo password prompt, sending password...");
//                        writer.println(sudoPassword);  // 自動送 sudo 密碼
//                    }
                }
            } catch (IOException e) {
                e.printStackTrace();
            }
        });
        readerThread.start();
        Thread.sleep(1000);
        // main thread: 從 System.in 讀使用者輸入,傳給遠端 shell
        try (BufferedReader consoleReader = new BufferedReader(new InputStreamReader(System.in)) ) {

            String command;
            System.out.print("$ ");  // prompt
            while ((command = consoleReader.readLine()) != null) {
                writer.println(command);
                if ("exit".equalsIgnoreCase(command.trim())) {
                    break;
                }
                System.out.print("$ ");
            }
        }

        readerThread.join();
        channel.disconnect();
        session.disconnect();
    }
}

測試

$ ls -al
[larzio@lzstg2 ~]$ ls -al
總計 24
drwx------. 5 larzio larzio 159  6月 11 16:31 .
drwxr-xr-x. 3 root   root    20  9月 11  2024 ..
.............
cd download
$ [larzio@lzstg2 ~]$ cd download
l s-al
$ [larzio@lzstg2 download]$ l s-al
-bash: l:命令找不到
ls -al
$ [larzio@lzstg2 download]$ ls -al
總計 16
drwxr-xr-x  2 root   root     70  6月 11 16:29 .
drwx------. 5 larzio larzio  159  6月 11 16:31 ..
.............
exit
[larzio@lzstg2 download]$ exit
登出

sftp

上傳或下載檔案,如果要能支援目錄,要使用 sftp channel

import com.jcraft.jsch.*;

import java.io.*;
import java.util.Vector;

public class JSchSftpUtil {

    private Session session;
    private ChannelSftp sftp;

    public JSchSftpUtil(Session session) throws JSchException {
        this.session = session;
        Channel channel = session.openChannel("sftp");
        channel.connect();
        sftp = (ChannelSftp) channel;
    }

    public void disconnect() {
        if (sftp != null && sftp.isConnected()) sftp.disconnect();
        if (session != null && session.isConnected()) session.disconnect();
    }

    public void upload(String localPath, String remotePath, int permission) throws Exception {
        File localFile = new File(localPath);
        if (!localFile.exists()) throw new FileNotFoundException(localPath);

        if (localFile.isFile()) {
            uploadFileWithResume(localFile, remotePath, permission);
        } else {
            uploadDirectory(localFile, remotePath, permission);
        }
    }

    public void download(String remotePath, String localPath) throws Exception {
        SftpATTRS attrs = null;
        try {
            attrs = sftp.stat(remotePath);
        } catch (Exception e) {
            throw new FileNotFoundException("Remote path not found: " + remotePath);
        }

        File localFile = new File(localPath);
        if (attrs.isDir()) {
            downloadDirectory(remotePath, localFile);
        } else {
            downloadFileWithResume(remotePath, localFile);
        }
    }

    // 進度監控
    private static class MyProgressMonitor implements SftpProgressMonitor {
        private long max = 0;
        private long count = 0;
        private long percent = -1;

        public MyProgressMonitor(long max) {
            this.max = max;
        }

        @Override
        public void init(int op, String src, String dest, long max) {
            System.out.println("Start transfer: " + src + " -> " + dest);
        }

        @Override
        public boolean count(long count) {
            this.count += count;
            long newPercent = this.count * 100 / max;
            if (newPercent != percent) {
                percent = newPercent;
                System.out.print("\rProgress: " + percent + "%");
            }
            return true;
        }

        @Override
        public void end() {
            System.out.println("\nTransfer complete");
        }
    }

    // 斷點續傳上傳單檔
    private void uploadFileWithResume(File localFile, String remoteFilePath, int permission) throws Exception {
        long localFileSize = localFile.length();
        long remoteFileSize = 0;

        try {
            SftpATTRS attrs = sftp.stat(remoteFilePath);
            remoteFileSize = attrs.getSize();
        } catch (SftpException e) {
            // 檔案不存在
            remoteFileSize = 0;
        }

        if (remoteFileSize == localFileSize) {
            System.out.println("Remote file already complete, skipping upload: " + remoteFilePath);
            return;
        } else if (remoteFileSize > localFileSize) {
            System.out.println("Remote file is larger than local file, overwriting");
            remoteFileSize = 0;
        }

        try (RandomAccessFile raf = new RandomAccessFile(localFile, "r")) {
            raf.seek(remoteFileSize);

            InputStream fis = new InputStream() {
                @Override
                public int read() throws IOException {
                    return raf.read();
                }

                @Override
                public int read(byte[] b, int off, int len) throws IOException {
                    return raf.read(b, off, len);
                }
            };

            sftp.put(fis, remoteFilePath, new MyProgressMonitor(localFileSize), ChannelSftp.APPEND);
            fis.close();
        }

        // 設定遠端檔案權限
        sftp.chmod(permission, remoteFilePath);
    }

    // 遞迴上傳目錄
    private void uploadDirectory(File localDir, String remoteDir, int permission) throws Exception {
        try {
            sftp.cd(remoteDir);
        } catch (SftpException e) {
            sftp.mkdir(remoteDir);
            sftp.cd(remoteDir);
        }

        for (File file : localDir.listFiles()) {
            if (file.isFile()) {
                uploadFileWithResume(file, remoteDir + "/" + file.getName(), permission);
            } else if (file.isDirectory()) {
                uploadDirectory(file, remoteDir + "/" + file.getName(), permission);
            }
        }

        sftp.cd("..");
    }

    // 斷點續傳下載單檔
    private void downloadFileWithResume(String remoteFilePath, File localFile) throws Exception {
        long remoteFileSize = sftp.stat(remoteFilePath).getSize();
        long localFileSize = 0;

        if (localFile.exists()) {
            localFileSize = localFile.length();
            if (localFileSize > remoteFileSize) {
                System.out.println("Local file larger than remote, overwrite");
                localFileSize = 0;
            } else if (localFileSize == remoteFileSize) {
                System.out.println("Local file already complete, skipping download: " + localFile.getAbsolutePath());
                return;
            }
        }

        OutputStream os;
        if (localFileSize > 0) {
            os = new FileOutputStream(localFile, true);
        } else {
            os = new FileOutputStream(localFile);
        }

        try (OutputStream outputStream = os) {
            sftp.get(remoteFilePath, outputStream, new MyProgressMonitor(remoteFileSize), ChannelSftp.RESUME, localFileSize);
        }
    }

    // 遞迴下載目錄
    private void downloadDirectory(String remoteDir, File localDir) throws Exception {
        if (!localDir.exists()) {
            localDir.mkdirs();
        }

        Vector<ChannelSftp.LsEntry> list = sftp.ls(remoteDir);

        for (ChannelSftp.LsEntry entry : list) {
            String filename = entry.getFilename();
            if (".".equals(filename) || "..".equals(filename)) continue;

            String remoteFilePath = remoteDir + "/" + filename;
            File localFilePath = new File(localDir, filename);

            if (entry.getAttrs().isDir()) {
                downloadDirectory(remoteFilePath, localFilePath);
            } else {
                downloadFileWithResume(remoteFilePath, localFilePath);
            }
        }
    }

    public static void main(String[] args) {
        String user = "user";
        String host = "192.168.1.89";
        int port = 22;
        String password = "pass";

        String localUploadPath = "/Users/charley/Downloads/temp";
        String remoteUploadPath = "/home/larzio/temp";

        String remoteDownloadPath = "/home/larzio/download";
        String localDownloadPath = "/Users/charley/Downloads/testdownload";

        int permission = 0644;

        JSch jsch = new JSch();
        Session session = null;
        JSchSftpUtil sftpUtil = null;

        try {
            session = jsch.getSession(user, host, port);
            session.setPassword(password);
            session.setConfig("StrictHostKeyChecking", "no");
            session.connect();

            sftpUtil = new JSchSftpUtil(session);

            // 上傳
            sftpUtil.upload(localUploadPath, remoteUploadPath, permission);
            System.out.println("Upload done.");

            // 下載
            sftpUtil.download(remoteDownloadPath, localDownloadPath);
            System.out.println("Download done.");

        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            if (sftpUtil != null) sftpUtil.disconnect();
        }
    }
}

References

JSch 的使用(SSH、SFTP) · Homurax's Blog

GitHub - mwiede/jsch: fork of the popular jsch library