Commit 58fbcb4a63b78835a0cb9aa2b33084669dc16d9a
1 parent
78ae2d69
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>
Showing
4 changed files
with
165 additions
and
10 deletions
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 | +} | ... | ... |