diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PersonProfileScreen.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PersonProfileScreen.kt new file mode 100644 index 0000000..39d994e --- /dev/null +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PersonProfileScreen.kt @@ -0,0 +1,238 @@ +package com.lemon.lifelog + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Card +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import com.lemon.lifelog.audio.SegmentPlayer +import com.lemon.lifelog.data.Person +import com.lemon.lifelog.data.SpeakerProfile +import java.io.File +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +private val PROFILE_FMT = SimpleDateFormat("MM-dd HH:mm", Locale.US) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun PersonProfileScreen( + vm: PhotologViewModel, + personId: Long, + onBack: () -> Unit, + onOpenPhoto: (Long) -> Unit, +) { + val person by vm.repo.persons.observe(personId).collectAsState(initial = null) + val photos by vm.repo.photos.observePhotosOfPerson(personId).collectAsState(initial = emptyList()) + val speakers by vm.repo.speakers.observeAll().collectAsState(initial = emptyList()) + val sid = person?.voiceSpeakerId + val audio by remember(sid) { + if (sid == null) kotlinx.coroutines.flow.flowOf(emptyList()) + else vm.repo.segments.observeBySpeaker(sid) + }.collectAsState(initial = emptyList()) + + var bindVoice by remember { mutableStateOf(false) } + val player = remember { SegmentPlayer() } + var playingId by remember { mutableStateOf(null) } + DisposableEffect(player) { onDispose { player.stop() } } + + Column(modifier = Modifier.fillMaxSize()) { + TopAppBar( + title = { Text(person?.name ?: "联系人", maxLines = 1, overflow = TextOverflow.Ellipsis) }, + navigationIcon = { + IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "返回") } + }, + ) + LazyColumn(modifier = Modifier.fillMaxSize()) { + // ----- 资料头 ----- + item { + person?.let { p -> + Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp)) { + if (p.avatarPath != null) { + AsyncImage( + model = File(p.avatarPath), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.size(72.dp).clip(RoundedCornerShape(12.dp)), + ) + } else { + Box(Modifier.size(72.dp).clip(RoundedCornerShape(12.dp))) {} + } + Column(modifier = Modifier.weight(1f)) { + Text(p.name, style = MaterialTheme.typography.titleLarge, maxLines = 1, overflow = TextOverflow.Ellipsis) + p.nickname?.takeIf { it != p.name }?.let { Text("微信名:$it", style = MaterialTheme.typography.bodySmall) } + } + } + Spacer(Modifier.height(10.dp)) + InfoRow("备注", p.remark) + InfoRow("微信号", p.alias) + InfoRow("手机号", p.phone) + // 声纹绑定 + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + val sName = speakers.firstOrNull { it.id == p.voiceSpeakerId }?.name + Text("声纹:${sName ?: "未绑定"}", style = MaterialTheme.typography.bodyMedium) + OutlinedButton(onClick = { bindVoice = true }) { Text(if (p.voiceSpeakerId == null) "绑定" else "改绑") } + } + } + HorizontalDivider() + } + } + // ----- 照片 ----- + item { + SectionHeader("照片", photos.size) + if (photos.isEmpty()) { + Text("还没有关联照片。导入照片后会自动认出 TA。", style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp)) + } else { + LazyRow( + modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp), + contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 12.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + items(photos, key = { it.id }) { ph -> + AsyncImage( + model = File(ph.path), + contentDescription = ph.caption, + contentScale = ContentScale.Crop, + modifier = Modifier.size(96.dp).clip(RoundedCornerShape(8.dp)).clickable { onOpenPhoto(ph.id) }, + ) + } + } + } + HorizontalDivider() + } + // ----- 录音 ----- + item { SectionHeader("录音", audio.size) } + if (sid == null) { + item { + Text("绑定声纹后,这里会列出 TA 说过的录音。", style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp)) + } + } else if (audio.isEmpty()) { + item { Text("这个声纹名下还没有录音段。", style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp)) } + } else { + items(audio, key = { it.id }) { row -> + Row( + modifier = Modifier.fillMaxWidth().clickable { + if (playingId == row.id) { player.stop(); playingId = null } + else { playingId = row.id; player.play(row.audioPath) { playingId = null } } + }.padding(horizontal = 16.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text(if (playingId == row.id) "■" else "▶", style = MaterialTheme.typography.titleMedium) + Column(modifier = Modifier.weight(1f)) { + Text(PROFILE_FMT.format(Date(row.startTs)) + " · ${"%.1fs".format(row.durationMs / 1000.0)}", style = MaterialTheme.typography.labelSmall) + Text(row.text ?: "(无转写)", style = MaterialTheme.typography.bodyMedium, maxLines = 2, overflow = TextOverflow.Ellipsis) + } + } + } + } + item { Spacer(Modifier.height(24.dp)) } + } + } + + if (bindVoice) { + BindVoiceDialog( + speakers = speakers, + current = person?.voiceSpeakerId, + onDismiss = { bindVoice = false }, + onPick = { vm.bindVoice(personId, it); bindVoice = false }, + onClear = { vm.bindVoice(personId, null); bindVoice = false }, + ) + } +} + +@Composable +private fun InfoRow(label: String, value: String?) { + if (value.isNullOrBlank()) return + Row(modifier = Modifier.fillMaxWidth().padding(vertical = 3.dp), horizontalArrangement = Arrangement.spacedBy(10.dp)) { + Text(label, style = MaterialTheme.typography.bodySmall, modifier = Modifier.width(56.dp)) + Text(value, style = MaterialTheme.typography.bodyMedium) + } +} + +@Composable +private fun SectionHeader(title: String, count: Int) { + Text( + "$title($count)", + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), + ) +} + +@Composable +private fun BindVoiceDialog( + speakers: List, + current: Long?, + onDismiss: () -> Unit, + onPick: (Long) -> Unit, + onClear: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("绑定声纹") }, + text = { + if (speakers.isEmpty()) { + Text("还没有声纹。去「纪要 → 声纹」录一段或聚类后再绑。") + } else { + LazyColumn(modifier = Modifier.height(360.dp)) { + items(speakers, key = { it.id }) { sp -> + Row( + modifier = Modifier.fillMaxWidth().clickable { onPick(sp.id) }.padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton(selected = current == sp.id, onClick = { onPick(sp.id) }) + Text(sp.name) + } + } + } + } + }, + confirmButton = { + if (current != null) TextButton(onClick = onClear) { Text("解绑") } + }, + dismissButton = { TextButton(onClick = onDismiss) { Text("取消") } }, + ) +} diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologScreen.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologScreen.kt index 533eda3..7c60e68 100644 --- a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologScreen.kt +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologScreen.kt @@ -3,6 +3,8 @@ package com.lemon.lifelog import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.PickVisualMediaRequest import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -11,9 +13,15 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.ui.draw.clip +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid @@ -74,6 +82,7 @@ internal fun PhotologTab(repo: Repository) { var view by remember { mutableStateOf(0) } // 0=照片 1=人物 var filterPersonId by remember { mutableStateOf(null) } var detailPhotoId by remember { mutableStateOf(null) } + var profilePersonId by remember { mutableStateOf(null) } val persons by vm.repo.persons.observeAll().collectAsState(initial = emptyList()) val pendingCount by vm.repo.photos.observeUnprocessedCount().collectAsState(initial = 0) @@ -88,6 +97,19 @@ internal fun PhotologTab(repo: Repository) { ActivityResultContracts.PickMultipleVisualMedia(30), ) { uris -> vm.importUris(uris) } + val pid = profilePersonId + if (pid != null) { + PersonProfileScreen( + vm = vm, personId = pid, + onBack = { profilePersonId = null }, + onOpenPhoto = { detailPhotoId = it }, + ) + detailPhotoId?.let { id -> + PhotoDetailSheet(vm = vm, photoId = id, persons = persons, onDismiss = { detailPhotoId = null }) + } + return + } + Column(modifier = Modifier.fillMaxSize()) { TabRow(selectedTabIndex = view) { Tab(selected = view == 0, onClick = { view = 0 }, text = { Text("照片") }) @@ -133,10 +155,10 @@ internal fun PhotologTab(repo: Repository) { } } } else { - PeopleList( + ContactList( vm = vm, persons = persons, - onOpen = { id -> filterPersonId = id; view = 0 }, + onOpen = { id -> profilePersonId = id }, ) } } @@ -243,99 +265,156 @@ private fun TagFaceDialog( onClear: () -> Unit, ) { var newName by remember { mutableStateOf("") } - AlertDialog( - onDismissRequest = onDismiss, - title = { Text("这张脸是谁?") }, - text = { - Column { - Text("标注后会自动在其它照片里找同一个人。", style = MaterialTheme.typography.bodySmall) + var query by remember { mutableStateOf("") } + val filtered = remember(persons, query) { + if (query.isBlank()) persons + else persons.filter { it.name.contains(query.trim(), ignoreCase = true) } + } + Dialog(onDismissRequest = onDismiss, properties = DialogProperties(usePlatformDefaultWidth = false)) { + Surface(shape = RoundedCornerShape(16.dp), modifier = Modifier.fillMaxWidth(0.94f).fillMaxHeight(0.85f)) { + Column(modifier = Modifier.fillMaxSize().padding(16.dp)) { + Text("这张脸是谁?", style = MaterialTheme.typography.titleMedium) + Text("标注后会自动在其它照片里找同一个人。", style = MaterialTheme.typography.labelSmall) Spacer(Modifier.height(8.dp)) - persons.forEach { p -> - Row( - modifier = Modifier.fillMaxWidth().clickable { onPick(p.id) }.padding(vertical = 6.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - RadioButton(selected = current == p.id, onClick = { onPick(p.id) }) - Text(p.name) + OutlinedTextField( + value = query, onValueChange = { query = it }, + placeholder = { Text("搜索联系人(共 ${persons.size})…") }, + singleLine = true, modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(4.dp)) + LazyColumn(modifier = Modifier.weight(1f).fillMaxWidth()) { + items(filtered, key = { it.id }) { p -> + Row( + modifier = Modifier.fillMaxWidth().clickable { onPick(p.id) }.padding(vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + RadioButton(selected = current == p.id, onClick = { onPick(p.id) }) + if (p.avatarPath != null) { + AsyncImage( + model = File(p.avatarPath), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.size(36.dp).clip(CircleShape), + ) + } + Text(p.name, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f)) + if (p.contactRef != null) Text("微信", style = MaterialTheme.typography.labelSmall) + } + } + if (filtered.isEmpty()) { + item { Text("没有匹配的联系人", style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(8.dp)) } } } - Spacer(Modifier.height(4.dp)) + HorizontalDivider() + Spacer(Modifier.height(8.dp)) OutlinedTextField( value = newName, onValueChange = { newName = it }, label = { Text("或新建一个人(名字)") }, singleLine = true, modifier = Modifier.fillMaxWidth(), ) - if (current != null) { - TextButton(onClick = onClear) { Text("清除归属") } + Spacer(Modifier.height(8.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + TextButton(onClick = onDismiss) { Text("取消") } + if (current != null) TextButton(onClick = onClear) { Text("清除归属") } + } + Button(onClick = { onCreate(newName) }, enabled = newName.isNotBlank()) { Text("新建并标注") } } } - }, - confirmButton = { - TextButton(onClick = { onCreate(newName) }, enabled = newName.isNotBlank()) { Text("新建并标注") } - }, - dismissButton = { TextButton(onClick = onDismiss) { Text("取消") } }, - ) + } + } } +/** 通讯录样式:搜索 + 拼音 A–Z 分组 + 头像,点进档案页。 */ +@OptIn(ExperimentalFoundationApi::class) @Composable -private fun PeopleList(vm: PhotologViewModel, persons: List, onOpen: (Long) -> Unit) { - var renameFor by remember { mutableStateOf(null) } +private fun ContactList(vm: PhotologViewModel, persons: List, onOpen: (Long) -> Unit) { val enrollReady = remember { vm.wechatEnrollReady() } var enrollTapped by remember { mutableStateOf(false) } + var query by remember { mutableStateOf("") } + + val sections = remember(persons, query) { + val q = query.trim() + val filtered = if (q.isBlank()) persons else persons.filter { p -> + p.name.contains(q, true) || p.nickname?.contains(q, true) == true || + p.remark?.contains(q, true) == true || p.alias?.contains(q, true) == true || + p.pinyin?.contains(q, true) == true + } + filtered.sortedWith(compareBy({ sectionLetter(it) }, { it.pinyin ?: it.name })) + .groupBy { sectionLetter(it) } + .toSortedMap(compareBy { if (it == "#") "[" else it }) // # 排最后 + } Column(modifier = Modifier.fillMaxSize()) { + OutlinedTextField( + value = query, onValueChange = { query = it }, + placeholder = { Text("搜索联系人(共 ${persons.size})") }, singleLine = true, + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 6.dp), + ) if (enrollReady) { Row( - modifier = Modifier.fillMaxWidth().padding(12.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp).padding(bottom = 6.dp), + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { Button(onClick = { vm.enrollWeChat(); enrollTapped = true }) { Text("从微信导入联系人") } Text( - if (enrollTapped) "后台建锚点中,人物会陆续出现…" else "用头像建人脸锚点,照片自动认人", + if (enrollTapped) "后台导入中,联系人会陆续出现…" else "导入全部真人联系人", style = MaterialTheme.typography.labelSmall, ) } - HorizontalDivider() } + HorizontalDivider() if (persons.isEmpty()) { Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) { - Text("还没有人物。点上方「从微信导入联系人」,或去「照片」里点一张脸标注。", style = MaterialTheme.typography.bodyMedium) + Text("还没有联系人。点「从微信导入联系人」,或在照片里标注人脸。", style = MaterialTheme.typography.bodyMedium) } } else { - LazyColumn(modifier = Modifier.fillMaxSize().padding(12.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { - items(persons, key = { it.id }) { p -> - val count by produceState(0, p.id) { value = withContext(Dispatchers.IO) { vm.repo.faces.countForPerson(p.id) } } - Card(modifier = Modifier.fillMaxWidth().clickable { onOpen(p.id) }) { - Row( - modifier = Modifier.fillMaxWidth().padding(14.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Column(modifier = Modifier.weight(1f)) { - Text(p.name, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.titleSmall) - Text( - "$count 张人脸" + - (if (p.contactRef != null) " · 微信" else "") + - (if (p.voiceSpeakerId != null) " · 声纹" else ""), - style = MaterialTheme.typography.labelSmall, - ) - } - TextButton(onClick = { renameFor = p }) { Text("重命名") } + LazyColumn(modifier = Modifier.fillMaxSize()) { + sections.forEach { (letter, group) -> + stickyHeader { + Surface(color = MaterialTheme.colorScheme.surfaceVariant, modifier = Modifier.fillMaxWidth()) { + Text(letter, style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(horizontal = 16.dp, vertical = 3.dp)) } } + items(group, key = { it.id }) { p -> ContactRow(p) { onOpen(p.id) } } } } } } - renameFor?.let { p -> - var name by remember { mutableStateOf(p.name) } - AlertDialog( - onDismissRequest = { renameFor = null }, - title = { Text("重命名") }, - text = { OutlinedTextField(value = name, onValueChange = { name = it }, singleLine = true) }, - confirmButton = { TextButton(onClick = { vm.renamePerson(p.id, name); renameFor = null }, enabled = name.isNotBlank()) { Text("保存") } }, - dismissButton = { TextButton(onClick = { renameFor = null }) { Text("取消") } }, - ) +} + +private fun sectionLetter(p: Person): String { + val c = (p.pyInitial?.firstOrNull() ?: p.name.firstOrNull())?.uppercaseChar() + return if (c != null && c in 'A'..'Z') c.toString() else "#" +} + +@Composable +private fun ContactRow(p: Person, onClick: () -> Unit) { + Row( + modifier = Modifier.fillMaxWidth().clickable(onClick = onClick).padding(horizontal = 16.dp, vertical = 7.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + if (p.avatarPath != null) { + AsyncImage( + model = File(p.avatarPath), contentDescription = null, contentScale = ContentScale.Crop, + modifier = Modifier.size(44.dp).clip(RoundedCornerShape(6.dp)), + ) + } else { + Box(modifier = Modifier.size(44.dp).clip(RoundedCornerShape(6.dp)).background(MaterialTheme.colorScheme.surfaceVariant)) + } + Column(modifier = Modifier.weight(1f)) { + Text(p.name, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.titleSmall) + val tags = listOfNotNull( + if (p.faceEmbedding != null) "人脸" else null, + if (p.voiceSpeakerId != null) "声纹" else null, + ) + if (tags.isNotEmpty()) Text(tags.joinToString(" · "), style = MaterialTheme.typography.labelSmall) + } } } diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologViewModel.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologViewModel.kt index 5f6cacf..f7c08a4 100644 --- a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologViewModel.kt +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologViewModel.kt @@ -74,6 +74,11 @@ class PhotologViewModel(app: Application) : AndroidViewModel(app) { viewModelScope.launch { repo.persons.updateName(personId, name.trim()) } } + /** 融合:把一个声纹绑到联系人(null = 解绑)。绑定后档案页"录音列表"才有内容。 */ + fun bindVoice(personId: Long, speakerId: Long?) { + viewModelScope.launch { repo.persons.linkVoice(personId, speakerId) } + } + /** 设备上是否已备好微信 enroll 数据(Mac 推过来的头像+清单)。 */ fun wechatEnrollReady(): Boolean = File(getApplication().filesDir, "wx_enroll/enroll.json").exists() diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/AppDatabase.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/AppDatabase.kt index f327cda..707b215 100644 --- a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/AppDatabase.kt +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/AppDatabase.kt @@ -20,7 +20,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase Photo::class, Face::class, ], - version = 5, + version = 6, exportSchema = false, ) abstract class AppDatabase : RoomDatabase() { @@ -71,6 +71,15 @@ abstract class AppDatabase : RoomDatabase() { } } + /** v0.7 photolog:persons 加微信资料字段(通讯录展示 + 拼音索引 + 手机号占位)。 */ + private val MIGRATION_5_6 = object : Migration(5, 6) { + override fun migrate(db: SupportSQLiteDatabase) { + for (col in listOf("nickname", "remark", "alias", "pinyin", "pyInitial", "phone")) { + db.execSQL("ALTER TABLE `persons` ADD COLUMN `$col` TEXT") + } + } + } + fun get(ctx: Context): AppDatabase = instance ?: synchronized(this) { instance ?: Room.databaseBuilder( @@ -78,7 +87,7 @@ abstract class AppDatabase : RoomDatabase() { AppDatabase::class.java, "lifelog.db", ) - .addMigrations(MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5) + .addMigrations(MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6) // 不再 fallbackToDestructiveMigration:以后改 schema 必须写 Migration, // 否则启动直接抛 IllegalStateException(fail-loud),绝不静默清掉真实录音。 .build() diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Daos.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Daos.kt index 9eb0ea9..e621296 100644 --- a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Daos.kt +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Daos.kt @@ -109,6 +109,22 @@ interface SegmentDao { ) fun observeTimeline(limit: Int = 100): Flow> + /** 某声纹(说话人)名下的录音段(人物档案页"录音列表"用)。 */ + @Query( + """ + SELECT s.id AS id, s.startTs AS startTs, s.durationMs AS durationMs, + t.text AS text, s.audioPath AS audioPath, + s.speakerId AS speakerId, s.speakerSim AS speakerSim, sp.name AS speakerName + FROM segments s + LEFT JOIN transcripts t ON t.segmentId = s.id + LEFT JOIN speaker_profiles sp ON sp.id = s.speakerId + WHERE s.speakerId = :speakerId + ORDER BY s.startTs DESC + LIMIT :limit + """, + ) + fun observeBySpeaker(speakerId: Long, limit: Int = 500): Flow> + /** 一次性取最近时间轴(RAG 检索用,非 Flow)。 */ @Query( """ @@ -225,9 +241,24 @@ interface PersonDao { @Query("SELECT * FROM persons WHERE id = :id") suspend fun get(id: Long): Person? + @Query("SELECT * FROM persons WHERE id = :id") + fun observe(id: Long): Flow + @Query("SELECT * FROM persons WHERE contactRef = :ref LIMIT 1") suspend fun getByContactRef(ref: String): Person? + /** 刷新微信资料(enroll upsert 用,不动 faceEmbedding/voiceSpeakerId)。 */ + @Query( + """ + UPDATE persons SET name=:name, nickname=:nickname, remark=:remark, alias=:alias, + pinyin=:pinyin, pyInitial=:pyInitial, avatarPath=:avatarPath WHERE id=:id + """, + ) + suspend fun updateContactInfo( + id: Long, name: String, nickname: String?, remark: String?, alias: String?, + pinyin: String?, pyInitial: String?, avatarPath: String?, + ) + /** 有人脸锚点的人(匹配 gallery 用)。 */ @Query("SELECT * FROM persons WHERE faceEmbedding IS NOT NULL") suspend fun anchors(): List diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Entities.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Entities.kt index 1285db4..0b10422 100644 --- a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Entities.kt +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Entities.kt @@ -97,6 +97,13 @@ data class Person( val avatarPath: String? = null, /** 人脸锚点 embedding(512维 LE bytes,来自头像或标注样本),用于自动认人;null = 还没有 */ val faceEmbedding: ByteArray? = null, + // ----- 微信资料(通讯录展示 + 拼音索引) ----- + val nickname: String? = null, // 微信昵称 + val remark: String? = null, // 备注名 + val alias: String? = null, // 微信号 + val pinyin: String? = null, // 全拼(排序) + val pyInitial: String? = null, // 首字母(A–Z 索引) + val phone: String? = null, // 手机号(未来短信关联,先占位) val createdAt: Long = System.currentTimeMillis(), ) diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/vision/WeChatEnrollWorker.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/vision/WeChatEnrollWorker.kt index a7b8ff2..3638e51 100644 --- a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/vision/WeChatEnrollWorker.kt +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/vision/WeChatEnrollWorker.kt @@ -11,12 +11,15 @@ import java.io.File import java.util.concurrent.atomic.AtomicBoolean /** - * 把微信联系人头像建成人脸锚点:读 filesDir/wx_enroll/enroll.json([{username,name}])+ - * avatars/{username}.jpg,对每张头像用**端上 FaceEngine** 检测,恰 1 张脸就建 Person - * (contactRef=wechat:username, avatarPath, faceEmbedding)。之后照片人脸自动认出联系人。 + * 微信联系人 enroll:读 filesDir/wx_enroll/enroll.json([{username,name,nickname,remark, + * alias,pyInitial,quanPin,hasFace}])+ avatars/{username}.jpg。 * - * 必须用端上 FaceEngine(与照片人脸同一引擎)才能跨比;不能用 Mac insightface 的 embedding。 - * 单轮有时间预算,超了 retry 续;按 contactRef 幂等。 + * Pass 1(快,无引擎):给**所有真人联系人**建/更新 Person(微信资料 + 拼音索引 + 头像)。 + * → 通讯录里全员可见,不只是有脸的。 + * Pass 2(端上 FaceEngine,预算+retry):对头像有脸(hasFace)且还没锚点的,算 faceEmbedding。 + * → 照片自动认人。必须端上同引擎才能跟照片人脸比。 + * + * 按 contactRef 幂等:再跑刷新资料、补未完成的人脸。 */ class WeChatEnrollWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx, params) { @@ -25,46 +28,64 @@ class WeChatEnrollWorker(ctx: Context, params: WorkerParameters) : CoroutineWork try { val dir = File(applicationContext.filesDir, "wx_enroll") val manifest = File(dir, "enroll.json") - if (!manifest.exists()) { - Log.w(TAG, "no enroll.json at ${manifest.path}") - return Result.success() - } + if (!manifest.exists()) return Result.success() val arr = JSONArray(manifest.readText()) val repo = Repository(applicationContext) + + // ---- Pass 1: 全员资料 upsert ---- + var created = 0 + for (i in 0 until arr.length()) { + val o = arr.getJSONObject(i) + val username = o.optString("username") + if (username.isBlank()) continue + val ref = "wechat:$username" + val name = o.optString("name").ifBlank { username } + val avatar = File(dir, "avatars/$username.jpg") + val avatarPath = if (avatar.exists()) avatar.absolutePath else null + val nick = o.optString("nickname").ifBlank { null } + val remark = o.optString("remark").ifBlank { null } + val alias = o.optString("alias").ifBlank { null } + val py = o.optString("quanPin").ifBlank { null } + val pyInit = o.optString("pyInitial").ifBlank { null } + val ex = repo.persons.getByContactRef(ref) + if (ex == null) { + repo.persons.insert( + Person( + name = name, contactRef = ref, avatarPath = avatarPath, + nickname = nick, remark = remark, alias = alias, pinyin = py, pyInitial = pyInit, + ), + ) + created++ + } else { + repo.persons.updateContactInfo(ex.id, name, nick, remark, alias, py, pyInit, avatarPath) + } + } + Log.i(TAG, "资料 upsert:新建 $created(共 ${arr.length()})") + + // ---- Pass 2: 人脸锚点(仅 hasFace 且未建锚点)---- val engine = FaceEngine.load(applicationContext) - var enrolled = 0 - var scanned = 0 + var anchored = 0 var hitDeadline = false val deadline = System.currentTimeMillis() + RUN_BUDGET_MS try { for (i in 0 until arr.length()) { - if (System.currentTimeMillis() > deadline) { hitDeadline = true; break } val o = arr.getJSONObject(i) + if (!o.optBoolean("hasFace")) continue + if (System.currentTimeMillis() > deadline) { hitDeadline = true; break } val username = o.optString("username") - val name = o.optString("name").ifBlank { username } - val ref = "wechat:$username" - if (repo.persons.getByContactRef(ref) != null) continue // 幂等 - + val p = repo.persons.getByContactRef("wechat:$username") ?: continue + if (p.faceEmbedding != null) continue val avatar = File(dir, "avatars/$username.jpg") if (!avatar.exists()) continue val bmp = ImageUtil.decodeOriented(avatar.absolutePath, reqMax = 640) ?: continue - scanned++ try { val det = engine.detect(bmp) if (det.size == 1) { - val emb = engine.embed(bmp, det[0].kps) - repo.persons.insert( - Person( - name = name, - contactRef = ref, - avatarPath = avatar.absolutePath, - faceEmbedding = ImageUtil.packEmbedding(emb), - ), - ) - enrolled++ + repo.persons.updateFaceEmbedding(p.id, ImageUtil.packEmbedding(engine.embed(bmp, det[0].kps))) + anchored++ } } catch (t: Throwable) { - Log.w(TAG, "enroll $username failed: ${t.message}") + Log.w(TAG, "anchor $username failed: ${t.message}") } finally { bmp.recycle() } @@ -72,9 +93,8 @@ class WeChatEnrollWorker(ctx: Context, params: WorkerParameters) : CoroutineWork } finally { engine.close() } - // 用新锚点回填已处理照片里的未归属脸。 FaceMatcher.backfillAll(repo) - Log.i(TAG, "本轮 enroll $enrolled(扫 $scanned),deadline=$hitDeadline") + Log.i(TAG, "本轮人脸锚点 +$anchored,deadline=$hitDeadline") return if (hitDeadline) Result.retry() else Result.success() } catch (t: Throwable) { Log.e(TAG, "worker failed", t) @@ -86,7 +106,6 @@ class WeChatEnrollWorker(ctx: Context, params: WorkerParameters) : CoroutineWork companion object { const val TAG = "WeChatEnroll" - /** 单轮预算 8min(端上 ~0.4s/张),超了 retry 续;WorkManager ~10min 上限内。 */ const val RUN_BUDGET_MS = 8 * 60 * 1000L private val isRunning = AtomicBoolean(false) } diff --git a/server/services/wechat/extract-wechat-avatars.py b/server/services/wechat/extract-wechat-avatars.py index 693f201..05b75c5 100644 --- a/server/services/wechat/extract-wechat-avatars.py +++ b/server/services/wechat/extract-wechat-avatars.py @@ -24,6 +24,8 @@ import sys, os, json, subprocess, urllib.request, concurrent.futures REAL_PERSON_SQL = """ SELECT r.username AS username, COALESCE(NULLIF(r.conRemark,''), NULLIF(r.nickname,''), r.username) AS name, + r.nickname AS nickname, r.conRemark AS remark, r.alias AS alias, + r.pyInitial AS pyInitial, r.quanPin AS quanPin, f.reserved1 AS avatar FROM rcontact r JOIN img_flag f ON f.username=r.username WHERE r.verifyFlag=0