From 58fbcb4a63b78835a0cb9aa2b33084669dc16d9a Mon Sep 17 00:00:00 2001 From: yibo Date: Wed, 10 Jun 2026 11:34:54 +0800 Subject: [PATCH] feat(lifelog/photolog): 端上 SQLCipher 解密微信聊天(P2 #6,不再依赖 Mac) --- lifelog-recorder/app/build.gradle.kts | 3 +++ lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologViewModel.kt | 49 +++++++++++++++++++++++++++++++++++++++---------- lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Daos.kt | 4 ++++ lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/vision/WeChatChatImporter.kt | 119 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 165 insertions(+), 10 deletions(-) create mode 100644 lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/vision/WeChatChatImporter.kt diff --git a/lifelog-recorder/app/build.gradle.kts b/lifelog-recorder/app/build.gradle.kts index 7763583..8acc9f0 100644 --- a/lifelog-recorder/app/build.gradle.kts +++ b/lifelog-recorder/app/build.gradle.kts @@ -114,4 +114,7 @@ dependencies { implementation("androidx.exifinterface:exifinterface:1.3.7") // 相册图片异步加载/缓存。 implementation("io.coil-kt:coil-compose:2.7.0") + // 端上解密微信 EnMicroMsg.db(SQLCipher,cipher_compatibility=1 读旧版加密库)→ 聊天本地化。 + implementation("net.zetetic:android-database-sqlcipher:4.5.4") + implementation("androidx.sqlite:sqlite:2.4.0") } diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologViewModel.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologViewModel.kt index 1114699..6218896 100644 --- a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologViewModel.kt +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologViewModel.kt @@ -12,6 +12,8 @@ import com.lemon.lifelog.data.Photo import com.lemon.lifelog.data.PhotoRow import com.lemon.lifelog.data.Repository import com.lemon.lifelog.data.SpeakerProfile +import com.lemon.lifelog.data.WechatMessage +import com.lemon.lifelog.vision.WeChatChatImporter import androidx.work.ExistingWorkPolicy import androidx.work.OneTimeWorkRequestBuilder import androidx.work.WorkManager @@ -479,13 +481,33 @@ class PhotologViewModel(app: Application) : AndroidViewModel(app) { */ fun downloadWeChatImages(personId: Long, onResult: (Int, String?) -> Unit) { viewModelScope.launch { - val (avatarN, momentN, msg) = withContext(Dispatchers.IO) { downloadWeChatCore(personId) } + val (avatarN, momentN, msg) = withContext(Dispatchers.IO) { downloadWeChatCore(personId, includeChat = true) } onResult(avatarN, msg) } } - /** 单人微信下载核心(**IO 线程调**):头像→照片(归属脸) + 朋友圈按条存。返回 (头像数, 朋友圈条数, 人读消息)。 */ - private suspend fun downloadWeChatCore(personId: Long): Triple { + /** 端上解密 EnMicroMsg.db 读 [wxids] 的文本聊天 → wechat_messages(先按 talker 清再灌)。返回总条数。IO 线程。 */ + private suspend fun importChatFor(wxids: Set): Int { + if (wxids.isEmpty()) return 0 + val cr = WeChatChatImporter.importChats(getApplication(), wxids) + var total = 0 + for ((wxid, msgs) in cr.byWxid) { + if (msgs.isEmpty()) continue + repo.wechatMessages.clearTalker(wxid) + repo.wechatMessages.insertAll( + msgs.map { WechatMessage(talker = wxid, content = it.content, createTime = it.createTime, isSend = it.isSend) }, + ) + total += msgs.size + } + return total + } + + /** + * 单人微信下载核心(**IO 线程调**):头像→照片(归属脸) + 朋友圈按条存 + (可选)聊天解密。 + * [includeChat]:单人下载 true;批量更新传 false,由 updateAllManaged 一次性解密所有 wxid(避免逐人重复解密整库)。 + * 返回 (头像数, 朋友圈条数, 人读消息)。 + */ + private suspend fun downloadWeChatCore(personId: Long, includeChat: Boolean): Triple { val app = getApplication() val p = repo.persons.get(personId) ?: return Triple(0, 0, "人物不存在") val wxid = p.contactRef?.removePrefix("wechat:")?.takeIf { p.contactRef.startsWith("wechat:") } @@ -517,21 +539,24 @@ class PhotologViewModel(app: Application) : AndroidViewModel(app) { momentN = r.moments.size imgN = r.moments.sumOf { it.images.size } } - if (avatarN > 0 || momentN > 0) { + // 3) 聊天:端上解密 EnMicroMsg.db 读该人聊天(单人下载时)。 + var chatN = 0 + if (includeChat) runCatching { chatN = importChatFor(setOf(wxid)) } + if (avatarN > 0 || momentN > 0 || chatN > 0) { repo.persons.setManaged(personId, true) repo.persons.touch(personId, System.currentTimeMillis()) if (avatarN > 0) PhotoScheduler.runNow(app) } - val ok = avatarN > 0 || momentN > 0 - return Triple(avatarN, momentN, if (ok) "已下载头像 + $momentN 条朋友圈($imgN 张图)" else (r.error ?: "没有可下载的内容")) + val ok = avatarN > 0 || momentN > 0 || chatN > 0 + return Triple(avatarN, momentN, if (ok) "已下载头像 + $momentN 条朋友圈($imgN 张图)+ $chatN 条聊天" else (r.error ?: "没有可下载的内容")) } /** * 通讯录「🔄 更新」:批量更新**所有有微信的管理人物**。 - * - 微信:逐个 downloadWeChatCore(头像+朋友圈图+文字); + * - 微信:逐个 downloadWeChatCore(头像+朋友圈图+文字)+ 一次性端上解密 EnMicroMsg.db 读所有人聊天; * - 派生分析(全局一次):提及反查(MentionScanner) + 人↔人共现(RelationBuilder) + 待办(FollowupExtractor); * - 图片识别:催一遍全相册扫描 + VL。 - * 不碰录音(24/7 后台自动转写/认人)。聊天暂不本地更新(EnMicroMsg 加密,仍靠 Mac enroll)。 + * 不碰录音(24/7 后台自动转写/认人)。 */ private val _updating = MutableStateFlow(false) val updating: StateFlow = _updating @@ -546,18 +571,22 @@ class PhotologViewModel(app: Application) : AndroidViewModel(app) { var moments = 0 for (p in targets) { runCatching { - val (a, m, _) = downloadWeChatCore(p.id) + // 批量时不在 core 里逐人解密聊天,统一下面一次解密整库。 + val (a, m, _) = downloadWeChatCore(p.id, includeChat = false) if (a > 0 || m > 0) people++ moments += m } } + // 聊天:一次解密 EnMicroMsg.db 读所有目标 wxid(避免逐人重复解密整库)。 + val wxids = targets.mapNotNull { it.contactRef?.removePrefix("wechat:") }.toSet() + val chats = runCatching { importChatFor(wxids) }.getOrDefault(0) // 派生分析(全局各一次) runCatching { com.lemon.lifelog.crm.MentionScanner.rescanAll(repo) com.lemon.lifelog.crm.RelationBuilder.rebuild(repo) } runCatching { com.lemon.lifelog.crm.FollowupExtractor.scan(app, repo) } - "已更新 $people 人 · $moments 条朋友圈;关系/待办/提及已重建" + "已更新 $people 人 · $moments 条朋友圈 · $chats 条聊天;关系/待办/提及已重建" } // 图片识别:催全相册扫描 + VL(enqueue,不阻塞)。 scanGallery(); runVlNow() diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Daos.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Daos.kt index 2de5fd7..ee4feb0 100644 --- a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Daos.kt +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Daos.kt @@ -812,6 +812,10 @@ interface WechatMessageDao { @Query("DELETE FROM wechat_messages") suspend fun clear() + /** 删某联系人的聊天(端上一键更新该人聊天前先清,避免重复累积)。 */ + @Query("DELETE FROM wechat_messages WHERE talker = :talker") + suspend fun clearTalker(talker: String) + @Query("SELECT COUNT(*) FROM wechat_messages") suspend fun count(): Int diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/vision/WeChatChatImporter.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/vision/WeChatChatImporter.kt new file mode 100644 index 0000000..df6f204 --- /dev/null +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/vision/WeChatChatImporter.kt @@ -0,0 +1,119 @@ +package com.lemon.lifelog.vision + +import android.content.Context +import android.os.Process +import android.util.Log +import net.sqlcipher.database.SQLiteDatabase +import net.sqlcipher.database.SQLiteDatabaseHook +import java.io.File +import java.security.MessageDigest + +/** + * 端上解密微信 `EnMicroMsg.db`(SQLCipher)读聊天。**纯本地、不经 Mac**(朋友圈库是明文,这库是加密的)。 + * + * key 推导(真机验证,无需 Frida):`MD5(imei + uin)[:7]` + * - `imei` 用微信默认 `"1234567890ABCDEF"`(现代 Android 读不到真 IMEI 时微信的回退值,稳定); + * - `uin` 从 `com.tencent.mm` 的 shared_prefs xml 里的 `last_login_uin` 读; + * - SQLCipher 参数:`cipher_compatibility=1`、`cipher_page_size=1024`(微信用的旧版 SQLCipher)。 + * + * 门槛是 root(读 com.tencent.mm 私有库 + 切全局挂载命名空间)。坑见 memory `pixel-app-su-mount-master`。 + */ +object WeChatChatImporter { + private const val TAG = "WeChatChat" + // imei 候选(默认值优先;空串兜底)。 + private val IMEI_CANDIDATES = listOf("1234567890ABCDEF", "") + + data class Msg(val content: String, val createTime: Long, val isSend: Boolean) + + /** 各 wxid → 其文本聊天(时间正序)。error 非空 = 失败。 */ + data class Result(val byWxid: Map>, val error: String? = null) + + /** 一趟 su + 一次解密:读 [wxids] 这些联系人的文本聊天。**必须 IO 线程调用**。 */ + fun importChats(ctx: Context, wxids: Set): Result { + if (wxids.isEmpty()) return Result(emptyMap()) + try { SQLiteDatabase.loadLibs(ctx) } catch (t: Throwable) { return Result(emptyMap(), "SQLCipher 加载失败:${t.message}") } + val stage = File(ctx.filesDir, "wx_chat_stage").apply { deleteRecursively(); mkdirs() } + val sp = stage.absolutePath + val uid = Process.myUid() + + // su:拷加密的 EnMicroMsg.db(+wal/shm) + 读 uin。--mount-master + export PATH 两坑见 memory。 + val script = buildString { + append("export PATH=/system/bin:/system/xbin:\$PATH; ") + append("BASE=/data/user/0/com.tencent.mm; ") + append("DB=\$(ls \$BASE/MicroMsg/*/EnMicroMsg.db 2>/dev/null | head -1); ") + append("[ -z \"\$DB\" ] && exit 3; ") + append("cp \"\$DB\" $sp/en.db || exit 4; ") + append("[ -f \"\$DB-wal\" ] && cp \"\$DB-wal\" $sp/en.db-wal; ") + append("[ -f \"\$DB-shm\" ] && cp \"\$DB-shm\" $sp/en.db-shm; ") + append("UIN=\$(grep -hoE 'last_login_uin\">[0-9-]+' \$BASE/shared_prefs/*.xml 2>/dev/null | grep -oE '[0-9-]+' | head -1); ") + append("echo \"uin=\$UIN\"; ") + append("chown -R $uid:$uid $sp; chmod -R u+rwX $sp") + } + val (code, out) = su(script) + if (code != 0) { + stage.deleteRecursively() + return Result(emptyMap(), if (code == 3) "没找到 EnMicroMsg.db" else "读微信数据失败(需 root):${out.take(80)}") + } + val uin = Regex("uin=(-?\\d+)").find(out)?.groupValues?.get(1) + if (uin.isNullOrBlank()) { stage.deleteRecursively(); return Result(emptyMap(), "没读到微信 uin") } + + val keys = IMEI_CANDIDATES.map { md5Seven(it + uin) }.distinct() + val sdb = openWithKeys(File(stage, "en.db").absolutePath, keys) + ?: run { stage.deleteRecursively(); return Result(emptyMap(), "解密失败(key 不匹配 / 微信版本变)") } + + val byWxid = HashMap>() + try { + val ph = wxids.joinToString(",") { "?" } + sdb.rawQuery( + "SELECT talker, content, createTime, isSend FROM message WHERE type=1 AND talker IN ($ph) ORDER BY createTime ASC", + wxids.toTypedArray(), + ).use { c -> + while (c.moveToNext()) { + val talker = c.getString(0) ?: continue + val content = c.getString(1) ?: continue + if (content.isBlank()) continue + byWxid.getOrPut(talker) { ArrayList() }.add(Msg(content, c.getLong(2), c.getInt(3) == 1)) + } + } + } catch (t: Throwable) { + sdb.close(); stage.deleteRecursively() + return Result(emptyMap(), "读 message 表失败:${t.message}") + } + sdb.close(); stage.deleteRecursively() + Log.i(TAG, "聊天:${wxids.size} 人,共 ${byWxid.values.sumOf { it.size }} 条") + return Result(byWxid) + } + + /** 逐个 key 试开;能查 sqlite_master 才算 key 对。失败返回 null。 */ + private fun openWithKeys(path: String, keys: List): SQLiteDatabase? { + val hook = object : SQLiteDatabaseHook { + override fun preKey(db: SQLiteDatabase) {} + override fun postKey(db: SQLiteDatabase) { + db.rawExecSQL("PRAGMA cipher_compatibility = 1;") + db.rawExecSQL("PRAGMA cipher_page_size = 1024;") + } + } + for (k in keys) { + try { + val db = SQLiteDatabase.openDatabase(path, k, null, SQLiteDatabase.OPEN_READONLY, hook) + db.rawQuery("SELECT count(*) FROM sqlite_master", null).use { it.moveToFirst() } + return db + } catch (t: Throwable) { + Log.w(TAG, "key 不对:${t.message?.take(60)}") + } + } + return null + } + + private fun md5Seven(s: String): String = + MessageDigest.getInstance("MD5").digest(s.toByteArray()).joinToString("") { "%02x".format(it) }.substring(0, 7) + + private fun su(script: String): Pair = try { + val p = ProcessBuilder("su", "--mount-master", "-c", script).redirectErrorStream(true).start() + val out = p.inputStream.bufferedReader().readText() + p.waitFor() + p.exitValue() to out + } catch (t: Throwable) { + -1 to (t.message ?: "su 不可用") + } +} -- libgit2 0.21.4