Commit 1b670b5422a1fba0c81a953c21fa1e3e2456dcd2
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>
Showing
7 changed files
with
81 additions
and
28 deletions
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologScreen.kt
| ... | ... | @@ -763,14 +763,21 @@ private fun WeChatPickerScreen(vm: PhotologViewModel, onBack: () -> 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: () -> 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: () -> 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()})") | ... | ... |