Commit eb0a52e4f94a00baa93df6068aac12623a4aa0c2

Authored by yibo
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>
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