package cn.haidong.zzb.rmsys import android.content.Context import android.database.sqlite.SQLiteDatabase import android.webkit.JavascriptInterface import org.json.JSONArray import org.json.JSONObject import java.io.File import java.text.SimpleDateFormat import java.util.Date import java.util.Locale /** * 数据包桥接:把后台导出的 SQLite 数据包变成页面能直接消费的 JSON。 * * 为什么不让 JS 直接执行 SQL: * SQL 通道等于把整库暴露给页面,一旦页面被注入就能读走全部材料。 * 这里只暴露固定方法(配置 / 上会名单 / 任免文件),最小权限。 * * 输出字段刻意对齐现有 pad.html 的 CADRES 结构(ef/ep 是对象、resume 是二维数组、 * family/notes 是对象数组),这样前端渲染代码一行都不用改。 */ class PackageBridge(private val ctx: Context) { companion object { /** 国密加密数据包(SM4-CBC + HMAC-SM3) */ private const val PKG_FILE = "rmsys.pkg" /** 旧版明文 SQLite 包:已停用,仅用于给出明确提示 */ private const val LEGACY_DB = "rmsys.db" private const val PREFS = "rmsys_pad" private const val KEY_PAD_NO = "pad_no" private const val DEFAULT_PAD_NO = "PAD-07" private val DT = SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.CHINA) } // ---------------- 基础路径 ---------------- private fun pkgFile(): File { // 优先「应用外部私有目录」,便于现场用 USB / adb 导入加密数据包; // 没有则退回内部存储(由系统安装时预置)。 val ext = ctx.getExternalFilesDir(null) if (ext != null) { val f = File(ext, PKG_FILE) if (f.exists()) return f } return File(ctx.filesDir, PKG_FILE) } /** 旧版明文包是否存在(存在则明确拒绝,避免绕过加密) */ private fun legacyDbFile(): File? { val ext = ctx.getExternalFilesDir(null) val cands = ArrayList() if (ext != null) cands.add(File(ext, LEGACY_DB)) cands.add(File(ctx.filesDir, LEGACY_DB)) return cands.firstOrNull { it.exists() } } /** 数据包主密钥:编译期由 gradle.properties 注入 BuildConfig,与后端库内 sysconfigs.PackageSm4Key 一致 */ private fun masterKey(): ByteArray? = runCatching { android.util.Base64.decode(BuildConfig.PKG_MASTER_KEY, android.util.Base64.NO_WRAP) } .getOrNull()?.takeIf { it.size == 32 } /** * 解密数据包 → 打开 → 用完擦除。 * * 明文只以「缓存目录临时文件」形式存在,且仅在本次调用期间: * 关闭 SQLite 后立刻清零覆盖再删除。这样即使平板被取证, * 目录里也只有加密的 rmsys.pkg,没有可直接打开的明文库。 */ private inline fun withPkg(onError: (String, String) -> T, block: (SQLiteDatabase) -> T): T { val pkg = pkgFile() if (!pkg.exists()) { val legacy = legacyDbFile() return if (legacy != null) onError("LEGACY_FORMAT", "检测到旧版明文数据包(rmsys.db),该格式已停用。请导入加密数据包 rmsys.pkg。") else onError("NO_PACKAGE", "本机未导入会议数据包,请联系干部科导入后再使用。") } val key = masterKey() ?: return onError("NO_KEY", "本机未配置数据包密钥,请安装正式版本。") val tmp = File(ctx.cacheDir, "pk_" + System.nanoTime() + ".db") try { val plain: ByteArray try { plain = PackageCrypto.decode(pkg.readBytes(), key) } catch (e: PackageCrypto.PackageException) { return onError("PACKAGE_INVALID", e.message ?: "数据包校验失败") } catch (e: Exception) { return onError("PACKAGE_ERROR", "读取数据包失败:" + (e.message ?: "")) } tmp.writeBytes(plain) val db = SQLiteDatabase.openDatabase(tmp.absolutePath, null, SQLiteDatabase.OPEN_READONLY) try { return block(db) } finally { db.close() } } finally { wipeFile(tmp) } } /** 清零覆盖后删除(降低文件被恢复的可能) */ private fun wipeFile(f: File) { try { if (!f.exists()) return val len = f.length() java.io.RandomAccessFile(f, "rw").use { raf -> val zeros = ByteArray(minOf(len, 64L * 1024).toInt().coerceAtLeast(1)) var left = len while (left > 0) { val n = minOf(left, zeros.size.toLong()).toInt() raf.write(zeros, 0, n) left -= n } raf.fd.sync() } f.delete() } catch (e: Exception) { // 清理失败不阻断业务,但至少尝试删除 runCatching { f.delete() } } } fun padNo(): String = ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE) .getString(KEY_PAD_NO, DEFAULT_PAD_NO) ?: DEFAULT_PAD_NO @JavascriptInterface fun setPadNo(no: String): String { val v = no.trim() // commit() 而不是 apply():要能立刻回读确认真的落盘了 val saved = ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE) .edit().putString(KEY_PAD_NO, v).commit() return JSONObject() .put("ok", saved) .put("padNo", padNo()) // 回读:以实际存下的值为准 .toString() } private fun cfg(db: SQLiteDatabase): Map { val m = HashMap() db.rawQuery("select k, v from pkg_config", null).use { c -> while (c.moveToNext()) { m[c.getString(0)] = if (c.isNull(1)) "" else c.getString(1) } } return m } // ---------------- JS 接口 ---------------- /** * 会后零留存:删除加密数据包、解密残留、缓存与本地设置,并回读确认真的不存在。 * * 这是保密检查必查项。此前页面只弹了个提示(注释还写着「演示:仅重置浏览状态」), * 材料实际一直留在平板上。 */ @JavascriptInterface fun clearPackage(): String { val o = JSONObject() val removed = ArrayList() val failed = ArrayList() try { // 1) 数据包:外部私有目录与内部存储两处都可能存在,旧版明文包一并清掉 val ext = ctx.getExternalFilesDir(null) val cands = ArrayList() if (ext != null) { cands.add(File(ext, PKG_FILE)) cands.add(File(ext, LEGACY_DB)) } cands.add(File(ctx.filesDir, PKG_FILE)) cands.add(File(ctx.filesDir, LEGACY_DB)) for (f in cands) { if (!f.exists()) continue wipeFile(f) if (f.exists()) failed.add(f.absolutePath) else removed.add(f.name) } // 2) 解密临时文件(异常退出时可能残留) ctx.cacheDir.listFiles()?.forEach { f -> if (f.name.startsWith("pk_") && f.name.endsWith(".db")) { wipeFile(f) if (f.exists()) failed.add(f.name) else removed.add(f.name) } } // 3) 缓存目录(含 WebView 磁盘缓存)与本地偏好 runCatching { ctx.cacheDir.deleteRecursively() } ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().clear().commit() o.put("ok", failed.isEmpty()) o.put("removed", JSONArray(removed)) if (failed.isNotEmpty()) o.put("reason", "以下文件未能删除,请手工处理:" + failed.joinToString("、")) // 回读确认:不靠「删除没报错」来判断成功 o.put("pkgExists", pkgFile().exists()) } catch (e: Exception) { o.put("ok", false).put("reason", "清除过程出错:" + (e.message ?: "")) } return o.toString() } // ---------------- 已阅回执 ---------------- /** 本次进程内已记录的键,避免同一份材料重复写盘 */ private val readKeys = HashSet() private var readKeysLoaded = false private fun receiptFile(): File { val dir = File(ctx.filesDir, "receipts") if (!dir.exists()) dir.mkdirs() return File(dir, "receipt_" + padNo() + ".jsonl") } private fun loadReadKeysOnce() { if (readKeysLoaded) return readKeysLoaded = true try { val f = receiptFile() if (!f.exists()) return f.forEachLine { line -> if (line.isBlank()) return@forEachLine val o = runCatching { JSONObject(line) }.getOrNull() ?: return@forEachLine readKeys.add( "${o.optInt("meetingId")}|${o.optInt("cadreId")}|${o.optString("type")}" ) } } catch (e: Exception) { /* 读不出来就当没有 */ } } /** * 记一条已阅回执。 * * APK 刻意不申请 INTERNET 权限(离线保密要求),所以不在线回传, * 而是把回执追加到 filesDir/receipts/receipt_.jsonl, * 干部科回收设备时导出该文件,在后台「导入已阅回执」里入库。 * 粒度:会议 + 干部 + 材料类型 + 设备号 + 首次阅读时间。 */ @JavascriptInterface fun markRead(meetingKey: String, cadreId: String, type: String, meetingId: String): String { val cid = cadreId.toIntOrNull() ?: return "{\"ok\":false}" val mid = meetingId.toIntOrNull() ?: 0 if (type.isBlank()) return "{\"ok\":false}" loadReadKeysOnce() val key = "$mid|$cid|$type" if (!readKeys.add(key)) return "{\"ok\":true,\"dup\":true}" return try { val o = JSONObject() .put("meetingId", mid) .put("meetingKey", meetingKey) .put("cadreId", cid) .put("type", type) .put("padNo", padNo()) .put("readAt", SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.CHINA).format(Date())) receiptFile().appendText(o.toString() + "\n") JSONObject().put("ok", true).toString() } catch (e: Exception) { JSONObject().put("ok", false).put("reason", e.message ?: "").toString() } } /** 回执文件路径(现场回收时用) */ @JavascriptInterface fun receiptsPath(): String = receiptFile().absolutePath /** 本机与数据包的基本信息(用于页面底部显示设备号) */ @JavascriptInterface fun info(): String { val o = JSONObject() o.put("padNo", padNo()) o.put("dbPath", pkgFile().absolutePath) o.put("hasPackage", pkgFile().exists()) o.put("encrypted", true) o.put("legacyDb", legacyDbFile()?.absolutePath ?: "") return o.toString() } /** * 启动校验:数据包存在 / 未过期 / 本机在授权设备清单内。 * 任一不通过,页面显示阻断页,不进入任何材料。 */ @JavascriptInterface fun verify(): String { val o = JSONObject() return withPkg({ code, msg -> o.put("ok", false).put("code", code).put("reason", msg).toString() }) { d -> val c = cfg(d) o.put("pkgNo", c["pkgNo"] ?: "") o.put("expireAt", c["expireAt"] ?: "") o.put("padNo", padNo()) o.put("needPwd", (c["needPwd"] ?: "0") == "1") // 1) 有效期 val exp = c["expireAt"] ?: "" if (exp.isNotEmpty()) { val t = runCatching { DT.parse(exp)?.time }.getOrNull() if (t != null && System.currentTimeMillis() > t) { return o.put("ok", false).put("code", "EXPIRED") .put("reason", "本次会议资料已于 $exp 失效(会后零留存)。").toString() } } // 2) 设备绑定 val bound = c["padBound"] ?: "[]" val list = runCatching { JSONArray(bound) }.getOrNull() var hit = false if (list != null) { for (i in 0 until list.length()) { if (list.optString(i).equals(padNo(), ignoreCase = true)) { hit = true; break } } } if (list != null && list.length() > 0 && !hit) { return o.put("ok", false).put("code", "NOT_BOUND") .put("reason", "本机(${padNo()})不在本次会议的授权设备清单内。").toString() } o.put("ok", true) o.put("meetingName", c["meetingName"] ?: "") return o.toString() } } /** 页面数据:配置 + 会议 + 处室 + 上会名单(含简历/家庭成员/批注) + 任免文件 */ @JavascriptInterface fun bootstrap(): String { val out = JSONObject() return withPkg({ code, msg -> out.put("ok", false).put("code", code).put("reason", msg).toString() }) { d -> val c = cfg(d) // ---- 配置 ---- val cfgOut = JSONObject() for ((k, v) in c) cfgOut.put(k, v) out.put("config", cfgOut) // ---- 会议(键名对齐 pad.html 的 MEETINGS:buwu / changwei / shuji)---- val type = c["meetingType"] ?: "buwu" val key = when (type) { "changwei", "cwh" -> "changwei" "shuji", "sjzt" -> "shuji" else -> "buwu" } val held = c["heldAt"] ?: "" val heldDate = runCatching { DT.parse(held) }.getOrNull() ?: Date() val sn = c["sessionNo"] ?: "" val shortName = when (key) { "buwu" -> "第${sn}次部务会" "changwei" -> "第${sn}次常委会" else -> "书记专题会议" } val title = when (key) { "buwu" -> "中共海东市委组织部文件" "changwei" -> "中共海东市委文件" else -> "中共海东市委组织部(酝酿材料)" } val m = JSONObject() m.put("name", c["meetingName"] ?: "") m.put("short", shortName) m.put("date", SimpleDateFormat("yyyy年M月d日", Locale.CHINA).format(heldDate)) m.put("wdate", SimpleDateFormat("yyyy-MM-dd", Locale.CHINA).format(heldDate)) m.put("secret", (c["secret"] ?: "机密") + " " + (c["secretStars"] ?: "")) m.put("depts", key == "buwu" && (c["deptCount"] ?: "0") != "0") m.put("title", title) out.put("meeting", m) out.put("meetingKey", key) // ---- 处室 ---- val depts = JSONArray() d.rawQuery("select code, name from depts order by sortNo", null).use { cur -> while (cur.moveToNext()) { depts.put(JSONObject().put("k", cur.getString(0)).put("name", cur.getString(1)).put("desc", "")) } } out.put("depts", depts) // ---- 上会名单 ---- val cadres = JSONArray() d.rawQuery( """select id, deptCode, sortNo, name, sex, birth, nation, nativePlace, birthPlace, partyDate, workDate, health, techPost, expertise, eduFullSchool, eduFullMajor, eduFullLevel, eduFullDegree, eduInSchool, eduInMajor, eduInLevel, eduInDegree, currentPost, proposedPost, dismissPost, rewards, exams, appointReason, kw, perf, care, suggest, result, vote from cadres order by sortNo""", null ).use { cur -> while (cur.moveToNext()) { val id = cur.getLong(0) val o = JSONObject() o.put("id", id) o.put("dept", cur.str(1)) o.put("seq", cur.getInt(2)) o.put("name", cur.str(3)) o.put("sex", cur.str(4)) o.put("birth", cur.str(5)) o.put("nation", cur.str(6)) o.put("native", cur.str(7)) o.put("birthplace", cur.str(8)) o.put("party", cur.str(9)) o.put("work", cur.str(10)) o.put("health", cur.str(11)) o.put("tech", cur.str(12)) o.put("expertise", cur.str(13)) o.put("ef", JSONObject().put("school", cur.str(14)).put("major", cur.str(15)) .put("level", cur.str(16)).put("degree", cur.str(17))) o.put("ep", JSONObject().put("school", cur.str(18)).put("major", cur.str(19)) .put("level", cur.str(20)).put("degree", cur.str(21))) o.put("current", cur.str(22)) o.put("proposed", cur.str(23)) o.put("dismiss", cur.str(24)) o.put("rewards", cur.str(25)) o.put("exams", cur.str(26)) o.put("reason", cur.str(27)) o.put("kw", cur.str(28)) o.put("perf", cur.str(29)) o.put("care", cur.str(30)) o.put("suggest", cur.str(31)) o.put("result", cur.str(32)) o.put("vote", cur.str(33)) // 简历 [[时间段, 内容], ...] val rs = JSONArray() d.rawQuery("select period, content from resumes where cadreId=? order by sortNo", arrayOf(id.toString())).use { r -> while (r.moveToNext()) rs.put(JSONArray().put(r.str(0)).put(r.str(1))) } o.put("resume", rs) // 家庭成员 [{rel,name,age,pol,org}] val fam = JSONArray() d.rawQuery( "select relation,name,age,political,workUnit from families where cadreId=? order by sortNo", arrayOf(id.toString()) ).use { r -> while (r.moveToNext()) fam.put( JSONObject().put("rel", r.str(0)).put("name", r.str(1)) .put("age", r.str(2)).put("pol", r.str(3)).put("org", r.str(4)) ) } o.put("family", fam) // 批注 [{by,at,text,field}] val nt = JSONArray() d.rawQuery( "select author,createdAt,content,fieldName from notes where cadreId=? order by id", arrayOf(id.toString()) ).use { r -> while (r.moveToNext()) nt.put( JSONObject().put("by", r.str(0)).put("at", r.str(1)) .put("text", r.str(2)).put("field", r.str(3)) ) } o.put("notes", nt) cadres.put(o) } } out.put("cadres", cadres) // ---- 任免文件 ---- val docs = JSONArray() d.rawQuery("select deptCode, docNo, title, persons, pages from appointdocs", null).use { cur -> while (cur.moveToNext()) docs.put( JSONObject().put("dept", cur.str(0)).put("no", cur.str(1)) .put("title", cur.str(2)).put("persons", cur.getInt(3)).put("pages", cur.getInt(4)) ) } out.put("docs", docs) out.put("ok", true) return out.toString() } } // ---------------- 工具 ---------------- private fun android.database.Cursor.str(i: Int): String = if (isNull(i)) "" else getString(i) }