Commit 2a6f99131c3c30c2d65e2df510b75b8d76685313

Authored by yibo
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>
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: () -&gt; 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&lt;Person&gt;, 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 +}
... ...