Commit aed2cc41d1664756be4de93fdb14f72e4c41b8a3

Authored by yibo
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>
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&lt;SpeakerProfile&gt;, onBind: () @@ -247,8 +266,13 @@ private fun ProfileHeader(p: Person, speakers: List&lt;SpeakerProfile&gt;, 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&lt;SpeakerProfile&gt;, onBind: () @@ -261,17 +285,53 @@ private fun ProfileHeader(p: Person, speakers: List&lt;SpeakerProfile&gt;, 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&lt;Person&gt;, onOpen: (L @@ -351,13 +355,13 @@ private fun ContactList(vm: PhotologViewModel, persons: List&lt;Person&gt;, 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: () -&gt; Unit) { @@ -413,7 +417,7 @@ private fun ContactRow(p: Person, onClick: () -&gt; 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: () -&gt; Unit) { @@ -423,6 +427,23 @@ private fun WeChatPickerScreen(vm: PhotologViewModel, onBack: () -&gt; 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: () -&gt; Unit) { @@ -441,24 +462,27 @@ private fun WeChatPickerScreen(vm: PhotologViewModel, onBack: () -&gt; 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: () -&gt; Unit) { @@ -493,8 +517,14 @@ private fun PickerRow(p: Person, onToggle: () -&gt; 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 进通讯录「最近联系人」置顶区。 */