From d79aff6cc25cad7a9d964ea3e93fddbe4e4e1c65 Mon Sep 17 00:00:00 2001 From: yibo Date: Wed, 10 Jun 2026 12:59:25 +0800 Subject: [PATCH] feat(lifelog/photolog): 微信好友 vs 群友区分 + 导入只导好友(默认) --- lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologScreen.kt | 44 +++++++++++++++++++++++++++++++++++--------- lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologViewModel.kt | 6 +++--- lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/AppDatabase.kt | 11 +++++++++-- lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Daos.kt | 4 ++++ lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Entities.kt | 2 ++ lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/vision/WeChatContactsImporter.kt | 39 +++++++++++++++++++++++++-------------- lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/vision/WeChatEnrollWorker.kt | 3 +++ 7 files changed, 81 insertions(+), 28 deletions(-) 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 ef0678e..b744793 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 @@ -763,14 +763,21 @@ private fun WeChatPickerScreen(vm: PhotologViewModel, onBack: () -> Unit, onOpen val enrolling by vm.enrolling.collectAsState() var enrollTapped by remember { mutableStateOf(false) } var query by remember { mutableStateOf("") } + var friendFilter by remember { mutableStateOf(0) } // 0=好友 1=群友 2=全部 val managedCount = all.count { it.managed } + val friendCount = all.count { it.wechatFriend == true } - val sections = remember(all, query) { + val sections = remember(all, query, friendFilter) { val q = query.trim() - val filtered = if (q.isBlank()) all else all.filter { p -> - p.name.contains(q, true) || p.nickname?.contains(q, true) == true || + val filtered = all.filter { p -> + (q.isBlank() || p.name.contains(q, true) || p.nickname?.contains(q, true) == true || p.remark?.contains(q, true) == true || p.alias?.contains(q, true) == true || - p.pinyin?.contains(q, true) == true + p.pinyin?.contains(q, true) == true) && + when (friendFilter) { + 0 -> p.wechatFriend == true + 1 -> p.wechatFriend == false || (p.contactRef?.startsWith("wechat:") == true && p.wechatFriend == null) + else -> true + } } filtered.sortedWith(compareBy({ sectionLetter(it) }, { it.pinyin ?: it.name })) .groupBy { sectionLetter(it) } @@ -798,12 +805,29 @@ private fun WeChatPickerScreen(vm: PhotologViewModel, onBack: () -> Unit, onOpen 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() } + android.widget.Toast.makeText(ctx, "读微信好友中…(首次需授予 root)", android.widget.Toast.LENGTH_SHORT).show() + vm.importWeChatContactsLocal(friendsOnly = true) { msg -> android.widget.Toast.makeText(ctx, msg, android.widget.Toast.LENGTH_LONG).show() } enrollTapped = true }, - ) { Text(if (enrolling) "导入中…" else "💬 微信导入") } - OutlinedButton(onClick = onOpenPhonePicker) { Text("📱 手机通讯录") } + ) { Text(if (enrolling) "导入中…" else "💬 微信好友") } + OutlinedButton( + enabled = !enrolling, + onClick = { + android.widget.Toast.makeText(ctx, "连群友一起读…(几千人+头像,会慢)", android.widget.Toast.LENGTH_SHORT).show() + vm.importWeChatContactsLocal(friendsOnly = false) { msg -> android.widget.Toast.makeText(ctx, msg, android.widget.Toast.LENGTH_LONG).show() } + enrollTapped = true + }, + ) { Text("+群友") } + OutlinedButton(onClick = onOpenPhonePicker) { Text("📱 手机") } + } + // 好友/群友过滤。 + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp).padding(bottom = 2.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically, + ) { + FilterChip(selected = friendFilter == 0, onClick = { friendFilter = 0 }, label = { Text("好友 $friendCount") }) + FilterChip(selected = friendFilter == 1, onClick = { friendFilter = 1 }, label = { Text("群友") }) + FilterChip(selected = friendFilter == 2, onClick = { friendFilter = 2 }, label = { Text("全部 ${all.size}") }) } // 全选/取消全选:作用于当前过滤后的列表(可先搜一批再全选)。 val filtered = remember(sections) { sections.values.flatten() } @@ -857,7 +881,9 @@ private fun PickerRow(p: Person, onToggle: () -> Unit) { Column(modifier = Modifier.weight(1f)) { Text(p.name, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.titleSmall) val src = when { - p.contactRef?.startsWith("wechat:") == true -> "微信" + p.contactRef?.startsWith("wechat:") == true -> when (p.wechatFriend) { + true -> "微信好友"; false -> "群友"; null -> "微信" + } p.contactRef?.startsWith("phone:") == true -> "手机" else -> null } 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 3c9669e..000fe85 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 @@ -486,16 +486,16 @@ class PhotologViewModel(app: Application) : AndroidViewModel(app) { */ private val _enrolling = MutableStateFlow(false) val enrolling: StateFlow = _enrolling - fun importWeChatContactsLocal(onDone: (String) -> Unit) { + fun importWeChatContactsLocal(friendsOnly: Boolean = true, 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) } + val res = withContext(Dispatchers.IO) { com.lemon.lifelog.vision.WeChatContactsImporter.importContacts(app, friendsOnly) } if (res.contacts > 0) enrollWeChat() // 触发 upsert + 人脸锚点 _enrolling.value = false onDone( - if (res.contacts > 0) "已读取 ${res.contacts} 个联系人 + ${res.avatars} 头像,正在建档/认人…" + if (res.contacts > 0) "已读取 ${res.contacts} 人(好友 ${res.friends})+ ${res.avatars} 头像,正在建档/认人…" else (res.error ?: "没读到联系人"), ) } diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/AppDatabase.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/AppDatabase.kt index 5f2c834..f28f54e 100644 --- a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/AppDatabase.kt +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/AppDatabase.kt @@ -28,7 +28,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase Topic::class, TopicMention::class, ], - version = 19, + version = 20, exportSchema = false, ) abstract class AppDatabase : RoomDatabase() { @@ -216,6 +216,13 @@ abstract class AppDatabase : RoomDatabase() { } } + /** v0.8 微信好友/群友区分:persons.wechatFriend(rcontact.type&1=1 且未删 = 好友)。 */ + private val MIGRATION_19_20 = object : Migration(19, 20) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE `persons` ADD COLUMN `wechatFriend` INTEGER") + } + } + fun get(ctx: Context): AppDatabase = instance ?: synchronized(this) { instance ?: Room.databaseBuilder( @@ -223,7 +230,7 @@ abstract class AppDatabase : RoomDatabase() { AppDatabase::class.java, "lifelog.db", ) - .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) + .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) // 不再 fallbackToDestructiveMigration:以后改 schema 必须写 Migration, // 否则启动直接抛 IllegalStateException(fail-loud),绝不静默清掉真实录音。 .build() diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Daos.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Daos.kt index ee4feb0..f8ce727 100644 --- a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Daos.kt +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Daos.kt @@ -591,6 +591,10 @@ interface PersonDao { @Query("UPDATE persons SET name = :name WHERE id = :id") suspend fun updateName(id: Long, name: String) + /** 微信好友/群友标记(enroll 时设:true=好友,false=群友)。 */ + @Query("UPDATE persons SET wechatFriend = :friend WHERE id = :id") + suspend fun setWechatFriend(id: Long, friend: Boolean?) + /** 合并人物:写回合并后的整条记录(按主键更新)。 */ @Update suspend fun update(p: Person) diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Entities.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Entities.kt index 1602ec4..944939d 100644 --- a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Entities.kt +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Entities.kt @@ -108,6 +108,8 @@ data class Person( val pinyin: String? = null, // 全拼(排序) val pyInitial: String? = null, // 首字母(A–Z 索引) val phone: String? = null, // 手机号(短信关联 / 手机通讯录去重键,存纯数字) + /** 微信关系:true=真好友(rcontact.type&1=1 且未删),false=群友/陌生人,null=非微信/未知。 */ + val wechatFriend: Boolean? = null, // ----- 手填资料 ----- val realName: String? = null, // 真名(微信昵称往往是网名,真名手填) val resume: String? = null, // 简历 / 备注(职业、公司、关系等手填长文) 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 index fe4b909..bac7d7c 100644 --- 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 @@ -22,35 +22,42 @@ import java.util.concurrent.TimeUnit object WeChatContactsImporter { private const val TAG = "WeChatContacts" - // 真人联系人 + 高清头像 URL(移植自 Mac extract-wechat-avatars.py)。 - private const val REAL_PERSON_SQL = """ + // 真人联系人 + 头像 URL + 好友判定(rcontact.type&1=1 且未删 = 好友,否则群友/陌生人)。 + // LEFT JOIN img_flag:好友头像很多不在 img_flag(那是群里看过被缓存的),所以头像可空、人照常导。 + private fun realPersonSql(friendsOnly: Boolean): String = """ 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 + f.reserved1 AS avatar, + CASE WHEN (r.type & 1) = 1 AND r.deleteFlag = 0 THEN 1 ELSE 0 END AS isFriend + FROM rcontact r LEFT 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 LIKE '%@stranger' 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%' + ${if (friendsOnly) "AND (r.type & 1) = 1 AND r.deleteFlag = 0" else ""} """ - data class Result(val contacts: Int, val avatars: Int, val error: String? = null) + data class Result(val contacts: Int, val friends: 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 { + /** + * 解密读联系人 + 下头像,写 `wx_enroll/`。[friendsOnly]=true 只导真好友(~几百),false 连群友(几千)。 + * **必须 IO 线程调用**(含 su/解密/网络/文件 IO)。 + */ + fun importContacts(ctx: Context, friendsOnly: Boolean = true): Result { val (opened, err) = WeChatDb.openEnMicroMsg(ctx) - if (opened == null) return Result(0, 0, err) + if (opened == null) return Result(0, 0, 0, err) val rows = ArrayList() + var friends = 0 try { - opened.db.rawQuery(REAL_PERSON_SQL, null).use { c -> + opened.db.rawQuery(realPersonSql(friendsOnly), null).use { c -> val iU = c.getColumnIndexOrThrow("username") val iName = c.getColumnIndexOrThrow("name") val iNick = c.getColumnIndexOrThrow("nickname") @@ -59,8 +66,11 @@ object WeChatContactsImporter { val iPyInit = c.getColumnIndexOrThrow("pyInitial") val iQuanPin = c.getColumnIndexOrThrow("quanPin") val iAvatar = c.getColumnIndexOrThrow("avatar") + val iFriend = c.getColumnIndexOrThrow("isFriend") while (c.moveToNext()) { val username = c.getString(iU)?.takeIf { it.isNotBlank() } ?: continue + val isFriend = c.getInt(iFriend) == 1 + if (isFriend) friends++ val o = JSONObject() .put("username", username) .put("name", c.getString(iName) ?: username) @@ -69,16 +79,17 @@ object WeChatContactsImporter { .put("alias", c.getString(iAlias) ?: "") .put("pyInitial", c.getString(iPyInit) ?: "") .put("quanPin", c.getString(iQuanPin) ?: "") + .put("isFriend", isFriend) .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}") + return Result(0, 0, 0, "读 rcontact/img_flag 失败:${t.message}") } opened.close() - if (rows.isEmpty()) return Result(0, 0, "没读到真人联系人") + if (rows.isEmpty()) return Result(0, 0, 0, "没读到联系人") val enrollDir = File(ctx.filesDir, "wx_enroll").apply { mkdirs() } val avatarDir = File(enrollDir, "avatars").apply { mkdirs() } @@ -88,8 +99,8 @@ object WeChatContactsImporter { 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) + Log.i(TAG, "联系人 ${rows.size}(好友 $friends),头像下载 ${downloaded.size}") + return Result(rows.size, friends, downloaded.size) } /** 并发下载头像(跳过已存在的,方便增量/幂等)。返回成功下载/已存在的 username 集合。 */ diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/vision/WeChatEnrollWorker.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/vision/WeChatEnrollWorker.kt index 1b13f42..4787094 100644 --- a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/vision/WeChatEnrollWorker.kt +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/vision/WeChatEnrollWorker.kt @@ -49,17 +49,20 @@ class WeChatEnrollWorker(ctx: Context, params: WorkerParameters) : CoroutineWork val alias = o.optString("alias").ifBlank { null } val py = o.optString("quanPin").ifBlank { null } val pyInit = o.optString("pyInitial").ifBlank { null } + val isFriend = if (o.has("isFriend")) o.optBoolean("isFriend") else null val ex = repo.persons.getByContactRef(ref) if (ex == null) { repo.persons.insert( Person( name = name, contactRef = ref, avatarPath = avatarPath, nickname = nick, remark = remark, alias = alias, pinyin = py, pyInitial = pyInit, + wechatFriend = isFriend, ), ) created++ } else { repo.persons.updateContactInfo(ex.id, name, nick, remark, alias, py, pyInit, avatarPath) + if (isFriend != null) repo.persons.setWechatFriend(ex.id, isFriend) } } Log.i(TAG, "资料 upsert:新建 $created(共 ${arr.length()})") -- libgit2 0.21.4