software copyright

This commit is contained in:
jiahong
2026-03-22 15:24:40 +08:00
parent e303bb868a
commit 60f336e345
155 changed files with 127262 additions and 0 deletions
@@ -0,0 +1,502 @@
/*
* 自然写互动课堂应用开发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;
}
}
@@ -0,0 +1,420 @@
/*
* 自然写互动课堂应用开发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资源已释放");
}
}
@@ -0,0 +1,470 @@
/*
* 自然写互动课堂应用开发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引擎资源已释放");
}
}
@@ -0,0 +1,584 @@
/*
* 自然写互动课堂应用开发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资源已释放");
}
}
@@ -0,0 +1,415 @@
/*
* 自然写互动课堂应用开发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);
}
}
@@ -0,0 +1,375 @@
/*
* 自然写互动课堂应用开发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;
}
}