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() } } /** 回执文件路径(现场回收时用) */ /** 包内 PDF 的页数(没有正文返回 0) */ @JavascriptInterface fun pdfPages(matId: Long): Int = withPkg({ _, _ -> 0 }) { d -> val bytes = pdfBlob(d, matId) ?: return@withPkg 0 pdfPageCount(bytes) } /** * 把包内 PDF 的某一页渲染成 JPEG 位图(base64),页面用 直接显示。 * 走系统 PdfRenderer:矢量渲染、离线可用、不需要第三方库。 */ @JavascriptInterface fun pdfPage(matId: Long, index: Int, targetWidth: Int): String = withPkg({ _, _ -> "" }) { d -> val bytes = pdfBlob(d, matId) ?: return@withPkg "" renderPdfPage(bytes, index, targetWidth) } /** 任免文件原件的页数(出包时上传的 Word 已转成 PDF 存在 docfiles 表) */ @JavascriptInterface fun docPages(docId: Long): Int = withPkg({ _, _ -> 0 }) { d -> val bytes = docBlob(d, docId) ?: return@withPkg 0 pdfPageCount(bytes) } /** 任免文件原件的某一页(JPEG base64) */ @JavascriptInterface fun docPage(docId: Long, index: Int, targetWidth: Int): String = withPkg({ _, _ -> "" }) { d -> val bytes = docBlob(d, docId) ?: return@withPkg "" renderPdfPage(bytes, index, targetWidth) } private fun pdfPageCount(bytes: ByteArray): Int { val tmp = File(ctx.cacheDir, "cnt_${System.nanoTime()}.pdf") return try { tmp.writeBytes(bytes) android.os.ParcelFileDescriptor.open(tmp, android.os.ParcelFileDescriptor.MODE_READ_ONLY).use { pfd -> android.graphics.pdf.PdfRenderer(pfd).use { it.pageCount } } } catch (e: Exception) { 0 } finally { wipeFile(tmp) } } private fun renderPdfPage(bytes: ByteArray, index: Int, targetWidth: Int): String { val tmp = File(ctx.cacheDir, "pg_${System.nanoTime()}.pdf") return try { tmp.writeBytes(bytes) android.os.ParcelFileDescriptor.open(tmp, android.os.ParcelFileDescriptor.MODE_READ_ONLY).use { pfd -> android.graphics.pdf.PdfRenderer(pfd).use { r -> if (index < 0 || index >= r.pageCount) return "" r.openPage(index).use { page -> val w = if (targetWidth in 480..3000) targetWidth else 1240 val h = Math.max(1, (w.toFloat() * page.height / page.width).toInt()) val bmp = android.graphics.Bitmap.createBitmap(w, h, android.graphics.Bitmap.Config.ARGB_8888) bmp.eraseColor(android.graphics.Color.WHITE) page.render(bmp, null, null, android.graphics.pdf.PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY) val bos = java.io.ByteArrayOutputStream() bmp.compress(android.graphics.Bitmap.CompressFormat.JPEG, 85, bos) bmp.recycle() android.util.Base64.encodeToString(bos.toByteArray(), android.util.Base64.NO_WRAP) } } } } catch (e: Exception) { "" } finally { wipeFile(tmp) } } private fun docBlob(d: SQLiteDatabase, docId: Long): ByteArray? = try { d.rawQuery("select data from docfiles where docId=? limit 1", arrayOf(docId.toString())).use { c -> if (c.moveToFirst()) c.getBlob(0) else null } } catch (e: Exception) { null } private fun pdfBlob(d: SQLiteDatabase, matId: Long): ByteArray? = try { d.rawQuery("select data from files where matId=? limit 1", arrayOf(matId.toString())).use { c -> if (c.moveToFirst()) c.getBlob(0) else null } } catch (e: Exception) { null } @JavascriptInterface fun receiptsPath(): String = receiptFile().absolutePath /** * 导出已阅回执:把本地 JSONL 复制到外部私有目录(Android/data/<包名>/files/), * 干部科用数据线拷走后,在后台「导入已阅回执」入库。 * 返回 { ok, path, lines, name } */ @JavascriptInterface fun exportReceipts(): String { val o = JSONObject() return try { val src = receiptFile() if (!src.exists() || src.length() == 0L) { o.put("ok", false).put("reason", "本机还没有已阅记录") return o.toString() } val lines = src.readLines().count { it.isNotBlank() } val ext = ctx.getExternalFilesDir(null) val dstDir = ext ?: File(ctx.filesDir, "export") if (!dstDir.exists()) dstDir.mkdirs() val dst = File(dstDir, src.name) src.copyTo(dst, overwrite = true) o.put("ok", true).put("name", dst.name).put("path", dst.absolutePath).put("lines", lines) o.toString() } catch (e: Exception) { runCatching { o.put("ok", false).put("reason", e.message ?: "导出失败") } o.toString() } } /** 退出 App:只结束当前界面,不删除任何数据(数据包由干部科手动清理) */ @JavascriptInterface fun exitApp(): String { return try { val act = ctx as? android.app.Activity if (act == null) { "{\"ok\":false,\"reason\":\"no-activity\"}" } else { act.runOnUiThread { act.finish() } "{\"ok\":true}" } } catch (e: Exception) { "{\"ok\":false,\"reason\":\"" + (e.message ?: "error").replace("\"", "'") + "\"}" } } /** 本机与数据包的基本信息(用于页面底部显示设备号) */ @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, hasPhoto, photo 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)) // 证件照:包里是 data:image/...;base64 字符串,页面直接当 img.src 用 val photo = cur.str(35) ?: "" o.put("photo", photo) // 简历 [[时间段, 内容], ...] 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) // ---- 任免文件 ---- // 任免文件(含"文件↔人"关联,PAD 端可显示涉及人员并标出漏文) val docLinks = HashMap>() try { d.rawQuery("select docId, cadreId from doc_cadres order by docId, sort", null).use { cur -> while (cur.moveToNext()) { val did = cur.getInt(0) docLinks.getOrPut(did) { mutableListOf() }.add(cur.getInt(1)) } } } catch (_: Exception) { } /* 任免文件正文段落:PAD 端按原文文字渲染,人名可点跳审批表 */ val docLines = HashMap>() try { d.rawQuery("select docId, content from doclines order by docId, seq", null).use { cur -> while (cur.moveToNext()) { docLines.getOrPut(cur.getInt(0)) { mutableListOf() }.add(cur.str(1)) } } } catch (_: Exception) { } val docs = JSONArray() d.rawQuery("select id, deptCode, docNo, title, persons, pages from appointdocs", null).use { cur -> while (cur.moveToNext()) { val ids = JSONArray() (docLinks[cur.getInt(0)] ?: emptyList()).forEach { ids.put(it) } docs.put( JSONObject().put("id", cur.getInt(0)).put("dept", cur.str(1)).put("no", cur.str(2)) .put("title", cur.str(3)).put("persons", cur.getInt(4)).put("pages", cur.getInt(5)) .put("cadreIds", ids) .put("lines", JSONArray(docLines[cur.getInt(0)] ?: emptyList())) ) } } out.put("docs", docs) // ---- 材料清单(PAD 用:有哪些材料、是否有 PDF 正文)---- // 不再给页面编造考察材料文本;页面按 matId 向本桥按需取 PDF 页面位图 val mats = JSONArray() try { d.rawQuery( """select m.id, m.cadreId, m.type, m.fileName, m.pages, m.version, m.status, case when f.matId is null then 0 else 1 end as hasPdf from materials m left join files f on f.matId = m.id order by m.cadreId, m.type""", null ).use { cur -> while (cur.moveToNext()) { mats.put( JSONObject().put("id", cur.getLong(0)).put("cadreId", cur.getLong(1)) .put("type", cur.str(2)).put("name", cur.str(3)) .put("pages", cur.getInt(4)).put("version", cur.str(5)) .put("status", cur.str(6)).put("hasPdf", cur.getInt(7)) ) } } } catch (_: Exception) { } out.put("materials", mats) out.put("ok", true) return out.toString() } } // ---------------- 工具 ---------------- private fun android.database.Cursor.str(i: Int): String = if (isNull(i)) "" else getString(i) }