Commit aed2cc41d1664756be4de93fdb14f72e4c41b8a3
1 parent
393dd254
feat(lifelog/photolog): 人物档案手填真名/简历 + 手机通讯录两源合并
- Person 加 realName/resume,档案页可编辑(PersonProfileScreen);DB 迁移 12→13 - 通讯录池新增手机系统通讯录来源(READ_CONTACTS / ContactsContract,contactRef phone:<id>), 按手机号/姓名跨源自动合并去重(PersonDao getByPhone/getByExactName/setPhone/updatePhoneInfo/updateProfile) - PhotologScreen/PhotologViewModel 支持两源池子 - AndroidManifest 加 READ_CONTACTS 权限;同步 CLAUDE.md Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Showing
8 changed files
with
232 additions
and
27 deletions
CLAUDE.md
| @@ -190,7 +190,7 @@ Foreground Service (foregroundServiceType=microphone, WAKE_LOCK partial) | @@ -190,7 +190,7 @@ Foreground Service (foregroundServiceType=microphone, WAKE_LOCK partial) | ||
| 190 | ├ SummaryWorker:按时间间隔切「会话」→ 本地 llama-swap(8080) Qwen3.5-2B 出纪要 | 190 | ├ SummaryWorker:按时间间隔切「会话」→ 本地 llama-swap(8080) Qwen3.5-2B 出纪要 |
| 191 | ├ ChatScreen:本地 2B/4B(llama-swap 8080) + Hermes(8642→Kimi) 多端点对话 | 191 | ├ ChatScreen:本地 2B/4B(llama-swap 8080) + Hermes(8642→Kimi) 多端点对话 |
| 192 | └ photolog/CRM 层 (PhotologScreen/PersonProfileScreen/PhotologViewModel + vision/) | 192 | └ photolog/CRM 层 (PhotologScreen/PersonProfileScreen/PhotologViewModel + vision/) |
| 193 | - 人物 tab = 「我管理的人物」;微信通讯录是池子(勾选纳入);相册人脸标注/绑声纹自动入库 | 193 | + 人物 tab = 「我管理的人物」;通讯录(微信+手机两源合并)是池子(勾选纳入);相册人脸标注/绑声纹自动入库 |
| 194 | ``` | 194 | ``` |
| 195 | 195 | ||
| 196 | **声纹纠偏 L1+L2**(`SpeakerClusterer`):L1 = reclusterAll 和 assign 都**冻结人工命名 anchor 不漂移**(止住「秦子琛吸宜博→质心漂→吞更多」雪球);L2 = 重命名/合并时 `rebuildAnchor` 用该人**全部段均值**重建质心(多 anchor,取代单段注册)。用户流程:听一段 → 命名/合并 → 「重新聚类」。阈值默认 0.45(设置页 0.35–0.65)。CAMPPlus 在短中文段上区分度有天花板,详见 memory `lifelog-block-diarization-pipeline.md`。 | 196 | **声纹纠偏 L1+L2**(`SpeakerClusterer`):L1 = reclusterAll 和 assign 都**冻结人工命名 anchor 不漂移**(止住「秦子琛吸宜博→质心漂→吞更多」雪球);L2 = 重命名/合并时 `rebuildAnchor` 用该人**全部段均值**重建质心(多 anchor,取代单段注册)。用户流程:听一段 → 命名/合并 → 「重新聚类」。阈值默认 0.45(设置页 0.35–0.65)。CAMPPlus 在短中文段上区分度有天花板,详见 memory `lifelog-block-diarization-pipeline.md`。 |
| @@ -199,7 +199,7 @@ Foreground Service (foregroundServiceType=microphone, WAKE_LOCK partial) | @@ -199,7 +199,7 @@ Foreground Service (foregroundServiceType=microphone, WAKE_LOCK partial) | ||
| 199 | 199 | ||
| 200 | **本地 LLM 栈(v0.6,零云端)**:Termux 自编 llama.cpp HEAD + llama-swap v223 监听 `127.0.0.1:8080`,按 model 名(`2b`/`4b`)热切换 Qwen3.5-{2B,4B}-Q4_K_M。纪要 worker + App 对话都打 8080;Magisk `service.d` 开机自启。完整部署/坑见 memory `pixel-local-llm-stack.md` + HANDOFF.md。 | 200 | **本地 LLM 栈(v0.6,零云端)**:Termux 自编 llama.cpp HEAD + llama-swap v223 监听 `127.0.0.1:8080`,按 model 名(`2b`/`4b`)热切换 Qwen3.5-{2B,4B}-Q4_K_M。纪要 worker + App 对话都打 8080;Magisk `service.d` 开机自启。完整部署/坑见 memory `pixel-local-llm-stack.md` + HANDOFF.md。 |
| 201 | 201 | ||
| 202 | -**photolog / 人脉 CRM 层(v0.7 新增)**:在录音之外把 lifelog 长成端上 mini-CRM。`PhotologScreen`(人物 tab + 相册 tab)、`PersonProfileScreen`(联系人档案:tab 式 + 记录搜索 + 微信聊天/朋友圈/AI 印象 + 「🎤 录一段绑定 TA」当场录声纹)、`PhotologViewModel` 统管。**两个人物池分离**:「我管理的人物」(CRM 关心的人,人物 tab 显示)vs「微信通讯录全部联系人」(池子,选人页里勾选纳入)。`vision/`(`FaceEngine`/`FaceMatcher`/`PhotoProcessor`/`PhotoScheduler`/`PhotoWorker`/`WeChatEnrollWorker`/`VisionClient`)做相册扫描 + 人脸聚类 + 微信头像锚点入库;微信头像配方与「锚点 embedding 必须端上算」见 memory `wechat-avatar-face-enrollment.md`。声纹绑人 ↔ 人脸标注 ↔ 微信联系人三者打通到同一 `Person`。 | 202 | +**photolog / 人脉 CRM 层(v0.7 新增)**:在录音之外把 lifelog 长成端上 mini-CRM。`PhotologScreen`(人物 tab + 相册 tab)、`PersonProfileScreen`(联系人档案:tab 式 + 记录搜索 + 微信聊天/朋友圈/AI 印象 + 「🎤 录一段绑定 TA」当场录声纹)、`PhotologViewModel` 统管。**两个人物池分离**:「我管理的人物」(CRM 关心的人,人物 tab 显示)vs「通讯录全部联系人」(池子,选人页里勾选纳入)。池子有**两个来源**:微信(Mac 推的 enroll 数据)+ 手机系统通讯录(`READ_CONTACTS` → `ContactsContract`,contactRef `phone:<id>`,按手机号/姓名跨源自动合并);`Person` 还存手填 `realName`(真名)/`resume`(简历),档案页可编辑。`vision/`(`FaceEngine`/`FaceMatcher`/`PhotoProcessor`/`PhotoScheduler`/`PhotoWorker`/`WeChatEnrollWorker`/`VisionClient`)做相册扫描 + 人脸聚类 + 微信头像锚点入库;微信头像配方与「锚点 embedding 必须端上算」见 memory `wechat-avatar-face-enrollment.md`。声纹绑人 ↔ 人脸标注 ↔ 微信联系人三者打通到同一 `Person`。 |
| 203 | 203 | ||
| 204 | 技术栈:Kotlin 2.1 + Compose BOM 2024.12.01 + Room 2.6.1 + WorkManager 2.10 + OkHttp 4.12,AGP 8.7.3,minSdk 30 / targetSdk 35(Android 14 强制 `foregroundServiceType` 起步),arm64-v8a 单 ABI。 | 204 | 技术栈:Kotlin 2.1 + Compose BOM 2024.12.01 + Room 2.6.1 + WorkManager 2.10 + OkHttp 4.12,AGP 8.7.3,minSdk 30 / targetSdk 35(Android 14 强制 `foregroundServiceType` 起步),arm64-v8a 单 ABI。 |
| 205 | 205 |
lifelog-recorder/app/src/main/AndroidManifest.xml
| @@ -11,6 +11,7 @@ | @@ -11,6 +11,7 @@ | ||
| 11 | <uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" /> | 11 | <uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" /> |
| 12 | <uses-permission android:name="android.permission.INTERNET" /> | 12 | <uses-permission android:name="android.permission.INTERNET" /> |
| 13 | <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> | 13 | <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> |
| 14 | + <uses-permission android:name="android.permission.READ_CONTACTS" /> | ||
| 14 | 15 | ||
| 15 | <application | 16 | <application |
| 16 | android:name=".LifelogApp" | 17 | android:name=".LifelogApp" |
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PersonProfileScreen.kt
| @@ -93,6 +93,7 @@ internal fun PersonProfileScreen( | @@ -93,6 +93,7 @@ internal fun PersonProfileScreen( | ||
| 93 | 93 | ||
| 94 | var bindVoice by remember { mutableStateOf(false) } | 94 | var bindVoice by remember { mutableStateOf(false) } |
| 95 | var recordVoice by remember { mutableStateOf(false) } | 95 | var recordVoice by remember { mutableStateOf(false) } |
| 96 | + var editProfile by remember { mutableStateOf(false) } | ||
| 96 | val player = remember { SegmentPlayer() } | 97 | val player = remember { SegmentPlayer() } |
| 97 | var playingId by remember { mutableStateOf<Long?>(null) } | 98 | var playingId by remember { mutableStateOf<Long?>(null) } |
| 98 | DisposableEffect(player) { onDispose { player.stop() } } | 99 | DisposableEffect(player) { onDispose { player.stop() } } |
| @@ -118,6 +119,7 @@ internal fun PersonProfileScreen( | @@ -118,6 +119,7 @@ internal fun PersonProfileScreen( | ||
| 118 | onBind = { bindVoice = true }, | 119 | onBind = { bindVoice = true }, |
| 119 | onRecordVoice = { vm.resetVoiceEnroll(); recordVoice = true }, | 120 | onRecordVoice = { vm.resetVoiceEnroll(); recordVoice = true }, |
| 120 | onEnrich = { vm.enrichImpression(personId) }, | 121 | onEnrich = { vm.enrichImpression(personId) }, |
| 122 | + onEditProfile = { editProfile = true }, | ||
| 121 | ) | 123 | ) |
| 122 | } | 124 | } |
| 123 | OutlinedTextField( | 125 | OutlinedTextField( |
| @@ -184,6 +186,16 @@ internal fun PersonProfileScreen( | @@ -184,6 +186,16 @@ internal fun PersonProfileScreen( | ||
| 184 | onDismiss = { recordVoice = false; vm.resetVoiceEnroll() }, | 186 | onDismiss = { recordVoice = false; vm.resetVoiceEnroll() }, |
| 185 | ) | 187 | ) |
| 186 | } | 188 | } |
| 189 | + | ||
| 190 | + if (editProfile) { | ||
| 191 | + person?.let { p -> | ||
| 192 | + EditProfileDialog( | ||
| 193 | + p = p, | ||
| 194 | + onDismiss = { editProfile = false }, | ||
| 195 | + onSave = { realName, resume -> vm.updateProfile(personId, realName, resume); editProfile = false }, | ||
| 196 | + ) | ||
| 197 | + } | ||
| 198 | + } | ||
| 187 | } | 199 | } |
| 188 | 200 | ||
| 189 | /** 当场录 6 秒 → 声纹 → 绑到 TA。录音期间 24/7 服务自动暂停让麦克风。 */ | 201 | /** 当场录 6 秒 → 声纹 → 绑到 TA。录音期间 24/7 服务自动暂停让麦克风。 */ |
| @@ -232,9 +244,16 @@ private fun EnrollVoiceDialog( | @@ -232,9 +244,16 @@ private fun EnrollVoiceDialog( | ||
| 232 | ) | 244 | ) |
| 233 | } | 245 | } |
| 234 | 246 | ||
| 235 | -/** 固定在 tab 上方的资料头:头像 + 名字 + 微信号/备注 + 声纹绑定 + AI 印象。 */ | 247 | +/** 固定在 tab 上方的资料头:头像 + 名字 + 微信名/真名/微信号/电话 + 简历 + 声纹绑定 + AI 印象。 */ |
| 236 | @Composable | 248 | @Composable |
| 237 | -private fun ProfileHeader(p: Person, speakers: List<SpeakerProfile>, onBind: () -> Unit, onRecordVoice: () -> Unit, onEnrich: () -> Unit) { | 249 | +private fun ProfileHeader( |
| 250 | + p: Person, | ||
| 251 | + speakers: List<SpeakerProfile>, | ||
| 252 | + onBind: () -> Unit, | ||
| 253 | + onRecordVoice: () -> Unit, | ||
| 254 | + onEnrich: () -> Unit, | ||
| 255 | + onEditProfile: () -> Unit, | ||
| 256 | +) { | ||
| 238 | Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp)) { | 257 | Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp)) { |
| 239 | Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { | 258 | Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { |
| 240 | if (p.avatarPath != null) { | 259 | if (p.avatarPath != null) { |
| @@ -247,8 +266,13 @@ private fun ProfileHeader(p: Person, speakers: List<SpeakerProfile>, onBind: () | @@ -247,8 +266,13 @@ private fun ProfileHeader(p: Person, speakers: List<SpeakerProfile>, onBind: () | ||
| 247 | } | 266 | } |
| 248 | Column(modifier = Modifier.weight(1f)) { | 267 | Column(modifier = Modifier.weight(1f)) { |
| 249 | Text(p.name, style = MaterialTheme.typography.titleMedium, maxLines = 1, overflow = TextOverflow.Ellipsis) | 268 | Text(p.name, style = MaterialTheme.typography.titleMedium, maxLines = 1, overflow = TextOverflow.Ellipsis) |
| 250 | - val sub = listOfNotNull(p.alias?.let { "微信号 $it" }, p.remark?.let { "备注 $it" }, p.phone?.let { "📱$it" }).joinToString(" ") | ||
| 251 | - if (sub.isNotBlank()) Text(sub, style = MaterialTheme.typography.labelSmall, maxLines = 1, overflow = TextOverflow.Ellipsis) | 269 | + val sub = listOfNotNull( |
| 270 | + p.nickname?.takeIf { it.isNotBlank() && it != p.name }?.let { "微信名 $it" }, | ||
| 271 | + p.realName?.takeIf { it.isNotBlank() }?.let { "真名 $it" }, | ||
| 272 | + p.alias?.takeIf { it.isNotBlank() }?.let { "微信号 $it" }, | ||
| 273 | + p.phone?.takeIf { it.isNotBlank() }?.let { "📱$it" }, | ||
| 274 | + ).joinToString(" ") | ||
| 275 | + if (sub.isNotBlank()) Text(sub, style = MaterialTheme.typography.labelSmall, maxLines = 2, overflow = TextOverflow.Ellipsis) | ||
| 252 | Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) { | 276 | Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) { |
| 253 | val sName = speakers.firstOrNull { it.id == p.voiceSpeakerId }?.name | 277 | val sName = speakers.firstOrNull { it.id == p.voiceSpeakerId }?.name |
| 254 | Text("声纹:${sName ?: "未绑定"}", style = MaterialTheme.typography.labelMedium) | 278 | Text("声纹:${sName ?: "未绑定"}", style = MaterialTheme.typography.labelMedium) |
| @@ -261,17 +285,53 @@ private fun ProfileHeader(p: Person, speakers: List<SpeakerProfile>, onBind: () | @@ -261,17 +285,53 @@ private fun ProfileHeader(p: Person, speakers: List<SpeakerProfile>, onBind: () | ||
| 261 | } | 285 | } |
| 262 | } | 286 | } |
| 263 | } | 287 | } |
| 288 | + p.resume?.takeIf { it.isNotBlank() }?.let { | ||
| 289 | + Surface(color = MaterialTheme.colorScheme.surfaceVariant, shape = RoundedCornerShape(10.dp), modifier = Modifier.fillMaxWidth()) { | ||
| 290 | + Text("📄 $it", style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(8.dp)) | ||
| 291 | + } | ||
| 292 | + } | ||
| 264 | p.impression?.let { | 293 | p.impression?.let { |
| 265 | Surface(color = MaterialTheme.colorScheme.secondaryContainer, shape = RoundedCornerShape(10.dp), modifier = Modifier.fillMaxWidth()) { | 294 | Surface(color = MaterialTheme.colorScheme.secondaryContainer, shape = RoundedCornerShape(10.dp), modifier = Modifier.fillMaxWidth()) { |
| 266 | Text("🧠 $it", style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(8.dp)) | 295 | Text("🧠 $it", style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(8.dp)) |
| 267 | } | 296 | } |
| 268 | } | 297 | } |
| 269 | - TextButton(onClick = onEnrich, contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 4.dp, vertical = 0.dp)) { | ||
| 270 | - Text(if (p.impression == null) "🧠 生成 AI 印象(本地 4B 看朋友圈)" else "重新生成印象", style = MaterialTheme.typography.labelSmall) | 298 | + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) { |
| 299 | + TextButton(onClick = onEditProfile, contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 4.dp, vertical = 0.dp)) { | ||
| 300 | + Text("✎ 编辑资料(真名/简历)", style = MaterialTheme.typography.labelSmall) | ||
| 301 | + } | ||
| 302 | + TextButton(onClick = onEnrich, contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 4.dp, vertical = 0.dp)) { | ||
| 303 | + Text(if (p.impression == null) "🧠 AI 印象" else "重新生成印象", style = MaterialTheme.typography.labelSmall) | ||
| 304 | + } | ||
| 271 | } | 305 | } |
| 272 | } | 306 | } |
| 273 | } | 307 | } |
| 274 | 308 | ||
| 309 | +/** 编辑真名 + 简历(手填,本地保存)。 */ | ||
| 310 | +@Composable | ||
| 311 | +private fun EditProfileDialog(p: Person, onDismiss: () -> Unit, onSave: (String, String) -> Unit) { | ||
| 312 | + var realName by remember { mutableStateOf(p.realName.orEmpty()) } | ||
| 313 | + var resume by remember { mutableStateOf(p.resume.orEmpty()) } | ||
| 314 | + AlertDialog( | ||
| 315 | + onDismissRequest = onDismiss, | ||
| 316 | + title = { Text("编辑资料") }, | ||
| 317 | + text = { | ||
| 318 | + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { | ||
| 319 | + OutlinedTextField( | ||
| 320 | + value = realName, onValueChange = { realName = it }, singleLine = true, | ||
| 321 | + label = { Text("真名") }, modifier = Modifier.fillMaxWidth(), | ||
| 322 | + ) | ||
| 323 | + OutlinedTextField( | ||
| 324 | + value = resume, onValueChange = { resume = it }, | ||
| 325 | + label = { Text("简历 / 备注(职业、公司、关系等)") }, | ||
| 326 | + minLines = 3, modifier = Modifier.fillMaxWidth(), | ||
| 327 | + ) | ||
| 328 | + } | ||
| 329 | + }, | ||
| 330 | + confirmButton = { TextButton(onClick = { onSave(realName, resume) }) { Text("保存") } }, | ||
| 331 | + dismissButton = { TextButton(onClick = onDismiss) { Text("取消") } }, | ||
| 332 | + ) | ||
| 333 | +} | ||
| 334 | + | ||
| 275 | @Composable | 335 | @Composable |
| 276 | private fun EmptyHint(text: String) { | 336 | private fun EmptyHint(text: String) { |
| 277 | Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) { | 337 | Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) { |
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologScreen.kt
| 1 | package com.lemon.lifelog | 1 | package com.lemon.lifelog |
| 2 | 2 | ||
| 3 | +import android.Manifest | ||
| 4 | +import android.content.pm.PackageManager | ||
| 3 | import androidx.activity.compose.rememberLauncherForActivityResult | 5 | import androidx.activity.compose.rememberLauncherForActivityResult |
| 4 | import androidx.activity.result.PickVisualMediaRequest | 6 | import androidx.activity.result.PickVisualMediaRequest |
| 5 | import androidx.activity.result.contract.ActivityResultContracts | 7 | import androidx.activity.result.contract.ActivityResultContracts |
| 8 | +import androidx.compose.ui.platform.LocalContext | ||
| 9 | +import androidx.core.content.ContextCompat | ||
| 6 | import androidx.compose.foundation.ExperimentalFoundationApi | 10 | import androidx.compose.foundation.ExperimentalFoundationApi |
| 7 | import androidx.compose.foundation.background | 11 | import androidx.compose.foundation.background |
| 8 | import androidx.compose.foundation.clickable | 12 | import androidx.compose.foundation.clickable |
| @@ -351,13 +355,13 @@ private fun ContactList(vm: PhotologViewModel, persons: List<Person>, onOpen: (L | @@ -351,13 +355,13 @@ private fun ContactList(vm: PhotologViewModel, persons: List<Person>, onOpen: (L | ||
| 351 | modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp).padding(bottom = 6.dp), | 355 | modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp).padding(bottom = 6.dp), |
| 352 | verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), | 356 | verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), |
| 353 | ) { | 357 | ) { |
| 354 | - Button(onClick = onOpenPicker) { Text("📇 微信通讯录") } | ||
| 355 | - Text("勾选你关心的人加入管理", style = MaterialTheme.typography.labelSmall) | 358 | + Button(onClick = onOpenPicker) { Text("📇 通讯录") } |
| 359 | + Text("从微信 / 手机通讯录勾选你关心的人加入管理", style = MaterialTheme.typography.labelSmall) | ||
| 356 | } | 360 | } |
| 357 | HorizontalDivider() | 361 | HorizontalDivider() |
| 358 | if (persons.isEmpty()) { | 362 | if (persons.isEmpty()) { |
| 359 | Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) { | 363 | Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) { |
| 360 | - Text("还没有管理的人物。\n点上方「微信通讯录」勾选你关心的人,或在照片里标注人脸、绑声纹会自动加入。", style = MaterialTheme.typography.bodyMedium) | 364 | + Text("还没有管理的人物。\n点上方「通讯录」从微信 / 手机通讯录勾选你关心的人,或在照片里标注人脸、绑声纹会自动加入。", style = MaterialTheme.typography.bodyMedium) |
| 361 | } | 365 | } |
| 362 | } else { | 366 | } else { |
| 363 | LazyColumn(modifier = Modifier.fillMaxSize()) { | 367 | LazyColumn(modifier = Modifier.fillMaxSize()) { |
| @@ -413,7 +417,7 @@ private fun ContactRow(p: Person, onClick: () -> Unit) { | @@ -413,7 +417,7 @@ private fun ContactRow(p: Person, onClick: () -> Unit) { | ||
| 413 | } | 417 | } |
| 414 | } | 418 | } |
| 415 | 419 | ||
| 416 | -/** 微信通讯录(全部联系人池):勾选 → 纳入「我管理的人物」。搜索 + 拼音 A–Z。 */ | 420 | +/** 通讯录(微信 + 手机两源合并的全部联系人池):勾选 → 纳入「我管理的人物」。搜索 + 拼音 A–Z。 */ |
| 417 | @OptIn(ExperimentalFoundationApi::class) | 421 | @OptIn(ExperimentalFoundationApi::class) |
| 418 | @Composable | 422 | @Composable |
| 419 | private fun WeChatPickerScreen(vm: PhotologViewModel, onBack: () -> Unit) { | 423 | private fun WeChatPickerScreen(vm: PhotologViewModel, onBack: () -> Unit) { |
| @@ -423,6 +427,23 @@ private fun WeChatPickerScreen(vm: PhotologViewModel, onBack: () -> Unit) { | @@ -423,6 +427,23 @@ private fun WeChatPickerScreen(vm: PhotologViewModel, onBack: () -> Unit) { | ||
| 423 | var query by remember { mutableStateOf("") } | 427 | var query by remember { mutableStateOf("") } |
| 424 | val managedCount = all.count { it.managed } | 428 | val managedCount = all.count { it.managed } |
| 425 | 429 | ||
| 430 | + val ctx = LocalContext.current | ||
| 431 | + var phoneStatus by remember { mutableStateOf<String?>(null) } | ||
| 432 | + val runPhoneImport = { | ||
| 433 | + phoneStatus = "导入中…" | ||
| 434 | + vm.importPhoneContacts { n -> phoneStatus = "已导入手机通讯录(新增 $n)" } | ||
| 435 | + } | ||
| 436 | + val contactsLauncher = rememberLauncherForActivityResult( | ||
| 437 | + ActivityResultContracts.RequestPermission(), | ||
| 438 | + ) { granted -> if (granted) runPhoneImport() else phoneStatus = "未授予通讯录权限" } | ||
| 439 | + val importPhone = { | ||
| 440 | + if (ContextCompat.checkSelfPermission(ctx, Manifest.permission.READ_CONTACTS) == PackageManager.PERMISSION_GRANTED) { | ||
| 441 | + runPhoneImport() | ||
| 442 | + } else { | ||
| 443 | + contactsLauncher.launch(Manifest.permission.READ_CONTACTS) | ||
| 444 | + } | ||
| 445 | + } | ||
| 446 | + | ||
| 426 | val sections = remember(all, query) { | 447 | val sections = remember(all, query) { |
| 427 | val q = query.trim() | 448 | val q = query.trim() |
| 428 | val filtered = if (q.isBlank()) all else all.filter { p -> | 449 | val filtered = if (q.isBlank()) all else all.filter { p -> |
| @@ -441,24 +462,27 @@ private fun WeChatPickerScreen(vm: PhotologViewModel, onBack: () -> Unit) { | @@ -441,24 +462,27 @@ private fun WeChatPickerScreen(vm: PhotologViewModel, onBack: () -> Unit) { | ||
| 441 | verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), | 462 | verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), |
| 442 | ) { | 463 | ) { |
| 443 | TextButton(onClick = onBack) { Text("← 返回") } | 464 | TextButton(onClick = onBack) { Text("← 返回") } |
| 444 | - Text("微信通讯录", style = MaterialTheme.typography.titleMedium) | 465 | + Text("通讯录", style = MaterialTheme.typography.titleMedium) |
| 445 | } | 466 | } |
| 446 | OutlinedTextField( | 467 | OutlinedTextField( |
| 447 | value = query, onValueChange = { query = it }, | 468 | value = query, onValueChange = { query = it }, |
| 448 | placeholder = { Text("搜索(共 ${all.size},已管理 $managedCount)") }, singleLine = true, | 469 | placeholder = { Text("搜索(共 ${all.size},已管理 $managedCount)") }, singleLine = true, |
| 449 | modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp), | 470 | modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp), |
| 450 | ) | 471 | ) |
| 451 | - if (enrollReady && all.isEmpty()) { | ||
| 452 | - Row( | ||
| 453 | - modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp).padding(bottom = 6.dp), | ||
| 454 | - verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), | ||
| 455 | - ) { | ||
| 456 | - Button(onClick = { vm.enrollWeChat(); enrollTapped = true }) { Text("从微信导入联系人") } | ||
| 457 | - Text(if (enrollTapped) "后台导入中,联系人会陆续出现…" else "导入全部真人联系人", style = MaterialTheme.typography.labelSmall) | 472 | + // 两源导入:微信(Mac 推过来的 enroll 数据)+ 手机系统通讯录。导入进池子,下面勾选。 |
| 473 | + Row( | ||
| 474 | + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp).padding(bottom = 4.dp), | ||
| 475 | + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), | ||
| 476 | + ) { | ||
| 477 | + if (enrollReady) { | ||
| 478 | + Button(onClick = { vm.enrollWeChat(); enrollTapped = true }) { Text("💬 微信导入") } | ||
| 458 | } | 479 | } |
| 459 | - } else { | ||
| 460 | - Text("勾选要纳入「我的人物」的联系人", style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(horizontal = 16.dp, vertical = 2.dp)) | 480 | + OutlinedButton(onClick = importPhone) { Text("📱 手机通讯录") } |
| 461 | } | 481 | } |
| 482 | + val hint = phoneStatus | ||
| 483 | + ?: if (enrollTapped) "微信后台导入中,联系人会陆续出现…" | ||
| 484 | + else "勾选要纳入「我的人物」的联系人" | ||
| 485 | + Text(hint, style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(horizontal = 16.dp, vertical = 2.dp)) | ||
| 462 | HorizontalDivider() | 486 | HorizontalDivider() |
| 463 | LazyColumn(modifier = Modifier.fillMaxSize()) { | 487 | LazyColumn(modifier = Modifier.fillMaxSize()) { |
| 464 | sections.forEach { (letter, group) -> | 488 | sections.forEach { (letter, group) -> |
| @@ -493,8 +517,14 @@ private fun PickerRow(p: Person, onToggle: () -> Unit) { | @@ -493,8 +517,14 @@ private fun PickerRow(p: Person, onToggle: () -> Unit) { | ||
| 493 | } | 517 | } |
| 494 | Column(modifier = Modifier.weight(1f)) { | 518 | Column(modifier = Modifier.weight(1f)) { |
| 495 | Text(p.name, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.titleSmall) | 519 | Text(p.name, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.titleSmall) |
| 496 | - val sub = p.remark?.takeIf { it.isNotBlank() && it != p.name } ?: p.nickname?.takeIf { it != p.name } | ||
| 497 | - if (sub != null) Text(sub, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.labelSmall) | 520 | + val src = when { |
| 521 | + p.contactRef?.startsWith("wechat:") == true -> "微信" | ||
| 522 | + p.contactRef?.startsWith("phone:") == true -> "手机" | ||
| 523 | + else -> null | ||
| 524 | + } | ||
| 525 | + val detail = p.remark?.takeIf { it.isNotBlank() && it != p.name } ?: p.nickname?.takeIf { it != p.name } | ||
| 526 | + val sub = listOfNotNull(src, detail).joinToString(" · ") | ||
| 527 | + if (sub.isNotBlank()) Text(sub, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.labelSmall) | ||
| 498 | } | 528 | } |
| 499 | } | 529 | } |
| 500 | } | 530 | } |
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologViewModel.kt
| @@ -97,6 +97,90 @@ class PhotologViewModel(app: Application) : AndroidViewModel(app) { | @@ -97,6 +97,90 @@ class PhotologViewModel(app: Application) : AndroidViewModel(app) { | ||
| 97 | viewModelScope.launch { repo.persons.updateName(personId, name.trim()) } | 97 | viewModelScope.launch { repo.persons.updateName(personId, name.trim()) } |
| 98 | } | 98 | } |
| 99 | 99 | ||
| 100 | + /** 档案手填资料:真名 + 简历(空串存 null)。 */ | ||
| 101 | + fun updateProfile(personId: Long, realName: String?, resume: String?) { | ||
| 102 | + viewModelScope.launch { | ||
| 103 | + repo.persons.updateProfile( | ||
| 104 | + personId, | ||
| 105 | + realName?.trim()?.ifBlank { null }, | ||
| 106 | + resume?.trim()?.ifBlank { null }, | ||
| 107 | + ) | ||
| 108 | + } | ||
| 109 | + } | ||
| 110 | + | ||
| 111 | + /** | ||
| 112 | + * 从手机系统通讯录导入联系人到「池子」(managed=false,跟微信一致,用户再勾选)。 | ||
| 113 | + * 跨源去重(用户选择「按手机号/姓名自动合并」): | ||
| 114 | + * 1) 已按 contactRef="phone:<id>" 建过 → 刷新资料; | ||
| 115 | + * 2) 否则手机号命中已有人物 → 只补手机号,不另建; | ||
| 116 | + * 3) 否则姓名精确命中 → 只补手机号,不另建; | ||
| 117 | + * 4) 都没有 → 新建。 | ||
| 118 | + * 调用方需先拿到 READ_CONTACTS。回调返回本轮新建数。 | ||
| 119 | + */ | ||
| 120 | + fun importPhoneContacts(onDone: (Int) -> Unit) { | ||
| 121 | + val app = getApplication<Application>() | ||
| 122 | + viewModelScope.launch { | ||
| 123 | + val created = withContext(Dispatchers.IO) { | ||
| 124 | + var count = 0 | ||
| 125 | + try { | ||
| 126 | + val proj = arrayOf( | ||
| 127 | + android.provider.ContactsContract.CommonDataKinds.Phone.CONTACT_ID, | ||
| 128 | + android.provider.ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME_PRIMARY, | ||
| 129 | + android.provider.ContactsContract.CommonDataKinds.Phone.NUMBER, | ||
| 130 | + android.provider.ContactsContract.CommonDataKinds.Phone.SORT_KEY_PRIMARY, | ||
| 131 | + ) | ||
| 132 | + val seen = HashSet<Long>() | ||
| 133 | + app.contentResolver.query( | ||
| 134 | + android.provider.ContactsContract.CommonDataKinds.Phone.CONTENT_URI, | ||
| 135 | + proj, null, null, null, | ||
| 136 | + )?.use { c -> | ||
| 137 | + val idIdx = c.getColumnIndex(android.provider.ContactsContract.CommonDataKinds.Phone.CONTACT_ID) | ||
| 138 | + val nameIdx = c.getColumnIndex(android.provider.ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME_PRIMARY) | ||
| 139 | + val numIdx = c.getColumnIndex(android.provider.ContactsContract.CommonDataKinds.Phone.NUMBER) | ||
| 140 | + val sortIdx = c.getColumnIndex(android.provider.ContactsContract.CommonDataKinds.Phone.SORT_KEY_PRIMARY) | ||
| 141 | + while (c.moveToNext()) { | ||
| 142 | + val cid = if (idIdx >= 0) c.getLong(idIdx) else continue | ||
| 143 | + if (!seen.add(cid)) continue // 一个联系人多号码,只取第一条 | ||
| 144 | + val name = (if (nameIdx >= 0) c.getString(nameIdx) else null)?.trim().orEmpty() | ||
| 145 | + if (name.isBlank()) continue | ||
| 146 | + val phone = (if (numIdx >= 0) c.getString(numIdx) else null) | ||
| 147 | + ?.filter { it.isDigit() }?.takeIf { it.isNotBlank() } | ||
| 148 | + val sortKey = if (sortIdx >= 0) c.getString(sortIdx) else null | ||
| 149 | + val pinyin = sortKey?.lowercase()?.filter { it.isLetter() }?.takeIf { it.isNotBlank() } | ||
| 150 | + val pyInit = ((sortKey?.firstOrNull() ?: name.firstOrNull())?.uppercaseChar()) | ||
| 151 | + ?.takeIf { it in 'A'..'Z' }?.toString() | ||
| 152 | + if (upsertPhoneContact(cid, name, phone, pinyin, pyInit)) count++ | ||
| 153 | + } | ||
| 154 | + } | ||
| 155 | + } catch (t: Throwable) { | ||
| 156 | + Log.w("PhotologVM", "phone import failed: ${t.message}") | ||
| 157 | + } | ||
| 158 | + count | ||
| 159 | + } | ||
| 160 | + onDone(created) | ||
| 161 | + } | ||
| 162 | + } | ||
| 163 | + | ||
| 164 | + /** 返回 true = 本轮新建了一行(用于计数)。 */ | ||
| 165 | + private suspend fun upsertPhoneContact( | ||
| 166 | + cid: Long, name: String, phone: String?, pinyin: String?, pyInit: String?, | ||
| 167 | + ): Boolean { | ||
| 168 | + val ref = "phone:$cid" | ||
| 169 | + repo.persons.getByContactRef(ref)?.let { | ||
| 170 | + repo.persons.updatePhoneInfo(it.id, name, phone, pinyin, pyInit) | ||
| 171 | + return false | ||
| 172 | + } | ||
| 173 | + val merge = (phone?.let { repo.persons.getByPhone(it) }) ?: repo.persons.getByExactName(name) | ||
| 174 | + if (merge != null) { | ||
| 175 | + if (merge.phone.isNullOrBlank() && phone != null) repo.persons.setPhone(merge.id, phone) | ||
| 176 | + return false | ||
| 177 | + } | ||
| 178 | + repo.persons.insert( | ||
| 179 | + Person(name = name, contactRef = ref, phone = phone, pinyin = pinyin, pyInitial = pyInit), | ||
| 180 | + ) | ||
| 181 | + return true | ||
| 182 | + } | ||
| 183 | + | ||
| 100 | /** 点开档案 / 互动时刷新最近访问 → 进通讯录置顶区。 */ | 184 | /** 点开档案 / 互动时刷新最近访问 → 进通讯录置顶区。 */ |
| 101 | fun touchPerson(personId: Long) { | 185 | fun touchPerson(personId: Long) { |
| 102 | viewModelScope.launch { repo.persons.touch(personId, System.currentTimeMillis()) } | 186 | viewModelScope.launch { repo.persons.touch(personId, System.currentTimeMillis()) } |
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/AppDatabase.kt
| @@ -22,7 +22,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase | @@ -22,7 +22,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase | ||
| 22 | WechatMessage::class, | 22 | WechatMessage::class, |
| 23 | Moment::class, | 23 | Moment::class, |
| 24 | ], | 24 | ], |
| 25 | - version = 12, | 25 | + version = 13, |
| 26 | exportSchema = false, | 26 | exportSchema = false, |
| 27 | ) | 27 | ) |
| 28 | abstract class AppDatabase : RoomDatabase() { | 28 | abstract class AppDatabase : RoomDatabase() { |
| @@ -124,6 +124,14 @@ abstract class AppDatabase : RoomDatabase() { | @@ -124,6 +124,14 @@ abstract class AppDatabase : RoomDatabase() { | ||
| 124 | } | 124 | } |
| 125 | } | 125 | } |
| 126 | 126 | ||
| 127 | + /** 人物档案:persons.realName(真名) + resume(简历/备注),手填。 */ | ||
| 128 | + private val MIGRATION_12_13 = object : Migration(12, 13) { | ||
| 129 | + override fun migrate(db: SupportSQLiteDatabase) { | ||
| 130 | + db.execSQL("ALTER TABLE `persons` ADD COLUMN `realName` TEXT") | ||
| 131 | + db.execSQL("ALTER TABLE `persons` ADD COLUMN `resume` TEXT") | ||
| 132 | + } | ||
| 133 | + } | ||
| 134 | + | ||
| 127 | fun get(ctx: Context): AppDatabase = | 135 | fun get(ctx: Context): AppDatabase = |
| 128 | instance ?: synchronized(this) { | 136 | instance ?: synchronized(this) { |
| 129 | instance ?: Room.databaseBuilder( | 137 | instance ?: Room.databaseBuilder( |
| @@ -131,7 +139,7 @@ abstract class AppDatabase : RoomDatabase() { | @@ -131,7 +139,7 @@ abstract class AppDatabase : RoomDatabase() { | ||
| 131 | AppDatabase::class.java, | 139 | AppDatabase::class.java, |
| 132 | "lifelog.db", | 140 | "lifelog.db", |
| 133 | ) | 141 | ) |
| 134 | - .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) | 142 | + .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) |
| 135 | // 不再 fallbackToDestructiveMigration:以后改 schema 必须写 Migration, | 143 | // 不再 fallbackToDestructiveMigration:以后改 schema 必须写 Migration, |
| 136 | // 否则启动直接抛 IllegalStateException(fail-loud),绝不静默清掉真实录音。 | 144 | // 否则启动直接抛 IllegalStateException(fail-loud),绝不静默清掉真实录音。 |
| 137 | .build() | 145 | .build() |
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Daos.kt
| @@ -300,6 +300,25 @@ interface PersonDao { | @@ -300,6 +300,25 @@ interface PersonDao { | ||
| 300 | @Query("SELECT * FROM persons WHERE contactRef = :ref LIMIT 1") | 300 | @Query("SELECT * FROM persons WHERE contactRef = :ref LIMIT 1") |
| 301 | suspend fun getByContactRef(ref: String): Person? | 301 | suspend fun getByContactRef(ref: String): Person? |
| 302 | 302 | ||
| 303 | + /** 跨源去重:手机通讯录导入时按手机号匹配已有人物(phone 存纯数字)。 */ | ||
| 304 | + @Query("SELECT * FROM persons WHERE phone = :phone LIMIT 1") | ||
| 305 | + suspend fun getByPhone(phone: String): Person? | ||
| 306 | + | ||
| 307 | + /** 跨源去重:按姓名精确匹配已有人物。 */ | ||
| 308 | + @Query("SELECT * FROM persons WHERE name = :name LIMIT 1") | ||
| 309 | + suspend fun getByExactName(name: String): Person? | ||
| 310 | + | ||
| 311 | + @Query("UPDATE persons SET phone = :phone WHERE id = :id") | ||
| 312 | + suspend fun setPhone(id: Long, phone: String?) | ||
| 313 | + | ||
| 314 | + /** 手机通讯录 upsert(按 contactRef="phone:<id>" 幂等,不动 face/voice/managed)。 */ | ||
| 315 | + @Query("UPDATE persons SET name=:name, phone=:phone, pinyin=:pinyin, pyInitial=:pyInitial WHERE id=:id") | ||
| 316 | + suspend fun updatePhoneInfo(id: Long, name: String, phone: String?, pinyin: String?, pyInitial: String?) | ||
| 317 | + | ||
| 318 | + /** 档案手填资料:真名 + 简历。 */ | ||
| 319 | + @Query("UPDATE persons SET realName = :realName, resume = :resume WHERE id = :id") | ||
| 320 | + suspend fun updateProfile(id: Long, realName: String?, resume: String?) | ||
| 321 | + | ||
| 303 | /** 刷新微信资料(enroll upsert 用,不动 faceEmbedding/voiceSpeakerId)。 */ | 322 | /** 刷新微信资料(enroll upsert 用,不动 faceEmbedding/voiceSpeakerId)。 */ |
| 304 | @Query( | 323 | @Query( |
| 305 | """ | 324 | """ |
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Entities.kt
| @@ -106,7 +106,10 @@ data class Person( | @@ -106,7 +106,10 @@ data class Person( | ||
| 106 | val alias: String? = null, // 微信号 | 106 | val alias: String? = null, // 微信号 |
| 107 | val pinyin: String? = null, // 全拼(排序) | 107 | val pinyin: String? = null, // 全拼(排序) |
| 108 | val pyInitial: String? = null, // 首字母(A–Z 索引) | 108 | val pyInitial: String? = null, // 首字母(A–Z 索引) |
| 109 | - val phone: String? = null, // 手机号(短信关联) | 109 | + val phone: String? = null, // 手机号(短信关联 / 手机通讯录去重键,存纯数字) |
| 110 | + // ----- 手填资料 ----- | ||
| 111 | + val realName: String? = null, // 真名(微信昵称往往是网名,真名手填) | ||
| 112 | + val resume: String? = null, // 简历 / 备注(职业、公司、关系等手填长文) | ||
| 110 | /** AI 印象:本地 LLM 综合 TA 的朋友圈/聊天生成的一句话画像 + 标签(爱好/性格)。 */ | 113 | /** AI 印象:本地 LLM 综合 TA 的朋友圈/聊天生成的一句话画像 + 标签(爱好/性格)。 */ |
| 111 | val impression: String? = null, | 114 | val impression: String? = null, |
| 112 | /** 最近访问/互动时间(点开档案、绑声纹等会刷新);>0 进通讯录「最近联系人」置顶区。 */ | 115 | /** 最近访问/互动时间(点开档案、绑声纹等会刷新);>0 进通讯录「最近联系人」置顶区。 */ |