Commit eb0a52e4f94a00baa93df6068aac12623a4aa0c2
1 parent
edbc4d24
feat(lifelog/photolog): 人脉里程碑1 — 通讯录 + 联系人档案页
把「人物」做成微信通讯录样式 + 联系人档案页(以人为中心聚合的地基)。 - data:Person 加微信资料字段(nickname/remark/alias/pinyin/pyInitial/phone) + 迁移 v5→v6; PersonDao observe(id) / updateContactInfo;SegmentDao observeBySpeaker(声纹→录音) - WeChatEnrollWorker 重写两段:Pass1 给**全部真人联系人**upsert 资料(通讯录全员可见), Pass2 仅对有脸头像建 faceEmbedding 锚点。幂等 - ContactList:搜索 + 拼音 A–Z 粘性分组 + 头像 + 人脸/声纹标,点进档案页 - PersonProfileScreen:头像 + 微信名/备注/微信号 + 声纹绑定 + 关联照片列表 + 录音列表 (录音 = segments.speakerId=person.voiceSpeakerId,绑声纹后出现) - 标注对话框改可滚动/搜索/固定底部按钮(联系人多时不再卡死) - extract 脚本补全字段(nickname/remark/alias/pinyin) 实测:4357 真人联系人入通讯录(1339 人脸锚点),A–Z 分组 + 头像 + 档案页正常。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Showing
8 changed files
with
483 additions
and
93 deletions
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PersonProfileScreen.kt
0 → 100644
| 1 | +package com.lemon.lifelog | ||
| 2 | + | ||
| 3 | +import androidx.compose.foundation.clickable | ||
| 4 | +import androidx.compose.foundation.layout.Arrangement | ||
| 5 | +import androidx.compose.foundation.layout.Box | ||
| 6 | +import androidx.compose.foundation.layout.Column | ||
| 7 | +import androidx.compose.foundation.layout.Row | ||
| 8 | +import androidx.compose.foundation.layout.Spacer | ||
| 9 | +import androidx.compose.foundation.layout.fillMaxSize | ||
| 10 | +import androidx.compose.foundation.layout.fillMaxWidth | ||
| 11 | +import androidx.compose.foundation.layout.height | ||
| 12 | +import androidx.compose.foundation.layout.padding | ||
| 13 | +import androidx.compose.foundation.layout.size | ||
| 14 | +import androidx.compose.foundation.layout.width | ||
| 15 | +import androidx.compose.foundation.lazy.LazyColumn | ||
| 16 | +import androidx.compose.foundation.lazy.LazyRow | ||
| 17 | +import androidx.compose.foundation.lazy.items | ||
| 18 | +import androidx.compose.foundation.shape.CircleShape | ||
| 19 | +import androidx.compose.foundation.shape.RoundedCornerShape | ||
| 20 | +import androidx.compose.material.icons.Icons | ||
| 21 | +import androidx.compose.material.icons.automirrored.filled.ArrowBack | ||
| 22 | +import androidx.compose.material3.AlertDialog | ||
| 23 | +import androidx.compose.material3.Card | ||
| 24 | +import androidx.compose.material3.ExperimentalMaterial3Api | ||
| 25 | +import androidx.compose.material3.HorizontalDivider | ||
| 26 | +import androidx.compose.material3.Icon | ||
| 27 | +import androidx.compose.material3.IconButton | ||
| 28 | +import androidx.compose.material3.MaterialTheme | ||
| 29 | +import androidx.compose.material3.OutlinedButton | ||
| 30 | +import androidx.compose.material3.RadioButton | ||
| 31 | +import androidx.compose.material3.Surface | ||
| 32 | +import androidx.compose.material3.Text | ||
| 33 | +import androidx.compose.material3.TextButton | ||
| 34 | +import androidx.compose.material3.TopAppBar | ||
| 35 | +import androidx.compose.runtime.Composable | ||
| 36 | +import androidx.compose.runtime.DisposableEffect | ||
| 37 | +import androidx.compose.runtime.collectAsState | ||
| 38 | +import androidx.compose.runtime.getValue | ||
| 39 | +import androidx.compose.runtime.mutableStateOf | ||
| 40 | +import androidx.compose.runtime.remember | ||
| 41 | +import androidx.compose.runtime.setValue | ||
| 42 | +import androidx.compose.ui.Alignment | ||
| 43 | +import androidx.compose.ui.Modifier | ||
| 44 | +import androidx.compose.ui.draw.clip | ||
| 45 | +import androidx.compose.ui.layout.ContentScale | ||
| 46 | +import androidx.compose.ui.text.style.TextOverflow | ||
| 47 | +import androidx.compose.ui.unit.dp | ||
| 48 | +import coil.compose.AsyncImage | ||
| 49 | +import com.lemon.lifelog.audio.SegmentPlayer | ||
| 50 | +import com.lemon.lifelog.data.Person | ||
| 51 | +import com.lemon.lifelog.data.SpeakerProfile | ||
| 52 | +import java.io.File | ||
| 53 | +import java.text.SimpleDateFormat | ||
| 54 | +import java.util.Date | ||
| 55 | +import java.util.Locale | ||
| 56 | + | ||
| 57 | +private val PROFILE_FMT = SimpleDateFormat("MM-dd HH:mm", Locale.US) | ||
| 58 | + | ||
| 59 | +@OptIn(ExperimentalMaterial3Api::class) | ||
| 60 | +@Composable | ||
| 61 | +internal fun PersonProfileScreen( | ||
| 62 | + vm: PhotologViewModel, | ||
| 63 | + personId: Long, | ||
| 64 | + onBack: () -> Unit, | ||
| 65 | + onOpenPhoto: (Long) -> Unit, | ||
| 66 | +) { | ||
| 67 | + val person by vm.repo.persons.observe(personId).collectAsState(initial = null) | ||
| 68 | + val photos by vm.repo.photos.observePhotosOfPerson(personId).collectAsState(initial = emptyList()) | ||
| 69 | + val speakers by vm.repo.speakers.observeAll().collectAsState(initial = emptyList()) | ||
| 70 | + val sid = person?.voiceSpeakerId | ||
| 71 | + val audio by remember(sid) { | ||
| 72 | + if (sid == null) kotlinx.coroutines.flow.flowOf(emptyList()) | ||
| 73 | + else vm.repo.segments.observeBySpeaker(sid) | ||
| 74 | + }.collectAsState(initial = emptyList()) | ||
| 75 | + | ||
| 76 | + var bindVoice by remember { mutableStateOf(false) } | ||
| 77 | + val player = remember { SegmentPlayer() } | ||
| 78 | + var playingId by remember { mutableStateOf<Long?>(null) } | ||
| 79 | + DisposableEffect(player) { onDispose { player.stop() } } | ||
| 80 | + | ||
| 81 | + Column(modifier = Modifier.fillMaxSize()) { | ||
| 82 | + TopAppBar( | ||
| 83 | + title = { Text(person?.name ?: "联系人", maxLines = 1, overflow = TextOverflow.Ellipsis) }, | ||
| 84 | + navigationIcon = { | ||
| 85 | + IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "返回") } | ||
| 86 | + }, | ||
| 87 | + ) | ||
| 88 | + LazyColumn(modifier = Modifier.fillMaxSize()) { | ||
| 89 | + // ----- 资料头 ----- | ||
| 90 | + item { | ||
| 91 | + person?.let { p -> | ||
| 92 | + Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) { | ||
| 93 | + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp)) { | ||
| 94 | + if (p.avatarPath != null) { | ||
| 95 | + AsyncImage( | ||
| 96 | + model = File(p.avatarPath), | ||
| 97 | + contentDescription = null, | ||
| 98 | + contentScale = ContentScale.Crop, | ||
| 99 | + modifier = Modifier.size(72.dp).clip(RoundedCornerShape(12.dp)), | ||
| 100 | + ) | ||
| 101 | + } else { | ||
| 102 | + Box(Modifier.size(72.dp).clip(RoundedCornerShape(12.dp))) {} | ||
| 103 | + } | ||
| 104 | + Column(modifier = Modifier.weight(1f)) { | ||
| 105 | + Text(p.name, style = MaterialTheme.typography.titleLarge, maxLines = 1, overflow = TextOverflow.Ellipsis) | ||
| 106 | + p.nickname?.takeIf { it != p.name }?.let { Text("微信名:$it", style = MaterialTheme.typography.bodySmall) } | ||
| 107 | + } | ||
| 108 | + } | ||
| 109 | + Spacer(Modifier.height(10.dp)) | ||
| 110 | + InfoRow("备注", p.remark) | ||
| 111 | + InfoRow("微信号", p.alias) | ||
| 112 | + InfoRow("手机号", p.phone) | ||
| 113 | + // 声纹绑定 | ||
| 114 | + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { | ||
| 115 | + val sName = speakers.firstOrNull { it.id == p.voiceSpeakerId }?.name | ||
| 116 | + Text("声纹:${sName ?: "未绑定"}", style = MaterialTheme.typography.bodyMedium) | ||
| 117 | + OutlinedButton(onClick = { bindVoice = true }) { Text(if (p.voiceSpeakerId == null) "绑定" else "改绑") } | ||
| 118 | + } | ||
| 119 | + } | ||
| 120 | + HorizontalDivider() | ||
| 121 | + } | ||
| 122 | + } | ||
| 123 | + // ----- 照片 ----- | ||
| 124 | + item { | ||
| 125 | + SectionHeader("照片", photos.size) | ||
| 126 | + if (photos.isEmpty()) { | ||
| 127 | + Text("还没有关联照片。导入照片后会自动认出 TA。", style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp)) | ||
| 128 | + } else { | ||
| 129 | + LazyRow( | ||
| 130 | + modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp), | ||
| 131 | + contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 12.dp), | ||
| 132 | + horizontalArrangement = Arrangement.spacedBy(6.dp), | ||
| 133 | + ) { | ||
| 134 | + items(photos, key = { it.id }) { ph -> | ||
| 135 | + AsyncImage( | ||
| 136 | + model = File(ph.path), | ||
| 137 | + contentDescription = ph.caption, | ||
| 138 | + contentScale = ContentScale.Crop, | ||
| 139 | + modifier = Modifier.size(96.dp).clip(RoundedCornerShape(8.dp)).clickable { onOpenPhoto(ph.id) }, | ||
| 140 | + ) | ||
| 141 | + } | ||
| 142 | + } | ||
| 143 | + } | ||
| 144 | + HorizontalDivider() | ||
| 145 | + } | ||
| 146 | + // ----- 录音 ----- | ||
| 147 | + item { SectionHeader("录音", audio.size) } | ||
| 148 | + if (sid == null) { | ||
| 149 | + item { | ||
| 150 | + Text("绑定声纹后,这里会列出 TA 说过的录音。", style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp)) | ||
| 151 | + } | ||
| 152 | + } else if (audio.isEmpty()) { | ||
| 153 | + item { Text("这个声纹名下还没有录音段。", style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp)) } | ||
| 154 | + } else { | ||
| 155 | + items(audio, key = { it.id }) { row -> | ||
| 156 | + Row( | ||
| 157 | + modifier = Modifier.fillMaxWidth().clickable { | ||
| 158 | + if (playingId == row.id) { player.stop(); playingId = null } | ||
| 159 | + else { playingId = row.id; player.play(row.audioPath) { playingId = null } } | ||
| 160 | + }.padding(horizontal = 16.dp, vertical = 8.dp), | ||
| 161 | + horizontalArrangement = Arrangement.spacedBy(10.dp), | ||
| 162 | + verticalAlignment = Alignment.CenterVertically, | ||
| 163 | + ) { | ||
| 164 | + Text(if (playingId == row.id) "■" else "▶", style = MaterialTheme.typography.titleMedium) | ||
| 165 | + Column(modifier = Modifier.weight(1f)) { | ||
| 166 | + Text(PROFILE_FMT.format(Date(row.startTs)) + " · ${"%.1fs".format(row.durationMs / 1000.0)}", style = MaterialTheme.typography.labelSmall) | ||
| 167 | + Text(row.text ?: "(无转写)", style = MaterialTheme.typography.bodyMedium, maxLines = 2, overflow = TextOverflow.Ellipsis) | ||
| 168 | + } | ||
| 169 | + } | ||
| 170 | + } | ||
| 171 | + } | ||
| 172 | + item { Spacer(Modifier.height(24.dp)) } | ||
| 173 | + } | ||
| 174 | + } | ||
| 175 | + | ||
| 176 | + if (bindVoice) { | ||
| 177 | + BindVoiceDialog( | ||
| 178 | + speakers = speakers, | ||
| 179 | + current = person?.voiceSpeakerId, | ||
| 180 | + onDismiss = { bindVoice = false }, | ||
| 181 | + onPick = { vm.bindVoice(personId, it); bindVoice = false }, | ||
| 182 | + onClear = { vm.bindVoice(personId, null); bindVoice = false }, | ||
| 183 | + ) | ||
| 184 | + } | ||
| 185 | +} | ||
| 186 | + | ||
| 187 | +@Composable | ||
| 188 | +private fun InfoRow(label: String, value: String?) { | ||
| 189 | + if (value.isNullOrBlank()) return | ||
| 190 | + Row(modifier = Modifier.fillMaxWidth().padding(vertical = 3.dp), horizontalArrangement = Arrangement.spacedBy(10.dp)) { | ||
| 191 | + Text(label, style = MaterialTheme.typography.bodySmall, modifier = Modifier.width(56.dp)) | ||
| 192 | + Text(value, style = MaterialTheme.typography.bodyMedium) | ||
| 193 | + } | ||
| 194 | +} | ||
| 195 | + | ||
| 196 | +@Composable | ||
| 197 | +private fun SectionHeader(title: String, count: Int) { | ||
| 198 | + Text( | ||
| 199 | + "$title($count)", | ||
| 200 | + style = MaterialTheme.typography.titleSmall, | ||
| 201 | + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), | ||
| 202 | + ) | ||
| 203 | +} | ||
| 204 | + | ||
| 205 | +@Composable | ||
| 206 | +private fun BindVoiceDialog( | ||
| 207 | + speakers: List<SpeakerProfile>, | ||
| 208 | + current: Long?, | ||
| 209 | + onDismiss: () -> Unit, | ||
| 210 | + onPick: (Long) -> Unit, | ||
| 211 | + onClear: () -> Unit, | ||
| 212 | +) { | ||
| 213 | + AlertDialog( | ||
| 214 | + onDismissRequest = onDismiss, | ||
| 215 | + title = { Text("绑定声纹") }, | ||
| 216 | + text = { | ||
| 217 | + if (speakers.isEmpty()) { | ||
| 218 | + Text("还没有声纹。去「纪要 → 声纹」录一段或聚类后再绑。") | ||
| 219 | + } else { | ||
| 220 | + LazyColumn(modifier = Modifier.height(360.dp)) { | ||
| 221 | + items(speakers, key = { it.id }) { sp -> | ||
| 222 | + Row( | ||
| 223 | + modifier = Modifier.fillMaxWidth().clickable { onPick(sp.id) }.padding(vertical = 8.dp), | ||
| 224 | + verticalAlignment = Alignment.CenterVertically, | ||
| 225 | + ) { | ||
| 226 | + RadioButton(selected = current == sp.id, onClick = { onPick(sp.id) }) | ||
| 227 | + Text(sp.name) | ||
| 228 | + } | ||
| 229 | + } | ||
| 230 | + } | ||
| 231 | + } | ||
| 232 | + }, | ||
| 233 | + confirmButton = { | ||
| 234 | + if (current != null) TextButton(onClick = onClear) { Text("解绑") } | ||
| 235 | + }, | ||
| 236 | + dismissButton = { TextButton(onClick = onDismiss) { Text("取消") } }, | ||
| 237 | + ) | ||
| 238 | +} |
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologScreen.kt
| @@ -3,6 +3,8 @@ package com.lemon.lifelog | @@ -3,6 +3,8 @@ package com.lemon.lifelog | ||
| 3 | import androidx.activity.compose.rememberLauncherForActivityResult | 3 | import androidx.activity.compose.rememberLauncherForActivityResult |
| 4 | import androidx.activity.result.PickVisualMediaRequest | 4 | import androidx.activity.result.PickVisualMediaRequest |
| 5 | import androidx.activity.result.contract.ActivityResultContracts | 5 | import androidx.activity.result.contract.ActivityResultContracts |
| 6 | +import androidx.compose.foundation.ExperimentalFoundationApi | ||
| 7 | +import androidx.compose.foundation.background | ||
| 6 | import androidx.compose.foundation.clickable | 8 | import androidx.compose.foundation.clickable |
| 7 | import androidx.compose.foundation.layout.Arrangement | 9 | import androidx.compose.foundation.layout.Arrangement |
| 8 | import androidx.compose.foundation.layout.Box | 10 | import androidx.compose.foundation.layout.Box |
| @@ -11,9 +13,15 @@ import androidx.compose.foundation.layout.Row | @@ -11,9 +13,15 @@ import androidx.compose.foundation.layout.Row | ||
| 11 | import androidx.compose.foundation.layout.Spacer | 13 | import androidx.compose.foundation.layout.Spacer |
| 12 | import androidx.compose.foundation.layout.aspectRatio | 14 | import androidx.compose.foundation.layout.aspectRatio |
| 13 | import androidx.compose.foundation.layout.fillMaxSize | 15 | import androidx.compose.foundation.layout.fillMaxSize |
| 16 | +import androidx.compose.foundation.layout.fillMaxHeight | ||
| 14 | import androidx.compose.foundation.layout.fillMaxWidth | 17 | import androidx.compose.foundation.layout.fillMaxWidth |
| 15 | import androidx.compose.foundation.layout.height | 18 | import androidx.compose.foundation.layout.height |
| 16 | import androidx.compose.foundation.layout.padding | 19 | import androidx.compose.foundation.layout.padding |
| 20 | +import androidx.compose.foundation.layout.size | ||
| 21 | +import androidx.compose.foundation.shape.CircleShape | ||
| 22 | +import androidx.compose.ui.draw.clip | ||
| 23 | +import androidx.compose.ui.window.Dialog | ||
| 24 | +import androidx.compose.ui.window.DialogProperties | ||
| 17 | import androidx.compose.foundation.lazy.LazyColumn | 25 | import androidx.compose.foundation.lazy.LazyColumn |
| 18 | import androidx.compose.foundation.lazy.grid.GridCells | 26 | import androidx.compose.foundation.lazy.grid.GridCells |
| 19 | import androidx.compose.foundation.lazy.grid.LazyVerticalGrid | 27 | import androidx.compose.foundation.lazy.grid.LazyVerticalGrid |
| @@ -74,6 +82,7 @@ internal fun PhotologTab(repo: Repository) { | @@ -74,6 +82,7 @@ internal fun PhotologTab(repo: Repository) { | ||
| 74 | var view by remember { mutableStateOf(0) } // 0=照片 1=人物 | 82 | var view by remember { mutableStateOf(0) } // 0=照片 1=人物 |
| 75 | var filterPersonId by remember { mutableStateOf<Long?>(null) } | 83 | var filterPersonId by remember { mutableStateOf<Long?>(null) } |
| 76 | var detailPhotoId by remember { mutableStateOf<Long?>(null) } | 84 | var detailPhotoId by remember { mutableStateOf<Long?>(null) } |
| 85 | + var profilePersonId by remember { mutableStateOf<Long?>(null) } | ||
| 77 | 86 | ||
| 78 | val persons by vm.repo.persons.observeAll().collectAsState(initial = emptyList()) | 87 | val persons by vm.repo.persons.observeAll().collectAsState(initial = emptyList()) |
| 79 | val pendingCount by vm.repo.photos.observeUnprocessedCount().collectAsState(initial = 0) | 88 | val pendingCount by vm.repo.photos.observeUnprocessedCount().collectAsState(initial = 0) |
| @@ -88,6 +97,19 @@ internal fun PhotologTab(repo: Repository) { | @@ -88,6 +97,19 @@ internal fun PhotologTab(repo: Repository) { | ||
| 88 | ActivityResultContracts.PickMultipleVisualMedia(30), | 97 | ActivityResultContracts.PickMultipleVisualMedia(30), |
| 89 | ) { uris -> vm.importUris(uris) } | 98 | ) { uris -> vm.importUris(uris) } |
| 90 | 99 | ||
| 100 | + val pid = profilePersonId | ||
| 101 | + if (pid != null) { | ||
| 102 | + PersonProfileScreen( | ||
| 103 | + vm = vm, personId = pid, | ||
| 104 | + onBack = { profilePersonId = null }, | ||
| 105 | + onOpenPhoto = { detailPhotoId = it }, | ||
| 106 | + ) | ||
| 107 | + detailPhotoId?.let { id -> | ||
| 108 | + PhotoDetailSheet(vm = vm, photoId = id, persons = persons, onDismiss = { detailPhotoId = null }) | ||
| 109 | + } | ||
| 110 | + return | ||
| 111 | + } | ||
| 112 | + | ||
| 91 | Column(modifier = Modifier.fillMaxSize()) { | 113 | Column(modifier = Modifier.fillMaxSize()) { |
| 92 | TabRow(selectedTabIndex = view) { | 114 | TabRow(selectedTabIndex = view) { |
| 93 | Tab(selected = view == 0, onClick = { view = 0 }, text = { Text("照片") }) | 115 | Tab(selected = view == 0, onClick = { view = 0 }, text = { Text("照片") }) |
| @@ -133,10 +155,10 @@ internal fun PhotologTab(repo: Repository) { | @@ -133,10 +155,10 @@ internal fun PhotologTab(repo: Repository) { | ||
| 133 | } | 155 | } |
| 134 | } | 156 | } |
| 135 | } else { | 157 | } else { |
| 136 | - PeopleList( | 158 | + ContactList( |
| 137 | vm = vm, | 159 | vm = vm, |
| 138 | persons = persons, | 160 | persons = persons, |
| 139 | - onOpen = { id -> filterPersonId = id; view = 0 }, | 161 | + onOpen = { id -> profilePersonId = id }, |
| 140 | ) | 162 | ) |
| 141 | } | 163 | } |
| 142 | } | 164 | } |
| @@ -243,99 +265,156 @@ private fun TagFaceDialog( | @@ -243,99 +265,156 @@ private fun TagFaceDialog( | ||
| 243 | onClear: () -> Unit, | 265 | onClear: () -> Unit, |
| 244 | ) { | 266 | ) { |
| 245 | var newName by remember { mutableStateOf("") } | 267 | var newName by remember { mutableStateOf("") } |
| 246 | - AlertDialog( | ||
| 247 | - onDismissRequest = onDismiss, | ||
| 248 | - title = { Text("这张脸是谁?") }, | ||
| 249 | - text = { | ||
| 250 | - Column { | ||
| 251 | - Text("标注后会自动在其它照片里找同一个人。", style = MaterialTheme.typography.bodySmall) | 268 | + var query by remember { mutableStateOf("") } |
| 269 | + val filtered = remember(persons, query) { | ||
| 270 | + if (query.isBlank()) persons | ||
| 271 | + else persons.filter { it.name.contains(query.trim(), ignoreCase = true) } | ||
| 272 | + } | ||
| 273 | + Dialog(onDismissRequest = onDismiss, properties = DialogProperties(usePlatformDefaultWidth = false)) { | ||
| 274 | + Surface(shape = RoundedCornerShape(16.dp), modifier = Modifier.fillMaxWidth(0.94f).fillMaxHeight(0.85f)) { | ||
| 275 | + Column(modifier = Modifier.fillMaxSize().padding(16.dp)) { | ||
| 276 | + Text("这张脸是谁?", style = MaterialTheme.typography.titleMedium) | ||
| 277 | + Text("标注后会自动在其它照片里找同一个人。", style = MaterialTheme.typography.labelSmall) | ||
| 252 | Spacer(Modifier.height(8.dp)) | 278 | Spacer(Modifier.height(8.dp)) |
| 253 | - persons.forEach { p -> | ||
| 254 | - Row( | ||
| 255 | - modifier = Modifier.fillMaxWidth().clickable { onPick(p.id) }.padding(vertical = 6.dp), | ||
| 256 | - verticalAlignment = Alignment.CenterVertically, | ||
| 257 | - ) { | ||
| 258 | - RadioButton(selected = current == p.id, onClick = { onPick(p.id) }) | ||
| 259 | - Text(p.name) | 279 | + OutlinedTextField( |
| 280 | + value = query, onValueChange = { query = it }, | ||
| 281 | + placeholder = { Text("搜索联系人(共 ${persons.size})…") }, | ||
| 282 | + singleLine = true, modifier = Modifier.fillMaxWidth(), | ||
| 283 | + ) | ||
| 284 | + Spacer(Modifier.height(4.dp)) | ||
| 285 | + LazyColumn(modifier = Modifier.weight(1f).fillMaxWidth()) { | ||
| 286 | + items(filtered, key = { it.id }) { p -> | ||
| 287 | + Row( | ||
| 288 | + modifier = Modifier.fillMaxWidth().clickable { onPick(p.id) }.padding(vertical = 6.dp), | ||
| 289 | + verticalAlignment = Alignment.CenterVertically, | ||
| 290 | + horizontalArrangement = Arrangement.spacedBy(8.dp), | ||
| 291 | + ) { | ||
| 292 | + RadioButton(selected = current == p.id, onClick = { onPick(p.id) }) | ||
| 293 | + if (p.avatarPath != null) { | ||
| 294 | + AsyncImage( | ||
| 295 | + model = File(p.avatarPath), | ||
| 296 | + contentDescription = null, | ||
| 297 | + contentScale = ContentScale.Crop, | ||
| 298 | + modifier = Modifier.size(36.dp).clip(CircleShape), | ||
| 299 | + ) | ||
| 300 | + } | ||
| 301 | + Text(p.name, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f)) | ||
| 302 | + if (p.contactRef != null) Text("微信", style = MaterialTheme.typography.labelSmall) | ||
| 303 | + } | ||
| 304 | + } | ||
| 305 | + if (filtered.isEmpty()) { | ||
| 306 | + item { Text("没有匹配的联系人", style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(8.dp)) } | ||
| 260 | } | 307 | } |
| 261 | } | 308 | } |
| 262 | - Spacer(Modifier.height(4.dp)) | 309 | + HorizontalDivider() |
| 310 | + Spacer(Modifier.height(8.dp)) | ||
| 263 | OutlinedTextField( | 311 | OutlinedTextField( |
| 264 | value = newName, onValueChange = { newName = it }, | 312 | value = newName, onValueChange = { newName = it }, |
| 265 | label = { Text("或新建一个人(名字)") }, singleLine = true, | 313 | label = { Text("或新建一个人(名字)") }, singleLine = true, |
| 266 | modifier = Modifier.fillMaxWidth(), | 314 | modifier = Modifier.fillMaxWidth(), |
| 267 | ) | 315 | ) |
| 268 | - if (current != null) { | ||
| 269 | - TextButton(onClick = onClear) { Text("清除归属") } | 316 | + Spacer(Modifier.height(8.dp)) |
| 317 | + Row( | ||
| 318 | + modifier = Modifier.fillMaxWidth(), | ||
| 319 | + horizontalArrangement = Arrangement.SpaceBetween, | ||
| 320 | + verticalAlignment = Alignment.CenterVertically, | ||
| 321 | + ) { | ||
| 322 | + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { | ||
| 323 | + TextButton(onClick = onDismiss) { Text("取消") } | ||
| 324 | + if (current != null) TextButton(onClick = onClear) { Text("清除归属") } | ||
| 325 | + } | ||
| 326 | + Button(onClick = { onCreate(newName) }, enabled = newName.isNotBlank()) { Text("新建并标注") } | ||
| 270 | } | 327 | } |
| 271 | } | 328 | } |
| 272 | - }, | ||
| 273 | - confirmButton = { | ||
| 274 | - TextButton(onClick = { onCreate(newName) }, enabled = newName.isNotBlank()) { Text("新建并标注") } | ||
| 275 | - }, | ||
| 276 | - dismissButton = { TextButton(onClick = onDismiss) { Text("取消") } }, | ||
| 277 | - ) | 329 | + } |
| 330 | + } | ||
| 278 | } | 331 | } |
| 279 | 332 | ||
| 333 | +/** 通讯录样式:搜索 + 拼音 A–Z 分组 + 头像,点进档案页。 */ | ||
| 334 | +@OptIn(ExperimentalFoundationApi::class) | ||
| 280 | @Composable | 335 | @Composable |
| 281 | -private fun PeopleList(vm: PhotologViewModel, persons: List<Person>, onOpen: (Long) -> Unit) { | ||
| 282 | - var renameFor by remember { mutableStateOf<Person?>(null) } | 336 | +private fun ContactList(vm: PhotologViewModel, persons: List<Person>, onOpen: (Long) -> Unit) { |
| 283 | val enrollReady = remember { vm.wechatEnrollReady() } | 337 | val enrollReady = remember { vm.wechatEnrollReady() } |
| 284 | var enrollTapped by remember { mutableStateOf(false) } | 338 | var enrollTapped by remember { mutableStateOf(false) } |
| 339 | + var query by remember { mutableStateOf("") } | ||
| 340 | + | ||
| 341 | + val sections = remember(persons, query) { | ||
| 342 | + val q = query.trim() | ||
| 343 | + val filtered = if (q.isBlank()) persons else persons.filter { p -> | ||
| 344 | + p.name.contains(q, true) || p.nickname?.contains(q, true) == true || | ||
| 345 | + p.remark?.contains(q, true) == true || p.alias?.contains(q, true) == true || | ||
| 346 | + p.pinyin?.contains(q, true) == true | ||
| 347 | + } | ||
| 348 | + filtered.sortedWith(compareBy({ sectionLetter(it) }, { it.pinyin ?: it.name })) | ||
| 349 | + .groupBy { sectionLetter(it) } | ||
| 350 | + .toSortedMap(compareBy { if (it == "#") "[" else it }) // # 排最后 | ||
| 351 | + } | ||
| 285 | 352 | ||
| 286 | Column(modifier = Modifier.fillMaxSize()) { | 353 | Column(modifier = Modifier.fillMaxSize()) { |
| 354 | + OutlinedTextField( | ||
| 355 | + value = query, onValueChange = { query = it }, | ||
| 356 | + placeholder = { Text("搜索联系人(共 ${persons.size})") }, singleLine = true, | ||
| 357 | + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 6.dp), | ||
| 358 | + ) | ||
| 287 | if (enrollReady) { | 359 | if (enrollReady) { |
| 288 | Row( | 360 | Row( |
| 289 | - modifier = Modifier.fillMaxWidth().padding(12.dp), | ||
| 290 | - verticalAlignment = Alignment.CenterVertically, | ||
| 291 | - horizontalArrangement = Arrangement.spacedBy(8.dp), | 361 | + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp).padding(bottom = 6.dp), |
| 362 | + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), | ||
| 292 | ) { | 363 | ) { |
| 293 | Button(onClick = { vm.enrollWeChat(); enrollTapped = true }) { Text("从微信导入联系人") } | 364 | Button(onClick = { vm.enrollWeChat(); enrollTapped = true }) { Text("从微信导入联系人") } |
| 294 | Text( | 365 | Text( |
| 295 | - if (enrollTapped) "后台建锚点中,人物会陆续出现…" else "用头像建人脸锚点,照片自动认人", | 366 | + if (enrollTapped) "后台导入中,联系人会陆续出现…" else "导入全部真人联系人", |
| 296 | style = MaterialTheme.typography.labelSmall, | 367 | style = MaterialTheme.typography.labelSmall, |
| 297 | ) | 368 | ) |
| 298 | } | 369 | } |
| 299 | - HorizontalDivider() | ||
| 300 | } | 370 | } |
| 371 | + HorizontalDivider() | ||
| 301 | if (persons.isEmpty()) { | 372 | if (persons.isEmpty()) { |
| 302 | Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) { | 373 | Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) { |
| 303 | - Text("还没有人物。点上方「从微信导入联系人」,或去「照片」里点一张脸标注。", style = MaterialTheme.typography.bodyMedium) | 374 | + Text("还没有联系人。点「从微信导入联系人」,或在照片里标注人脸。", style = MaterialTheme.typography.bodyMedium) |
| 304 | } | 375 | } |
| 305 | } else { | 376 | } else { |
| 306 | - LazyColumn(modifier = Modifier.fillMaxSize().padding(12.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { | ||
| 307 | - items(persons, key = { it.id }) { p -> | ||
| 308 | - val count by produceState(0, p.id) { value = withContext(Dispatchers.IO) { vm.repo.faces.countForPerson(p.id) } } | ||
| 309 | - Card(modifier = Modifier.fillMaxWidth().clickable { onOpen(p.id) }) { | ||
| 310 | - Row( | ||
| 311 | - modifier = Modifier.fillMaxWidth().padding(14.dp), | ||
| 312 | - horizontalArrangement = Arrangement.SpaceBetween, | ||
| 313 | - verticalAlignment = Alignment.CenterVertically, | ||
| 314 | - ) { | ||
| 315 | - Column(modifier = Modifier.weight(1f)) { | ||
| 316 | - Text(p.name, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.titleSmall) | ||
| 317 | - Text( | ||
| 318 | - "$count 张人脸" + | ||
| 319 | - (if (p.contactRef != null) " · 微信" else "") + | ||
| 320 | - (if (p.voiceSpeakerId != null) " · 声纹" else ""), | ||
| 321 | - style = MaterialTheme.typography.labelSmall, | ||
| 322 | - ) | ||
| 323 | - } | ||
| 324 | - TextButton(onClick = { renameFor = p }) { Text("重命名") } | 377 | + LazyColumn(modifier = Modifier.fillMaxSize()) { |
| 378 | + sections.forEach { (letter, group) -> | ||
| 379 | + stickyHeader { | ||
| 380 | + Surface(color = MaterialTheme.colorScheme.surfaceVariant, modifier = Modifier.fillMaxWidth()) { | ||
| 381 | + Text(letter, style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(horizontal = 16.dp, vertical = 3.dp)) | ||
| 325 | } | 382 | } |
| 326 | } | 383 | } |
| 384 | + items(group, key = { it.id }) { p -> ContactRow(p) { onOpen(p.id) } } | ||
| 327 | } | 385 | } |
| 328 | } | 386 | } |
| 329 | } | 387 | } |
| 330 | } | 388 | } |
| 331 | - renameFor?.let { p -> | ||
| 332 | - var name by remember { mutableStateOf(p.name) } | ||
| 333 | - AlertDialog( | ||
| 334 | - onDismissRequest = { renameFor = null }, | ||
| 335 | - title = { Text("重命名") }, | ||
| 336 | - text = { OutlinedTextField(value = name, onValueChange = { name = it }, singleLine = true) }, | ||
| 337 | - confirmButton = { TextButton(onClick = { vm.renamePerson(p.id, name); renameFor = null }, enabled = name.isNotBlank()) { Text("保存") } }, | ||
| 338 | - dismissButton = { TextButton(onClick = { renameFor = null }) { Text("取消") } }, | ||
| 339 | - ) | 389 | +} |
| 390 | + | ||
| 391 | +private fun sectionLetter(p: Person): String { | ||
| 392 | + val c = (p.pyInitial?.firstOrNull() ?: p.name.firstOrNull())?.uppercaseChar() | ||
| 393 | + return if (c != null && c in 'A'..'Z') c.toString() else "#" | ||
| 394 | +} | ||
| 395 | + | ||
| 396 | +@Composable | ||
| 397 | +private fun ContactRow(p: Person, onClick: () -> Unit) { | ||
| 398 | + Row( | ||
| 399 | + modifier = Modifier.fillMaxWidth().clickable(onClick = onClick).padding(horizontal = 16.dp, vertical = 7.dp), | ||
| 400 | + verticalAlignment = Alignment.CenterVertically, | ||
| 401 | + horizontalArrangement = Arrangement.spacedBy(12.dp), | ||
| 402 | + ) { | ||
| 403 | + if (p.avatarPath != null) { | ||
| 404 | + AsyncImage( | ||
| 405 | + model = File(p.avatarPath), contentDescription = null, contentScale = ContentScale.Crop, | ||
| 406 | + modifier = Modifier.size(44.dp).clip(RoundedCornerShape(6.dp)), | ||
| 407 | + ) | ||
| 408 | + } else { | ||
| 409 | + Box(modifier = Modifier.size(44.dp).clip(RoundedCornerShape(6.dp)).background(MaterialTheme.colorScheme.surfaceVariant)) | ||
| 410 | + } | ||
| 411 | + Column(modifier = Modifier.weight(1f)) { | ||
| 412 | + Text(p.name, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.titleSmall) | ||
| 413 | + val tags = listOfNotNull( | ||
| 414 | + if (p.faceEmbedding != null) "人脸" else null, | ||
| 415 | + if (p.voiceSpeakerId != null) "声纹" else null, | ||
| 416 | + ) | ||
| 417 | + if (tags.isNotEmpty()) Text(tags.joinToString(" · "), style = MaterialTheme.typography.labelSmall) | ||
| 418 | + } | ||
| 340 | } | 419 | } |
| 341 | } | 420 | } |
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologViewModel.kt
| @@ -74,6 +74,11 @@ class PhotologViewModel(app: Application) : AndroidViewModel(app) { | @@ -74,6 +74,11 @@ class PhotologViewModel(app: Application) : AndroidViewModel(app) { | ||
| 74 | viewModelScope.launch { repo.persons.updateName(personId, name.trim()) } | 74 | viewModelScope.launch { repo.persons.updateName(personId, name.trim()) } |
| 75 | } | 75 | } |
| 76 | 76 | ||
| 77 | + /** 融合:把一个声纹绑到联系人(null = 解绑)。绑定后档案页"录音列表"才有内容。 */ | ||
| 78 | + fun bindVoice(personId: Long, speakerId: Long?) { | ||
| 79 | + viewModelScope.launch { repo.persons.linkVoice(personId, speakerId) } | ||
| 80 | + } | ||
| 81 | + | ||
| 77 | /** 设备上是否已备好微信 enroll 数据(Mac 推过来的头像+清单)。 */ | 82 | /** 设备上是否已备好微信 enroll 数据(Mac 推过来的头像+清单)。 */ |
| 78 | fun wechatEnrollReady(): Boolean = | 83 | fun wechatEnrollReady(): Boolean = |
| 79 | File(getApplication<Application>().filesDir, "wx_enroll/enroll.json").exists() | 84 | File(getApplication<Application>().filesDir, "wx_enroll/enroll.json").exists() |
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/AppDatabase.kt
| @@ -20,7 +20,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase | @@ -20,7 +20,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase | ||
| 20 | Photo::class, | 20 | Photo::class, |
| 21 | Face::class, | 21 | Face::class, |
| 22 | ], | 22 | ], |
| 23 | - version = 5, | 23 | + version = 6, |
| 24 | exportSchema = false, | 24 | exportSchema = false, |
| 25 | ) | 25 | ) |
| 26 | abstract class AppDatabase : RoomDatabase() { | 26 | abstract class AppDatabase : RoomDatabase() { |
| @@ -71,6 +71,15 @@ abstract class AppDatabase : RoomDatabase() { | @@ -71,6 +71,15 @@ abstract class AppDatabase : RoomDatabase() { | ||
| 71 | } | 71 | } |
| 72 | } | 72 | } |
| 73 | 73 | ||
| 74 | + /** v0.7 photolog:persons 加微信资料字段(通讯录展示 + 拼音索引 + 手机号占位)。 */ | ||
| 75 | + private val MIGRATION_5_6 = object : Migration(5, 6) { | ||
| 76 | + override fun migrate(db: SupportSQLiteDatabase) { | ||
| 77 | + for (col in listOf("nickname", "remark", "alias", "pinyin", "pyInitial", "phone")) { | ||
| 78 | + db.execSQL("ALTER TABLE `persons` ADD COLUMN `$col` TEXT") | ||
| 79 | + } | ||
| 80 | + } | ||
| 81 | + } | ||
| 82 | + | ||
| 74 | fun get(ctx: Context): AppDatabase = | 83 | fun get(ctx: Context): AppDatabase = |
| 75 | instance ?: synchronized(this) { | 84 | instance ?: synchronized(this) { |
| 76 | instance ?: Room.databaseBuilder( | 85 | instance ?: Room.databaseBuilder( |
| @@ -78,7 +87,7 @@ abstract class AppDatabase : RoomDatabase() { | @@ -78,7 +87,7 @@ abstract class AppDatabase : RoomDatabase() { | ||
| 78 | AppDatabase::class.java, | 87 | AppDatabase::class.java, |
| 79 | "lifelog.db", | 88 | "lifelog.db", |
| 80 | ) | 89 | ) |
| 81 | - .addMigrations(MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5) | 90 | + .addMigrations(MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6) |
| 82 | // 不再 fallbackToDestructiveMigration:以后改 schema 必须写 Migration, | 91 | // 不再 fallbackToDestructiveMigration:以后改 schema 必须写 Migration, |
| 83 | // 否则启动直接抛 IllegalStateException(fail-loud),绝不静默清掉真实录音。 | 92 | // 否则启动直接抛 IllegalStateException(fail-loud),绝不静默清掉真实录音。 |
| 84 | .build() | 93 | .build() |
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Daos.kt
| @@ -109,6 +109,22 @@ interface SegmentDao { | @@ -109,6 +109,22 @@ interface SegmentDao { | ||
| 109 | ) | 109 | ) |
| 110 | fun observeTimeline(limit: Int = 100): Flow<List<SegmentRow>> | 110 | fun observeTimeline(limit: Int = 100): Flow<List<SegmentRow>> |
| 111 | 111 | ||
| 112 | + /** 某声纹(说话人)名下的录音段(人物档案页"录音列表"用)。 */ | ||
| 113 | + @Query( | ||
| 114 | + """ | ||
| 115 | + SELECT s.id AS id, s.startTs AS startTs, s.durationMs AS durationMs, | ||
| 116 | + t.text AS text, s.audioPath AS audioPath, | ||
| 117 | + s.speakerId AS speakerId, s.speakerSim AS speakerSim, sp.name AS speakerName | ||
| 118 | + FROM segments s | ||
| 119 | + LEFT JOIN transcripts t ON t.segmentId = s.id | ||
| 120 | + LEFT JOIN speaker_profiles sp ON sp.id = s.speakerId | ||
| 121 | + WHERE s.speakerId = :speakerId | ||
| 122 | + ORDER BY s.startTs DESC | ||
| 123 | + LIMIT :limit | ||
| 124 | + """, | ||
| 125 | + ) | ||
| 126 | + fun observeBySpeaker(speakerId: Long, limit: Int = 500): Flow<List<SegmentRow>> | ||
| 127 | + | ||
| 112 | /** 一次性取最近时间轴(RAG 检索用,非 Flow)。 */ | 128 | /** 一次性取最近时间轴(RAG 检索用,非 Flow)。 */ |
| 113 | @Query( | 129 | @Query( |
| 114 | """ | 130 | """ |
| @@ -225,9 +241,24 @@ interface PersonDao { | @@ -225,9 +241,24 @@ interface PersonDao { | ||
| 225 | @Query("SELECT * FROM persons WHERE id = :id") | 241 | @Query("SELECT * FROM persons WHERE id = :id") |
| 226 | suspend fun get(id: Long): Person? | 242 | suspend fun get(id: Long): Person? |
| 227 | 243 | ||
| 244 | + @Query("SELECT * FROM persons WHERE id = :id") | ||
| 245 | + fun observe(id: Long): Flow<Person?> | ||
| 246 | + | ||
| 228 | @Query("SELECT * FROM persons WHERE contactRef = :ref LIMIT 1") | 247 | @Query("SELECT * FROM persons WHERE contactRef = :ref LIMIT 1") |
| 229 | suspend fun getByContactRef(ref: String): Person? | 248 | suspend fun getByContactRef(ref: String): Person? |
| 230 | 249 | ||
| 250 | + /** 刷新微信资料(enroll upsert 用,不动 faceEmbedding/voiceSpeakerId)。 */ | ||
| 251 | + @Query( | ||
| 252 | + """ | ||
| 253 | + UPDATE persons SET name=:name, nickname=:nickname, remark=:remark, alias=:alias, | ||
| 254 | + pinyin=:pinyin, pyInitial=:pyInitial, avatarPath=:avatarPath WHERE id=:id | ||
| 255 | + """, | ||
| 256 | + ) | ||
| 257 | + suspend fun updateContactInfo( | ||
| 258 | + id: Long, name: String, nickname: String?, remark: String?, alias: String?, | ||
| 259 | + pinyin: String?, pyInitial: String?, avatarPath: String?, | ||
| 260 | + ) | ||
| 261 | + | ||
| 231 | /** 有人脸锚点的人(匹配 gallery 用)。 */ | 262 | /** 有人脸锚点的人(匹配 gallery 用)。 */ |
| 232 | @Query("SELECT * FROM persons WHERE faceEmbedding IS NOT NULL") | 263 | @Query("SELECT * FROM persons WHERE faceEmbedding IS NOT NULL") |
| 233 | suspend fun anchors(): List<Person> | 264 | suspend fun anchors(): List<Person> |
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Entities.kt
| @@ -97,6 +97,13 @@ data class Person( | @@ -97,6 +97,13 @@ data class Person( | ||
| 97 | val avatarPath: String? = null, | 97 | val avatarPath: String? = null, |
| 98 | /** 人脸锚点 embedding(512维 LE bytes,来自头像或标注样本),用于自动认人;null = 还没有 */ | 98 | /** 人脸锚点 embedding(512维 LE bytes,来自头像或标注样本),用于自动认人;null = 还没有 */ |
| 99 | val faceEmbedding: ByteArray? = null, | 99 | val faceEmbedding: ByteArray? = null, |
| 100 | + // ----- 微信资料(通讯录展示 + 拼音索引) ----- | ||
| 101 | + val nickname: String? = null, // 微信昵称 | ||
| 102 | + val remark: String? = null, // 备注名 | ||
| 103 | + val alias: String? = null, // 微信号 | ||
| 104 | + val pinyin: String? = null, // 全拼(排序) | ||
| 105 | + val pyInitial: String? = null, // 首字母(A–Z 索引) | ||
| 106 | + val phone: String? = null, // 手机号(未来短信关联,先占位) | ||
| 100 | val createdAt: Long = System.currentTimeMillis(), | 107 | val createdAt: Long = System.currentTimeMillis(), |
| 101 | ) | 108 | ) |
| 102 | 109 |
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/vision/WeChatEnrollWorker.kt
| @@ -11,12 +11,15 @@ import java.io.File | @@ -11,12 +11,15 @@ import java.io.File | ||
| 11 | import java.util.concurrent.atomic.AtomicBoolean | 11 | import java.util.concurrent.atomic.AtomicBoolean |
| 12 | 12 | ||
| 13 | /** | 13 | /** |
| 14 | - * 把微信联系人头像建成人脸锚点:读 filesDir/wx_enroll/enroll.json([{username,name}])+ | ||
| 15 | - * avatars/{username}.jpg,对每张头像用**端上 FaceEngine** 检测,恰 1 张脸就建 Person | ||
| 16 | - * (contactRef=wechat:username, avatarPath, faceEmbedding)。之后照片人脸自动认出联系人。 | 14 | + * 微信联系人 enroll:读 filesDir/wx_enroll/enroll.json([{username,name,nickname,remark, |
| 15 | + * alias,pyInitial,quanPin,hasFace}])+ avatars/{username}.jpg。 | ||
| 17 | * | 16 | * |
| 18 | - * 必须用端上 FaceEngine(与照片人脸同一引擎)才能跨比;不能用 Mac insightface 的 embedding。 | ||
| 19 | - * 单轮有时间预算,超了 retry 续;按 contactRef 幂等。 | 17 | + * Pass 1(快,无引擎):给**所有真人联系人**建/更新 Person(微信资料 + 拼音索引 + 头像)。 |
| 18 | + * → 通讯录里全员可见,不只是有脸的。 | ||
| 19 | + * Pass 2(端上 FaceEngine,预算+retry):对头像有脸(hasFace)且还没锚点的,算 faceEmbedding。 | ||
| 20 | + * → 照片自动认人。必须端上同引擎才能跟照片人脸比。 | ||
| 21 | + * | ||
| 22 | + * 按 contactRef 幂等:再跑刷新资料、补未完成的人脸。 | ||
| 20 | */ | 23 | */ |
| 21 | class WeChatEnrollWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx, params) { | 24 | class WeChatEnrollWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx, params) { |
| 22 | 25 | ||
| @@ -25,46 +28,64 @@ class WeChatEnrollWorker(ctx: Context, params: WorkerParameters) : CoroutineWork | @@ -25,46 +28,64 @@ class WeChatEnrollWorker(ctx: Context, params: WorkerParameters) : CoroutineWork | ||
| 25 | try { | 28 | try { |
| 26 | val dir = File(applicationContext.filesDir, "wx_enroll") | 29 | val dir = File(applicationContext.filesDir, "wx_enroll") |
| 27 | val manifest = File(dir, "enroll.json") | 30 | val manifest = File(dir, "enroll.json") |
| 28 | - if (!manifest.exists()) { | ||
| 29 | - Log.w(TAG, "no enroll.json at ${manifest.path}") | ||
| 30 | - return Result.success() | ||
| 31 | - } | 31 | + if (!manifest.exists()) return Result.success() |
| 32 | val arr = JSONArray(manifest.readText()) | 32 | val arr = JSONArray(manifest.readText()) |
| 33 | val repo = Repository(applicationContext) | 33 | val repo = Repository(applicationContext) |
| 34 | + | ||
| 35 | + // ---- Pass 1: 全员资料 upsert ---- | ||
| 36 | + var created = 0 | ||
| 37 | + for (i in 0 until arr.length()) { | ||
| 38 | + val o = arr.getJSONObject(i) | ||
| 39 | + val username = o.optString("username") | ||
| 40 | + if (username.isBlank()) continue | ||
| 41 | + val ref = "wechat:$username" | ||
| 42 | + val name = o.optString("name").ifBlank { username } | ||
| 43 | + val avatar = File(dir, "avatars/$username.jpg") | ||
| 44 | + val avatarPath = if (avatar.exists()) avatar.absolutePath else null | ||
| 45 | + val nick = o.optString("nickname").ifBlank { null } | ||
| 46 | + val remark = o.optString("remark").ifBlank { null } | ||
| 47 | + val alias = o.optString("alias").ifBlank { null } | ||
| 48 | + val py = o.optString("quanPin").ifBlank { null } | ||
| 49 | + val pyInit = o.optString("pyInitial").ifBlank { null } | ||
| 50 | + val ex = repo.persons.getByContactRef(ref) | ||
| 51 | + if (ex == null) { | ||
| 52 | + repo.persons.insert( | ||
| 53 | + Person( | ||
| 54 | + name = name, contactRef = ref, avatarPath = avatarPath, | ||
| 55 | + nickname = nick, remark = remark, alias = alias, pinyin = py, pyInitial = pyInit, | ||
| 56 | + ), | ||
| 57 | + ) | ||
| 58 | + created++ | ||
| 59 | + } else { | ||
| 60 | + repo.persons.updateContactInfo(ex.id, name, nick, remark, alias, py, pyInit, avatarPath) | ||
| 61 | + } | ||
| 62 | + } | ||
| 63 | + Log.i(TAG, "资料 upsert:新建 $created(共 ${arr.length()})") | ||
| 64 | + | ||
| 65 | + // ---- Pass 2: 人脸锚点(仅 hasFace 且未建锚点)---- | ||
| 34 | val engine = FaceEngine.load(applicationContext) | 66 | val engine = FaceEngine.load(applicationContext) |
| 35 | - var enrolled = 0 | ||
| 36 | - var scanned = 0 | 67 | + var anchored = 0 |
| 37 | var hitDeadline = false | 68 | var hitDeadline = false |
| 38 | val deadline = System.currentTimeMillis() + RUN_BUDGET_MS | 69 | val deadline = System.currentTimeMillis() + RUN_BUDGET_MS |
| 39 | try { | 70 | try { |
| 40 | for (i in 0 until arr.length()) { | 71 | for (i in 0 until arr.length()) { |
| 41 | - if (System.currentTimeMillis() > deadline) { hitDeadline = true; break } | ||
| 42 | val o = arr.getJSONObject(i) | 72 | val o = arr.getJSONObject(i) |
| 73 | + if (!o.optBoolean("hasFace")) continue | ||
| 74 | + if (System.currentTimeMillis() > deadline) { hitDeadline = true; break } | ||
| 43 | val username = o.optString("username") | 75 | val username = o.optString("username") |
| 44 | - val name = o.optString("name").ifBlank { username } | ||
| 45 | - val ref = "wechat:$username" | ||
| 46 | - if (repo.persons.getByContactRef(ref) != null) continue // 幂等 | ||
| 47 | - | 76 | + val p = repo.persons.getByContactRef("wechat:$username") ?: continue |
| 77 | + if (p.faceEmbedding != null) continue | ||
| 48 | val avatar = File(dir, "avatars/$username.jpg") | 78 | val avatar = File(dir, "avatars/$username.jpg") |
| 49 | if (!avatar.exists()) continue | 79 | if (!avatar.exists()) continue |
| 50 | val bmp = ImageUtil.decodeOriented(avatar.absolutePath, reqMax = 640) ?: continue | 80 | val bmp = ImageUtil.decodeOriented(avatar.absolutePath, reqMax = 640) ?: continue |
| 51 | - scanned++ | ||
| 52 | try { | 81 | try { |
| 53 | val det = engine.detect(bmp) | 82 | val det = engine.detect(bmp) |
| 54 | if (det.size == 1) { | 83 | if (det.size == 1) { |
| 55 | - val emb = engine.embed(bmp, det[0].kps) | ||
| 56 | - repo.persons.insert( | ||
| 57 | - Person( | ||
| 58 | - name = name, | ||
| 59 | - contactRef = ref, | ||
| 60 | - avatarPath = avatar.absolutePath, | ||
| 61 | - faceEmbedding = ImageUtil.packEmbedding(emb), | ||
| 62 | - ), | ||
| 63 | - ) | ||
| 64 | - enrolled++ | 84 | + repo.persons.updateFaceEmbedding(p.id, ImageUtil.packEmbedding(engine.embed(bmp, det[0].kps))) |
| 85 | + anchored++ | ||
| 65 | } | 86 | } |
| 66 | } catch (t: Throwable) { | 87 | } catch (t: Throwable) { |
| 67 | - Log.w(TAG, "enroll $username failed: ${t.message}") | 88 | + Log.w(TAG, "anchor $username failed: ${t.message}") |
| 68 | } finally { | 89 | } finally { |
| 69 | bmp.recycle() | 90 | bmp.recycle() |
| 70 | } | 91 | } |
| @@ -72,9 +93,8 @@ class WeChatEnrollWorker(ctx: Context, params: WorkerParameters) : CoroutineWork | @@ -72,9 +93,8 @@ class WeChatEnrollWorker(ctx: Context, params: WorkerParameters) : CoroutineWork | ||
| 72 | } finally { | 93 | } finally { |
| 73 | engine.close() | 94 | engine.close() |
| 74 | } | 95 | } |
| 75 | - // 用新锚点回填已处理照片里的未归属脸。 | ||
| 76 | FaceMatcher.backfillAll(repo) | 96 | FaceMatcher.backfillAll(repo) |
| 77 | - Log.i(TAG, "本轮 enroll $enrolled(扫 $scanned),deadline=$hitDeadline") | 97 | + Log.i(TAG, "本轮人脸锚点 +$anchored,deadline=$hitDeadline") |
| 78 | return if (hitDeadline) Result.retry() else Result.success() | 98 | return if (hitDeadline) Result.retry() else Result.success() |
| 79 | } catch (t: Throwable) { | 99 | } catch (t: Throwable) { |
| 80 | Log.e(TAG, "worker failed", t) | 100 | Log.e(TAG, "worker failed", t) |
| @@ -86,7 +106,6 @@ class WeChatEnrollWorker(ctx: Context, params: WorkerParameters) : CoroutineWork | @@ -86,7 +106,6 @@ class WeChatEnrollWorker(ctx: Context, params: WorkerParameters) : CoroutineWork | ||
| 86 | 106 | ||
| 87 | companion object { | 107 | companion object { |
| 88 | const val TAG = "WeChatEnroll" | 108 | const val TAG = "WeChatEnroll" |
| 89 | - /** 单轮预算 8min(端上 ~0.4s/张),超了 retry 续;WorkManager ~10min 上限内。 */ | ||
| 90 | const val RUN_BUDGET_MS = 8 * 60 * 1000L | 109 | const val RUN_BUDGET_MS = 8 * 60 * 1000L |
| 91 | private val isRunning = AtomicBoolean(false) | 110 | private val isRunning = AtomicBoolean(false) |
| 92 | } | 111 | } |
server/services/wechat/extract-wechat-avatars.py
| @@ -24,6 +24,8 @@ import sys, os, json, subprocess, urllib.request, concurrent.futures | @@ -24,6 +24,8 @@ import sys, os, json, subprocess, urllib.request, concurrent.futures | ||
| 24 | REAL_PERSON_SQL = """ | 24 | REAL_PERSON_SQL = """ |
| 25 | SELECT r.username AS username, | 25 | SELECT r.username AS username, |
| 26 | COALESCE(NULLIF(r.conRemark,''), NULLIF(r.nickname,''), r.username) AS name, | 26 | COALESCE(NULLIF(r.conRemark,''), NULLIF(r.nickname,''), r.username) AS name, |
| 27 | + r.nickname AS nickname, r.conRemark AS remark, r.alias AS alias, | ||
| 28 | + r.pyInitial AS pyInitial, r.quanPin AS quanPin, | ||
| 27 | f.reserved1 AS avatar | 29 | f.reserved1 AS avatar |
| 28 | FROM rcontact r JOIN img_flag f ON f.username=r.username | 30 | FROM rcontact r JOIN img_flag f ON f.username=r.username |
| 29 | WHERE r.verifyFlag=0 | 31 | WHERE r.verifyFlag=0 |