Commit f01618917a911866560d11f7a447f320c1147838

Authored by yibo
1 parent 136b916d

feat(lifelog/photolog): 端上 SQLCipher 解密微信聊天(P2 #6,不再依赖 Mac)

微信聊天本地化——EnMicroMsg.db 是 SQLCipher 加密的,过去靠 Mac 解密推 wx_enroll,现在端上直接读:
- key 纯端上可推导(真机验证,无需 Frida):MD5("1234567890ABCDEF" + uin)[:7]
  imei 用微信默认值(现代 Android 读不到真 IMEI 的回退,稳定);uin 从 shared_prefs last_login_uin 读
- WeChatChatImporter:su --mount-master 拷 EnMicroMsg.db + 读 uin → 算 key → SQLCipher 打开
  (cipher_compatibility=1, cipher_page_size=1024) → 读 message 表 type=1 文本,按 talker 取目标 wxid
- 依赖:net.zetetic:android-database-sqlcipher:4.5.4
- 接线:单人「⬇下载图片」顺带解密该人聊天;「🔄更新」一次性解密整库读所有 wxid(避免逐人重复解密)
  聊天写 wechat_messages(按 talker 先清再灌,幂等);档案页「聊天」tab 直接显示
- 真机验证:读到与某联系人的聊天记录

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
lifelog-recorder/app/build.gradle.kts
... ... @@ -114,4 +114,7 @@ dependencies {
114 114 implementation("androidx.exifinterface:exifinterface:1.3.7")
115 115 // 相册图片异步加载/缓存。
116 116 implementation("io.coil-kt:coil-compose:2.7.0")
  117 + // 端上解密微信 EnMicroMsg.db(SQLCipher,cipher_compatibility=1 读旧版加密库)→ 聊天本地化。
  118 + implementation("net.zetetic:android-database-sqlcipher:4.5.4")
  119 + implementation("androidx.sqlite:sqlite:2.4.0")
117 120 }
... ...
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologViewModel.kt
... ... @@ -12,6 +12,8 @@ import com.lemon.lifelog.data.Photo
12 12 import com.lemon.lifelog.data.PhotoRow
13 13 import com.lemon.lifelog.data.Repository
14 14 import com.lemon.lifelog.data.SpeakerProfile
  15 +import com.lemon.lifelog.data.WechatMessage
  16 +import com.lemon.lifelog.vision.WeChatChatImporter
15 17 import androidx.work.ExistingWorkPolicy
16 18 import androidx.work.OneTimeWorkRequestBuilder
17 19 import androidx.work.WorkManager
... ... @@ -479,13 +481,33 @@ class PhotologViewModel(app: Application) : AndroidViewModel(app) {
479 481 */
480 482 fun downloadWeChatImages(personId: Long, onResult: (Int, String?) -> Unit) {
481 483 viewModelScope.launch {
482   - val (avatarN, momentN, msg) = withContext(Dispatchers.IO) { downloadWeChatCore(personId) }
  484 + val (avatarN, momentN, msg) = withContext(Dispatchers.IO) { downloadWeChatCore(personId, includeChat = true) }
483 485 onResult(avatarN, msg)
484 486 }
485 487 }
486 488  
487   - /** 单人微信下载核心(**IO 线程调**):头像→照片(归属脸) + 朋友圈按条存。返回 (头像数, 朋友圈条数, 人读消息)。 */
488   - private suspend fun downloadWeChatCore(personId: Long): Triple<Int, Int, String> {
  489 + /** 端上解密 EnMicroMsg.db 读 [wxids] 的文本聊天 → wechat_messages(先按 talker 清再灌)。返回总条数。IO 线程。 */
  490 + private suspend fun importChatFor(wxids: Set<String>): Int {
  491 + if (wxids.isEmpty()) return 0
  492 + val cr = WeChatChatImporter.importChats(getApplication(), wxids)
  493 + var total = 0
  494 + for ((wxid, msgs) in cr.byWxid) {
  495 + if (msgs.isEmpty()) continue
  496 + repo.wechatMessages.clearTalker(wxid)
  497 + repo.wechatMessages.insertAll(
  498 + msgs.map { WechatMessage(talker = wxid, content = it.content, createTime = it.createTime, isSend = it.isSend) },
  499 + )
  500 + total += msgs.size
  501 + }
  502 + return total
  503 + }
  504 +
  505 + /**
  506 + * 单人微信下载核心(**IO 线程调**):头像→照片(归属脸) + 朋友圈按条存 + (可选)聊天解密。
  507 + * [includeChat]:单人下载 true;批量更新传 false,由 updateAllManaged 一次性解密所有 wxid(避免逐人重复解密整库)。
  508 + * 返回 (头像数, 朋友圈条数, 人读消息)。
  509 + */
  510 + private suspend fun downloadWeChatCore(personId: Long, includeChat: Boolean): Triple<Int, Int, String> {
489 511 val app = getApplication<Application>()
490 512 val p = repo.persons.get(personId) ?: return Triple(0, 0, "人物不存在")
491 513 val wxid = p.contactRef?.removePrefix("wechat:")?.takeIf { p.contactRef.startsWith("wechat:") }
... ... @@ -517,21 +539,24 @@ class PhotologViewModel(app: Application) : AndroidViewModel(app) {
517 539 momentN = r.moments.size
518 540 imgN = r.moments.sumOf { it.images.size }
519 541 }
520   - if (avatarN > 0 || momentN > 0) {
  542 + // 3) 聊天:端上解密 EnMicroMsg.db 读该人聊天(单人下载时)。
  543 + var chatN = 0
  544 + if (includeChat) runCatching { chatN = importChatFor(setOf(wxid)) }
  545 + if (avatarN > 0 || momentN > 0 || chatN > 0) {
521 546 repo.persons.setManaged(personId, true)
522 547 repo.persons.touch(personId, System.currentTimeMillis())
523 548 if (avatarN > 0) PhotoScheduler.runNow(app)
524 549 }
525   - val ok = avatarN > 0 || momentN > 0
526   - return Triple(avatarN, momentN, if (ok) "已下载头像 + $momentN 条朋友圈($imgN 张图)" else (r.error ?: "没有可下载的内容"))
  550 + val ok = avatarN > 0 || momentN > 0 || chatN > 0
  551 + return Triple(avatarN, momentN, if (ok) "已下载头像 + $momentN 条朋友圈($imgN 张图)+ $chatN 条聊天" else (r.error ?: "没有可下载的内容"))
527 552 }
528 553  
529 554 /**
530 555 * 通讯录「🔄 更新」:批量更新**所有有微信的管理人物**。
531   - * - 微信:逐个 downloadWeChatCore(头像+朋友圈图+文字);
  556 + * - 微信:逐个 downloadWeChatCore(头像+朋友圈图+文字)+ 一次性端上解密 EnMicroMsg.db 读所有人聊天;
532 557 * - 派生分析(全局一次):提及反查(MentionScanner) + 人↔人共现(RelationBuilder) + 待办(FollowupExtractor);
533 558 * - 图片识别:催一遍全相册扫描 + VL。
534   - * 不碰录音(24/7 后台自动转写/认人)。聊天暂不本地更新(EnMicroMsg 加密,仍靠 Mac enroll)。
  559 + * 不碰录音(24/7 后台自动转写/认人)。
535 560 */
536 561 private val _updating = MutableStateFlow(false)
537 562 val updating: StateFlow<Boolean> = _updating
... ... @@ -546,18 +571,22 @@ class PhotologViewModel(app: Application) : AndroidViewModel(app) {
546 571 var moments = 0
547 572 for (p in targets) {
548 573 runCatching {
549   - val (a, m, _) = downloadWeChatCore(p.id)
  574 + // 批量时不在 core 里逐人解密聊天,统一下面一次解密整库。
  575 + val (a, m, _) = downloadWeChatCore(p.id, includeChat = false)
550 576 if (a > 0 || m > 0) people++
551 577 moments += m
552 578 }
553 579 }
  580 + // 聊天:一次解密 EnMicroMsg.db 读所有目标 wxid(避免逐人重复解密整库)。
  581 + val wxids = targets.mapNotNull { it.contactRef?.removePrefix("wechat:") }.toSet()
  582 + val chats = runCatching { importChatFor(wxids) }.getOrDefault(0)
554 583 // 派生分析(全局各一次)
555 584 runCatching {
556 585 com.lemon.lifelog.crm.MentionScanner.rescanAll(repo)
557 586 com.lemon.lifelog.crm.RelationBuilder.rebuild(repo)
558 587 }
559 588 runCatching { com.lemon.lifelog.crm.FollowupExtractor.scan(app, repo) }
560   - "已更新 $people 人 · $moments 条朋友圈;关系/待办/提及已重建"
  589 + "已更新 $people 人 · $moments 条朋友圈 · $chats 条聊天;关系/待办/提及已重建"
561 590 }
562 591 // 图片识别:催全相册扫描 + VL(enqueue,不阻塞)。
563 592 scanGallery(); runVlNow()
... ...
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Daos.kt
... ... @@ -812,6 +812,10 @@ interface WechatMessageDao {
812 812 @Query("DELETE FROM wechat_messages")
813 813 suspend fun clear()
814 814  
  815 + /** 删某联系人的聊天(端上一键更新该人聊天前先清,避免重复累积)。 */
  816 + @Query("DELETE FROM wechat_messages WHERE talker = :talker")
  817 + suspend fun clearTalker(talker: String)
  818 +
815 819 @Query("SELECT COUNT(*) FROM wechat_messages")
816 820 suspend fun count(): Int
817 821  
... ...
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/vision/WeChatChatImporter.kt 0 → 100644
  1 +package com.lemon.lifelog.vision
  2 +
  3 +import android.content.Context
  4 +import android.os.Process
  5 +import android.util.Log
  6 +import net.sqlcipher.database.SQLiteDatabase
  7 +import net.sqlcipher.database.SQLiteDatabaseHook
  8 +import java.io.File
  9 +import java.security.MessageDigest
  10 +
  11 +/**
  12 + * 端上解密微信 `EnMicroMsg.db`(SQLCipher)读聊天。**纯本地、不经 Mac**(朋友圈库是明文,这库是加密的)。
  13 + *
  14 + * key 推导(真机验证,无需 Frida):`MD5(imei + uin)[:7]`
  15 + * - `imei` 用微信默认 `"1234567890ABCDEF"`(现代 Android 读不到真 IMEI 时微信的回退值,稳定);
  16 + * - `uin` 从 `com.tencent.mm` 的 shared_prefs xml 里的 `last_login_uin` 读;
  17 + * - SQLCipher 参数:`cipher_compatibility=1`、`cipher_page_size=1024`(微信用的旧版 SQLCipher)。
  18 + *
  19 + * 门槛是 root(读 com.tencent.mm 私有库 + 切全局挂载命名空间)。坑见 memory `pixel-app-su-mount-master`。
  20 + */
  21 +object WeChatChatImporter {
  22 + private const val TAG = "WeChatChat"
  23 + // imei 候选(默认值优先;空串兜底)。
  24 + private val IMEI_CANDIDATES = listOf("1234567890ABCDEF", "")
  25 +
  26 + data class Msg(val content: String, val createTime: Long, val isSend: Boolean)
  27 +
  28 + /** 各 wxid → 其文本聊天(时间正序)。error 非空 = 失败。 */
  29 + data class Result(val byWxid: Map<String, List<Msg>>, val error: String? = null)
  30 +
  31 + /** 一趟 su + 一次解密:读 [wxids] 这些联系人的文本聊天。**必须 IO 线程调用**。 */
  32 + fun importChats(ctx: Context, wxids: Set<String>): Result {
  33 + if (wxids.isEmpty()) return Result(emptyMap())
  34 + try { SQLiteDatabase.loadLibs(ctx) } catch (t: Throwable) { return Result(emptyMap(), "SQLCipher 加载失败:${t.message}") }
  35 + val stage = File(ctx.filesDir, "wx_chat_stage").apply { deleteRecursively(); mkdirs() }
  36 + val sp = stage.absolutePath
  37 + val uid = Process.myUid()
  38 +
  39 + // su:拷加密的 EnMicroMsg.db(+wal/shm) + 读 uin。--mount-master + export PATH 两坑见 memory。
  40 + val script = buildString {
  41 + append("export PATH=/system/bin:/system/xbin:\$PATH; ")
  42 + append("BASE=/data/user/0/com.tencent.mm; ")
  43 + append("DB=\$(ls \$BASE/MicroMsg/*/EnMicroMsg.db 2>/dev/null | head -1); ")
  44 + append("[ -z \"\$DB\" ] && exit 3; ")
  45 + append("cp \"\$DB\" $sp/en.db || exit 4; ")
  46 + append("[ -f \"\$DB-wal\" ] && cp \"\$DB-wal\" $sp/en.db-wal; ")
  47 + append("[ -f \"\$DB-shm\" ] && cp \"\$DB-shm\" $sp/en.db-shm; ")
  48 + append("UIN=\$(grep -hoE 'last_login_uin\">[0-9-]+' \$BASE/shared_prefs/*.xml 2>/dev/null | grep -oE '[0-9-]+' | head -1); ")
  49 + append("echo \"uin=\$UIN\"; ")
  50 + append("chown -R $uid:$uid $sp; chmod -R u+rwX $sp")
  51 + }
  52 + val (code, out) = su(script)
  53 + if (code != 0) {
  54 + stage.deleteRecursively()
  55 + return Result(emptyMap(), if (code == 3) "没找到 EnMicroMsg.db" else "读微信数据失败(需 root):${out.take(80)}")
  56 + }
  57 + val uin = Regex("uin=(-?\\d+)").find(out)?.groupValues?.get(1)
  58 + if (uin.isNullOrBlank()) { stage.deleteRecursively(); return Result(emptyMap(), "没读到微信 uin") }
  59 +
  60 + val keys = IMEI_CANDIDATES.map { md5Seven(it + uin) }.distinct()
  61 + val sdb = openWithKeys(File(stage, "en.db").absolutePath, keys)
  62 + ?: run { stage.deleteRecursively(); return Result(emptyMap(), "解密失败(key 不匹配 / 微信版本变)") }
  63 +
  64 + val byWxid = HashMap<String, MutableList<Msg>>()
  65 + try {
  66 + val ph = wxids.joinToString(",") { "?" }
  67 + sdb.rawQuery(
  68 + "SELECT talker, content, createTime, isSend FROM message WHERE type=1 AND talker IN ($ph) ORDER BY createTime ASC",
  69 + wxids.toTypedArray(),
  70 + ).use { c ->
  71 + while (c.moveToNext()) {
  72 + val talker = c.getString(0) ?: continue
  73 + val content = c.getString(1) ?: continue
  74 + if (content.isBlank()) continue
  75 + byWxid.getOrPut(talker) { ArrayList() }.add(Msg(content, c.getLong(2), c.getInt(3) == 1))
  76 + }
  77 + }
  78 + } catch (t: Throwable) {
  79 + sdb.close(); stage.deleteRecursively()
  80 + return Result(emptyMap(), "读 message 表失败:${t.message}")
  81 + }
  82 + sdb.close(); stage.deleteRecursively()
  83 + Log.i(TAG, "聊天:${wxids.size} 人,共 ${byWxid.values.sumOf { it.size }} 条")
  84 + return Result(byWxid)
  85 + }
  86 +
  87 + /** 逐个 key 试开;能查 sqlite_master 才算 key 对。失败返回 null。 */
  88 + private fun openWithKeys(path: String, keys: List<String>): SQLiteDatabase? {
  89 + val hook = object : SQLiteDatabaseHook {
  90 + override fun preKey(db: SQLiteDatabase) {}
  91 + override fun postKey(db: SQLiteDatabase) {
  92 + db.rawExecSQL("PRAGMA cipher_compatibility = 1;")
  93 + db.rawExecSQL("PRAGMA cipher_page_size = 1024;")
  94 + }
  95 + }
  96 + for (k in keys) {
  97 + try {
  98 + val db = SQLiteDatabase.openDatabase(path, k, null, SQLiteDatabase.OPEN_READONLY, hook)
  99 + db.rawQuery("SELECT count(*) FROM sqlite_master", null).use { it.moveToFirst() }
  100 + return db
  101 + } catch (t: Throwable) {
  102 + Log.w(TAG, "key 不对:${t.message?.take(60)}")
  103 + }
  104 + }
  105 + return null
  106 + }
  107 +
  108 + private fun md5Seven(s: String): String =
  109 + MessageDigest.getInstance("MD5").digest(s.toByteArray()).joinToString("") { "%02x".format(it) }.substring(0, 7)
  110 +
  111 + private fun su(script: String): Pair<Int, String> = try {
  112 + val p = ProcessBuilder("su", "--mount-master", "-c", script).redirectErrorStream(true).start()
  113 + val out = p.inputStream.bufferedReader().readText()
  114 + p.waitFor()
  115 + p.exitValue() to out
  116 + } catch (t: Throwable) {
  117 + -1 to (t.message ?: "su 不可用")
  118 + }
  119 +}
... ...