Commit e3109a74e9e94df54a23fa8dd2a7059466f5e0cd
1 parent
996bc374
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>
Showing
5 changed files
with
283 additions
and
89 deletions
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologScreen.kt
| ... | ... | @@ -759,7 +759,8 @@ private fun ContactRow(p: Person, onClick: () -> Unit) { |
| 759 | 759 | @Composable |
| 760 | 760 | private fun WeChatPickerScreen(vm: PhotologViewModel, onBack: () -> Unit, onOpenPhonePicker: () -> Unit) { |
| 761 | 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 | 764 | var enrollTapped by remember { mutableStateOf(false) } |
| 764 | 765 | var query by remember { mutableStateOf("") } |
| 765 | 766 | val managedCount = all.count { it.managed } |
| ... | ... | @@ -789,19 +790,38 @@ private fun WeChatPickerScreen(vm: PhotologViewModel, onBack: () -> Unit, onOpen |
| 789 | 790 | placeholder = { Text("搜索(共 ${all.size},已管理 $managedCount)") }, singleLine = true, |
| 790 | 791 | modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp), |
| 791 | 792 | ) |
| 792 | - // 两源导入:微信(Mac 推过来的 enroll 数据,导入进池子后下面勾选)+ 手机系统通讯录(进多选页勾选导入)。 | |
| 793 | + // 两源导入:微信(端上解密 EnMicroMsg 读联系人+头像,不经 Mac)+ 手机系统通讯录(进多选页勾选导入)。 | |
| 793 | 794 | Row( |
| 794 | 795 | modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp).padding(bottom = 4.dp), |
| 795 | 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 | 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 | 825 | HorizontalDivider() |
| 806 | 826 | LazyColumn(modifier = Modifier.fillMaxSize()) { |
| 807 | 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 | 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 | 169 | /** 提及反查:全量重扫所有转写,按当前 managed 人物词典重建 mentions(档案「被提到」tab 用)。 */ |
| 162 | 170 | private val _mentionScanning = MutableStateFlow(false) |
| 163 | 171 | val mentionScanning: StateFlow<Boolean> = _mentionScanning |
| ... | ... | @@ -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 | 505 | * 一键下载该人物的微信内容(**纯本地,不经 Mac**): |
| 477 | 506 | * - 头像:`wx_enroll/avatars/<wxid>.jpg` → 导入成「照片」(归属脸挂到人物),出现在档案页「照片」tab。 |
| 478 | 507 | * - 朋友圈:端上 su 读微信明文 sns 缓存,**按条**存(图存 `moments/<wxid>/<ms>_<i>.jpg`,文字进 moments 表); | ... | ... |
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/vision/WeChatChatImporter.kt
| 1 | 1 | package com.lemon.lifelog.vision |
| 2 | 2 | |
| 3 | 3 | import android.content.Context |
| 4 | -import android.os.Process | |
| 5 | 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 | 9 | object WeChatChatImporter { |
| 22 | 10 | private const val TAG = "WeChatChat" |
| 23 | - // imei 候选(默认值优先;空串兜底)。 | |
| 24 | - private val IMEI_CANDIDATES = listOf("1234567890ABCDEF", "") | |
| 25 | 11 | |
| 26 | 12 | data class Msg(val content: String, val createTime: Long, val isSend: Boolean) |
| 27 | 13 | |
| ... | ... | @@ -31,40 +17,12 @@ object WeChatChatImporter { |
| 31 | 17 | /** 一趟 su + 一次解密:读 [wxids] 这些联系人的文本聊天。**必须 IO 线程调用**。 */ |
| 32 | 18 | fun importChats(ctx: Context, wxids: Set<String>): Result { |
| 33 | 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 | 22 | val byWxid = HashMap<String, MutableList<Msg>>() |
| 65 | 23 | try { |
| 66 | 24 | val ph = wxids.joinToString(",") { "?" } |
| 67 | - sdb.rawQuery( | |
| 25 | + opened.db.rawQuery( | |
| 68 | 26 | "SELECT talker, content, createTime, isSend FROM message WHERE type=1 AND talker IN ($ph) ORDER BY createTime ASC", |
| 69 | 27 | wxids.toTypedArray(), |
| 70 | 28 | ).use { c -> |
| ... | ... | @@ -76,44 +34,11 @@ object WeChatChatImporter { |
| 76 | 34 | } |
| 77 | 35 | } |
| 78 | 36 | } catch (t: Throwable) { |
| 79 | - sdb.close(); stage.deleteRecursively() | |
| 37 | + opened.close() | |
| 80 | 38 | return Result(emptyMap(), "读 message 表失败:${t.message}") |
| 81 | 39 | } |
| 82 | - sdb.close(); stage.deleteRecursively() | |
| 40 | + opened.close() | |
| 83 | 41 | Log.i(TAG, "聊天:${wxids.size} 人,共 ${byWxid.values.sumOf { it.size }} 条") |
| 84 | 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 | +} | ... | ... |