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,4 +114,7 @@ dependencies { | ||
| 114 | implementation("androidx.exifinterface:exifinterface:1.3.7") | 114 | implementation("androidx.exifinterface:exifinterface:1.3.7") |
| 115 | // 相册图片异步加载/缓存。 | 115 | // 相册图片异步加载/缓存。 |
| 116 | implementation("io.coil-kt:coil-compose:2.7.0") | 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,6 +12,8 @@ import com.lemon.lifelog.data.Photo | ||
| 12 | import com.lemon.lifelog.data.PhotoRow | 12 | import com.lemon.lifelog.data.PhotoRow |
| 13 | import com.lemon.lifelog.data.Repository | 13 | import com.lemon.lifelog.data.Repository |
| 14 | import com.lemon.lifelog.data.SpeakerProfile | 14 | import com.lemon.lifelog.data.SpeakerProfile |
| 15 | +import com.lemon.lifelog.data.WechatMessage | ||
| 16 | +import com.lemon.lifelog.vision.WeChatChatImporter | ||
| 15 | import androidx.work.ExistingWorkPolicy | 17 | import androidx.work.ExistingWorkPolicy |
| 16 | import androidx.work.OneTimeWorkRequestBuilder | 18 | import androidx.work.OneTimeWorkRequestBuilder |
| 17 | import androidx.work.WorkManager | 19 | import androidx.work.WorkManager |
| @@ -479,13 +481,33 @@ class PhotologViewModel(app: Application) : AndroidViewModel(app) { | @@ -479,13 +481,33 @@ class PhotologViewModel(app: Application) : AndroidViewModel(app) { | ||
| 479 | */ | 481 | */ |
| 480 | fun downloadWeChatImages(personId: Long, onResult: (Int, String?) -> Unit) { | 482 | fun downloadWeChatImages(personId: Long, onResult: (Int, String?) -> Unit) { |
| 481 | viewModelScope.launch { | 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 | onResult(avatarN, msg) | 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 | val app = getApplication<Application>() | 511 | val app = getApplication<Application>() |
| 490 | val p = repo.persons.get(personId) ?: return Triple(0, 0, "人物不存在") | 512 | val p = repo.persons.get(personId) ?: return Triple(0, 0, "人物不存在") |
| 491 | val wxid = p.contactRef?.removePrefix("wechat:")?.takeIf { p.contactRef.startsWith("wechat:") } | 513 | val wxid = p.contactRef?.removePrefix("wechat:")?.takeIf { p.contactRef.startsWith("wechat:") } |
| @@ -517,21 +539,24 @@ class PhotologViewModel(app: Application) : AndroidViewModel(app) { | @@ -517,21 +539,24 @@ class PhotologViewModel(app: Application) : AndroidViewModel(app) { | ||
| 517 | momentN = r.moments.size | 539 | momentN = r.moments.size |
| 518 | imgN = r.moments.sumOf { it.images.size } | 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 | repo.persons.setManaged(personId, true) | 546 | repo.persons.setManaged(personId, true) |
| 522 | repo.persons.touch(personId, System.currentTimeMillis()) | 547 | repo.persons.touch(personId, System.currentTimeMillis()) |
| 523 | if (avatarN > 0) PhotoScheduler.runNow(app) | 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 | * - 派生分析(全局一次):提及反查(MentionScanner) + 人↔人共现(RelationBuilder) + 待办(FollowupExtractor); | 557 | * - 派生分析(全局一次):提及反查(MentionScanner) + 人↔人共现(RelationBuilder) + 待办(FollowupExtractor); |
| 533 | * - 图片识别:催一遍全相册扫描 + VL。 | 558 | * - 图片识别:催一遍全相册扫描 + VL。 |
| 534 | - * 不碰录音(24/7 后台自动转写/认人)。聊天暂不本地更新(EnMicroMsg 加密,仍靠 Mac enroll)。 | 559 | + * 不碰录音(24/7 后台自动转写/认人)。 |
| 535 | */ | 560 | */ |
| 536 | private val _updating = MutableStateFlow(false) | 561 | private val _updating = MutableStateFlow(false) |
| 537 | val updating: StateFlow<Boolean> = _updating | 562 | val updating: StateFlow<Boolean> = _updating |
| @@ -546,18 +571,22 @@ class PhotologViewModel(app: Application) : AndroidViewModel(app) { | @@ -546,18 +571,22 @@ class PhotologViewModel(app: Application) : AndroidViewModel(app) { | ||
| 546 | var moments = 0 | 571 | var moments = 0 |
| 547 | for (p in targets) { | 572 | for (p in targets) { |
| 548 | runCatching { | 573 | runCatching { |
| 549 | - val (a, m, _) = downloadWeChatCore(p.id) | 574 | + // 批量时不在 core 里逐人解密聊天,统一下面一次解密整库。 |
| 575 | + val (a, m, _) = downloadWeChatCore(p.id, includeChat = false) | ||
| 550 | if (a > 0 || m > 0) people++ | 576 | if (a > 0 || m > 0) people++ |
| 551 | moments += m | 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 | runCatching { | 584 | runCatching { |
| 556 | com.lemon.lifelog.crm.MentionScanner.rescanAll(repo) | 585 | com.lemon.lifelog.crm.MentionScanner.rescanAll(repo) |
| 557 | com.lemon.lifelog.crm.RelationBuilder.rebuild(repo) | 586 | com.lemon.lifelog.crm.RelationBuilder.rebuild(repo) |
| 558 | } | 587 | } |
| 559 | runCatching { com.lemon.lifelog.crm.FollowupExtractor.scan(app, repo) } | 588 | runCatching { com.lemon.lifelog.crm.FollowupExtractor.scan(app, repo) } |
| 560 | - "已更新 $people 人 · $moments 条朋友圈;关系/待办/提及已重建" | 589 | + "已更新 $people 人 · $moments 条朋友圈 · $chats 条聊天;关系/待办/提及已重建" |
| 561 | } | 590 | } |
| 562 | // 图片识别:催全相册扫描 + VL(enqueue,不阻塞)。 | 591 | // 图片识别:催全相册扫描 + VL(enqueue,不阻塞)。 |
| 563 | scanGallery(); runVlNow() | 592 | scanGallery(); runVlNow() |
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Daos.kt
| @@ -812,6 +812,10 @@ interface WechatMessageDao { | @@ -812,6 +812,10 @@ interface WechatMessageDao { | ||
| 812 | @Query("DELETE FROM wechat_messages") | 812 | @Query("DELETE FROM wechat_messages") |
| 813 | suspend fun clear() | 813 | suspend fun clear() |
| 814 | 814 | ||
| 815 | + /** 删某联系人的聊天(端上一键更新该人聊天前先清,避免重复累积)。 */ | ||
| 816 | + @Query("DELETE FROM wechat_messages WHERE talker = :talker") | ||
| 817 | + suspend fun clearTalker(talker: String) | ||
| 818 | + | ||
| 815 | @Query("SELECT COUNT(*) FROM wechat_messages") | 819 | @Query("SELECT COUNT(*) FROM wechat_messages") |
| 816 | suspend fun count(): Int | 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 | +} |