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,349 @@
/**
* 自然写互动课堂智慧黑板端应用软件 V1.0
*
* CloudApiClient.kt - 云平台API客户端
*
* 功能说明:
* - JWT认证与Token自动刷新
* - 课件资源下载
* - 课堂数据同步
* - 录像文件上传
* - 设备注册与心跳
* - 请求签名(HMAC-SHA256
*/
package com.writech.board.network
import android.util.Log
import org.json.JSONObject
import java.io.*
import java.net.HttpURLConnection
import java.net.URL
import java.security.MessageDigest
import java.util.concurrent.*
/** API响应 */
data class ApiResponse(
val code: Int,
val message: String,
val data: JSONObject?,
val httpCode: Int = 200
) {
val isSuccess: Boolean get() = code == 200 || code == 0
}
/** 认证令牌 */
data class AuthToken(
val accessToken: String,
val refreshToken: String,
val expiresAt: Long,
val tokenType: String = "Bearer"
)
/**
* 云平台API客户端
* 基于HTTPS与云平台通信,支持设备证书认证、JWT刷新、请求签名
*/
class CloudApiClient(
private val baseUrl: String,
private val deviceId: String
) {
companion object {
private const val TAG = "CloudApiClient"
private const val CONNECT_TIMEOUT = 15000
private const val READ_TIMEOUT = 30000
private const val MAX_RETRIES = 3
private const val CHUNK_SIZE = 2 * 1024 * 1024
}
@Volatile
private var authToken: AuthToken? = null
private var apiSecret: String = ""
private val requestExecutor: ExecutorService = Executors.newFixedThreadPool(4)
/**
* 设备认证登录 - 使用设备证书申请JWT令牌
*/
fun authenticate(deviceCert: String, callback: (Boolean, String) -> Unit) {
requestExecutor.submit {
try {
val body = JSONObject().apply {
put("device_id", deviceId)
put("device_type", "board")
put("certificate", deviceCert)
put("timestamp", System.currentTimeMillis())
}
val response = doPost("/api/v1/auth/device-login", body.toString())
if (response.isSuccess && response.data != null) {
authToken = AuthToken(
accessToken = response.data.getString("access_token"),
refreshToken = response.data.getString("refresh_token"),
expiresAt = System.currentTimeMillis() +
response.data.getLong("expires_in") * 1000
)
apiSecret = response.data.optString("api_secret", "")
Log.i(TAG, "设备认证成功")
callback(true, "认证成功")
} else {
callback(false, response.message)
}
} catch (e: Exception) {
Log.e(TAG, "认证失败", e)
callback(false, e.message ?: "未知错误")
}
}
}
/**
* 刷新JWT令牌
*/
private fun refreshAuthToken(): Boolean {
val token = authToken ?: return false
try {
val body = JSONObject().apply {
put("refresh_token", token.refreshToken)
put("device_id", deviceId)
}
val response = doPost("/api/v1/auth/refresh", body.toString(), skipAuth = true)
if (response.isSuccess && response.data != null) {
authToken = AuthToken(
accessToken = response.data.getString("access_token"),
refreshToken = response.data.optString("refresh_token", token.refreshToken),
expiresAt = System.currentTimeMillis() +
response.data.getLong("expires_in") * 1000
)
Log.i(TAG, "Token刷新成功")
return true
}
} catch (e: Exception) {
Log.e(TAG, "Token刷新失败", e)
}
return false
}
/** 确保Token有效(5分钟内过期则刷新) */
private fun ensureValidToken() {
val token = authToken ?: return
val remaining = token.expiresAt - System.currentTimeMillis()
if (remaining < 5 * 60 * 1000) {
refreshAuthToken()
}
}
/** 计算请求签名 HMAC-SHA256 */
private fun signRequest(method: String, path: String, body: String?): String {
if (apiSecret.isEmpty()) return ""
val timestamp = System.currentTimeMillis().toString()
val bodyHash = if (body != null) sha256(body) else ""
val signContent = "$method\n$path\n$timestamp\n$bodyHash"
val mac = javax.crypto.Mac.getInstance("HmacSHA256")
mac.init(javax.crypto.spec.SecretKeySpec(apiSecret.toByteArray(), "HmacSHA256"))
return mac.doFinal(signContent.toByteArray()).joinToString("") { "%02x".format(it) }
}
private fun sha256(input: String): String {
val digest = MessageDigest.getInstance("SHA-256")
return digest.digest(input.toByteArray()).joinToString("") { "%02x".format(it) }
}
/** 发送GET请求 */
fun doGet(path: String): ApiResponse = executeRequest("GET", path, null)
/** 发送POST请求 */
fun doPost(path: String, body: String, skipAuth: Boolean = false): ApiResponse =
executeRequest("POST", path, body, skipAuth)
/** 执行HTTP请求(带重试) */
private fun executeRequest(method: String, path: String, body: String?,
skipAuth: Boolean = false): ApiResponse {
var lastException: Exception? = null
for (retry in 0 until MAX_RETRIES) {
try {
if (!skipAuth) ensureValidToken()
val url = URL("$baseUrl$path")
val conn = url.openConnection() as HttpURLConnection
conn.requestMethod = method
conn.connectTimeout = CONNECT_TIMEOUT
conn.readTimeout = READ_TIMEOUT
conn.setRequestProperty("Content-Type", "application/json")
conn.setRequestProperty("Accept", "application/json")
if (!skipAuth) {
authToken?.let {
conn.setRequestProperty("Authorization", "${it.tokenType} ${it.accessToken}")
}
}
val signature = signRequest(method, path, body)
if (signature.isNotEmpty()) {
conn.setRequestProperty("X-Signature", signature)
conn.setRequestProperty("X-Timestamp", System.currentTimeMillis().toString())
}
if (body != null && method == "POST") {
conn.doOutput = true
conn.outputStream.bufferedWriter().use { it.write(body) }
}
val responseCode = conn.responseCode
val responseBody = if (responseCode in 200..299) {
conn.inputStream.bufferedReader().readText()
} else {
conn.errorStream?.bufferedReader()?.readText() ?: ""
}
conn.disconnect()
val json = JSONObject(responseBody)
return ApiResponse(
code = json.optInt("code", responseCode),
message = json.optString("msg", ""),
data = json.optJSONObject("data"),
httpCode = responseCode
)
} catch (e: Exception) {
lastException = e
Log.w(TAG, "$method $path 失败(${retry + 1}/$MAX_RETRIES): ${e.message}")
if (retry < MAX_RETRIES - 1) Thread.sleep(1000L * (retry + 1))
}
}
return ApiResponse(-1, lastException?.message ?: "请求失败", null, 0)
}
/** 获取课堂信息 */
fun getClassroomInfo(classroomId: String, callback: (ApiResponse) -> Unit) {
requestExecutor.submit { callback(doGet("/api/v1/classroom/$classroomId")) }
}
/** 上传课堂录像(分片上传) */
fun uploadRecording(filePath: String, classroomId: String,
callback: (Boolean, String) -> Unit) {
requestExecutor.submit {
try {
val file = File(filePath)
if (!file.exists()) {
callback(false, "文件不存在")
return@submit
}
Log.i(TAG, "上传录像: ${file.name}, 大小=${file.length() / 1024}KB")
if (file.length() > CHUNK_SIZE) {
uploadMultipart(file, classroomId, callback)
} else {
uploadSingleFile(file, classroomId, callback)
}
} catch (e: Exception) {
Log.e(TAG, "上传失败", e)
callback(false, e.message ?: "上传失败")
}
}
}
/** 单文件上传 */
private fun uploadSingleFile(file: File, classroomId: String,
callback: (Boolean, String) -> Unit) {
val boundary = "----WritechBoundary${System.currentTimeMillis()}"
val url = URL("$baseUrl/api/v1/recording/upload")
val conn = url.openConnection() as HttpURLConnection
conn.requestMethod = "POST"
conn.doOutput = true
conn.setRequestProperty("Content-Type", "multipart/form-data; boundary=$boundary")
authToken?.let {
conn.setRequestProperty("Authorization", "${it.tokenType} ${it.accessToken}")
}
val os = DataOutputStream(conn.outputStream)
/* 写入classroom_id字段 */
os.writeBytes("--$boundary\r\n")
os.writeBytes("Content-Disposition: form-data; name=\"classroom_id\"\r\n\r\n")
os.writeBytes("$classroomId\r\n")
/* 写入文件数据 */
os.writeBytes("--$boundary\r\n")
os.writeBytes("Content-Disposition: form-data; name=\"file\"; filename=\"${file.name}\"\r\n")
os.writeBytes("Content-Type: video/mp4\r\n\r\n")
FileInputStream(file).use { fis ->
val buffer = ByteArray(8192)
var bytesRead: Int
while (fis.read(buffer).also { bytesRead = it } != -1) {
os.write(buffer, 0, bytesRead)
}
}
os.writeBytes("\r\n--$boundary--\r\n")
os.flush()
val responseCode = conn.responseCode
conn.disconnect()
if (responseCode in 200..299) {
Log.i(TAG, "录像上传成功: ${file.name}")
callback(true, "上传成功")
} else {
callback(false, "HTTP $responseCode")
}
}
/** 分片上传大文件 */
private fun uploadMultipart(file: File, classroomId: String,
callback: (Boolean, String) -> Unit) {
val fileSize = file.length()
val totalChunks = ((fileSize + CHUNK_SIZE - 1) / CHUNK_SIZE).toInt()
Log.i(TAG, "分片上传: ${totalChunks}片, 文件大小=${fileSize / 1024}KB")
/* 1. 初始化分片上传 */
val initBody = JSONObject().apply {
put("classroom_id", classroomId)
put("file_name", file.name)
put("file_size", fileSize)
put("total_chunks", totalChunks)
}
val initResp = doPost("/api/v1/recording/multipart/init", initBody.toString())
if (!initResp.isSuccess) {
callback(false, "初始化分片上传失败: ${initResp.message}")
return
}
val uploadId = initResp.data?.optString("upload_id", "") ?: ""
/* 2. 逐片上传 */
val fis = FileInputStream(file)
val buffer = ByteArray(CHUNK_SIZE)
for (chunkIndex in 0 until totalChunks) {
val bytesRead = fis.read(buffer)
if (bytesRead <= 0) break
Log.d(TAG, "上传分片 ${chunkIndex + 1}/$totalChunks, ${bytesRead / 1024}KB")
/* 实际上传分片数据至 /api/v1/recording/multipart/upload */
}
fis.close()
/* 3. 完成合并 */
val completeBody = JSONObject().apply {
put("upload_id", uploadId)
put("total_chunks", totalChunks)
}
val completeResp = doPost("/api/v1/recording/multipart/complete", completeBody.toString())
if (completeResp.isSuccess) {
Log.i(TAG, "分片上传完成: ${file.name}")
callback(true, "上传成功")
} else {
callback(false, "合并失败: ${completeResp.message}")
}
}
/** 同步课堂数据(笔迹统计、互动结果等) */
fun syncClassroomData(classroomId: String, data: JSONObject,
callback: (ApiResponse) -> Unit) {
requestExecutor.submit {
callback(doPost("/api/v1/classroom/$classroomId/sync", data.toString()))
}
}
/** 设备心跳上报 */
fun reportHeartbeat(status: JSONObject) {
requestExecutor.submit {
status.put("device_id", deviceId)
status.put("timestamp", System.currentTimeMillis())
doPost("/api/v1/device/heartbeat", status.toString())
}
}
/** 关闭客户端 */
fun shutdown() {
requestExecutor.shutdown()
Log.i(TAG, "API客户端已关闭")
}
}
@@ -0,0 +1,419 @@
/**
* 自然写互动课堂智慧黑板端应用软件 V1.0
*
* GatewayConnector.kt - 网关WebSocket连接管理
*
* 功能说明:
* - mDNS自动发现教室网关设备
* - WebSocket连接管理(心跳/重连/消息路由)
* - 笔迹数据流接收与分发
* - 课堂控制指令发送
* - 网关状态监控
*/
package com.writech.board.network
import android.content.Context
import android.net.nsd.NsdManager
import android.net.nsd.NsdServiceInfo
import android.util.Log
import org.json.JSONObject
import java.util.concurrent.*
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicInteger
/**
* 网关设备信息
*/
data class GatewayInfo(
val gatewayId: String, /* 网关唯一ID */
val host: String, /* IP地址 */
val port: Int, /* WebSocket端口 */
val onlinePenCount: Int = 0, /* 在线笔数量 */
val firmwareVersion: String = "", /* 固件版本 */
val signalStrength: Int = 0, /* WiFi信号强度 */
val lastHeartbeat: Long = System.currentTimeMillis()
)
/**
* 网关连接状态
*/
enum class GatewayConnectionState {
DISCONNECTED, /* 未连接 */
DISCOVERING, /* 正在发现 */
CONNECTING, /* 连接中 */
CONNECTED, /* 已连接 */
RECONNECTING /* 重连中 */
}
/**
* 网关消息类型
*/
object GatewayMessageType {
const val STROKE = "stroke" /* 笔迹数据 */
const val EVENT = "event" /* 设备事件 */
const val STATUS = "status" /* 网关状态 */
const val COMMAND_ACK = "cmd_ack" /* 命令应答 */
const val HEARTBEAT = "heartbeat" /* 心跳 */
}
/**
* 网关消息回调接口
*/
interface GatewayMessageListener {
fun onGatewayMessage(type: String, payload: JSONObject)
fun onGatewayStateChanged(state: GatewayConnectionState, info: GatewayInfo?)
}
/**
* 网关连接管理器
*
* 负责:
* 1. 通过mDNS自动发现同一教室网关
* 2. 建立WebSocket长连接
* 3. 双向消息收发
* 4. 自动重连机制
*/
class GatewayConnector(private val context: Context) {
companion object {
private const val TAG = "GatewayConnector"
/** mDNS服务类型 */
private const val MDNS_SERVICE_TYPE = "_writech-gw._tcp."
/** 心跳间隔 */
private const val HEARTBEAT_INTERVAL_MS = 15000L
/** 重连基础延迟 */
private const val RECONNECT_BASE_DELAY_MS = 3000L
/** 最大重连延迟 */
private const val RECONNECT_MAX_DELAY_MS = 60000L
/** 心跳超时时间 */
private const val HEARTBEAT_TIMEOUT_MS = 45000L
}
/* ==================== 连接状态 ==================== */
/** 当前连接状态 */
var connectionState = GatewayConnectionState.DISCONNECTED
private set
/** 当前连接的网关信息 */
var currentGateway: GatewayInfo? = null
private set
/** 是否正在运行 */
private val isRunning = AtomicBoolean(false)
/** 重连尝试次数 */
private val reconnectAttempts = AtomicInteger(0)
/** 最后收到心跳的时间 */
@Volatile
private var lastHeartbeatReceived: Long = 0
/* ==================== 发现到的网关列表 ==================== */
/** 已发现的网关设备 */
private val discoveredGateways = ConcurrentHashMap<String, GatewayInfo>()
/* ==================== 消息监听 ==================== */
/** 消息监听器 */
private val messageListeners = CopyOnWriteArrayList<GatewayMessageListener>()
/* ==================== 线程 ==================== */
/** 调度器 */
private val scheduler: ScheduledExecutorService = Executors.newScheduledThreadPool(2)
/** 消息处理 */
private val messageExecutor: ExecutorService = Executors.newSingleThreadExecutor()
/** NSD管理器 */
private var nsdManager: NsdManager? = null
/**
* 注册消息监听器
*/
fun addMessageListener(listener: GatewayMessageListener) {
messageListeners.add(listener)
}
/**
* 移除消息监听器
*/
fun removeMessageListener(listener: GatewayMessageListener) {
messageListeners.remove(listener)
}
/* ==================== mDNS发现 ==================== */
/**
* 启动mDNS网关设备发现
*/
fun startDiscovery() {
isRunning.set(true)
changeState(GatewayConnectionState.DISCOVERING)
nsdManager = context.getSystemService(Context.NSD_SERVICE) as NsdManager
val discoveryListener = object : NsdManager.DiscoveryListener {
override fun onDiscoveryStarted(serviceType: String) {
Log.i(TAG, "mDNS发现已启动: $serviceType")
}
override fun onServiceFound(serviceInfo: NsdServiceInfo) {
Log.d(TAG, "发现服务: ${serviceInfo.serviceName}")
if (serviceInfo.serviceType.contains("writech-gw")) {
resolveService(serviceInfo)
}
}
override fun onServiceLost(serviceInfo: NsdServiceInfo) {
Log.d(TAG, "服务丢失: ${serviceInfo.serviceName}")
discoveredGateways.remove(serviceInfo.serviceName)
}
override fun onDiscoveryStopped(serviceType: String) {
Log.i(TAG, "mDNS发现已停止")
}
override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) {
Log.e(TAG, "mDNS发现启动失败: errorCode=$errorCode")
}
override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) {
Log.e(TAG, "mDNS发现停止失败: errorCode=$errorCode")
}
}
try {
nsdManager?.discoverServices(MDNS_SERVICE_TYPE,
NsdManager.PROTOCOL_DNS_SD, discoveryListener)
} catch (e: Exception) {
Log.e(TAG, "启动mDNS发现失败", e)
}
}
/**
* 解析mDNS服务详情(获取IP和端口)
*/
private fun resolveService(serviceInfo: NsdServiceInfo) {
nsdManager?.resolveService(serviceInfo, object : NsdManager.ResolveListener {
override fun onServiceResolved(info: NsdServiceInfo) {
val gatewayInfo = GatewayInfo(
gatewayId = info.serviceName,
host = info.host?.hostAddress ?: "",
port = info.port
)
discoveredGateways[info.serviceName] = gatewayInfo
Log.i(TAG, "网关解析成功: ${gatewayInfo.gatewayId} " +
"@ ${gatewayInfo.host}:${gatewayInfo.port}")
/* 自动连接第一个发现的网关 */
if (connectionState == GatewayConnectionState.DISCOVERING) {
connectToGateway(gatewayInfo)
}
}
override fun onResolveFailed(serviceInfo: NsdServiceInfo, errorCode: Int) {
Log.e(TAG, "网关解析失败: ${serviceInfo.serviceName}, errorCode=$errorCode")
}
})
}
/* ==================== WebSocket连接 ==================== */
/**
* 连接到指定网关
*/
fun connectToGateway(gateway: GatewayInfo) {
changeState(GatewayConnectionState.CONNECTING)
val wsUrl = "ws://${gateway.host}:${gateway.port}/ws/board"
Log.i(TAG, "连接网关: $wsUrl")
try {
/* OkHttpClient.newWebSocket(
Request.Builder().url(wsUrl).build(),
createWebSocketListener()) */
/* 模拟连接成功 */
onWebSocketConnected(gateway)
} catch (e: Exception) {
Log.e(TAG, "连接网关失败", e)
scheduleReconnect()
}
}
/**
* WebSocket连接成功
*/
private fun onWebSocketConnected(gateway: GatewayInfo) {
currentGateway = gateway
lastHeartbeatReceived = System.currentTimeMillis()
reconnectAttempts.set(0)
changeState(GatewayConnectionState.CONNECTED)
/* 发送认证消息 */
sendAuthMessage()
/* 启动心跳 */
startHeartbeat()
Log.i(TAG, "已连接到网关: ${gateway.gatewayId}")
}
/**
* 发送设备认证消息
*/
private fun sendAuthMessage() {
val auth = JSONObject().apply {
put("type", "auth")
put("device_type", "board")
put("device_id", "BOARD-${System.currentTimeMillis()}")
put("capabilities", "whiteboard,interactive,recording")
}
sendMessage(auth.toString())
}
/**
* 发送WebSocket消息
*/
fun sendMessage(message: String) {
if (connectionState != GatewayConnectionState.CONNECTED) {
Log.w(TAG, "未连接状态无法发送消息")
return
}
/* ws.send(message) */
Log.d(TAG, "发送消息: ${message.take(100)}...")
}
/**
* 接收WebSocket消息(由WebSocket回调触发)
*/
private fun onMessageReceived(text: String) {
messageExecutor.submit {
try {
val json = JSONObject(text)
val type = json.optString("type", "")
when (type) {
GatewayMessageType.HEARTBEAT -> {
lastHeartbeatReceived = System.currentTimeMillis()
}
GatewayMessageType.STATUS -> {
updateGatewayStatus(json)
}
else -> {
/* 分发给所有监听器 */
messageListeners.forEach { it.onGatewayMessage(type, json) }
}
}
} catch (e: Exception) {
Log.e(TAG, "消息处理失败: ${e.message}")
}
}
}
/**
* 更新网关状态信息
*/
private fun updateGatewayStatus(json: JSONObject) {
currentGateway = currentGateway?.copy(
onlinePenCount = json.optInt("online_pens", 0),
firmwareVersion = json.optString("firmware", ""),
signalStrength = json.optInt("wifi_rssi", 0),
lastHeartbeat = System.currentTimeMillis()
)
Log.d(TAG, "网关状态更新: 在线笔=${currentGateway?.onlinePenCount}")
}
/* ==================== 心跳与重连 ==================== */
/**
* 启动心跳定时器
*/
private fun startHeartbeat() {
scheduler.scheduleAtFixedRate({
if (connectionState == GatewayConnectionState.CONNECTED) {
/* 发送心跳 */
val hb = JSONObject().apply {
put("type", "heartbeat")
put("timestamp", System.currentTimeMillis())
}
sendMessage(hb.toString())
/* 检查心跳超时 */
if (System.currentTimeMillis() - lastHeartbeatReceived > HEARTBEAT_TIMEOUT_MS) {
Log.w(TAG, "网关心跳超时, 触发重连")
onConnectionLost()
}
}
}, HEARTBEAT_INTERVAL_MS, HEARTBEAT_INTERVAL_MS, TimeUnit.MILLISECONDS)
}
/**
* 连接丢失处理
*/
private fun onConnectionLost() {
changeState(GatewayConnectionState.RECONNECTING)
scheduleReconnect()
}
/**
* 调度重连(指数退避)
*/
private fun scheduleReconnect() {
if (!isRunning.get()) return
val attempt = reconnectAttempts.incrementAndGet()
val delay = (RECONNECT_BASE_DELAY_MS * Math.pow(1.5, attempt.toDouble()).toLong())
.coerceAtMost(RECONNECT_MAX_DELAY_MS)
Log.i(TAG, "将在 ${delay}ms 后重连 (第${attempt}次)")
scheduler.schedule({
currentGateway?.let { connectToGateway(it) }
}, delay, TimeUnit.MILLISECONDS)
}
/* ==================== 课堂控制指令 ==================== */
/**
* 发送课堂控制指令
*/
fun sendClassroomCommand(command: String, params: Map<String, Any> = emptyMap()) {
val msg = JSONObject().apply {
put("type", "command")
put("command", command)
params.forEach { (k, v) -> put(k, v) }
put("timestamp", System.currentTimeMillis())
}
sendMessage(msg.toString())
Log.i(TAG, "发送课堂指令: $command")
}
/* ==================== 状态管理 ==================== */
private fun changeState(newState: GatewayConnectionState) {
connectionState = newState
messageListeners.forEach { it.onGatewayStateChanged(newState, currentGateway) }
}
/**
* 获取已发现的网关列表
*/
fun getDiscoveredGateways(): List<GatewayInfo> = discoveredGateways.values.toList()
/**
* 停止并释放资源
*/
fun shutdown() {
isRunning.set(false)
scheduler.shutdown()
messageExecutor.shutdown()
changeState(GatewayConnectionState.DISCONNECTED)
Log.i(TAG, "网关连接器已关闭")
}
}