Commit 1b670b5422a1fba0c81a953c21fa1e3e2456dcd2

Authored by yibo
1 parent e3109a74

feat(lifelog/photolog): 微信好友 vs 群友区分 + 导入只导好友(默认)

4922 联系人里绝大多数是群友(共群非好友),真好友只 ~214。区分依据(真机验证):
- 好友 = rcontact.type & 1 = 1 且 deleteFlag = 0(有备注的人无一 type=4,佐证 type=4=群友)
- persons.wechatFriend 列(迁移 19→20,true=好友/false=群友/null=未知)
- WeChatContactsImporter 加 isFriend 计算 + friendsOnly 参数;img_flag 改 LEFT JOIN
  (好友头像多不在 img_flag,INNER JOIN 会漏,改 LEFT 后头像可空、人照导)
- WeChatEnrollWorker 落 wechatFriend
- 通讯录:导入分「💬微信好友(只好友)」/「+群友(全部)」;过滤 chip 好友/群友/全部(默认好友);
  每行标签 微信好友/群友/微信

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologScreen.kt
... ... @@ -763,14 +763,21 @@ private fun WeChatPickerScreen(vm: PhotologViewModel, onBack: () -&gt; Unit, onOpen
763 763 val enrolling by vm.enrolling.collectAsState()
764 764 var enrollTapped by remember { mutableStateOf(false) }
765 765 var query by remember { mutableStateOf("") }
  766 + var friendFilter by remember { mutableStateOf(0) } // 0=好友 1=群友 2=全部
766 767 val managedCount = all.count { it.managed }
  768 + val friendCount = all.count { it.wechatFriend == true }
767 769  
768   - val sections = remember(all, query) {
  770 + val sections = remember(all, query, friendFilter) {
769 771 val q = query.trim()
770   - val filtered = if (q.isBlank()) all else all.filter { p ->
771   - p.name.contains(q, true) || p.nickname?.contains(q, true) == true ||
  772 + val filtered = all.filter { p ->
  773 + (q.isBlank() || p.name.contains(q, true) || p.nickname?.contains(q, true) == true ||
772 774 p.remark?.contains(q, true) == true || p.alias?.contains(q, true) == true ||
773   - p.pinyin?.contains(q, true) == true
  775 + p.pinyin?.contains(q, true) == true) &&
  776 + when (friendFilter) {
  777 + 0 -> p.wechatFriend == true
  778 + 1 -> p.wechatFriend == false || (p.contactRef?.startsWith("wechat:") == true && p.wechatFriend == null)
  779 + else -> true
  780 + }
774 781 }
775 782 filtered.sortedWith(compareBy({ sectionLetter(it) }, { it.pinyin ?: it.name }))
776 783 .groupBy { sectionLetter(it) }
... ... @@ -798,12 +805,29 @@ private fun WeChatPickerScreen(vm: PhotologViewModel, onBack: () -&gt; Unit, onOpen
798 805 Button(
799 806 enabled = !enrolling,
800 807 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() }
  808 + android.widget.Toast.makeText(ctx, "读微信好友中…(首次需授予 root)", android.widget.Toast.LENGTH_SHORT).show()
  809 + vm.importWeChatContactsLocal(friendsOnly = true) { msg -> android.widget.Toast.makeText(ctx, msg, android.widget.Toast.LENGTH_LONG).show() }
803 810 enrollTapped = true
804 811 },
805   - ) { Text(if (enrolling) "导入中…" else "💬 微信导入") }
806   - OutlinedButton(onClick = onOpenPhonePicker) { Text("📱 手机通讯录") }
  812 + ) { Text(if (enrolling) "导入中…" else "💬 微信好友") }
  813 + OutlinedButton(
  814 + enabled = !enrolling,
  815 + onClick = {
  816 + android.widget.Toast.makeText(ctx, "连群友一起读…(几千人+头像,会慢)", android.widget.Toast.LENGTH_SHORT).show()
  817 + vm.importWeChatContactsLocal(friendsOnly = false) { msg -> android.widget.Toast.makeText(ctx, msg, android.widget.Toast.LENGTH_LONG).show() }
  818 + enrollTapped = true
  819 + },
  820 + ) { Text("+群友") }
  821 + OutlinedButton(onClick = onOpenPhonePicker) { Text("📱 手机") }
  822 + }
  823 + // 好友/群友过滤。
  824 + Row(
  825 + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp).padding(bottom = 2.dp),
  826 + horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically,
  827 + ) {
  828 + FilterChip(selected = friendFilter == 0, onClick = { friendFilter = 0 }, label = { Text("好友 $friendCount") })
  829 + FilterChip(selected = friendFilter == 1, onClick = { friendFilter = 1 }, label = { Text("群友") })
  830 + FilterChip(selected = friendFilter == 2, onClick = { friendFilter = 2 }, label = { Text("全部 ${all.size}") })
807 831 }
808 832 // 全选/取消全选:作用于当前过滤后的列表(可先搜一批再全选)。
809 833 val filtered = remember(sections) { sections.values.flatten() }
... ... @@ -857,7 +881,9 @@ private fun PickerRow(p: Person, onToggle: () -&gt; Unit) {
857 881 Column(modifier = Modifier.weight(1f)) {
858 882 Text(p.name, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.titleSmall)
859 883 val src = when {
860   - p.contactRef?.startsWith("wechat:") == true -> "微信"
  884 + p.contactRef?.startsWith("wechat:") == true -> when (p.wechatFriend) {
  885 + true -> "微信好友"; false -> "群友"; null -> "微信"
  886 + }
861 887 p.contactRef?.startsWith("phone:") == true -> "手机"
862 888 else -> null
863 889 }
... ...
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologViewModel.kt
... ... @@ -486,16 +486,16 @@ class PhotologViewModel(app: Application) : AndroidViewModel(app) {
486 486 */
487 487 private val _enrolling = MutableStateFlow(false)
488 488 val enrolling: StateFlow<Boolean> = _enrolling
489   - fun importWeChatContactsLocal(onDone: (String) -> Unit) {
  489 + fun importWeChatContactsLocal(friendsOnly: Boolean = true, onDone: (String) -> Unit) {
490 490 if (_enrolling.value) return
491 491 _enrolling.value = true
492 492 val app = getApplication<Application>()
493 493 viewModelScope.launch {
494   - val res = withContext(Dispatchers.IO) { com.lemon.lifelog.vision.WeChatContactsImporter.importContacts(app) }
  494 + val res = withContext(Dispatchers.IO) { com.lemon.lifelog.vision.WeChatContactsImporter.importContacts(app, friendsOnly) }
495 495 if (res.contacts > 0) enrollWeChat() // 触发 upsert + 人脸锚点
496 496 _enrolling.value = false
497 497 onDone(
498   - if (res.contacts > 0) "已读取 ${res.contacts} 个联系人 + ${res.avatars} 头像,正在建档/认人…"
  498 + if (res.contacts > 0) "已读取 ${res.contacts} 人(好友 ${res.friends})+ ${res.avatars} 头像,正在建档/认人…"
499 499 else (res.error ?: "没读到联系人"),
500 500 )
501 501 }
... ...
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/AppDatabase.kt
... ... @@ -28,7 +28,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase
28 28 Topic::class,
29 29 TopicMention::class,
30 30 ],
31   - version = 19,
  31 + version = 20,
32 32 exportSchema = false,
33 33 )
34 34 abstract class AppDatabase : RoomDatabase() {
... ... @@ -216,6 +216,13 @@ abstract class AppDatabase : RoomDatabase() {
216 216 }
217 217 }
218 218  
  219 + /** v0.8 微信好友/群友区分:persons.wechatFriend(rcontact.type&1=1 且未删 = 好友)。 */
  220 + private val MIGRATION_19_20 = object : Migration(19, 20) {
  221 + override fun migrate(db: SupportSQLiteDatabase) {
  222 + db.execSQL("ALTER TABLE `persons` ADD COLUMN `wechatFriend` INTEGER")
  223 + }
  224 + }
  225 +
219 226 fun get(ctx: Context): AppDatabase =
220 227 instance ?: synchronized(this) {
221 228 instance ?: Room.databaseBuilder(
... ... @@ -223,7 +230,7 @@ abstract class AppDatabase : RoomDatabase() {
223 230 AppDatabase::class.java,
224 231 "lifelog.db",
225 232 )
226   - .addMigrations(MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_9, MIGRATION_9_10, MIGRATION_10_11, MIGRATION_11_12, MIGRATION_12_13, MIGRATION_13_14, MIGRATION_14_15, MIGRATION_15_16, MIGRATION_16_17, MIGRATION_17_18, MIGRATION_18_19)
  233 + .addMigrations(MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_9, MIGRATION_9_10, MIGRATION_10_11, MIGRATION_11_12, MIGRATION_12_13, MIGRATION_13_14, MIGRATION_14_15, MIGRATION_15_16, MIGRATION_16_17, MIGRATION_17_18, MIGRATION_18_19, MIGRATION_19_20)
227 234 // 不再 fallbackToDestructiveMigration:以后改 schema 必须写 Migration,
228 235 // 否则启动直接抛 IllegalStateException(fail-loud),绝不静默清掉真实录音。
229 236 .build()
... ...
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Daos.kt
... ... @@ -591,6 +591,10 @@ interface PersonDao {
591 591 @Query("UPDATE persons SET name = :name WHERE id = :id")
592 592 suspend fun updateName(id: Long, name: String)
593 593  
  594 + /** 微信好友/群友标记(enroll 时设:true=好友,false=群友)。 */
  595 + @Query("UPDATE persons SET wechatFriend = :friend WHERE id = :id")
  596 + suspend fun setWechatFriend(id: Long, friend: Boolean?)
  597 +
594 598 /** 合并人物:写回合并后的整条记录(按主键更新)。 */
595 599 @Update
596 600 suspend fun update(p: Person)
... ...
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Entities.kt
... ... @@ -108,6 +108,8 @@ data class Person(
108 108 val pinyin: String? = null, // 全拼(排序)
109 109 val pyInitial: String? = null, // 首字母(A–Z 索引)
110 110 val phone: String? = null, // 手机号(短信关联 / 手机通讯录去重键,存纯数字)
  111 + /** 微信关系:true=真好友(rcontact.type&1=1 且未删),false=群友/陌生人,null=非微信/未知。 */
  112 + val wechatFriend: Boolean? = null,
111 113 // ----- 手填资料 -----
112 114 val realName: String? = null, // 真名(微信昵称往往是网名,真名手填)
113 115 val resume: String? = null, // 简历 / 备注(职业、公司、关系等手填长文)
... ...
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/vision/WeChatContactsImporter.kt
... ... @@ -22,35 +22,42 @@ import java.util.concurrent.TimeUnit
22 22 object WeChatContactsImporter {
23 23 private const val TAG = "WeChatContacts"
24 24  
25   - // 真人联系人 + 高清头像 URL(移植自 Mac extract-wechat-avatars.py)。
26   - private const val REAL_PERSON_SQL = """
  25 + // 真人联系人 + 头像 URL + 好友判定(rcontact.type&1=1 且未删 = 好友,否则群友/陌生人)。
  26 + // LEFT JOIN img_flag:好友头像很多不在 img_flag(那是群里看过被缓存的),所以头像可空、人照常导。
  27 + private fun realPersonSql(friendsOnly: Boolean): String = """
27 28 SELECT r.username AS username,
28 29 COALESCE(NULLIF(r.conRemark,''), NULLIF(r.nickname,''), r.username) AS name,
29 30 r.nickname AS nickname, r.conRemark AS remark, r.alias AS alias,
30 31 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
  32 + f.reserved1 AS avatar,
  33 + CASE WHEN (r.type & 1) = 1 AND r.deleteFlag = 0 THEN 1 ELSE 0 END AS isFriend
  34 + FROM rcontact r LEFT JOIN img_flag f ON f.username = r.username
33 35 WHERE r.verifyFlag = 0
34 36 AND r.username NOT LIKE '%@chatroom'
35 37 AND r.username NOT LIKE 'gh_%'
36 38 AND r.username NOT LIKE '%@openim'
  39 + AND r.username NOT LIKE '%@stranger'
37 40 AND r.username NOT IN ('weixin','filehelper','fmessage','medianote','floatbottle',
38 41 'qmessage','qqmail','newsapp','officialaccounts','helper_entry','exmail_tool',
39 42 'mphelper','brandsessionholder')
40   - AND f.reserved1 LIKE 'http%'
  43 + ${if (friendsOnly) "AND (r.type & 1) = 1 AND r.deleteFlag = 0" else ""}
41 44 """
42 45  
43   - data class Result(val contacts: Int, val avatars: Int, val error: String? = null)
  46 + data class Result(val contacts: Int, val friends: Int, val avatars: Int, val error: String? = null)
44 47  
45 48 private data class Row(val username: String, val obj: JSONObject, val avatarUrl: String?)
46 49  
47   - /** 解密读联系人 + 下头像,写 `wx_enroll/`。**必须 IO 线程调用**(含 su/解密/网络/文件 IO)。 */
48   - fun importContacts(ctx: Context): Result {
  50 + /**
  51 + * 解密读联系人 + 下头像,写 `wx_enroll/`。[friendsOnly]=true 只导真好友(~几百),false 连群友(几千)。
  52 + * **必须 IO 线程调用**(含 su/解密/网络/文件 IO)。
  53 + */
  54 + fun importContacts(ctx: Context, friendsOnly: Boolean = true): Result {
49 55 val (opened, err) = WeChatDb.openEnMicroMsg(ctx)
50   - if (opened == null) return Result(0, 0, err)
  56 + if (opened == null) return Result(0, 0, 0, err)
51 57 val rows = ArrayList<Row>()
  58 + var friends = 0
52 59 try {
53   - opened.db.rawQuery(REAL_PERSON_SQL, null).use { c ->
  60 + opened.db.rawQuery(realPersonSql(friendsOnly), null).use { c ->
54 61 val iU = c.getColumnIndexOrThrow("username")
55 62 val iName = c.getColumnIndexOrThrow("name")
56 63 val iNick = c.getColumnIndexOrThrow("nickname")
... ... @@ -59,8 +66,11 @@ object WeChatContactsImporter {
59 66 val iPyInit = c.getColumnIndexOrThrow("pyInitial")
60 67 val iQuanPin = c.getColumnIndexOrThrow("quanPin")
61 68 val iAvatar = c.getColumnIndexOrThrow("avatar")
  69 + val iFriend = c.getColumnIndexOrThrow("isFriend")
62 70 while (c.moveToNext()) {
63 71 val username = c.getString(iU)?.takeIf { it.isNotBlank() } ?: continue
  72 + val isFriend = c.getInt(iFriend) == 1
  73 + if (isFriend) friends++
64 74 val o = JSONObject()
65 75 .put("username", username)
66 76 .put("name", c.getString(iName) ?: username)
... ... @@ -69,16 +79,17 @@ object WeChatContactsImporter {
69 79 .put("alias", c.getString(iAlias) ?: "")
70 80 .put("pyInitial", c.getString(iPyInit) ?: "")
71 81 .put("quanPin", c.getString(iQuanPin) ?: "")
  82 + .put("isFriend", isFriend)
72 83 .put("hasFace", false)
73 84 rows.add(Row(username, o, c.getString(iAvatar)?.takeIf { it.startsWith("http") }))
74 85 }
75 86 }
76 87 } catch (t: Throwable) {
77 88 opened.close()
78   - return Result(0, 0, "读 rcontact/img_flag 失败:${t.message}")
  89 + return Result(0, 0, 0, "读 rcontact/img_flag 失败:${t.message}")
79 90 }
80 91 opened.close()
81   - if (rows.isEmpty()) return Result(0, 0, "没读到真人联系人")
  92 + if (rows.isEmpty()) return Result(0, 0, 0, "没读到联系人")
82 93  
83 94 val enrollDir = File(ctx.filesDir, "wx_enroll").apply { mkdirs() }
84 95 val avatarDir = File(enrollDir, "avatars").apply { mkdirs() }
... ... @@ -88,8 +99,8 @@ object WeChatContactsImporter {
88 99 val arr = JSONArray()
89 100 rows.forEach { arr.put(it.obj) }
90 101 File(enrollDir, "enroll.json").writeText(arr.toString())
91   - Log.i(TAG, "联系人 ${rows.size},头像下载 ${downloaded.size}")
92   - return Result(rows.size, downloaded.size)
  102 + Log.i(TAG, "联系人 ${rows.size}(好友 $friends),头像下载 ${downloaded.size}")
  103 + return Result(rows.size, friends, downloaded.size)
93 104 }
94 105  
95 106 /** 并发下载头像(跳过已存在的,方便增量/幂等)。返回成功下载/已存在的 username 集合。 */
... ...
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/vision/WeChatEnrollWorker.kt
... ... @@ -49,17 +49,20 @@ class WeChatEnrollWorker(ctx: Context, params: WorkerParameters) : CoroutineWork
49 49 val alias = o.optString("alias").ifBlank { null }
50 50 val py = o.optString("quanPin").ifBlank { null }
51 51 val pyInit = o.optString("pyInitial").ifBlank { null }
  52 + val isFriend = if (o.has("isFriend")) o.optBoolean("isFriend") else null
52 53 val ex = repo.persons.getByContactRef(ref)
53 54 if (ex == null) {
54 55 repo.persons.insert(
55 56 Person(
56 57 name = name, contactRef = ref, avatarPath = avatarPath,
57 58 nickname = nick, remark = remark, alias = alias, pinyin = py, pyInitial = pyInit,
  59 + wechatFriend = isFriend,
58 60 ),
59 61 )
60 62 created++
61 63 } else {
62 64 repo.persons.updateContactInfo(ex.id, name, nick, remark, alias, py, pyInit, avatarPath)
  65 + if (isFriend != null) repo.persons.setWechatFriend(ex.id, isFriend)
63 66 }
64 67 }
65 68 Log.i(TAG, "资料 upsert:新建 $created(共 ${arr.length()})")
... ...