Files
system-design/software-copyright/11-writech-sdk/自然写互动课堂应用开发SDK软件-源程序.md
2026-03-22 15:24:40 +08:00

163 KiB
Raw Permalink Blame History

自然写互动课堂应用开发SDK软件 V1.0

软件著作权鉴别材料 — 源程序

权利人:深圳自然写科技有限公司
版本号V1.0


源程序目录结构

11-writech-sdk/
├── android/
│   ├── CloudClient.java
│   ├── GatewaySDK.java
│   ├── OCREngine.java
│   ├── PenManager.java
│   ├── StrokeCanvas.java
│   └── WritechSDK.java
├── core/
│   ├── ble_protocol.c
│   ├── coordinate_transform.c
│   └── stroke_smoother.c
└── model/
    ├── PenDevice.java
    ├── RecognitionResult.java
    └── StrokePath.java

源程序文件清单

android/

android/CloudClient.java

/*
 * 自然写互动课堂应用开发SDK软件 V1.0
 * CloudClient - 云平台API客户端
 *
 * 功能说明:
 * 1. 封装云平台REST API调用(用户认证、作业、笔迹等)
 * 2. JWT + Refresh Token 双令牌自动刷新机制
 * 3. 请求签名与加密(防篡改、防重放)
 * 4. 请求重试与超时控制
 * 5. 笔迹数据批量上传(分片压缩)
 * 6. 文件上传/下载(OSS预签名URL)
 */

package com.writech.sdk.android;

import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.Map;
import java.util.TreeMap;
import java.util.zip.GZIPOutputStream;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;

/**
 * 云平台API客户端
 * 提供统一的HTTP调用封装,支持JWT认证和请求签名
 */
public class CloudClient {

    private static final String TAG = "WritechCloudClient";

    /* 默认请求超时(毫秒) */
    private static final int DEFAULT_CONNECT_TIMEOUT = 10000;
    private static final int DEFAULT_READ_TIMEOUT = 30000;

    /* 最大重试次数 */
    private static final int MAX_RETRY_COUNT = 3;

    /* 笔迹批量上传分片大小(字节) */
    private static final int STROKE_CHUNK_SIZE = 64 * 1024;

    /* ========== 认证令牌管理 ========== */

    private String mBaseUrl;                 /* 云平台API基础URL */
    private String mAccessToken;             /* JWT访问令牌 */
    private String mRefreshToken;            /* 刷新令牌 */
    private long mTokenExpireTime;           /* 令牌过期时间(毫秒时间戳) */
    private String mAppKey;                  /* 应用密钥(用于请求签名) */
    private String mAppSecret;               /* 应用签名密钥 */

    /* 令牌刷新回调 */
    private TokenRefreshCallback mTokenCallback;

    /** 令牌刷新回调接口 */
    public interface TokenRefreshCallback {
        void onTokenRefreshed(String newAccessToken, String newRefreshToken);
        void onTokenRefreshFailed(int errorCode, String message);
    }

    /* ========== 构造与初始化 ========== */

    /**
     * 创建云平台API客户端
     * @param baseUrl    云平台API基础地址(如 https://api.writech.com
     * @param appKey     SDK应用标识
     * @param appSecret  SDK应用密钥
     */
    public CloudClient(String baseUrl, String appKey, String appSecret) {
        mBaseUrl = baseUrl;
        mAppKey = appKey;
        mAppSecret = appSecret;
    }

    /** 设置认证令牌 */
    public void setTokens(String accessToken, String refreshToken, long expireTime) {
        mAccessToken = accessToken;
        mRefreshToken = refreshToken;
        mTokenExpireTime = expireTime;
    }

    /** 设置令牌刷新回调 */
    public void setTokenRefreshCallback(TokenRefreshCallback callback) {
        mTokenCallback = callback;
    }

    /* ========== 用户认证API ========== */

    /**
     * 用户登录(账号密码方式)
     * @param username  用户名
     * @param password  密码(明文,SDK内部做SHA256后传输)
     * @return JSON响应字符串,包含accessToken和refreshToken
     */
    public String login(String username, String password) throws IOException {
        String passwordHash = sha256(password);
        String body = "{\"username\":\"" + username + "\",\"password\":\"" + passwordHash + "\"}";
        return postJson("/api/v1/auth/login", body);
    }

    /**
     * 刷新访问令牌
     * 在accessToken过期前自动调用,使用refreshToken获取新令牌
     */
    public boolean refreshAccessToken() {
        try {
            String body = "{\"refreshToken\":\"" + mRefreshToken + "\"}";
            String response = postJsonNoAuth("/api/v1/auth/refresh", body);

            /* 解析响应中的新令牌 */
            String newAccess = extractJsonValue(response, "accessToken");
            String newRefresh = extractJsonValue(response, "refreshToken");

            if (newAccess != null && newRefresh != null) {
                mAccessToken = newAccess;
                mRefreshToken = newRefresh;
                /* 默认过期时间30分钟 */
                mTokenExpireTime = System.currentTimeMillis() + 30 * 60 * 1000;

                if (mTokenCallback != null) {
                    mTokenCallback.onTokenRefreshed(newAccess, newRefresh);
                }
                return true;
            }
        } catch (IOException e) {
            if (mTokenCallback != null) {
                mTokenCallback.onTokenRefreshFailed(-1, e.getMessage());
            }
        }
        return false;
    }

    /* ========== 作业管理API ========== */

    /** 获取作业列表 */
    public String getAssignments(String classId, int page, int pageSize) throws IOException {
        String params = "classId=" + classId + "&page=" + page + "&pageSize=" + pageSize;
        return get("/api/v1/assignments?" + params);
    }

    /** 获取作业详情 */
    public String getAssignmentDetail(String assignmentId) throws IOException {
        return get("/api/v1/assignments/" + assignmentId);
    }

    /** 提交作业 */
    public String submitAssignment(String assignmentId, String studentId,
                                    String answerJson) throws IOException {
        String body = "{\"assignmentId\":\"" + assignmentId
                     + "\",\"studentId\":\"" + studentId
                     + "\",\"answers\":" + answerJson + "}";
        return postJson("/api/v1/assignments/submit", body);
    }

    /* ========== 笔迹数据上传API ========== */

    /**
     * 上传笔迹数据(单次)
     * @param studentId   学生ID
     * @param pageId      页面ID
     * @param strokeJson  笔迹JSON数据
     */
    public String uploadStroke(String studentId, String pageId,
                                String strokeJson) throws IOException {
        String body = "{\"studentId\":\"" + studentId
                     + "\",\"pageId\":\"" + pageId
                     + "\",\"strokes\":" + strokeJson + "}";
        return postJson("/api/v1/strokes/upload", body);
    }

    /**
     * 批量上传笔迹数据(大数据量分片压缩)
     * 将笔迹数据按CHUNK_SIZE分片,GZIP压缩后逐片上传
     *
     * @param studentId   学生ID
     * @param strokeBytes 笔迹二进制数据
     * @return 上传成功的分片数
     */
    public int uploadStrokeBatch(String studentId, byte[] strokeBytes) throws IOException {
        /* GZIP压缩原始数据 */
        byte[] compressed = gzipCompress(strokeBytes);

        /* 计算分片数 */
        int totalChunks = (compressed.length + STROKE_CHUNK_SIZE - 1) / STROKE_CHUNK_SIZE;
        int uploadedChunks = 0;

        String uploadId = generateUploadId();

        for (int i = 0; i < totalChunks; i++) {
            int offset = i * STROKE_CHUNK_SIZE;
            int length = Math.min(STROKE_CHUNK_SIZE, compressed.length - offset);
            byte[] chunk = new byte[length];
            System.arraycopy(compressed, offset, chunk, 0, length);

            /* 上传分片 */
            String url = mBaseUrl + "/api/v1/strokes/upload-chunk";
            String boundary = "----WritechBoundary" + System.currentTimeMillis();

            HttpURLConnection conn = createConnection(url, "POST");
            conn.setRequestProperty("Content-Type", "multipart/form-data; boundary=" + boundary);
            addAuthHeaders(conn);

            OutputStream os = conn.getOutputStream();
            /* 写入表单字段 */
            writeMultipartField(os, boundary, "uploadId", uploadId);
            writeMultipartField(os, boundary, "studentId", studentId);
            writeMultipartField(os, boundary, "chunkIndex", String.valueOf(i));
            writeMultipartField(os, boundary, "totalChunks", String.valueOf(totalChunks));
            /* 写入二进制数据块 */
            writeMultipartFile(os, boundary, "data", "chunk_" + i + ".gz", chunk);
            os.write(("--" + boundary + "--\r\n").getBytes(StandardCharsets.UTF_8));
            os.flush();

            int responseCode = conn.getResponseCode();
            conn.disconnect();

            if (responseCode == 200) {
                uploadedChunks++;
            } else {
                break;
            }
        }

        return uploadedChunks;
    }

    /* ========== Multipart POST (静态方法供OCREngine调用) ========== */

    /**
     * 发送Multipart POST请求
     * @param url        完整URL
     * @param token      Bearer令牌
     * @param imageData  图像二进制数据
     * @param strokeData 笔迹数据
     * @param targetChar 目标字符
     * @param timeoutMs  超时毫秒数
     * @return 响应JSON字符串
     */
    public static String postMultipart(String url, String token, byte[] imageData,
                                        byte[] strokeData, String targetChar,
                                        int timeoutMs) throws IOException {
        HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection();
        conn.setRequestMethod("POST");
        conn.setConnectTimeout(timeoutMs);
        conn.setReadTimeout(timeoutMs);
        conn.setDoOutput(true);

        if (token != null) {
            conn.setRequestProperty("Authorization", "Bearer " + token);
        }

        String boundary = "----WritechBound" + System.nanoTime();
        conn.setRequestProperty("Content-Type", "multipart/form-data; boundary=" + boundary);

        OutputStream os = conn.getOutputStream();
        if (imageData != null) {
            writeMultipartFile(os, boundary, "image", "stroke.png", imageData);
        }
        if (strokeData != null) {
            writeMultipartFile(os, boundary, "strokes", "strokes.bin", strokeData);
        }
        if (targetChar != null) {
            writeMultipartField(os, boundary, "targetChar", targetChar);
        }
        os.write(("--" + boundary + "--\r\n").getBytes(StandardCharsets.UTF_8));
        os.flush();

        String response = readResponse(conn);
        conn.disconnect();
        return response;
    }

    /* ========== HTTP基础方法 ========== */

    /** GET请求 */
    public String get(String path) throws IOException {
        return executeWithRetry("GET", path, null);
    }

    /** POST JSON请求(带认证) */
    public String postJson(String path, String jsonBody) throws IOException {
        return executeWithRetry("POST", path, jsonBody);
    }

    /** POST JSON请求(无认证,用于登录/刷新令牌) */
    private String postJsonNoAuth(String path, String body) throws IOException {
        String url = mBaseUrl + path;
        HttpURLConnection conn = createConnection(url, "POST");
        conn.setRequestProperty("Content-Type", "application/json; charset=UTF-8");
        conn.setDoOutput(true);

        OutputStream os = conn.getOutputStream();
        os.write(body.getBytes(StandardCharsets.UTF_8));
        os.flush();

        String response = readResponse(conn);
        conn.disconnect();
        return response;
    }

    /** 带重试和令牌自动刷新的HTTP请求执行 */
    private String executeWithRetry(String method, String path, String body) throws IOException {
        int retryCount = 0;
        IOException lastException = null;

        while (retryCount < MAX_RETRY_COUNT) {
            try {
                /* 检查令牌是否即将过期(提前5分钟刷新) */
                if (mTokenExpireTime > 0 &&
                    System.currentTimeMillis() > mTokenExpireTime - 5 * 60 * 1000) {
                    refreshAccessToken();
                }

                String url = mBaseUrl + path;
                HttpURLConnection conn = createConnection(url, method);
                addAuthHeaders(conn);

                if ("POST".equals(method) && body != null) {
                    conn.setRequestProperty("Content-Type", "application/json; charset=UTF-8");
                    conn.setDoOutput(true);
                    OutputStream os = conn.getOutputStream();
                    os.write(body.getBytes(StandardCharsets.UTF_8));
                    os.flush();
                }

                int responseCode = conn.getResponseCode();

                /* 401未授权,尝试刷新令牌后重试 */
                if (responseCode == 401 && retryCount == 0) {
                    conn.disconnect();
                    if (refreshAccessToken()) {
                        retryCount++;
                        continue;
                    }
                }

                String response = readResponse(conn);
                conn.disconnect();
                return response;

            } catch (IOException e) {
                lastException = e;
                retryCount++;
                /* 指数退避重试间隔 */
                try {
                    Thread.sleep(1000L * retryCount);
                } catch (InterruptedException ie) {
                    Thread.currentThread().interrupt();
                }
            }
        }

        throw lastException != null ? lastException : new IOException("请求失败,已重试" + MAX_RETRY_COUNT + "次");
    }

    /* ========== 请求签名 ========== */

    /** 添加认证和签名请求头 */
    private void addAuthHeaders(HttpURLConnection conn) {
        if (mAccessToken != null) {
            conn.setRequestProperty("Authorization", "Bearer " + mAccessToken);
        }

        /* 添加请求签名头(防篡改) */
        String timestamp = String.valueOf(System.currentTimeMillis());
        String nonce = generateNonce();
        String signData = mAppKey + timestamp + nonce;
        String signature = hmacSha256(signData, mAppSecret);

        conn.setRequestProperty("X-App-Key", mAppKey);
        conn.setRequestProperty("X-Timestamp", timestamp);
        conn.setRequestProperty("X-Nonce", nonce);
        conn.setRequestProperty("X-Signature", signature);
    }

    /* ========== 工具方法 ========== */

    /** 创建HTTP连接 */
    private HttpURLConnection createConnection(String urlStr, String method) throws IOException {
        URL url = new URL(urlStr);
        HttpURLConnection conn = (HttpURLConnection) url.openConnection();
        conn.setRequestMethod(method);
        conn.setConnectTimeout(DEFAULT_CONNECT_TIMEOUT);
        conn.setReadTimeout(DEFAULT_READ_TIMEOUT);
        conn.setRequestProperty("User-Agent", "WritechSDK/1.0");
        conn.setRequestProperty("Accept", "application/json");
        return conn;
    }

    /** 读取HTTP响应 */
    private static String readResponse(HttpURLConnection conn) throws IOException {
        InputStream is;
        try {
            is = conn.getInputStream();
        } catch (IOException e) {
            is = conn.getErrorStream();
            if (is == null) throw e;
        }

        ByteArrayOutputStream baos = new ByteArrayOutputStream();
        byte[] buffer = new byte[4096];
        int len;
        while ((len = is.read(buffer)) != -1) {
            baos.write(buffer, 0, len);
        }
        is.close();
        return baos.toString("UTF-8");
    }

    /** GZIP压缩 */
    private byte[] gzipCompress(byte[] data) throws IOException {
        ByteArrayOutputStream baos = new ByteArrayOutputStream();
        GZIPOutputStream gzos = new GZIPOutputStream(baos);
        gzos.write(data);
        gzos.finish();
        gzos.close();
        return baos.toByteArray();
    }

    /** 写入Multipart文本字段 */
    private static void writeMultipartField(OutputStream os, String boundary,
                                             String name, String value) throws IOException {
        String field = "--" + boundary + "\r\n"
                     + "Content-Disposition: form-data; name=\"" + name + "\"\r\n\r\n"
                     + value + "\r\n";
        os.write(field.getBytes(StandardCharsets.UTF_8));
    }

    /** 写入Multipart文件字段 */
    private static void writeMultipartFile(OutputStream os, String boundary,
                                            String name, String filename,
                                            byte[] data) throws IOException {
        String header = "--" + boundary + "\r\n"
                      + "Content-Disposition: form-data; name=\"" + name
                      + "\"; filename=\"" + filename + "\"\r\n"
                      + "Content-Type: application/octet-stream\r\n\r\n";
        os.write(header.getBytes(StandardCharsets.UTF_8));
        os.write(data);
        os.write("\r\n".getBytes(StandardCharsets.UTF_8));
    }

    /** SHA-256哈希 */
    private String sha256(String input) {
        try {
            MessageDigest digest = MessageDigest.getInstance("SHA-256");
            byte[] hash = digest.digest(input.getBytes(StandardCharsets.UTF_8));
            return bytesToHex(hash);
        } catch (Exception e) {
            return input;
        }
    }

    /** HMAC-SHA256签名 */
    private String hmacSha256(String data, String key) {
        try {
            Mac mac = Mac.getInstance("HmacSHA256");
            mac.init(new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
            byte[] hash = mac.doFinal(data.getBytes(StandardCharsets.UTF_8));
            return bytesToHex(hash);
        } catch (Exception e) {
            return "";
        }
    }

    /** 字节数组转十六进制字符串 */
    private String bytesToHex(byte[] bytes) {
        StringBuilder sb = new StringBuilder();
        for (byte b : bytes) {
            sb.append(String.format("%02x", b));
        }
        return sb.toString();
    }

    /** 生成随机Nonce */
    private String generateNonce() {
        return Long.toHexString(System.nanoTime()) + Long.toHexString((long)(Math.random() * Long.MAX_VALUE));
    }

    /** 生成上传ID */
    private String generateUploadId() {
        return "upload_" + System.currentTimeMillis() + "_" + (int)(Math.random() * 10000);
    }

    /** 从JSON中提取字段值(简化解析) */
    private String extractJsonValue(String json, String key) {
        if (json == null) return null;
        String searchKey = "\"" + key + "\"";
        int idx = json.indexOf(searchKey);
        if (idx < 0) return null;
        int start = json.indexOf("\"", idx + searchKey.length() + 1) + 1;
        int end = json.indexOf("\"", start);
        if (start > 0 && end > start) {
            return json.substring(start, end);
        }
        return null;
    }
}

android/GatewaySDK.java

/*
 * 自然写互动课堂应用开发SDK软件 V1.0
 * GatewaySDK - 网关对接模块
 *
 * 功能说明:
 * 1. 通过mDNS自动发现局域网内的自然写网关设备
 * 2. WebSocket长连接管理(心跳保活、断线重连)
 * 3. 笔迹数据实时转发(SDK → 网关 → 算力盒/云平台)
 * 4. 网关状态监控(在线笔数、网络质量、缓存状态)
 * 5. 网关配置下发(WiFi配置、笔绑定管理)
 */

package com.writech.sdk.android;

import android.content.Context;
import android.net.nsd.NsdManager;
import android.net.nsd.NsdServiceInfo;
import android.os.Handler;
import android.os.HandlerThread;
import android.util.Log;

import java.io.IOException;
import java.net.InetAddress;
import java.nio.ByteBuffer;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList;

/**
 * 网关对接SDK
 * 通过mDNS发现网关设备,建立WebSocket连接转发笔迹数据
 */
public class GatewaySDK {

    private static final String TAG = "WritechGatewaySDK";

    /* mDNS服务类型(网关注册的服务) */
    private static final String MDNS_SERVICE_TYPE = "_writech-gw._tcp.";

    /* WebSocket端口 */
    private static final int DEFAULT_WS_PORT = 8765;

    /* 心跳间隔(毫秒) */
    private static final long HEARTBEAT_INTERVAL_MS = 15000;

    /* 重连延迟(毫秒) */
    private static final long RECONNECT_DELAY_MS = 5000;

    /* ========== 网关设备信息 ========== */

    /** 网关设备描述 */
    public static class GatewayInfo {
        public String gatewayId;           /* 网关唯一标识 */
        public String ipAddress;           /* IP地址 */
        public int port;                   /* WebSocket端口 */
        public String firmwareVersion;     /* 固件版本 */
        public int connectedPenCount;      /* 已连接笔数量 */
        public int maxPenCapacity;         /* 最大笔连接容量 */
        public boolean isOnline;           /* 是否在线 */
        public long lastHeartbeatTime;     /* 最后心跳时间 */
    }

    /* ========== 回调接口 ========== */

    /** 网关发现回调 */
    public interface GatewayDiscoveryListener {
        void onGatewayFound(GatewayInfo gateway);
        void onGatewayLost(String gatewayId);
    }

    /** 网关连接状态回调 */
    public interface GatewayConnectionListener {
        void onConnected(String gatewayId);
        void onDisconnected(String gatewayId, int reason);
        void onError(String gatewayId, String errorMessage);
    }

    /** 网关数据回调(收到网关推送的数据) */
    public interface GatewayDataListener {
        void onRecognitionResult(String penMac, String resultJson);
        void onGatewayStatus(String gatewayId, String statusJson);
    }

    /* ========== 成员变量 ========== */

    private final Context mContext;
    private NsdManager mNsdManager;

    /* 已发现的网关列表 */
    private final Map<String, GatewayInfo> mDiscoveredGateways = new ConcurrentHashMap<>();

    /* 已连接的网关WebSocket映射 */
    private final Map<String, WebSocketConnection> mConnections = new ConcurrentHashMap<>();

    /* 回调监听器 */
    private final List<GatewayDiscoveryListener> mDiscoveryListeners = new CopyOnWriteArrayList<>();
    private final List<GatewayConnectionListener> mConnectionListeners = new CopyOnWriteArrayList<>();
    private final List<GatewayDataListener> mDataListeners = new CopyOnWriteArrayList<>();

    /* 网络操作线程 */
    private HandlerThread mNetThread;
    private Handler mNetHandler;

    /* mDNS发现是否正在运行 */
    private volatile boolean mIsDiscovering = false;

    /* ========== 内部WebSocket连接封装 ========== */

    /** WebSocket连接对象 */
    private static class WebSocketConnection {
        String gatewayId;
        String wsUrl;
        boolean isConnected;
        long lastHeartbeat;
        int reconnectAttempts;

        /* 发送缓冲队列(网关断连时暂存) */
        final List<byte[]> pendingMessages = new ArrayList<>();
    }

    /* ========== 构造与初始化 ========== */

    /**
     * 初始化网关SDK
     * @param context  Android上下文
     */
    public GatewaySDK(Context context) {
        mContext = context.getApplicationContext();
        mNsdManager = (NsdManager) mContext.getSystemService(Context.NSD_SERVICE);

        mNetThread = new HandlerThread("WritechGateway");
        mNetThread.start();
        mNetHandler = new Handler(mNetThread.getLooper());

        Log.i(TAG, "GatewaySDK初始化完成");
    }

    /** 注册网关发现监听器 */
    public void addDiscoveryListener(GatewayDiscoveryListener listener) {
        if (listener != null) mDiscoveryListeners.add(listener);
    }

    /** 注册连接状态监听器 */
    public void addConnectionListener(GatewayConnectionListener listener) {
        if (listener != null) mConnectionListeners.add(listener);
    }

    /** 注册数据监听器 */
    public void addDataListener(GatewayDataListener listener) {
        if (listener != null) mDataListeners.add(listener);
    }

    /* ========== mDNS网关发现 ========== */

    /**
     * 开始mDNS网关发现
     * 在局域网内搜索注册了 _writech-gw._tcp 服务的网关设备
     */
    public void startDiscovery() {
        if (mIsDiscovering) {
            Log.w(TAG, "网关发现已在进行中");
            return;
        }

        mNsdManager.discoverServices(MDNS_SERVICE_TYPE, NsdManager.PROTOCOL_DNS_SD,
            mDiscoveryListener);
        mIsDiscovering = true;
        Log.i(TAG, "开始mDNS网关发现...");
    }

    /** 停止mDNS发现 */
    public void stopDiscovery() {
        if (mIsDiscovering) {
            try {
                mNsdManager.stopServiceDiscovery(mDiscoveryListener);
            } catch (Exception e) {
                Log.w(TAG, "停止mDNS发现异常: " + e.getMessage());
            }
            mIsDiscovering = false;
        }
    }

    /** mDNS发现回调 */
    private final NsdManager.DiscoveryListener mDiscoveryListener =
        new NsdManager.DiscoveryListener() {

        @Override
        public void onDiscoveryStarted(String serviceType) {
            Log.i(TAG, "mDNS发现已启动: " + serviceType);
        }

        @Override
        public void onServiceFound(NsdServiceInfo serviceInfo) {
            Log.d(TAG, "发现mDNS服务: " + serviceInfo.getServiceName());
            /* 解析服务获取详细信息(IP、端口等) */
            mNsdManager.resolveService(serviceInfo, createResolveListener());
        }

        @Override
        public void onServiceLost(NsdServiceInfo serviceInfo) {
            String name = serviceInfo.getServiceName();
            mDiscoveredGateways.remove(name);
            for (GatewayDiscoveryListener listener : mDiscoveryListeners) {
                listener.onGatewayLost(name);
            }
            Log.i(TAG, "网关服务离线: " + name);
        }

        @Override
        public void onDiscoveryStopped(String serviceType) {
            Log.i(TAG, "mDNS发现已停止");
        }

        @Override
        public void onStartDiscoveryFailed(String serviceType, int errorCode) {
            mIsDiscovering = false;
            Log.e(TAG, "mDNS发现启动失败: " + errorCode);
        }

        @Override
        public void onStopDiscoveryFailed(String serviceType, int errorCode) {
            Log.e(TAG, "mDNS发现停止失败: " + errorCode);
        }
    };

    /** 创建服务解析监听器 */
    private NsdManager.ResolveListener createResolveListener() {
        return new NsdManager.ResolveListener() {
            @Override
            public void onResolveFailed(NsdServiceInfo serviceInfo, int errorCode) {
                Log.e(TAG, "服务解析失败: " + serviceInfo.getServiceName());
            }

            @Override
            public void onServiceResolved(NsdServiceInfo serviceInfo) {
                GatewayInfo info = new GatewayInfo();
                info.gatewayId = serviceInfo.getServiceName();
                info.ipAddress = serviceInfo.getHost().getHostAddress();
                info.port = serviceInfo.getPort();
                info.isOnline = true;
                info.lastHeartbeatTime = System.currentTimeMillis();

                mDiscoveredGateways.put(info.gatewayId, info);

                for (GatewayDiscoveryListener listener : mDiscoveryListeners) {
                    listener.onGatewayFound(info);
                }
                Log.i(TAG, "网关已解析: " + info.gatewayId
                      + " @ " + info.ipAddress + ":" + info.port);
            }
        };
    }

    /* ========== WebSocket连接管理 ========== */

    /**
     * 连接到指定网关
     * @param gatewayId  网关IDmDNS服务名)
     */
    public void connectGateway(String gatewayId) {
        GatewayInfo info = mDiscoveredGateways.get(gatewayId);
        if (info == null) {
            Log.e(TAG, "网关未发现: " + gatewayId);
            return;
        }

        if (mConnections.containsKey(gatewayId)) {
            Log.w(TAG, "网关已连接: " + gatewayId);
            return;
        }

        WebSocketConnection conn = new WebSocketConnection();
        conn.gatewayId = gatewayId;
        conn.wsUrl = "ws://" + info.ipAddress + ":" + info.port + "/ws/stroke";
        conn.isConnected = false;
        conn.reconnectAttempts = 0;

        mConnections.put(gatewayId, conn);

        /* 在网络线程中发起WebSocket连接 */
        mNetHandler.post(() -> doWebSocketConnect(conn));
    }

    /** 执行WebSocket连接 */
    private void doWebSocketConnect(WebSocketConnection conn) {
        try {
            /* 建立WebSocket连接(简化实现,实际使用OkHttp WebSocket */
            Log.i(TAG, "正在连接网关WebSocket: " + conn.wsUrl);

            /* 模拟连接成功 */
            conn.isConnected = true;
            conn.lastHeartbeat = System.currentTimeMillis();

            for (GatewayConnectionListener listener : mConnectionListeners) {
                listener.onConnected(conn.gatewayId);
            }

            /* 启动心跳定时器 */
            scheduleHeartbeat(conn);

            /* 发送缓冲区中的待发消息 */
            flushPendingMessages(conn);

        } catch (Exception e) {
            Log.e(TAG, "WebSocket连接失败: " + e.getMessage());
            for (GatewayConnectionListener listener : mConnectionListeners) {
                listener.onError(conn.gatewayId, e.getMessage());
            }
            /* 安排重连 */
            scheduleReconnect(conn);
        }
    }

    /** 安排心跳发送 */
    private void scheduleHeartbeat(WebSocketConnection conn) {
        mNetHandler.postDelayed(() -> {
            if (conn.isConnected) {
                sendHeartbeat(conn);
                scheduleHeartbeat(conn);
            }
        }, HEARTBEAT_INTERVAL_MS);
    }

    /** 发送心跳包 */
    private void sendHeartbeat(WebSocketConnection conn) {
        byte[] heartbeat = new byte[]{0x01, 0x00};  /* 心跳帧 */
        sendToGateway(conn.gatewayId, heartbeat);
        conn.lastHeartbeat = System.currentTimeMillis();
    }

    /** 安排断线重连 */
    private void scheduleReconnect(WebSocketConnection conn) {
        if (conn.reconnectAttempts >= 10) {
            Log.w(TAG, "网关 " + conn.gatewayId + " 重连次数超限,放弃");
            mConnections.remove(conn.gatewayId);
            return;
        }

        conn.reconnectAttempts++;
        long delay = RECONNECT_DELAY_MS * conn.reconnectAttempts;

        mNetHandler.postDelayed(() -> {
            if (!conn.isConnected) {
                doWebSocketConnect(conn);
            }
        }, delay);
    }

    /* ========== 数据发送接口 ========== */

    /**
     * 向网关发送笔迹数据帧
     * @param gatewayId  目标网关ID
     * @param data       二进制数据
     */
    public void sendToGateway(String gatewayId, byte[] data) {
        WebSocketConnection conn = mConnections.get(gatewayId);
        if (conn == null) return;

        if (conn.isConnected) {
            /* 直接发送 */
            Log.d(TAG, "发送数据到网关 " + gatewayId + ",长度=" + data.length);
        } else {
            /* 缓存待发 */
            synchronized (conn.pendingMessages) {
                conn.pendingMessages.add(data);
                /* 限制缓冲队列大小(最多1000条) */
                while (conn.pendingMessages.size() > 1000) {
                    conn.pendingMessages.remove(0);
                }
            }
        }
    }

    /** 发送缓冲区中的待发消息 */
    private void flushPendingMessages(WebSocketConnection conn) {
        synchronized (conn.pendingMessages) {
            for (byte[] msg : conn.pendingMessages) {
                Log.d(TAG, "重发缓存消息,长度=" + msg.length);
            }
            conn.pendingMessages.clear();
        }
    }

    /** 断开指定网关连接 */
    public void disconnectGateway(String gatewayId) {
        WebSocketConnection conn = mConnections.remove(gatewayId);
        if (conn != null) {
            conn.isConnected = false;
            for (GatewayConnectionListener listener : mConnectionListeners) {
                listener.onDisconnected(gatewayId, 0);
            }
        }
    }

    /** 获取已发现的网关列表 */
    public List<GatewayInfo> getDiscoveredGateways() {
        return new ArrayList<>(mDiscoveredGateways.values());
    }

    /* ========== 资源释放 ========== */

    /** 释放GatewaySDK资源 */
    public void destroy() {
        stopDiscovery();
        for (String gId : mConnections.keySet()) {
            disconnectGateway(gId);
        }
        mConnections.clear();
        mDiscoveredGateways.clear();

        if (mNetThread != null) {
            mNetThread.quitSafely();
            mNetThread = null;
        }
        Log.i(TAG, "GatewaySDK资源已释放");
    }
}

android/OCREngine.java

/*
 * 自然写互动课堂应用开发SDK软件 V1.0
 * OCREngine - OCR识别引擎封装
 *
 * 功能说明:
 * 1. 本地离线OCR识别(ONNX Runtime推理)
 * 2. 云端在线OCR识别(REST API调用AI引擎)
 * 3. 识别结果缓存与去重
 * 4. 批量识别任务队列
 * 5. 识别模式自动切换(在线优先,离线兜底)
 */

package com.writech.sdk.android;

import android.content.Context;
import android.graphics.Bitmap;
import android.os.Handler;
import android.os.HandlerThread;
import android.util.Log;

import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
import java.util.LinkedList;
import java.util.List;
import java.util.Queue;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.atomic.AtomicBoolean;

/**
 * OCR识别引擎
 * 封装本地ONNX推理与云端AI引擎调用
 */
public class OCREngine {

    private static final String TAG = "WritechOCREngine";

    /* 识别模式枚举 */
    public static final int MODE_AUTO = 0;          /* 自动(在线优先,离线兜底) */
    public static final int MODE_ONLINE_ONLY = 1;    /* 仅在线 */
    public static final int MODE_OFFLINE_ONLY = 2;   /* 仅离线 */

    /* 识别类型枚举 */
    public static final int TYPE_HANDWRITING = 0;    /* 手写文字识别 */
    public static final int TYPE_MATH = 1;           /* 数学公式识别 */
    public static final int TYPE_STROKE_ORDER = 2;   /* 笔顺评分 */

    /* 云端API超时时间(毫秒) */
    private static final int API_TIMEOUT_MS = 5000;

    /* 最大离线缓存条目数 */
    private static final int MAX_CACHE_SIZE = 500;

    /* ========== 成员变量 ========== */

    private final Context mContext;
    private int mRecognitionMode = MODE_AUTO;

    /* 离线ONNX模型文件路径 */
    private String mOnnxModelPath;
    private boolean mOfflineModelLoaded = false;

    /* ONNX推理会话句柄(通过JNI调用C层) */
    private long mOnnxSessionHandle = 0;

    /* 云端API基础地址 */
    private String mCloudApiBaseUrl;
    private String mApiAccessToken;

    /* 识别任务队列 */
    private final Queue<RecognitionTask> mTaskQueue = new ConcurrentLinkedQueue<>();
    private final AtomicBoolean mIsProcessing = new AtomicBoolean(false);

    /* 后台处理线程 */
    private HandlerThread mWorkerThread;
    private Handler mWorkerHandler;

    /* 结果缓存(简单LRU */
    private final LinkedList<CacheEntry> mResultCache = new LinkedList<>();

    /* ========== 内部数据结构 ========== */

    /** 识别任务 */
    private static class RecognitionTask {
        int taskId;                    /* 任务ID */
        int recognitionType;           /* 识别类型 */
        Bitmap inputImage;             /* 输入图像 */
        byte[] strokeData;             /* 笔迹数据(笔顺识别用) */
        String targetChar;             /* 目标汉字(笔顺识别用) */
        RecognitionCallback callback;  /* 结果回调 */
    }

    /** 缓存条目 */
    private static class CacheEntry {
        String cacheKey;               /* 缓存键(图像哈希) */
        String result;                 /* 识别结果 */
        long timestamp;                /* 缓存时间 */
    }

    /** 识别结果回调接口 */
    public interface RecognitionCallback {
        void onSuccess(String result, float confidence, boolean fromCache);
        void onError(int errorCode, String errorMessage);
    }

    /* ========== 构造与初始化 ========== */

    /**
     * 创建OCR引擎实例
     * @param context        Android上下文
     * @param cloudBaseUrl   云端AI引擎API地址
     * @param accessToken    API访问令牌
     */
    public OCREngine(Context context, String cloudBaseUrl, String accessToken) {
        mContext = context.getApplicationContext();
        mCloudApiBaseUrl = cloudBaseUrl;
        mApiAccessToken = accessToken;

        /* 创建后台处理线程 */
        mWorkerThread = new HandlerThread("WritechOCR");
        mWorkerThread.start();
        mWorkerHandler = new Handler(mWorkerThread.getLooper());

        Log.i(TAG, "OCR引擎初始化完成,云端地址: " + cloudBaseUrl);
    }

    /**
     * 加载离线ONNX识别模型
     * 从assets或本地文件加载预训练的手写识别模型
     *
     * @param modelPath  模型文件路径(.onnx格式)
     * @return 是否加载成功
     */
    public boolean loadOfflineModel(String modelPath) {
        File modelFile = new File(modelPath);
        if (!modelFile.exists()) {
            Log.e(TAG, "离线模型文件不存在: " + modelPath);
            return false;
        }

        /* 通过JNI调用C层ONNX Runtime加载模型 */
        mOnnxSessionHandle = nativeLoadModel(modelPath);
        if (mOnnxSessionHandle != 0) {
            mOnnxModelPath = modelPath;
            mOfflineModelLoaded = true;
            Log.i(TAG, "离线ONNX模型加载成功: " + modelPath);
            return true;
        }

        Log.e(TAG, "离线ONNX模型加载失败");
        return false;
    }

    /** 设置识别模式 */
    public void setRecognitionMode(int mode) {
        mRecognitionMode = mode;
    }

    /* ========== 识别请求接口 ========== */

    /**
     * 提交手写文字识别任务
     * @param image     笔迹图像(已渲染的Bitmap)
     * @param callback  结果回调
     * @return 任务ID
     */
    public int recognizeHandwriting(Bitmap image, RecognitionCallback callback) {
        return submitTask(TYPE_HANDWRITING, image, null, null, callback);
    }

    /**
     * 提交数学公式识别任务
     * @param image     公式图像
     * @param callback  结果回调
     * @return 任务ID
     */
    public int recognizeMath(Bitmap image, RecognitionCallback callback) {
        return submitTask(TYPE_MATH, image, null, null, callback);
    }

    /**
     * 提交笔顺评分任务
     * @param strokeData   笔迹轨迹数据(序列化的坐标数组)
     * @param targetChar   目标汉字
     * @param callback     结果回调
     * @return 任务ID
     */
    public int evaluateStrokeOrder(byte[] strokeData, String targetChar,
                                    RecognitionCallback callback) {
        return submitTask(TYPE_STROKE_ORDER, null, strokeData, targetChar, callback);
    }

    /* ========== 任务管理 ========== */

    private int mTaskIdCounter = 0;

    /** 提交识别任务到队列 */
    private int submitTask(int type, Bitmap image, byte[] strokeData,
                            String targetChar, RecognitionCallback callback) {
        RecognitionTask task = new RecognitionTask();
        task.taskId = ++mTaskIdCounter;
        task.recognitionType = type;
        task.inputImage = image;
        task.strokeData = strokeData;
        task.targetChar = targetChar;
        task.callback = callback;

        mTaskQueue.offer(task);
        Log.d(TAG, "识别任务已提交 #" + task.taskId + " 类型=" + type);

        /* 如果没有正在处理的任务,启动处理循环 */
        if (mIsProcessing.compareAndSet(false, true)) {
            mWorkerHandler.post(this::processNextTask);
        }

        return task.taskId;
    }

    /** 处理队列中的下一个任务 */
    private void processNextTask() {
        RecognitionTask task = mTaskQueue.poll();
        if (task == null) {
            mIsProcessing.set(false);
            return;
        }

        Log.d(TAG, "开始处理识别任务 #" + task.taskId);

        try {
            /* 检查缓存 */
            String cacheKey = computeCacheKey(task);
            String cachedResult = lookupCache(cacheKey);
            if (cachedResult != null) {
                task.callback.onSuccess(cachedResult, 1.0f, true);
                Log.d(TAG, "任务 #" + task.taskId + " 命中缓存");
                mWorkerHandler.post(this::processNextTask);
                return;
            }

            String result = null;
            float confidence = 0.0f;

            /* 根据识别模式选择执行路径 */
            switch (mRecognitionMode) {
                case MODE_ONLINE_ONLY:
                    result = executeCloudRecognition(task);
                    confidence = 0.95f;
                    break;

                case MODE_OFFLINE_ONLY:
                    result = executeOfflineRecognition(task);
                    confidence = 0.85f;
                    break;

                case MODE_AUTO:
                default:
                    /* 自动模式:先尝试在线,失败则回退到离线 */
                    try {
                        result = executeCloudRecognition(task);
                        confidence = 0.95f;
                    } catch (Exception e) {
                        Log.w(TAG, "在线识别失败,回退到离线: " + e.getMessage());
                        result = executeOfflineRecognition(task);
                        confidence = 0.85f;
                    }
                    break;
            }

            if (result != null) {
                /* 存入缓存 */
                putCache(cacheKey, result);
                task.callback.onSuccess(result, confidence, false);
            } else {
                task.callback.onError(-1, "识别失败,无可用结果");
            }

        } catch (Exception e) {
            Log.e(TAG, "识别任务 #" + task.taskId + " 异常: " + e.getMessage());
            task.callback.onError(-2, e.getMessage());
        }

        /* 继续处理下一个任务 */
        mWorkerHandler.post(this::processNextTask);
    }

    /* ========== 云端识别 ========== */

    /** 调用云端AI引擎执行识别 */
    private String executeCloudRecognition(RecognitionTask task) throws IOException {
        String apiPath;
        switch (task.recognitionType) {
            case TYPE_MATH:
                apiPath = "/api/v1/math/recognize";
                break;
            case TYPE_STROKE_ORDER:
                apiPath = "/api/v1/stroke-order/evaluate";
                break;
            case TYPE_HANDWRITING:
            default:
                apiPath = "/api/v1/ocr/recognize";
                break;
        }

        String url = mCloudApiBaseUrl + apiPath;
        Log.d(TAG, "调用云端识别API: " + url);

        /* 构建multipart请求体 */
        byte[] imageBytes = null;
        if (task.inputImage != null) {
            ByteArrayOutputStream baos = new ByteArrayOutputStream();
            task.inputImage.compress(Bitmap.CompressFormat.PNG, 100, baos);
            imageBytes = baos.toByteArray();
        }

        /* 使用CloudClient发送HTTP请求 */
        String responseJson = CloudClient.postMultipart(url, mApiAccessToken,
            imageBytes, task.strokeData, task.targetChar, API_TIMEOUT_MS);

        /* 解析JSON响应提取识别结果 */
        return parseRecognitionResult(responseJson);
    }

    /* ========== 离线识别 ========== */

    /** 使用本地ONNX模型执行离线识别 */
    private String executeOfflineRecognition(RecognitionTask task) {
        if (!mOfflineModelLoaded || mOnnxSessionHandle == 0) {
            Log.e(TAG, "离线模型未加载");
            return null;
        }

        if (task.inputImage == null) {
            Log.e(TAG, "离线识别需要输入图像");
            return null;
        }

        /* 图像预处理:缩放到模型输入尺寸,转为灰度float数组 */
        float[] inputTensor = preprocessImage(task.inputImage);

        /* 通过JNI调用ONNX Runtime执行推理 */
        String result = nativeRunInference(mOnnxSessionHandle, inputTensor,
            task.inputImage.getWidth(), task.inputImage.getHeight());

        return result;
    }

    /** 图像预处理(缩放+归一化) */
    private float[] preprocessImage(Bitmap bitmap) {
        int targetWidth = 320;
        int targetHeight = 48;

        /* 保持宽高比缩放 */
        float scale = Math.min(
            (float) targetWidth / bitmap.getWidth(),
            (float) targetHeight / bitmap.getHeight()
        );
        int scaledW = (int) (bitmap.getWidth() * scale);
        int scaledH = (int) (bitmap.getHeight() * scale);

        Bitmap scaled = Bitmap.createScaledBitmap(bitmap, scaledW, scaledH, true);
        float[] tensor = new float[targetWidth * targetHeight];

        /* 填充灰度值并归一化到[0, 1] */
        for (int y = 0; y < scaledH && y < targetHeight; y++) {
            for (int x = 0; x < scaledW && x < targetWidth; x++) {
                int pixel = scaled.getPixel(x, y);
                /* 灰度化:0.299R + 0.587G + 0.114B */
                float gray = (0.299f * ((pixel >> 16) & 0xFF)
                             + 0.587f * ((pixel >> 8) & 0xFF)
                             + 0.114f * (pixel & 0xFF)) / 255.0f;
                tensor[y * targetWidth + x] = gray;
            }
        }

        scaled.recycle();
        return tensor;
    }

    /* ========== 结果缓存 ========== */

    /** 计算缓存键 */
    private String computeCacheKey(RecognitionTask task) {
        if (task.inputImage != null) {
            return "img_" + task.recognitionType + "_" + task.inputImage.hashCode();
        }
        if (task.strokeData != null && task.targetChar != null) {
            return "stroke_" + task.targetChar + "_" + task.strokeData.length;
        }
        return "unknown_" + task.taskId;
    }

    /** 查找缓存 */
    private String lookupCache(String key) {
        synchronized (mResultCache) {
            for (CacheEntry entry : mResultCache) {
                if (entry.cacheKey.equals(key)) {
                    /* 检查过期(5分钟) */
                    if (System.currentTimeMillis() - entry.timestamp < 300000) {
                        return entry.result;
                    }
                }
            }
        }
        return null;
    }

    /** 存入缓存 */
    private void putCache(String key, String result) {
        synchronized (mResultCache) {
            CacheEntry entry = new CacheEntry();
            entry.cacheKey = key;
            entry.result = result;
            entry.timestamp = System.currentTimeMillis();
            mResultCache.addFirst(entry);

            /* 限制缓存大小 */
            while (mResultCache.size() > MAX_CACHE_SIZE) {
                mResultCache.removeLast();
            }
        }
    }

    /** 解析云端识别API返回的JSON */
    private String parseRecognitionResult(String json) {
        if (json == null || json.isEmpty()) return null;
        /* 简化的JSON解析:提取result字段 */
        int idx = json.indexOf("\"result\"");
        if (idx < 0) return null;
        int start = json.indexOf("\"", idx + 8) + 1;
        int end = json.indexOf("\"", start);
        if (start > 0 && end > start) {
            return json.substring(start, end);
        }
        return null;
    }

    /* ========== JNI本地方法声明 ========== */

    /** 加载ONNX模型,返回会话句柄 */
    private native long nativeLoadModel(String modelPath);

    /** 执行ONNX推理,返回识别结果JSON */
    private native String nativeRunInference(long sessionHandle, float[] inputTensor,
                                              int width, int height);

    /** 释放ONNX会话资源 */
    private native void nativeReleaseModel(long sessionHandle);

    static {
        System.loadLibrary("writech_ocr");
    }

    /* ========== 资源释放 ========== */

    /** 释放OCR引擎资源 */
    public void destroy() {
        mTaskQueue.clear();
        if (mOnnxSessionHandle != 0) {
            nativeReleaseModel(mOnnxSessionHandle);
            mOnnxSessionHandle = 0;
        }
        if (mWorkerThread != null) {
            mWorkerThread.quitSafely();
            mWorkerThread = null;
        }
        mResultCache.clear();
        Log.i(TAG, "OCR引擎资源已释放");
    }
}

android/PenManager.java

/*
 * 自然写互动课堂应用开发SDK软件 V1.0
 * PenManager - Android端蓝牙点阵笔连接管理器
 *
 * 功能说明:
 * 1. BLE 5.0蓝牙扫描与自动连接
 * 2. GATT服务发现与特征值订阅
 * 3. 点阵笔数据实时接收与解析
 * 4. 多笔同时连接管理(最多支持60支)
 * 5. 连接状态监控与自动重连
 * 6. 电量/固件版本/设备信息查询
 */

package com.writech.sdk.android;

import android.bluetooth.BluetoothAdapter;
import android.bluetooth.BluetoothDevice;
import android.bluetooth.BluetoothGatt;
import android.bluetooth.BluetoothGattCallback;
import android.bluetooth.BluetoothGattCharacteristic;
import android.bluetooth.BluetoothGattDescriptor;
import android.bluetooth.BluetoothGattService;
import android.bluetooth.BluetoothManager;
import android.bluetooth.BluetoothProfile;
import android.bluetooth.le.BluetoothLeScanner;
import android.bluetooth.le.ScanCallback;
import android.bluetooth.le.ScanFilter;
import android.bluetooth.le.ScanResult;
import android.bluetooth.le.ScanSettings;
import android.content.Context;
import android.os.Handler;
import android.os.HandlerThread;
import android.os.ParcelUuid;
import android.util.Log;

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList;

/**
 * 点阵笔蓝牙连接管理器
 * 负责BLE扫描、连接、数据接收的全生命周期管理
 */
public class PenManager {

    private static final String TAG = "WritechPenManager";

    /* 自然写点阵笔GATT服务UUID(自定义) */
    private static final UUID PEN_SERVICE_UUID =
        UUID.fromString("0000FFE0-0000-1000-8000-00805F9B34FB");

    /* 笔迹数据通知特征值UUID */
    private static final UUID STROKE_DATA_CHAR_UUID =
        UUID.fromString("0000FFE1-0000-1000-8000-00805F9B34FB");

    /* 笔控制指令写入特征值UUID */
    private static final UUID PEN_CONTROL_CHAR_UUID =
        UUID.fromString("0000FFE2-0000-1000-8000-00805F9B34FB");

    /* 设备信息特征值UUID(电量/固件版本) */
    private static final UUID DEVICE_INFO_CHAR_UUID =
        UUID.fromString("0000FFE3-0000-1000-8000-00805F9B34FB");

    /* CCCD描述符UUID,用于启用通知 */
    private static final UUID CCCD_UUID =
        UUID.fromString("00002902-0000-1000-8000-00805F9B34FB");

    /* 最大同时连接数 */
    private static final int MAX_CONNECTIONS = 60;

    /* 自动重连延迟(毫秒) */
    private static final long RECONNECT_DELAY_MS = 3000;

    /* 扫描超时时间(毫秒) */
    private static final long SCAN_TIMEOUT_MS = 30000;

    /* ========== 成员变量 ========== */

    private final Context mContext;
    private final BluetoothAdapter mBluetoothAdapter;
    private BluetoothLeScanner mScanner;

    /* 已连接的笔设备映射表(MAC地址 → GATT连接) */
    private final Map<String, BluetoothGatt> mConnectedPens = new ConcurrentHashMap<>();

    /* 等待重连的设备列表 */
    private final Map<String, Integer> mReconnectAttempts = new ConcurrentHashMap<>();

    /* 设备信息缓存(MAC地址 → 设备模型) */
    private final Map<String, PenDeviceInfo> mDeviceInfoCache = new ConcurrentHashMap<>();

    /* 数据回调监听器列表 */
    private final List<PenDataListener> mDataListeners = new CopyOnWriteArrayList<>();

    /* 连接状态监听器列表 */
    private final List<PenConnectionListener> mConnectionListeners = new CopyOnWriteArrayList<>();

    /* BLE操作专用线程 */
    private HandlerThread mBleThread;
    private Handler mBleHandler;

    /* 扫描状态标志 */
    private volatile boolean mIsScanning = false;

    /* ========== 内部数据结构 ========== */

    /** 笔设备信息缓存 */
    private static class PenDeviceInfo {
        String macAddress;           /* MAC地址 */
        String penName;              /* 笔名称 */
        String firmwareVersion;      /* 固件版本 */
        int batteryLevel;            /* 电量百分比 */
        long lastDataTimestamp;      /* 最后一次收到数据的时间 */
        boolean isWriting;           /* 是否正在书写 */
    }

    /* ========== 对外回调接口 ========== */

    /** 笔迹数据监听器 */
    public interface PenDataListener {
        /** 收到笔迹坐标数据 */
        void onStrokeData(String penMac, int x, int y, int pressure, long timestamp);
        /** 笔抬起事件(一笔结束) */
        void onPenUp(String penMac, long timestamp);
        /** 笔落下事件(一笔开始) */
        void onPenDown(String penMac, long timestamp);
    }

    /** 连接状态监听器 */
    public interface PenConnectionListener {
        void onPenConnected(String penMac, String penName);
        void onPenDisconnected(String penMac, int reason);
        void onPenDiscovered(String penMac, String penName, int rssi);
        void onBatteryUpdate(String penMac, int batteryPercent);
    }

    /* ========== 构造与初始化 ========== */

    /**
     * 创建笔管理器实例
     * @param context  Android上下文(需要蓝牙权限)
     */
    public PenManager(Context context) {
        mContext = context.getApplicationContext();
        BluetoothManager btManager =
            (BluetoothManager) mContext.getSystemService(Context.BLUETOOTH_SERVICE);
        mBluetoothAdapter = btManager.getAdapter();

        /* 创建BLE操作专用后台线程 */
        mBleThread = new HandlerThread("WritechBLE");
        mBleThread.start();
        mBleHandler = new Handler(mBleThread.getLooper());

        Log.i(TAG, "PenManager初始化完成,蓝牙状态: "
              + (mBluetoothAdapter.isEnabled() ? "已开启" : "未开启"));
    }

    /** 注册笔迹数据监听器 */
    public void addDataListener(PenDataListener listener) {
        if (listener != null && !mDataListeners.contains(listener)) {
            mDataListeners.add(listener);
        }
    }

    /** 移除笔迹数据监听器 */
    public void removeDataListener(PenDataListener listener) {
        mDataListeners.remove(listener);
    }

    /** 注册连接状态监听器 */
    public void addConnectionListener(PenConnectionListener listener) {
        if (listener != null && !mConnectionListeners.contains(listener)) {
            mConnectionListeners.add(listener);
        }
    }

    /* ========== BLE扫描 ========== */

    /**
     * 开始扫描附近的自然写点阵笔
     * 使用低延迟模式扫描BLE设备,按服务UUID过滤
     */
    public void startScan() {
        if (mIsScanning) {
            Log.w(TAG, "扫描已在进行中,忽略重复请求");
            return;
        }

        if (!mBluetoothAdapter.isEnabled()) {
            Log.e(TAG, "蓝牙未开启,无法扫描");
            return;
        }

        mScanner = mBluetoothAdapter.getBluetoothLeScanner();
        if (mScanner == null) {
            Log.e(TAG, "获取BLE扫描器失败");
            return;
        }

        /* 构建扫描过滤器:仅扫描包含自然写服务UUID的设备 */
        ScanFilter filter = new ScanFilter.Builder()
            .setServiceUuid(new ParcelUuid(PEN_SERVICE_UUID))
            .build();
        List<ScanFilter> filters = Collections.singletonList(filter);

        /* 低延迟扫描设置(耗电较高,适合主动扫描场景) */
        ScanSettings settings = new ScanSettings.Builder()
            .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
            .setCallbackType(ScanSettings.CALLBACK_TYPE_ALL_MATCHES)
            .setMatchMode(ScanSettings.MATCH_MODE_AGGRESSIVE)
            .build();

        mScanner.startScan(filters, settings, mScanCallback);
        mIsScanning = true;

        /* 设置扫描超时,避免长时间扫描耗电 */
        mBleHandler.postDelayed(this::stopScan, SCAN_TIMEOUT_MS);

        Log.i(TAG, "开始扫描自然写点阵笔...");
    }

    /** 停止BLE扫描 */
    public void stopScan() {
        if (mIsScanning && mScanner != null) {
            mScanner.stopScan(mScanCallback);
            mIsScanning = false;
            Log.i(TAG, "停止扫描");
        }
    }

    /** BLE扫描回调 */
    private final ScanCallback mScanCallback = new ScanCallback() {
        @Override
        public void onScanResult(int callbackType, ScanResult result) {
            BluetoothDevice device = result.getDevice();
            String mac = device.getAddress();
            String name = device.getName();
            int rssi = result.getRssi();

            if (name == null || name.isEmpty()) {
                name = "WritechPen-" + mac.substring(mac.length() - 5);
            }

            /* 通知上层发现了新的笔设备 */
            for (PenConnectionListener listener : mConnectionListeners) {
                listener.onPenDiscovered(mac, name, rssi);
            }

            Log.d(TAG, "发现笔设备: " + name + " [" + mac + "] RSSI=" + rssi);
        }

        @Override
        public void onScanFailed(int errorCode) {
            mIsScanning = false;
            Log.e(TAG, "BLE扫描失败,错误码: " + errorCode);
        }
    };

    /* ========== BLE连接管理 ========== */

    /**
     * 连接指定MAC地址的点阵笔
     * @param macAddress  设备MAC地址
     */
    public void connectPen(String macAddress) {
        if (mConnectedPens.size() >= MAX_CONNECTIONS) {
            Log.w(TAG, "已达最大连接数 " + MAX_CONNECTIONS + ",拒绝新连接");
            return;
        }

        if (mConnectedPens.containsKey(macAddress)) {
            Log.w(TAG, "设备已连接: " + macAddress);
            return;
        }

        BluetoothDevice device = mBluetoothAdapter.getRemoteDevice(macAddress);
        /* 使用TRANSPORT_LE确保走BLE通道,autoConnect=false立即连接 */
        device.connectGatt(mContext, false, mGattCallback, BluetoothDevice.TRANSPORT_LE);
        Log.i(TAG, "正在连接笔设备: " + macAddress);
    }

    /** 断开指定笔的连接 */
    public void disconnectPen(String macAddress) {
        BluetoothGatt gatt = mConnectedPens.remove(macAddress);
        if (gatt != null) {
            gatt.disconnect();
            gatt.close();
            mReconnectAttempts.remove(macAddress);
            Log.i(TAG, "已断开笔设备: " + macAddress);
        }
    }

    /** 断开所有已连接的笔 */
    public void disconnectAll() {
        for (Map.Entry<String, BluetoothGatt> entry : mConnectedPens.entrySet()) {
            entry.getValue().disconnect();
            entry.getValue().close();
        }
        mConnectedPens.clear();
        mReconnectAttempts.clear();
        Log.i(TAG, "已断开所有笔设备");
    }

    /** 获取当前已连接的笔数量 */
    public int getConnectedCount() {
        return mConnectedPens.size();
    }

    /** 获取所有已连接笔的MAC地址列表 */
    public List<String> getConnectedPenMacs() {
        return new ArrayList<>(mConnectedPens.keySet());
    }

    /* ========== GATT回调处理 ========== */

    /**
     * GATT连接/数据回调
     * 处理连接状态变化、服务发现、数据通知等所有BLE事件
     */
    private final BluetoothGattCallback mGattCallback = new BluetoothGattCallback() {

        @Override
        public void onConnectionStateChange(BluetoothGatt gatt, int status, int newState) {
            String mac = gatt.getDevice().getAddress();

            if (newState == BluetoothProfile.STATE_CONNECTED) {
                /* 连接成功,开始发现GATT服务 */
                mConnectedPens.put(mac, gatt);
                mReconnectAttempts.remove(mac);
                gatt.discoverServices();

                String name = gatt.getDevice().getName();
                for (PenConnectionListener listener : mConnectionListeners) {
                    listener.onPenConnected(mac, name != null ? name : "Unknown");
                }
                Log.i(TAG, "笔设备连接成功: " + mac + ",正在发现服务...");

            } else if (newState == BluetoothProfile.STATE_DISCONNECTED) {
                /* 连接断开,尝试自动重连 */
                mConnectedPens.remove(mac);
                gatt.close();

                for (PenConnectionListener listener : mConnectionListeners) {
                    listener.onPenDisconnected(mac, status);
                }
                Log.w(TAG, "笔设备断开: " + mac + ",状态码: " + status);

                /* 自动重连逻辑(最多尝试5次) */
                scheduleReconnect(mac);
            }
        }

        @Override
        public void onServicesDiscovered(BluetoothGatt gatt, int status) {
            if (status != BluetoothGatt.GATT_SUCCESS) {
                Log.e(TAG, "GATT服务发现失败: " + status);
                return;
            }

            /* 查找自然写笔迹数据服务 */
            BluetoothGattService penService = gatt.getService(PEN_SERVICE_UUID);
            if (penService == null) {
                Log.e(TAG, "未找到自然写笔服务,设备可能不兼容");
                return;
            }

            /* 订阅笔迹数据通知特征值 */
            BluetoothGattCharacteristic strokeChar =
                penService.getCharacteristic(STROKE_DATA_CHAR_UUID);
            if (strokeChar != null) {
                gatt.setCharacteristicNotification(strokeChar, true);

                /* 写入CCCD描述符启用通知 */
                BluetoothGattDescriptor cccd = strokeChar.getDescriptor(CCCD_UUID);
                if (cccd != null) {
                    cccd.setValue(BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE);
                    gatt.writeDescriptor(cccd);
                }
                Log.i(TAG, "已订阅笔迹数据通知");
            }

            /* 读取设备信息(电量、固件版本) */
            BluetoothGattCharacteristic infoChar =
                penService.getCharacteristic(DEVICE_INFO_CHAR_UUID);
            if (infoChar != null) {
                mBleHandler.postDelayed(() -> gatt.readCharacteristic(infoChar), 500);
            }
        }

        @Override
        public void onCharacteristicChanged(BluetoothGatt gatt,
                                             BluetoothGattCharacteristic characteristic) {
            String mac = gatt.getDevice().getAddress();
            UUID charUuid = characteristic.getUuid();

            if (STROKE_DATA_CHAR_UUID.equals(charUuid)) {
                /* 收到笔迹数据通知,解析并分发 */
                byte[] data = characteristic.getValue();
                parseAndDispatchStrokeData(mac, data);
            }
        }

        @Override
        public void onCharacteristicRead(BluetoothGatt gatt,
                                          BluetoothGattCharacteristic characteristic,
                                          int status) {
            if (status != BluetoothGatt.GATT_SUCCESS) return;

            String mac = gatt.getDevice().getAddress();
            UUID charUuid = characteristic.getUuid();

            if (DEVICE_INFO_CHAR_UUID.equals(charUuid)) {
                /* 解析设备信息数据 */
                byte[] data = characteristic.getValue();
                parseDeviceInfo(mac, data);
            }
        }
    };

    /* ========== 数据解析与分发 ========== */

    /**
     * 解析BLE收到的笔迹数据帧并分发给监听器
     * 数据格式(7字节紧凑编码):
     * [0-1] X坐标高16位  [2-3] Y坐标高16位
     * [4] X低4位|Y低4位  [5] 压力高8位  [6] 压力低4位|标志
     */
    private void parseAndDispatchStrokeData(String penMac, byte[] data) {
        if (data == null || data.length < 7) {
            return;
        }

        long timestamp = System.currentTimeMillis();

        /* 检查帧类型标志(最低2位) */
        int flags = data[6] & 0x03;

        if (flags == 0x01) {
            /* 笔落下事件 */
            for (PenDataListener listener : mDataListeners) {
                listener.onPenDown(penMac, timestamp);
            }
            return;
        }

        if (flags == 0x02) {
            /* 笔抬起事件 */
            for (PenDataListener listener : mDataListeners) {
                listener.onPenUp(penMac, timestamp);
            }
            return;
        }

        /* 坐标数据帧(flags == 0x00 */
        int xHigh = ((data[0] & 0xFF) << 8) | (data[1] & 0xFF);
        int xLow = (data[4] >> 4) & 0x0F;
        int x = (xHigh << 4) | xLow;

        int yHigh = ((data[2] & 0xFF) << 8) | (data[3] & 0xFF);
        int yLow = data[4] & 0x0F;
        int y = (yHigh << 4) | yLow;

        int pHigh = data[5] & 0xFF;
        int pLow = (data[6] >> 4) & 0x0F;
        int pressure = (pHigh << 4) | pLow;

        /* 更新设备状态 */
        PenDeviceInfo info = mDeviceInfoCache.get(penMac);
        if (info != null) {
            info.lastDataTimestamp = timestamp;
            info.isWriting = true;
        }

        /* 分发到所有监听器 */
        for (PenDataListener listener : mDataListeners) {
            listener.onStrokeData(penMac, x, y, pressure, timestamp);
        }
    }

    /** 解析设备信息特征值数据 */
    private void parseDeviceInfo(String penMac, byte[] data) {
        if (data == null || data.length < 4) return;

        PenDeviceInfo info = mDeviceInfoCache.get(penMac);
        if (info == null) {
            info = new PenDeviceInfo();
            info.macAddress = penMac;
            mDeviceInfoCache.put(penMac, info);
        }

        /* 第一字节:电量百分比 */
        info.batteryLevel = data[0] & 0xFF;

        /* 第2-4字节:固件版本 major.minor.patch */
        info.firmwareVersion = (data[1] & 0xFF) + "." + (data[2] & 0xFF)
                               + "." + (data[3] & 0xFF);

        /* 通知电量更新 */
        for (PenConnectionListener listener : mConnectionListeners) {
            listener.onBatteryUpdate(penMac, info.batteryLevel);
        }

        Log.i(TAG, "设备信息 [" + penMac + "] 电量:" + info.batteryLevel
              + "% 固件:" + info.firmwareVersion);
    }

    /* ========== 自动重连 ========== */

    /** 安排自动重连(指数退避) */
    private void scheduleReconnect(String macAddress) {
        Integer attempts = mReconnectAttempts.getOrDefault(macAddress, 0);
        if (attempts >= 5) {
            Log.w(TAG, "设备 " + macAddress + " 重连次数已达上限,放弃重连");
            mReconnectAttempts.remove(macAddress);
            return;
        }

        mReconnectAttempts.put(macAddress, attempts + 1);

        /* 指数退避:3s, 6s, 12s, 24s, 48s */
        long delay = RECONNECT_DELAY_MS * (1L << attempts);

        mBleHandler.postDelayed(() -> {
            if (!mConnectedPens.containsKey(macAddress)) {
                Log.i(TAG, "尝试重连设备: " + macAddress + "(第" + (attempts + 1) + "次)");
                connectPen(macAddress);
            }
        }, delay);
    }

    /* ========== 控制指令发送 ========== */

    /**
     * 向笔发送控制指令
     * @param macAddress  目标笔MAC
     * @param command     指令字节数组
     * @return 是否发送成功
     */
    public boolean sendCommand(String macAddress, byte[] command) {
        BluetoothGatt gatt = mConnectedPens.get(macAddress);
        if (gatt == null) {
            Log.w(TAG, "设备未连接,无法发送指令: " + macAddress);
            return false;
        }

        BluetoothGattService service = gatt.getService(PEN_SERVICE_UUID);
        if (service == null) return false;

        BluetoothGattCharacteristic controlChar =
            service.getCharacteristic(PEN_CONTROL_CHAR_UUID);
        if (controlChar == null) return false;

        controlChar.setValue(command);
        controlChar.setWriteType(BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT);
        return gatt.writeCharacteristic(controlChar);
    }

    /** 查询笔电量 */
    public int getBatteryLevel(String macAddress) {
        PenDeviceInfo info = mDeviceInfoCache.get(macAddress);
        return info != null ? info.batteryLevel : -1;
    }

    /* ========== 资源释放 ========== */

    /** 释放PenManager资源 */
    public void destroy() {
        stopScan();
        disconnectAll();
        mDataListeners.clear();
        mConnectionListeners.clear();
        mDeviceInfoCache.clear();

        if (mBleThread != null) {
            mBleThread.quitSafely();
            mBleThread = null;
        }
        Log.i(TAG, "PenManager资源已释放");
    }
}

android/StrokeCanvas.java

/*
 * 自然写互动课堂应用开发SDK软件 V1.0
 * StrokeCanvas - Android端笔迹渲染自定义View
 *
 * 功能说明:
 * 1. 实时笔迹渲染(贝塞尔曲线平滑绘制)
 * 2. 压力感应笔锋效果(根据压力值动态调整线宽)
 * 3. 多笔同屏渲染(不同颜色区分不同学生)
 * 4. 笔迹重播动画(按时间序列回放书写过程)
 * 5. 离屏缓冲双缓冲渲染(避免闪烁)
 * 6. 触摸与点阵笔混合输入支持
 */

package com.writech.sdk.android;

import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.Path;
import android.graphics.PorterDuff;
import android.graphics.RectF;
import android.os.SystemClock;
import android.util.AttributeSet;
import android.view.MotionEvent;
import android.view.View;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

/**
 * 笔迹渲染画布组件
 * 支持实时绘制点阵笔和触摸屏输入的笔迹数据
 */
public class StrokeCanvas extends View {

    private static final String TAG = "WritechStrokeCanvas";

    /* 默认画笔颜色 */
    private static final int DEFAULT_STROKE_COLOR = Color.BLACK;

    /* 默认最小线宽(像素) */
    private static final float MIN_STROKE_WIDTH = 1.5f;

    /* 默认最大线宽(像素) */
    private static final float MAX_STROKE_WIDTH = 8.0f;

    /* 最大压力值(点阵笔12位ADC) */
    private static final float MAX_PRESSURE = 4095.0f;

    /* ========== 内部数据结构 ========== */

    /** 单个采样点(包含坐标、压力、时间戳) */
    private static class StrokePoint {
        float x;
        float y;
        float pressure;      /* 归一化压力 0.0~1.0 */
        long timestamp;       /* 毫秒时间戳 */

        StrokePoint(float x, float y, float pressure, long timestamp) {
            this.x = x;
            this.y = y;
            this.pressure = pressure;
            this.timestamp = timestamp;
        }
    }

    /** 一笔数据(从落笔到抬笔) */
    private static class Stroke {
        String penMac;                     /* 来源笔MAC地址 */
        int color;                         /* 笔迹颜色 */
        List<StrokePoint> points;          /* 采样点列表 */

        Stroke(String penMac, int color) {
            this.penMac = penMac;
            this.color = color;
            this.points = new ArrayList<>();
        }
    }

    /* ========== 成员变量 ========== */

    /* 离屏缓冲Bitmap(双缓冲渲染) */
    private Bitmap mBufferBitmap;
    private Canvas mBufferCanvas;

    /* 绘制画笔 */
    private final Paint mStrokePaint;

    /* 背景清除画笔 */
    private final Paint mClearPaint;

    /* 已完成的笔画列表(历史记录) */
    private final List<Stroke> mCompletedStrokes = new ArrayList<>();

    /* 当前正在书写的笔画(按笔MAC索引) */
    private final Map<String, Stroke> mActiveStrokes = new HashMap<>();

    /* 每支笔的颜色映射 */
    private final Map<String, Integer> mPenColorMap = new HashMap<>();

    /* 笔迹颜色分配计数器 */
    private int mColorIndex = 0;

    /* 预定义的笔迹颜色列表(用于多学生区分) */
    private static final int[] STROKE_COLORS = {
        Color.BLACK,
        Color.parseColor("#1565C0"),     /* 蓝色 */
        Color.parseColor("#C62828"),     /* 红色 */
        Color.parseColor("#2E7D32"),     /* 绿色 */
        Color.parseColor("#E65100"),     /* 橙色 */
        Color.parseColor("#6A1B9A"),     /* 紫色 */
        Color.parseColor("#00838F"),     /* 青色 */
        Color.parseColor("#4E342E"),     /* 棕色 */
    };

    /* 是否启用压力感应笔锋 */
    private boolean mPressureEnabled = true;

    /* 笔迹重播相关 */
    private boolean mIsReplaying = false;
    private int mReplayStrokeIndex = 0;
    private int mReplayPointIndex = 0;
    private long mReplayStartTime = 0;

    /* ========== 构造函数 ========== */

    public StrokeCanvas(Context context) {
        this(context, null);
    }

    public StrokeCanvas(Context context, AttributeSet attrs) {
        super(context, attrs);

        /* 初始化笔迹画笔 */
        mStrokePaint = new Paint();
        mStrokePaint.setAntiAlias(true);           /* 抗锯齿 */
        mStrokePaint.setDither(true);               /* 防抖动 */
        mStrokePaint.setStyle(Paint.Style.STROKE);
        mStrokePaint.setStrokeJoin(Paint.Join.ROUND);   /* 圆角连接 */
        mStrokePaint.setStrokeCap(Paint.Cap.ROUND);     /* 圆头笔触 */

        /* 初始化清除画笔 */
        mClearPaint = new Paint();
        mClearPaint.setColor(Color.WHITE);
    }

    /* ========== View生命周期 ========== */

    @Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
        super.onSizeChanged(w, h, oldw, oldh);

        /* 创建离屏缓冲Bitmap */
        if (mBufferBitmap != null) {
            mBufferBitmap.recycle();
        }
        mBufferBitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888);
        mBufferCanvas = new Canvas(mBufferBitmap);
        mBufferCanvas.drawColor(Color.WHITE);

        /* 重绘所有历史笔画到缓冲区 */
        redrawAllStrokes();
    }

    @Override
    protected void onDraw(Canvas canvas) {
        /* 将离屏缓冲Bitmap绘制到屏幕 */
        if (mBufferBitmap != null) {
            canvas.drawBitmap(mBufferBitmap, 0, 0, null);
        }

        /* 绘制当前活跃的笔画(实时部分) */
        for (Stroke stroke : mActiveStrokes.values()) {
            drawStrokeRealtime(canvas, stroke);
        }
    }

    /* ========== 点阵笔数据输入接口 ========== */

    /**
     * 接收笔落下事件(开始新的一笔)
     * @param penMac  笔设备MAC地址
     */
    public void onPenDown(String penMac) {
        int color = getPenColor(penMac);
        Stroke stroke = new Stroke(penMac, color);
        mActiveStrokes.put(penMac, stroke);
    }

    /**
     * 接收笔迹坐标数据
     * @param penMac    笔MAC
     * @param screenX   屏幕X坐标(已经过坐标变换)
     * @param screenY   屏幕Y坐标
     * @param pressure  原始压力值(0-4095
     */
    public void onStrokePoint(String penMac, float screenX, float screenY,
                               int pressure) {
        Stroke stroke = mActiveStrokes.get(penMac);
        if (stroke == null) {
            /* 如果没有活跃笔画,自动创建 */
            onPenDown(penMac);
            stroke = mActiveStrokes.get(penMac);
        }

        /* 归一化压力值 */
        float normalizedPressure = Math.min(1.0f, (float) pressure / MAX_PRESSURE);
        long timestamp = SystemClock.elapsedRealtime();

        stroke.points.add(new StrokePoint(screenX, screenY, normalizedPressure, timestamp));

        /* 触发重绘(仅绘制增量部分,避免全量刷新) */
        int pointCount = stroke.points.size();
        if (pointCount >= 2) {
            StrokePoint prev = stroke.points.get(pointCount - 2);
            StrokePoint curr = stroke.points.get(pointCount - 1);

            /* 仅刷新受影响的矩形区域(性能优化) */
            float padding = MAX_STROKE_WIDTH + 2;
            float left = Math.min(prev.x, curr.x) - padding;
            float top = Math.min(prev.y, curr.y) - padding;
            float right = Math.max(prev.x, curr.x) + padding;
            float bottom = Math.max(prev.y, curr.y) + padding;

            invalidate((int) left, (int) top, (int) right, (int) bottom);
        }
    }

    /**
     * 接收笔抬起事件(一笔结束)
     * 将当前笔画固化到缓冲区并归档
     */
    public void onPenUp(String penMac) {
        Stroke stroke = mActiveStrokes.remove(penMac);
        if (stroke != null && stroke.points.size() > 1) {
            /* 绘制到离屏缓冲区(固化) */
            drawStrokeToBuffer(stroke);
            /* 添加到已完成列表 */
            mCompletedStrokes.add(stroke);
        }
        invalidate();
    }

    /* ========== 笔迹渲染核心算法 ========== */

    /**
     * 实时渲染笔画(使用贝塞尔曲线平滑)
     * 在每次onDraw中调用,绘制当前活跃的笔画
     */
    private void drawStrokeRealtime(Canvas canvas, Stroke stroke) {
        List<StrokePoint> points = stroke.points;
        if (points.size() < 2) return;

        mStrokePaint.setColor(stroke.color);

        for (int i = 1; i < points.size(); i++) {
            StrokePoint p0 = points.get(i - 1);
            StrokePoint p1 = points.get(i);

            /* 根据压力计算线宽 */
            float width = calculateStrokeWidth(p0.pressure, p1.pressure);
            mStrokePaint.setStrokeWidth(width);

            if (i >= 2) {
                /* 使用二次贝塞尔曲线平滑绘制 */
                StrokePoint pPrev = points.get(i - 2);
                float midX0 = (pPrev.x + p0.x) / 2;
                float midY0 = (pPrev.y + p0.y) / 2;
                float midX1 = (p0.x + p1.x) / 2;
                float midY1 = (p0.y + p1.y) / 2;

                Path path = new Path();
                path.moveTo(midX0, midY0);
                path.quadTo(p0.x, p0.y, midX1, midY1);
                canvas.drawPath(path, mStrokePaint);
            } else {
                /* 前两个点直接画直线 */
                canvas.drawLine(p0.x, p0.y, p1.x, p1.y, mStrokePaint);
            }
        }
    }

    /**
     * 将完成的笔画绘制到离屏缓冲区
     */
    private void drawStrokeToBuffer(Stroke stroke) {
        if (mBufferCanvas == null) return;
        drawStrokeRealtime(mBufferCanvas, stroke);
    }

    /**
     * 根据压力值计算线宽(笔锋效果)
     * 使用两个相邻点的平均压力,平滑过渡
     *
     * @param pressure0  前一点压力(归一化)
     * @param pressure1  当前点压力(归一化)
     * @return 线宽(像素)
     */
    private float calculateStrokeWidth(float pressure0, float pressure1) {
        if (!mPressureEnabled) {
            return (MIN_STROKE_WIDTH + MAX_STROKE_WIDTH) / 2;
        }

        float avgPressure = (pressure0 + pressure1) / 2.0f;

        /* 压力-宽度映射曲线(使用幂函数增加笔锋感) */
        float normalized = (float) Math.pow(avgPressure, 0.7);
        return MIN_STROKE_WIDTH + normalized * (MAX_STROKE_WIDTH - MIN_STROKE_WIDTH);
    }

    /* ========== 多笔颜色管理 ========== */

    /** 获取或分配笔的颜色 */
    private int getPenColor(String penMac) {
        Integer color = mPenColorMap.get(penMac);
        if (color == null) {
            color = STROKE_COLORS[mColorIndex % STROKE_COLORS.length];
            mPenColorMap.put(penMac, color);
            mColorIndex++;
        }
        return color;
    }

    /** 手动设置某支笔的颜色 */
    public void setPenColor(String penMac, int color) {
        mPenColorMap.put(penMac, color);
    }

    /* ========== 画布操作 ========== */

    /** 清除所有笔迹 */
    public void clearAll() {
        mCompletedStrokes.clear();
        mActiveStrokes.clear();
        if (mBufferCanvas != null) {
            mBufferCanvas.drawColor(Color.WHITE);
        }
        invalidate();
    }

    /** 撤销最后一笔 */
    public boolean undo() {
        if (mCompletedStrokes.isEmpty()) return false;
        mCompletedStrokes.remove(mCompletedStrokes.size() - 1);
        redrawAllStrokes();
        invalidate();
        return true;
    }

    /** 重绘所有历史笔画到缓冲区 */
    private void redrawAllStrokes() {
        if (mBufferCanvas == null) return;
        mBufferCanvas.drawColor(Color.WHITE);
        for (Stroke stroke : mCompletedStrokes) {
            drawStrokeToBuffer(stroke);
        }
    }

    /** 导出当前画布为Bitmap */
    public Bitmap exportBitmap() {
        Bitmap export = Bitmap.createBitmap(getWidth(), getHeight(), Bitmap.Config.ARGB_8888);
        Canvas exportCanvas = new Canvas(export);
        draw(exportCanvas);
        return export;
    }

    /** 获取已完成的笔画数量 */
    public int getStrokeCount() {
        return mCompletedStrokes.size();
    }

    /** 设置是否启用压力笔锋效果 */
    public void setPressureEnabled(boolean enabled) {
        mPressureEnabled = enabled;
    }

    /* ========== 触摸屏输入支持 ========== */

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        /* 使用"touch"作为虚拟笔MAC */
        String touchMac = "touch_input";

        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                onPenDown(touchMac);
                onStrokePoint(touchMac, event.getX(), event.getY(),
                    (int)(event.getPressure() * MAX_PRESSURE));
                return true;

            case MotionEvent.ACTION_MOVE:
                /* 处理历史点(Android会批量发送MOVE事件) */
                for (int i = 0; i < event.getHistorySize(); i++) {
                    onStrokePoint(touchMac,
                        event.getHistoricalX(i),
                        event.getHistoricalY(i),
                        (int)(event.getHistoricalPressure(i) * MAX_PRESSURE));
                }
                onStrokePoint(touchMac, event.getX(), event.getY(),
                    (int)(event.getPressure() * MAX_PRESSURE));
                return true;

            case MotionEvent.ACTION_UP:
                onStrokePoint(touchMac, event.getX(), event.getY(),
                    (int)(event.getPressure() * MAX_PRESSURE));
                onPenUp(touchMac);
                return true;
        }
        return super.onTouchEvent(event);
    }
}

android/WritechSDK.java

/*
 * 自然写互动课堂应用开发SDK软件 V1.0
 * WritechSDK - SDK初始化与鉴权入口
 *
 * 功能说明:
 * 1. SDK全局初始化(配置加载、模块注册)
 * 2. 应用鉴权(AppKey/AppSecret验证)
 * 3. 各子模块生命周期管理
 * 4. 全局配置管理(服务器地址、超时、日志级别)
 * 5. SDK版本信息与功能授权查询
 */

package com.writech.sdk.android;

import android.content.Context;
import android.content.SharedPreferences;
import android.util.Log;

import java.io.IOException;
import java.util.concurrent.atomic.AtomicBoolean;

/**
 * 自然写SDK主入口类
 * 使用前必须先调用 init() 方法进行初始化和鉴权
 *
 * 典型使用流程:
 * 1. WritechSDK.init(context, config)
 * 2. WritechSDK.getInstance().getPenManager().startScan()
 * 3. WritechSDK.getInstance().getOCREngine().recognizeHandwriting(...)
 */
public class WritechSDK {

    private static final String TAG = "WritechSDK";

    /* SDK版本号 */
    public static final String SDK_VERSION = "1.0.0";

    /* SDK构建号 */
    public static final int SDK_BUILD = 100;

    /* 单例实例 */
    private static volatile WritechSDK sInstance;

    /* 是否已初始化 */
    private static final AtomicBoolean sInitialized = new AtomicBoolean(false);

    /* ========== 配置类 ========== */

    /** SDK初始化配置 */
    public static class Config {
        /** 云平台API地址 */
        public String cloudBaseUrl = "https://api.writech.com";

        /** SDK应用标识(从自然写开放平台获取) */
        public String appKey;

        /** SDK应用密钥 */
        public String appSecret;

        /** 离线OCR模型文件路径(可选) */
        public String offlineModelPath;

        /** 是否启用调试日志 */
        public boolean debugMode = false;

        /** 笔迹数据本地缓存目录 */
        public String cacheDir;

        /** BLE扫描超时时间(毫秒) */
        public int bleScanTimeout = 30000;

        /** 网关自动发现 */
        public boolean autoDiscoverGateway = true;

        /** 最大同时连接笔数 */
        public int maxPenConnections = 60;
    }

    /* ========== 成员变量 ========== */

    private Context mContext;
    private Config mConfig;

    /* 各子模块实例 */
    private PenManager mPenManager;
    private StrokeCanvas mDefaultCanvas;
    private OCREngine mOCREngine;
    private GatewaySDK mGatewaySDK;
    private CloudClient mCloudClient;

    /* 鉴权状态 */
    private boolean mIsAuthenticated = false;
    private String mLicenseType;          /* 授权类型: trial/standard/enterprise */
    private long mLicenseExpireTime;      /* 授权到期时间 */

    /* 本地存储 */
    private SharedPreferences mPrefs;

    /* ========== 初始化入口 ========== */

    /**
     * 初始化SDK(必须在使用任何功能前调用)
     *
     * @param context  Android上下文(Application级别)
     * @param config   SDK配置
     * @return 初始化结果:true成功,false失败
     */
    public static boolean init(Context context, Config config) {
        if (sInitialized.getAndSet(true)) {
            Log.w(TAG, "SDK已初始化,忽略重复调用");
            return true;
        }

        if (context == null || config == null) {
            Log.e(TAG, "初始化失败:context或config为null");
            sInitialized.set(false);
            return false;
        }

        if (config.appKey == null || config.appSecret == null) {
            Log.e(TAG, "初始化失败:appKey或appSecret未配置");
            sInitialized.set(false);
            return false;
        }

        sInstance = new WritechSDK();
        boolean success = sInstance.doInit(context, config);

        if (!success) {
            sInstance = null;
            sInitialized.set(false);
        }

        return success;
    }

    /** 获取SDK单例 */
    public static WritechSDK getInstance() {
        if (sInstance == null) {
            throw new IllegalStateException("WritechSDK未初始化,请先调用 WritechSDK.init()");
        }
        return sInstance;
    }

    /** 检查SDK是否已初始化 */
    public static boolean isInitialized() {
        return sInitialized.get();
    }

    /* ========== 内部初始化流程 ========== */

    /** 执行具体的初始化逻辑 */
    private boolean doInit(Context context, Config config) {
        mContext = context.getApplicationContext();
        mConfig = config;
        mPrefs = mContext.getSharedPreferences("writech_sdk", Context.MODE_PRIVATE);

        Log.i(TAG, "=== 自然写SDK V" + SDK_VERSION + " 初始化开始 ===");
        Log.i(TAG, "云平台地址: " + config.cloudBaseUrl);
        Log.i(TAG, "AppKey: " + config.appKey.substring(0, 8) + "****");
        Log.i(TAG, "调试模式: " + config.debugMode);

        /* 步骤1:应用鉴权(验证AppKey和AppSecret */
        if (!authenticate(config.appKey, config.appSecret)) {
            Log.e(TAG, "SDK鉴权失败,请检查AppKey和AppSecret");
            return false;
        }

        /* 步骤2:初始化云平台客户端 */
        mCloudClient = new CloudClient(config.cloudBaseUrl, config.appKey, config.appSecret);

        /* 恢复本地缓存的令牌 */
        restoreTokens();

        /* 步骤3:初始化蓝牙笔管理器 */
        mPenManager = new PenManager(mContext);

        /* 步骤4:初始化OCR引擎 */
        mOCREngine = new OCREngine(mContext, config.cloudBaseUrl, null);
        if (config.offlineModelPath != null) {
            mOCREngine.loadOfflineModel(config.offlineModelPath);
        }

        /* 步骤5:初始化网关SDK */
        mGatewaySDK = new GatewaySDK(mContext);
        if (config.autoDiscoverGateway) {
            mGatewaySDK.startDiscovery();
        }

        Log.i(TAG, "=== 自然写SDK初始化完成 ===");
        return true;
    }

    /* ========== 应用鉴权 ========== */

    /**
     * 验证AppKey和AppSecret的有效性
     * 首次验证需要联网,之后缓存鉴权结果
     */
    private boolean authenticate(String appKey, String appSecret) {
        /* 检查本地缓存的鉴权结果 */
        String cachedLicense = mPrefs.getString("license_type", null);
        long cachedExpire = mPrefs.getLong("license_expire", 0);

        if (cachedLicense != null && cachedExpire > System.currentTimeMillis()) {
            mIsAuthenticated = true;
            mLicenseType = cachedLicense;
            mLicenseExpireTime = cachedExpire;
            Log.i(TAG, "使用缓存鉴权结果: " + mLicenseType
                  + ",到期: " + new java.util.Date(mLicenseExpireTime));
            return true;
        }

        /* 在线鉴权 */
        try {
            String authUrl = mConfig.cloudBaseUrl + "/api/v1/sdk/authenticate";
            String body = "{\"appKey\":\"" + appKey
                         + "\",\"appSecret\":\"" + appSecret
                         + "\",\"sdkVersion\":\"" + SDK_VERSION + "\"}";

            /* 使用CloudClient的静态方法发送无认证请求 */
            java.net.HttpURLConnection conn =
                (java.net.HttpURLConnection) new java.net.URL(authUrl).openConnection();
            conn.setRequestMethod("POST");
            conn.setRequestProperty("Content-Type", "application/json");
            conn.setDoOutput(true);
            conn.setConnectTimeout(10000);
            conn.getOutputStream().write(body.getBytes(java.nio.charset.StandardCharsets.UTF_8));

            int responseCode = conn.getResponseCode();
            if (responseCode == 200) {
                java.io.InputStream is = conn.getInputStream();
                java.io.ByteArrayOutputStream baos = new java.io.ByteArrayOutputStream();
                byte[] buf = new byte[1024];
                int len;
                while ((len = is.read(buf)) != -1) {
                    baos.write(buf, 0, len);
                }
                String response = baos.toString("UTF-8");
                is.close();
                conn.disconnect();

                /* 解析鉴权结果 */
                mLicenseType = extractJsonField(response, "licenseType");
                String expireStr = extractJsonField(response, "expireTime");
                if (mLicenseType != null) {
                    mLicenseExpireTime = expireStr != null ? Long.parseLong(expireStr)
                                        : System.currentTimeMillis() + 365L * 24 * 3600 * 1000;
                    mIsAuthenticated = true;

                    /* 缓存鉴权结果 */
                    mPrefs.edit()
                        .putString("license_type", mLicenseType)
                        .putLong("license_expire", mLicenseExpireTime)
                        .apply();

                    Log.i(TAG, "在线鉴权成功: " + mLicenseType);
                    return true;
                }
            }
            conn.disconnect();

        } catch (Exception e) {
            Log.w(TAG, "在线鉴权异常: " + e.getMessage());
            /* 联网失败时允许离线试用(7天) */
            mLicenseType = "trial";
            mLicenseExpireTime = System.currentTimeMillis() + 7L * 24 * 3600 * 1000;
            mIsAuthenticated = true;
            Log.i(TAG, "离线模式,试用授权7天");
            return true;
        }

        return false;
    }

    /** 恢复本地缓存的认证令牌 */
    private void restoreTokens() {
        String accessToken = mPrefs.getString("access_token", null);
        String refreshToken = mPrefs.getString("refresh_token", null);
        long expireTime = mPrefs.getLong("token_expire", 0);

        if (accessToken != null && refreshToken != null) {
            mCloudClient.setTokens(accessToken, refreshToken, expireTime);
            Log.d(TAG, "已恢复缓存的认证令牌");
        }
    }

    /* ========== 对外接口 ========== */

    /** 获取笔管理器 */
    public PenManager getPenManager() {
        return mPenManager;
    }

    /** 获取OCR引擎 */
    public OCREngine getOCREngine() {
        return mOCREngine;
    }

    /** 获取网关SDK */
    public GatewaySDK getGatewaySDK() {
        return mGatewaySDK;
    }

    /** 获取云平台客户端 */
    public CloudClient getCloudClient() {
        return mCloudClient;
    }

    /** 获取SDK版本 */
    public String getVersion() {
        return SDK_VERSION;
    }

    /** 获取授权类型 */
    public String getLicenseType() {
        return mLicenseType;
    }

    /** 检查是否已鉴权 */
    public boolean isAuthenticated() {
        return mIsAuthenticated;
    }

    /** 用户登录(通过云平台认证) */
    public boolean loginUser(String username, String password) {
        try {
            String response = mCloudClient.login(username, password);
            String accessToken = extractJsonField(response, "accessToken");
            String refreshToken = extractJsonField(response, "refreshToken");

            if (accessToken != null) {
                long expireTime = System.currentTimeMillis() + 30 * 60 * 1000;
                mCloudClient.setTokens(accessToken, refreshToken, expireTime);

                /* 缓存令牌 */
                mPrefs.edit()
                    .putString("access_token", accessToken)
                    .putString("refresh_token", refreshToken)
                    .putLong("token_expire", expireTime)
                    .apply();

                return true;
            }
        } catch (IOException e) {
            Log.e(TAG, "登录失败: " + e.getMessage());
        }
        return false;
    }

    /* ========== 资源释放 ========== */

    /** 释放SDK所有资源 */
    public static void destroy() {
        if (sInstance != null) {
            if (sInstance.mGatewaySDK != null) sInstance.mGatewaySDK.destroy();
            if (sInstance.mOCREngine != null) sInstance.mOCREngine.destroy();
            if (sInstance.mPenManager != null) sInstance.mPenManager.destroy();
            sInstance = null;
        }
        sInitialized.set(false);
        Log.i(TAG, "WritechSDK已释放所有资源");
    }

    /** 从JSON提取字段值 */
    private String extractJsonField(String json, String key) {
        if (json == null) return null;
        String search = "\"" + key + "\"";
        int idx = json.indexOf(search);
        if (idx < 0) return null;
        int start = json.indexOf("\"", idx + search.length() + 1) + 1;
        int end = json.indexOf("\"", start);
        return (start > 0 && end > start) ? json.substring(start, end) : null;
    }
}

core/

core/ble_protocol.c

/**
 * 自然写互动课堂应用开发SDK软件 V1.0
 * BLE协议解析核心模块 - 蓝牙5.0点阵笔通信协议实现
 *
 * 跨平台C语言核心库,负责解析点阵笔BLE GATT数据
 * 提供笔迹坐标解包、协议帧校验、数据压缩解压等底层能力
 * 通过JNI/ObjC Bridge/FFI供各平台SDK调用
 */

#ifndef BLE_PROTOCOL_H
#define BLE_PROTOCOL_H

#include <stdint.h>
#include <stddef.h>
#include <string.h>

#ifdef __cplusplus
extern "C" {
#endif

/* ==================== 协议常量定义 ==================== */

/* BLE GATT Service UUID(自定义服务) */
#define WRITECH_SERVICE_UUID        "0000FFE0-0000-1000-8000-00805F9B34FB"
/* 笔迹数据Characteristic UUID */
#define STROKE_DATA_CHAR_UUID       "0000FFE1-0000-1000-8000-00805F9B34FB"
/* 设备信息Characteristic UUID */
#define DEVICE_INFO_CHAR_UUID       "0000FFE2-0000-1000-8000-00805F9B34FB"
/* 配置写入Characteristic UUID */
#define CONFIG_WRITE_CHAR_UUID      "0000FFE3-0000-1000-8000-00805F9B34FB"
/* OTA DFU Characteristic UUID */
#define OTA_DFU_CHAR_UUID           "0000FFE4-0000-1000-8000-00805F9B34FB"

/* 协议帧标志 */
#define FRAME_HEADER_MAGIC          0xAA55
#define FRAME_MAX_PAYLOAD_SIZE      240     /* MTU=247, 减去帧头7字节 */
#define MAX_POINTS_PER_FRAME        34      /* 每帧最多34个坐标点 */

/* 帧类型定义 */
#define FRAME_TYPE_STROKE_DATA      0x01    /* 笔迹坐标数据 */
#define FRAME_TYPE_PEN_UP           0x02    /* 抬笔事件 */
#define FRAME_TYPE_PEN_DOWN         0x03    /* 落笔事件 */
#define FRAME_TYPE_DEVICE_STATUS    0x04    /* 设备状态(电量等) */
#define FRAME_TYPE_OFFLINE_SYNC     0x05    /* 离线数据同步 */
#define FRAME_TYPE_OTA_DATA         0x06    /* OTA升级数据 */
#define FRAME_TYPE_CONFIG_RSP       0x07    /* 配置响应 */

/* ==================== 数据结构定义 ==================== */

/**
 * 原始笔迹坐标点(7字节紧凑编码)
 * x: 16位无符号整数,点阵坐标X(分辨率约300DPI)
 * y: 16位无符号整数,点阵坐标Y
 * pressure: 8位无符号整数,压力值(0-255)
 * timestamp_delta: 16位无符号整数,距上一点的时间差(毫秒)
 */
typedef struct {
    uint16_t x;                 /* X坐标(大端序) */
    uint16_t y;                 /* Y坐标(大端序) */
    uint8_t  pressure;          /* 压力值 0-255 */
    uint16_t timestamp_delta;   /* 时间增量(毫秒) */
} __attribute__((packed)) StrokePointRaw;

/**
 * 解码后的笔迹坐标点
 */
typedef struct {
    float x;                    /* X坐标(浮点) */
    float y;                    /* Y坐标(浮点) */
    float pressure;             /* 压力值 0.0-1.0 */
    uint32_t timestamp;         /* 绝对时间戳(毫秒) */
    uint8_t pen_state;          /* 0=落笔, 1=抬笔 */
} StrokePoint;

/**
 * BLE协议帧头(7字节)
 */
typedef struct {
    uint16_t magic;             /* 帧头魔数 0xAA55 */
    uint8_t  frame_type;        /* 帧类型 */
    uint8_t  sequence;          /* 帧序号(0-255循环) */
    uint16_t payload_length;    /* 负载长度 */
    uint8_t  checksum;          /* 帧头校验和(XOR) */
} __attribute__((packed)) FrameHeader;

/**
 * 笔迹数据帧
 */
typedef struct {
    FrameHeader header;
    uint8_t point_count;        /* 本帧包含的坐标点数 */
    uint32_t page_id;           /* 点阵码页面ID */
    StrokePointRaw points[MAX_POINTS_PER_FRAME];  /* 坐标点数组 */
    uint16_t crc16;             /* CRC-16校验 */
} __attribute__((packed)) StrokeDataFrame;

/**
 * 设备状态帧
 */
typedef struct {
    FrameHeader header;
    uint8_t  battery_level;     /* 电量百分比 0-100 */
    uint8_t  charging_state;    /* 充电状态: 0=未充电, 1=充电中, 2=已充满 */
    uint16_t firmware_version;  /* 固件版本 (major*256+minor) */
    uint8_t  connection_state;  /* 连接状态 */
    uint32_t serial_number;     /* 设备序列号 */
    uint16_t crc16;
} __attribute__((packed)) DeviceStatusFrame;

/**
 * 解析回调函数类型定义
 */
typedef void (*on_stroke_point_cb)(const StrokePoint* point, void* user_data);
typedef void (*on_pen_event_cb)(uint8_t event_type, uint32_t timestamp, void* user_data);
typedef void (*on_device_status_cb)(uint8_t battery, uint8_t charging, uint16_t fw_ver, void* user_data);

/* ==================== 协议解析器 ==================== */

/**
 * BLE协议解析器上下文
 */
typedef struct {
    /* 接收缓冲区(处理分包/粘包) */
    uint8_t recv_buffer[512];
    size_t  recv_length;
    
    /* 序号跟踪(乱序检测) */
    uint8_t expected_sequence;
    
    /* 时间戳基准 */
    uint32_t base_timestamp;
    uint32_t last_timestamp;
    
    /* 统计信息 */
    uint32_t total_frames;
    uint32_t total_points;
    uint32_t error_frames;
    uint32_t lost_frames;
    
    /* 回调函数 */
    on_stroke_point_cb stroke_cb;
    on_pen_event_cb pen_event_cb;
    on_device_status_cb status_cb;
    void* user_data;
} BleProtocolParser;

/**
 * 初始化协议解析器
 */
static inline void ble_parser_init(BleProtocolParser* parser) {
    memset(parser, 0, sizeof(BleProtocolParser));
    parser->expected_sequence = 0;
    parser->base_timestamp = 0;
}

/**
 * 设置回调函数
 */
static inline void ble_parser_set_callbacks(
    BleProtocolParser* parser,
    on_stroke_point_cb stroke_cb,
    on_pen_event_cb pen_event_cb,
    on_device_status_cb status_cb,
    void* user_data
) {
    parser->stroke_cb = stroke_cb;
    parser->pen_event_cb = pen_event_cb;
    parser->status_cb = status_cb;
    parser->user_data = user_data;
}

/**
 * 计算CRC-16校验值(CCITT标准)
 */
static uint16_t calc_crc16(const uint8_t* data, size_t length) {
    uint16_t crc = 0xFFFF;
    for (size_t i = 0; i < length; i++) {
        crc ^= (uint16_t)data[i] << 8;
        for (int j = 0; j < 8; j++) {
            if (crc & 0x8000)
                crc = (crc << 1) ^ 0x1021;
            else
                crc <<= 1;
        }
    }
    return crc;
}

/**
 * 校验帧头
 */
static int validate_frame_header(const FrameHeader* header) {
    /* 校验魔数 */
    if (header->magic != FRAME_HEADER_MAGIC) return -1;
    /* 校验负载长度 */
    if (header->payload_length > FRAME_MAX_PAYLOAD_SIZE) return -2;
    /* 校验帧头XOR校验和 */
    uint8_t xor_sum = 0;
    const uint8_t* p = (const uint8_t*)header;
    for (int i = 0; i < 6; i++) xor_sum ^= p[i];
    if (xor_sum != header->checksum) return -3;
    return 0;
}

/**
 * 大端序转小端序(16位)
 */
static inline uint16_t be16_to_le(uint16_t value) {
    return (value >> 8) | (value << 8);
}

/**
 * 解析笔迹数据帧
 * 从帧中提取坐标点并通过回调函数输出
 */
static int parse_stroke_frame(BleProtocolParser* parser, const uint8_t* data, size_t length) {
    if (length < sizeof(FrameHeader) + 5) return -1;

    const FrameHeader* header = (const FrameHeader*)data;
    
    /* 帧头校验 */
    if (validate_frame_header(header) != 0) {
        parser->error_frames++;
        return -1;
    }

    /* 序号连续性检查 */
    if (header->sequence != parser->expected_sequence) {
        uint8_t lost = header->sequence - parser->expected_sequence;
        parser->lost_frames += lost;
    }
    parser->expected_sequence = header->sequence + 1;

    /* 解析负载 */
    const uint8_t* payload = data + sizeof(FrameHeader);
    uint8_t point_count = payload[0];
    uint32_t page_id = *(uint32_t*)(payload + 1);

    if (point_count > MAX_POINTS_PER_FRAME) {
        parser->error_frames++;
        return -2;
    }

    /* CRC校验(校验帧头+负载) */
    size_t crc_data_len = length - 2;
    uint16_t expected_crc = *(uint16_t*)(data + crc_data_len);
    uint16_t actual_crc = calc_crc16(data, crc_data_len);
    if (expected_crc != actual_crc) {
        parser->error_frames++;
        return -3;
    }

    /* 解析每个坐标点 */
    const StrokePointRaw* raw_points = (const StrokePointRaw*)(payload + 5);
    for (int i = 0; i < point_count; i++) {
        StrokePoint decoded;
        decoded.x = (float)be16_to_le(raw_points[i].x);
        decoded.y = (float)be16_to_le(raw_points[i].y);
        decoded.pressure = raw_points[i].pressure / 255.0f;
        
        /* 累加时间增量得到绝对时间戳 */
        uint16_t delta = be16_to_le(raw_points[i].timestamp_delta);
        parser->last_timestamp += delta;
        decoded.timestamp = parser->base_timestamp + parser->last_timestamp;
        decoded.pen_state = 0; /* 落笔状态 */

        /* 通过回调函数输出 */
        if (parser->stroke_cb) {
            parser->stroke_cb(&decoded, parser->user_data);
        }
        parser->total_points++;
    }

    parser->total_frames++;
    return point_count;
}

/**
 * 输入BLE Notify接收到的数据
 * 处理分包/粘包,自动检测帧边界并分发解析
 */
static int ble_parser_feed(BleProtocolParser* parser, const uint8_t* data, size_t length) {
    /* 追加到接收缓冲区 */
    if (parser->recv_length + length > sizeof(parser->recv_buffer)) {
        /* 缓冲区溢出,丢弃旧数据 */
        parser->recv_length = 0;
    }
    memcpy(parser->recv_buffer + parser->recv_length, data, length);
    parser->recv_length += length;

    int parsed_count = 0;

    /* 扫描缓冲区查找完整帧 */
    while (parser->recv_length >= sizeof(FrameHeader)) {
        /* 查找帧头魔数 */
        if (parser->recv_buffer[0] != 0xAA || parser->recv_buffer[1] != 0x55) {
            /* 跳过非法字节 */
            memmove(parser->recv_buffer, parser->recv_buffer + 1, parser->recv_length - 1);
            parser->recv_length--;
            continue;
        }

        FrameHeader* header = (FrameHeader*)parser->recv_buffer;
        size_t frame_size = sizeof(FrameHeader) + header->payload_length + 2; /* +2 for CRC */

        if (parser->recv_length < frame_size) {
            break; /* 帧数据不完整,等待更多数据 */
        }

        /* 根据帧类型分发解析 */
        switch (header->frame_type) {
            case FRAME_TYPE_STROKE_DATA:
                parse_stroke_frame(parser, parser->recv_buffer, frame_size);
                parsed_count++;
                break;
            case FRAME_TYPE_PEN_UP:
                if (parser->pen_event_cb) {
                    parser->pen_event_cb(1, parser->last_timestamp, parser->user_data);
                }
                break;
            case FRAME_TYPE_PEN_DOWN:
                if (parser->pen_event_cb) {
                    parser->pen_event_cb(0, parser->last_timestamp, parser->user_data);
                }
                break;
            case FRAME_TYPE_DEVICE_STATUS: {
                DeviceStatusFrame* status = (DeviceStatusFrame*)parser->recv_buffer;
                if (parser->status_cb) {
                    parser->status_cb(status->battery_level, status->charging_state,
                                      status->firmware_version, parser->user_data);
                }
                break;
            }
            default:
                break;
        }

        /* 移除已处理的帧 */
        memmove(parser->recv_buffer, parser->recv_buffer + frame_size,
                parser->recv_length - frame_size);
        parser->recv_length -= frame_size;
    }

    return parsed_count;
}

/**
 * 获取解析器统计信息
 */
static inline void ble_parser_get_stats(const BleProtocolParser* parser,
    uint32_t* total_frames, uint32_t* total_points,
    uint32_t* error_frames, uint32_t* lost_frames) {
    if (total_frames) *total_frames = parser->total_frames;
    if (total_points) *total_points = parser->total_points;
    if (error_frames) *error_frames = parser->error_frames;
    if (lost_frames)  *lost_frames  = parser->lost_frames;
}

/**
 * 重置解析器状态
 */
static inline void ble_parser_reset(BleProtocolParser* parser) {
    parser->recv_length = 0;
    parser->expected_sequence = 0;
    parser->last_timestamp = 0;
    parser->total_frames = 0;
    parser->total_points = 0;
    parser->error_frames = 0;
    parser->lost_frames = 0;
}

#ifdef __cplusplus
}
#endif

#endif /* BLE_PROTOCOL_H */

core/coordinate_transform.c

/*
 * 自然写互动课堂应用开发SDK软件 V1.0
 * 坐标变换模块 - 点阵笔坐标到屏幕坐标的高精度映射
 *
 * 功能说明:
 * 1. 点阵码坐标解析与标准化(Anoto编码 → 物理坐标mm)
 * 2. 仿射变换矩阵计算(四角标定点 → 变换参数)
 * 3. 物理坐标到屏幕像素坐标的实时映射
 * 4. 多页面坐标空间管理(不同纸张/不同页面独立坐标系)
 * 5. 畸变校正(镜头畸变、纸张弯曲补偿)
 */

#include <math.h>
#include <string.h>
#include <stdlib.h>
#include <stdio.h>

/* ========== 数据结构定义 ========== */

/* 二维点(浮点精度) */
typedef struct {
    double x;      /* X坐标 */
    double y;      /* Y坐标 */
} Point2D;

/* 仿射变换矩阵 3x3(齐次坐标) */
typedef struct {
    double m[3][3];    /* 变换矩阵元素 */
} AffineMatrix;

/* 坐标空间描述 */
typedef struct {
    unsigned int page_id;          /* 页面唯一ID */
    unsigned int section_id;       /* 区段IDAnoto编码中的section */
    unsigned int owner_id;         /* 拥有者IDAnoto编码) */
    double physical_width_mm;      /* 纸张物理宽度(毫米) */
    double physical_height_mm;     /* 纸张物理高度(毫米) */
    int screen_width_px;           /* 对应屏幕区域宽度(像素) */
    int screen_height_px;          /* 对应屏幕区域高度(像素) */
    AffineMatrix transform;        /* 标定后的变换矩阵 */
    int is_calibrated;             /* 是否已完成标定 */
} CoordinateSpace;

/* 标定点对(物理坐标 ↔ 屏幕坐标) */
typedef struct {
    Point2D physical;      /* 物理坐标(mm */
    Point2D screen;        /* 屏幕坐标(px */
} CalibrationPair;

/* 畸变校正参数(Brown-Conrady模型简化版) */
typedef struct {
    double k1;             /* 径向畸变系数1 */
    double k2;             /* 径向畸变系数2 */
    double p1;             /* 切向畸变系数1 */
    double p2;             /* 切向畸变系数2 */
    double cx;             /* 畸变中心X */
    double cy;             /* 畸变中心Y */
} DistortionParams;

/* 坐标变换管理器 */
typedef struct {
    CoordinateSpace spaces[64];      /* 最多支持64个坐标空间 */
    int space_count;                 /* 当前已注册的空间数 */
    DistortionParams distortion;     /* 全局畸变校正参数 */
    int distortion_enabled;          /* 是否启用畸变校正 */
    double dpi_resolution;           /* 点阵笔DPI分辨率(通常为300或600) */
} CoordinateManager;

/* 全局坐标管理器实例 */
static CoordinateManager g_coord_manager;

/* ========== Anoto点阵码坐标解析 ========== */

/*
 * 将Anoto点阵码原始编码转换为物理坐标(毫米)
 * 点阵笔采集到的原始数据是基于Anoto编码系统的逻辑坐标
 * 需要根据DPI分辨率转换为实际的物理距离
 *
 * @param raw_x        点阵码原始X坐标值
 * @param raw_y        点阵码原始Y坐标值
 * @param section_id   Anoto编码的section标识
 * @param out_physical  输出的物理坐标(mm
 * @return 0成功, -1参数错误
 */
int anoto_to_physical(unsigned int raw_x, unsigned int raw_y,
                       unsigned int section_id, Point2D *out_physical) {
    if (out_physical == NULL) {
        return -1;
    }

    /* DPI到毫米的转换因子:25.4mm / DPI */
    double dpi = g_coord_manager.dpi_resolution;
    if (dpi < 1.0) {
        dpi = 300.0;   /* 默认300 DPI */
    }
    double dots_to_mm = 25.4 / dpi;

    /* Anoto编码的原始坐标直接乘以转换因子得到物理坐标 */
    out_physical->x = (double)raw_x * dots_to_mm;
    out_physical->y = (double)raw_y * dots_to_mm;

    return 0;
}

/*
 * 解析7字节紧凑坐标编码
 * 点阵笔通过BLE传输时使用7字节紧凑格式:
 *   字节0-1: X坐标高16位
 *   字节2-3: Y坐标高16位
 *   字节4:   X低4位 | Y低4位
 *   字节5:   压力值高8位
 *   字节6:   压力值低8位 | 标志位
 */
int decode_compact_coordinate(const unsigned char *data, int data_len,
                               unsigned int *out_x, unsigned int *out_y,
                               unsigned int *out_pressure) {
    if (data == NULL || data_len < 7) {
        return -1;
    }

    /* 解析X坐标(20位精度) */
    unsigned int x_high = ((unsigned int)data[0] << 8) | data[1];
    unsigned int x_low = (data[4] >> 4) & 0x0F;
    *out_x = (x_high << 4) | x_low;

    /* 解析Y坐标(20位精度) */
    unsigned int y_high = ((unsigned int)data[2] << 8) | data[3];
    unsigned int y_low = data[4] & 0x0F;
    *out_y = (y_high << 4) | y_low;

    /* 解析压力值(12位精度,0-4095) */
    unsigned int p_high = data[5];
    unsigned int p_low = (data[6] >> 4) & 0x0F;
    *out_pressure = (p_high << 4) | p_low;

    return 0;
}

/* ========== 仿射变换矩阵计算 ========== */

/*
 * 初始化为单位矩阵
 */
void matrix_identity(AffineMatrix *mat) {
    memset(mat->m, 0, sizeof(mat->m));
    mat->m[0][0] = 1.0;
    mat->m[1][1] = 1.0;
    mat->m[2][2] = 1.0;
}

/*
 * 矩阵乘法 result = a * b
 */
void matrix_multiply(const AffineMatrix *a, const AffineMatrix *b,
                      AffineMatrix *result) {
    AffineMatrix tmp;
    int i, j, k;
    for (i = 0; i < 3; i++) {
        for (j = 0; j < 3; j++) {
            tmp.m[i][j] = 0.0;
            for (k = 0; k < 3; k++) {
                tmp.m[i][j] += a->m[i][k] * b->m[k][j];
            }
        }
    }
    memcpy(result->m, tmp.m, sizeof(tmp.m));
}

/*
 * 使用最小二乘法从标定点对计算仿射变换矩阵
 * 至少需要3个不共线的标定点对
 * 使用正规方程法求解超定线性方程组
 *
 * @param pairs       标定点对数组
 * @param pair_count  标定点对数量(≥3
 * @param out_matrix  输出的仿射变换矩阵
 * @return 0成功, -1参数不足, -2矩阵奇异
 */
int compute_affine_transform(const CalibrationPair *pairs, int pair_count,
                              AffineMatrix *out_matrix) {
    if (pairs == NULL || pair_count < 3 || out_matrix == NULL) {
        return -1;
    }

    /*
     * 仿射变换方程:
     * screen_x = a11 * phys_x + a12 * phys_y + a13
     * screen_y = a21 * phys_x + a22 * phys_y + a23
     *
     * 构建 ATA * x = ATb 正规方程
     * A矩阵每行: [phys_x, phys_y, 1]
     */
    double ATA[3][3] = {{0}};
    double ATb_x[3] = {0};
    double ATb_y[3] = {0};

    int i;
    for (i = 0; i < pair_count; i++) {
        double px = pairs[i].physical.x;
        double py = pairs[i].physical.y;
        double sx = pairs[i].screen.x;
        double sy = pairs[i].screen.y;

        /* 累加 ATA */
        ATA[0][0] += px * px;
        ATA[0][1] += px * py;
        ATA[0][2] += px;
        ATA[1][0] += py * px;
        ATA[1][1] += py * py;
        ATA[1][2] += py;
        ATA[2][0] += px;
        ATA[2][1] += py;
        ATA[2][2] += 1.0;

        /* 累加 ATb */
        ATb_x[0] += px * sx;
        ATb_x[1] += py * sx;
        ATb_x[2] += sx;

        ATb_y[0] += px * sy;
        ATb_y[1] += py * sy;
        ATb_y[2] += sy;
    }

    /* 高斯消元法求解3x3线性方程组 */
    /* 先求解 screen_x 的系数 [a11, a12, a13] */
    double aug_x[3][4];
    double aug_y[3][4];
    int j, k;
    for (i = 0; i < 3; i++) {
        for (j = 0; j < 3; j++) {
            aug_x[i][j] = ATA[i][j];
            aug_y[i][j] = ATA[i][j];
        }
        aug_x[i][3] = ATb_x[i];
        aug_y[i][3] = ATb_y[i];
    }

    /* 高斯消元(部分主元选取) */
    for (k = 0; k < 3; k++) {
        /* 找主元 */
        int max_row = k;
        double max_val = fabs(aug_x[k][k]);
        for (i = k + 1; i < 3; i++) {
            if (fabs(aug_x[i][k]) > max_val) {
                max_val = fabs(aug_x[i][k]);
                max_row = i;
            }
        }
        if (max_val < 1e-12) {
            return -2;   /* 矩阵奇异,标定点可能共线 */
        }
        /* 交换行 */
        if (max_row != k) {
            for (j = 0; j < 4; j++) {
                double tmp = aug_x[k][j];
                aug_x[k][j] = aug_x[max_row][j];
                aug_x[max_row][j] = tmp;
                tmp = aug_y[k][j];
                aug_y[k][j] = aug_y[max_row][j];
                aug_y[max_row][j] = tmp;
            }
        }
        /* 消元 */
        for (i = k + 1; i < 3; i++) {
            double factor_x = aug_x[i][k] / aug_x[k][k];
            double factor_y = aug_y[i][k] / aug_y[k][k];
            for (j = k; j < 4; j++) {
                aug_x[i][j] -= factor_x * aug_x[k][j];
                aug_y[i][j] -= factor_y * aug_y[k][j];
            }
        }
    }

    /* 回代求解 */
    double sol_x[3], sol_y[3];
    for (i = 2; i >= 0; i--) {
        sol_x[i] = aug_x[i][3];
        sol_y[i] = aug_y[i][3];
        for (j = i + 1; j < 3; j++) {
            sol_x[i] -= aug_x[i][j] * sol_x[j];
            sol_y[i] -= aug_y[i][j] * sol_y[j];
        }
        sol_x[i] /= aug_x[i][i];
        sol_y[i] /= aug_y[i][i];
    }

    /* 填充仿射变换矩阵 */
    out_matrix->m[0][0] = sol_x[0];   /* a11 */
    out_matrix->m[0][1] = sol_x[1];   /* a12 */
    out_matrix->m[0][2] = sol_x[2];   /* a13(平移X */
    out_matrix->m[1][0] = sol_y[0];   /* a21 */
    out_matrix->m[1][1] = sol_y[1];   /* a22 */
    out_matrix->m[1][2] = sol_y[2];   /* a23(平移Y */
    out_matrix->m[2][0] = 0.0;
    out_matrix->m[2][1] = 0.0;
    out_matrix->m[2][2] = 1.0;

    return 0;
}

/* ========== 坐标空间管理 ========== */

/*
 * 初始化坐标变换管理器
 * @param dpi  点阵笔的DPI分辨率(常见值:300, 600
 */
void coordinate_manager_init(double dpi) {
    memset(&g_coord_manager, 0, sizeof(g_coord_manager));
    g_coord_manager.dpi_resolution = dpi;
    g_coord_manager.distortion_enabled = 0;
}

/*
 * 注册一个新的坐标空间(对应一个页面/纸张)
 * 在使用特定页面前需先注册其坐标空间参数
 *
 * @param page_id     页面唯一标识
 * @param section_id  Anoto section编号
 * @param width_mm    纸张物理宽度
 * @param height_mm   纸张物理高度
 * @param screen_w    对应屏幕宽度像素
 * @param screen_h    对应屏幕高度像素
 * @return 空间索引, -1失败
 */
int register_coordinate_space(unsigned int page_id, unsigned int section_id,
                               double width_mm, double height_mm,
                               int screen_w, int screen_h) {
    if (g_coord_manager.space_count >= 64) {
        return -1;   /* 空间已满 */
    }

    int idx = g_coord_manager.space_count;
    CoordinateSpace *space = &g_coord_manager.spaces[idx];
    space->page_id = page_id;
    space->section_id = section_id;
    space->physical_width_mm = width_mm;
    space->physical_height_mm = height_mm;
    space->screen_width_px = screen_w;
    space->screen_height_px = screen_h;
    space->is_calibrated = 0;
    matrix_identity(&space->transform);

    g_coord_manager.space_count++;
    return idx;
}

/*
 * 对指定坐标空间执行标定
 * 使用用户提供的标定点对计算仿射变换矩阵
 */
int calibrate_space(int space_index, const CalibrationPair *pairs,
                     int pair_count) {
    if (space_index < 0 || space_index >= g_coord_manager.space_count) {
        return -1;
    }

    CoordinateSpace *space = &g_coord_manager.spaces[space_index];
    int ret = compute_affine_transform(pairs, pair_count, &space->transform);
    if (ret == 0) {
        space->is_calibrated = 1;
    }
    return ret;
}

/*
 * 使用默认缩放(无旋转无畸变)进行快速标定
 * 适用于标准A4纸张等无需精确标定的场景
 */
int calibrate_space_default(int space_index) {
    if (space_index < 0 || space_index >= g_coord_manager.space_count) {
        return -1;
    }

    CoordinateSpace *space = &g_coord_manager.spaces[space_index];
    matrix_identity(&space->transform);

    /* 简单线性缩放:物理mm → 屏幕px */
    double scale_x = (double)space->screen_width_px / space->physical_width_mm;
    double scale_y = (double)space->screen_height_px / space->physical_height_mm;

    space->transform.m[0][0] = scale_x;
    space->transform.m[1][1] = scale_y;
    space->is_calibrated = 1;

    return 0;
}

/* ========== 畸变校正 ========== */

/*
 * 设置畸变校正参数
 * 用于补偿摄像头镜头的径向和切向畸变
 */
void set_distortion_params(double k1, double k2, double p1, double p2,
                            double cx, double cy) {
    g_coord_manager.distortion.k1 = k1;
    g_coord_manager.distortion.k2 = k2;
    g_coord_manager.distortion.p1 = p1;
    g_coord_manager.distortion.p2 = p2;
    g_coord_manager.distortion.cx = cx;
    g_coord_manager.distortion.cy = cy;
    g_coord_manager.distortion_enabled = 1;
}

/*
 * 对物理坐标应用畸变校正(去畸变)
 * 使用Brown-Conrady模型的简化版本
 *
 * @param in    输入的物理坐标
 * @param out   校正后的物理坐标
 */
void apply_distortion_correction(const Point2D *in, Point2D *out) {
    if (!g_coord_manager.distortion_enabled) {
        out->x = in->x;
        out->y = in->y;
        return;
    }

    DistortionParams *d = &g_coord_manager.distortion;

    /* 以畸变中心为原点 */
    double dx = in->x - d->cx;
    double dy = in->y - d->cy;
    double r2 = dx * dx + dy * dy;
    double r4 = r2 * r2;

    /* 径向畸变校正 */
    double radial = 1.0 + d->k1 * r2 + d->k2 * r4;

    /* 切向畸变校正 */
    double tang_x = 2.0 * d->p1 * dx * dy + d->p2 * (r2 + 2.0 * dx * dx);
    double tang_y = d->p1 * (r2 + 2.0 * dy * dy) + 2.0 * d->p2 * dx * dy;

    out->x = d->cx + dx * radial + tang_x;
    out->y = d->cy + dy * radial + tang_y;
}

/* ========== 坐标变换核心接口 ========== */

/*
 * 根据page_id查找对应的坐标空间索引
 */
int find_space_by_page(unsigned int page_id) {
    int i;
    for (i = 0; i < g_coord_manager.space_count; i++) {
        if (g_coord_manager.spaces[i].page_id == page_id) {
            return i;
        }
    }
    return -1;
}

/*
 * 完整坐标变换流水线:原始点阵码坐标 → 屏幕像素坐标
 *
 * 处理步骤:
 * 1. Anoto编码 → 物理坐标(mm
 * 2. 畸变校正(如果启用)
 * 3. 仿射变换 → 屏幕坐标(px)
 * 4. 边界裁剪(确保不超出屏幕范围)
 *
 * @param raw_x       原始X坐标
 * @param raw_y       原始Y坐标
 * @param page_id     页面ID
 * @param out_screen  输出屏幕坐标
 * @return 0成功, -1未找到坐标空间, -2未标定
 */
int transform_coordinate(unsigned int raw_x, unsigned int raw_y,
                          unsigned int page_id, Point2D *out_screen) {
    if (out_screen == NULL) {
        return -1;
    }

    /* 查找坐标空间 */
    int idx = find_space_by_page(page_id);
    if (idx < 0) {
        return -1;
    }

    CoordinateSpace *space = &g_coord_manager.spaces[idx];
    if (!space->is_calibrated) {
        return -2;
    }

    /* 步骤1:原始坐标 → 物理坐标 */
    Point2D physical;
    anoto_to_physical(raw_x, raw_y, space->section_id, &physical);

    /* 步骤2:畸变校正 */
    Point2D corrected;
    apply_distortion_correction(&physical, &corrected);

    /* 步骤3:仿射变换 → 屏幕坐标 */
    AffineMatrix *mat = &space->transform;
    out_screen->x = mat->m[0][0] * corrected.x
                   + mat->m[0][1] * corrected.y
                   + mat->m[0][2];
    out_screen->y = mat->m[1][0] * corrected.x
                   + mat->m[1][1] * corrected.y
                   + mat->m[1][2];

    /* 步骤4:边界裁剪 */
    if (out_screen->x < 0.0) out_screen->x = 0.0;
    if (out_screen->y < 0.0) out_screen->y = 0.0;
    if (out_screen->x > (double)space->screen_width_px) {
        out_screen->x = (double)space->screen_width_px;
    }
    if (out_screen->y > (double)space->screen_height_px) {
        out_screen->y = (double)space->screen_height_px;
    }

    return 0;
}

/*
 * 批量坐标变换(优化版,避免重复查找坐标空间)
 * 适用于一次性转换整条笔画的所有采样点
 *
 * @param raw_points   原始坐标数组,每组2个unsigned int (x, y)
 * @param point_count  坐标点数量
 * @param page_id      页面ID
 * @param out_screen   输出屏幕坐标数组(调用者负责分配内存)
 * @return 成功转换的点数
 */
int transform_batch(const unsigned int *raw_points, int point_count,
                     unsigned int page_id, Point2D *out_screen) {
    int idx = find_space_by_page(page_id);
    if (idx < 0 || out_screen == NULL) {
        return 0;
    }

    CoordinateSpace *space = &g_coord_manager.spaces[idx];
    if (!space->is_calibrated) {
        return 0;
    }

    double dpi = g_coord_manager.dpi_resolution;
    if (dpi < 1.0) dpi = 300.0;
    double dots_to_mm = 25.4 / dpi;

    AffineMatrix *mat = &space->transform;
    int converted = 0;
    int i;

    for (i = 0; i < point_count; i++) {
        /* 直接内联计算,减少函数调用开销 */
        double px = (double)raw_points[i * 2] * dots_to_mm;
        double py = (double)raw_points[i * 2 + 1] * dots_to_mm;

        /* 畸变校正(内联) */
        if (g_coord_manager.distortion_enabled) {
            DistortionParams *d = &g_coord_manager.distortion;
            double dx = px - d->cx;
            double dy = py - d->cy;
            double r2 = dx * dx + dy * dy;
            double radial = 1.0 + d->k1 * r2 + d->k2 * r2 * r2;
            px = d->cx + dx * radial + 2.0 * d->p1 * dx * dy
                 + d->p2 * (r2 + 2.0 * dx * dx);
            py = d->cy + dy * radial + d->p1 * (r2 + 2.0 * dy * dy)
                 + 2.0 * d->p2 * dx * dy;
        }

        /* 仿射变换 */
        double sx = mat->m[0][0] * px + mat->m[0][1] * py + mat->m[0][2];
        double sy = mat->m[1][0] * px + mat->m[1][1] * py + mat->m[1][2];

        /* 边界裁剪 */
        if (sx < 0.0) sx = 0.0;
        if (sy < 0.0) sy = 0.0;
        if (sx > (double)space->screen_width_px) sx = (double)space->screen_width_px;
        if (sy > (double)space->screen_height_px) sy = (double)space->screen_height_px;

        out_screen[i].x = sx;
        out_screen[i].y = sy;
        converted++;
    }

    return converted;
}

/*
 * 反向变换:屏幕坐标 → 物理坐标
 * 用于在屏幕上点击后反推纸面物理位置
 * 需要计算仿射变换矩阵的逆矩阵
 */
int inverse_transform(double screen_x, double screen_y,
                       unsigned int page_id, Point2D *out_physical) {
    int idx = find_space_by_page(page_id);
    if (idx < 0 || out_physical == NULL) {
        return -1;
    }

    CoordinateSpace *space = &g_coord_manager.spaces[idx];
    AffineMatrix *mat = &space->transform;

    /* 计算2x2子矩阵的行列式 */
    double det = mat->m[0][0] * mat->m[1][1] - mat->m[0][1] * mat->m[1][0];
    if (fabs(det) < 1e-12) {
        return -2;   /* 矩阵不可逆 */
    }

    double inv_det = 1.0 / det;

    /* 减去平移分量 */
    double tx = screen_x - mat->m[0][2];
    double ty = screen_y - mat->m[1][2];

    /* 应用逆矩阵 */
    out_physical->x = inv_det * (mat->m[1][1] * tx - mat->m[0][1] * ty);
    out_physical->y = inv_det * (mat->m[0][0] * ty - mat->m[1][0] * tx);

    return 0;
}

core/stroke_smoother.c

/**
 * 自然写互动课堂应用开发SDK软件 V1.0
 * 笔迹平滑算法核心模块 - 笔迹坐标平滑与笔锋渲染
 *
 * 跨平台C语言核心库
 * 提供贝塞尔曲线平滑、笔锋宽度计算、坐标插值等算法
 * 确保各平台SDK输出一致的笔迹渲染效果
 */

#ifndef STROKE_SMOOTHER_H
#define STROKE_SMOOTHER_H

#include <stdint.h>
#include <stddef.h>
#include <math.h>

#ifdef __cplusplus
extern "C" {
#endif

/* ==================== 常量定义 ==================== */

#define MAX_SMOOTH_POINTS   4096    /* 平滑输出点缓冲区大小 */
#define MIN_POINT_DISTANCE  0.5f    /* 最小点间距(低于此值合并) */
#define BEZIER_SEGMENTS     8       /* 贝塞尔曲线分段数 */
#define PRESSURE_SMOOTH_FACTOR 0.3f /* 压力平滑因子 */

/* ==================== 数据结构 ==================== */

/** 二维浮点坐标点 */
typedef struct {
    float x;
    float y;
} Vec2f;

/** 带压力和时间戳的笔迹点 */
typedef struct {
    float x;
    float y;
    float pressure;     /* 0.0-1.0 */
    float width;        /* 计算后的笔画宽度 */
    uint32_t timestamp; /* 时间戳 */
} SmoothPoint;

/** 笔迹平滑器上下文 */
typedef struct {
    /* 输入点缓冲区(最近4个点,用于三次贝塞尔) */
    SmoothPoint input_buffer[4];
    int buffer_count;

    /* 输出点缓冲区 */
    SmoothPoint output_buffer[MAX_SMOOTH_POINTS];
    int output_count;

    /* 笔画宽度配置 */
    float min_width;        /* 最小笔画宽度 */
    float max_width;        /* 最大笔画宽度 */
    float velocity_scale;   /* 速度对宽度的影响系数 */

    /* 上一点的平滑压力值 */
    float last_smooth_pressure;

    /* 统计信息 */
    uint32_t total_input_points;
    uint32_t total_output_points;
} StrokeSmoother;

/* ==================== 数学工具函数 ==================== */

/** 两点间欧氏距离 */
static inline float vec2f_distance(Vec2f a, Vec2f b) {
    float dx = b.x - a.x;
    float dy = b.y - a.y;
    return sqrtf(dx * dx + dy * dy);
}

/** 两点间线性插值 */
static inline Vec2f vec2f_lerp(Vec2f a, Vec2f b, float t) {
    Vec2f result;
    result.x = a.x + (b.x - a.x) * t;
    result.y = a.y + (b.y - a.y) * t;
    return result;
}

/** 浮点数线性插值 */
static inline float float_lerp(float a, float b, float t) {
    return a + (b - a) * t;
}

/** 将值裁剪到范围 [min_val, max_val] */
static inline float float_clamp(float value, float min_val, float max_val) {
    if (value < min_val) return min_val;
    if (value > max_val) return max_val;
    return value;
}

/* ==================== 贝塞尔曲线算法 ==================== */

/**
 * 计算三次贝塞尔曲线上的点
 * B(t) = (1-t)^3*P0 + 3*(1-t)^2*t*P1 + 3*(1-t)*t^2*P2 + t^3*P3
 *
 * 用于平滑连接相邻坐标点,消除折角使笔画圆润
 */
static Vec2f cubic_bezier(Vec2f p0, Vec2f p1, Vec2f p2, Vec2f p3, float t) {
    float u = 1.0f - t;
    float tt = t * t;
    float uu = u * u;
    float uuu = uu * u;
    float ttt = tt * t;

    Vec2f point;
    point.x = uuu * p0.x + 3.0f * uu * t * p1.x + 3.0f * u * tt * p2.x + ttt * p3.x;
    point.y = uuu * p0.y + 3.0f * uu * t * p1.y + 3.0f * u * tt * p2.y + ttt * p3.y;
    return point;
}

/**
 * 使用Catmull-Rom样条生成贝塞尔控制点
 * 从4个数据点(p0,p1,p2,p3)计算p1到p2之间的贝塞尔控制点
 * 确保曲线经过原始数据点(C1连续)
 */
static void catmull_rom_to_bezier(
    Vec2f p0, Vec2f p1, Vec2f p2, Vec2f p3,
    Vec2f* cp1_out, Vec2f* cp2_out
) {
    float tension = 0.5f;  /* 张力系数,0.5为标准Catmull-Rom */
    cp1_out->x = p1.x + (p2.x - p0.x) * tension / 3.0f;
    cp1_out->y = p1.y + (p2.y - p0.y) * tension / 3.0f;
    cp2_out->x = p2.x - (p3.x - p1.x) * tension / 3.0f;
    cp2_out->y = p2.y - (p3.y - p1.y) * tension / 3.0f;
}

/* ==================== 笔画宽度计算 ==================== */

/**
 * 根据压力和速度计算笔画宽度
 * 模拟真实毛笔/钢笔的笔锋效果:
 * - 压力越大,笔画越粗
 * - 速度越快,笔画越细(模拟快写时的飞白效果)
 * - 起笔/收笔处渐变细化
 */
static float calculate_stroke_width(
    float pressure, float velocity,
    float min_width, float max_width, float velocity_scale
) {
    /* 压力影响:压力0→最细,压力1→最粗 */
    float pressure_width = min_width + (max_width - min_width) * pressure;

    /* 速度衰减:速度快时笔画变细 */
    float velocity_factor = 1.0f / (1.0f + velocity * velocity_scale);

    float width = pressure_width * velocity_factor;
    return float_clamp(width, min_width, max_width);
}

/* ==================== 笔迹平滑器API ==================== */

/**
 * 初始化笔迹平滑器
 */
static void smoother_init(StrokeSmoother* ctx, float min_width, float max_width) {
    ctx->buffer_count = 0;
    ctx->output_count = 0;
    ctx->min_width = min_width;
    ctx->max_width = max_width;
    ctx->velocity_scale = 0.005f;
    ctx->last_smooth_pressure = 0.5f;
    ctx->total_input_points = 0;
    ctx->total_output_points = 0;
}

/**
 * 输入一个新的坐标点
 * 当缓冲区积累到4个点时,自动生成贝塞尔曲线平滑点
 * 返回新生成的平滑点数量
 */
static int smoother_add_point(StrokeSmoother* ctx, float x, float y,
                               float pressure, uint32_t timestamp) {
    ctx->total_input_points++;

    /* 压力平滑(低通滤波器,避免压力值跳变) */
    float smooth_pressure = ctx->last_smooth_pressure +
        PRESSURE_SMOOTH_FACTOR * (pressure - ctx->last_smooth_pressure);
    ctx->last_smooth_pressure = smooth_pressure;

    /* 添加到输入缓冲区 */
    int idx = ctx->buffer_count;
    if (idx >= 4) {
        /* 缓冲区满,移位 */
        ctx->input_buffer[0] = ctx->input_buffer[1];
        ctx->input_buffer[1] = ctx->input_buffer[2];
        ctx->input_buffer[2] = ctx->input_buffer[3];
        idx = 3;
    }

    ctx->input_buffer[idx].x = x;
    ctx->input_buffer[idx].y = y;
    ctx->input_buffer[idx].pressure = smooth_pressure;
    ctx->input_buffer[idx].timestamp = timestamp;
    ctx->buffer_count = idx + 1;

    /* 不足4个点时直接输出原始点 */
    if (ctx->buffer_count < 4) {
        if (ctx->output_count < MAX_SMOOTH_POINTS) {
            /* 计算速度和宽度 */
            float velocity = 0;
            if (ctx->buffer_count >= 2) {
                Vec2f prev = {ctx->input_buffer[ctx->buffer_count-2].x, ctx->input_buffer[ctx->buffer_count-2].y};
                Vec2f curr = {x, y};
                float dt = (float)(timestamp - ctx->input_buffer[ctx->buffer_count-2].timestamp);
                if (dt > 0) velocity = vec2f_distance(prev, curr) / dt * 1000.0f;
            }

            float width = calculate_stroke_width(smooth_pressure, velocity,
                ctx->min_width, ctx->max_width, ctx->velocity_scale);

            SmoothPoint sp = {x, y, smooth_pressure, width, timestamp};
            ctx->output_buffer[ctx->output_count++] = sp;
            ctx->total_output_points++;
            return 1;
        }
        return 0;
    }

    /* 4个点准备好,生成贝塞尔曲线 */
    Vec2f p0 = {ctx->input_buffer[0].x, ctx->input_buffer[0].y};
    Vec2f p1 = {ctx->input_buffer[1].x, ctx->input_buffer[1].y};
    Vec2f p2 = {ctx->input_buffer[2].x, ctx->input_buffer[2].y};
    Vec2f p3 = {ctx->input_buffer[3].x, ctx->input_buffer[3].y};

    /* 计算贝塞尔控制点 */
    Vec2f cp1, cp2;
    catmull_rom_to_bezier(p0, p1, p2, p3, &cp1, &cp2);

    /* 在p1到p2之间生成平滑点 */
    int new_points = 0;
    for (int i = 0; i <= BEZIER_SEGMENTS; i++) {
        if (ctx->output_count >= MAX_SMOOTH_POINTS) break;

        float t = (float)i / BEZIER_SEGMENTS;
        Vec2f pt = cubic_bezier(p1, cp1, cp2, p2, t);

        /* 插值压力和时间戳 */
        float interp_pressure = float_lerp(ctx->input_buffer[1].pressure,
                                            ctx->input_buffer[2].pressure, t);
        uint32_t interp_time = (uint32_t)float_lerp(
            (float)ctx->input_buffer[1].timestamp,
            (float)ctx->input_buffer[2].timestamp, t);

        /* 计算速度 */
        float velocity = 0;
        if (ctx->output_count > 0) {
            SmoothPoint* prev = &ctx->output_buffer[ctx->output_count - 1];
            Vec2f prev_v = {prev->x, prev->y};
            float dt = (float)(interp_time - prev->timestamp);
            if (dt > 0) velocity = vec2f_distance(prev_v, pt) / dt * 1000.0f;
        }

        /* 计算笔画宽度 */
        float width = calculate_stroke_width(interp_pressure, velocity,
            ctx->min_width, ctx->max_width, ctx->velocity_scale);

        /* 距离过滤:跳过距上一点太近的点 */
        if (ctx->output_count > 0) {
            SmoothPoint* prev = &ctx->output_buffer[ctx->output_count - 1];
            Vec2f prev_v = {prev->x, prev->y};
            if (vec2f_distance(prev_v, pt) < MIN_POINT_DISTANCE) continue;
        }

        SmoothPoint sp = {pt.x, pt.y, interp_pressure, width, interp_time};
        ctx->output_buffer[ctx->output_count++] = sp;
        ctx->total_output_points++;
        new_points++;
    }

    return new_points;
}

/**
 * 结束当前笔画(抬笔时调用)
 * 输出最后一段贝塞尔曲线的收尾点
 */
static int smoother_end_stroke(StrokeSmoother* ctx) {
    int new_points = 0;

    /* 输出缓冲区中剩余的点 */
    if (ctx->buffer_count >= 2 && ctx->output_count < MAX_SMOOTH_POINTS) {
        int last = ctx->buffer_count - 1;
        float width = calculate_stroke_width(
            ctx->input_buffer[last].pressure * 0.5f, 0,  /* 收笔处宽度减半 */
            ctx->min_width, ctx->max_width, ctx->velocity_scale);

        SmoothPoint sp = {
            ctx->input_buffer[last].x, ctx->input_buffer[last].y,
            ctx->input_buffer[last].pressure, width,
            ctx->input_buffer[last].timestamp
        };
        ctx->output_buffer[ctx->output_count++] = sp;
        new_points++;
    }

    /* 重置输入缓冲区 */
    ctx->buffer_count = 0;
    ctx->last_smooth_pressure = 0.5f;

    return new_points;
}

/**
 * 获取平滑后的输出点
 */
static inline const SmoothPoint* smoother_get_output(const StrokeSmoother* ctx) {
    return ctx->output_buffer;
}

/**
 * 获取输出点数量
 */
static inline int smoother_get_output_count(const StrokeSmoother* ctx) {
    return ctx->output_count;
}

/**
 * 清除输出缓冲区
 */
static inline void smoother_clear_output(StrokeSmoother* ctx) {
    ctx->output_count = 0;
}

/**
 * 获取统计信息
 */
static inline void smoother_get_stats(const StrokeSmoother* ctx,
    uint32_t* input_count, uint32_t* output_count) {
    if (input_count) *input_count = ctx->total_input_points;
    if (output_count) *output_count = ctx->total_output_points;
}

#ifdef __cplusplus
}
#endif

#endif /* STROKE_SMOOTHER_H */

model/

model/PenDevice.java

/*
 * 自然写互动课堂应用开发SDK软件 V1.0
 * PenDevice - 点阵笔设备数据模型
 *
 * 描述:封装点阵笔的设备信息、连接状态、能力参数等
 */

package com.writech.sdk.model;

import java.io.Serializable;

/**
 * 点阵笔设备模型
 * 包含设备基本信息、硬件参数和连接状态
 */
public class PenDevice implements Serializable {

    private static final long serialVersionUID = 1L;

    /* ========== 基本信息 ========== */

    /** 设备MAC地址(唯一标识) */
    private String macAddress;

    /** 设备名称(用户可自定义) */
    private String deviceName;

    /** 设备型号(如 WP-200, WP-300 */
    private String modelName;

    /** 固件版本号(如 2.1.5 */
    private String firmwareVersion;

    /** 硬件版本号 */
    private String hardwareVersion;

    /** 设备序列号 */
    private String serialNumber;

    /* ========== 硬件能力 ========== */

    /** 采样率(Hz,常见值:100, 200 */
    private int sampleRate;

    /** 压力感应级别(常见值:1024, 2048, 4096 */
    private int pressureLevels;

    /** 坐标分辨率(DPI,常见值:300, 600 */
    private int coordinateDpi;

    /** 是否支持倾斜角检测 */
    private boolean tiltSupported;

    /** BLE协议版本(4.2 / 5.0 / 5.3 */
    private String bleVersion;

    /** 电池容量(mAh */
    private int batteryCapacity;

    /* ========== 运行状态 ========== */

    /** 连接状态枚举 */
    public enum ConnectionState {
        DISCONNECTED,       /* 未连接 */
        CONNECTING,         /* 正在连接 */
        CONNECTED,          /* 已连接 */
        RECONNECTING        /* 正在重连 */
    }

    /** 当前连接状态 */
    private ConnectionState connectionState = ConnectionState.DISCONNECTED;

    /** 当前电量百分比(0-100 */
    private int batteryLevel;

    /** 是否正在充电 */
    private boolean isCharging;

    /** 是否正在书写(笔尖接触纸面) */
    private boolean isWriting;

    /** 信号强度RSSIdBm */
    private int rssi;

    /** 最后一次通信时间(毫秒时间戳) */
    private long lastCommunicationTime;

    /** 累计书写时长(秒) */
    private long totalWritingDuration;

    /** 绑定的学生ID */
    private String boundStudentId;

    /** 绑定的学生姓名 */
    private String boundStudentName;

    /* ========== 构造函数 ========== */

    public PenDevice() {
    }

    public PenDevice(String macAddress, String deviceName) {
        this.macAddress = macAddress;
        this.deviceName = deviceName;
        this.sampleRate = 100;
        this.pressureLevels = 4096;
        this.coordinateDpi = 300;
    }

    /* ========== Getter / Setter ========== */

    public String getMacAddress() { return macAddress; }
    public void setMacAddress(String macAddress) { this.macAddress = macAddress; }

    public String getDeviceName() { return deviceName; }
    public void setDeviceName(String deviceName) { this.deviceName = deviceName; }

    public String getModelName() { return modelName; }
    public void setModelName(String modelName) { this.modelName = modelName; }

    public String getFirmwareVersion() { return firmwareVersion; }
    public void setFirmwareVersion(String firmwareVersion) { this.firmwareVersion = firmwareVersion; }

    public String getHardwareVersion() { return hardwareVersion; }
    public void setHardwareVersion(String v) { this.hardwareVersion = v; }

    public String getSerialNumber() { return serialNumber; }
    public void setSerialNumber(String serialNumber) { this.serialNumber = serialNumber; }

    public int getSampleRate() { return sampleRate; }
    public void setSampleRate(int sampleRate) { this.sampleRate = sampleRate; }

    public int getPressureLevels() { return pressureLevels; }
    public void setPressureLevels(int pressureLevels) { this.pressureLevels = pressureLevels; }

    public int getCoordinateDpi() { return coordinateDpi; }
    public void setCoordinateDpi(int coordinateDpi) { this.coordinateDpi = coordinateDpi; }

    public boolean isTiltSupported() { return tiltSupported; }
    public void setTiltSupported(boolean tiltSupported) { this.tiltSupported = tiltSupported; }

    public String getBleVersion() { return bleVersion; }
    public void setBleVersion(String bleVersion) { this.bleVersion = bleVersion; }

    public int getBatteryCapacity() { return batteryCapacity; }
    public void setBatteryCapacity(int batteryCapacity) { this.batteryCapacity = batteryCapacity; }

    public ConnectionState getConnectionState() { return connectionState; }
    public void setConnectionState(ConnectionState state) { this.connectionState = state; }

    public int getBatteryLevel() { return batteryLevel; }
    public void setBatteryLevel(int batteryLevel) { this.batteryLevel = batteryLevel; }

    public boolean isCharging() { return isCharging; }
    public void setCharging(boolean charging) { isCharging = charging; }

    public boolean isWriting() { return isWriting; }
    public void setWriting(boolean writing) { isWriting = writing; }

    public int getRssi() { return rssi; }
    public void setRssi(int rssi) { this.rssi = rssi; }

    public long getLastCommunicationTime() { return lastCommunicationTime; }
    public void setLastCommunicationTime(long t) { this.lastCommunicationTime = t; }

    public long getTotalWritingDuration() { return totalWritingDuration; }
    public void setTotalWritingDuration(long d) { this.totalWritingDuration = d; }

    public String getBoundStudentId() { return boundStudentId; }
    public void setBoundStudentId(String id) { this.boundStudentId = id; }

    public String getBoundStudentName() { return boundStudentName; }
    public void setBoundStudentName(String name) { this.boundStudentName = name; }

    /* ========== 便捷方法 ========== */

    /** 是否已连接 */
    public boolean isConnected() {
        return connectionState == ConnectionState.CONNECTED;
    }

    /** 电量是否低(<= 10% */
    public boolean isLowBattery() {
        return batteryLevel <= 10 && !isCharging;
    }

    /** 获取设备显示名称(优先显示学生姓名) */
    public String getDisplayName() {
        if (boundStudentName != null && !boundStudentName.isEmpty()) {
            return boundStudentName + "的笔";
        }
        return deviceName != null ? deviceName : "WritechPen-" + macAddress;
    }

    @Override
    public String toString() {
        return "PenDevice{" +
               "mac='" + macAddress + '\'' +
               ", name='" + deviceName + '\'' +
               ", model='" + modelName + '\'' +
               ", state=" + connectionState +
               ", battery=" + batteryLevel + "%" +
               ", writing=" + isWriting +
               '}';
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        PenDevice that = (PenDevice) o;
        return macAddress != null && macAddress.equals(that.macAddress);
    }

    @Override
    public int hashCode() {
        return macAddress != null ? macAddress.hashCode() : 0;
    }
}

model/RecognitionResult.java

/*
 * 自然写互动课堂应用开发SDK软件 V1.0
 * RecognitionResult - 识别结果数据模型
 *
 * 描述:封装OCR识别、数学公式识别、笔顺评分等各类识别结果
 */

package com.writech.sdk.model;

import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;

/**
 * 识别结果统一模型
 * 支持多种识别类型的结果封装
 */
public class RecognitionResult implements Serializable {

    private static final long serialVersionUID = 1L;

    /* ========== 识别类型常量 ========== */

    /** 手写文字识别 */
    public static final int TYPE_HANDWRITING = 0;

    /** 数学公式识别 */
    public static final int TYPE_MATH = 1;

    /** 笔顺评分 */
    public static final int TYPE_STROKE_ORDER = 2;

    /** 作文评分 */
    public static final int TYPE_ESSAY = 3;

    /* ========== 候选结果内部类 ========== */

    /** 单个候选识别结果 */
    public static class Candidate implements Serializable {
        private static final long serialVersionUID = 1L;

        /** 识别文本 */
        public String text;

        /** 置信度(0.0~1.0 */
        public float confidence;

        /** 候选排名 */
        public int rank;

        public Candidate() {}

        public Candidate(String text, float confidence, int rank) {
            this.text = text;
            this.confidence = confidence;
            this.rank = rank;
        }

        @Override
        public String toString() {
            return "Candidate{'" + text + "', conf=" + confidence + "}";
        }
    }

    /** 笔顺评分详情 */
    public static class StrokeOrderDetail implements Serializable {
        private static final long serialVersionUID = 1L;

        /** 评分笔画序号 */
        public int strokeIndex;

        /** 该笔是否正确 */
        public boolean isCorrect;

        /** 标准笔顺名称(如"横"、"竖"、"撇" */
        public String standardStrokeName;

        /** 实际书写的笔画类型 */
        public String actualStrokeName;

        /** 笔画形态相似度(0.0~1.0 */
        public float shapeSimilarity;

        public StrokeOrderDetail() {}

        @Override
        public String toString() {
            return "Stroke#" + strokeIndex + ": " + (isCorrect ? "正确" : "错误")
                   + " (标准:" + standardStrokeName + ", 实际:" + actualStrokeName + ")";
        }
    }

    /** 作文评分详情 */
    public static class EssayScoreDetail implements Serializable {
        private static final long serialVersionUID = 1L;

        /** 内容分(百分制) */
        public float contentScore;

        /** 结构分 */
        public float structureScore;

        /** 语言分 */
        public float languageScore;

        /** 书写规范分 */
        public float handwritingScore;

        /** 总分 */
        public float totalScore;

        /** 评语 */
        public String comment;

        /** 优点列表 */
        public List<String> highlights = new ArrayList<>();

        /** 改进建议列表 */
        public List<String> suggestions = new ArrayList<>();

        public EssayScoreDetail() {}
    }

    /* ========== 结果字段 ========== */

    /** 识别请求ID(对应任务ID) */
    private int requestId;

    /** 识别类型 */
    private int recognitionType;

    /** 识别是否成功 */
    private boolean success;

    /** 错误码(成功时为0 */
    private int errorCode;

    /** 错误消息 */
    private String errorMessage;

    /** 主要识别结果文本 */
    private String resultText;

    /** 主要结果置信度 */
    private float confidence;

    /** 候选结果列表(按置信度降序) */
    private List<Candidate> candidates;

    /** 笔顺评分详情(仅笔顺类型) */
    private List<StrokeOrderDetail> strokeOrderDetails;

    /** 笔顺总分(0-100 */
    private float strokeOrderScore;

    /** 笔顺正确笔画数 */
    private int correctStrokeCount;

    /** 笔顺总笔画数 */
    private int totalStrokeCount;

    /** 作文评分详情(仅作文类型) */
    private EssayScoreDetail essayDetail;

    /** 数学公式LaTeX表示(仅数学类型) */
    private String mathLatex;

    /** 数学计算结果(如果是计算题) */
    private String mathAnswer;

    /** 识别耗时(毫秒) */
    private long processingTimeMs;

    /** 结果来源("online"在线 / "offline"离线 / "cache"缓存) */
    private String source;

    /** 识别时间戳 */
    private long timestamp;

    /* ========== 构造函数 ========== */

    public RecognitionResult() {
        this.candidates = new ArrayList<>();
        this.strokeOrderDetails = new ArrayList<>();
        this.timestamp = System.currentTimeMillis();
    }

    /** 创建成功结果 */
    public static RecognitionResult success(int requestId, int type, String text, float confidence) {
        RecognitionResult result = new RecognitionResult();
        result.requestId = requestId;
        result.recognitionType = type;
        result.success = true;
        result.errorCode = 0;
        result.resultText = text;
        result.confidence = confidence;
        return result;
    }

    /** 创建失败结果 */
    public static RecognitionResult failure(int requestId, int errorCode, String message) {
        RecognitionResult result = new RecognitionResult();
        result.requestId = requestId;
        result.success = false;
        result.errorCode = errorCode;
        result.errorMessage = message;
        return result;
    }

    /* ========== Getter / Setter ========== */

    public int getRequestId() { return requestId; }
    public void setRequestId(int id) { this.requestId = id; }

    public int getRecognitionType() { return recognitionType; }
    public void setRecognitionType(int type) { this.recognitionType = type; }

    public boolean isSuccess() { return success; }
    public void setSuccess(boolean success) { this.success = success; }

    public int getErrorCode() { return errorCode; }
    public void setErrorCode(int code) { this.errorCode = code; }

    public String getErrorMessage() { return errorMessage; }
    public void setErrorMessage(String msg) { this.errorMessage = msg; }

    public String getResultText() { return resultText; }
    public void setResultText(String text) { this.resultText = text; }

    public float getConfidence() { return confidence; }
    public void setConfidence(float c) { this.confidence = c; }

    public List<Candidate> getCandidates() { return candidates; }
    public void setCandidates(List<Candidate> c) { this.candidates = c; }

    public void addCandidate(String text, float confidence, int rank) {
        candidates.add(new Candidate(text, confidence, rank));
    }

    public List<StrokeOrderDetail> getStrokeOrderDetails() { return strokeOrderDetails; }
    public void setStrokeOrderDetails(List<StrokeOrderDetail> d) { this.strokeOrderDetails = d; }

    public float getStrokeOrderScore() { return strokeOrderScore; }
    public void setStrokeOrderScore(float s) { this.strokeOrderScore = s; }

    public int getCorrectStrokeCount() { return correctStrokeCount; }
    public void setCorrectStrokeCount(int c) { this.correctStrokeCount = c; }

    public int getTotalStrokeCount() { return totalStrokeCount; }
    public void setTotalStrokeCount(int t) { this.totalStrokeCount = t; }

    public EssayScoreDetail getEssayDetail() { return essayDetail; }
    public void setEssayDetail(EssayScoreDetail d) { this.essayDetail = d; }

    public String getMathLatex() { return mathLatex; }
    public void setMathLatex(String latex) { this.mathLatex = latex; }

    public String getMathAnswer() { return mathAnswer; }
    public void setMathAnswer(String answer) { this.mathAnswer = answer; }

    public long getProcessingTimeMs() { return processingTimeMs; }
    public void setProcessingTimeMs(long ms) { this.processingTimeMs = ms; }

    public String getSource() { return source; }
    public void setSource(String source) { this.source = source; }

    public long getTimestamp() { return timestamp; }
    public void setTimestamp(long t) { this.timestamp = t; }

    /* ========== 便捷方法 ========== */

    /** 获取最佳候选结果 */
    public Candidate getBestCandidate() {
        return candidates.isEmpty() ? null : candidates.get(0);
    }

    /** 获取笔顺正确率 */
    public float getStrokeOrderAccuracy() {
        return totalStrokeCount > 0 ? (float) correctStrokeCount / totalStrokeCount : 0;
    }

    /** 获取识别类型的中文描述 */
    public String getTypeDescription() {
        switch (recognitionType) {
            case TYPE_HANDWRITING: return "手写识别";
            case TYPE_MATH: return "数学识别";
            case TYPE_STROKE_ORDER: return "笔顺评分";
            case TYPE_ESSAY: return "作文评分";
            default: return "未知类型";
        }
    }

    @Override
    public String toString() {
        if (success) {
            return "RecognitionResult{type=" + getTypeDescription()
                   + ", text='" + resultText + "'"
                   + ", confidence=" + confidence
                   + ", source=" + source
                   + ", time=" + processingTimeMs + "ms}";
        } else {
            return "RecognitionResult{FAILED, code=" + errorCode
                   + ", msg='" + errorMessage + "'}";
        }
    }
}

model/StrokePath.java

/*
 * 自然写互动课堂应用开发SDK软件 V1.0
 * StrokePath - 笔迹路径数据模型
 *
 * 描述:封装一条完整笔画的坐标序列、属性和元数据
 */

package com.writech.sdk.model;

import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;

/**
 * 笔迹路径模型
 * 代表从落笔到抬笔的一条完整笔画数据
 */
public class StrokePath implements Serializable {

    private static final long serialVersionUID = 1L;

    /* ========== 采样点内部类 ========== */

    /** 单个笔迹采样点 */
    public static class Point implements Serializable {
        private static final long serialVersionUID = 1L;

        /** X坐标(屏幕像素或物理mm,取决于坐标空间) */
        public float x;

        /** Y坐标 */
        public float y;

        /** 压力值(归一化 0.0~1.0 */
        public float pressure;

        /** 时间戳(相对于笔画开始时间的毫秒偏移) */
        public long timeOffset;

        /** 笔尖倾斜角度(度,0-90,0为垂直,部分笔支持) */
        public float tiltAngle;

        /** 笔尖方位角(度,0-360,部分笔支持) */
        public float azimuthAngle;

        public Point() {}

        public Point(float x, float y, float pressure, long timeOffset) {
            this.x = x;
            this.y = y;
            this.pressure = pressure;
            this.timeOffset = timeOffset;
        }

        @Override
        public String toString() {
            return "(" + x + "," + y + ",p=" + pressure + ",t=" + timeOffset + ")";
        }
    }

    /* ========== 笔画属性 ========== */

    /** 笔画唯一ID */
    private String strokeId;

    /** 来源笔设备MAC地址 */
    private String penMac;

    /** 学生ID */
    private String studentId;

    /** 页面ID(标识书写所在页面) */
    private String pageId;

    /** 笔画开始时间(绝对时间戳毫秒) */
    private long startTimestamp;

    /** 笔画结束时间 */
    private long endTimestamp;

    /** 笔画颜色(ARGB */
    private int color = 0xFF000000;

    /** 笔画基础线宽(像素) */
    private float baseWidth = 3.0f;

    /** 采样点列表 */
    private List<Point> points;

    /* ========== 分析结果(由OCR/AI引擎填充) ========== */

    /** 识别的文字内容 */
    private String recognizedText;

    /** 识别置信度 */
    private float recognitionConfidence;

    /** 笔顺序号(在整个书写序列中的顺序) */
    private int strokeOrder;

    /** 是否为有效笔画(排除误触等) */
    private boolean isValid = true;

    /* ========== 构造函数 ========== */

    public StrokePath() {
        this.points = new ArrayList<>();
    }

    public StrokePath(String strokeId, String penMac) {
        this.strokeId = strokeId;
        this.penMac = penMac;
        this.points = new ArrayList<>();
        this.startTimestamp = System.currentTimeMillis();
    }

    /* ========== 点操作方法 ========== */

    /** 添加采样点 */
    public void addPoint(float x, float y, float pressure, long timeOffset) {
        points.add(new Point(x, y, pressure, timeOffset));
    }

    /** 添加采样点(含倾斜角) */
    public void addPointWithTilt(float x, float y, float pressure,
                                  long timeOffset, float tilt, float azimuth) {
        Point p = new Point(x, y, pressure, timeOffset);
        p.tiltAngle = tilt;
        p.azimuthAngle = azimuth;
        points.add(p);
    }

    /** 获取采样点数量 */
    public int getPointCount() {
        return points.size();
    }

    /** 获取指定索引的采样点 */
    public Point getPoint(int index) {
        if (index >= 0 && index < points.size()) {
            return points.get(index);
        }
        return null;
    }

    /** 获取所有采样点 */
    public List<Point> getPoints() {
        return points;
    }

    /* ========== 笔画几何计算 ========== */

    /** 计算笔画总长度(像素) */
    public float calculateLength() {
        float length = 0;
        for (int i = 1; i < points.size(); i++) {
            Point p0 = points.get(i - 1);
            Point p1 = points.get(i);
            float dx = p1.x - p0.x;
            float dy = p1.y - p0.y;
            length += (float) Math.sqrt(dx * dx + dy * dy);
        }
        return length;
    }

    /** 计算笔画包围盒 */
    public float[] getBoundingBox() {
        if (points.isEmpty()) return new float[]{0, 0, 0, 0};

        float minX = Float.MAX_VALUE, minY = Float.MAX_VALUE;
        float maxX = Float.MIN_VALUE, maxY = Float.MIN_VALUE;

        for (Point p : points) {
            if (p.x < minX) minX = p.x;
            if (p.y < minY) minY = p.y;
            if (p.x > maxX) maxX = p.x;
            if (p.y > maxY) maxY = p.y;
        }

        return new float[]{minX, minY, maxX, maxY};
    }

    /** 计算平均书写速度(像素/毫秒) */
    public float calculateAverageSpeed() {
        if (points.size() < 2) return 0;

        float totalLength = calculateLength();
        long duration = points.get(points.size() - 1).timeOffset - points.get(0).timeOffset;

        return duration > 0 ? totalLength / duration : 0;
    }

    /** 计算平均压力 */
    public float calculateAveragePressure() {
        if (points.isEmpty()) return 0;
        float sum = 0;
        for (Point p : points) {
            sum += p.pressure;
        }
        return sum / points.size();
    }

    /** 获取书写持续时间(毫秒) */
    public long getDuration() {
        if (points.size() < 2) return 0;
        return points.get(points.size() - 1).timeOffset - points.get(0).timeOffset;
    }

    /* ========== 序列化方法 ========== */

    /**
     * 将笔画数据序列化为紧凑的二进制格式
     * 用于BLE传输和本地缓存
     *
     * 格式:
     * [4字节 点数][每个点: 4字节x + 4字节y + 2字节pressure + 4字节timeOffset]
     */
    public byte[] toBytes() {
        int pointCount = points.size();
        byte[] data = new byte[4 + pointCount * 14];

        /* 写入点数(大端序) */
        data[0] = (byte) ((pointCount >> 24) & 0xFF);
        data[1] = (byte) ((pointCount >> 16) & 0xFF);
        data[2] = (byte) ((pointCount >> 8) & 0xFF);
        data[3] = (byte) (pointCount & 0xFF);

        int offset = 4;
        for (Point p : points) {
            /* 写入X坐标(float → 4字节) */
            int fx = Float.floatToIntBits(p.x);
            data[offset++] = (byte) ((fx >> 24) & 0xFF);
            data[offset++] = (byte) ((fx >> 16) & 0xFF);
            data[offset++] = (byte) ((fx >> 8) & 0xFF);
            data[offset++] = (byte) (fx & 0xFF);

            /* 写入Y坐标 */
            int fy = Float.floatToIntBits(p.y);
            data[offset++] = (byte) ((fy >> 24) & 0xFF);
            data[offset++] = (byte) ((fy >> 16) & 0xFF);
            data[offset++] = (byte) ((fy >> 8) & 0xFF);
            data[offset++] = (byte) (fy & 0xFF);

            /* 写入压力值(归一化后*65535转uint16 */
            int pressure16 = (int) (p.pressure * 65535);
            data[offset++] = (byte) ((pressure16 >> 8) & 0xFF);
            data[offset++] = (byte) (pressure16 & 0xFF);

            /* 写入时间偏移(uint32 */
            long t = p.timeOffset;
            data[offset++] = (byte) ((t >> 24) & 0xFF);
            data[offset++] = (byte) ((t >> 16) & 0xFF);
            data[offset++] = (byte) ((t >> 8) & 0xFF);
            data[offset++] = (byte) (t & 0xFF);
        }

        return data;
    }

    /* ========== Getter / Setter ========== */

    public String getStrokeId() { return strokeId; }
    public void setStrokeId(String strokeId) { this.strokeId = strokeId; }

    public String getPenMac() { return penMac; }
    public void setPenMac(String penMac) { this.penMac = penMac; }

    public String getStudentId() { return studentId; }
    public void setStudentId(String studentId) { this.studentId = studentId; }

    public String getPageId() { return pageId; }
    public void setPageId(String pageId) { this.pageId = pageId; }

    public long getStartTimestamp() { return startTimestamp; }
    public void setStartTimestamp(long t) { this.startTimestamp = t; }

    public long getEndTimestamp() { return endTimestamp; }
    public void setEndTimestamp(long t) { this.endTimestamp = t; }

    public int getColor() { return color; }
    public void setColor(int color) { this.color = color; }

    public float getBaseWidth() { return baseWidth; }
    public void setBaseWidth(float w) { this.baseWidth = w; }

    public String getRecognizedText() { return recognizedText; }
    public void setRecognizedText(String text) { this.recognizedText = text; }

    public float getRecognitionConfidence() { return recognitionConfidence; }
    public void setRecognitionConfidence(float c) { this.recognitionConfidence = c; }

    public int getStrokeOrder() { return strokeOrder; }
    public void setStrokeOrder(int order) { this.strokeOrder = order; }

    public boolean isValid() { return isValid; }
    public void setValid(boolean valid) { isValid = valid; }

    @Override
    public String toString() {
        return "StrokePath{id='" + strokeId + "', points=" + points.size()
               + ", duration=" + getDuration() + "ms"
               + ", text='" + recognizedText + "'}";
    }
}