# 自然写互动课堂智慧黑板端应用软件 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()
}
```

