Commit 7a40a4a7083e95474c89d10996feae4cd5fb43b4
1 parent
c6699b26
feat(lifelog/photolog): 人物一键下载微信头像+朋友圈(纯端上 root)+ 相册按人物分组
朋友圈/头像全部端上化,不经 Mac: - WeChatSnsImporter:su --mount-master 读明文 SnsMicroMsg.db + sns 缓存大图(snsb_*)。 坑:app 申请的 su 默认在本 app 挂载命名空间(看不到微信数据)→ 必须 --mount-master 切全局; 且 app-su 的 PATH 为空 → 脚本须 export PATH。按「条」归属:content 子串匹配 media id。 - 人物档案「⬇下载图片」:头像→照片(归属脸,进「照片」tab);朋友圈**按条**存 moments/<wxid>/<ms>_<i>.jpg + 文字进 moments 表;朋友圈图不进相册。幂等(getByPath/clearUser/ 目录清空)+清理旧版误导入的 moment_ 照片。 - 朋友圈 tab 改成微信式「时间+正文+九宫格图」(MomentRow 按 createTime glob 回该条的图)。 - 相册加「人物文件夹(默认)/所有图片」两 tab(observePersonPhotoCounts,复用 faces.personId)。 并带入并行进行中的端上 CRM(人↔人共现 Relation/社群):AppDatabase v17 + Entities/Daos/ CommunitiesScreen/crm.RelationAnalysis —— 与本功能在 Daos/迁移链上耦合,无法干净拆分,整树可编译。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Showing
9 changed files
with
711 additions
and
24 deletions
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/CommunitiesScreen.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.fillMaxSize | |
| 9 | +import androidx.compose.foundation.layout.fillMaxWidth | |
| 10 | +import androidx.compose.foundation.layout.padding | |
| 11 | +import androidx.compose.foundation.lazy.LazyColumn | |
| 12 | +import androidx.compose.foundation.lazy.items | |
| 13 | +import androidx.compose.material.icons.Icons | |
| 14 | +import androidx.compose.material.icons.automirrored.filled.ArrowBack | |
| 15 | +import androidx.compose.material3.Card | |
| 16 | +import androidx.compose.material3.DropdownMenu | |
| 17 | +import androidx.compose.material3.DropdownMenuItem | |
| 18 | +import androidx.compose.material3.ExperimentalMaterial3Api | |
| 19 | +import androidx.compose.material3.HorizontalDivider | |
| 20 | +import androidx.compose.material3.Icon | |
| 21 | +import androidx.compose.material3.IconButton | |
| 22 | +import androidx.compose.material3.MaterialTheme | |
| 23 | +import androidx.compose.material3.OutlinedButton | |
| 24 | +import androidx.compose.material3.Text | |
| 25 | +import androidx.compose.material3.TopAppBar | |
| 26 | +import androidx.compose.runtime.Composable | |
| 27 | +import androidx.compose.runtime.collectAsState | |
| 28 | +import androidx.compose.runtime.getValue | |
| 29 | +import androidx.compose.runtime.mutableStateOf | |
| 30 | +import androidx.compose.runtime.remember | |
| 31 | +import androidx.compose.runtime.setValue | |
| 32 | +import androidx.compose.runtime.LaunchedEffect | |
| 33 | +import androidx.compose.ui.Alignment | |
| 34 | +import androidx.compose.ui.Modifier | |
| 35 | +import androidx.compose.ui.unit.dp | |
| 36 | +import com.lemon.lifelog.crm.RelationAnalysis | |
| 37 | +import com.lemon.lifelog.data.Person | |
| 38 | + | |
| 39 | +/** | |
| 40 | + * 关系分析页:上半「关系链」(两人最短共现路径),下半「圈子」(连通分量社群)。 | |
| 41 | + * 数据来自 relations 表(「重新扫描」后由 RelationBuilder 产)。点人进档案。 | |
| 42 | + */ | |
| 43 | +@OptIn(ExperimentalMaterial3Api::class) | |
| 44 | +@Composable | |
| 45 | +internal fun CommunitiesScreen( | |
| 46 | + vm: PhotologViewModel, | |
| 47 | + onBack: () -> Unit, | |
| 48 | + onOpenPerson: (Long) -> Unit, | |
| 49 | +) { | |
| 50 | + val persons by vm.repo.persons.observeAll().collectAsState(initial = emptyList()) | |
| 51 | + val managed by vm.repo.persons.observeManaged().collectAsState(initial = emptyList()) | |
| 52 | + val nameById = remember(persons) { persons.associate { it.id to it.name } } | |
| 53 | + var comms by remember { mutableStateOf<List<RelationAnalysis.Community>?>(null) } | |
| 54 | + LaunchedEffect(Unit) { comms = RelationAnalysis.communities(vm.repo) } | |
| 55 | + | |
| 56 | + Column(Modifier.fillMaxSize()) { | |
| 57 | + TopAppBar( | |
| 58 | + title = { Text("关系") }, | |
| 59 | + navigationIcon = { | |
| 60 | + IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "返回") } | |
| 61 | + }, | |
| 62 | + ) | |
| 63 | + LazyColumn(Modifier.fillMaxSize().padding(horizontal = 12.dp)) { | |
| 64 | + item { PathFinder(managed, nameById, vm, onOpenPerson) } | |
| 65 | + item { | |
| 66 | + Text("圈子", style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(top = 12.dp, bottom = 4.dp)) | |
| 67 | + } | |
| 68 | + val list = comms | |
| 69 | + when { | |
| 70 | + list == null -> item { HintBox("分析中…") } | |
| 71 | + list.isEmpty() -> item { | |
| 72 | + HintBox("还没有圈子。圈子来自录音会话里同时出现/被提到的人——把几个人加入「我管理的人物」、到档案「被提到」tab 点「重新扫描」后再来看。") | |
| 73 | + } | |
| 74 | + else -> items(list) { c -> | |
| 75 | + Card(Modifier.fillMaxWidth().padding(vertical = 6.dp)) { | |
| 76 | + Column(Modifier.padding(12.dp)) { | |
| 77 | + Text("${c.memberIds.size} 人 · ${c.edgeCount} 条共现", style = MaterialTheme.typography.labelMedium) | |
| 78 | + HorizontalDivider(Modifier.padding(vertical = 6.dp)) | |
| 79 | + c.memberIds.forEach { id -> | |
| 80 | + PersonLine(nameById[id] ?: "#$id") { onOpenPerson(id) } | |
| 81 | + } | |
| 82 | + } | |
| 83 | + } | |
| 84 | + } | |
| 85 | + } | |
| 86 | + } | |
| 87 | + } | |
| 88 | +} | |
| 89 | + | |
| 90 | +/** 关系链查找:选两人 → BFS 最短共现路径,渲染 A → B → C。 */ | |
| 91 | +@Composable | |
| 92 | +private fun PathFinder( | |
| 93 | + managed: List<Person>, | |
| 94 | + nameById: Map<Long, String>, | |
| 95 | + vm: PhotologViewModel, | |
| 96 | + onOpenPerson: (Long) -> Unit, | |
| 97 | +) { | |
| 98 | + var fromId by remember { mutableStateOf<Long?>(null) } | |
| 99 | + var toId by remember { mutableStateOf<Long?>(null) } | |
| 100 | + var path by remember { mutableStateOf<List<Long>?>(null) } | |
| 101 | + LaunchedEffect(fromId, toId) { | |
| 102 | + val a = fromId; val b = toId | |
| 103 | + path = if (a != null && b != null) RelationAnalysis.shortestPath(vm.repo, a, b) else null | |
| 104 | + } | |
| 105 | + Card(Modifier.fillMaxWidth().padding(top = 8.dp)) { | |
| 106 | + Column(Modifier.padding(12.dp)) { | |
| 107 | + Text("关系链", style = MaterialTheme.typography.titleMedium) | |
| 108 | + Text("看两个人是怎么通过共现连起来的。", style = MaterialTheme.typography.labelSmall) | |
| 109 | + Row(Modifier.padding(top = 8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) { | |
| 110 | + PersonPicker("从", fromId, managed) { fromId = it } | |
| 111 | + PersonPicker("到", toId, managed) { toId = it } | |
| 112 | + } | |
| 113 | + val p = path | |
| 114 | + when { | |
| 115 | + fromId == null || toId == null -> {} | |
| 116 | + p == null -> {} | |
| 117 | + p.isEmpty() -> Text("两人之间还没有共现路径(不连通)。", style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(top = 8.dp)) | |
| 118 | + else -> Column(Modifier.padding(top = 8.dp)) { | |
| 119 | + if (p.size == 2) Text("直接共现。", style = MaterialTheme.typography.labelSmall) | |
| 120 | + else Text("经 ${p.size - 2} 人中转:", style = MaterialTheme.typography.labelSmall) | |
| 121 | + p.forEachIndexed { i, id -> | |
| 122 | + Row(verticalAlignment = Alignment.CenterVertically) { | |
| 123 | + if (i > 0) Text("↓ ", style = MaterialTheme.typography.bodySmall) | |
| 124 | + Text( | |
| 125 | + nameById[id] ?: "#$id", | |
| 126 | + style = MaterialTheme.typography.bodyMedium, | |
| 127 | + color = MaterialTheme.colorScheme.primary, | |
| 128 | + modifier = Modifier.clickable { onOpenPerson(id) }.padding(vertical = 2.dp), | |
| 129 | + ) | |
| 130 | + } | |
| 131 | + } | |
| 132 | + } | |
| 133 | + } | |
| 134 | + } | |
| 135 | + } | |
| 136 | +} | |
| 137 | + | |
| 138 | +@Composable | |
| 139 | +private fun PersonPicker(label: String, selectedId: Long?, persons: List<Person>, onPick: (Long) -> Unit) { | |
| 140 | + var open by remember { mutableStateOf(false) } | |
| 141 | + Box { | |
| 142 | + OutlinedButton(onClick = { open = true }) { | |
| 143 | + Text(persons.firstOrNull { it.id == selectedId }?.name ?: label) | |
| 144 | + } | |
| 145 | + DropdownMenu(expanded = open, onDismissRequest = { open = false }) { | |
| 146 | + persons.forEach { p -> | |
| 147 | + DropdownMenuItem(text = { Text(p.name) }, onClick = { onPick(p.id); open = false }) | |
| 148 | + } | |
| 149 | + } | |
| 150 | + } | |
| 151 | +} | |
| 152 | + | |
| 153 | +@Composable | |
| 154 | +private fun PersonLine(name: String, onClick: () -> Unit) { | |
| 155 | + Row( | |
| 156 | + Modifier.fillMaxWidth().clickable(onClick = onClick).padding(vertical = 8.dp), | |
| 157 | + horizontalArrangement = Arrangement.spacedBy(10.dp), | |
| 158 | + verticalAlignment = Alignment.CenterVertically, | |
| 159 | + ) { | |
| 160 | + Text("👤") | |
| 161 | + Text(name, Modifier.weight(1f), style = MaterialTheme.typography.bodyLarge) | |
| 162 | + Text("›", style = MaterialTheme.typography.titleMedium) | |
| 163 | + } | |
| 164 | +} | |
| 165 | + | |
| 166 | +@Composable | |
| 167 | +private fun HintBox(text: String) { | |
| 168 | + Box(Modifier.fillMaxWidth().padding(24.dp), contentAlignment = Alignment.Center) { | |
| 169 | + Text(text, style = MaterialTheme.typography.bodyMedium) | |
| 170 | + } | |
| 171 | +} | ... | ... |
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PersonProfileScreen.kt
| ... | ... | @@ -52,8 +52,10 @@ import androidx.compose.runtime.remember |
| 52 | 52 | import androidx.compose.runtime.setValue |
| 53 | 53 | import androidx.compose.ui.Alignment |
| 54 | 54 | import androidx.compose.ui.Modifier |
| 55 | +import android.widget.Toast | |
| 55 | 56 | import androidx.compose.ui.draw.clip |
| 56 | 57 | import androidx.compose.ui.graphics.Brush |
| 58 | +import androidx.compose.ui.platform.LocalContext | |
| 57 | 59 | import androidx.compose.ui.layout.ContentScale |
| 58 | 60 | import androidx.compose.ui.text.style.TextOverflow |
| 59 | 61 | import androidx.compose.ui.unit.dp |
| ... | ... | @@ -81,6 +83,7 @@ internal fun PersonProfileScreen( |
| 81 | 83 | onRequestMerge: () -> Unit, |
| 82 | 84 | onOpenPerson: (Long) -> Unit, |
| 83 | 85 | ) { |
| 86 | + val ctx = LocalContext.current | |
| 84 | 87 | val person by vm.repo.persons.observe(personId).collectAsState(initial = null) |
| 85 | 88 | val photos by vm.repo.photos.observePhotosOfPerson(personId).collectAsState(initial = emptyList()) |
| 86 | 89 | val speakers by vm.repo.speakers.observeAll().collectAsState(initial = emptyList()) |
| ... | ... | @@ -141,6 +144,12 @@ internal fun PersonProfileScreen( |
| 141 | 144 | onEnrich = { vm.enrichImpression(personId) }, |
| 142 | 145 | onEditProfile = { editProfile = true }, |
| 143 | 146 | onMerge = onRequestMerge, |
| 147 | + onDownload = { | |
| 148 | + Toast.makeText(ctx, "下载中…(读微信本地缓存,首次需授予 root)", Toast.LENGTH_SHORT).show() | |
| 149 | + vm.downloadWeChatImages(personId) { _, msg -> | |
| 150 | + Toast.makeText(ctx, msg ?: "完成", Toast.LENGTH_LONG).show() | |
| 151 | + } | |
| 152 | + }, | |
| 144 | 153 | ) |
| 145 | 154 | } |
| 146 | 155 | OutlinedTextField( |
| ... | ... | @@ -183,7 +192,7 @@ internal fun PersonProfileScreen( |
| 183 | 192 | else LazyColumn(Modifier.fillMaxSize()) { items(fMsgs, key = { it.id }) { m -> ChatBubble(m) } } |
| 184 | 193 | 3 -> if (wxid == null) EmptyHint("非微信联系人。") |
| 185 | 194 | else if (fMoments.isEmpty()) EmptyHint(if (q.isBlank()) "没有 TA 的朋友圈记录。" else "没有匹配的朋友圈。") |
| 186 | - else LazyColumn(Modifier.fillMaxSize()) { items(fMoments, key = { it.id }) { mo -> MomentRow(mo) } } | |
| 195 | + else LazyColumn(Modifier.fillMaxSize()) { items(fMoments, key = { it.id }) { mo -> MomentRow(mo, wxid.orEmpty()) } } | |
| 187 | 196 | // 被提到:别人录音里提到 TA 的段(按声纹=谁说的 展示)。 |
| 188 | 197 | 4 -> Column(Modifier.fillMaxSize()) { |
| 189 | 198 | Row( |
| ... | ... | @@ -507,11 +516,35 @@ private fun RelatedRow(rp: RelatedPerson, onClick: () -> Unit) { |
| 507 | 516 | } |
| 508 | 517 | } |
| 509 | 518 | |
| 519 | +/** 朋友圈一条:文字 + 九宫格图片 + 时间(图从 moments/<wxid>/<createTime>_*.jpg 按 createTime glob 回)。 */ | |
| 510 | 520 | @Composable |
| 511 | -private fun MomentRow(m: Moment) { | |
| 512 | - Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp)) { | |
| 513 | - Text(PROFILE_FMT.format(Date(m.createTime)), style = MaterialTheme.typography.labelSmall) | |
| 514 | - Text(m.text, style = MaterialTheme.typography.bodyMedium) | |
| 521 | +private fun MomentRow(m: Moment, wxid: String) { | |
| 522 | + val ctx = LocalContext.current | |
| 523 | + val images = remember(m.id, wxid) { | |
| 524 | + if (wxid.isBlank()) emptyList() | |
| 525 | + else File(ctx.filesDir, "moments/$wxid") | |
| 526 | + .listFiles { f -> f.name.startsWith("${m.createTime}_") }?.sortedBy { it.name }?.toList().orEmpty() | |
| 527 | + } | |
| 528 | + Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp)) { | |
| 529 | + if (m.text.isNotBlank()) Text(m.text, style = MaterialTheme.typography.bodyMedium) | |
| 530 | + if (images.isNotEmpty()) { | |
| 531 | + Spacer(Modifier.height(6.dp)) | |
| 532 | + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { | |
| 533 | + images.chunked(3).forEach { row -> | |
| 534 | + Row(horizontalArrangement = Arrangement.spacedBy(4.dp), modifier = Modifier.fillMaxWidth()) { | |
| 535 | + row.forEach { f -> | |
| 536 | + AsyncImage( | |
| 537 | + model = f, contentDescription = null, contentScale = ContentScale.Crop, | |
| 538 | + modifier = Modifier.weight(1f).aspectRatio(1f).clip(RoundedCornerShape(6.dp)), | |
| 539 | + ) | |
| 540 | + } | |
| 541 | + repeat(3 - row.size) { Spacer(Modifier.weight(1f)) } | |
| 542 | + } | |
| 543 | + } | |
| 544 | + } | |
| 545 | + } | |
| 546 | + Spacer(Modifier.height(6.dp)) | |
| 547 | + Text(PROFILE_FMT.format(Date(m.createTime)), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) | |
| 515 | 548 | } |
| 516 | 549 | } |
| 517 | 550 | ... | ... |
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologScreen.kt
| ... | ... | @@ -98,6 +98,7 @@ internal fun ContactsTab(repo: Repository) { |
| 98 | 98 | var detailPhotoId by remember { mutableStateOf<Long?>(null) } |
| 99 | 99 | var showPicker by remember { mutableStateOf(false) } |
| 100 | 100 | var showPhonePicker by remember { mutableStateOf(false) } |
| 101 | + var showCommunities by remember { mutableStateOf(false) } | |
| 101 | 102 | var mergeForId by remember { mutableStateOf<Long?>(null) } |
| 102 | 103 | // 人物 tab 只显示「我管理的人物」;全部微信联系人是池子(在选人页里)。 |
| 103 | 104 | val managed by vm.repo.persons.observeManaged().collectAsState(initial = emptyList()) |
| ... | ... | @@ -114,6 +115,11 @@ internal fun ContactsTab(repo: Repository) { |
| 114 | 115 | onOpenPerson = { vm.touchPerson(it); profilePersonId = it }, |
| 115 | 116 | ) |
| 116 | 117 | showPhonePicker -> PhoneContactPickerScreen(vm = vm, onBack = { showPhonePicker = false }) |
| 118 | + showCommunities -> CommunitiesScreen( | |
| 119 | + vm = vm, | |
| 120 | + onBack = { showCommunities = false }, | |
| 121 | + onOpenPerson = { vm.touchPerson(it); showCommunities = false; profilePersonId = it }, | |
| 122 | + ) | |
| 117 | 123 | showPicker -> WeChatPickerScreen( |
| 118 | 124 | vm = vm, |
| 119 | 125 | onBack = { showPicker = false }, |
| ... | ... | @@ -123,6 +129,7 @@ internal fun ContactsTab(repo: Repository) { |
| 123 | 129 | vm = vm, persons = managed, |
| 124 | 130 | onOpen = { vm.touchPerson(it); profilePersonId = it }, |
| 125 | 131 | onOpenPicker = { showPicker = true }, |
| 132 | + onOpenCommunities = { showCommunities = true }, | |
| 126 | 133 | ) |
| 127 | 134 | } |
| 128 | 135 | detailPhotoId?.let { id -> |
| ... | ... | @@ -181,6 +188,20 @@ internal fun AlbumTab(repo: Repository) { |
| 181 | 188 | |
| 182 | 189 | val shown = results ?: photos |
| 183 | 190 | |
| 191 | + // 两个子 tab:0=人物文件夹(默认) 1=所有图片。搜索时两者都让位给搜索结果。 | |
| 192 | + var albumTab by remember { mutableStateOf(0) } | |
| 193 | + var folderPerson by remember { mutableStateOf<Long?>(null) } | |
| 194 | + val personCounts by vm.repo.photos.observePersonPhotoCounts().collectAsState(initial = emptyList()) | |
| 195 | + val folders = remember(personCounts, persons) { | |
| 196 | + val nameOf = persons.associateBy { it.id } | |
| 197 | + personCounts.filter { it.count > 0 } | |
| 198 | + .mapNotNull { c -> nameOf[c.personId]?.let { Triple(it.id, it, c.count) } } | |
| 199 | + .sortedByDescending { it.third } | |
| 200 | + } | |
| 201 | + val folderPhotos by remember(folderPerson) { | |
| 202 | + folderPerson?.let { vm.repo.photos.observePhotosOfPerson(it) } ?: flowOf(emptyList()) | |
| 203 | + }.collectAsState(initial = emptyList()) | |
| 204 | + | |
| 184 | 205 | Column(modifier = Modifier.fillMaxSize()) { |
| 185 | 206 | // 权限 + 进度。 |
| 186 | 207 | if (!granted) { |
| ... | ... | @@ -235,28 +256,38 @@ internal fun AlbumTab(repo: Repository) { |
| 235 | 256 | }) { Text("+ 手动导入") } |
| 236 | 257 | searchMsg?.let { Text(it, style = MaterialTheme.typography.labelSmall) } |
| 237 | 258 | } |
| 259 | + // 子 tab(搜索时隐藏,直接显示搜索结果)。 | |
| 260 | + if (results == null) { | |
| 261 | + TabRow(selectedTabIndex = albumTab) { | |
| 262 | + Tab(selected = albumTab == 0, onClick = { albumTab = 0; folderPerson = null }, text = { Text("人物文件夹") }) | |
| 263 | + Tab(selected = albumTab == 1, onClick = { albumTab = 1 }, text = { Text("所有图片") }) | |
| 264 | + } | |
| 265 | + } | |
| 238 | 266 | HorizontalDivider() |
| 239 | - if (shown.isEmpty()) { | |
| 240 | - Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) { | |
| 241 | - Text( | |
| 242 | - when { | |
| 243 | - results != null -> "没搜到。换个词,或等场景描述(VL)跑得更多再搜。" | |
| 244 | - !granted -> "点上方「开启全相册索引」,自动把手机相册所有照片纳入。" | |
| 245 | - progress.total > 0 -> "正在索引 ${progress.total} 张…稍后刷新。" | |
| 246 | - else -> "还没有照片。开启全相册索引,或「+ 手动导入」。" | |
| 247 | - }, | |
| 248 | - style = MaterialTheme.typography.bodyMedium, | |
| 249 | - ) | |
| 267 | + when { | |
| 268 | + // 搜索结果优先 | |
| 269 | + results != null -> PhotoGrid(shown, vlCurrentId) { detailPhotoId = it } | |
| 270 | + // 所有图片 | |
| 271 | + albumTab == 1 -> if (photos.isEmpty()) AlbumEmptyHint(granted, progress.total) | |
| 272 | + else PhotoGrid(photos, vlCurrentId) { detailPhotoId = it } | |
| 273 | + // 人物文件夹 → 点进某人 → 该人照片 | |
| 274 | + folderPerson != null -> Column(Modifier.fillMaxSize()) { | |
| 275 | + val who = persons.firstOrNull { it.id == folderPerson }?.name ?: "TA" | |
| 276 | + TextButton(onClick = { folderPerson = null }) { Text("← 人物文件夹 · $who(${folderPhotos.size})") } | |
| 277 | + if (folderPhotos.isEmpty()) AlbumEmptyHint(granted, 0) | |
| 278 | + else PhotoGrid(folderPhotos, vlCurrentId) { detailPhotoId = it } | |
| 250 | 279 | } |
| 251 | - } else { | |
| 252 | - LazyVerticalGrid( | |
| 280 | + folders.isEmpty() -> Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) { | |
| 281 | + Text("还没有归到人物的照片。\n在照片里标注人脸、绑声纹,或在人物档案页「⬇ 下载图片」。", style = MaterialTheme.typography.bodyMedium) | |
| 282 | + } | |
| 283 | + else -> LazyVerticalGrid( | |
| 253 | 284 | columns = GridCells.Fixed(3), |
| 254 | 285 | modifier = Modifier.fillMaxSize().padding(horizontal = 4.dp), |
| 255 | 286 | verticalArrangement = Arrangement.spacedBy(4.dp), |
| 256 | 287 | horizontalArrangement = Arrangement.spacedBy(4.dp), |
| 257 | 288 | ) { |
| 258 | - gridItems(shown, key = { it.id }) { p -> | |
| 259 | - PhotoCell(p, processing = p.id == vlCurrentId) { detailPhotoId = p.id } | |
| 289 | + gridItems(folders, key = { it.first }) { (pid, person, count) -> | |
| 290 | + PersonFolderCell(person, count) { folderPerson = pid } | |
| 260 | 291 | } |
| 261 | 292 | } |
| 262 | 293 | } |
| ... | ... | @@ -326,6 +357,63 @@ private fun ProgressHeader( |
| 326 | 357 | } |
| 327 | 358 | |
| 328 | 359 | /** 角标:按 scanState/处理态显示「转圈 / 待理解 / 待索引」。 */ |
| 360 | +/** 照片网格(相册各视图共用)。 */ | |
| 361 | +@Composable | |
| 362 | +private fun PhotoGrid(rows: List<PhotoRow>, vlCurrentId: Long?, onOpen: (Long) -> Unit) { | |
| 363 | + LazyVerticalGrid( | |
| 364 | + columns = GridCells.Fixed(3), | |
| 365 | + modifier = Modifier.fillMaxSize().padding(horizontal = 4.dp), | |
| 366 | + verticalArrangement = Arrangement.spacedBy(4.dp), | |
| 367 | + horizontalArrangement = Arrangement.spacedBy(4.dp), | |
| 368 | + ) { | |
| 369 | + gridItems(rows, key = { it.id }) { p -> | |
| 370 | + PhotoCell(p, processing = p.id == vlCurrentId) { onOpen(p.id) } | |
| 371 | + } | |
| 372 | + } | |
| 373 | +} | |
| 374 | + | |
| 375 | +@Composable | |
| 376 | +private fun AlbumEmptyHint(granted: Boolean, total: Int) { | |
| 377 | + Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) { | |
| 378 | + Text( | |
| 379 | + when { | |
| 380 | + !granted -> "点上方「开启全相册索引」,自动把手机相册所有照片纳入。" | |
| 381 | + total > 0 -> "正在索引 $total 张…稍后刷新。" | |
| 382 | + else -> "还没有照片。开启全相册索引,或「+ 手动导入」。" | |
| 383 | + }, | |
| 384 | + style = MaterialTheme.typography.bodyMedium, | |
| 385 | + ) | |
| 386 | + } | |
| 387 | +} | |
| 388 | + | |
| 389 | +/** 人物文件夹封面格:头像 + 名字 + 照片数。 */ | |
| 390 | +@Composable | |
| 391 | +private fun PersonFolderCell(person: Person, count: Int, onClick: () -> Unit) { | |
| 392 | + Column( | |
| 393 | + modifier = Modifier.clickable(onClick = onClick).padding(4.dp), | |
| 394 | + horizontalAlignment = Alignment.CenterHorizontally, | |
| 395 | + ) { | |
| 396 | + Box(modifier = Modifier.aspectRatio(1f).fillMaxWidth().clip(RoundedCornerShape(10.dp)).background(MaterialTheme.colorScheme.surfaceVariant)) { | |
| 397 | + if (person.avatarPath != null) { | |
| 398 | + AsyncImage( | |
| 399 | + model = File(person.avatarPath), contentDescription = null, contentScale = ContentScale.Crop, | |
| 400 | + modifier = Modifier.fillMaxSize(), | |
| 401 | + ) | |
| 402 | + } else { | |
| 403 | + Text(person.name.take(1), modifier = Modifier.align(Alignment.Center), style = MaterialTheme.typography.headlineMedium) | |
| 404 | + } | |
| 405 | + Surface( | |
| 406 | + color = MaterialTheme.colorScheme.scrim.copy(alpha = 0.55f), | |
| 407 | + modifier = Modifier.align(Alignment.BottomEnd).padding(4.dp), | |
| 408 | + shape = RoundedCornerShape(50), | |
| 409 | + ) { | |
| 410 | + Text(" $count ", style = MaterialTheme.typography.labelSmall, color = androidx.compose.ui.graphics.Color.White) | |
| 411 | + } | |
| 412 | + } | |
| 413 | + Text(person.name, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.labelMedium) | |
| 414 | + } | |
| 415 | +} | |
| 416 | + | |
| 329 | 417 | @Composable |
| 330 | 418 | private fun PhotoCell(p: PhotoRow, processing: Boolean, onClick: () -> Unit) { |
| 331 | 419 | Box(modifier = Modifier.aspectRatio(1f).clickable(onClick = onClick)) { |
| ... | ... | @@ -552,7 +640,7 @@ private fun TagFaceDialog( |
| 552 | 640 | /** 「我管理的人物」列表:搜索 + 最近置顶 + 拼音 A–Z,点进档案页。顶部入口去「微信通讯录」选人。 */ |
| 553 | 641 | @OptIn(ExperimentalFoundationApi::class) |
| 554 | 642 | @Composable |
| 555 | -private fun ContactList(vm: PhotologViewModel, persons: List<Person>, onOpen: (Long) -> Unit, onOpenPicker: () -> Unit) { | |
| 643 | +private fun ContactList(vm: PhotologViewModel, persons: List<Person>, onOpen: (Long) -> Unit, onOpenPicker: () -> Unit, onOpenCommunities: () -> Unit) { | |
| 556 | 644 | var query by remember { mutableStateOf("") } |
| 557 | 645 | |
| 558 | 646 | val sections = remember(persons, query) { |
| ... | ... | @@ -582,7 +670,7 @@ private fun ContactList(vm: PhotologViewModel, persons: List<Person>, onOpen: (L |
| 582 | 670 | verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), |
| 583 | 671 | ) { |
| 584 | 672 | Button(onClick = onOpenPicker) { Text("📇 通讯录") } |
| 585 | - Text("从微信 / 手机通讯录勾选你关心的人加入管理", style = MaterialTheme.typography.labelSmall) | |
| 673 | + OutlinedButton(onClick = onOpenCommunities) { Text("🕸 圈子") } | |
| 586 | 674 | } |
| 587 | 675 | HorizontalDivider() |
| 588 | 676 | if (persons.isEmpty()) { | ... | ... |
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologViewModel.kt
| ... | ... | @@ -5,6 +5,8 @@ import android.net.Uri |
| 5 | 5 | import android.util.Log |
| 6 | 6 | import androidx.lifecycle.AndroidViewModel |
| 7 | 7 | import androidx.lifecycle.viewModelScope |
| 8 | +import com.lemon.lifelog.data.Face | |
| 9 | +import com.lemon.lifelog.data.Moment | |
| 8 | 10 | import com.lemon.lifelog.data.Person |
| 9 | 11 | import com.lemon.lifelog.data.Photo |
| 10 | 12 | import com.lemon.lifelog.data.PhotoRow |
| ... | ... | @@ -29,6 +31,7 @@ import com.lemon.lifelog.vision.ImageUtil |
| 29 | 31 | import com.lemon.lifelog.vision.PhotoScheduler |
| 30 | 32 | import com.lemon.lifelog.vision.PhotoVlState |
| 31 | 33 | import com.lemon.lifelog.vision.WeChatEnrollWorker |
| 34 | +import com.lemon.lifelog.vision.WeChatSnsImporter | |
| 32 | 35 | import kotlinx.coroutines.Dispatchers |
| 33 | 36 | import kotlinx.coroutines.Job |
| 34 | 37 | import kotlinx.coroutines.delay |
| ... | ... | @@ -441,6 +444,71 @@ class PhotologViewModel(app: Application) : AndroidViewModel(app) { |
| 441 | 444 | WorkManager.getInstance(getApplication()).enqueueUniqueWork("wx-enroll", ExistingWorkPolicy.KEEP, req) |
| 442 | 445 | } |
| 443 | 446 | |
| 447 | + /** | |
| 448 | + * 一键下载该人物的微信内容(**纯本地,不经 Mac**): | |
| 449 | + * - 头像:`wx_enroll/avatars/<wxid>.jpg` → 导入成「照片」(归属脸挂到人物),出现在档案页「照片」tab。 | |
| 450 | + * - 朋友圈:端上 su 读微信明文 sns 缓存,**按条**存(图存 `moments/<wxid>/<ms>_<i>.jpg`,文字进 moments 表); | |
| 451 | + * 朋友圈图**不进**「照片」相册(照片 tab 只放头像+真正认出的合照),由「朋友圈」tab 按条渲染。 | |
| 452 | + * 回调 (头像张数, 人读消息)。 | |
| 453 | + */ | |
| 454 | + fun downloadWeChatImages(personId: Long, onResult: (Int, String?) -> Unit) { | |
| 455 | + val app = getApplication<Application>() | |
| 456 | + viewModelScope.launch { | |
| 457 | + val (added, msg) = withContext(Dispatchers.IO) { | |
| 458 | + val p = repo.persons.get(personId) ?: return@withContext 0 to "人物不存在" | |
| 459 | + val wxid = p.contactRef?.removePrefix("wechat:")?.takeIf { p.contactRef.startsWith("wechat:") } | |
| 460 | + ?: return@withContext 0 to "不是微信联系人,无法下载" | |
| 461 | + val photosDir = File(app.filesDir, "photos").apply { mkdirs() } | |
| 462 | + | |
| 463 | + // 清掉旧版误倒进相册的朋友圈图(path 形如 photos/moment_<wxid>_*.jpg)+ 其归属脸。 | |
| 464 | + runCatching { | |
| 465 | + for (id in repo.photos.idsByPathLike("%/moment_${wxid}_%")) { | |
| 466 | + repo.faces.deleteByPhoto(id); repo.photos.delete(id) | |
| 467 | + } | |
| 468 | + } | |
| 469 | + | |
| 470 | + // 1) 头像 → 照片(归属脸)。 | |
| 471 | + var avatarN = 0 | |
| 472 | + val avatar = File(app.filesDir, "wx_enroll/avatars/$wxid.jpg") | |
| 473 | + if (avatar.exists()) { | |
| 474 | + val dst = File(photosDir, "wxavatar_$wxid.jpg") | |
| 475 | + if (repo.photos.getByPath(dst.absolutePath) == null) { | |
| 476 | + runCatching { avatar.copyTo(dst, overwrite = true); importOwnedPhoto(dst, personId, p.createdAt); avatarN++ } | |
| 477 | + } | |
| 478 | + } | |
| 479 | + | |
| 480 | + // 2) 朋友圈:图按条存到 moments/<wxid>/,文字进 moments 表(不进照片相册)。 | |
| 481 | + val momentsDir = File(app.filesDir, "moments/$wxid").apply { deleteRecursively(); mkdirs() } | |
| 482 | + val r = WeChatSnsImporter.importMomentsFor(app, wxid, momentsDir) | |
| 483 | + var momentN = 0 | |
| 484 | + var imgN = 0 | |
| 485 | + if (r.moments.isNotEmpty()) { | |
| 486 | + repo.moments.clearUser(wxid) | |
| 487 | + repo.moments.insertAll(r.moments.map { Moment(userName = wxid, text = it.text, createTime = it.createTimeMs) }) | |
| 488 | + momentN = r.moments.size | |
| 489 | + imgN = r.moments.sumOf { it.images.size } | |
| 490 | + } | |
| 491 | + | |
| 492 | + if (avatarN > 0 || momentN > 0) { | |
| 493 | + repo.persons.setManaged(personId, true) | |
| 494 | + repo.persons.touch(personId, System.currentTimeMillis()) | |
| 495 | + if (avatarN > 0) PhotoScheduler.runNow(app) // 让后台对头像跑人脸 | |
| 496 | + } | |
| 497 | + val ok = avatarN > 0 || momentN > 0 | |
| 498 | + Pair(avatarN, if (ok) "已下载头像 + $momentN 条朋友圈($imgN 张图)" else (r.error ?: "没有可下载的内容")) | |
| 499 | + } | |
| 500 | + onResult(added, msg) | |
| 501 | + } | |
| 502 | + } | |
| 503 | + | |
| 504 | + /** 插入一张 imported 照片 + 一条「归属脸」(detScore=-1 哨兵,无 embedding) 把它挂到 [personId]。 */ | |
| 505 | + private suspend fun importOwnedPhoto(file: File, personId: Long, takenTs: Long) { | |
| 506 | + val pid = repo.photos.insert(Photo(path = file.absolutePath, source = "imported", takenTs = takenTs)) | |
| 507 | + repo.faces.insert( | |
| 508 | + Face(photoId = pid, left = 0f, top = 0f, right = 0f, bottom = 0f, detScore = -1f, personId = personId), | |
| 509 | + ) | |
| 510 | + } | |
| 511 | + | |
| 444 | 512 | fun deletePhoto(photoId: Long) { |
| 445 | 513 | viewModelScope.launch { |
| 446 | 514 | withContext(Dispatchers.IO) { | ... | ... |
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/crm/RelationAnalysis.kt
0 → 100644
| 1 | +package com.lemon.lifelog.crm | |
| 2 | + | |
| 3 | +import com.lemon.lifelog.data.Repository | |
| 4 | +import kotlinx.coroutines.Dispatchers | |
| 5 | +import kotlinx.coroutines.withContext | |
| 6 | + | |
| 7 | +/** | |
| 8 | + * 端上 CRM #3 关系分析:在 [com.lemon.lifelog.data.Relation] 共现图上做纯算法分析。 | |
| 9 | + * - [communities]:连通分量(社群/圈子),把互相有共现的人圈成一团。 | |
| 10 | + * - [shortestPath]:两人间最短关系链(BFS,无向)——「A 和 B 怎么连上的」。 | |
| 11 | + * 都是无向图算法,权重仅用于内部排序(圈子按规模、成员按度数)。 | |
| 12 | + */ | |
| 13 | +object RelationAnalysis { | |
| 14 | + | |
| 15 | + /** 一个社群:成员 personId(按度数降序)、内部边数。 */ | |
| 16 | + data class Community(val memberIds: List<Long>, val edgeCount: Int) | |
| 17 | + | |
| 18 | + /** 连通分量。只保留 ≥ [minWeight] 的边、规模 ≥2 的团;按规模降序。 */ | |
| 19 | + suspend fun communities(repo: Repository, minWeight: Int = 1): List<Community> = withContext(Dispatchers.IO) { | |
| 20 | + val edges = repo.relations.allEdges().filter { it.weight >= minWeight } | |
| 21 | + if (edges.isEmpty()) return@withContext emptyList() | |
| 22 | + | |
| 23 | + // union-find | |
| 24 | + val parent = HashMap<Long, Long>() | |
| 25 | + fun find(x: Long): Long { | |
| 26 | + var r = x | |
| 27 | + while (parent[r] != r) r = parent[r]!! | |
| 28 | + var c = x | |
| 29 | + while (parent[c] != c) { val n = parent[c]!!; parent[c] = r; c = n } | |
| 30 | + return r | |
| 31 | + } | |
| 32 | + fun union(a: Long, b: Long) { | |
| 33 | + parent.getOrPut(a) { a }; parent.getOrPut(b) { b } | |
| 34 | + val ra = find(a); val rb = find(b) | |
| 35 | + if (ra != rb) parent[ra] = rb | |
| 36 | + } | |
| 37 | + val degree = HashMap<Long, Int>() | |
| 38 | + val edgesByRoot = HashMap<Long, Int>() | |
| 39 | + for (e in edges) { | |
| 40 | + union(e.personA, e.personB) | |
| 41 | + degree[e.personA] = (degree[e.personA] ?: 0) + 1 | |
| 42 | + degree[e.personB] = (degree[e.personB] ?: 0) + 1 | |
| 43 | + } | |
| 44 | + val groups = HashMap<Long, MutableList<Long>>() | |
| 45 | + for (node in parent.keys) groups.getOrPut(find(node)) { mutableListOf() }.add(node) | |
| 46 | + for (e in edges) { val r = find(e.personA); edgesByRoot[r] = (edgesByRoot[r] ?: 0) + 1 } | |
| 47 | + | |
| 48 | + groups.values | |
| 49 | + .filter { it.size >= 2 } | |
| 50 | + .map { members -> | |
| 51 | + Community( | |
| 52 | + memberIds = members.sortedByDescending { degree[it] ?: 0 }, | |
| 53 | + edgeCount = edgesByRoot[find(members[0])] ?: 0, | |
| 54 | + ) | |
| 55 | + } | |
| 56 | + .sortedByDescending { it.memberIds.size } | |
| 57 | + } | |
| 58 | + | |
| 59 | + /** [fromId]→[toId] 的最短关系链(含两端);不连通返回空。 */ | |
| 60 | + suspend fun shortestPath(repo: Repository, fromId: Long, toId: Long): List<Long> = withContext(Dispatchers.IO) { | |
| 61 | + if (fromId == toId) return@withContext listOf(fromId) | |
| 62 | + val adj = HashMap<Long, MutableList<Long>>() | |
| 63 | + for (e in repo.relations.allEdges()) { | |
| 64 | + adj.getOrPut(e.personA) { mutableListOf() }.add(e.personB) | |
| 65 | + adj.getOrPut(e.personB) { mutableListOf() }.add(e.personA) | |
| 66 | + } | |
| 67 | + if (fromId !in adj || toId !in adj) return@withContext emptyList() | |
| 68 | + val prev = HashMap<Long, Long>() | |
| 69 | + val seen = hashSetOf(fromId) | |
| 70 | + val queue = ArrayDeque<Long>().apply { add(fromId) } | |
| 71 | + while (queue.isNotEmpty()) { | |
| 72 | + val cur = queue.removeFirst() | |
| 73 | + if (cur == toId) { | |
| 74 | + val path = ArrayList<Long>() | |
| 75 | + var n: Long? = toId | |
| 76 | + while (n != null) { path.add(n); n = prev[n] } | |
| 77 | + return@withContext path.reversed() | |
| 78 | + } | |
| 79 | + for (nb in adj[cur].orEmpty()) if (seen.add(nb)) { prev[nb] = cur; queue.add(nb) } | |
| 80 | + } | |
| 81 | + emptyList() | |
| 82 | + } | |
| 83 | +} | ... | ... |
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/AppDatabase.kt
| ... | ... | @@ -24,8 +24,9 @@ import androidx.sqlite.db.SupportSQLiteDatabase |
| 24 | 24 | DeletedGallery::class, |
| 25 | 25 | Mention::class, |
| 26 | 26 | Relation::class, |
| 27 | + Followup::class, | |
| 27 | 28 | ], |
| 28 | - version = 17, | |
| 29 | + version = 18, | |
| 29 | 30 | exportSchema = false, |
| 30 | 31 | ) |
| 31 | 32 | abstract class AppDatabase : RoomDatabase() { |
| ... | ... | @@ -41,6 +42,7 @@ abstract class AppDatabase : RoomDatabase() { |
| 41 | 42 | abstract fun momentDao(): MomentDao |
| 42 | 43 | abstract fun mentionDao(): MentionDao |
| 43 | 44 | abstract fun relationDao(): RelationDao |
| 45 | + abstract fun followupDao(): FollowupDao | |
| 44 | 46 | |
| 45 | 47 | companion object { |
| 46 | 48 | @Volatile |
| ... | ... | @@ -185,6 +187,17 @@ abstract class AppDatabase : RoomDatabase() { |
| 185 | 187 | } |
| 186 | 188 | } |
| 187 | 189 | |
| 190 | + /** v0.9 端上 CRM #3:待跟进(followups)表。 */ | |
| 191 | + private val MIGRATION_17_18 = object : Migration(17, 18) { | |
| 192 | + override fun migrate(db: SupportSQLiteDatabase) { | |
| 193 | + db.execSQL( | |
| 194 | + "CREATE TABLE IF NOT EXISTS `followups` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `text` TEXT NOT NULL, `personId` INTEGER, `sourceTs` INTEGER NOT NULL, `done` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL)", | |
| 195 | + ) | |
| 196 | + db.execSQL("CREATE INDEX IF NOT EXISTS `index_followups_done` ON `followups` (`done`)") | |
| 197 | + db.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS `index_followups_sourceTs_text` ON `followups` (`sourceTs`, `text`)") | |
| 198 | + } | |
| 199 | + } | |
| 200 | + | |
| 188 | 201 | fun get(ctx: Context): AppDatabase = |
| 189 | 202 | instance ?: synchronized(this) { |
| 190 | 203 | instance ?: Room.databaseBuilder( |
| ... | ... | @@ -192,7 +205,7 @@ abstract class AppDatabase : RoomDatabase() { |
| 192 | 205 | AppDatabase::class.java, |
| 193 | 206 | "lifelog.db", |
| 194 | 207 | ) |
| 195 | - .addMigrations(MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_9, MIGRATION_9_10, MIGRATION_10_11, MIGRATION_11_12, MIGRATION_12_13, MIGRATION_13_14, MIGRATION_14_15, MIGRATION_15_16, MIGRATION_16_17) | |
| 208 | + .addMigrations(MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_9, MIGRATION_9_10, MIGRATION_10_11, MIGRATION_11_12, MIGRATION_12_13, MIGRATION_13_14, MIGRATION_14_15, MIGRATION_15_16, MIGRATION_16_17, MIGRATION_17_18) | |
| 196 | 209 | // 不再 fallbackToDestructiveMigration:以后改 schema 必须写 Migration, |
| 197 | 210 | // 否则启动直接抛 IllegalStateException(fail-loud),绝不静默清掉真实录音。 |
| 198 | 211 | .build() | ... | ... |
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Daos.kt
| ... | ... | @@ -336,6 +336,46 @@ data class RelatedPerson( |
| 336 | 336 | val lastTs: Long, |
| 337 | 337 | ) |
| 338 | 338 | |
| 339 | +/** 一条共现边(关系分析:社群发现 + 最短路径)。 */ | |
| 340 | +data class RelationEdge(val personA: Long, val personB: Long, val weight: Int) | |
| 341 | + | |
| 342 | +/** 待跟进行(含关联人物名)。 */ | |
| 343 | +data class FollowupRow( | |
| 344 | + val id: Long, | |
| 345 | + val text: String, | |
| 346 | + val personId: Long?, | |
| 347 | + val personName: String?, | |
| 348 | + val sourceTs: Long, | |
| 349 | + val done: Boolean, | |
| 350 | +) | |
| 351 | + | |
| 352 | +@Dao | |
| 353 | +interface FollowupDao { | |
| 354 | + @Insert(onConflict = OnConflictStrategy.IGNORE) | |
| 355 | + suspend fun insertAll(fs: List<Followup>) | |
| 356 | + | |
| 357 | + @Query("UPDATE followups SET done = :done WHERE id = :id") | |
| 358 | + suspend fun setDone(id: Long, done: Boolean) | |
| 359 | + | |
| 360 | + @Query("DELETE FROM followups WHERE id = :id") | |
| 361 | + suspend fun delete(id: Long) | |
| 362 | + | |
| 363 | + @Query("SELECT COUNT(*) FROM followups WHERE done = 0") | |
| 364 | + fun observeOpenCount(): Flow<Int> | |
| 365 | + | |
| 366 | + /** 未完成在前、按来源时间倒序。 */ | |
| 367 | + @Query( | |
| 368 | + """ | |
| 369 | + SELECT f.id AS id, f.text AS text, f.personId AS personId, p.name AS personName, | |
| 370 | + f.sourceTs AS sourceTs, f.done AS done | |
| 371 | + FROM followups f | |
| 372 | + LEFT JOIN persons p ON p.id = f.personId | |
| 373 | + ORDER BY f.done ASC, f.sourceTs DESC | |
| 374 | + """, | |
| 375 | + ) | |
| 376 | + fun observeAll(): Flow<List<FollowupRow>> | |
| 377 | +} | |
| 378 | + | |
| 339 | 379 | @Dao |
| 340 | 380 | interface RelationDao { |
| 341 | 381 | @Insert(onConflict = OnConflictStrategy.IGNORE) |
| ... | ... | @@ -347,6 +387,14 @@ interface RelationDao { |
| 347 | 387 | @Query("SELECT COUNT(*) FROM relations WHERE personA = :pid OR personB = :pid") |
| 348 | 388 | fun observeCountForPerson(pid: Long): Flow<Int> |
| 349 | 389 | |
| 390 | + /** 全部边(关系分析:社群发现 + 最短路径)。 */ | |
| 391 | + @Query("SELECT personA, personB, weight FROM relations") | |
| 392 | + suspend fun allEdges(): List<RelationEdge> | |
| 393 | + | |
| 394 | + /** 总边数(圈子入口角标)。 */ | |
| 395 | + @Query("SELECT COUNT(*) FROM relations") | |
| 396 | + fun observeTotal(): Flow<Int> | |
| 397 | + | |
| 350 | 398 | /** 与某人有共现边的人(取边的另一端),按权重降序。 */ |
| 351 | 399 | @Query( |
| 352 | 400 | """ |
| ... | ... | @@ -400,6 +448,12 @@ data class PhotoVec( |
| 400 | 448 | val captionEmbedding: ByteArray, |
| 401 | 449 | ) |
| 402 | 450 | |
| 451 | +/** 某人物名下照片数(相册「人物文件夹」封面 + 计数)。 */ | |
| 452 | +data class PersonPhotoCount( | |
| 453 | + val personId: Long, | |
| 454 | + val count: Int, | |
| 455 | +) | |
| 456 | + | |
| 403 | 457 | /** 相册分层进度(进度条用)。 */ |
| 404 | 458 | data class PhotoProgress( |
| 405 | 459 | val total: Int, |
| ... | ... | @@ -587,6 +641,10 @@ interface PhotoDao { |
| 587 | 641 | ) |
| 588 | 642 | fun observeGrid(limit: Int = 500): Flow<List<PhotoRow>> |
| 589 | 643 | |
| 644 | + /** 每个人物名下的照片数(相册「人物文件夹」用)。 */ | |
| 645 | + @Query("SELECT personId AS personId, COUNT(DISTINCT photoId) AS count FROM faces WHERE personId IS NOT NULL GROUP BY personId") | |
| 646 | + fun observePersonPhotoCounts(): Flow<List<PersonPhotoCount>> | |
| 647 | + | |
| 590 | 648 | /** 某个人出现的所有照片(faces.personId join)。 */ |
| 591 | 649 | @Query( |
| 592 | 650 | """ |
| ... | ... | @@ -630,6 +688,10 @@ interface PhotoDao { |
| 630 | 688 | |
| 631 | 689 | @Query("DELETE FROM photos WHERE id = :id") |
| 632 | 690 | suspend fun delete(id: Long) |
| 691 | + | |
| 692 | + /** 按 path 模糊查 id(清理旧版误导入的朋友圈图用,pat 形如 "%/moment_<wxid>_%")。 */ | |
| 693 | + @Query("SELECT id FROM photos WHERE path LIKE :pat") | |
| 694 | + suspend fun idsByPathLike(pat: String): List<Long> | |
| 633 | 695 | } |
| 634 | 696 | |
| 635 | 697 | @Dao |
| ... | ... | @@ -675,6 +737,10 @@ interface MomentDao { |
| 675 | 737 | @Query("DELETE FROM moments") |
| 676 | 738 | suspend fun clear() |
| 677 | 739 | |
| 740 | + /** 删某人的朋友圈(一键下载该人朋友圈前先清,避免重复累积)。 */ | |
| 741 | + @Query("DELETE FROM moments WHERE userName = :u") | |
| 742 | + suspend fun clearUser(u: String) | |
| 743 | + | |
| 678 | 744 | @Query("SELECT COUNT(*) FROM moments") |
| 679 | 745 | suspend fun count(): Int |
| 680 | 746 | ... | ... |
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Entities.kt
| ... | ... | @@ -213,6 +213,25 @@ data class Mention( |
| 213 | 213 | ) |
| 214 | 214 | |
| 215 | 215 | /** |
| 216 | + * 待跟进(followup):本地 LLM 从录音转写里抽出的「待办/承诺」——谁要做什么。 | |
| 217 | + * 去重键 (sourceTs, text) 唯一(同一段同一条只记一次)。[done] 用户勾掉。 | |
| 218 | + */ | |
| 219 | +@Entity( | |
| 220 | + tableName = "followups", | |
| 221 | + indices = [Index("done"), Index(value = ["sourceTs", "text"], unique = true)], | |
| 222 | +) | |
| 223 | +data class Followup( | |
| 224 | + @PrimaryKey(autoGenerate = true) val id: Long = 0, | |
| 225 | + val text: String, | |
| 226 | + /** 关联人物(可空,从文本匹配 Person) */ | |
| 227 | + val personId: Long? = null, | |
| 228 | + /** 来源录音时间(窗口内最新段的墙钟) */ | |
| 229 | + val sourceTs: Long, | |
| 230 | + val done: Boolean = false, | |
| 231 | + val createdAt: Long = System.currentTimeMillis(), | |
| 232 | +) | |
| 233 | + | |
| 234 | +/** | |
| 216 | 235 | * 人↔人「共现」无向边:同一会话(5min 静音切)里同时**在场**(声纹匹配)或**被提到**(mentions) |
| 217 | 236 | * 的两个人 → 一条边,[weight] = 共现过的会话数。规范化存 [personA] < [personB],(A,B) 唯一。 |
| 218 | 237 | */ | ... | ... |
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/vision/WeChatSnsImporter.kt
0 → 100644
| 1 | +package com.lemon.lifelog.vision | |
| 2 | + | |
| 3 | +import android.content.Context | |
| 4 | +import android.database.sqlite.SQLiteDatabase | |
| 5 | +import android.os.Process | |
| 6 | +import android.util.Log | |
| 7 | +import java.io.File | |
| 8 | + | |
| 9 | +/** | |
| 10 | + * 纯本地(端上 root)读微信朋友圈缓存图,按发布者 wxid 归属。**不经 Mac**。 | |
| 11 | + * | |
| 12 | + * 可行性(已在真机验证,37/37 大图 100% 归属): | |
| 13 | + * - `SnsMicroMsg.db` 是**明文 SQLite**(不加密);sns 缓存大图 `snsb_<mediaId>` 是**明文 JPEG**。 | |
| 14 | + * - 归属:每条朋友圈的 `SnsInfo.content`(明文 protobuf 字节)里含其各图的 media id 十进制串, | |
| 15 | + * 缓存文件名正是 `snsb_<mediaId>` → 「media id 是否作为子串出现在某条 content」即可把图归到该条作者 userName。 | |
| 16 | + * | |
| 17 | + * 唯一门槛是读 `com.tencent.mm` 私有文件需 root:用 `su` 一次性把 SnsMicroMsg.db + sns 大图缓存 | |
| 18 | + * 拷进本 app 私有目录(chown 给本 app uid),之后全部在 app 内处理,无需再 su。 | |
| 19 | + * | |
| 20 | + * 注意:只导入「**当前微信本地已缓存**」的图(= 你在微信里浏览过的朋友圈)。没缓存的不会凭空有, | |
| 21 | + * 需要的话先在微信里刷一下 TA 的朋友圈。(content 里也有 CDN URL,未来可加联网兜底下载。) | |
| 22 | + */ | |
| 23 | +object WeChatSnsImporter { | |
| 24 | + private const val TAG = "WeChatSns" | |
| 25 | + | |
| 26 | + /** 一条朋友圈:发布时间(ms) + 正文 + 它自己的图片文件(已拷到 destDir,按 `<ms>_<i>.jpg` 命名)。 */ | |
| 27 | + data class MomentData(val createTimeMs: Long, val text: String, val images: List<File>) | |
| 28 | + | |
| 29 | + data class Result( | |
| 30 | + val moments: List<MomentData> = emptyList(), | |
| 31 | + val error: String? = null, | |
| 32 | + ) | |
| 33 | + | |
| 34 | + /** | |
| 35 | + * 一趟 su + 读库:按「条」拉 [wxid] 的朋友圈——每条的正文 + 它自己已缓存的大图(拷到 [destDir])。 | |
| 36 | + * 失败(无 root / 微信路径变 / 没缓存 / 没授权)返回空 + error。**必须在 IO 线程调用**。 | |
| 37 | + */ | |
| 38 | + fun importMomentsFor(ctx: Context, wxid: String, destDir: File): Result { | |
| 39 | + if (wxid.isBlank()) return Result(error = "空 wxid") | |
| 40 | + val stage = File(ctx.filesDir, "sns_stage").apply { deleteRecursively(); mkdirs() } | |
| 41 | + val stagePath = stage.absolutePath | |
| 42 | + val uid = Process.myUid() | |
| 43 | + | |
| 44 | + // 一次 su:定位微信用户哈希目录 → 拷明文 SnsMicroMsg.db(含 wal/shm)+ sns 大图缓存 → chown 给本 app。 | |
| 45 | + // 关键:app 经 su 起的 shell PATH 常为空,必须显式 export,否则 ls/grep/find/cp/chown 全找不到。 | |
| 46 | + // 定位微信用户目录用 glob 直接找 SnsMicroMsg.db(不依赖 grep 的 {n} 区间)。 | |
| 47 | + val script = buildString { | |
| 48 | + append("export PATH=/system/bin:/system/xbin:\$PATH; ") | |
| 49 | + append("BASE=/data/user/0/com.tencent.mm; ") | |
| 50 | + append("DB=\$(ls \$BASE/MicroMsg/*/SnsMicroMsg.db 2>/dev/null | head -1); ") | |
| 51 | + append("[ -z \"\$DB\" ] && exit 3; ") | |
| 52 | + append("H=\$(basename \$(dirname \"\$DB\")); SNS=\$BASE/cache/\$H/sns; ") | |
| 53 | + append("cp \"\$DB\" $stagePath/sns.db || exit 4; ") | |
| 54 | + append("[ -f \"\$DB-wal\" ] && cp \"\$DB-wal\" $stagePath/sns.db-wal; ") | |
| 55 | + append("[ -f \"\$DB-shm\" ] && cp \"\$DB-shm\" $stagePath/sns.db-shm; ") | |
| 56 | + append("mkdir -p $stagePath/cache; ") | |
| 57 | + append("[ -d \"\$SNS\" ] && find \"\$SNS\" -type f -name 'snsb_*' -exec cp {} $stagePath/cache/ \\; ; ") | |
| 58 | + append("chown -R $uid:$uid $stagePath; chmod -R u+rwX $stagePath") | |
| 59 | + } | |
| 60 | + val (code, out) = su(script) | |
| 61 | + Log.i(TAG, "su stage code=$code out=$out") | |
| 62 | + if (code != 0) { | |
| 63 | + Log.w(TAG, "su stage failed code=$code out=$out") | |
| 64 | + val hint = when (code) { | |
| 65 | + 3 -> "没找到微信数据目录" | |
| 66 | + 4 -> "没找到 SnsMicroMsg.db" | |
| 67 | + else -> "读微信数据失败(需 Magisk 授予本 app root 权限):${out.take(120)}" | |
| 68 | + } | |
| 69 | + return Result(emptyList(), error = hint) | |
| 70 | + } | |
| 71 | + | |
| 72 | + val db = File(stage, "sns.db") | |
| 73 | + if (!db.exists()) return Result(emptyList(), error = "没找到 SnsMicroMsg.db") | |
| 74 | + // 缓存的大图(可能为空——纯文字朋友圈仍要导)。 | |
| 75 | + val snsb = File(stage, "cache").listFiles()?.filter { it.isFile && it.name.startsWith("snsb_") }.orEmpty() | |
| 76 | + val byId = snsb.associateBy { it.name.removePrefix("snsb_") } | |
| 77 | + destDir.mkdirs() | |
| 78 | + | |
| 79 | + // 按「条」读该 wxid 的朋友圈:每条 = 正文 + 它自己的图(content 里命中的 media id → snsb 缓存)。 | |
| 80 | + // createTime 是秒,存 ms 方便 Date() 展示;图按 `<ms>_<i>.jpg` 命名,UI 据 ms glob 回该条的图。 | |
| 81 | + val moments = ArrayList<MomentData>() | |
| 82 | + var imgTotal = 0 | |
| 83 | + try { | |
| 84 | + SQLiteDatabase.openDatabase(db.absolutePath, null, SQLiteDatabase.OPEN_READONLY).use { sdb -> | |
| 85 | + sdb.rawQuery( | |
| 86 | + "SELECT createTime, content FROM SnsInfo WHERE userName = ? AND content IS NOT NULL ORDER BY createTime DESC", | |
| 87 | + arrayOf(wxid), | |
| 88 | + ).use { c -> | |
| 89 | + while (c.moveToNext()) { | |
| 90 | + val ms = c.getLong(0) * 1000L | |
| 91 | + val content = c.getBlob(1) ?: continue | |
| 92 | + val raw = String(content, Charsets.ISO_8859_1) | |
| 93 | + val ids = byId.keys.filter { it in raw } | |
| 94 | + val text = momentText(content) | |
| 95 | + if (ids.isEmpty() && text.isBlank()) continue | |
| 96 | + val imgs = ArrayList<File>() | |
| 97 | + ids.forEachIndexed { i, id -> | |
| 98 | + val src = byId[id] ?: return@forEachIndexed | |
| 99 | + val dst = File(destDir, "${ms}_$i.jpg") | |
| 100 | + runCatching { src.copyTo(dst, overwrite = true); imgs.add(dst); imgTotal++ } | |
| 101 | + } | |
| 102 | + moments.add(MomentData(ms, text, imgs)) | |
| 103 | + } | |
| 104 | + } | |
| 105 | + } | |
| 106 | + } catch (t: Throwable) { | |
| 107 | + stage.deleteRecursively() | |
| 108 | + return Result(error = "解析 SnsMicroMsg.db 失败:${t.message}") | |
| 109 | + } | |
| 110 | + stage.deleteRecursively() | |
| 111 | + if (moments.isEmpty()) return Result(error = "这台机器上还没缓存 TA 的朋友圈") | |
| 112 | + Log.i(TAG, "wxid=$wxid 朋友圈:${moments.size} 条,图 $imgTotal 张") | |
| 113 | + return Result(moments) | |
| 114 | + } | |
| 115 | + | |
| 116 | + /** | |
| 117 | + * 从朋友圈 content(protobuf 字节)抠出可读正文。 | |
| 118 | + * 思路同 Mac 端 extract-wechat-chat-moments.py:按字符类切出候选串,去掉 URL/wxid/纯字母数字, | |
| 119 | + * 取中文最多的那段(朋友圈正文)。content 里的文本字段是 UTF-8,二进制部分被字符类自然排除。 | |
| 120 | + */ | |
| 121 | + private fun momentText(content: ByteArray): String { | |
| 122 | + val s = String(content, Charsets.UTF_8) | |
| 123 | + val cand = Regex("[\\u4e00-\\u9fffA-Za-z0-9,。!?、,.!?#@::…~()()【】\\-—\\s]{2,}") | |
| 124 | + .findAll(s).map { it.value.trim() } | |
| 125 | + .filter { it.length >= 2 } | |
| 126 | + .filter { c -> !listOf("http", "wxid", "mmsns", "mp.weixin").any { c.startsWith(it) } } | |
| 127 | + .filter { !it.matches(Regex("[A-Za-z0-9]+")) } | |
| 128 | + .toList() | |
| 129 | + val cjk = Regex("[\\u4e00-\\u9fff]") | |
| 130 | + return cand.maxByOrNull { cjk.findAll(it).count() }?.trim().orEmpty() | |
| 131 | + } | |
| 132 | + | |
| 133 | + /** | |
| 134 | + * 跑一条 root 命令,返回 (exitCode, 合并输出)。su 不可用 / 被拒返回非 0。 | |
| 135 | + * `--mount-master`:app 申请的 su 默认在本 app 的挂载命名空间,看不到别 app 的 /data; | |
| 136 | + * 切到**全局挂载命名空间**才能读 com.tencent.mm 的数据目录。 | |
| 137 | + */ | |
| 138 | + private fun su(script: String): Pair<Int, String> = try { | |
| 139 | + val p = ProcessBuilder("su", "--mount-master", "-c", script).redirectErrorStream(true).start() | |
| 140 | + val out = p.inputStream.bufferedReader().readText() | |
| 141 | + p.waitFor() | |
| 142 | + p.exitValue() to out | |
| 143 | + } catch (t: Throwable) { | |
| 144 | + -1 to (t.message ?: "su 不可用") | |
| 145 | + } | |
| 146 | +} | ... | ... |
-
mentioned in commit aef8755d9551c12780e927a20f85dd4e0030f9a3