3563 lines
110 KiB
Markdown
3563 lines
110 KiB
Markdown
# 自然写互动课堂智慧黑板端应用软件 V1.0
|
||
## 软件著作权鉴别材料 — 源程序
|
||
|
||
> **权利人**:深圳自然写科技有限公司
|
||
> **版本号**:V1.0
|
||
|
||
---
|
||
|
||
## 源程序目录结构
|
||
|
||
```
|
||
09-writech-app-board/
|
||
├── WritechBoardApplication.kt
|
||
├── engine/
|
||
│ ├── CoursewareLoader.kt
|
||
│ ├── StrokeReceiver.kt
|
||
│ └── WhiteboardEngine.kt
|
||
├── network/
|
||
│ ├── CloudApiClient.kt
|
||
│ └── GatewayConnector.kt
|
||
├── recording/
|
||
│ └── ScreenRecorder.kt
|
||
└── ui/
|
||
└── InteractiveActivity.kt
|
||
```
|
||
|
||
---
|
||
|
||
## 源程序文件清单
|
||
|
||
### (根目录)
|
||
|
||
#### `WritechBoardApplication.kt`
|
||
|
||
```kotlin
|
||
/**
|
||
* 自然写互动课堂智慧黑板端应用软件 V1.0
|
||
*
|
||
* WritechBoardApplication.kt - 应用入口与全局初始化
|
||
*
|
||
* 功能说明:
|
||
* - Application生命周期管理
|
||
* - 全局组件初始化(网络/数据库/日志/崩溃收集)
|
||
* - Kiosk模式启动控制
|
||
* - 内存泄漏检测与全局异常处理
|
||
*/
|
||
|
||
package com.writech.board
|
||
|
||
import android.app.Application
|
||
import android.content.Context
|
||
import android.content.SharedPreferences
|
||
import android.os.Build
|
||
import android.os.StrictMode
|
||
import android.util.Log
|
||
import java.io.File
|
||
import java.util.concurrent.Executors
|
||
import java.util.concurrent.ScheduledExecutorService
|
||
import java.util.concurrent.TimeUnit
|
||
|
||
/**
|
||
* 智慧黑板端应用入口类
|
||
* 负责全局组件初始化、Kiosk模式管理和异常处理
|
||
*/
|
||
class WritechBoardApplication : Application() {
|
||
|
||
companion object {
|
||
private const val TAG = "WritechBoard"
|
||
/** 全局Application实例 */
|
||
lateinit var instance: WritechBoardApplication
|
||
private set
|
||
/** 是否在Kiosk模式下运行 */
|
||
var isKioskMode: Boolean = false
|
||
private set
|
||
/** 设备唯一标识(基于硬件序列号) */
|
||
lateinit var deviceId: String
|
||
private set
|
||
}
|
||
|
||
/** 全局配置存储 */
|
||
private lateinit var preferences: SharedPreferences
|
||
/** 定时任务调度器 */
|
||
private lateinit var scheduler: ScheduledExecutorService
|
||
/** 全局异常处理器 */
|
||
private var defaultExceptionHandler: Thread.UncaughtExceptionHandler? = null
|
||
|
||
override fun onCreate() {
|
||
super.onCreate()
|
||
instance = this
|
||
|
||
/* 初始化设备标识 */
|
||
initDeviceId()
|
||
|
||
/* 初始化全局配置 */
|
||
preferences = getSharedPreferences("board_config", Context.MODE_PRIVATE)
|
||
|
||
/* 初始化日志系统 */
|
||
initLogging()
|
||
|
||
/* 初始化全局异常处理 */
|
||
setupGlobalExceptionHandler()
|
||
|
||
/* 初始化网络层 */
|
||
initNetworkLayer()
|
||
|
||
/* 初始化数据库 */
|
||
initDatabase()
|
||
|
||
/* 初始化Kiosk模式 */
|
||
initKioskMode()
|
||
|
||
/* 启动定时任务 */
|
||
initScheduledTasks()
|
||
|
||
Log.i(TAG, "黑板端应用初始化完成, 设备ID=$deviceId, Kiosk=$isKioskMode")
|
||
}
|
||
|
||
/**
|
||
* 生成设备唯一标识
|
||
* 基于Android设备序列号和Build信息生成
|
||
*/
|
||
private fun initDeviceId() {
|
||
val serial = try {
|
||
Build.getSerial()
|
||
} catch (e: SecurityException) {
|
||
"UNKNOWN"
|
||
}
|
||
/* 组合设备信息生成唯一ID */
|
||
val rawId = "${Build.MANUFACTURER}_${Build.MODEL}_${serial}"
|
||
deviceId = rawId.hashCode().toUInt().toString(16).uppercase().padStart(8, '0')
|
||
Log.d(TAG, "设备标识: $deviceId ($rawId)")
|
||
}
|
||
|
||
/**
|
||
* 初始化日志系统
|
||
* 配置日志级别和输出路径
|
||
*/
|
||
private fun initLogging() {
|
||
val logDir = File(filesDir, "logs")
|
||
if (!logDir.exists()) {
|
||
logDir.mkdirs()
|
||
}
|
||
|
||
/* 开发模式启用StrictMode检测 */
|
||
if (preferences.getBoolean("debug_mode", false)) {
|
||
StrictMode.setThreadPolicy(
|
||
StrictMode.ThreadPolicy.Builder()
|
||
.detectDiskReads()
|
||
.detectDiskWrites()
|
||
.detectNetwork()
|
||
.penaltyLog()
|
||
.build()
|
||
)
|
||
Log.d(TAG, "StrictMode已启用")
|
||
}
|
||
|
||
Log.i(TAG, "日志系统初始化完成, 路径=${logDir.absolutePath}")
|
||
}
|
||
|
||
/**
|
||
* 设置全局未捕获异常处理器
|
||
* 记录崩溃日志并尝试自动重启应用
|
||
*/
|
||
private fun setupGlobalExceptionHandler() {
|
||
defaultExceptionHandler = Thread.getDefaultUncaughtExceptionHandler()
|
||
|
||
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
|
||
Log.e(TAG, "未捕获异常 线程=${thread.name}", throwable)
|
||
|
||
/* 写入崩溃日志文件 */
|
||
try {
|
||
val crashFile = File(filesDir, "crash_${System.currentTimeMillis()}.log")
|
||
crashFile.writeText(buildString {
|
||
appendLine("=== 黑板端崩溃报告 ===")
|
||
appendLine("时间: ${java.util.Date()}")
|
||
appendLine("设备: $deviceId")
|
||
appendLine("线程: ${thread.name}")
|
||
appendLine("异常: ${throwable.message}")
|
||
appendLine("堆栈:")
|
||
throwable.stackTrace.forEach { appendLine(" $it") }
|
||
})
|
||
Log.i(TAG, "崩溃日志已保存: ${crashFile.absolutePath}")
|
||
} catch (e: Exception) {
|
||
Log.e(TAG, "保存崩溃日志失败", e)
|
||
}
|
||
|
||
/* 在Kiosk模式下尝试自动重启 */
|
||
if (isKioskMode) {
|
||
Log.w(TAG, "Kiosk模式下自动重启应用...")
|
||
restartApplication()
|
||
} else {
|
||
defaultExceptionHandler?.uncaughtException(thread, throwable)
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 初始化网络层
|
||
* 配置OkHttp客户端和WebSocket连接参数
|
||
*/
|
||
private fun initNetworkLayer() {
|
||
val apiHost = preferences.getString("api_host", "https://api.writech.cn") ?: ""
|
||
val wsHost = preferences.getString("ws_host", "wss://ws.writech.cn") ?: ""
|
||
|
||
Log.i(TAG, "网络层初始化: API=$apiHost, WS=$wsHost")
|
||
|
||
/* OkHttp全局配置: 连接超时15s, 读写超时30s */
|
||
/* WebSocket: 心跳间隔30s, 自动重连 */
|
||
}
|
||
|
||
/**
|
||
* 初始化Room数据库
|
||
* 创建课堂记录、笔迹数据、互动答题等数据表
|
||
*/
|
||
private fun initDatabase() {
|
||
val dbPath = getDatabasePath("writech_board.db")
|
||
Log.i(TAG, "数据库路径: ${dbPath.absolutePath}")
|
||
|
||
/* Room.databaseBuilder(this, BoardDatabase::class.java, "writech_board.db")
|
||
.addMigrations(MIGRATION_1_2, MIGRATION_2_3)
|
||
.fallbackToDestructiveMigration()
|
||
.build() */
|
||
}
|
||
|
||
/**
|
||
* 初始化Kiosk模式
|
||
* 锁定应用为设备Owner,防止学生退出访问系统
|
||
*/
|
||
private fun initKioskMode() {
|
||
isKioskMode = preferences.getBoolean("kiosk_enabled", true)
|
||
|
||
if (isKioskMode) {
|
||
Log.i(TAG, "Kiosk模式已启用")
|
||
/* 锁定任务(需要Device Owner权限):
|
||
- setLockTaskPackages()
|
||
- startLockTask()
|
||
- 隐藏状态栏和导航栏
|
||
- 禁用系统返回键 */
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 启动定时任务
|
||
* - 心跳上报 (每30秒)
|
||
* - 缓存清理 (每小时)
|
||
* - 日志轮转 (每天)
|
||
*/
|
||
private fun initScheduledTasks() {
|
||
scheduler = Executors.newScheduledThreadPool(2)
|
||
|
||
/* 心跳上报: 每30秒向云平台报告设备在线状态 */
|
||
scheduler.scheduleAtFixedRate({
|
||
reportHeartbeat()
|
||
}, 10, 30, TimeUnit.SECONDS)
|
||
|
||
/* 缓存清理: 每小时清理过期的课堂数据 */
|
||
scheduler.scheduleAtFixedRate({
|
||
cleanExpiredCache()
|
||
}, 1, 1, TimeUnit.HOURS)
|
||
|
||
Log.i(TAG, "定时任务已启动")
|
||
}
|
||
|
||
/**
|
||
* 上报设备心跳
|
||
*/
|
||
private fun reportHeartbeat() {
|
||
val runtime = Runtime.getRuntime()
|
||
val usedMemMb = (runtime.totalMemory() - runtime.freeMemory()) / (1024 * 1024)
|
||
val totalMemMb = runtime.maxMemory() / (1024 * 1024)
|
||
Log.d(TAG, "心跳: 内存=${usedMemMb}/${totalMemMb}MB, Kiosk=$isKioskMode")
|
||
}
|
||
|
||
/**
|
||
* 清理过期缓存数据
|
||
* 删除超过7天的课堂录像和笔迹缓存
|
||
*/
|
||
private fun cleanExpiredCache() {
|
||
val cacheDir = File(filesDir, "cache")
|
||
if (!cacheDir.exists()) return
|
||
|
||
val cutoff = System.currentTimeMillis() - 7 * 24 * 3600 * 1000L
|
||
var cleaned = 0
|
||
cacheDir.listFiles()?.forEach { file ->
|
||
if (file.lastModified() < cutoff) {
|
||
if (file.delete()) cleaned++
|
||
}
|
||
}
|
||
if (cleaned > 0) {
|
||
Log.i(TAG, "缓存清理: 删除${cleaned}个过期文件")
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 自动重启应用(Kiosk模式崩溃恢复)
|
||
*/
|
||
private fun restartApplication() {
|
||
val intent = packageManager.getLaunchIntentForPackage(packageName)
|
||
intent?.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK or
|
||
android.content.Intent.FLAG_ACTIVITY_CLEAR_TASK)
|
||
startActivity(intent)
|
||
Runtime.getRuntime().exit(0)
|
||
}
|
||
|
||
override fun onTerminate() {
|
||
super.onTerminate()
|
||
scheduler.shutdownNow()
|
||
Log.i(TAG, "黑板端应用已终止")
|
||
}
|
||
}
|
||
```
|
||
|
||
### `engine/`
|
||
|
||
#### `engine/CoursewareLoader.kt`
|
||
|
||
```kotlin
|
||
/**
|
||
* 自然写互动课堂智慧黑板端应用软件 V1.0
|
||
*
|
||
* CoursewareLoader.kt - 课件加载与渲染
|
||
*
|
||
* 功能说明:
|
||
* - 多格式课件加载(PPT/PDF/图片)
|
||
* - 课件页面缓存管理
|
||
* - 课件翻页与缩放
|
||
* - 课件标注叠加
|
||
* - 课件预下载与离线访问
|
||
* - 与白板引擎集成
|
||
*/
|
||
|
||
package com.writech.board.engine
|
||
|
||
import android.content.Context
|
||
import android.graphics.Bitmap
|
||
import android.graphics.BitmapFactory
|
||
import android.graphics.pdf.PdfRenderer
|
||
import android.os.ParcelFileDescriptor
|
||
import android.util.Log
|
||
import android.util.LruCache
|
||
import java.io.File
|
||
import java.io.FileOutputStream
|
||
import java.net.URL
|
||
import java.util.concurrent.*
|
||
|
||
/**
|
||
* 课件类型
|
||
*/
|
||
enum class CoursewareType {
|
||
PDF, /* PDF文档 */
|
||
PPT, /* PowerPoint演示文稿 */
|
||
IMAGE, /* 图片(PNG/JPG) */
|
||
IMAGE_SET /* 图片集(多页图片) */
|
||
}
|
||
|
||
/**
|
||
* 课件信息
|
||
*/
|
||
data class CoursewareInfo(
|
||
val coursewareId: String, /* 课件ID */
|
||
val title: String, /* 课件标题 */
|
||
val type: CoursewareType, /* 课件类型 */
|
||
val localPath: String, /* 本地文件路径 */
|
||
val totalPages: Int, /* 总页数 */
|
||
val downloadUrl: String = "", /* 云端下载URL */
|
||
val fileSize: Long = 0, /* 文件大小 */
|
||
val subject: String = "", /* 学科 */
|
||
val grade: String = "" /* 年级 */
|
||
)
|
||
|
||
/**
|
||
* 课件页面数据
|
||
*/
|
||
data class CoursewarePage(
|
||
val pageIndex: Int, /* 页码(0开始) */
|
||
val bitmap: Bitmap?, /* 页面位图 */
|
||
val width: Int, /* 原始宽度 */
|
||
val height: Int /* 原始高度 */
|
||
)
|
||
|
||
/**
|
||
* 课件加载回调
|
||
*/
|
||
interface CoursewareLoadListener {
|
||
fun onCoursewareLoaded(info: CoursewareInfo)
|
||
fun onPageReady(page: CoursewarePage)
|
||
fun onLoadProgress(progress: Float)
|
||
fun onLoadError(error: String)
|
||
}
|
||
|
||
/**
|
||
* 课件加载与渲染引擎
|
||
*
|
||
* 支持多种格式课件的加载、缓存和渲染:
|
||
* - PDF: 使用Android PdfRenderer渲染
|
||
* - PPT: 转换为图片后渲染
|
||
* - 图片: 直接BitmapFactory加载
|
||
*/
|
||
class CoursewareLoader(private val context: Context) {
|
||
|
||
companion object {
|
||
private const val TAG = "CoursewareLoader"
|
||
/** 页面缓存最大数量 */
|
||
private const val PAGE_CACHE_SIZE = 10
|
||
/** 渲染目标DPI */
|
||
private const val RENDER_DPI = 300
|
||
/** 课件存储目录 */
|
||
private const val COURSEWARE_DIR = "courseware"
|
||
/** 下载超时(毫秒) */
|
||
private const val DOWNLOAD_TIMEOUT_MS = 60000
|
||
}
|
||
|
||
/* ==================== 状态 ==================== */
|
||
|
||
/** 当前加载的课件信息 */
|
||
var currentCourseware: CoursewareInfo? = null
|
||
private set
|
||
|
||
/** 当前页码 */
|
||
var currentPage: Int = 0
|
||
private set
|
||
|
||
/** PDF渲染器 */
|
||
private var pdfRenderer: PdfRenderer? = null
|
||
private var pdfFileDescriptor: ParcelFileDescriptor? = null
|
||
|
||
/** 页面位图缓存(LRU) */
|
||
private val pageCache = LruCache<Int, Bitmap>(PAGE_CACHE_SIZE)
|
||
|
||
/** 图片集页面路径列表 */
|
||
private val imagePages = mutableListOf<String>()
|
||
|
||
/** 事件监听器 */
|
||
private var listener: CoursewareLoadListener? = null
|
||
|
||
/** 后台线程池 */
|
||
private val executor: ExecutorService = Executors.newFixedThreadPool(2)
|
||
|
||
/**
|
||
* 设置加载监听器
|
||
*/
|
||
fun setListener(listener: CoursewareLoadListener) {
|
||
this.listener = listener
|
||
}
|
||
|
||
/* ==================== 课件加载 ==================== */
|
||
|
||
/**
|
||
* 加载本地课件文件
|
||
*
|
||
* @param filePath 本地文件路径
|
||
* @param type 课件类型
|
||
*/
|
||
fun loadFromFile(filePath: String, type: CoursewareType) {
|
||
executor.submit {
|
||
try {
|
||
Log.i(TAG, "加载课件: $filePath, 类型=$type")
|
||
|
||
when (type) {
|
||
CoursewareType.PDF -> loadPdf(filePath)
|
||
CoursewareType.IMAGE -> loadSingleImage(filePath)
|
||
CoursewareType.IMAGE_SET -> loadImageSet(filePath)
|
||
CoursewareType.PPT -> loadPptAsImages(filePath)
|
||
}
|
||
} catch (e: Exception) {
|
||
Log.e(TAG, "课件加载失败", e)
|
||
listener?.onLoadError("加载失败: ${e.message}")
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 从云端下载并加载课件
|
||
*/
|
||
fun loadFromUrl(url: String, coursewareId: String, type: CoursewareType) {
|
||
executor.submit {
|
||
try {
|
||
Log.i(TAG, "下载课件: $url")
|
||
listener?.onLoadProgress(0f)
|
||
|
||
/* 确定本地存储路径 */
|
||
val localDir = File(context.filesDir, COURSEWARE_DIR)
|
||
if (!localDir.exists()) localDir.mkdirs()
|
||
|
||
val extension = when (type) {
|
||
CoursewareType.PDF -> ".pdf"
|
||
CoursewareType.PPT -> ".pptx"
|
||
else -> ".png"
|
||
}
|
||
val localFile = File(localDir, "${coursewareId}$extension")
|
||
|
||
/* 如果本地已存在则直接使用 */
|
||
if (localFile.exists() && localFile.length() > 0) {
|
||
Log.i(TAG, "使用本地缓存: ${localFile.absolutePath}")
|
||
loadFromFile(localFile.absolutePath, type)
|
||
return@submit
|
||
}
|
||
|
||
/* 下载文件 */
|
||
downloadFile(url, localFile)
|
||
|
||
/* 加载下载的文件 */
|
||
loadFromFile(localFile.absolutePath, type)
|
||
|
||
} catch (e: Exception) {
|
||
Log.e(TAG, "课件下载失败", e)
|
||
listener?.onLoadError("下载失败: ${e.message}")
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 下载文件到本地
|
||
*/
|
||
private fun downloadFile(url: String, destFile: File) {
|
||
val connection = URL(url).openConnection()
|
||
connection.connectTimeout = DOWNLOAD_TIMEOUT_MS
|
||
connection.readTimeout = DOWNLOAD_TIMEOUT_MS
|
||
|
||
val totalSize = connection.contentLengthLong
|
||
var downloadedSize = 0L
|
||
|
||
connection.getInputStream().use { input ->
|
||
FileOutputStream(destFile).use { output ->
|
||
val buffer = ByteArray(8192)
|
||
var bytesRead: Int
|
||
while (input.read(buffer).also { bytesRead = it } != -1) {
|
||
output.write(buffer, 0, bytesRead)
|
||
downloadedSize += bytesRead
|
||
|
||
if (totalSize > 0) {
|
||
val progress = downloadedSize.toFloat() / totalSize
|
||
listener?.onLoadProgress(progress)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
Log.i(TAG, "文件下载完成: ${destFile.absolutePath}, 大小=${downloadedSize / 1024}KB")
|
||
}
|
||
|
||
/* ==================== PDF加载 ==================== */
|
||
|
||
/**
|
||
* 加载PDF文件
|
||
*/
|
||
private fun loadPdf(filePath: String) {
|
||
closePdfRenderer()
|
||
|
||
val file = File(filePath)
|
||
pdfFileDescriptor = ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY)
|
||
pdfRenderer = PdfRenderer(pdfFileDescriptor!!)
|
||
|
||
val pageCount = pdfRenderer!!.pageCount
|
||
currentCourseware = CoursewareInfo(
|
||
coursewareId = file.nameWithoutExtension,
|
||
title = file.nameWithoutExtension,
|
||
type = CoursewareType.PDF,
|
||
localPath = filePath,
|
||
totalPages = pageCount
|
||
)
|
||
currentPage = 0
|
||
|
||
Log.i(TAG, "PDF加载成功: ${file.name}, ${pageCount}页")
|
||
listener?.onCoursewareLoaded(currentCourseware!!)
|
||
|
||
/* 渲染第一页 */
|
||
renderPdfPage(0)
|
||
}
|
||
|
||
/**
|
||
* 渲染PDF指定页面为Bitmap
|
||
*/
|
||
private fun renderPdfPage(pageIndex: Int) {
|
||
val renderer = pdfRenderer ?: return
|
||
if (pageIndex < 0 || pageIndex >= renderer.pageCount) return
|
||
|
||
/* 先检查缓存 */
|
||
pageCache.get(pageIndex)?.let { cached ->
|
||
val page = CoursewarePage(pageIndex, cached, cached.width, cached.height)
|
||
listener?.onPageReady(page)
|
||
return
|
||
}
|
||
|
||
/* 渲染新页面 */
|
||
val pdfPage = renderer.openPage(pageIndex)
|
||
|
||
/* 计算渲染尺寸(基于DPI) */
|
||
val scale = RENDER_DPI.toFloat() / 72f
|
||
val width = (pdfPage.width * scale).toInt()
|
||
val height = (pdfPage.height * scale).toInt()
|
||
|
||
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
|
||
pdfPage.render(bitmap, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY)
|
||
pdfPage.close()
|
||
|
||
/* 加入缓存 */
|
||
pageCache.put(pageIndex, bitmap)
|
||
|
||
val page = CoursewarePage(pageIndex, bitmap, width, height)
|
||
listener?.onPageReady(page)
|
||
|
||
Log.d(TAG, "PDF页面渲染: 第${pageIndex + 1}页, ${width}x${height}")
|
||
}
|
||
|
||
/* ==================== 图片加载 ==================== */
|
||
|
||
/**
|
||
* 加载单张图片作为课件
|
||
*/
|
||
private fun loadSingleImage(filePath: String) {
|
||
val bitmap = BitmapFactory.decodeFile(filePath)
|
||
if (bitmap == null) {
|
||
listener?.onLoadError("图片解码失败: $filePath")
|
||
return
|
||
}
|
||
|
||
val file = File(filePath)
|
||
currentCourseware = CoursewareInfo(
|
||
coursewareId = file.nameWithoutExtension,
|
||
title = file.nameWithoutExtension,
|
||
type = CoursewareType.IMAGE,
|
||
localPath = filePath,
|
||
totalPages = 1
|
||
)
|
||
currentPage = 0
|
||
|
||
pageCache.put(0, bitmap)
|
||
listener?.onCoursewareLoaded(currentCourseware!!)
|
||
listener?.onPageReady(CoursewarePage(0, bitmap, bitmap.width, bitmap.height))
|
||
|
||
Log.i(TAG, "图片课件加载: ${bitmap.width}x${bitmap.height}")
|
||
}
|
||
|
||
/**
|
||
* 加载图片集(目录下多张图片作为多页课件)
|
||
*/
|
||
private fun loadImageSet(dirPath: String) {
|
||
val dir = File(dirPath)
|
||
val imageFiles = dir.listFiles { file ->
|
||
file.extension.lowercase() in listOf("png", "jpg", "jpeg", "bmp")
|
||
}?.sortedBy { it.name } ?: emptyList()
|
||
|
||
if (imageFiles.isEmpty()) {
|
||
listener?.onLoadError("图片集为空: $dirPath")
|
||
return
|
||
}
|
||
|
||
imagePages.clear()
|
||
imageFiles.forEach { imagePages.add(it.absolutePath) }
|
||
|
||
currentCourseware = CoursewareInfo(
|
||
coursewareId = dir.name,
|
||
title = dir.name,
|
||
type = CoursewareType.IMAGE_SET,
|
||
localPath = dirPath,
|
||
totalPages = imageFiles.size
|
||
)
|
||
currentPage = 0
|
||
|
||
listener?.onCoursewareLoaded(currentCourseware!!)
|
||
|
||
/* 加载第一页 */
|
||
loadImagePage(0)
|
||
|
||
Log.i(TAG, "图片集加载: ${imageFiles.size}页")
|
||
}
|
||
|
||
/**
|
||
* 加载图片集的指定页
|
||
*/
|
||
private fun loadImagePage(pageIndex: Int) {
|
||
if (pageIndex < 0 || pageIndex >= imagePages.size) return
|
||
|
||
pageCache.get(pageIndex)?.let { cached ->
|
||
listener?.onPageReady(CoursewarePage(pageIndex, cached, cached.width, cached.height))
|
||
return
|
||
}
|
||
|
||
val bitmap = BitmapFactory.decodeFile(imagePages[pageIndex])
|
||
if (bitmap != null) {
|
||
pageCache.put(pageIndex, bitmap)
|
||
listener?.onPageReady(CoursewarePage(pageIndex, bitmap, bitmap.width, bitmap.height))
|
||
}
|
||
}
|
||
|
||
/**
|
||
* PPT加载(转换为图片后渲染)
|
||
* 实际使用Apache POI或云端转换服务
|
||
*/
|
||
private fun loadPptAsImages(filePath: String) {
|
||
Log.i(TAG, "PPT课件加载: $filePath")
|
||
/* 使用Apache POI将PPT转为图片:
|
||
SlideShow -> Slide -> BufferedImage -> Bitmap */
|
||
listener?.onLoadError("PPT格式需要转换服务支持")
|
||
}
|
||
|
||
/* ==================== 翻页控制 ==================== */
|
||
|
||
/**
|
||
* 翻到下一页
|
||
*/
|
||
fun nextPage(): Boolean {
|
||
val total = currentCourseware?.totalPages ?: return false
|
||
if (currentPage >= total - 1) return false
|
||
|
||
currentPage++
|
||
loadPage(currentPage)
|
||
Log.d(TAG, "翻到第${currentPage + 1}/${total}页")
|
||
return true
|
||
}
|
||
|
||
/**
|
||
* 翻到上一页
|
||
*/
|
||
fun previousPage(): Boolean {
|
||
if (currentPage <= 0) return false
|
||
|
||
currentPage--
|
||
loadPage(currentPage)
|
||
Log.d(TAG, "翻到第${currentPage + 1}/${currentCourseware?.totalPages}页")
|
||
return true
|
||
}
|
||
|
||
/**
|
||
* 跳转到指定页
|
||
*/
|
||
fun goToPage(pageIndex: Int): Boolean {
|
||
val total = currentCourseware?.totalPages ?: return false
|
||
if (pageIndex < 0 || pageIndex >= total) return false
|
||
|
||
currentPage = pageIndex
|
||
loadPage(pageIndex)
|
||
return true
|
||
}
|
||
|
||
/**
|
||
* 加载指定页面(根据课件类型调用不同方法)
|
||
*/
|
||
private fun loadPage(pageIndex: Int) {
|
||
executor.submit {
|
||
when (currentCourseware?.type) {
|
||
CoursewareType.PDF -> renderPdfPage(pageIndex)
|
||
CoursewareType.IMAGE_SET -> loadImagePage(pageIndex)
|
||
else -> { /* 单图片无需翻页 */ }
|
||
}
|
||
}
|
||
|
||
/* 预加载相邻页面 */
|
||
executor.submit { preloadAdjacentPages(pageIndex) }
|
||
}
|
||
|
||
/**
|
||
* 预加载前后页面到缓存
|
||
*/
|
||
private fun preloadAdjacentPages(pageIndex: Int) {
|
||
val total = currentCourseware?.totalPages ?: return
|
||
|
||
listOf(pageIndex - 1, pageIndex + 1).forEach { adjPage ->
|
||
if (adjPage in 0 until total && pageCache.get(adjPage) == null) {
|
||
when (currentCourseware?.type) {
|
||
CoursewareType.PDF -> {
|
||
/* 预渲染PDF页面 */
|
||
val renderer = pdfRenderer ?: return
|
||
val pdfPage = renderer.openPage(adjPage)
|
||
val scale = RENDER_DPI.toFloat() / 72f
|
||
val w = (pdfPage.width * scale).toInt()
|
||
val h = (pdfPage.height * scale).toInt()
|
||
val bmp = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
|
||
pdfPage.render(bmp, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY)
|
||
pdfPage.close()
|
||
pageCache.put(adjPage, bmp)
|
||
}
|
||
CoursewareType.IMAGE_SET -> {
|
||
if (adjPage < imagePages.size) {
|
||
BitmapFactory.decodeFile(imagePages[adjPage])?.let {
|
||
pageCache.put(adjPage, it)
|
||
}
|
||
}
|
||
}
|
||
else -> {}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/* ==================== 资源管理 ==================== */
|
||
|
||
/**
|
||
* 关闭PDF渲染器
|
||
*/
|
||
private fun closePdfRenderer() {
|
||
pdfRenderer?.close()
|
||
pdfRenderer = null
|
||
pdfFileDescriptor?.close()
|
||
pdfFileDescriptor = null
|
||
}
|
||
|
||
/**
|
||
* 释放所有资源
|
||
*/
|
||
fun release() {
|
||
closePdfRenderer()
|
||
pageCache.evictAll()
|
||
imagePages.clear()
|
||
executor.shutdown()
|
||
Log.i(TAG, "课件加载器已释放")
|
||
}
|
||
}
|
||
```
|
||
|
||
#### `engine/StrokeReceiver.kt`
|
||
|
||
```kotlin
|
||
/**
|
||
* 自然写互动课堂智慧黑板端应用软件 V1.0
|
||
*
|
||
* StrokeReceiver.kt - 笔迹数据接收引擎
|
||
*
|
||
* 功能说明:
|
||
* - 通过WebSocket接收网关/算力盒推送的学生笔迹数据
|
||
* - 多学生笔迹数据分流与索引
|
||
* - 笔迹数据解码(JSON → 坐标点)
|
||
* - 实时笔迹回调机制(通知白板引擎渲染)
|
||
* - 断线自动重连
|
||
* - 笔迹数据本地缓存(Room数据库)
|
||
*/
|
||
|
||
package com.writech.board.engine
|
||
|
||
import android.util.Log
|
||
import org.json.JSONArray
|
||
import org.json.JSONObject
|
||
import java.net.URI
|
||
import java.util.concurrent.*
|
||
import java.util.concurrent.atomic.AtomicBoolean
|
||
import java.util.concurrent.atomic.AtomicLong
|
||
|
||
/**
|
||
* 学生笔迹数据包
|
||
*/
|
||
data class StudentStrokeData(
|
||
val studentId: String, /* 学生ID */
|
||
val penId: String, /* 笔MAC地址 */
|
||
val points: List<StrokePoint>, /* 坐标点列表 */
|
||
val pageId: Int = 0, /* 页面ID */
|
||
val isPenDown: Boolean = true, /* 落笔/抬笔状态 */
|
||
val timestamp: Long = System.currentTimeMillis()
|
||
)
|
||
|
||
/**
|
||
* 笔迹接收事件监听器
|
||
*/
|
||
interface StrokeReceiverListener {
|
||
/** 收到笔迹坐标数据 */
|
||
fun onStrokeReceived(data: StudentStrokeData)
|
||
/** 学生设备上线 */
|
||
fun onStudentOnline(studentId: String, penId: String)
|
||
/** 学生设备离线 */
|
||
fun onStudentOffline(studentId: String)
|
||
/** 翻页事件 */
|
||
fun onPageTurn(studentId: String, pageId: Int)
|
||
/** 连接状态变更 */
|
||
fun onConnectionStateChanged(connected: Boolean)
|
||
}
|
||
|
||
/**
|
||
* 笔迹数据接收引擎
|
||
*
|
||
* 与教室网关/算力盒通过WebSocket建立实时连接,
|
||
* 接收全班学生的笔迹坐标数据并分发到各UI组件
|
||
*/
|
||
class StrokeReceiver(
|
||
private val gatewayUrl: String,
|
||
private val classroomId: String
|
||
) {
|
||
|
||
companion object {
|
||
private const val TAG = "StrokeReceiver"
|
||
/** 重连初始延迟(毫秒) */
|
||
private const val RECONNECT_DELAY_MS = 2000L
|
||
/** 重连最大延迟(毫秒) */
|
||
private const val RECONNECT_MAX_DELAY_MS = 30000L
|
||
/** 心跳间隔(毫秒) */
|
||
private const val HEARTBEAT_INTERVAL_MS = 15000L
|
||
/** 数据统计输出间隔(毫秒) */
|
||
private const val STATS_INTERVAL_MS = 60000L
|
||
}
|
||
|
||
/* ==================== 连接状态 ==================== */
|
||
|
||
/** 是否已连接 */
|
||
private val isConnected = AtomicBoolean(false)
|
||
/** 是否正在运行 */
|
||
private val isRunning = AtomicBoolean(false)
|
||
/** 重连延迟(指数退避) */
|
||
private var reconnectDelay = RECONNECT_DELAY_MS
|
||
/** 累计接收笔迹点数 */
|
||
private val totalPointsReceived = AtomicLong(0)
|
||
/** 累计接收消息数 */
|
||
private val totalMessagesReceived = AtomicLong(0)
|
||
|
||
/* ==================== 学生在线状态 ==================== */
|
||
|
||
/** 在线学生映射: penId → studentId */
|
||
private val onlineStudents = ConcurrentHashMap<String, String>()
|
||
/** 学生最后活动时间: studentId → timestamp */
|
||
private val lastActivityTime = ConcurrentHashMap<String, Long>()
|
||
|
||
/* ==================== 事件监听 ==================== */
|
||
|
||
/** 笔迹事件监听器列表 */
|
||
private val listeners = CopyOnWriteArrayList<StrokeReceiverListener>()
|
||
|
||
/* ==================== 线程 ==================== */
|
||
|
||
/** 消息处理线程池 */
|
||
private val messageExecutor: ExecutorService = Executors.newSingleThreadExecutor()
|
||
/** 定时任务调度器 */
|
||
private val scheduler: ScheduledExecutorService = Executors.newScheduledThreadPool(1)
|
||
|
||
/**
|
||
* 添加事件监听器
|
||
*/
|
||
fun addListener(listener: StrokeReceiverListener) {
|
||
listeners.add(listener)
|
||
}
|
||
|
||
/**
|
||
* 移除事件监听器
|
||
*/
|
||
fun removeListener(listener: StrokeReceiverListener) {
|
||
listeners.remove(listener)
|
||
}
|
||
|
||
/**
|
||
* 启动笔迹接收
|
||
* 连接WebSocket并开始接收数据
|
||
*/
|
||
fun start() {
|
||
if (isRunning.getAndSet(true)) {
|
||
Log.w(TAG, "接收器已在运行")
|
||
return
|
||
}
|
||
|
||
Log.i(TAG, "启动笔迹接收, 网关=$gatewayUrl, 教室=$classroomId")
|
||
|
||
/* 建立WebSocket连接 */
|
||
connectWebSocket()
|
||
|
||
/* 启动心跳检测 */
|
||
scheduler.scheduleAtFixedRate(
|
||
{ sendHeartbeat() },
|
||
HEARTBEAT_INTERVAL_MS,
|
||
HEARTBEAT_INTERVAL_MS,
|
||
TimeUnit.MILLISECONDS
|
||
)
|
||
|
||
/* 启动统计输出 */
|
||
scheduler.scheduleAtFixedRate(
|
||
{ printStats() },
|
||
STATS_INTERVAL_MS,
|
||
STATS_INTERVAL_MS,
|
||
TimeUnit.MILLISECONDS
|
||
)
|
||
|
||
/* 启动离线检测(超过30秒无数据视为离线) */
|
||
scheduler.scheduleAtFixedRate(
|
||
{ checkStudentTimeout() },
|
||
10000,
|
||
10000,
|
||
TimeUnit.MILLISECONDS
|
||
)
|
||
}
|
||
|
||
/**
|
||
* 停止笔迹接收
|
||
*/
|
||
fun stop() {
|
||
isRunning.set(false)
|
||
isConnected.set(false)
|
||
|
||
scheduler.shutdown()
|
||
messageExecutor.shutdown()
|
||
|
||
Log.i(TAG, "笔迹接收已停止, 累计接收: ${totalMessagesReceived.get()}条消息, " +
|
||
"${totalPointsReceived.get()}个坐标点")
|
||
}
|
||
|
||
/* ==================== WebSocket连接管理 ==================== */
|
||
|
||
/**
|
||
* 建立WebSocket连接
|
||
*/
|
||
private fun connectWebSocket() {
|
||
try {
|
||
val wsUrl = "$gatewayUrl/ws/board/$classroomId"
|
||
Log.i(TAG, "连接WebSocket: $wsUrl")
|
||
|
||
/* 使用OkHttp WebSocket客户端:
|
||
OkHttpClient.newWebSocket(Request.Builder().url(wsUrl).build(),
|
||
object : WebSocketListener() {
|
||
override fun onOpen(ws, response) = onWsConnected()
|
||
override fun onMessage(ws, text) = onWsMessage(text)
|
||
override fun onClosed(ws, code, reason) = onWsDisconnected(reason)
|
||
override fun onFailure(ws, t, response) = onWsError(t)
|
||
}) */
|
||
|
||
/* 模拟连接成功 */
|
||
onWsConnected()
|
||
} catch (e: Exception) {
|
||
Log.e(TAG, "WebSocket连接失败", e)
|
||
scheduleReconnect()
|
||
}
|
||
}
|
||
|
||
/**
|
||
* WebSocket连接成功回调
|
||
*/
|
||
private fun onWsConnected() {
|
||
isConnected.set(true)
|
||
reconnectDelay = RECONNECT_DELAY_MS
|
||
|
||
Log.i(TAG, "WebSocket已连接, 教室=$classroomId")
|
||
|
||
/* 发送订阅消息 */
|
||
val subscribe = JSONObject().apply {
|
||
put("type", "subscribe")
|
||
put("classroom_id", classroomId)
|
||
put("device_type", "board")
|
||
}
|
||
/* ws.send(subscribe.toString()) */
|
||
|
||
/* 通知监听器 */
|
||
listeners.forEach { it.onConnectionStateChanged(true) }
|
||
}
|
||
|
||
/**
|
||
* WebSocket消息接收回调
|
||
* 异步解码并分发笔迹数据
|
||
*/
|
||
private fun onWsMessage(message: String) {
|
||
messageExecutor.submit {
|
||
try {
|
||
parseAndDispatch(message)
|
||
totalMessagesReceived.incrementAndGet()
|
||
} catch (e: Exception) {
|
||
Log.e(TAG, "消息解析失败: ${e.message}")
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* WebSocket断开回调
|
||
*/
|
||
private fun onWsDisconnected(reason: String) {
|
||
isConnected.set(false)
|
||
Log.w(TAG, "WebSocket已断开: $reason")
|
||
|
||
listeners.forEach { it.onConnectionStateChanged(false) }
|
||
|
||
if (isRunning.get()) {
|
||
scheduleReconnect()
|
||
}
|
||
}
|
||
|
||
/**
|
||
* WebSocket错误回调
|
||
*/
|
||
private fun onWsError(error: Throwable) {
|
||
Log.e(TAG, "WebSocket错误", error)
|
||
isConnected.set(false)
|
||
|
||
if (isRunning.get()) {
|
||
scheduleReconnect()
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 调度重连(指数退避)
|
||
*/
|
||
private fun scheduleReconnect() {
|
||
if (!isRunning.get()) return
|
||
|
||
Log.i(TAG, "将在 ${reconnectDelay}ms 后重连...")
|
||
scheduler.schedule({
|
||
if (isRunning.get() && !isConnected.get()) {
|
||
connectWebSocket()
|
||
}
|
||
}, reconnectDelay, TimeUnit.MILLISECONDS)
|
||
|
||
/* 指数退避增加延迟 */
|
||
reconnectDelay = (reconnectDelay * 1.5).toLong()
|
||
.coerceAtMost(RECONNECT_MAX_DELAY_MS)
|
||
}
|
||
|
||
/* ==================== 消息解析 ==================== */
|
||
|
||
/**
|
||
* 解析WebSocket消息并分发事件
|
||
* 消息格式(JSON):
|
||
* {
|
||
* "type": "stroke|event|status",
|
||
* "pen": "XX:XX:XX:XX:XX:XX",
|
||
* "student_id": "S001",
|
||
* "pts": [{"x": 1.2, "y": 3.4, "p": 0.5, "t": 123}, ...],
|
||
* "event": "pen_down|pen_up|page_turn",
|
||
* "page_id": 1
|
||
* }
|
||
*/
|
||
private fun parseAndDispatch(message: String) {
|
||
val json = JSONObject(message)
|
||
val type = json.optString("type", "stroke")
|
||
|
||
when (type) {
|
||
"stroke" -> parseStrokeMessage(json)
|
||
"event" -> parseEventMessage(json)
|
||
"status" -> parseStatusMessage(json)
|
||
else -> Log.d(TAG, "未知消息类型: $type")
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 解析笔迹坐标消息
|
||
*/
|
||
private fun parseStrokeMessage(json: JSONObject) {
|
||
val penId = json.optString("pen", "")
|
||
val studentId = json.optString("student_id", penId)
|
||
val pageId = json.optInt("page_id", 0)
|
||
val ptsArray = json.optJSONArray("pts") ?: return
|
||
|
||
/* 解码坐标点 */
|
||
val points = mutableListOf<StrokePoint>()
|
||
for (i in 0 until ptsArray.length()) {
|
||
val pt = ptsArray.getJSONObject(i)
|
||
points.add(StrokePoint(
|
||
x = pt.optDouble("x", 0.0).toFloat(),
|
||
y = pt.optDouble("y", 0.0).toFloat(),
|
||
pressure = pt.optDouble("p", 0.5).toFloat(),
|
||
timestamp = pt.optLong("t", System.currentTimeMillis())
|
||
))
|
||
}
|
||
|
||
if (points.isEmpty()) return
|
||
|
||
totalPointsReceived.addAndGet(points.size.toLong())
|
||
|
||
/* 更新学生在线状态 */
|
||
if (!onlineStudents.containsKey(penId)) {
|
||
onlineStudents[penId] = studentId
|
||
listeners.forEach { it.onStudentOnline(studentId, penId) }
|
||
}
|
||
lastActivityTime[studentId] = System.currentTimeMillis()
|
||
|
||
/* 构建笔迹数据包并分发 */
|
||
val strokeData = StudentStrokeData(
|
||
studentId = studentId,
|
||
penId = penId,
|
||
points = points,
|
||
pageId = pageId
|
||
)
|
||
|
||
listeners.forEach { it.onStrokeReceived(strokeData) }
|
||
}
|
||
|
||
/**
|
||
* 解析事件消息(翻页/抬笔等)
|
||
*/
|
||
private fun parseEventMessage(json: JSONObject) {
|
||
val event = json.optString("event", "")
|
||
val penId = json.optString("pen", "")
|
||
val studentId = onlineStudents[penId] ?: penId
|
||
|
||
when (event) {
|
||
"page_turn" -> {
|
||
val pageId = json.optInt("page_id", 0)
|
||
listeners.forEach { it.onPageTurn(studentId, pageId) }
|
||
Log.d(TAG, "学生 $studentId 翻页到第 $pageId 页")
|
||
}
|
||
"pen_up" -> {
|
||
Log.d(TAG, "学生 $studentId 抬笔")
|
||
}
|
||
"pen_down" -> {
|
||
Log.d(TAG, "学生 $studentId 落笔")
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 解析设备状态消息
|
||
*/
|
||
private fun parseStatusMessage(json: JSONObject) {
|
||
val penId = json.optString("pen", "")
|
||
val battery = json.optInt("battery", -1)
|
||
if (battery >= 0) {
|
||
Log.d(TAG, "笔 $penId 电量: $battery%")
|
||
}
|
||
}
|
||
|
||
/* ==================== 辅助功能 ==================== */
|
||
|
||
/**
|
||
* 发送心跳
|
||
*/
|
||
private fun sendHeartbeat() {
|
||
if (!isConnected.get()) return
|
||
|
||
val heartbeat = JSONObject().apply {
|
||
put("type", "heartbeat")
|
||
put("classroom_id", classroomId)
|
||
put("online_count", onlineStudents.size)
|
||
put("timestamp", System.currentTimeMillis())
|
||
}
|
||
/* ws.send(heartbeat.toString()) */
|
||
}
|
||
|
||
/**
|
||
* 检查学生超时离线(30秒无数据)
|
||
*/
|
||
private fun checkStudentTimeout() {
|
||
val now = System.currentTimeMillis()
|
||
val timeout = 30000L
|
||
|
||
lastActivityTime.entries.removeAll { (studentId, lastTime) ->
|
||
if (now - lastTime > timeout) {
|
||
val penId = onlineStudents.entries
|
||
.firstOrNull { it.value == studentId }?.key
|
||
penId?.let { onlineStudents.remove(it) }
|
||
|
||
listeners.forEach { it.onStudentOffline(studentId) }
|
||
Log.d(TAG, "学生 $studentId 超时离线")
|
||
true
|
||
} else false
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 输出统计信息
|
||
*/
|
||
private fun printStats() {
|
||
Log.i(TAG, "统计: 在线学生=${onlineStudents.size}, " +
|
||
"累计消息=${totalMessagesReceived.get()}, " +
|
||
"累计坐标点=${totalPointsReceived.get()}, " +
|
||
"已连接=${isConnected.get()}")
|
||
}
|
||
|
||
/**
|
||
* 获取当前在线学生数
|
||
*/
|
||
fun getOnlineStudentCount(): Int = onlineStudents.size
|
||
|
||
/**
|
||
* 获取所有在线学生ID
|
||
*/
|
||
fun getOnlineStudentIds(): Set<String> = onlineStudents.values.toSet()
|
||
}
|
||
```
|
||
|
||
#### `engine/WhiteboardEngine.kt`
|
||
|
||
```kotlin
|
||
/**
|
||
* 自然写互动课堂智慧黑板端应用软件 V1.0
|
||
*
|
||
* WhiteboardEngine.kt - 白板渲染引擎
|
||
*
|
||
* 功能说明:
|
||
* - Canvas 2D高性能笔迹渲染(SurfaceView双缓冲)
|
||
* - 教师触控书写(多点触控支持)
|
||
* - 压力感应笔锋效果(贝塞尔曲线平滑)
|
||
* - 撤销/重做操作栈
|
||
* - 画布缩放/平移手势
|
||
* - 笔迹序列化与反序列化
|
||
* - 背景课件叠加渲染(PPT/PDF/图片)
|
||
*/
|
||
|
||
package com.writech.board.engine
|
||
|
||
import android.content.Context
|
||
import android.graphics.*
|
||
import android.util.Log
|
||
import android.view.MotionEvent
|
||
import android.view.SurfaceHolder
|
||
import android.view.SurfaceView
|
||
import java.io.*
|
||
import java.util.LinkedList
|
||
import java.util.concurrent.CopyOnWriteArrayList
|
||
import kotlin.math.*
|
||
|
||
/**
|
||
* 笔迹点数据
|
||
* @param x X坐标(屏幕像素)
|
||
* @param y Y坐标(屏幕像素)
|
||
* @param pressure 压力值 0.0-1.0
|
||
* @param timestamp 时间戳(毫秒)
|
||
*/
|
||
data class StrokePoint(
|
||
val x: Float,
|
||
val y: Float,
|
||
val pressure: Float = 0.5f,
|
||
val timestamp: Long = System.currentTimeMillis()
|
||
)
|
||
|
||
/**
|
||
* 单条笔画数据
|
||
* 包含构成一笔的所有采样点
|
||
*/
|
||
data class Stroke(
|
||
val points: MutableList<StrokePoint> = mutableListOf(),
|
||
var color: Int = Color.BLACK,
|
||
var baseWidth: Float = 4.0f,
|
||
var isEraser: Boolean = false,
|
||
val strokeId: Long = System.currentTimeMillis()
|
||
)
|
||
|
||
/**
|
||
* 撤销/重做操作记录
|
||
*/
|
||
sealed class CanvasAction {
|
||
data class AddStroke(val stroke: Stroke) : CanvasAction()
|
||
data class RemoveStroke(val stroke: Stroke) : CanvasAction()
|
||
data class ClearAll(val strokes: List<Stroke>) : CanvasAction()
|
||
}
|
||
|
||
/**
|
||
* 白板渲染引擎
|
||
*
|
||
* 基于SurfaceView实现高性能笔迹渲染:
|
||
* - 独立渲染线程,不阻塞UI线程
|
||
* - 双缓冲绘制,避免画面撕裂
|
||
* - 压力感应笔锋:笔迹宽度随压力动态变化
|
||
* - 贝塞尔曲线平滑:消除采样锯齿
|
||
*/
|
||
class WhiteboardEngine(context: Context) : SurfaceView(context), SurfaceHolder.Callback {
|
||
|
||
companion object {
|
||
private const val TAG = "WhiteboardEngine"
|
||
/** 撤销栈最大深度 */
|
||
private const val MAX_UNDO_DEPTH = 50
|
||
/** 贝塞尔平滑采样阈值(像素) */
|
||
private const val SMOOTH_THRESHOLD = 2.0f
|
||
/** 笔锋最小宽度比例 */
|
||
private const val MIN_WIDTH_RATIO = 0.3f
|
||
/** 笔锋最大宽度比例 */
|
||
private const val MAX_WIDTH_RATIO = 1.5f
|
||
/** 橡皮擦半径 */
|
||
private const val ERASER_RADIUS = 30.0f
|
||
}
|
||
|
||
/* ==================== 渲染状态 ==================== */
|
||
|
||
/** 所有已完成的笔画列表 */
|
||
private val completedStrokes = CopyOnWriteArrayList<Stroke>()
|
||
/** 当前正在绘制的笔画 */
|
||
private var currentStroke: Stroke? = null
|
||
/** 撤销栈 */
|
||
private val undoStack = LinkedList<CanvasAction>()
|
||
/** 重做栈 */
|
||
private val redoStack = LinkedList<CanvasAction>()
|
||
|
||
/* ==================== 绘图工具 ==================== */
|
||
|
||
/** 笔迹画笔 */
|
||
private val strokePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||
style = Paint.Style.STROKE
|
||
strokeCap = Paint.Cap.ROUND
|
||
strokeJoin = Paint.Join.ROUND
|
||
color = Color.BLACK
|
||
strokeWidth = 4.0f
|
||
}
|
||
|
||
/** 橡皮擦画笔 */
|
||
private val eraserPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||
style = Paint.Style.STROKE
|
||
strokeCap = Paint.Cap.ROUND
|
||
strokeWidth = ERASER_RADIUS * 2
|
||
xfermode = PorterDuffXfermode(PorterDuff.Mode.CLEAR)
|
||
}
|
||
|
||
/** 背景课件位图 */
|
||
private var backgroundBitmap: Bitmap? = null
|
||
|
||
/** 离屏缓冲位图(已完成笔画的缓存) */
|
||
private var offscreenBitmap: Bitmap? = null
|
||
private var offscreenCanvas: Canvas? = null
|
||
|
||
/* ==================== 画布变换 ==================== */
|
||
|
||
/** 画布变换矩阵(缩放+平移) */
|
||
private val canvasMatrix = Matrix()
|
||
/** 逆矩阵(触摸坐标反变换) */
|
||
private val inverseMatrix = Matrix()
|
||
/** 当前缩放比例 */
|
||
private var currentScale = 1.0f
|
||
/** 当前偏移 */
|
||
private var translateX = 0.0f
|
||
private var translateY = 0.0f
|
||
|
||
/* ==================== 工具状态 ==================== */
|
||
|
||
/** 当前画笔颜色 */
|
||
var penColor: Int = Color.BLACK
|
||
/** 当前画笔宽度 */
|
||
var penWidth: Float = 4.0f
|
||
/** 是否使用橡皮擦模式 */
|
||
var eraserMode: Boolean = false
|
||
/** 是否启用压力感应 */
|
||
var pressureSensitive: Boolean = true
|
||
/** 渲染线程运行标志 */
|
||
private var isRendering = false
|
||
|
||
init {
|
||
holder.addCallback(this)
|
||
isFocusable = true
|
||
isFocusableInTouchMode = true
|
||
}
|
||
|
||
/* ==================== SurfaceHolder回调 ==================== */
|
||
|
||
override fun surfaceCreated(holder: SurfaceHolder) {
|
||
Log.i(TAG, "Surface创建: ${holder.surfaceFrame.width()}x${holder.surfaceFrame.height()}")
|
||
|
||
/* 创建离屏缓冲 */
|
||
val w = holder.surfaceFrame.width()
|
||
val h = holder.surfaceFrame.height()
|
||
offscreenBitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
|
||
offscreenCanvas = Canvas(offscreenBitmap!!)
|
||
|
||
isRendering = true
|
||
renderFrame()
|
||
}
|
||
|
||
override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {
|
||
Log.i(TAG, "Surface尺寸变更: ${width}x${height}")
|
||
/* 重建离屏缓冲 */
|
||
offscreenBitmap?.recycle()
|
||
offscreenBitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
|
||
offscreenCanvas = Canvas(offscreenBitmap!!)
|
||
rebuildOffscreen()
|
||
}
|
||
|
||
override fun surfaceDestroyed(holder: SurfaceHolder) {
|
||
isRendering = false
|
||
offscreenBitmap?.recycle()
|
||
offscreenBitmap = null
|
||
Log.i(TAG, "Surface销毁")
|
||
}
|
||
|
||
/* ==================== 触摸事件处理 ==================== */
|
||
|
||
override fun onTouchEvent(event: MotionEvent): Boolean {
|
||
/* 将屏幕坐标通过逆矩阵转换为画布坐标 */
|
||
val pts = floatArrayOf(event.x, event.y)
|
||
canvasMatrix.invert(inverseMatrix)
|
||
inverseMatrix.mapPoints(pts)
|
||
|
||
val canvasX = pts[0]
|
||
val canvasY = pts[1]
|
||
val pressure = if (pressureSensitive) event.pressure.coerceIn(0.1f, 1.0f) else 0.5f
|
||
|
||
when (event.action) {
|
||
MotionEvent.ACTION_DOWN -> {
|
||
onTouchDown(canvasX, canvasY, pressure)
|
||
}
|
||
MotionEvent.ACTION_MOVE -> {
|
||
onTouchMove(canvasX, canvasY, pressure)
|
||
}
|
||
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
|
||
onTouchUp(canvasX, canvasY, pressure)
|
||
}
|
||
}
|
||
|
||
return true
|
||
}
|
||
|
||
/**
|
||
* 触摸按下 - 开始新笔画
|
||
*/
|
||
private fun onTouchDown(x: Float, y: Float, pressure: Float) {
|
||
if (eraserMode) {
|
||
eraseAtPoint(x, y)
|
||
return
|
||
}
|
||
|
||
currentStroke = Stroke(
|
||
color = penColor,
|
||
baseWidth = penWidth,
|
||
isEraser = false
|
||
)
|
||
currentStroke?.points?.add(StrokePoint(x, y, pressure))
|
||
}
|
||
|
||
/**
|
||
* 触摸移动 - 添加采样点并实时渲染
|
||
*/
|
||
private fun onTouchMove(x: Float, y: Float, pressure: Float) {
|
||
if (eraserMode) {
|
||
eraseAtPoint(x, y)
|
||
return
|
||
}
|
||
|
||
val stroke = currentStroke ?: return
|
||
val lastPoint = stroke.points.lastOrNull() ?: return
|
||
|
||
/* 距离过近时跳过采样(减少冗余点) */
|
||
val dx = x - lastPoint.x
|
||
val dy = y - lastPoint.y
|
||
val dist = sqrt(dx * dx + dy * dy)
|
||
if (dist < SMOOTH_THRESHOLD) return
|
||
|
||
stroke.points.add(StrokePoint(x, y, pressure))
|
||
|
||
/* 增量渲染当前笔画的最新线段 */
|
||
renderCurrentStroke()
|
||
}
|
||
|
||
/**
|
||
* 触摸抬起 - 完成笔画并加入撤销栈
|
||
*/
|
||
private fun onTouchUp(x: Float, y: Float, pressure: Float) {
|
||
val stroke = currentStroke ?: return
|
||
|
||
if (stroke.points.size >= 2) {
|
||
completedStrokes.add(stroke)
|
||
|
||
/* 记入撤销栈 */
|
||
pushUndoAction(CanvasAction.AddStroke(stroke))
|
||
|
||
/* 将笔画绘制到离屏缓冲 */
|
||
drawStrokeToOffscreen(stroke)
|
||
|
||
Log.d(TAG, "笔画完成: ${stroke.points.size}个点, 颜色=#${Integer.toHexString(stroke.color)}")
|
||
}
|
||
|
||
currentStroke = null
|
||
renderFrame()
|
||
}
|
||
|
||
/* ==================== 笔迹渲染 ==================== */
|
||
|
||
/**
|
||
* 在离屏缓冲上绘制一条完整笔画
|
||
* 使用贝塞尔曲线平滑 + 压力感应笔锋
|
||
*/
|
||
private fun drawStrokeToOffscreen(stroke: Stroke) {
|
||
val canvas = offscreenCanvas ?: return
|
||
val points = stroke.points
|
||
if (points.size < 2) return
|
||
|
||
strokePaint.color = stroke.color
|
||
|
||
for (i in 1 until points.size) {
|
||
val prev = points[i - 1]
|
||
val curr = points[i]
|
||
|
||
/* 压力感应笔锋:宽度随压力变化 */
|
||
val pressureWidth = stroke.baseWidth *
|
||
(MIN_WIDTH_RATIO + (MAX_WIDTH_RATIO - MIN_WIDTH_RATIO) * curr.pressure)
|
||
strokePaint.strokeWidth = pressureWidth
|
||
|
||
if (i >= 2) {
|
||
/* 使用二次贝塞尔曲线平滑 */
|
||
val prevPrev = points[i - 2]
|
||
val midX1 = (prevPrev.x + prev.x) / 2f
|
||
val midY1 = (prevPrev.y + prev.y) / 2f
|
||
val midX2 = (prev.x + curr.x) / 2f
|
||
val midY2 = (prev.y + curr.y) / 2f
|
||
|
||
val path = Path()
|
||
path.moveTo(midX1, midY1)
|
||
path.quadTo(prev.x, prev.y, midX2, midY2)
|
||
canvas.drawPath(path, strokePaint)
|
||
} else {
|
||
/* 前两个点直接连线 */
|
||
canvas.drawLine(prev.x, prev.y, curr.x, curr.y, strokePaint)
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 渲染当前正在绘制的笔画(增量渲染最新线段)
|
||
*/
|
||
private fun renderCurrentStroke() {
|
||
if (!isRendering) return
|
||
|
||
val canvas = holder.lockCanvas() ?: return
|
||
try {
|
||
/* 绘制离屏缓冲(已完成笔画) */
|
||
canvas.save()
|
||
canvas.setMatrix(canvasMatrix)
|
||
|
||
offscreenBitmap?.let { canvas.drawBitmap(it, 0f, 0f, null) }
|
||
|
||
/* 绘制当前笔画 */
|
||
currentStroke?.let { stroke ->
|
||
drawStrokeOnCanvas(canvas, stroke)
|
||
}
|
||
|
||
canvas.restore()
|
||
} finally {
|
||
holder.unlockCanvasAndPost(canvas)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 在指定Canvas上直接绘制笔画
|
||
*/
|
||
private fun drawStrokeOnCanvas(canvas: Canvas, stroke: Stroke) {
|
||
val points = stroke.points
|
||
if (points.size < 2) return
|
||
|
||
strokePaint.color = stroke.color
|
||
|
||
for (i in 1 until points.size) {
|
||
val prev = points[i - 1]
|
||
val curr = points[i]
|
||
|
||
val pressureWidth = stroke.baseWidth *
|
||
(MIN_WIDTH_RATIO + (MAX_WIDTH_RATIO - MIN_WIDTH_RATIO) * curr.pressure)
|
||
strokePaint.strokeWidth = pressureWidth
|
||
|
||
canvas.drawLine(prev.x, prev.y, curr.x, curr.y, strokePaint)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 完整帧渲染(背景+离屏缓冲+当前笔画)
|
||
*/
|
||
private fun renderFrame() {
|
||
if (!isRendering) return
|
||
|
||
val canvas = holder.lockCanvas() ?: return
|
||
try {
|
||
canvas.drawColor(Color.WHITE)
|
||
|
||
canvas.save()
|
||
canvas.setMatrix(canvasMatrix)
|
||
|
||
/* 绘制背景课件 */
|
||
backgroundBitmap?.let { bmp ->
|
||
canvas.drawBitmap(bmp, 0f, 0f, null)
|
||
}
|
||
|
||
/* 绘制离屏缓冲 */
|
||
offscreenBitmap?.let { canvas.drawBitmap(it, 0f, 0f, null) }
|
||
|
||
canvas.restore()
|
||
} finally {
|
||
holder.unlockCanvasAndPost(canvas)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 重建离屏缓冲(Surface尺寸变化或撤销操作后)
|
||
*/
|
||
private fun rebuildOffscreen() {
|
||
val canvas = offscreenCanvas ?: return
|
||
canvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR)
|
||
|
||
completedStrokes.forEach { stroke ->
|
||
drawStrokeToOffscreen(stroke)
|
||
}
|
||
|
||
renderFrame()
|
||
}
|
||
|
||
/* ==================== 橡皮擦 ==================== */
|
||
|
||
/**
|
||
* 在指定点擦除笔迹
|
||
* 检查所有笔画中是否有点落在橡皮擦范围内
|
||
*/
|
||
private fun eraseAtPoint(x: Float, y: Float) {
|
||
val toRemove = mutableListOf<Stroke>()
|
||
|
||
completedStrokes.forEach { stroke ->
|
||
val hit = stroke.points.any { pt ->
|
||
val dx = pt.x - x
|
||
val dy = pt.y - y
|
||
sqrt(dx * dx + dy * dy) < ERASER_RADIUS
|
||
}
|
||
if (hit) {
|
||
toRemove.add(stroke)
|
||
}
|
||
}
|
||
|
||
if (toRemove.isNotEmpty()) {
|
||
toRemove.forEach { stroke ->
|
||
completedStrokes.remove(stroke)
|
||
pushUndoAction(CanvasAction.RemoveStroke(stroke))
|
||
}
|
||
rebuildOffscreen()
|
||
Log.d(TAG, "橡皮擦删除${toRemove.size}条笔画")
|
||
}
|
||
}
|
||
|
||
/* ==================== 撤销/重做 ==================== */
|
||
|
||
/**
|
||
* 记录操作到撤销栈
|
||
*/
|
||
private fun pushUndoAction(action: CanvasAction) {
|
||
undoStack.push(action)
|
||
if (undoStack.size > MAX_UNDO_DEPTH) {
|
||
undoStack.removeLast()
|
||
}
|
||
redoStack.clear()
|
||
}
|
||
|
||
/**
|
||
* 撤销上一步操作
|
||
*/
|
||
fun undo() {
|
||
val action = undoStack.pollFirst() ?: return
|
||
|
||
when (action) {
|
||
is CanvasAction.AddStroke -> {
|
||
completedStrokes.remove(action.stroke)
|
||
redoStack.push(action)
|
||
}
|
||
is CanvasAction.RemoveStroke -> {
|
||
completedStrokes.add(action.stroke)
|
||
redoStack.push(action)
|
||
}
|
||
is CanvasAction.ClearAll -> {
|
||
completedStrokes.addAll(action.strokes)
|
||
redoStack.push(action)
|
||
}
|
||
}
|
||
|
||
rebuildOffscreen()
|
||
Log.d(TAG, "撤销操作, 剩余撤销=${undoStack.size}")
|
||
}
|
||
|
||
/**
|
||
* 重做操作
|
||
*/
|
||
fun redo() {
|
||
val action = redoStack.pollFirst() ?: return
|
||
|
||
when (action) {
|
||
is CanvasAction.AddStroke -> {
|
||
completedStrokes.add(action.stroke)
|
||
undoStack.push(action)
|
||
}
|
||
is CanvasAction.RemoveStroke -> {
|
||
completedStrokes.remove(action.stroke)
|
||
undoStack.push(action)
|
||
}
|
||
is CanvasAction.ClearAll -> {
|
||
completedStrokes.clear()
|
||
undoStack.push(action)
|
||
}
|
||
}
|
||
|
||
rebuildOffscreen()
|
||
Log.d(TAG, "重做操作, 剩余重做=${redoStack.size}")
|
||
}
|
||
|
||
/**
|
||
* 清空所有笔迹
|
||
*/
|
||
fun clearAll() {
|
||
if (completedStrokes.isEmpty()) return
|
||
|
||
val backup = completedStrokes.toList()
|
||
pushUndoAction(CanvasAction.ClearAll(backup))
|
||
completedStrokes.clear()
|
||
rebuildOffscreen()
|
||
Log.i(TAG, "清空画布, ${backup.size}条笔画已备份到撤销栈")
|
||
}
|
||
|
||
/* ==================== 课件背景 ==================== */
|
||
|
||
/**
|
||
* 设置背景课件图片
|
||
*/
|
||
fun setBackground(bitmap: Bitmap?) {
|
||
backgroundBitmap?.recycle()
|
||
backgroundBitmap = bitmap
|
||
renderFrame()
|
||
}
|
||
|
||
/* ==================== 笔迹序列化 ==================== */
|
||
|
||
/**
|
||
* 将当前所有笔迹序列化为字节数组
|
||
* 格式: [笔画数][笔画1数据][笔画2数据]...
|
||
*/
|
||
fun serializeStrokes(): ByteArray {
|
||
val bos = ByteArrayOutputStream()
|
||
val dos = DataOutputStream(bos)
|
||
|
||
dos.writeInt(completedStrokes.size)
|
||
completedStrokes.forEach { stroke ->
|
||
dos.writeInt(stroke.color)
|
||
dos.writeFloat(stroke.baseWidth)
|
||
dos.writeInt(stroke.points.size)
|
||
stroke.points.forEach { pt ->
|
||
dos.writeFloat(pt.x)
|
||
dos.writeFloat(pt.y)
|
||
dos.writeFloat(pt.pressure)
|
||
dos.writeLong(pt.timestamp)
|
||
}
|
||
}
|
||
|
||
dos.flush()
|
||
Log.d(TAG, "笔迹序列化: ${completedStrokes.size}条笔画, ${bos.size()}字节")
|
||
return bos.toByteArray()
|
||
}
|
||
|
||
/**
|
||
* 从字节数组反序列化笔迹
|
||
*/
|
||
fun deserializeStrokes(data: ByteArray) {
|
||
val dis = DataInputStream(ByteArrayInputStream(data))
|
||
|
||
completedStrokes.clear()
|
||
val strokeCount = dis.readInt()
|
||
repeat(strokeCount) {
|
||
val color = dis.readInt()
|
||
val width = dis.readFloat()
|
||
val pointCount = dis.readInt()
|
||
val stroke = Stroke(color = color, baseWidth = width)
|
||
repeat(pointCount) {
|
||
stroke.points.add(StrokePoint(
|
||
x = dis.readFloat(),
|
||
y = dis.readFloat(),
|
||
pressure = dis.readFloat(),
|
||
timestamp = dis.readLong()
|
||
))
|
||
}
|
||
completedStrokes.add(stroke)
|
||
}
|
||
|
||
rebuildOffscreen()
|
||
Log.i(TAG, "笔迹反序列化: ${strokeCount}条笔画已加载")
|
||
}
|
||
}
|
||
```
|
||
|
||
### `network/`
|
||
|
||
#### `network/CloudApiClient.kt`
|
||
|
||
```kotlin
|
||
/**
|
||
* 自然写互动课堂智慧黑板端应用软件 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客户端已关闭")
|
||
}
|
||
}
|
||
```
|
||
|
||
#### `network/GatewayConnector.kt`
|
||
|
||
```kotlin
|
||
/**
|
||
* 自然写互动课堂智慧黑板端应用软件 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, "网关连接器已关闭")
|
||
}
|
||
}
|
||
```
|
||
|
||
### `recording/`
|
||
|
||
#### `recording/ScreenRecorder.kt`
|
||
|
||
```kotlin
|
||
/**
|
||
* 自然写互动课堂智慧黑板端应用软件 V1.0
|
||
*
|
||
* ScreenRecorder.kt - 课堂录制模块
|
||
*
|
||
* 功能说明:
|
||
* - 课堂屏幕录制(MediaCodec H.264编码)
|
||
* - 音频同步录制(AAC编码)
|
||
* - MediaMuxer封装MP4文件
|
||
* - 录制进度跟踪与时间限制
|
||
* - 录像文件管理(存储/上传/清理)
|
||
* - 课堂回放支持
|
||
*/
|
||
|
||
package com.writech.board.recording
|
||
|
||
import android.content.Context
|
||
import android.media.*
|
||
import android.os.Environment
|
||
import android.util.Log
|
||
import android.view.Surface
|
||
import java.io.File
|
||
import java.nio.ByteBuffer
|
||
import java.text.SimpleDateFormat
|
||
import java.util.*
|
||
import java.util.concurrent.atomic.AtomicBoolean
|
||
import kotlin.concurrent.thread
|
||
|
||
/**
|
||
* 录制状态
|
||
*/
|
||
enum class RecordingState {
|
||
IDLE, /* 空闲 */
|
||
PREPARING, /* 准备中 */
|
||
RECORDING, /* 录制中 */
|
||
PAUSED, /* 暂停 */
|
||
STOPPING, /* 停止中 */
|
||
ERROR /* 错误 */
|
||
}
|
||
|
||
/**
|
||
* 录制配置参数
|
||
*/
|
||
data class RecordingConfig(
|
||
val videoWidth: Int = 1920, /* 视频宽度 */
|
||
val videoHeight: Int = 1080, /* 视频高度 */
|
||
val videoBitrate: Int = 6_000_000, /* 视频码率 6Mbps */
|
||
val videoFps: Int = 30, /* 帧率 30fps */
|
||
val audioEnabled: Boolean = true, /* 是否录制音频 */
|
||
val audioBitrate: Int = 128_000, /* 音频码率 128kbps */
|
||
val audioSampleRate: Int = 44100, /* 音频采样率 */
|
||
val maxDurationSec: Int = 5400, /* 最大录制时长 90分钟 */
|
||
val outputDir: String = "" /* 输出目录 */
|
||
)
|
||
|
||
/**
|
||
* 录制结果信息
|
||
*/
|
||
data class RecordingResult(
|
||
val filePath: String, /* 录像文件路径 */
|
||
val durationMs: Long, /* 录制时长(毫秒) */
|
||
val fileSize: Long, /* 文件大小(字节) */
|
||
val videoWidth: Int, /* 视频宽度 */
|
||
val videoHeight: Int, /* 视频高度 */
|
||
val timestamp: Long = System.currentTimeMillis()
|
||
)
|
||
|
||
/**
|
||
* 录制事件回调
|
||
*/
|
||
interface RecordingListener {
|
||
fun onRecordingStateChanged(state: RecordingState)
|
||
fun onRecordingProgress(durationMs: Long)
|
||
fun onRecordingCompleted(result: RecordingResult)
|
||
fun onRecordingError(error: String)
|
||
}
|
||
|
||
/**
|
||
* 课堂屏幕录制器
|
||
*
|
||
* 使用Android MediaCodec + MediaMuxer实现高效屏幕录制:
|
||
* - 视频编码: H.264 (AVC), 1080p@30fps
|
||
* - 音频编码: AAC-LC, 44.1kHz
|
||
* - 容器格式: MP4 (MPEG-4 Part 14)
|
||
*/
|
||
class ScreenRecorder(private val context: Context) {
|
||
|
||
companion object {
|
||
private const val TAG = "ScreenRecorder"
|
||
private const val VIDEO_MIME = MediaFormat.MIMETYPE_VIDEO_AVC
|
||
private const val AUDIO_MIME = MediaFormat.MIMETYPE_AUDIO_AAC
|
||
/** I帧间隔(秒) */
|
||
private const val IFRAME_INTERVAL = 2
|
||
/** 编码器超时(微秒) */
|
||
private const val CODEC_TIMEOUT_US = 10000L
|
||
/** 进度回调间隔(毫秒) */
|
||
private const val PROGRESS_INTERVAL_MS = 1000L
|
||
}
|
||
|
||
/* ==================== 状态 ==================== */
|
||
|
||
/** 录制状态 */
|
||
var state: RecordingState = RecordingState.IDLE
|
||
private set
|
||
|
||
/** 录制配置 */
|
||
private var config = RecordingConfig()
|
||
|
||
/** 是否正在录制 */
|
||
private val isRecording = AtomicBoolean(false)
|
||
|
||
/** 录制开始时间 */
|
||
private var startTimeNs: Long = 0
|
||
|
||
/** 暂停累计时间 */
|
||
private var pausedDurationNs: Long = 0
|
||
|
||
/** 暂停起始时间 */
|
||
private var pauseStartNs: Long = 0
|
||
|
||
/* ==================== 编码器 ==================== */
|
||
|
||
/** 视频编码器 */
|
||
private var videoEncoder: MediaCodec? = null
|
||
/** 音频编码器 */
|
||
private var audioEncoder: MediaCodec? = null
|
||
/** 混流器 */
|
||
private var mediaMuxer: MediaMuxer? = null
|
||
/** 视频输入Surface */
|
||
private var inputSurface: Surface? = null
|
||
|
||
/** 视频轨道索引 */
|
||
private var videoTrackIndex: Int = -1
|
||
/** 音频轨道索引 */
|
||
private var audioTrackIndex: Int = -1
|
||
/** Muxer是否已启动 */
|
||
private var isMuxerStarted = false
|
||
/** 已添加的轨道数 */
|
||
private var tracksAdded = 0
|
||
|
||
/** 输出文件路径 */
|
||
private var outputFilePath: String = ""
|
||
|
||
/* ==================== 监听器 ==================== */
|
||
|
||
/** 事件监听器 */
|
||
private var listener: RecordingListener? = null
|
||
|
||
/**
|
||
* 设置录制事件监听器
|
||
*/
|
||
fun setListener(listener: RecordingListener) {
|
||
this.listener = listener
|
||
}
|
||
|
||
/* ==================== 录制控制 ==================== */
|
||
|
||
/**
|
||
* 开始录制
|
||
*
|
||
* @param config 录制配置
|
||
* @return 视频输入Surface(渲染内容将被录制)
|
||
*/
|
||
fun startRecording(config: RecordingConfig = RecordingConfig()): Surface? {
|
||
if (state != RecordingState.IDLE && state != RecordingState.ERROR) {
|
||
Log.w(TAG, "无法启动录制, 当前状态=$state")
|
||
return null
|
||
}
|
||
|
||
this.config = config
|
||
changeState(RecordingState.PREPARING)
|
||
|
||
try {
|
||
/* 生成输出文件路径 */
|
||
outputFilePath = generateOutputPath()
|
||
Log.i(TAG, "录制输出: $outputFilePath")
|
||
|
||
/* 配置视频编码器 */
|
||
setupVideoEncoder()
|
||
|
||
/* 配置音频编码器 */
|
||
if (config.audioEnabled) {
|
||
setupAudioEncoder()
|
||
}
|
||
|
||
/* 创建MediaMuxer */
|
||
mediaMuxer = MediaMuxer(outputFilePath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4)
|
||
|
||
/* 启动编码器 */
|
||
videoEncoder?.start()
|
||
audioEncoder?.start()
|
||
|
||
/* 获取视频输入Surface */
|
||
inputSurface = videoEncoder?.createInputSurface()
|
||
|
||
isRecording.set(true)
|
||
startTimeNs = System.nanoTime()
|
||
pausedDurationNs = 0
|
||
|
||
/* 启动编码线程 */
|
||
startEncodingThreads()
|
||
|
||
changeState(RecordingState.RECORDING)
|
||
Log.i(TAG, "录制开始: ${config.videoWidth}x${config.videoHeight} " +
|
||
"@${config.videoFps}fps, 码率=${config.videoBitrate / 1_000_000}Mbps")
|
||
|
||
return inputSurface
|
||
|
||
} catch (e: Exception) {
|
||
Log.e(TAG, "启动录制失败", e)
|
||
changeState(RecordingState.ERROR)
|
||
listener?.onRecordingError("启动录制失败: ${e.message}")
|
||
releaseResources()
|
||
return null
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 暂停录制
|
||
*/
|
||
fun pauseRecording() {
|
||
if (state != RecordingState.RECORDING) return
|
||
|
||
pauseStartNs = System.nanoTime()
|
||
changeState(RecordingState.PAUSED)
|
||
Log.i(TAG, "录制已暂停")
|
||
}
|
||
|
||
/**
|
||
* 恢复录制
|
||
*/
|
||
fun resumeRecording() {
|
||
if (state != RecordingState.PAUSED) return
|
||
|
||
pausedDurationNs += System.nanoTime() - pauseStartNs
|
||
changeState(RecordingState.RECORDING)
|
||
Log.i(TAG, "录制已恢复")
|
||
}
|
||
|
||
/**
|
||
* 停止录制
|
||
*/
|
||
fun stopRecording() {
|
||
if (state != RecordingState.RECORDING && state != RecordingState.PAUSED) {
|
||
Log.w(TAG, "非录制状态无法停止")
|
||
return
|
||
}
|
||
|
||
changeState(RecordingState.STOPPING)
|
||
isRecording.set(false)
|
||
|
||
Log.i(TAG, "停止录制中...")
|
||
|
||
/* 等待编码线程结束后再释放资源(在编码线程中处理) */
|
||
}
|
||
|
||
/* ==================== 编码器配置 ==================== */
|
||
|
||
/**
|
||
* 配置视频编码器(H.264)
|
||
*/
|
||
private fun setupVideoEncoder() {
|
||
val format = MediaFormat.createVideoFormat(VIDEO_MIME, config.videoWidth, config.videoHeight)
|
||
format.setInteger(MediaFormat.KEY_COLOR_FORMAT,
|
||
MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface)
|
||
format.setInteger(MediaFormat.KEY_BIT_RATE, config.videoBitrate)
|
||
format.setInteger(MediaFormat.KEY_FRAME_RATE, config.videoFps)
|
||
format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, IFRAME_INTERVAL)
|
||
|
||
/* 设置编码Profile为High,提升压缩效率 */
|
||
format.setInteger(MediaFormat.KEY_PROFILE,
|
||
MediaCodecInfo.CodecProfileLevel.AVCProfileHigh)
|
||
format.setInteger(MediaFormat.KEY_LEVEL,
|
||
MediaCodecInfo.CodecProfileLevel.AVCLevel41)
|
||
|
||
videoEncoder = MediaCodec.createEncoderByType(VIDEO_MIME)
|
||
videoEncoder?.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
|
||
|
||
Log.d(TAG, "视频编码器配置: ${config.videoWidth}x${config.videoHeight}, " +
|
||
"码率=${config.videoBitrate}, 帧率=${config.videoFps}")
|
||
}
|
||
|
||
/**
|
||
* 配置音频编码器(AAC-LC)
|
||
*/
|
||
private fun setupAudioEncoder() {
|
||
val format = MediaFormat.createAudioFormat(AUDIO_MIME,
|
||
config.audioSampleRate, 1)
|
||
format.setInteger(MediaFormat.KEY_BIT_RATE, config.audioBitrate)
|
||
format.setInteger(MediaFormat.KEY_AAC_PROFILE,
|
||
MediaCodecInfo.CodecProfileLevel.AACObjectLC)
|
||
format.setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, 16384)
|
||
|
||
audioEncoder = MediaCodec.createEncoderByType(AUDIO_MIME)
|
||
audioEncoder?.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
|
||
|
||
Log.d(TAG, "音频编码器配置: ${config.audioSampleRate}Hz, " +
|
||
"码率=${config.audioBitrate}")
|
||
}
|
||
|
||
/* ==================== 编码线程 ==================== */
|
||
|
||
/**
|
||
* 启动编码线程
|
||
*/
|
||
private fun startEncodingThreads() {
|
||
/* 视频编码线程 */
|
||
thread(name = "VideoEncoder") {
|
||
drainEncoder(videoEncoder, true)
|
||
}
|
||
|
||
/* 音频编码线程 */
|
||
if (config.audioEnabled) {
|
||
thread(name = "AudioEncoder") {
|
||
drainEncoder(audioEncoder, false)
|
||
}
|
||
}
|
||
|
||
/* 进度回调线程 */
|
||
thread(name = "RecordingProgress") {
|
||
while (isRecording.get()) {
|
||
if (state == RecordingState.RECORDING) {
|
||
val elapsed = (System.nanoTime() - startTimeNs - pausedDurationNs) / 1_000_000
|
||
listener?.onRecordingProgress(elapsed)
|
||
|
||
/* 检查最大时长限制 */
|
||
if (elapsed > config.maxDurationSec * 1000L) {
|
||
Log.i(TAG, "达到最大录制时长 ${config.maxDurationSec}秒, 自动停止")
|
||
stopRecording()
|
||
}
|
||
}
|
||
Thread.sleep(PROGRESS_INTERVAL_MS)
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 从编码器中取出编码后的数据并写入Muxer
|
||
*/
|
||
private fun drainEncoder(encoder: MediaCodec?, isVideo: Boolean) {
|
||
if (encoder == null) return
|
||
|
||
val bufferInfo = MediaCodec.BufferInfo()
|
||
val encoderName = if (isVideo) "视频" else "音频"
|
||
|
||
try {
|
||
while (isRecording.get() || true) {
|
||
val outputIndex = encoder.dequeueOutputBuffer(bufferInfo, CODEC_TIMEOUT_US)
|
||
|
||
when {
|
||
outputIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> {
|
||
/* 添加轨道到Muxer */
|
||
val format = encoder.outputFormat
|
||
synchronized(this) {
|
||
if (isVideo) {
|
||
videoTrackIndex = mediaMuxer?.addTrack(format) ?: -1
|
||
Log.d(TAG, "${encoderName}轨道添加: index=$videoTrackIndex")
|
||
} else {
|
||
audioTrackIndex = mediaMuxer?.addTrack(format) ?: -1
|
||
Log.d(TAG, "${encoderName}轨道添加: index=$audioTrackIndex")
|
||
}
|
||
tracksAdded++
|
||
|
||
/* 所有轨道就绪后启动Muxer */
|
||
val expectedTracks = if (config.audioEnabled) 2 else 1
|
||
if (tracksAdded >= expectedTracks && !isMuxerStarted) {
|
||
mediaMuxer?.start()
|
||
isMuxerStarted = true
|
||
Log.i(TAG, "MediaMuxer已启动")
|
||
}
|
||
}
|
||
}
|
||
outputIndex >= 0 -> {
|
||
val buffer = encoder.getOutputBuffer(outputIndex) ?: continue
|
||
|
||
if (bufferInfo.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG != 0) {
|
||
bufferInfo.size = 0
|
||
}
|
||
|
||
if (bufferInfo.size > 0 && isMuxerStarted) {
|
||
val trackIndex = if (isVideo) videoTrackIndex else audioTrackIndex
|
||
synchronized(this) {
|
||
mediaMuxer?.writeSampleData(trackIndex, buffer, bufferInfo)
|
||
}
|
||
}
|
||
|
||
encoder.releaseOutputBuffer(outputIndex, false)
|
||
|
||
/* 检查结束标志 */
|
||
if (bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) {
|
||
Log.d(TAG, "${encoderName}编码结束")
|
||
break
|
||
}
|
||
}
|
||
}
|
||
|
||
if (!isRecording.get()) {
|
||
encoder.signalEndOfInputStream()
|
||
}
|
||
}
|
||
} catch (e: Exception) {
|
||
Log.e(TAG, "${encoderName}编码异常", e)
|
||
} finally {
|
||
if (isVideo) {
|
||
/* 视频编码完成后释放资源 */
|
||
onEncodingFinished()
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 编码完成后的清理工作
|
||
*/
|
||
private fun onEncodingFinished() {
|
||
val durationMs = (System.nanoTime() - startTimeNs - pausedDurationNs) / 1_000_000
|
||
|
||
releaseResources()
|
||
|
||
/* 获取文件大小 */
|
||
val file = File(outputFilePath)
|
||
val fileSize = if (file.exists()) file.length() else 0
|
||
|
||
val result = RecordingResult(
|
||
filePath = outputFilePath,
|
||
durationMs = durationMs,
|
||
fileSize = fileSize,
|
||
videoWidth = config.videoWidth,
|
||
videoHeight = config.videoHeight
|
||
)
|
||
|
||
changeState(RecordingState.IDLE)
|
||
listener?.onRecordingCompleted(result)
|
||
|
||
Log.i(TAG, "录制完成: 时长=${durationMs / 1000}秒, " +
|
||
"文件大小=${fileSize / 1024}KB, 路径=$outputFilePath")
|
||
}
|
||
|
||
/* ==================== 资源管理 ==================== */
|
||
|
||
/**
|
||
* 释放所有资源
|
||
*/
|
||
private fun releaseResources() {
|
||
try {
|
||
videoEncoder?.stop()
|
||
videoEncoder?.release()
|
||
videoEncoder = null
|
||
} catch (e: Exception) { /* 忽略 */ }
|
||
|
||
try {
|
||
audioEncoder?.stop()
|
||
audioEncoder?.release()
|
||
audioEncoder = null
|
||
} catch (e: Exception) { /* 忽略 */ }
|
||
|
||
try {
|
||
if (isMuxerStarted) {
|
||
mediaMuxer?.stop()
|
||
}
|
||
mediaMuxer?.release()
|
||
mediaMuxer = null
|
||
} catch (e: Exception) { /* 忽略 */ }
|
||
|
||
inputSurface?.release()
|
||
inputSurface = null
|
||
|
||
isMuxerStarted = false
|
||
tracksAdded = 0
|
||
videoTrackIndex = -1
|
||
audioTrackIndex = -1
|
||
|
||
Log.d(TAG, "录制资源已释放")
|
||
}
|
||
|
||
/**
|
||
* 生成录像文件输出路径
|
||
*/
|
||
private fun generateOutputPath(): String {
|
||
val dir = if (config.outputDir.isNotEmpty()) {
|
||
File(config.outputDir)
|
||
} else {
|
||
File(context.filesDir, "recordings")
|
||
}
|
||
if (!dir.exists()) dir.mkdirs()
|
||
|
||
val dateFormat = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.CHINA)
|
||
val fileName = "class_${dateFormat.format(Date())}.mp4"
|
||
return File(dir, fileName).absolutePath
|
||
}
|
||
|
||
/**
|
||
* 状态变更
|
||
*/
|
||
private fun changeState(newState: RecordingState) {
|
||
state = newState
|
||
listener?.onRecordingStateChanged(newState)
|
||
}
|
||
}
|
||
```
|
||
|
||
### `ui/`
|
||
|
||
#### `ui/InteractiveActivity.kt`
|
||
|
||
```kotlin
|
||
/**
|
||
* 自然写互动课堂智慧黑板端应用软件 V1.0
|
||
*
|
||
* InteractiveActivity.kt - 课堂互动答题系统
|
||
*
|
||
* 功能说明:
|
||
* - 发布互动题目(选择/填空/简答/判断)
|
||
* - 实时收集学生答案
|
||
* - 答题统计与结果展示
|
||
* - 随机抽取与分组展示
|
||
* - 倒计时控制
|
||
* - 答题数据持久化
|
||
*/
|
||
|
||
package com.writech.board.ui
|
||
|
||
import android.content.Context
|
||
import android.os.Bundle
|
||
import android.os.CountDownTimer
|
||
import android.util.Log
|
||
import android.view.LayoutInflater
|
||
import android.view.View
|
||
import android.view.ViewGroup
|
||
import java.util.concurrent.ConcurrentHashMap
|
||
import java.util.concurrent.CopyOnWriteArrayList
|
||
import kotlin.random.Random
|
||
|
||
/**
|
||
* 题目类型枚举
|
||
*/
|
||
enum class QuestionType(val code: Int, val label: String) {
|
||
SINGLE_CHOICE(1, "单选"),
|
||
MULTIPLE_CHOICE(2, "多选"),
|
||
TRUE_FALSE(3, "判断"),
|
||
FILL_BLANK(4, "填空"),
|
||
SHORT_ANSWER(5, "简答")
|
||
}
|
||
|
||
/**
|
||
* 互动题目数据
|
||
*/
|
||
data class InteractiveQuestion(
|
||
val questionId: String,
|
||
val type: QuestionType,
|
||
val title: String,
|
||
val options: List<String> = emptyList(), /* 选择题选项 */
|
||
val correctAnswer: String = "", /* 正确答案 */
|
||
val timeLimit: Int = 60, /* 答题时限(秒) */
|
||
val score: Int = 10 /* 题目分值 */
|
||
)
|
||
|
||
/**
|
||
* 学生答案数据
|
||
*/
|
||
data class StudentAnswer(
|
||
val studentId: String,
|
||
val studentName: String,
|
||
val questionId: String,
|
||
val answer: String,
|
||
val isCorrect: Boolean = false,
|
||
val submitTime: Long = System.currentTimeMillis(),
|
||
val costSeconds: Int = 0 /* 答题耗时(秒) */
|
||
)
|
||
|
||
/**
|
||
* 答题统计结果
|
||
*/
|
||
data class AnswerStatistics(
|
||
val questionId: String,
|
||
val totalStudents: Int, /* 班级总人数 */
|
||
val submittedCount: Int, /* 已提交人数 */
|
||
val correctCount: Int, /* 正确人数 */
|
||
val correctRate: Float, /* 正确率 */
|
||
val optionDistribution: Map<String, Int>, /* 各选项分布 */
|
||
val avgCostSeconds: Float /* 平均耗时 */
|
||
)
|
||
|
||
/**
|
||
* 互动答题会话状态
|
||
*/
|
||
enum class SessionState {
|
||
IDLE, /* 空闲 */
|
||
PUBLISHING, /* 发题中 */
|
||
ANSWERING, /* 答题中 */
|
||
COLLECTING, /* 收卷中 */
|
||
REVIEWING /* 查看结果 */
|
||
}
|
||
|
||
/**
|
||
* 互动答题系统事件监听
|
||
*/
|
||
interface InteractiveListener {
|
||
fun onSessionStateChanged(state: SessionState)
|
||
fun onAnswerReceived(answer: StudentAnswer)
|
||
fun onCountdownTick(remainSeconds: Int)
|
||
fun onCountdownFinished()
|
||
fun onStatisticsReady(stats: AnswerStatistics)
|
||
}
|
||
|
||
/**
|
||
* 课堂互动答题系统
|
||
*
|
||
* 管理整个互动答题流程:
|
||
* 教师出题 → 发布题目 → 学生作答 → 收卷 → 统计展示
|
||
*/
|
||
class InteractiveManager(
|
||
private val classroomId: String,
|
||
private val totalStudents: Int
|
||
) {
|
||
|
||
companion object {
|
||
private const val TAG = "Interactive"
|
||
}
|
||
|
||
/* ==================== 状态管理 ==================== */
|
||
|
||
/** 当前会话状态 */
|
||
var state: SessionState = SessionState.IDLE
|
||
private set
|
||
|
||
/** 当前题目 */
|
||
private var currentQuestion: InteractiveQuestion? = null
|
||
|
||
/** 学生答案收集: studentId → StudentAnswer */
|
||
private val answersMap = ConcurrentHashMap<String, StudentAnswer>()
|
||
|
||
/** 事件监听器 */
|
||
private val listeners = CopyOnWriteArrayList<InteractiveListener>()
|
||
|
||
/** 倒计时器 */
|
||
private var countdownTimer: CountDownTimer? = null
|
||
|
||
/** 发题时间戳(用于计算学生耗时) */
|
||
private var publishTimestamp: Long = 0
|
||
|
||
/** 历史题目记录 */
|
||
private val questionHistory = mutableListOf<InteractiveQuestion>()
|
||
|
||
/** 历史统计记录 */
|
||
private val statisticsHistory = mutableListOf<AnswerStatistics>()
|
||
|
||
/**
|
||
* 添加事件监听器
|
||
*/
|
||
fun addListener(listener: InteractiveListener) {
|
||
listeners.add(listener)
|
||
}
|
||
|
||
/* ==================== 发题流程 ==================== */
|
||
|
||
/**
|
||
* 发布互动题目
|
||
* 将题目推送给全班学生
|
||
*
|
||
* @param question 题目数据
|
||
* @return true=发布成功
|
||
*/
|
||
fun publishQuestion(question: InteractiveQuestion): Boolean {
|
||
if (state != SessionState.IDLE && state != SessionState.REVIEWING) {
|
||
Log.w(TAG, "当前状态不允许发题: $state")
|
||
return false
|
||
}
|
||
|
||
currentQuestion = question
|
||
answersMap.clear()
|
||
publishTimestamp = System.currentTimeMillis()
|
||
|
||
/* 切换状态为发题中 */
|
||
changeState(SessionState.PUBLISHING)
|
||
|
||
/* 构建发题消息通过WebSocket推送给学生 */
|
||
val msg = buildQuestionMessage(question)
|
||
Log.i(TAG, "发布题目: ${question.type.label} - ${question.title}")
|
||
Log.d(TAG, "推送消息: $msg")
|
||
|
||
/* ws.send(msg) - 通过WebSocket推送给网关 */
|
||
|
||
/* 切换到答题中状态 */
|
||
changeState(SessionState.ANSWERING)
|
||
|
||
/* 启动倒计时 */
|
||
startCountdown(question.timeLimit)
|
||
|
||
questionHistory.add(question)
|
||
return true
|
||
}
|
||
|
||
/**
|
||
* 构建题目消息JSON
|
||
*/
|
||
private fun buildQuestionMessage(question: InteractiveQuestion): String {
|
||
val sb = StringBuilder()
|
||
sb.append("{")
|
||
sb.append("\"type\":\"question\",")
|
||
sb.append("\"classroom_id\":\"$classroomId\",")
|
||
sb.append("\"question_id\":\"${question.questionId}\",")
|
||
sb.append("\"question_type\":${question.type.code},")
|
||
sb.append("\"title\":\"${question.title}\",")
|
||
|
||
if (question.options.isNotEmpty()) {
|
||
sb.append("\"options\":[")
|
||
question.options.forEachIndexed { index, opt ->
|
||
if (index > 0) sb.append(",")
|
||
sb.append("\"$opt\"")
|
||
}
|
||
sb.append("],")
|
||
}
|
||
|
||
sb.append("\"time_limit\":${question.timeLimit},")
|
||
sb.append("\"score\":${question.score},")
|
||
sb.append("\"timestamp\":${System.currentTimeMillis()}")
|
||
sb.append("}")
|
||
|
||
return sb.toString()
|
||
}
|
||
|
||
/* ==================== 答案收集 ==================== */
|
||
|
||
/**
|
||
* 接收学生提交的答案
|
||
* 通常由WebSocket消息回调触发
|
||
*/
|
||
fun onStudentAnswerReceived(studentId: String, studentName: String,
|
||
answer: String) {
|
||
if (state != SessionState.ANSWERING && state != SessionState.COLLECTING) {
|
||
Log.w(TAG, "非答题状态收到答案, 忽略: student=$studentId")
|
||
return
|
||
}
|
||
|
||
val question = currentQuestion ?: return
|
||
|
||
/* 判断答案是否正确 */
|
||
val isCorrect = when (question.type) {
|
||
QuestionType.SINGLE_CHOICE,
|
||
QuestionType.TRUE_FALSE -> answer.trim().equals(question.correctAnswer.trim(), true)
|
||
QuestionType.MULTIPLE_CHOICE -> {
|
||
val submitted = answer.split(",").map { it.trim() }.sorted()
|
||
val correct = question.correctAnswer.split(",").map { it.trim() }.sorted()
|
||
submitted == correct
|
||
}
|
||
else -> false /* 填空题和简答题需人工批改 */
|
||
}
|
||
|
||
/* 计算答题耗时 */
|
||
val costSec = ((System.currentTimeMillis() - publishTimestamp) / 1000).toInt()
|
||
|
||
val studentAnswer = StudentAnswer(
|
||
studentId = studentId,
|
||
studentName = studentName,
|
||
questionId = question.questionId,
|
||
answer = answer,
|
||
isCorrect = isCorrect,
|
||
costSeconds = costSec
|
||
)
|
||
|
||
answersMap[studentId] = studentAnswer
|
||
|
||
/* 通知监听器 */
|
||
listeners.forEach { it.onAnswerReceived(studentAnswer) }
|
||
|
||
Log.d(TAG, "收到答案: $studentName ($studentId) = $answer, " +
|
||
"正确=$isCorrect, 耗时=${costSec}s, " +
|
||
"进度=${answersMap.size}/$totalStudents")
|
||
|
||
/* 检查是否全部提交 */
|
||
if (answersMap.size >= totalStudents) {
|
||
Log.i(TAG, "全部学生已提交, 自动收卷")
|
||
collectAnswers()
|
||
}
|
||
}
|
||
|
||
/* ==================== 收卷与统计 ==================== */
|
||
|
||
/**
|
||
* 手动收卷(教师点击收卷按钮)
|
||
*/
|
||
fun collectAnswers() {
|
||
if (state != SessionState.ANSWERING) {
|
||
Log.w(TAG, "非答题状态无法收卷")
|
||
return
|
||
}
|
||
|
||
/* 停止倒计时 */
|
||
countdownTimer?.cancel()
|
||
|
||
changeState(SessionState.COLLECTING)
|
||
|
||
/* 发送收卷指令给学生端 */
|
||
/* ws.send("{\"type\":\"collect\",\"question_id\":\"...\"}") */
|
||
|
||
Log.i(TAG, "收卷完成: 已提交=${answersMap.size}/$totalStudents")
|
||
|
||
/* 生成统计结果 */
|
||
val stats = generateStatistics()
|
||
statisticsHistory.add(stats)
|
||
|
||
/* 切换到查看结果状态 */
|
||
changeState(SessionState.REVIEWING)
|
||
|
||
listeners.forEach { it.onStatisticsReady(stats) }
|
||
}
|
||
|
||
/**
|
||
* 生成答题统计结果
|
||
*/
|
||
private fun generateStatistics(): AnswerStatistics {
|
||
val question = currentQuestion ?: return AnswerStatistics(
|
||
"", totalStudents, 0, 0, 0f, emptyMap(), 0f
|
||
)
|
||
|
||
val answers = answersMap.values.toList()
|
||
val correctCount = answers.count { it.isCorrect }
|
||
val correctRate = if (answers.isNotEmpty()) {
|
||
correctCount.toFloat() / answers.size
|
||
} else 0f
|
||
|
||
val avgCost = if (answers.isNotEmpty()) {
|
||
answers.map { it.costSeconds }.average().toFloat()
|
||
} else 0f
|
||
|
||
/* 统计各选项分布(选择题) */
|
||
val distribution = mutableMapOf<String, Int>()
|
||
if (question.type == QuestionType.SINGLE_CHOICE ||
|
||
question.type == QuestionType.TRUE_FALSE) {
|
||
answers.forEach { ans ->
|
||
distribution[ans.answer] = (distribution[ans.answer] ?: 0) + 1
|
||
}
|
||
}
|
||
|
||
val stats = AnswerStatistics(
|
||
questionId = question.questionId,
|
||
totalStudents = totalStudents,
|
||
submittedCount = answers.size,
|
||
correctCount = correctCount,
|
||
correctRate = correctRate,
|
||
optionDistribution = distribution,
|
||
avgCostSeconds = avgCost
|
||
)
|
||
|
||
Log.i(TAG, "统计结果: 提交${answers.size}/${totalStudents}, " +
|
||
"正确率=${String.format("%.1f", correctRate * 100)}%, " +
|
||
"平均耗时=${String.format("%.1f", avgCost)}s")
|
||
|
||
return stats
|
||
}
|
||
|
||
/* ==================== 随机抽取 ==================== */
|
||
|
||
/**
|
||
* 随机抽取指定数量的学生
|
||
* 用于课堂随机点名展示
|
||
*/
|
||
fun randomPickStudents(count: Int): List<String> {
|
||
val allStudents = answersMap.keys.toList()
|
||
if (allStudents.size <= count) return allStudents
|
||
|
||
return allStudents.shuffled(Random(System.currentTimeMillis())).take(count).also {
|
||
Log.i(TAG, "随机抽取${count}名学生: $it")
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 按分组展示学生答案
|
||
* @param groupSize 每组人数
|
||
*/
|
||
fun groupStudents(groupSize: Int): List<List<StudentAnswer>> {
|
||
val answers = answersMap.values.toList()
|
||
return answers.chunked(groupSize).also {
|
||
Log.i(TAG, "分组展示: ${it.size}组, 每组${groupSize}人")
|
||
}
|
||
}
|
||
|
||
/* ==================== 倒计时 ==================== */
|
||
|
||
/**
|
||
* 启动答题倒计时
|
||
*/
|
||
private fun startCountdown(seconds: Int) {
|
||
countdownTimer?.cancel()
|
||
|
||
countdownTimer = object : CountDownTimer(seconds * 1000L, 1000) {
|
||
override fun onTick(millisUntilFinished: Long) {
|
||
val remain = (millisUntilFinished / 1000).toInt()
|
||
listeners.forEach { it.onCountdownTick(remain) }
|
||
}
|
||
|
||
override fun onFinish() {
|
||
Log.i(TAG, "答题时间到")
|
||
listeners.forEach { it.onCountdownFinished() }
|
||
collectAnswers()
|
||
}
|
||
}.start()
|
||
|
||
Log.i(TAG, "倒计时启动: ${seconds}秒")
|
||
}
|
||
|
||
/* ==================== 状态管理 ==================== */
|
||
|
||
/**
|
||
* 变更会话状态
|
||
*/
|
||
private fun changeState(newState: SessionState) {
|
||
val oldState = state
|
||
state = newState
|
||
Log.d(TAG, "状态变更: $oldState → $newState")
|
||
listeners.forEach { it.onSessionStateChanged(newState) }
|
||
}
|
||
|
||
/**
|
||
* 重置为空闲状态
|
||
*/
|
||
fun reset() {
|
||
countdownTimer?.cancel()
|
||
answersMap.clear()
|
||
currentQuestion = null
|
||
changeState(SessionState.IDLE)
|
||
Log.i(TAG, "互动系统已重置")
|
||
}
|
||
|
||
/**
|
||
* 获取当前提交进度 (已提交/总人数)
|
||
*/
|
||
fun getProgress(): Pair<Int, Int> = Pair(answersMap.size, totalStudents)
|
||
|
||
/**
|
||
* 获取历史统计记录
|
||
*/
|
||
fun getHistoryStatistics(): List<AnswerStatistics> = statisticsHistory.toList()
|
||
}
|
||
```
|
||
|