# 自然写互动课堂智慧黑板端应用软件 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(PAGE_CACHE_SIZE) /** 图片集页面路径列表 */ private val imagePages = mutableListOf() /** 事件监听器 */ 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, /* 坐标点列表 */ 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() /** 学生最后活动时间: studentId → timestamp */ private val lastActivityTime = ConcurrentHashMap() /* ==================== 事件监听 ==================== */ /** 笔迹事件监听器列表 */ private val listeners = CopyOnWriteArrayList() /* ==================== 线程 ==================== */ /** 消息处理线程池 */ 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() 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 = 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 = 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) : 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() /** 当前正在绘制的笔画 */ private var currentStroke: Stroke? = null /** 撤销栈 */ private val undoStack = LinkedList() /** 重做栈 */ private val redoStack = LinkedList() /* ==================== 绘图工具 ==================== */ /** 笔迹画笔 */ 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() 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() /* ==================== 消息监听 ==================== */ /** 消息监听器 */ private val messageListeners = CopyOnWriteArrayList() /* ==================== 线程 ==================== */ /** 调度器 */ 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 = 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 = 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 = 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, /* 各选项分布 */ 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() /** 事件监听器 */ private val listeners = CopyOnWriteArrayList() /** 倒计时器 */ private var countdownTimer: CountDownTimer? = null /** 发题时间戳(用于计算学生耗时) */ private var publishTimestamp: Long = 0 /** 历史题目记录 */ private val questionHistory = mutableListOf() /** 历史统计记录 */ private val statisticsHistory = mutableListOf() /** * 添加事件监听器 */ 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() 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 { 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> { 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 = Pair(answersMap.size, totalStudents) /** * 获取历史统计记录 */ fun getHistoryStatistics(): List = statisticsHistory.toList() } ```