From 80c4997de11aa1c5e9b8bc3a9ea9eaeb81c683f5 Mon Sep 17 00:00:00 2001 From: yibo Date: Wed, 10 Jun 2026 12:04:30 +0800 Subject: [PATCH] feat(lifelog/photolog): 微信联系人+头像端上化(解密 rcontact/img_flag)+ 通讯录全选 --- lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologScreen.kt | 36 ++++++++++++++++++++++++++++-------- lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologViewModel.kt | 29 +++++++++++++++++++++++++++++ lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/vision/WeChatChatImporter.kt | 87 ++++++--------------------------------------------------------------------------------- lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/vision/WeChatContactsImporter.kt | 128 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/vision/WeChatDb.kt | 92 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 283 insertions(+), 89 deletions(-) create mode 100644 lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/vision/WeChatContactsImporter.kt create mode 100644 lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/vision/WeChatDb.kt diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologScreen.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologScreen.kt index 54a7edb..ef0678e 100644 --- a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologScreen.kt +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologScreen.kt @@ -759,7 +759,8 @@ private fun ContactRow(p: Person, onClick: () -> Unit) { @Composable private fun WeChatPickerScreen(vm: PhotologViewModel, onBack: () -> Unit, onOpenPhonePicker: () -> Unit) { val all by vm.repo.persons.observeAll().collectAsState(initial = emptyList()) - val enrollReady = remember { vm.wechatEnrollReady() } + val ctx = LocalContext.current + val enrolling by vm.enrolling.collectAsState() var enrollTapped by remember { mutableStateOf(false) } var query by remember { mutableStateOf("") } val managedCount = all.count { it.managed } @@ -789,19 +790,38 @@ private fun WeChatPickerScreen(vm: PhotologViewModel, onBack: () -> Unit, onOpen placeholder = { Text("搜索(共 ${all.size},已管理 $managedCount)") }, singleLine = true, modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp), ) - // 两源导入:微信(Mac 推过来的 enroll 数据,导入进池子后下面勾选)+ 手机系统通讯录(进多选页勾选导入)。 + // 两源导入:微信(端上解密 EnMicroMsg 读联系人+头像,不经 Mac)+ 手机系统通讯录(进多选页勾选导入)。 Row( modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp).padding(bottom = 4.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - if (enrollReady) { - Button(onClick = { vm.enrollWeChat(); enrollTapped = true }) { Text("💬 微信导入") } - } + Button( + enabled = !enrolling, + onClick = { + android.widget.Toast.makeText(ctx, "读微信联系人中…(首次下几千头像会慢,首次需授予 root)", android.widget.Toast.LENGTH_SHORT).show() + vm.importWeChatContactsLocal { msg -> android.widget.Toast.makeText(ctx, msg, android.widget.Toast.LENGTH_LONG).show() } + enrollTapped = true + }, + ) { Text(if (enrolling) "导入中…" else "💬 微信导入") } OutlinedButton(onClick = onOpenPhonePicker) { Text("📱 手机通讯录") } } - val hint = if (enrollTapped) "微信后台导入中,联系人会陆续出现…" - else "勾选要纳入「我的人物」的联系人" - Text(hint, style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(horizontal = 16.dp, vertical = 2.dp)) + // 全选/取消全选:作用于当前过滤后的列表(可先搜一批再全选)。 + val filtered = remember(sections) { sections.values.flatten() } + val allManaged = filtered.isNotEmpty() && filtered.all { it.managed } + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + if (enrollTapped) "微信导入中,联系人会陆续出现…" else "勾选要纳入「我的人物」的联系人", + style = MaterialTheme.typography.labelSmall, + ) + if (filtered.isNotEmpty()) { + TextButton(onClick = { vm.setManagedBulk(filtered.map { it.id }, !allManaged) }) { + Text(if (allManaged) "取消全选" else "全选(${filtered.size})", style = MaterialTheme.typography.labelMedium) + } + } + } HorizontalDivider() LazyColumn(modifier = Modifier.fillMaxSize()) { sections.forEach { (letter, group) -> 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 6218896..3c9669e 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 @@ -158,6 +158,14 @@ class PhotologViewModel(app: Application) : AndroidViewModel(app) { viewModelScope.launch { repo.persons.setManaged(personId, managed) } } + /** 批量勾选 / 取消(通讯录「全选/取消全选」用,作用于当前过滤列表)。 */ + fun setManagedBulk(ids: List, managed: Boolean) { + if (ids.isEmpty()) return + viewModelScope.launch { + withContext(Dispatchers.IO) { for (id in ids) repo.persons.setManaged(id, managed) } + } + } + /** 提及反查:全量重扫所有转写,按当前 managed 人物词典重建 mentions(档案「被提到」tab 用)。 */ private val _mentionScanning = MutableStateFlow(false) val mentionScanning: StateFlow = _mentionScanning @@ -473,6 +481,27 @@ class PhotologViewModel(app: Application) : AndroidViewModel(app) { } /** + * 端上一键导入微信联系人 + 头像(**纯本地不经 Mac**):解密读 rcontact/img_flag → 产出 wx_enroll/ + * → 触发 [WeChatEnrollWorker] 做 Person upsert + 端上人脸锚点。头像几千张首次会下一阵。 + */ + private val _enrolling = MutableStateFlow(false) + val enrolling: StateFlow = _enrolling + fun importWeChatContactsLocal(onDone: (String) -> Unit) { + if (_enrolling.value) return + _enrolling.value = true + val app = getApplication() + viewModelScope.launch { + val res = withContext(Dispatchers.IO) { com.lemon.lifelog.vision.WeChatContactsImporter.importContacts(app) } + if (res.contacts > 0) enrollWeChat() // 触发 upsert + 人脸锚点 + _enrolling.value = false + onDone( + if (res.contacts > 0) "已读取 ${res.contacts} 个联系人 + ${res.avatars} 头像,正在建档/认人…" + else (res.error ?: "没读到联系人"), + ) + } + } + + /** * 一键下载该人物的微信内容(**纯本地,不经 Mac**): * - 头像:`wx_enroll/avatars/.jpg` → 导入成「照片」(归属脸挂到人物),出现在档案页「照片」tab。 * - 朋友圈:端上 su 读微信明文 sns 缓存,**按条**存(图存 `moments//_.jpg`,文字进 moments 表); 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 index df6f204..087f477 100644 --- 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 @@ -1,27 +1,13 @@ 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`。 + * 端上解密微信 `EnMicroMsg.db` 读聊天。**纯本地、不经 Mac**(解密细节见 [WeChatDb])。 */ 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) @@ -31,40 +17,12 @@ object WeChatChatImporter { /** 一趟 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 (opened, err) = WeChatDb.openEnMicroMsg(ctx) + if (opened == null) return Result(emptyMap(), err) val byWxid = HashMap>() try { val ph = wxids.joinToString(",") { "?" } - sdb.rawQuery( + opened.db.rawQuery( "SELECT talker, content, createTime, isSend FROM message WHERE type=1 AND talker IN ($ph) ORDER BY createTime ASC", wxids.toTypedArray(), ).use { c -> @@ -76,44 +34,11 @@ object WeChatChatImporter { } } } catch (t: Throwable) { - sdb.close(); stage.deleteRecursively() + opened.close() return Result(emptyMap(), "读 message 表失败:${t.message}") } - sdb.close(); stage.deleteRecursively() + opened.close() 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 不可用") - } } diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/vision/WeChatContactsImporter.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/vision/WeChatContactsImporter.kt new file mode 100644 index 0000000..fe4b909 --- /dev/null +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/vision/WeChatContactsImporter.kt @@ -0,0 +1,128 @@ +package com.lemon.lifelog.vision + +import android.content.Context +import android.util.Log +import okhttp3.OkHttpClient +import okhttp3.Request +import org.json.JSONArray +import org.json.JSONObject +import java.io.File +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit + +/** + * 端上读微信联系人 + 头像 → 产出 `filesDir/wx_enroll/`(enroll.json + avatars/),**纯本地不经 Mac**。 + * 之后交给现有 [WeChatEnrollWorker] 做 Person upsert + 端上人脸锚点(复用其 Pass1/Pass2)。 + * + * - 联系人:解密 `EnMicroMsg.db`([WeChatDb])读 `rcontact ⨝ img_flag` 真人筛选(verifyFlag=0,排群/公众号/企业微信/系统号)。 + * - 头像:`img_flag.reserved1` = 高清头像公开 URL(`wx.qlogo.cn`,无需鉴权),OkHttp 并发下载到 `avatars/.jpg`。 + * + * SQL/筛选移植自 Mac 端 `server/services/wechat/extract-wechat-avatars.py`。 + */ +object WeChatContactsImporter { + private const val TAG = "WeChatContacts" + + // 真人联系人 + 高清头像 URL(移植自 Mac extract-wechat-avatars.py)。 + private const val REAL_PERSON_SQL = """ + SELECT r.username AS username, + COALESCE(NULLIF(r.conRemark,''), NULLIF(r.nickname,''), r.username) AS name, + r.nickname AS nickname, r.conRemark AS remark, r.alias AS alias, + r.pyInitial AS pyInitial, r.quanPin AS quanPin, + f.reserved1 AS avatar + FROM rcontact r JOIN img_flag f ON f.username = r.username + WHERE r.verifyFlag = 0 + AND r.username NOT LIKE '%@chatroom' + AND r.username NOT LIKE 'gh_%' + AND r.username NOT LIKE '%@openim' + AND r.username NOT IN ('weixin','filehelper','fmessage','medianote','floatbottle', + 'qmessage','qqmail','newsapp','officialaccounts','helper_entry','exmail_tool', + 'mphelper','brandsessionholder') + AND f.reserved1 LIKE 'http%' + """ + + data class Result(val contacts: Int, val avatars: Int, val error: String? = null) + + private data class Row(val username: String, val obj: JSONObject, val avatarUrl: String?) + + /** 解密读联系人 + 下头像,写 `wx_enroll/`。**必须 IO 线程调用**(含 su/解密/网络/文件 IO)。 */ + fun importContacts(ctx: Context): Result { + val (opened, err) = WeChatDb.openEnMicroMsg(ctx) + if (opened == null) return Result(0, 0, err) + val rows = ArrayList() + try { + opened.db.rawQuery(REAL_PERSON_SQL, null).use { c -> + val iU = c.getColumnIndexOrThrow("username") + val iName = c.getColumnIndexOrThrow("name") + val iNick = c.getColumnIndexOrThrow("nickname") + val iRemark = c.getColumnIndexOrThrow("remark") + val iAlias = c.getColumnIndexOrThrow("alias") + val iPyInit = c.getColumnIndexOrThrow("pyInitial") + val iQuanPin = c.getColumnIndexOrThrow("quanPin") + val iAvatar = c.getColumnIndexOrThrow("avatar") + while (c.moveToNext()) { + val username = c.getString(iU)?.takeIf { it.isNotBlank() } ?: continue + val o = JSONObject() + .put("username", username) + .put("name", c.getString(iName) ?: username) + .put("nickname", c.getString(iNick) ?: "") + .put("remark", c.getString(iRemark) ?: "") + .put("alias", c.getString(iAlias) ?: "") + .put("pyInitial", c.getString(iPyInit) ?: "") + .put("quanPin", c.getString(iQuanPin) ?: "") + .put("hasFace", false) + rows.add(Row(username, o, c.getString(iAvatar)?.takeIf { it.startsWith("http") })) + } + } + } catch (t: Throwable) { + opened.close() + return Result(0, 0, "读 rcontact/img_flag 失败:${t.message}") + } + opened.close() + if (rows.isEmpty()) return Result(0, 0, "没读到真人联系人") + + val enrollDir = File(ctx.filesDir, "wx_enroll").apply { mkdirs() } + val avatarDir = File(enrollDir, "avatars").apply { mkdirs() } + val downloaded = downloadAvatars(rows.mapNotNull { r -> r.avatarUrl?.let { r.username to it } }, avatarDir) + rows.forEach { if (it.username in downloaded) it.obj.put("hasFace", true) } + + val arr = JSONArray() + rows.forEach { arr.put(it.obj) } + File(enrollDir, "enroll.json").writeText(arr.toString()) + Log.i(TAG, "联系人 ${rows.size},头像下载 ${downloaded.size}") + return Result(rows.size, downloaded.size) + } + + /** 并发下载头像(跳过已存在的,方便增量/幂等)。返回成功下载/已存在的 username 集合。 */ + private fun downloadAvatars(items: List>, dir: File): Set { + if (items.isEmpty()) return emptySet() + val client = OkHttpClient.Builder() + .callTimeout(12, TimeUnit.SECONDS) + .connectTimeout(8, TimeUnit.SECONDS) + .build() + val ok = java.util.concurrent.ConcurrentHashMap.newKeySet() + val pool = Executors.newFixedThreadPool(12) + try { + val futures = items.map { (username, url) -> + pool.submit { + val dest = File(dir, "$username.jpg") + if (dest.exists() && dest.length() > 0) { ok.add(username); return@submit } + try { + val req = Request.Builder().url(url).header("User-Agent", "Mozilla/5.0").build() + client.newCall(req).execute().use { resp -> + val bytes = resp.body?.bytes() + if (resp.isSuccessful && bytes != null && bytes.size > 200) { + dest.writeBytes(bytes) + ok.add(username) + } + } + } catch (_: Throwable) { + } + } + } + futures.forEach { runCatching { it.get(30, TimeUnit.SECONDS) } } + } finally { + pool.shutdownNow() + } + return ok + } +} diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/vision/WeChatDb.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/vision/WeChatDb.kt new file mode 100644 index 0000000..d58a997 --- /dev/null +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/vision/WeChatDb.kt @@ -0,0 +1,92 @@ +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,明文化后供 chat / contacts 等复用)。**纯本地,不经 Mac/Frida**。 + * + * key 推导(真机验证):`MD5("1234567890ABCDEF" + uin)[:7]`(imei 用微信默认值;uin 从 shared_prefs 读)。 + * SQLCipher:`cipher_compatibility=1` + `page_size=1024`。两个 app-su 坑(--mount-master / 空 PATH)见 §七。 + * 详见 memory `wechat-android-decrypt`。 + */ +object WeChatDb { + private const val TAG = "WeChatDb" + private val IMEI_CANDIDATES = listOf("1234567890ABCDEF", "") + + /** 已打开的库 + 它的暂存目录;用完务必 [close](关库 + 删明文暂存)。 */ + class Opened(val db: SQLiteDatabase, private val stage: File) { + fun close() { + runCatching { db.close() } + stage.deleteRecursively() + } + } + + /** su 拷 EnMicroMsg.db + 读 uin → 算 key → SQLCipher 只读打开。失败返回 (null, 错误信息)。**IO 线程调**。 */ + fun openEnMicroMsg(ctx: Context): Pair { + try { SQLiteDatabase.loadLibs(ctx) } catch (t: Throwable) { return null to "SQLCipher 加载失败:${t.message}" } + val stage = File(ctx.filesDir, "wx_db_stage").apply { deleteRecursively(); mkdirs() } + val sp = stage.absolutePath + val uid = Process.myUid() + 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 null to 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 null to "没读到微信 uin" } + val keys = IMEI_CANDIDATES.map { md5Seven(it + uin) }.distinct() + val db = openWithKeys(File(stage, "en.db").absolutePath, keys) + ?: run { stage.deleteRecursively(); return null to "解密失败(key 不匹配 / 微信版本变)" } + return Opened(db, stage) to 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