software copyright
This commit is contained in:
@@ -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 网关ID(mDNS服务名)
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user