You need to sign in before continuing.
Commit 2a6f99131c3c30c2d65e2df510b75b8d76685313
1 parent
a3d829d7
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,8 +52,10 @@ import androidx.compose.runtime.remember | ||
| 52 | import androidx.compose.runtime.setValue | 52 | import androidx.compose.runtime.setValue |
| 53 | import androidx.compose.ui.Alignment | 53 | import androidx.compose.ui.Alignment |
| 54 | import androidx.compose.ui.Modifier | 54 | import androidx.compose.ui.Modifier |
| 55 | +import android.widget.Toast | ||
| 55 | import androidx.compose.ui.draw.clip | 56 | import androidx.compose.ui.draw.clip |
| 56 | import androidx.compose.ui.graphics.Brush | 57 | import androidx.compose.ui.graphics.Brush |
| 58 | +import androidx.compose.ui.platform.LocalContext | ||
| 57 | import androidx.compose.ui.layout.ContentScale | 59 | import androidx.compose.ui.layout.ContentScale |
| 58 | import androidx.compose.ui.text.style.TextOverflow | 60 | import androidx.compose.ui.text.style.TextOverflow |
| 59 | import androidx.compose.ui.unit.dp | 61 | import androidx.compose.ui.unit.dp |
| @@ -81,6 +83,7 @@ internal fun PersonProfileScreen( | @@ -81,6 +83,7 @@ internal fun PersonProfileScreen( | ||
| 81 | onRequestMerge: () -> Unit, | 83 | onRequestMerge: () -> Unit, |
| 82 | onOpenPerson: (Long) -> Unit, | 84 | onOpenPerson: (Long) -> Unit, |
| 83 | ) { | 85 | ) { |
| 86 | + val ctx = LocalContext.current | ||
| 84 | val person by vm.repo.persons.observe(personId).collectAsState(initial = null) | 87 | val person by vm.repo.persons.observe(personId).collectAsState(initial = null) |
| 85 | val photos by vm.repo.photos.observePhotosOfPerson(personId).collectAsState(initial = emptyList()) | 88 | val photos by vm.repo.photos.observePhotosOfPerson(personId).collectAsState(initial = emptyList()) |
| 86 | val speakers by vm.repo.speakers.observeAll().collectAsState(initial = emptyList()) | 89 | val speakers by vm.repo.speakers.observeAll().collectAsState(initial = emptyList()) |
| @@ -141,6 +144,12 @@ internal fun PersonProfileScreen( | @@ -141,6 +144,12 @@ internal fun PersonProfileScreen( | ||
| 141 | onEnrich = { vm.enrichImpression(personId) }, | 144 | onEnrich = { vm.enrichImpression(personId) }, |
| 142 | onEditProfile = { editProfile = true }, | 145 | onEditProfile = { editProfile = true }, |
| 143 | onMerge = onRequestMerge, | 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 | OutlinedTextField( | 155 | OutlinedTextField( |
| @@ -183,7 +192,7 @@ internal fun PersonProfileScreen( | @@ -183,7 +192,7 @@ internal fun PersonProfileScreen( | ||
| 183 | else LazyColumn(Modifier.fillMaxSize()) { items(fMsgs, key = { it.id }) { m -> ChatBubble(m) } } | 192 | else LazyColumn(Modifier.fillMaxSize()) { items(fMsgs, key = { it.id }) { m -> ChatBubble(m) } } |
| 184 | 3 -> if (wxid == null) EmptyHint("非微信联系人。") | 193 | 3 -> if (wxid == null) EmptyHint("非微信联系人。") |
| 185 | else if (fMoments.isEmpty()) EmptyHint(if (q.isBlank()) "没有 TA 的朋友圈记录。" else "没有匹配的朋友圈。") | 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 | // 被提到:别人录音里提到 TA 的段(按声纹=谁说的 展示)。 | 196 | // 被提到:别人录音里提到 TA 的段(按声纹=谁说的 展示)。 |
| 188 | 4 -> Column(Modifier.fillMaxSize()) { | 197 | 4 -> Column(Modifier.fillMaxSize()) { |
| 189 | Row( | 198 | Row( |
| @@ -507,11 +516,35 @@ private fun RelatedRow(rp: RelatedPerson, onClick: () -> Unit) { | @@ -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 | @Composable | 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,6 +98,7 @@ internal fun ContactsTab(repo: Repository) { | ||
| 98 | var detailPhotoId by remember { mutableStateOf<Long?>(null) } | 98 | var detailPhotoId by remember { mutableStateOf<Long?>(null) } |
| 99 | var showPicker by remember { mutableStateOf(false) } | 99 | var showPicker by remember { mutableStateOf(false) } |
| 100 | var showPhonePicker by remember { mutableStateOf(false) } | 100 | var showPhonePicker by remember { mutableStateOf(false) } |
| 101 | + var showCommunities by remember { mutableStateOf(false) } | ||
| 101 | var mergeForId by remember { mutableStateOf<Long?>(null) } | 102 | var mergeForId by remember { mutableStateOf<Long?>(null) } |
| 102 | // 人物 tab 只显示「我管理的人物」;全部微信联系人是池子(在选人页里)。 | 103 | // 人物 tab 只显示「我管理的人物」;全部微信联系人是池子(在选人页里)。 |
| 103 | val managed by vm.repo.persons.observeManaged().collectAsState(initial = emptyList()) | 104 | val managed by vm.repo.persons.observeManaged().collectAsState(initial = emptyList()) |
| @@ -114,6 +115,11 @@ internal fun ContactsTab(repo: Repository) { | @@ -114,6 +115,11 @@ internal fun ContactsTab(repo: Repository) { | ||
| 114 | onOpenPerson = { vm.touchPerson(it); profilePersonId = it }, | 115 | onOpenPerson = { vm.touchPerson(it); profilePersonId = it }, |
| 115 | ) | 116 | ) |
| 116 | showPhonePicker -> PhoneContactPickerScreen(vm = vm, onBack = { showPhonePicker = false }) | 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 | showPicker -> WeChatPickerScreen( | 123 | showPicker -> WeChatPickerScreen( |
| 118 | vm = vm, | 124 | vm = vm, |
| 119 | onBack = { showPicker = false }, | 125 | onBack = { showPicker = false }, |
| @@ -123,6 +129,7 @@ internal fun ContactsTab(repo: Repository) { | @@ -123,6 +129,7 @@ internal fun ContactsTab(repo: Repository) { | ||
| 123 | vm = vm, persons = managed, | 129 | vm = vm, persons = managed, |
| 124 | onOpen = { vm.touchPerson(it); profilePersonId = it }, | 130 | onOpen = { vm.touchPerson(it); profilePersonId = it }, |
| 125 | onOpenPicker = { showPicker = true }, | 131 | onOpenPicker = { showPicker = true }, |
| 132 | + onOpenCommunities = { showCommunities = true }, | ||
| 126 | ) | 133 | ) |
| 127 | } | 134 | } |
| 128 | detailPhotoId?.let { id -> | 135 | detailPhotoId?.let { id -> |
| @@ -181,6 +188,20 @@ internal fun AlbumTab(repo: Repository) { | @@ -181,6 +188,20 @@ internal fun AlbumTab(repo: Repository) { | ||
| 181 | 188 | ||
| 182 | val shown = results ?: photos | 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 | Column(modifier = Modifier.fillMaxSize()) { | 205 | Column(modifier = Modifier.fillMaxSize()) { |
| 185 | // 权限 + 进度。 | 206 | // 权限 + 进度。 |
| 186 | if (!granted) { | 207 | if (!granted) { |
| @@ -235,28 +256,38 @@ internal fun AlbumTab(repo: Repository) { | @@ -235,28 +256,38 @@ internal fun AlbumTab(repo: Repository) { | ||
| 235 | }) { Text("+ 手动导入") } | 256 | }) { Text("+ 手动导入") } |
| 236 | searchMsg?.let { Text(it, style = MaterialTheme.typography.labelSmall) } | 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 | HorizontalDivider() | 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 | columns = GridCells.Fixed(3), | 284 | columns = GridCells.Fixed(3), |
| 254 | modifier = Modifier.fillMaxSize().padding(horizontal = 4.dp), | 285 | modifier = Modifier.fillMaxSize().padding(horizontal = 4.dp), |
| 255 | verticalArrangement = Arrangement.spacedBy(4.dp), | 286 | verticalArrangement = Arrangement.spacedBy(4.dp), |
| 256 | horizontalArrangement = Arrangement.spacedBy(4.dp), | 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,6 +357,63 @@ private fun ProgressHeader( | ||
| 326 | } | 357 | } |
| 327 | 358 | ||
| 328 | /** 角标:按 scanState/处理态显示「转圈 / 待理解 / 待索引」。 */ | 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 | @Composable | 417 | @Composable |
| 330 | private fun PhotoCell(p: PhotoRow, processing: Boolean, onClick: () -> Unit) { | 418 | private fun PhotoCell(p: PhotoRow, processing: Boolean, onClick: () -> Unit) { |
| 331 | Box(modifier = Modifier.aspectRatio(1f).clickable(onClick = onClick)) { | 419 | Box(modifier = Modifier.aspectRatio(1f).clickable(onClick = onClick)) { |
| @@ -552,7 +640,7 @@ private fun TagFaceDialog( | @@ -552,7 +640,7 @@ private fun TagFaceDialog( | ||
| 552 | /** 「我管理的人物」列表:搜索 + 最近置顶 + 拼音 A–Z,点进档案页。顶部入口去「微信通讯录」选人。 */ | 640 | /** 「我管理的人物」列表:搜索 + 最近置顶 + 拼音 A–Z,点进档案页。顶部入口去「微信通讯录」选人。 */ |
| 553 | @OptIn(ExperimentalFoundationApi::class) | 641 | @OptIn(ExperimentalFoundationApi::class) |
| 554 | @Composable | 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 | var query by remember { mutableStateOf("") } | 644 | var query by remember { mutableStateOf("") } |
| 557 | 645 | ||
| 558 | val sections = remember(persons, query) { | 646 | val sections = remember(persons, query) { |
| @@ -582,7 +670,7 @@ private fun ContactList(vm: PhotologViewModel, persons: List<Person>, onOpen: (L | @@ -582,7 +670,7 @@ private fun ContactList(vm: PhotologViewModel, persons: List<Person>, onOpen: (L | ||
| 582 | verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), | 670 | verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), |
| 583 | ) { | 671 | ) { |
| 584 | Button(onClick = onOpenPicker) { Text("📇 通讯录") } | 672 | Button(onClick = onOpenPicker) { Text("📇 通讯录") } |
| 585 | - Text("从微信 / 手机通讯录勾选你关心的人加入管理", style = MaterialTheme.typography.labelSmall) | 673 | + OutlinedButton(onClick = onOpenCommunities) { Text("🕸 圈子") } |
| 586 | } | 674 | } |
| 587 | HorizontalDivider() | 675 | HorizontalDivider() |
| 588 | if (persons.isEmpty()) { | 676 | if (persons.isEmpty()) { |
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologViewModel.kt
| @@ -5,6 +5,8 @@ import android.net.Uri | @@ -5,6 +5,8 @@ import android.net.Uri | ||
| 5 | import android.util.Log | 5 | import android.util.Log |
| 6 | import androidx.lifecycle.AndroidViewModel | 6 | import androidx.lifecycle.AndroidViewModel |
| 7 | import androidx.lifecycle.viewModelScope | 7 | import androidx.lifecycle.viewModelScope |
| 8 | +import com.lemon.lifelog.data.Face | ||
| 9 | +import com.lemon.lifelog.data.Moment | ||
| 8 | import com.lemon.lifelog.data.Person | 10 | import com.lemon.lifelog.data.Person |
| 9 | import com.lemon.lifelog.data.Photo | 11 | import com.lemon.lifelog.data.Photo |
| 10 | import com.lemon.lifelog.data.PhotoRow | 12 | import com.lemon.lifelog.data.PhotoRow |
| @@ -29,6 +31,7 @@ import com.lemon.lifelog.vision.ImageUtil | @@ -29,6 +31,7 @@ import com.lemon.lifelog.vision.ImageUtil | ||
| 29 | import com.lemon.lifelog.vision.PhotoScheduler | 31 | import com.lemon.lifelog.vision.PhotoScheduler |
| 30 | import com.lemon.lifelog.vision.PhotoVlState | 32 | import com.lemon.lifelog.vision.PhotoVlState |
| 31 | import com.lemon.lifelog.vision.WeChatEnrollWorker | 33 | import com.lemon.lifelog.vision.WeChatEnrollWorker |
| 34 | +import com.lemon.lifelog.vision.WeChatSnsImporter | ||
| 32 | import kotlinx.coroutines.Dispatchers | 35 | import kotlinx.coroutines.Dispatchers |
| 33 | import kotlinx.coroutines.Job | 36 | import kotlinx.coroutines.Job |
| 34 | import kotlinx.coroutines.delay | 37 | import kotlinx.coroutines.delay |
| @@ -441,6 +444,71 @@ class PhotologViewModel(app: Application) : AndroidViewModel(app) { | @@ -441,6 +444,71 @@ class PhotologViewModel(app: Application) : AndroidViewModel(app) { | ||
| 441 | WorkManager.getInstance(getApplication()).enqueueUniqueWork("wx-enroll", ExistingWorkPolicy.KEEP, req) | 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 | fun deletePhoto(photoId: Long) { | 512 | fun deletePhoto(photoId: Long) { |
| 445 | viewModelScope.launch { | 513 | viewModelScope.launch { |
| 446 | withContext(Dispatchers.IO) { | 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,8 +24,9 @@ import androidx.sqlite.db.SupportSQLiteDatabase | ||
| 24 | DeletedGallery::class, | 24 | DeletedGallery::class, |
| 25 | Mention::class, | 25 | Mention::class, |
| 26 | Relation::class, | 26 | Relation::class, |
| 27 | + Followup::class, | ||
| 27 | ], | 28 | ], |
| 28 | - version = 17, | 29 | + version = 18, |
| 29 | exportSchema = false, | 30 | exportSchema = false, |
| 30 | ) | 31 | ) |
| 31 | abstract class AppDatabase : RoomDatabase() { | 32 | abstract class AppDatabase : RoomDatabase() { |
| @@ -41,6 +42,7 @@ abstract class AppDatabase : RoomDatabase() { | @@ -41,6 +42,7 @@ abstract class AppDatabase : RoomDatabase() { | ||
| 41 | abstract fun momentDao(): MomentDao | 42 | abstract fun momentDao(): MomentDao |
| 42 | abstract fun mentionDao(): MentionDao | 43 | abstract fun mentionDao(): MentionDao |
| 43 | abstract fun relationDao(): RelationDao | 44 | abstract fun relationDao(): RelationDao |
| 45 | + abstract fun followupDao(): FollowupDao | ||
| 44 | 46 | ||
| 45 | companion object { | 47 | companion object { |
| 46 | @Volatile | 48 | @Volatile |
| @@ -185,6 +187,17 @@ abstract class AppDatabase : RoomDatabase() { | @@ -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 | fun get(ctx: Context): AppDatabase = | 201 | fun get(ctx: Context): AppDatabase = |
| 189 | instance ?: synchronized(this) { | 202 | instance ?: synchronized(this) { |
| 190 | instance ?: Room.databaseBuilder( | 203 | instance ?: Room.databaseBuilder( |
| @@ -192,7 +205,7 @@ abstract class AppDatabase : RoomDatabase() { | @@ -192,7 +205,7 @@ abstract class AppDatabase : RoomDatabase() { | ||
| 192 | AppDatabase::class.java, | 205 | AppDatabase::class.java, |
| 193 | "lifelog.db", | 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 | // 不再 fallbackToDestructiveMigration:以后改 schema 必须写 Migration, | 209 | // 不再 fallbackToDestructiveMigration:以后改 schema 必须写 Migration, |
| 197 | // 否则启动直接抛 IllegalStateException(fail-loud),绝不静默清掉真实录音。 | 210 | // 否则启动直接抛 IllegalStateException(fail-loud),绝不静默清掉真实录音。 |
| 198 | .build() | 211 | .build() |
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Daos.kt
| @@ -336,6 +336,46 @@ data class RelatedPerson( | @@ -336,6 +336,46 @@ data class RelatedPerson( | ||
| 336 | val lastTs: Long, | 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 | @Dao | 379 | @Dao |
| 340 | interface RelationDao { | 380 | interface RelationDao { |
| 341 | @Insert(onConflict = OnConflictStrategy.IGNORE) | 381 | @Insert(onConflict = OnConflictStrategy.IGNORE) |
| @@ -347,6 +387,14 @@ interface RelationDao { | @@ -347,6 +387,14 @@ interface RelationDao { | ||
| 347 | @Query("SELECT COUNT(*) FROM relations WHERE personA = :pid OR personB = :pid") | 387 | @Query("SELECT COUNT(*) FROM relations WHERE personA = :pid OR personB = :pid") |
| 348 | fun observeCountForPerson(pid: Long): Flow<Int> | 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 | @Query( | 399 | @Query( |
| 352 | """ | 400 | """ |
| @@ -400,6 +448,12 @@ data class PhotoVec( | @@ -400,6 +448,12 @@ data class PhotoVec( | ||
| 400 | val captionEmbedding: ByteArray, | 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 | data class PhotoProgress( | 458 | data class PhotoProgress( |
| 405 | val total: Int, | 459 | val total: Int, |
| @@ -587,6 +641,10 @@ interface PhotoDao { | @@ -587,6 +641,10 @@ interface PhotoDao { | ||
| 587 | ) | 641 | ) |
| 588 | fun observeGrid(limit: Int = 500): Flow<List<PhotoRow>> | 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 | /** 某个人出现的所有照片(faces.personId join)。 */ | 648 | /** 某个人出现的所有照片(faces.personId join)。 */ |
| 591 | @Query( | 649 | @Query( |
| 592 | """ | 650 | """ |
| @@ -630,6 +688,10 @@ interface PhotoDao { | @@ -630,6 +688,10 @@ interface PhotoDao { | ||
| 630 | 688 | ||
| 631 | @Query("DELETE FROM photos WHERE id = :id") | 689 | @Query("DELETE FROM photos WHERE id = :id") |
| 632 | suspend fun delete(id: Long) | 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 | @Dao | 697 | @Dao |
| @@ -675,6 +737,10 @@ interface MomentDao { | @@ -675,6 +737,10 @@ interface MomentDao { | ||
| 675 | @Query("DELETE FROM moments") | 737 | @Query("DELETE FROM moments") |
| 676 | suspend fun clear() | 738 | suspend fun clear() |
| 677 | 739 | ||
| 740 | + /** 删某人的朋友圈(一键下载该人朋友圈前先清,避免重复累积)。 */ | ||
| 741 | + @Query("DELETE FROM moments WHERE userName = :u") | ||
| 742 | + suspend fun clearUser(u: String) | ||
| 743 | + | ||
| 678 | @Query("SELECT COUNT(*) FROM moments") | 744 | @Query("SELECT COUNT(*) FROM moments") |
| 679 | suspend fun count(): Int | 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,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 | * 人↔人「共现」无向边:同一会话(5min 静音切)里同时**在场**(声纹匹配)或**被提到**(mentions) | 235 | * 人↔人「共现」无向边:同一会话(5min 静音切)里同时**在场**(声纹匹配)或**被提到**(mentions) |
| 217 | * 的两个人 → 一条边,[weight] = 共现过的会话数。规范化存 [personA] < [personB],(A,B) 唯一。 | 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 | +} |