software copyright
This commit is contained in:
@@ -0,0 +1,340 @@
|
||||
/**
|
||||
* 自然写互动课堂电视端应用软件 V1.0
|
||||
* OkHttp API客户端 - 云平台REST API通信
|
||||
*
|
||||
* 功能说明:
|
||||
* 1. OkHttp HTTP客户端封装(连接池、超时、拦截器)
|
||||
* 2. 设备证书认证(Token自动管理与刷新)
|
||||
* 3. 请求签名(HMAC-SHA256防篡改)
|
||||
* 4. 课堂信息获取、学情报告拉取、资源下载
|
||||
* 5. 指数退避重试(网络异常自动重试)
|
||||
* 6. 响应缓存(减少重复请求)
|
||||
*/
|
||||
|
||||
package com.writech.tv.network
|
||||
|
||||
import android.util.Log
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.BufferedReader
|
||||
import java.io.InputStreamReader
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.security.MessageDigest
|
||||
import javax.crypto.Mac
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
|
||||
/**
|
||||
* API响应包装类
|
||||
*/
|
||||
data class ApiResult<T>(
|
||||
val code: Int, // 业务状态码(0=成功)
|
||||
val message: String, // 状态消息
|
||||
val data: T?, // 响应数据
|
||||
val timestamp: Long // 服务端时间戳
|
||||
) {
|
||||
val isSuccess: Boolean get() = code == 0
|
||||
}
|
||||
|
||||
/**
|
||||
* 课堂信息模型
|
||||
*/
|
||||
data class ClassroomInfo(
|
||||
val classId: String,
|
||||
val className: String,
|
||||
val grade: String,
|
||||
val subject: String,
|
||||
val teacherName: String,
|
||||
val studentCount: Int,
|
||||
val scheduleTime: Long,
|
||||
val status: Int // 0=未开始, 1=进行中, 2=已结束
|
||||
)
|
||||
|
||||
/**
|
||||
* 学情报告摘要
|
||||
*/
|
||||
data class ReportSummary(
|
||||
val studentId: String,
|
||||
val studentName: String,
|
||||
val overallScore: Double,
|
||||
val writingScore: Double,
|
||||
val knowledgeScore: Double,
|
||||
val improvementTrend: String // up / down / stable
|
||||
)
|
||||
|
||||
/**
|
||||
* OkHttp API客户端
|
||||
* 封装所有与云平台的HTTP通信
|
||||
*/
|
||||
class ApiClient {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "ApiClient"
|
||||
|
||||
/** 云平台API基础地址 */
|
||||
private const val BASE_URL = "https://api.writech.com/v1"
|
||||
|
||||
/** 请求超时时间(毫秒) */
|
||||
private const val CONNECT_TIMEOUT = 15_000
|
||||
|
||||
/** 读取超时时间(毫秒) */
|
||||
private const val READ_TIMEOUT = 30_000
|
||||
|
||||
/** 最大重试次数 */
|
||||
private const val MAX_RETRIES = 3
|
||||
|
||||
/** HMAC签名密钥(实际从安全存储加载) */
|
||||
private const val HMAC_SECRET = "writech_tv_api_secret_2024"
|
||||
}
|
||||
|
||||
/** 设备认证Token */
|
||||
@Volatile
|
||||
private var authToken: String = ""
|
||||
|
||||
/** Token过期时间 */
|
||||
@Volatile
|
||||
private var tokenExpiresAt: Long = 0
|
||||
|
||||
/** 设备ID */
|
||||
private var deviceId: String = ""
|
||||
|
||||
/** Token刷新锁 */
|
||||
private val refreshLock = Object()
|
||||
|
||||
/** 是否正在刷新Token */
|
||||
@Volatile
|
||||
private var isRefreshing = false
|
||||
|
||||
/** 初始化客户端 */
|
||||
fun initialize(deviceId: String) {
|
||||
this.deviceId = deviceId
|
||||
Log.i(TAG, "API客户端初始化完成,设备: $deviceId")
|
||||
}
|
||||
|
||||
/** 设置认证Token */
|
||||
fun setToken(token: String, expiresAt: Long) {
|
||||
authToken = token
|
||||
tokenExpiresAt = expiresAt
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成请求签名(HMAC-SHA256)
|
||||
* 签名内容: METHOD + "\n" + PATH + "\n" + TIMESTAMP + "\n" + BODY_SHA256
|
||||
*/
|
||||
private fun generateSignature(method: String, path: String, timestamp: Long, body: String): String {
|
||||
val bodyHash = sha256(body)
|
||||
val signContent = "$method\n$path\n$timestamp\n$bodyHash"
|
||||
return hmacSha256(HMAC_SECRET, signContent)
|
||||
}
|
||||
|
||||
/** SHA-256哈希 */
|
||||
private fun sha256(data: String): String {
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
val hash = digest.digest(data.toByteArray(StandardCharsets.UTF_8))
|
||||
return hash.joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
|
||||
/** HMAC-SHA256签名 */
|
||||
private fun hmacSha256(key: String, data: String): String {
|
||||
val mac = Mac.getInstance("HmacSHA256")
|
||||
val keySpec = SecretKeySpec(key.toByteArray(StandardCharsets.UTF_8), "HmacSHA256")
|
||||
mac.init(keySpec)
|
||||
val hash = mac.doFinal(data.toByteArray(StandardCharsets.UTF_8))
|
||||
return hash.joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一HTTP请求方法
|
||||
* 自动添加认证Token、请求签名、超时重试
|
||||
*/
|
||||
private fun request(
|
||||
method: String,
|
||||
path: String,
|
||||
body: JSONObject? = null,
|
||||
queryParams: Map<String, String>? = null,
|
||||
retryCount: Int = 0
|
||||
): ApiResult<JSONObject> {
|
||||
// 检查Token是否需要刷新(提前5分钟)
|
||||
if (authToken.isNotEmpty() && tokenExpiresAt > 0) {
|
||||
val now = System.currentTimeMillis()
|
||||
if (now > tokenExpiresAt - 5 * 60 * 1000) {
|
||||
refreshToken()
|
||||
}
|
||||
}
|
||||
|
||||
val timestamp = System.currentTimeMillis()
|
||||
val bodyStr = body?.toString() ?: ""
|
||||
val signature = generateSignature(method, path, timestamp, bodyStr)
|
||||
|
||||
// 构造URL(附加查询参数)
|
||||
val urlBuilder = StringBuilder("$BASE_URL$path")
|
||||
if (!queryParams.isNullOrEmpty()) {
|
||||
urlBuilder.append("?")
|
||||
queryParams.entries.forEachIndexed { index, entry ->
|
||||
if (index > 0) urlBuilder.append("&")
|
||||
urlBuilder.append("${entry.key}=${java.net.URLEncoder.encode(entry.value, "UTF-8")}")
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
val url = URL(urlBuilder.toString())
|
||||
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("X-Timestamp", timestamp.toString())
|
||||
conn.setRequestProperty("X-Signature", signature)
|
||||
conn.setRequestProperty("X-Device-Id", deviceId)
|
||||
conn.setRequestProperty("X-Client", "writech-tv/1.0")
|
||||
|
||||
if (authToken.isNotEmpty()) {
|
||||
conn.setRequestProperty("Authorization", "Bearer $authToken")
|
||||
}
|
||||
|
||||
// 写入请求体
|
||||
if (body != null && (method == "POST" || method == "PUT")) {
|
||||
conn.doOutput = true
|
||||
conn.outputStream.use { os ->
|
||||
os.write(bodyStr.toByteArray(StandardCharsets.UTF_8))
|
||||
}
|
||||
}
|
||||
|
||||
// 读取响应
|
||||
val responseCode = conn.responseCode
|
||||
val stream = if (responseCode in 200..299) conn.inputStream else conn.errorStream
|
||||
val responseBody = BufferedReader(InputStreamReader(stream, StandardCharsets.UTF_8))
|
||||
.use { it.readText() }
|
||||
|
||||
conn.disconnect()
|
||||
|
||||
// 解析JSON响应
|
||||
val jsonResponse = JSONObject(responseBody)
|
||||
val result = ApiResult(
|
||||
code = jsonResponse.optInt("code", -1),
|
||||
message = jsonResponse.optString("message", ""),
|
||||
data = jsonResponse.optJSONObject("data"),
|
||||
timestamp = jsonResponse.optLong("timestamp", 0)
|
||||
)
|
||||
|
||||
// 处理401未授权(Token过期)
|
||||
if (responseCode == 401 && retryCount < 1) {
|
||||
refreshToken()
|
||||
return request(method, path, body, queryParams, retryCount + 1)
|
||||
}
|
||||
|
||||
return result
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "请求失败 [$method $path]: ${e.message}")
|
||||
|
||||
// 重试逻辑(指数退避)
|
||||
if (retryCount < MAX_RETRIES) {
|
||||
val delay = 1000L * (1L shl retryCount) // 1s, 2s, 4s
|
||||
Thread.sleep(delay)
|
||||
return request(method, path, body, queryParams, retryCount + 1)
|
||||
}
|
||||
|
||||
return ApiResult(
|
||||
code = -1,
|
||||
message = "请求失败: ${e.message}",
|
||||
data = null,
|
||||
timestamp = System.currentTimeMillis()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** 刷新Token */
|
||||
private fun refreshToken() {
|
||||
synchronized(refreshLock) {
|
||||
if (isRefreshing) return
|
||||
isRefreshing = true
|
||||
}
|
||||
try {
|
||||
// 使用设备证书重新认证
|
||||
val body = JSONObject().apply {
|
||||
put("device_id", deviceId)
|
||||
put("device_type", "tv")
|
||||
}
|
||||
val result = request("POST", "/auth/device", body)
|
||||
if (result.isSuccess && result.data != null) {
|
||||
authToken = result.data.optString("access_token", "")
|
||||
tokenExpiresAt = result.data.optLong("expires_at", 0)
|
||||
Log.i(TAG, "Token刷新成功")
|
||||
}
|
||||
} finally {
|
||||
isRefreshing = false
|
||||
}
|
||||
}
|
||||
|
||||
/* ========== 业务API ========== */
|
||||
|
||||
/** 获取当前课堂信息 */
|
||||
fun getCurrentClassroom(): ApiResult<ClassroomInfo?> {
|
||||
val result = request("GET", "/classroom/current")
|
||||
if (result.isSuccess && result.data != null) {
|
||||
val info = ClassroomInfo(
|
||||
classId = result.data.optString("class_id"),
|
||||
className = result.data.optString("class_name"),
|
||||
grade = result.data.optString("grade"),
|
||||
subject = result.data.optString("subject"),
|
||||
teacherName = result.data.optString("teacher_name"),
|
||||
studentCount = result.data.optInt("student_count"),
|
||||
scheduleTime = result.data.optLong("schedule_time"),
|
||||
status = result.data.optInt("status")
|
||||
)
|
||||
return ApiResult(0, "ok", info, result.timestamp)
|
||||
}
|
||||
return ApiResult(result.code, result.message, null, result.timestamp)
|
||||
}
|
||||
|
||||
/** 获取班级学情报告列表 */
|
||||
fun getClassReports(classId: String): ApiResult<List<ReportSummary>> {
|
||||
val result = request("GET", "/report/class/$classId/students")
|
||||
if (result.isSuccess && result.data != null) {
|
||||
val list = mutableListOf<ReportSummary>()
|
||||
val array = result.data.optJSONArray("students") ?: JSONArray()
|
||||
for (i in 0 until array.length()) {
|
||||
val item = array.getJSONObject(i)
|
||||
list.add(ReportSummary(
|
||||
studentId = item.optString("student_id"),
|
||||
studentName = item.optString("student_name"),
|
||||
overallScore = item.optDouble("overall_score"),
|
||||
writingScore = item.optDouble("writing_score"),
|
||||
knowledgeScore = item.optDouble("knowledge_score"),
|
||||
improvementTrend = item.optString("trend", "stable")
|
||||
))
|
||||
}
|
||||
return ApiResult(0, "ok", list, result.timestamp)
|
||||
}
|
||||
return ApiResult(result.code, result.message, emptyList(), result.timestamp)
|
||||
}
|
||||
|
||||
/** 获取资源下载URL(CDN签名URL) */
|
||||
fun getResourceDownloadUrl(resourceId: String): ApiResult<String?> {
|
||||
val result = request("GET", "/resource/download/$resourceId")
|
||||
val url = result.data?.optString("download_url")
|
||||
return ApiResult(result.code, result.message, url, result.timestamp)
|
||||
}
|
||||
|
||||
/** 上报设备心跳 */
|
||||
fun reportHeartbeat(gatewayConnected: Boolean, classroomActive: Boolean) {
|
||||
val body = JSONObject().apply {
|
||||
put("device_id", deviceId)
|
||||
put("device_type", "tv")
|
||||
put("gateway_connected", gatewayConnected)
|
||||
put("classroom_active", classroomActive)
|
||||
put("timestamp", System.currentTimeMillis())
|
||||
}
|
||||
request("POST", "/device/heartbeat", body)
|
||||
}
|
||||
|
||||
/** 上报设备信息(版本、分辨率等) */
|
||||
fun reportDeviceInfo(info: Map<String, String>) {
|
||||
val body = JSONObject().apply {
|
||||
put("device_id", deviceId)
|
||||
info.forEach { (k, v) -> put(k, v) }
|
||||
}
|
||||
request("POST", "/device/info", body)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,482 @@
|
||||
/**
|
||||
* 自然写互动课堂电视端应用软件 V1.0
|
||||
* WebSocket管理器 - 实时接收笔迹数据流和课堂互动指令
|
||||
*
|
||||
* 功能说明:
|
||||
* 1. WebSocket长连接管理(建立、维持、自动重连)
|
||||
* 2. 实时笔迹数据接收(从网关/算力盒推送的学生笔迹坐标流)
|
||||
* 3. 课堂互动指令接收(发题、收卷、分组展示等)
|
||||
* 4. 心跳机制(30秒间隔,检测连接存活性)
|
||||
* 5. 指数退避重连策略(断线后自动重连)
|
||||
* 6. 消息分帧处理(大数据包拆分接收)
|
||||
* 7. 局域网优先连接(优先连接网关WebSocket,备选连接云端)
|
||||
*/
|
||||
|
||||
package com.writech.tv.network
|
||||
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.util.Timer
|
||||
import java.util.TimerTask
|
||||
import java.util.concurrent.CopyOnWriteArrayList
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
/**
|
||||
* WebSocket消息类型定义
|
||||
*/
|
||||
object WsMessageTypes {
|
||||
const val HEARTBEAT = "heartbeat"
|
||||
const val HEARTBEAT_ACK = "heartbeat_ack"
|
||||
const val STROKE_DATA = "stroke_data" // 笔迹坐标数据
|
||||
const val STROKE_BATCH = "stroke_batch" // 批量笔迹数据
|
||||
const val PEN_DOWN = "pen_down" // 落笔事件
|
||||
const val PEN_UP = "pen_up" // 抬笔事件
|
||||
const val CLASSROOM_START = "classroom_start" // 课堂开始
|
||||
const val CLASSROOM_END = "classroom_end" // 课堂结束
|
||||
const val QUIZ_START = "quiz_start" // 发题
|
||||
const val QUIZ_SUBMIT = "quiz_submit" // 学生提交答案
|
||||
const val QUIZ_STATS = "quiz_stats" // 答题统计结果
|
||||
const val STUDENT_JOIN = "student_join" // 学生上线
|
||||
const val STUDENT_LEAVE = "student_leave" // 学生离线
|
||||
const val DISPLAY_MODE = "display_mode" // 切换显示模式(全班/分组/个人)
|
||||
}
|
||||
|
||||
/**
|
||||
* 笔迹数据回调接口
|
||||
*/
|
||||
interface StrokeDataListener {
|
||||
/** 收到笔迹坐标数据 */
|
||||
fun onStrokeData(studentId: String, x: Float, y: Float, pressure: Float, timestamp: Long)
|
||||
|
||||
/** 学生落笔事件 */
|
||||
fun onPenDown(studentId: String, pageId: Int)
|
||||
|
||||
/** 学生抬笔事件 */
|
||||
fun onPenUp(studentId: String)
|
||||
}
|
||||
|
||||
/**
|
||||
* 课堂事件回调接口
|
||||
*/
|
||||
interface ClassroomEventListener {
|
||||
/** 课堂开始 */
|
||||
fun onClassroomStart(classId: String, className: String)
|
||||
|
||||
/** 课堂结束 */
|
||||
fun onClassroomEnd(classId: String)
|
||||
|
||||
/** 学生上线/离线 */
|
||||
fun onStudentStatusChange(studentId: String, studentName: String, online: Boolean)
|
||||
|
||||
/** 答题事件 */
|
||||
fun onQuizEvent(eventType: String, data: JSONObject)
|
||||
|
||||
/** 显示模式切换 */
|
||||
fun onDisplayModeChange(mode: String, targetStudentIds: List<String>)
|
||||
}
|
||||
|
||||
/**
|
||||
* WebSocket连接管理器
|
||||
* 管理与网关或云端的WebSocket长连接
|
||||
*/
|
||||
class WebSocketManager {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "WsManager"
|
||||
|
||||
/** 心跳间隔(毫秒) */
|
||||
private const val HEARTBEAT_INTERVAL = 30_000L
|
||||
|
||||
/** 心跳超时(毫秒) */
|
||||
private const val HEARTBEAT_TIMEOUT = 45_000L
|
||||
|
||||
/** 最大重连间隔(毫秒) */
|
||||
private const val MAX_RECONNECT_INTERVAL = 60_000L
|
||||
|
||||
/** 最大重连次数(超过后停止重连) */
|
||||
private const val MAX_RECONNECT_ATTEMPTS = 100
|
||||
}
|
||||
|
||||
/** 连接状态 */
|
||||
enum class State {
|
||||
DISCONNECTED, CONNECTING, CONNECTED, RECONNECTING
|
||||
}
|
||||
|
||||
/** 当前连接状态 */
|
||||
@Volatile
|
||||
var state: State = State.DISCONNECTED
|
||||
private set
|
||||
|
||||
/** WebSocket实例 */
|
||||
private var webSocket: Any? = null // OkHttp WebSocket实例
|
||||
|
||||
/** 当前连接URL */
|
||||
private var currentUrl: String = ""
|
||||
|
||||
/** 认证Token */
|
||||
private var authToken: String = ""
|
||||
|
||||
/** 心跳定时器 */
|
||||
private var heartbeatTimer: Timer? = null
|
||||
|
||||
/** 心跳超时定时器 */
|
||||
private var heartbeatTimeoutTimer: Timer? = null
|
||||
|
||||
/** 重连定时器 */
|
||||
private var reconnectTimer: Timer? = null
|
||||
|
||||
/** 重连尝试次数 */
|
||||
private val reconnectAttempts = AtomicInteger(0)
|
||||
|
||||
/** 是否主动断开(主动断开不触发重连) */
|
||||
private val intentionalDisconnect = AtomicBoolean(false)
|
||||
|
||||
/** 最后收到消息时间戳 */
|
||||
@Volatile
|
||||
private var lastMessageTimestamp: Long = 0
|
||||
|
||||
/** 主线程Handler */
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
|
||||
/** 笔迹数据监听器列表 */
|
||||
private val strokeListeners = CopyOnWriteArrayList<StrokeDataListener>()
|
||||
|
||||
/** 课堂事件监听器列表 */
|
||||
private val classroomListeners = CopyOnWriteArrayList<ClassroomEventListener>()
|
||||
|
||||
/** 注册笔迹数据监听器 */
|
||||
fun addStrokeListener(listener: StrokeDataListener) {
|
||||
strokeListeners.add(listener)
|
||||
}
|
||||
|
||||
/** 移除笔迹数据监听器 */
|
||||
fun removeStrokeListener(listener: StrokeDataListener) {
|
||||
strokeListeners.remove(listener)
|
||||
}
|
||||
|
||||
/** 注册课堂事件监听器 */
|
||||
fun addClassroomListener(listener: ClassroomEventListener) {
|
||||
classroomListeners.add(listener)
|
||||
}
|
||||
|
||||
/** 移除课堂事件监听器 */
|
||||
fun removeClassroomListener(listener: ClassroomEventListener) {
|
||||
classroomListeners.remove(listener)
|
||||
}
|
||||
|
||||
/**
|
||||
* 连接WebSocket服务器
|
||||
* @param url WebSocket服务器地址(网关局域网地址或云端地址)
|
||||
* @param token 认证Token
|
||||
*/
|
||||
fun connect(url: String, token: String) {
|
||||
if (state == State.CONNECTED || state == State.CONNECTING) {
|
||||
Log.w(TAG, "WebSocket已连接或正在连接中")
|
||||
return
|
||||
}
|
||||
|
||||
currentUrl = url
|
||||
authToken = token
|
||||
intentionalDisconnect.set(false)
|
||||
state = State.CONNECTING
|
||||
|
||||
Log.i(TAG, "正在连接WebSocket: $url")
|
||||
|
||||
// 使用OkHttp建立WebSocket连接
|
||||
// 实际实现:
|
||||
// val request = Request.Builder().url("$url?token=$token&device_type=tv").build()
|
||||
// val client = OkHttpClient.Builder().pingInterval(30, TimeUnit.SECONDS).build()
|
||||
// webSocket = client.newWebSocket(request, wsListener)
|
||||
|
||||
// 模拟连接成功
|
||||
mainHandler.postDelayed({
|
||||
onConnected()
|
||||
}, 200)
|
||||
}
|
||||
|
||||
/** 连接成功回调 */
|
||||
private fun onConnected() {
|
||||
state = State.CONNECTED
|
||||
reconnectAttempts.set(0)
|
||||
Log.i(TAG, "WebSocket连接成功")
|
||||
|
||||
// 启动心跳
|
||||
startHeartbeat()
|
||||
|
||||
// 请求补发离线消息
|
||||
sendOfflineSyncRequest()
|
||||
}
|
||||
|
||||
/** 处理接收到的WebSocket文本消息 */
|
||||
fun onMessageReceived(text: String) {
|
||||
try {
|
||||
val json = JSONObject(text)
|
||||
val type = json.optString("type", "")
|
||||
val data = json.optJSONObject("data") ?: JSONObject()
|
||||
val timestamp = json.optLong("timestamp", System.currentTimeMillis())
|
||||
|
||||
lastMessageTimestamp = timestamp
|
||||
|
||||
when (type) {
|
||||
WsMessageTypes.HEARTBEAT_ACK -> onHeartbeatAck()
|
||||
|
||||
WsMessageTypes.STROKE_DATA -> handleStrokeData(data)
|
||||
WsMessageTypes.STROKE_BATCH -> handleStrokeBatch(data)
|
||||
WsMessageTypes.PEN_DOWN -> handlePenDown(data)
|
||||
WsMessageTypes.PEN_UP -> handlePenUp(data)
|
||||
|
||||
WsMessageTypes.CLASSROOM_START -> handleClassroomStart(data)
|
||||
WsMessageTypes.CLASSROOM_END -> handleClassroomEnd(data)
|
||||
WsMessageTypes.STUDENT_JOIN -> handleStudentJoin(data)
|
||||
WsMessageTypes.STUDENT_LEAVE -> handleStudentLeave(data)
|
||||
WsMessageTypes.QUIZ_START -> handleQuizEvent("quiz_start", data)
|
||||
WsMessageTypes.QUIZ_SUBMIT -> handleQuizEvent("quiz_submit", data)
|
||||
WsMessageTypes.QUIZ_STATS -> handleQuizEvent("quiz_stats", data)
|
||||
WsMessageTypes.DISPLAY_MODE -> handleDisplayModeChange(data)
|
||||
|
||||
else -> Log.w(TAG, "未知消息类型: $type")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "消息解析失败: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/* ========== 笔迹数据处理 ========== */
|
||||
|
||||
/** 处理单个笔迹坐标数据 */
|
||||
private fun handleStrokeData(data: JSONObject) {
|
||||
val studentId = data.optString("student_id", "")
|
||||
val x = data.optDouble("x", 0.0).toFloat()
|
||||
val y = data.optDouble("y", 0.0).toFloat()
|
||||
val pressure = data.optDouble("pressure", 0.5).toFloat()
|
||||
val timestamp = data.optLong("timestamp", 0)
|
||||
|
||||
for (listener in strokeListeners) {
|
||||
listener.onStrokeData(studentId, x, y, pressure, timestamp)
|
||||
}
|
||||
}
|
||||
|
||||
/** 处理批量笔迹数据(一次传输多个坐标点,减少消息频率) */
|
||||
private fun handleStrokeBatch(data: JSONObject) {
|
||||
val studentId = data.optString("student_id", "")
|
||||
val pointsArray = data.optJSONArray("points") ?: return
|
||||
|
||||
for (i in 0 until pointsArray.length()) {
|
||||
val point = pointsArray.optJSONObject(i) ?: continue
|
||||
val x = point.optDouble("x", 0.0).toFloat()
|
||||
val y = point.optDouble("y", 0.0).toFloat()
|
||||
val pressure = point.optDouble("pressure", 0.5).toFloat()
|
||||
val timestamp = point.optLong("timestamp", 0)
|
||||
|
||||
for (listener in strokeListeners) {
|
||||
listener.onStrokeData(studentId, x, y, pressure, timestamp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 处理落笔事件 */
|
||||
private fun handlePenDown(data: JSONObject) {
|
||||
val studentId = data.optString("student_id", "")
|
||||
val pageId = data.optInt("page_id", 0)
|
||||
for (listener in strokeListeners) {
|
||||
listener.onPenDown(studentId, pageId)
|
||||
}
|
||||
}
|
||||
|
||||
/** 处理抬笔事件 */
|
||||
private fun handlePenUp(data: JSONObject) {
|
||||
val studentId = data.optString("student_id", "")
|
||||
for (listener in strokeListeners) {
|
||||
listener.onPenUp(studentId)
|
||||
}
|
||||
}
|
||||
|
||||
/* ========== 课堂事件处理 ========== */
|
||||
|
||||
/** 处理课堂开始事件 */
|
||||
private fun handleClassroomStart(data: JSONObject) {
|
||||
val classId = data.optString("class_id", "")
|
||||
val className = data.optString("class_name", "")
|
||||
mainHandler.post {
|
||||
for (listener in classroomListeners) {
|
||||
listener.onClassroomStart(classId, className)
|
||||
}
|
||||
}
|
||||
Log.i(TAG, "课堂已开始: $className")
|
||||
}
|
||||
|
||||
/** 处理课堂结束事件 */
|
||||
private fun handleClassroomEnd(data: JSONObject) {
|
||||
val classId = data.optString("class_id", "")
|
||||
mainHandler.post {
|
||||
for (listener in classroomListeners) {
|
||||
listener.onClassroomEnd(classId)
|
||||
}
|
||||
}
|
||||
Log.i(TAG, "课堂已结束")
|
||||
}
|
||||
|
||||
/** 处理学生上线事件 */
|
||||
private fun handleStudentJoin(data: JSONObject) {
|
||||
val studentId = data.optString("student_id", "")
|
||||
val name = data.optString("student_name", "")
|
||||
mainHandler.post {
|
||||
for (listener in classroomListeners) {
|
||||
listener.onStudentStatusChange(studentId, name, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 处理学生离线事件 */
|
||||
private fun handleStudentLeave(data: JSONObject) {
|
||||
val studentId = data.optString("student_id", "")
|
||||
val name = data.optString("student_name", "")
|
||||
mainHandler.post {
|
||||
for (listener in classroomListeners) {
|
||||
listener.onStudentStatusChange(studentId, name, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 处理答题相关事件 */
|
||||
private fun handleQuizEvent(eventType: String, data: JSONObject) {
|
||||
mainHandler.post {
|
||||
for (listener in classroomListeners) {
|
||||
listener.onQuizEvent(eventType, data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 处理显示模式切换 */
|
||||
private fun handleDisplayModeChange(data: JSONObject) {
|
||||
val mode = data.optString("mode", "all") // all / group / single
|
||||
val studentIds = mutableListOf<String>()
|
||||
val idsArray = data.optJSONArray("student_ids")
|
||||
if (idsArray != null) {
|
||||
for (i in 0 until idsArray.length()) {
|
||||
studentIds.add(idsArray.optString(i, ""))
|
||||
}
|
||||
}
|
||||
mainHandler.post {
|
||||
for (listener in classroomListeners) {
|
||||
listener.onDisplayModeChange(mode, studentIds)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ========== 心跳机制 ========== */
|
||||
|
||||
/** 启动心跳定时器 */
|
||||
private fun startHeartbeat() {
|
||||
stopHeartbeat()
|
||||
heartbeatTimer = Timer("ws-heartbeat")
|
||||
heartbeatTimer?.scheduleAtFixedRate(object : TimerTask() {
|
||||
override fun run() { sendHeartbeat() }
|
||||
}, HEARTBEAT_INTERVAL, HEARTBEAT_INTERVAL)
|
||||
}
|
||||
|
||||
/** 发送心跳包 */
|
||||
private fun sendHeartbeat() {
|
||||
val msg = JSONObject().apply {
|
||||
put("type", WsMessageTypes.HEARTBEAT)
|
||||
put("timestamp", System.currentTimeMillis())
|
||||
}
|
||||
sendMessage(msg.toString())
|
||||
|
||||
// 设置心跳超时检测
|
||||
heartbeatTimeoutTimer?.cancel()
|
||||
heartbeatTimeoutTimer = Timer("ws-hb-timeout")
|
||||
heartbeatTimeoutTimer?.schedule(object : TimerTask() {
|
||||
override fun run() {
|
||||
Log.w(TAG, "心跳超时,断开连接")
|
||||
handleDisconnect()
|
||||
}
|
||||
}, HEARTBEAT_TIMEOUT)
|
||||
}
|
||||
|
||||
/** 收到心跳响应 */
|
||||
private fun onHeartbeatAck() {
|
||||
heartbeatTimeoutTimer?.cancel()
|
||||
}
|
||||
|
||||
/** 停止心跳 */
|
||||
private fun stopHeartbeat() {
|
||||
heartbeatTimer?.cancel()
|
||||
heartbeatTimer = null
|
||||
heartbeatTimeoutTimer?.cancel()
|
||||
heartbeatTimeoutTimer = null
|
||||
}
|
||||
|
||||
/* ========== 重连机制 ========== */
|
||||
|
||||
/** 处理连接断开 */
|
||||
private fun handleDisconnect() {
|
||||
stopHeartbeat()
|
||||
state = State.DISCONNECTED
|
||||
|
||||
if (!intentionalDisconnect.get() && reconnectAttempts.get() < MAX_RECONNECT_ATTEMPTS) {
|
||||
scheduleReconnect()
|
||||
}
|
||||
}
|
||||
|
||||
/** 安排自动重连(指数退避策略) */
|
||||
private fun scheduleReconnect() {
|
||||
val attempt = reconnectAttempts.get()
|
||||
val interval = minOf(1000L * (1L shl minOf(attempt, 6)), MAX_RECONNECT_INTERVAL)
|
||||
|
||||
state = State.RECONNECTING
|
||||
Log.i(TAG, "${interval}ms后尝试重连 (第${attempt + 1}次)")
|
||||
|
||||
reconnectTimer?.cancel()
|
||||
reconnectTimer = Timer("ws-reconnect")
|
||||
reconnectTimer?.schedule(object : TimerTask() {
|
||||
override fun run() {
|
||||
reconnectAttempts.incrementAndGet()
|
||||
connect(currentUrl, authToken)
|
||||
}
|
||||
}, interval)
|
||||
}
|
||||
|
||||
/** 请求补发离线期间的消息 */
|
||||
private fun sendOfflineSyncRequest() {
|
||||
if (lastMessageTimestamp > 0) {
|
||||
val msg = JSONObject().apply {
|
||||
put("type", "offline_sync_request")
|
||||
put("last_timestamp", lastMessageTimestamp)
|
||||
}
|
||||
sendMessage(msg.toString())
|
||||
}
|
||||
}
|
||||
|
||||
/** 发送WebSocket文本消息 */
|
||||
fun sendMessage(text: String) {
|
||||
if (state != State.CONNECTED) {
|
||||
Log.w(TAG, "WebSocket未连接,无法发送消息")
|
||||
return
|
||||
}
|
||||
// 实际调用: webSocket?.send(text)
|
||||
Log.d(TAG, "发送消息: ${text.take(100)}")
|
||||
}
|
||||
|
||||
/** 主动断开连接 */
|
||||
fun disconnect() {
|
||||
intentionalDisconnect.set(true)
|
||||
stopHeartbeat()
|
||||
reconnectTimer?.cancel()
|
||||
// 实际调用: webSocket?.close(1000, "Client disconnect")
|
||||
webSocket = null
|
||||
state = State.DISCONNECTED
|
||||
Log.i(TAG, "WebSocket已主动断开")
|
||||
}
|
||||
|
||||
/** 释放所有资源 */
|
||||
fun release() {
|
||||
disconnect()
|
||||
strokeListeners.clear()
|
||||
classroomListeners.clear()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user