Commit 80c4997de11aa1c5e9b8bc3a9ea9eaeb81c683f5

Authored by yibo
1 parent 54a7e24b

feat(lifelog/photolog): 微信联系人+头像端上化(解密 rcontact/img_flag)+ 通讯录全选

最后一块去 Mac 依赖——联系人/头像也端上读:
- WeChatDb:共享的 EnMicroMsg.db 端上解密(su --mount-master + 算 key + SQLCipher 打开),
  WeChatChatImporter 重构成复用它(聊天逻辑不变)
- WeChatContactsImporter:解密读 rcontact ⨝ img_flag 真人筛选(移植 Mac extract-wechat-avatars.py)
  → 产出 wx_enroll/enroll.json + OkHttp 12 线程并发下高清头像(img_flag.reserved1, wx.qlogo.cn 公开)
  → 触发现有 WeChatEnrollWorker 做 Person upsert + 端上人脸锚点
- 通讯录选人页「💬 微信导入」改成端上 importWeChatContactsLocal(不再依赖 Mac 推 enroll)
- 通讯录加「全选/取消全选(N)」批量纳入管理(作用于当前过滤列表,可先搜再全选)
- 真机验证:读到 4922 联系人 + 4818 头像

注:当前导入含群友(rcontact.type=4),好友(type&1=1)区分 + 导入筛选下一步做。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologScreen.kt
@@ -759,7 +759,8 @@ private fun ContactRow(p: Person, onClick: () -&gt; Unit) { @@ -759,7 +759,8 @@ private fun ContactRow(p: Person, onClick: () -&gt; Unit) {
759 @Composable 759 @Composable
760 private fun WeChatPickerScreen(vm: PhotologViewModel, onBack: () -> Unit, onOpenPhonePicker: () -> Unit) { 760 private fun WeChatPickerScreen(vm: PhotologViewModel, onBack: () -> Unit, onOpenPhonePicker: () -> Unit) {
761 val all by vm.repo.persons.observeAll().collectAsState(initial = emptyList()) 761 val all by vm.repo.persons.observeAll().collectAsState(initial = emptyList())
762 - val enrollReady = remember { vm.wechatEnrollReady() } 762 + val ctx = LocalContext.current
  763 + val enrolling by vm.enrolling.collectAsState()
763 var enrollTapped by remember { mutableStateOf(false) } 764 var enrollTapped by remember { mutableStateOf(false) }
764 var query by remember { mutableStateOf("") } 765 var query by remember { mutableStateOf("") }
765 val managedCount = all.count { it.managed } 766 val managedCount = all.count { it.managed }
@@ -789,19 +790,38 @@ private fun WeChatPickerScreen(vm: PhotologViewModel, onBack: () -&gt; Unit, onOpen @@ -789,19 +790,38 @@ private fun WeChatPickerScreen(vm: PhotologViewModel, onBack: () -&gt; Unit, onOpen
789 placeholder = { Text("搜索(共 ${all.size},已管理 $managedCount)") }, singleLine = true, 790 placeholder = { Text("搜索(共 ${all.size},已管理 $managedCount)") }, singleLine = true,
790 modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp), 791 modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp),
791 ) 792 )
792 - // 两源导入:微信(Mac 推过来的 enroll 数据,导入进池子后下面勾选)+ 手机系统通讯录(进多选页勾选导入)。 793 + // 两源导入:微信(端上解密 EnMicroMsg 读联系人+头像,不经 Mac)+ 手机系统通讯录(进多选页勾选导入)。
793 Row( 794 Row(
794 modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp).padding(bottom = 4.dp), 795 modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp).padding(bottom = 4.dp),
795 verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), 796 verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp),
796 ) { 797 ) {
797 - if (enrollReady) {  
798 - Button(onClick = { vm.enrollWeChat(); enrollTapped = true }) { Text("💬 微信导入") }  
799 - } 798 + Button(
  799 + enabled = !enrolling,
  800 + onClick = {
  801 + android.widget.Toast.makeText(ctx, "读微信联系人中…(首次下几千头像会慢,首次需授予 root)", android.widget.Toast.LENGTH_SHORT).show()
  802 + vm.importWeChatContactsLocal { msg -> android.widget.Toast.makeText(ctx, msg, android.widget.Toast.LENGTH_LONG).show() }
  803 + enrollTapped = true
  804 + },
  805 + ) { Text(if (enrolling) "导入中…" else "💬 微信导入") }
800 OutlinedButton(onClick = onOpenPhonePicker) { Text("📱 手机通讯录") } 806 OutlinedButton(onClick = onOpenPhonePicker) { Text("📱 手机通讯录") }
801 } 807 }
802 - val hint = if (enrollTapped) "微信后台导入中,联系人会陆续出现…"  
803 - else "勾选要纳入「我的人物」的联系人"  
804 - Text(hint, style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(horizontal = 16.dp, vertical = 2.dp)) 808 + // 全选/取消全选:作用于当前过滤后的列表(可先搜一批再全选)。
  809 + val filtered = remember(sections) { sections.values.flatten() }
  810 + val allManaged = filtered.isNotEmpty() && filtered.all { it.managed }
  811 + Row(
  812 + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 2.dp),
  813 + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween,
  814 + ) {
  815 + Text(
  816 + if (enrollTapped) "微信导入中,联系人会陆续出现…" else "勾选要纳入「我的人物」的联系人",
  817 + style = MaterialTheme.typography.labelSmall,
  818 + )
  819 + if (filtered.isNotEmpty()) {
  820 + TextButton(onClick = { vm.setManagedBulk(filtered.map { it.id }, !allManaged) }) {
  821 + Text(if (allManaged) "取消全选" else "全选(${filtered.size})", style = MaterialTheme.typography.labelMedium)
  822 + }
  823 + }
  824 + }
805 HorizontalDivider() 825 HorizontalDivider()
806 LazyColumn(modifier = Modifier.fillMaxSize()) { 826 LazyColumn(modifier = Modifier.fillMaxSize()) {
807 sections.forEach { (letter, group) -> 827 sections.forEach { (letter, group) ->
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologViewModel.kt
@@ -158,6 +158,14 @@ class PhotologViewModel(app: Application) : AndroidViewModel(app) { @@ -158,6 +158,14 @@ class PhotologViewModel(app: Application) : AndroidViewModel(app) {
158 viewModelScope.launch { repo.persons.setManaged(personId, managed) } 158 viewModelScope.launch { repo.persons.setManaged(personId, managed) }
159 } 159 }
160 160
  161 + /** 批量勾选 / 取消(通讯录「全选/取消全选」用,作用于当前过滤列表)。 */
  162 + fun setManagedBulk(ids: List<Long>, managed: Boolean) {
  163 + if (ids.isEmpty()) return
  164 + viewModelScope.launch {
  165 + withContext(Dispatchers.IO) { for (id in ids) repo.persons.setManaged(id, managed) }
  166 + }
  167 + }
  168 +
161 /** 提及反查:全量重扫所有转写,按当前 managed 人物词典重建 mentions(档案「被提到」tab 用)。 */ 169 /** 提及反查:全量重扫所有转写,按当前 managed 人物词典重建 mentions(档案「被提到」tab 用)。 */
162 private val _mentionScanning = MutableStateFlow(false) 170 private val _mentionScanning = MutableStateFlow(false)
163 val mentionScanning: StateFlow<Boolean> = _mentionScanning 171 val mentionScanning: StateFlow<Boolean> = _mentionScanning
@@ -473,6 +481,27 @@ class PhotologViewModel(app: Application) : AndroidViewModel(app) { @@ -473,6 +481,27 @@ class PhotologViewModel(app: Application) : AndroidViewModel(app) {
473 } 481 }
474 482
475 /** 483 /**
  484 + * 端上一键导入微信联系人 + 头像(**纯本地不经 Mac**):解密读 rcontact/img_flag → 产出 wx_enroll/
  485 + * → 触发 [WeChatEnrollWorker] 做 Person upsert + 端上人脸锚点。头像几千张首次会下一阵。
  486 + */
  487 + private val _enrolling = MutableStateFlow(false)
  488 + val enrolling: StateFlow<Boolean> = _enrolling
  489 + fun importWeChatContactsLocal(onDone: (String) -> Unit) {
  490 + if (_enrolling.value) return
  491 + _enrolling.value = true
  492 + val app = getApplication<Application>()
  493 + viewModelScope.launch {
  494 + val res = withContext(Dispatchers.IO) { com.lemon.lifelog.vision.WeChatContactsImporter.importContacts(app) }
  495 + if (res.contacts > 0) enrollWeChat() // 触发 upsert + 人脸锚点
  496 + _enrolling.value = false
  497 + onDone(
  498 + if (res.contacts > 0) "已读取 ${res.contacts} 个联系人 + ${res.avatars} 头像,正在建档/认人…"
  499 + else (res.error ?: "没读到联系人"),
  500 + )
  501 + }
  502 + }
  503 +
  504 + /**
476 * 一键下载该人物的微信内容(**纯本地,不经 Mac**): 505 * 一键下载该人物的微信内容(**纯本地,不经 Mac**):
477 * - 头像:`wx_enroll/avatars/<wxid>.jpg` → 导入成「照片」(归属脸挂到人物),出现在档案页「照片」tab。 506 * - 头像:`wx_enroll/avatars/<wxid>.jpg` → 导入成「照片」(归属脸挂到人物),出现在档案页「照片」tab。
478 * - 朋友圈:端上 su 读微信明文 sns 缓存,**按条**存(图存 `moments/<wxid>/<ms>_<i>.jpg`,文字进 moments 表); 507 * - 朋友圈:端上 su 读微信明文 sns 缓存,**按条**存(图存 `moments/<wxid>/<ms>_<i>.jpg`,文字进 moments 表);
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/vision/WeChatChatImporter.kt
1 package com.lemon.lifelog.vision 1 package com.lemon.lifelog.vision
2 2
3 import android.content.Context 3 import android.content.Context
4 -import android.os.Process  
5 import android.util.Log 4 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 5
11 /** 6 /**
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`。 7 + * 端上解密微信 `EnMicroMsg.db` 读聊天。**纯本地、不经 Mac**(解密细节见 [WeChatDb])。
20 */ 8 */
21 object WeChatChatImporter { 9 object WeChatChatImporter {
22 private const val TAG = "WeChatChat" 10 private const val TAG = "WeChatChat"
23 - // imei 候选(默认值优先;空串兜底)。  
24 - private val IMEI_CANDIDATES = listOf("1234567890ABCDEF", "")  
25 11
26 data class Msg(val content: String, val createTime: Long, val isSend: Boolean) 12 data class Msg(val content: String, val createTime: Long, val isSend: Boolean)
27 13
@@ -31,40 +17,12 @@ object WeChatChatImporter { @@ -31,40 +17,12 @@ object WeChatChatImporter {
31 /** 一趟 su + 一次解密:读 [wxids] 这些联系人的文本聊天。**必须 IO 线程调用**。 */ 17 /** 一趟 su + 一次解密:读 [wxids] 这些联系人的文本聊天。**必须 IO 线程调用**。 */
32 fun importChats(ctx: Context, wxids: Set<String>): Result { 18 fun importChats(ctx: Context, wxids: Set<String>): Result {
33 if (wxids.isEmpty()) return Result(emptyMap()) 19 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 - 20 + val (opened, err) = WeChatDb.openEnMicroMsg(ctx)
  21 + if (opened == null) return Result(emptyMap(), err)
64 val byWxid = HashMap<String, MutableList<Msg>>() 22 val byWxid = HashMap<String, MutableList<Msg>>()
65 try { 23 try {
66 val ph = wxids.joinToString(",") { "?" } 24 val ph = wxids.joinToString(",") { "?" }
67 - sdb.rawQuery( 25 + opened.db.rawQuery(
68 "SELECT talker, content, createTime, isSend FROM message WHERE type=1 AND talker IN ($ph) ORDER BY createTime ASC", 26 "SELECT talker, content, createTime, isSend FROM message WHERE type=1 AND talker IN ($ph) ORDER BY createTime ASC",
69 wxids.toTypedArray(), 27 wxids.toTypedArray(),
70 ).use { c -> 28 ).use { c ->
@@ -76,44 +34,11 @@ object WeChatChatImporter { @@ -76,44 +34,11 @@ object WeChatChatImporter {
76 } 34 }
77 } 35 }
78 } catch (t: Throwable) { 36 } catch (t: Throwable) {
79 - sdb.close(); stage.deleteRecursively() 37 + opened.close()
80 return Result(emptyMap(), "读 message 表失败:${t.message}") 38 return Result(emptyMap(), "读 message 表失败:${t.message}")
81 } 39 }
82 - sdb.close(); stage.deleteRecursively() 40 + opened.close()
83 Log.i(TAG, "聊天:${wxids.size} 人,共 ${byWxid.values.sumOf { it.size }} 条") 41 Log.i(TAG, "聊天:${wxids.size} 人,共 ${byWxid.values.sumOf { it.size }} 条")
84 return Result(byWxid) 42 return Result(byWxid)
85 } 43 }
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 } 44 }
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/vision/WeChatContactsImporter.kt 0 → 100644
  1 +package com.lemon.lifelog.vision
  2 +
  3 +import android.content.Context
  4 +import android.util.Log
  5 +import okhttp3.OkHttpClient
  6 +import okhttp3.Request
  7 +import org.json.JSONArray
  8 +import org.json.JSONObject
  9 +import java.io.File
  10 +import java.util.concurrent.Executors
  11 +import java.util.concurrent.TimeUnit
  12 +
  13 +/**
  14 + * 端上读微信联系人 + 头像 → 产出 `filesDir/wx_enroll/`(enroll.json + avatars/),**纯本地不经 Mac**。
  15 + * 之后交给现有 [WeChatEnrollWorker] 做 Person upsert + 端上人脸锚点(复用其 Pass1/Pass2)。
  16 + *
  17 + * - 联系人:解密 `EnMicroMsg.db`([WeChatDb])读 `rcontact ⨝ img_flag` 真人筛选(verifyFlag=0,排群/公众号/企业微信/系统号)。
  18 + * - 头像:`img_flag.reserved1` = 高清头像公开 URL(`wx.qlogo.cn`,无需鉴权),OkHttp 并发下载到 `avatars/<wxid>.jpg`。
  19 + *
  20 + * SQL/筛选移植自 Mac 端 `server/services/wechat/extract-wechat-avatars.py`。
  21 + */
  22 +object WeChatContactsImporter {
  23 + private const val TAG = "WeChatContacts"
  24 +
  25 + // 真人联系人 + 高清头像 URL(移植自 Mac extract-wechat-avatars.py)。
  26 + private const val REAL_PERSON_SQL = """
  27 + SELECT r.username AS username,
  28 + COALESCE(NULLIF(r.conRemark,''), NULLIF(r.nickname,''), r.username) AS name,
  29 + r.nickname AS nickname, r.conRemark AS remark, r.alias AS alias,
  30 + r.pyInitial AS pyInitial, r.quanPin AS quanPin,
  31 + f.reserved1 AS avatar
  32 + FROM rcontact r JOIN img_flag f ON f.username = r.username
  33 + WHERE r.verifyFlag = 0
  34 + AND r.username NOT LIKE '%@chatroom'
  35 + AND r.username NOT LIKE 'gh_%'
  36 + AND r.username NOT LIKE '%@openim'
  37 + AND r.username NOT IN ('weixin','filehelper','fmessage','medianote','floatbottle',
  38 + 'qmessage','qqmail','newsapp','officialaccounts','helper_entry','exmail_tool',
  39 + 'mphelper','brandsessionholder')
  40 + AND f.reserved1 LIKE 'http%'
  41 + """
  42 +
  43 + data class Result(val contacts: Int, val avatars: Int, val error: String? = null)
  44 +
  45 + private data class Row(val username: String, val obj: JSONObject, val avatarUrl: String?)
  46 +
  47 + /** 解密读联系人 + 下头像,写 `wx_enroll/`。**必须 IO 线程调用**(含 su/解密/网络/文件 IO)。 */
  48 + fun importContacts(ctx: Context): Result {
  49 + val (opened, err) = WeChatDb.openEnMicroMsg(ctx)
  50 + if (opened == null) return Result(0, 0, err)
  51 + val rows = ArrayList<Row>()
  52 + try {
  53 + opened.db.rawQuery(REAL_PERSON_SQL, null).use { c ->
  54 + val iU = c.getColumnIndexOrThrow("username")
  55 + val iName = c.getColumnIndexOrThrow("name")
  56 + val iNick = c.getColumnIndexOrThrow("nickname")
  57 + val iRemark = c.getColumnIndexOrThrow("remark")
  58 + val iAlias = c.getColumnIndexOrThrow("alias")
  59 + val iPyInit = c.getColumnIndexOrThrow("pyInitial")
  60 + val iQuanPin = c.getColumnIndexOrThrow("quanPin")
  61 + val iAvatar = c.getColumnIndexOrThrow("avatar")
  62 + while (c.moveToNext()) {
  63 + val username = c.getString(iU)?.takeIf { it.isNotBlank() } ?: continue
  64 + val o = JSONObject()
  65 + .put("username", username)
  66 + .put("name", c.getString(iName) ?: username)
  67 + .put("nickname", c.getString(iNick) ?: "")
  68 + .put("remark", c.getString(iRemark) ?: "")
  69 + .put("alias", c.getString(iAlias) ?: "")
  70 + .put("pyInitial", c.getString(iPyInit) ?: "")
  71 + .put("quanPin", c.getString(iQuanPin) ?: "")
  72 + .put("hasFace", false)
  73 + rows.add(Row(username, o, c.getString(iAvatar)?.takeIf { it.startsWith("http") }))
  74 + }
  75 + }
  76 + } catch (t: Throwable) {
  77 + opened.close()
  78 + return Result(0, 0, "读 rcontact/img_flag 失败:${t.message}")
  79 + }
  80 + opened.close()
  81 + if (rows.isEmpty()) return Result(0, 0, "没读到真人联系人")
  82 +
  83 + val enrollDir = File(ctx.filesDir, "wx_enroll").apply { mkdirs() }
  84 + val avatarDir = File(enrollDir, "avatars").apply { mkdirs() }
  85 + val downloaded = downloadAvatars(rows.mapNotNull { r -> r.avatarUrl?.let { r.username to it } }, avatarDir)
  86 + rows.forEach { if (it.username in downloaded) it.obj.put("hasFace", true) }
  87 +
  88 + val arr = JSONArray()
  89 + rows.forEach { arr.put(it.obj) }
  90 + File(enrollDir, "enroll.json").writeText(arr.toString())
  91 + Log.i(TAG, "联系人 ${rows.size},头像下载 ${downloaded.size}")
  92 + return Result(rows.size, downloaded.size)
  93 + }
  94 +
  95 + /** 并发下载头像(跳过已存在的,方便增量/幂等)。返回成功下载/已存在的 username 集合。 */
  96 + private fun downloadAvatars(items: List<Pair<String, String>>, dir: File): Set<String> {
  97 + if (items.isEmpty()) return emptySet()
  98 + val client = OkHttpClient.Builder()
  99 + .callTimeout(12, TimeUnit.SECONDS)
  100 + .connectTimeout(8, TimeUnit.SECONDS)
  101 + .build()
  102 + val ok = java.util.concurrent.ConcurrentHashMap.newKeySet<String>()
  103 + val pool = Executors.newFixedThreadPool(12)
  104 + try {
  105 + val futures = items.map { (username, url) ->
  106 + pool.submit {
  107 + val dest = File(dir, "$username.jpg")
  108 + if (dest.exists() && dest.length() > 0) { ok.add(username); return@submit }
  109 + try {
  110 + val req = Request.Builder().url(url).header("User-Agent", "Mozilla/5.0").build()
  111 + client.newCall(req).execute().use { resp ->
  112 + val bytes = resp.body?.bytes()
  113 + if (resp.isSuccessful && bytes != null && bytes.size > 200) {
  114 + dest.writeBytes(bytes)
  115 + ok.add(username)
  116 + }
  117 + }
  118 + } catch (_: Throwable) {
  119 + }
  120 + }
  121 + }
  122 + futures.forEach { runCatching { it.get(30, TimeUnit.SECONDS) } }
  123 + } finally {
  124 + pool.shutdownNow()
  125 + }
  126 + return ok
  127 + }
  128 +}
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/vision/WeChatDb.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,明文化后供 chat / contacts 等复用)。**纯本地,不经 Mac/Frida**。
  13 + *
  14 + * key 推导(真机验证):`MD5("1234567890ABCDEF" + uin)[:7]`(imei 用微信默认值;uin 从 shared_prefs 读)。
  15 + * SQLCipher:`cipher_compatibility=1` + `page_size=1024`。两个 app-su 坑(--mount-master / 空 PATH)见 §七。
  16 + * 详见 memory `wechat-android-decrypt`。
  17 + */
  18 +object WeChatDb {
  19 + private const val TAG = "WeChatDb"
  20 + private val IMEI_CANDIDATES = listOf("1234567890ABCDEF", "")
  21 +
  22 + /** 已打开的库 + 它的暂存目录;用完务必 [close](关库 + 删明文暂存)。 */
  23 + class Opened(val db: SQLiteDatabase, private val stage: File) {
  24 + fun close() {
  25 + runCatching { db.close() }
  26 + stage.deleteRecursively()
  27 + }
  28 + }
  29 +
  30 + /** su 拷 EnMicroMsg.db + 读 uin → 算 key → SQLCipher 只读打开。失败返回 (null, 错误信息)。**IO 线程调**。 */
  31 + fun openEnMicroMsg(ctx: Context): Pair<Opened?, String?> {
  32 + try { SQLiteDatabase.loadLibs(ctx) } catch (t: Throwable) { return null to "SQLCipher 加载失败:${t.message}" }
  33 + val stage = File(ctx.filesDir, "wx_db_stage").apply { deleteRecursively(); mkdirs() }
  34 + val sp = stage.absolutePath
  35 + val uid = Process.myUid()
  36 + val script = buildString {
  37 + append("export PATH=/system/bin:/system/xbin:\$PATH; ")
  38 + append("BASE=/data/user/0/com.tencent.mm; ")
  39 + append("DB=\$(ls \$BASE/MicroMsg/*/EnMicroMsg.db 2>/dev/null | head -1); ")
  40 + append("[ -z \"\$DB\" ] && exit 3; ")
  41 + append("cp \"\$DB\" $sp/en.db || exit 4; ")
  42 + append("[ -f \"\$DB-wal\" ] && cp \"\$DB-wal\" $sp/en.db-wal; ")
  43 + append("[ -f \"\$DB-shm\" ] && cp \"\$DB-shm\" $sp/en.db-shm; ")
  44 + append("UIN=\$(grep -hoE 'last_login_uin\">[0-9-]+' \$BASE/shared_prefs/*.xml 2>/dev/null | grep -oE '[0-9-]+' | head -1); ")
  45 + append("echo \"uin=\$UIN\"; ")
  46 + append("chown -R $uid:$uid $sp; chmod -R u+rwX $sp")
  47 + }
  48 + val (code, out) = su(script)
  49 + if (code != 0) {
  50 + stage.deleteRecursively()
  51 + return null to if (code == 3) "没找到 EnMicroMsg.db" else "读微信数据失败(需 root):${out.take(80)}"
  52 + }
  53 + val uin = Regex("uin=(-?\\d+)").find(out)?.groupValues?.get(1)
  54 + if (uin.isNullOrBlank()) { stage.deleteRecursively(); return null to "没读到微信 uin" }
  55 + val keys = IMEI_CANDIDATES.map { md5Seven(it + uin) }.distinct()
  56 + val db = openWithKeys(File(stage, "en.db").absolutePath, keys)
  57 + ?: run { stage.deleteRecursively(); return null to "解密失败(key 不匹配 / 微信版本变)" }
  58 + return Opened(db, stage) to null
  59 + }
  60 +
  61 + private fun openWithKeys(path: String, keys: List<String>): SQLiteDatabase? {
  62 + val hook = object : SQLiteDatabaseHook {
  63 + override fun preKey(db: SQLiteDatabase) {}
  64 + override fun postKey(db: SQLiteDatabase) {
  65 + db.rawExecSQL("PRAGMA cipher_compatibility = 1;")
  66 + db.rawExecSQL("PRAGMA cipher_page_size = 1024;")
  67 + }
  68 + }
  69 + for (k in keys) {
  70 + try {
  71 + val db = SQLiteDatabase.openDatabase(path, k, null, SQLiteDatabase.OPEN_READONLY, hook)
  72 + db.rawQuery("SELECT count(*) FROM sqlite_master", null).use { it.moveToFirst() }
  73 + return db
  74 + } catch (t: Throwable) {
  75 + Log.w(TAG, "key 不对:${t.message?.take(60)}")
  76 + }
  77 + }
  78 + return null
  79 + }
  80 +
  81 + private fun md5Seven(s: String): String =
  82 + MessageDigest.getInstance("MD5").digest(s.toByteArray()).joinToString("") { "%02x".format(it) }.substring(0, 7)
  83 +
  84 + private fun su(script: String): Pair<Int, String> = try {
  85 + val p = ProcessBuilder("su", "--mount-master", "-c", script).redirectErrorStream(true).start()
  86 + val out = p.inputStream.bufferedReader().readText()
  87 + p.waitFor()
  88 + p.exitValue() to out
  89 + } catch (t: Throwable) {
  90 + -1 to (t.message ?: "su 不可用")
  91 + }
  92 +}