From 7a40a4a7083e95474c89d10996feae4cd5fb43b4 Mon Sep 17 00:00:00 2001 From: yibo Date: Tue, 9 Jun 2026 17:58:42 +0800 Subject: [PATCH] feat(lifelog/photolog): 人物一键下载微信头像+朋友圈(纯端上 root)+ 相册按人物分组 --- lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/CommunitiesScreen.kt | 171 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PersonProfileScreen.kt | 43 ++++++++++++++++++++++++++++++++++++++----- lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologScreen.kt | 122 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------- lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologViewModel.kt | 68 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/crm/RelationAnalysis.kt | 83 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/AppDatabase.kt | 17 +++++++++++++++-- lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Daos.kt | 66 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Entities.kt | 19 +++++++++++++++++++ lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/vision/WeChatSnsImporter.kt | 146 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 9 files changed, 711 insertions(+), 24 deletions(-) create mode 100644 lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/CommunitiesScreen.kt create mode 100644 lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/crm/RelationAnalysis.kt create mode 100644 lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/vision/WeChatSnsImporter.kt diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/CommunitiesScreen.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/CommunitiesScreen.kt new file mode 100644 index 0000000..533acbc --- /dev/null +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/CommunitiesScreen.kt @@ -0,0 +1,171 @@ +package com.lemon.lifelog + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Card +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.lemon.lifelog.crm.RelationAnalysis +import com.lemon.lifelog.data.Person + +/** + * 关系分析页:上半「关系链」(两人最短共现路径),下半「圈子」(连通分量社群)。 + * 数据来自 relations 表(「重新扫描」后由 RelationBuilder 产)。点人进档案。 + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun CommunitiesScreen( + vm: PhotologViewModel, + onBack: () -> Unit, + onOpenPerson: (Long) -> Unit, +) { + val persons by vm.repo.persons.observeAll().collectAsState(initial = emptyList()) + val managed by vm.repo.persons.observeManaged().collectAsState(initial = emptyList()) + val nameById = remember(persons) { persons.associate { it.id to it.name } } + var comms by remember { mutableStateOf?>(null) } + LaunchedEffect(Unit) { comms = RelationAnalysis.communities(vm.repo) } + + Column(Modifier.fillMaxSize()) { + TopAppBar( + title = { Text("关系") }, + navigationIcon = { + IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "返回") } + }, + ) + LazyColumn(Modifier.fillMaxSize().padding(horizontal = 12.dp)) { + item { PathFinder(managed, nameById, vm, onOpenPerson) } + item { + Text("圈子", style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(top = 12.dp, bottom = 4.dp)) + } + val list = comms + when { + list == null -> item { HintBox("分析中…") } + list.isEmpty() -> item { + HintBox("还没有圈子。圈子来自录音会话里同时出现/被提到的人——把几个人加入「我管理的人物」、到档案「被提到」tab 点「重新扫描」后再来看。") + } + else -> items(list) { c -> + Card(Modifier.fillMaxWidth().padding(vertical = 6.dp)) { + Column(Modifier.padding(12.dp)) { + Text("${c.memberIds.size} 人 · ${c.edgeCount} 条共现", style = MaterialTheme.typography.labelMedium) + HorizontalDivider(Modifier.padding(vertical = 6.dp)) + c.memberIds.forEach { id -> + PersonLine(nameById[id] ?: "#$id") { onOpenPerson(id) } + } + } + } + } + } + } + } +} + +/** 关系链查找:选两人 → BFS 最短共现路径,渲染 A → B → C。 */ +@Composable +private fun PathFinder( + managed: List, + nameById: Map, + vm: PhotologViewModel, + onOpenPerson: (Long) -> Unit, +) { + var fromId by remember { mutableStateOf(null) } + var toId by remember { mutableStateOf(null) } + var path by remember { mutableStateOf?>(null) } + LaunchedEffect(fromId, toId) { + val a = fromId; val b = toId + path = if (a != null && b != null) RelationAnalysis.shortestPath(vm.repo, a, b) else null + } + Card(Modifier.fillMaxWidth().padding(top = 8.dp)) { + Column(Modifier.padding(12.dp)) { + Text("关系链", style = MaterialTheme.typography.titleMedium) + Text("看两个人是怎么通过共现连起来的。", style = MaterialTheme.typography.labelSmall) + Row(Modifier.padding(top = 8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + PersonPicker("从", fromId, managed) { fromId = it } + PersonPicker("到", toId, managed) { toId = it } + } + val p = path + when { + fromId == null || toId == null -> {} + p == null -> {} + p.isEmpty() -> Text("两人之间还没有共现路径(不连通)。", style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(top = 8.dp)) + else -> Column(Modifier.padding(top = 8.dp)) { + if (p.size == 2) Text("直接共现。", style = MaterialTheme.typography.labelSmall) + else Text("经 ${p.size - 2} 人中转:", style = MaterialTheme.typography.labelSmall) + p.forEachIndexed { i, id -> + Row(verticalAlignment = Alignment.CenterVertically) { + if (i > 0) Text("↓ ", style = MaterialTheme.typography.bodySmall) + Text( + nameById[id] ?: "#$id", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.clickable { onOpenPerson(id) }.padding(vertical = 2.dp), + ) + } + } + } + } + } + } +} + +@Composable +private fun PersonPicker(label: String, selectedId: Long?, persons: List, onPick: (Long) -> Unit) { + var open by remember { mutableStateOf(false) } + Box { + OutlinedButton(onClick = { open = true }) { + Text(persons.firstOrNull { it.id == selectedId }?.name ?: label) + } + DropdownMenu(expanded = open, onDismissRequest = { open = false }) { + persons.forEach { p -> + DropdownMenuItem(text = { Text(p.name) }, onClick = { onPick(p.id); open = false }) + } + } + } +} + +@Composable +private fun PersonLine(name: String, onClick: () -> Unit) { + Row( + Modifier.fillMaxWidth().clickable(onClick = onClick).padding(vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text("👤") + Text(name, Modifier.weight(1f), style = MaterialTheme.typography.bodyLarge) + Text("›", style = MaterialTheme.typography.titleMedium) + } +} + +@Composable +private fun HintBox(text: String) { + Box(Modifier.fillMaxWidth().padding(24.dp), contentAlignment = Alignment.Center) { + Text(text, style = MaterialTheme.typography.bodyMedium) + } +} diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PersonProfileScreen.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PersonProfileScreen.kt index b7bd10e..c389007 100644 --- a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PersonProfileScreen.kt +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PersonProfileScreen.kt @@ -52,8 +52,10 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import android.widget.Toast import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -81,6 +83,7 @@ internal fun PersonProfileScreen( onRequestMerge: () -> Unit, onOpenPerson: (Long) -> Unit, ) { + val ctx = LocalContext.current val person by vm.repo.persons.observe(personId).collectAsState(initial = null) val photos by vm.repo.photos.observePhotosOfPerson(personId).collectAsState(initial = emptyList()) val speakers by vm.repo.speakers.observeAll().collectAsState(initial = emptyList()) @@ -141,6 +144,12 @@ internal fun PersonProfileScreen( onEnrich = { vm.enrichImpression(personId) }, onEditProfile = { editProfile = true }, onMerge = onRequestMerge, + onDownload = { + Toast.makeText(ctx, "下载中…(读微信本地缓存,首次需授予 root)", Toast.LENGTH_SHORT).show() + vm.downloadWeChatImages(personId) { _, msg -> + Toast.makeText(ctx, msg ?: "完成", Toast.LENGTH_LONG).show() + } + }, ) } OutlinedTextField( @@ -183,7 +192,7 @@ internal fun PersonProfileScreen( else LazyColumn(Modifier.fillMaxSize()) { items(fMsgs, key = { it.id }) { m -> ChatBubble(m) } } 3 -> if (wxid == null) EmptyHint("非微信联系人。") else if (fMoments.isEmpty()) EmptyHint(if (q.isBlank()) "没有 TA 的朋友圈记录。" else "没有匹配的朋友圈。") - else LazyColumn(Modifier.fillMaxSize()) { items(fMoments, key = { it.id }) { mo -> MomentRow(mo) } } + else LazyColumn(Modifier.fillMaxSize()) { items(fMoments, key = { it.id }) { mo -> MomentRow(mo, wxid.orEmpty()) } } // 被提到:别人录音里提到 TA 的段(按声纹=谁说的 展示)。 4 -> Column(Modifier.fillMaxSize()) { Row( @@ -507,11 +516,35 @@ private fun RelatedRow(rp: RelatedPerson, onClick: () -> Unit) { } } +/** 朋友圈一条:文字 + 九宫格图片 + 时间(图从 moments//_*.jpg 按 createTime glob 回)。 */ @Composable -private fun MomentRow(m: Moment) { - Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp)) { - Text(PROFILE_FMT.format(Date(m.createTime)), style = MaterialTheme.typography.labelSmall) - Text(m.text, style = MaterialTheme.typography.bodyMedium) +private fun MomentRow(m: Moment, wxid: String) { + val ctx = LocalContext.current + val images = remember(m.id, wxid) { + if (wxid.isBlank()) emptyList() + else File(ctx.filesDir, "moments/$wxid") + .listFiles { f -> f.name.startsWith("${m.createTime}_") }?.sortedBy { it.name }?.toList().orEmpty() + } + Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp)) { + if (m.text.isNotBlank()) Text(m.text, style = MaterialTheme.typography.bodyMedium) + if (images.isNotEmpty()) { + Spacer(Modifier.height(6.dp)) + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + images.chunked(3).forEach { row -> + Row(horizontalArrangement = Arrangement.spacedBy(4.dp), modifier = Modifier.fillMaxWidth()) { + row.forEach { f -> + AsyncImage( + model = f, contentDescription = null, contentScale = ContentScale.Crop, + modifier = Modifier.weight(1f).aspectRatio(1f).clip(RoundedCornerShape(6.dp)), + ) + } + repeat(3 - row.size) { Spacer(Modifier.weight(1f)) } + } + } + } + } + Spacer(Modifier.height(6.dp)) + Text(PROFILE_FMT.format(Date(m.createTime)), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) } } diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologScreen.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologScreen.kt index de835a5..04ce1a7 100644 --- a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologScreen.kt +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologScreen.kt @@ -98,6 +98,7 @@ internal fun ContactsTab(repo: Repository) { var detailPhotoId by remember { mutableStateOf(null) } var showPicker by remember { mutableStateOf(false) } var showPhonePicker by remember { mutableStateOf(false) } + var showCommunities by remember { mutableStateOf(false) } var mergeForId by remember { mutableStateOf(null) } // 人物 tab 只显示「我管理的人物」;全部微信联系人是池子(在选人页里)。 val managed by vm.repo.persons.observeManaged().collectAsState(initial = emptyList()) @@ -114,6 +115,11 @@ internal fun ContactsTab(repo: Repository) { onOpenPerson = { vm.touchPerson(it); profilePersonId = it }, ) showPhonePicker -> PhoneContactPickerScreen(vm = vm, onBack = { showPhonePicker = false }) + showCommunities -> CommunitiesScreen( + vm = vm, + onBack = { showCommunities = false }, + onOpenPerson = { vm.touchPerson(it); showCommunities = false; profilePersonId = it }, + ) showPicker -> WeChatPickerScreen( vm = vm, onBack = { showPicker = false }, @@ -123,6 +129,7 @@ internal fun ContactsTab(repo: Repository) { vm = vm, persons = managed, onOpen = { vm.touchPerson(it); profilePersonId = it }, onOpenPicker = { showPicker = true }, + onOpenCommunities = { showCommunities = true }, ) } detailPhotoId?.let { id -> @@ -181,6 +188,20 @@ internal fun AlbumTab(repo: Repository) { val shown = results ?: photos + // 两个子 tab:0=人物文件夹(默认) 1=所有图片。搜索时两者都让位给搜索结果。 + var albumTab by remember { mutableStateOf(0) } + var folderPerson by remember { mutableStateOf(null) } + val personCounts by vm.repo.photos.observePersonPhotoCounts().collectAsState(initial = emptyList()) + val folders = remember(personCounts, persons) { + val nameOf = persons.associateBy { it.id } + personCounts.filter { it.count > 0 } + .mapNotNull { c -> nameOf[c.personId]?.let { Triple(it.id, it, c.count) } } + .sortedByDescending { it.third } + } + val folderPhotos by remember(folderPerson) { + folderPerson?.let { vm.repo.photos.observePhotosOfPerson(it) } ?: flowOf(emptyList()) + }.collectAsState(initial = emptyList()) + Column(modifier = Modifier.fillMaxSize()) { // 权限 + 进度。 if (!granted) { @@ -235,28 +256,38 @@ internal fun AlbumTab(repo: Repository) { }) { Text("+ 手动导入") } searchMsg?.let { Text(it, style = MaterialTheme.typography.labelSmall) } } + // 子 tab(搜索时隐藏,直接显示搜索结果)。 + if (results == null) { + TabRow(selectedTabIndex = albumTab) { + Tab(selected = albumTab == 0, onClick = { albumTab = 0; folderPerson = null }, text = { Text("人物文件夹") }) + Tab(selected = albumTab == 1, onClick = { albumTab = 1 }, text = { Text("所有图片") }) + } + } HorizontalDivider() - if (shown.isEmpty()) { - Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) { - Text( - when { - results != null -> "没搜到。换个词,或等场景描述(VL)跑得更多再搜。" - !granted -> "点上方「开启全相册索引」,自动把手机相册所有照片纳入。" - progress.total > 0 -> "正在索引 ${progress.total} 张…稍后刷新。" - else -> "还没有照片。开启全相册索引,或「+ 手动导入」。" - }, - style = MaterialTheme.typography.bodyMedium, - ) + when { + // 搜索结果优先 + results != null -> PhotoGrid(shown, vlCurrentId) { detailPhotoId = it } + // 所有图片 + albumTab == 1 -> if (photos.isEmpty()) AlbumEmptyHint(granted, progress.total) + else PhotoGrid(photos, vlCurrentId) { detailPhotoId = it } + // 人物文件夹 → 点进某人 → 该人照片 + folderPerson != null -> Column(Modifier.fillMaxSize()) { + val who = persons.firstOrNull { it.id == folderPerson }?.name ?: "TA" + TextButton(onClick = { folderPerson = null }) { Text("← 人物文件夹 · $who(${folderPhotos.size})") } + if (folderPhotos.isEmpty()) AlbumEmptyHint(granted, 0) + else PhotoGrid(folderPhotos, vlCurrentId) { detailPhotoId = it } } - } else { - LazyVerticalGrid( + folders.isEmpty() -> Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) { + Text("还没有归到人物的照片。\n在照片里标注人脸、绑声纹,或在人物档案页「⬇ 下载图片」。", style = MaterialTheme.typography.bodyMedium) + } + else -> LazyVerticalGrid( columns = GridCells.Fixed(3), modifier = Modifier.fillMaxSize().padding(horizontal = 4.dp), verticalArrangement = Arrangement.spacedBy(4.dp), horizontalArrangement = Arrangement.spacedBy(4.dp), ) { - gridItems(shown, key = { it.id }) { p -> - PhotoCell(p, processing = p.id == vlCurrentId) { detailPhotoId = p.id } + gridItems(folders, key = { it.first }) { (pid, person, count) -> + PersonFolderCell(person, count) { folderPerson = pid } } } } @@ -326,6 +357,63 @@ private fun ProgressHeader( } /** 角标:按 scanState/处理态显示「转圈 / 待理解 / 待索引」。 */ +/** 照片网格(相册各视图共用)。 */ +@Composable +private fun PhotoGrid(rows: List, vlCurrentId: Long?, onOpen: (Long) -> Unit) { + LazyVerticalGrid( + columns = GridCells.Fixed(3), + modifier = Modifier.fillMaxSize().padding(horizontal = 4.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + gridItems(rows, key = { it.id }) { p -> + PhotoCell(p, processing = p.id == vlCurrentId) { onOpen(p.id) } + } + } +} + +@Composable +private fun AlbumEmptyHint(granted: Boolean, total: Int) { + Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) { + Text( + when { + !granted -> "点上方「开启全相册索引」,自动把手机相册所有照片纳入。" + total > 0 -> "正在索引 $total 张…稍后刷新。" + else -> "还没有照片。开启全相册索引,或「+ 手动导入」。" + }, + style = MaterialTheme.typography.bodyMedium, + ) + } +} + +/** 人物文件夹封面格:头像 + 名字 + 照片数。 */ +@Composable +private fun PersonFolderCell(person: Person, count: Int, onClick: () -> Unit) { + Column( + modifier = Modifier.clickable(onClick = onClick).padding(4.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Box(modifier = Modifier.aspectRatio(1f).fillMaxWidth().clip(RoundedCornerShape(10.dp)).background(MaterialTheme.colorScheme.surfaceVariant)) { + if (person.avatarPath != null) { + AsyncImage( + model = File(person.avatarPath), contentDescription = null, contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize(), + ) + } else { + Text(person.name.take(1), modifier = Modifier.align(Alignment.Center), style = MaterialTheme.typography.headlineMedium) + } + Surface( + color = MaterialTheme.colorScheme.scrim.copy(alpha = 0.55f), + modifier = Modifier.align(Alignment.BottomEnd).padding(4.dp), + shape = RoundedCornerShape(50), + ) { + Text(" $count ", style = MaterialTheme.typography.labelSmall, color = androidx.compose.ui.graphics.Color.White) + } + } + Text(person.name, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.labelMedium) + } +} + @Composable private fun PhotoCell(p: PhotoRow, processing: Boolean, onClick: () -> Unit) { Box(modifier = Modifier.aspectRatio(1f).clickable(onClick = onClick)) { @@ -552,7 +640,7 @@ private fun TagFaceDialog( /** 「我管理的人物」列表:搜索 + 最近置顶 + 拼音 A–Z,点进档案页。顶部入口去「微信通讯录」选人。 */ @OptIn(ExperimentalFoundationApi::class) @Composable -private fun ContactList(vm: PhotologViewModel, persons: List, onOpen: (Long) -> Unit, onOpenPicker: () -> Unit) { +private fun ContactList(vm: PhotologViewModel, persons: List, onOpen: (Long) -> Unit, onOpenPicker: () -> Unit, onOpenCommunities: () -> Unit) { var query by remember { mutableStateOf("") } val sections = remember(persons, query) { @@ -582,7 +670,7 @@ private fun ContactList(vm: PhotologViewModel, persons: List, onOpen: (L verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { Button(onClick = onOpenPicker) { Text("📇 通讯录") } - Text("从微信 / 手机通讯录勾选你关心的人加入管理", style = MaterialTheme.typography.labelSmall) + OutlinedButton(onClick = onOpenCommunities) { Text("🕸 圈子") } } HorizontalDivider() if (persons.isEmpty()) { diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologViewModel.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologViewModel.kt index 18661b7..f65fd17 100644 --- a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologViewModel.kt +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologViewModel.kt @@ -5,6 +5,8 @@ import android.net.Uri import android.util.Log import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope +import com.lemon.lifelog.data.Face +import com.lemon.lifelog.data.Moment import com.lemon.lifelog.data.Person import com.lemon.lifelog.data.Photo import com.lemon.lifelog.data.PhotoRow @@ -29,6 +31,7 @@ import com.lemon.lifelog.vision.ImageUtil import com.lemon.lifelog.vision.PhotoScheduler import com.lemon.lifelog.vision.PhotoVlState import com.lemon.lifelog.vision.WeChatEnrollWorker +import com.lemon.lifelog.vision.WeChatSnsImporter import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.delay @@ -441,6 +444,71 @@ class PhotologViewModel(app: Application) : AndroidViewModel(app) { WorkManager.getInstance(getApplication()).enqueueUniqueWork("wx-enroll", ExistingWorkPolicy.KEEP, req) } + /** + * 一键下载该人物的微信内容(**纯本地,不经 Mac**): + * - 头像:`wx_enroll/avatars/.jpg` → 导入成「照片」(归属脸挂到人物),出现在档案页「照片」tab。 + * - 朋友圈:端上 su 读微信明文 sns 缓存,**按条**存(图存 `moments//_.jpg`,文字进 moments 表); + * 朋友圈图**不进**「照片」相册(照片 tab 只放头像+真正认出的合照),由「朋友圈」tab 按条渲染。 + * 回调 (头像张数, 人读消息)。 + */ + fun downloadWeChatImages(personId: Long, onResult: (Int, String?) -> Unit) { + val app = getApplication() + viewModelScope.launch { + val (added, msg) = withContext(Dispatchers.IO) { + val p = repo.persons.get(personId) ?: return@withContext 0 to "人物不存在" + val wxid = p.contactRef?.removePrefix("wechat:")?.takeIf { p.contactRef.startsWith("wechat:") } + ?: return@withContext 0 to "不是微信联系人,无法下载" + val photosDir = File(app.filesDir, "photos").apply { mkdirs() } + + // 清掉旧版误倒进相册的朋友圈图(path 形如 photos/moment__*.jpg)+ 其归属脸。 + runCatching { + for (id in repo.photos.idsByPathLike("%/moment_${wxid}_%")) { + repo.faces.deleteByPhoto(id); repo.photos.delete(id) + } + } + + // 1) 头像 → 照片(归属脸)。 + var avatarN = 0 + val avatar = File(app.filesDir, "wx_enroll/avatars/$wxid.jpg") + if (avatar.exists()) { + val dst = File(photosDir, "wxavatar_$wxid.jpg") + if (repo.photos.getByPath(dst.absolutePath) == null) { + runCatching { avatar.copyTo(dst, overwrite = true); importOwnedPhoto(dst, personId, p.createdAt); avatarN++ } + } + } + + // 2) 朋友圈:图按条存到 moments//,文字进 moments 表(不进照片相册)。 + val momentsDir = File(app.filesDir, "moments/$wxid").apply { deleteRecursively(); mkdirs() } + val r = WeChatSnsImporter.importMomentsFor(app, wxid, momentsDir) + var momentN = 0 + var imgN = 0 + if (r.moments.isNotEmpty()) { + repo.moments.clearUser(wxid) + repo.moments.insertAll(r.moments.map { Moment(userName = wxid, text = it.text, createTime = it.createTimeMs) }) + momentN = r.moments.size + imgN = r.moments.sumOf { it.images.size } + } + + if (avatarN > 0 || momentN > 0) { + repo.persons.setManaged(personId, true) + repo.persons.touch(personId, System.currentTimeMillis()) + if (avatarN > 0) PhotoScheduler.runNow(app) // 让后台对头像跑人脸 + } + val ok = avatarN > 0 || momentN > 0 + Pair(avatarN, if (ok) "已下载头像 + $momentN 条朋友圈($imgN 张图)" else (r.error ?: "没有可下载的内容")) + } + onResult(added, msg) + } + } + + /** 插入一张 imported 照片 + 一条「归属脸」(detScore=-1 哨兵,无 embedding) 把它挂到 [personId]。 */ + private suspend fun importOwnedPhoto(file: File, personId: Long, takenTs: Long) { + val pid = repo.photos.insert(Photo(path = file.absolutePath, source = "imported", takenTs = takenTs)) + repo.faces.insert( + Face(photoId = pid, left = 0f, top = 0f, right = 0f, bottom = 0f, detScore = -1f, personId = personId), + ) + } + fun deletePhoto(photoId: Long) { viewModelScope.launch { withContext(Dispatchers.IO) { diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/crm/RelationAnalysis.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/crm/RelationAnalysis.kt new file mode 100644 index 0000000..e154ca2 --- /dev/null +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/crm/RelationAnalysis.kt @@ -0,0 +1,83 @@ +package com.lemon.lifelog.crm + +import com.lemon.lifelog.data.Repository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** + * 端上 CRM #3 关系分析:在 [com.lemon.lifelog.data.Relation] 共现图上做纯算法分析。 + * - [communities]:连通分量(社群/圈子),把互相有共现的人圈成一团。 + * - [shortestPath]:两人间最短关系链(BFS,无向)——「A 和 B 怎么连上的」。 + * 都是无向图算法,权重仅用于内部排序(圈子按规模、成员按度数)。 + */ +object RelationAnalysis { + + /** 一个社群:成员 personId(按度数降序)、内部边数。 */ + data class Community(val memberIds: List, val edgeCount: Int) + + /** 连通分量。只保留 ≥ [minWeight] 的边、规模 ≥2 的团;按规模降序。 */ + suspend fun communities(repo: Repository, minWeight: Int = 1): List = withContext(Dispatchers.IO) { + val edges = repo.relations.allEdges().filter { it.weight >= minWeight } + if (edges.isEmpty()) return@withContext emptyList() + + // union-find + val parent = HashMap() + fun find(x: Long): Long { + var r = x + while (parent[r] != r) r = parent[r]!! + var c = x + while (parent[c] != c) { val n = parent[c]!!; parent[c] = r; c = n } + return r + } + fun union(a: Long, b: Long) { + parent.getOrPut(a) { a }; parent.getOrPut(b) { b } + val ra = find(a); val rb = find(b) + if (ra != rb) parent[ra] = rb + } + val degree = HashMap() + val edgesByRoot = HashMap() + for (e in edges) { + union(e.personA, e.personB) + degree[e.personA] = (degree[e.personA] ?: 0) + 1 + degree[e.personB] = (degree[e.personB] ?: 0) + 1 + } + val groups = HashMap>() + for (node in parent.keys) groups.getOrPut(find(node)) { mutableListOf() }.add(node) + for (e in edges) { val r = find(e.personA); edgesByRoot[r] = (edgesByRoot[r] ?: 0) + 1 } + + groups.values + .filter { it.size >= 2 } + .map { members -> + Community( + memberIds = members.sortedByDescending { degree[it] ?: 0 }, + edgeCount = edgesByRoot[find(members[0])] ?: 0, + ) + } + .sortedByDescending { it.memberIds.size } + } + + /** [fromId]→[toId] 的最短关系链(含两端);不连通返回空。 */ + suspend fun shortestPath(repo: Repository, fromId: Long, toId: Long): List = withContext(Dispatchers.IO) { + if (fromId == toId) return@withContext listOf(fromId) + val adj = HashMap>() + for (e in repo.relations.allEdges()) { + adj.getOrPut(e.personA) { mutableListOf() }.add(e.personB) + adj.getOrPut(e.personB) { mutableListOf() }.add(e.personA) + } + if (fromId !in adj || toId !in adj) return@withContext emptyList() + val prev = HashMap() + val seen = hashSetOf(fromId) + val queue = ArrayDeque().apply { add(fromId) } + while (queue.isNotEmpty()) { + val cur = queue.removeFirst() + if (cur == toId) { + val path = ArrayList() + var n: Long? = toId + while (n != null) { path.add(n); n = prev[n] } + return@withContext path.reversed() + } + for (nb in adj[cur].orEmpty()) if (seen.add(nb)) { prev[nb] = cur; queue.add(nb) } + } + emptyList() + } +} diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/AppDatabase.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/AppDatabase.kt index 5268126..e56e66e 100644 --- a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/AppDatabase.kt +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/AppDatabase.kt @@ -24,8 +24,9 @@ import androidx.sqlite.db.SupportSQLiteDatabase DeletedGallery::class, Mention::class, Relation::class, + Followup::class, ], - version = 17, + version = 18, exportSchema = false, ) abstract class AppDatabase : RoomDatabase() { @@ -41,6 +42,7 @@ abstract class AppDatabase : RoomDatabase() { abstract fun momentDao(): MomentDao abstract fun mentionDao(): MentionDao abstract fun relationDao(): RelationDao + abstract fun followupDao(): FollowupDao companion object { @Volatile @@ -185,6 +187,17 @@ abstract class AppDatabase : RoomDatabase() { } } + /** v0.9 端上 CRM #3:待跟进(followups)表。 */ + private val MIGRATION_17_18 = object : Migration(17, 18) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + "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)", + ) + db.execSQL("CREATE INDEX IF NOT EXISTS `index_followups_done` ON `followups` (`done`)") + db.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS `index_followups_sourceTs_text` ON `followups` (`sourceTs`, `text`)") + } + } + fun get(ctx: Context): AppDatabase = instance ?: synchronized(this) { instance ?: Room.databaseBuilder( @@ -192,7 +205,7 @@ abstract class AppDatabase : RoomDatabase() { AppDatabase::class.java, "lifelog.db", ) - .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) + .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) // 不再 fallbackToDestructiveMigration:以后改 schema 必须写 Migration, // 否则启动直接抛 IllegalStateException(fail-loud),绝不静默清掉真实录音。 .build() diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Daos.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Daos.kt index b94743e..1ec797f 100644 --- a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Daos.kt +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Daos.kt @@ -336,6 +336,46 @@ data class RelatedPerson( val lastTs: Long, ) +/** 一条共现边(关系分析:社群发现 + 最短路径)。 */ +data class RelationEdge(val personA: Long, val personB: Long, val weight: Int) + +/** 待跟进行(含关联人物名)。 */ +data class FollowupRow( + val id: Long, + val text: String, + val personId: Long?, + val personName: String?, + val sourceTs: Long, + val done: Boolean, +) + +@Dao +interface FollowupDao { + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insertAll(fs: List) + + @Query("UPDATE followups SET done = :done WHERE id = :id") + suspend fun setDone(id: Long, done: Boolean) + + @Query("DELETE FROM followups WHERE id = :id") + suspend fun delete(id: Long) + + @Query("SELECT COUNT(*) FROM followups WHERE done = 0") + fun observeOpenCount(): Flow + + /** 未完成在前、按来源时间倒序。 */ + @Query( + """ + SELECT f.id AS id, f.text AS text, f.personId AS personId, p.name AS personName, + f.sourceTs AS sourceTs, f.done AS done + FROM followups f + LEFT JOIN persons p ON p.id = f.personId + ORDER BY f.done ASC, f.sourceTs DESC + """, + ) + fun observeAll(): Flow> +} + @Dao interface RelationDao { @Insert(onConflict = OnConflictStrategy.IGNORE) @@ -347,6 +387,14 @@ interface RelationDao { @Query("SELECT COUNT(*) FROM relations WHERE personA = :pid OR personB = :pid") fun observeCountForPerson(pid: Long): Flow + /** 全部边(关系分析:社群发现 + 最短路径)。 */ + @Query("SELECT personA, personB, weight FROM relations") + suspend fun allEdges(): List + + /** 总边数(圈子入口角标)。 */ + @Query("SELECT COUNT(*) FROM relations") + fun observeTotal(): Flow + /** 与某人有共现边的人(取边的另一端),按权重降序。 */ @Query( """ @@ -400,6 +448,12 @@ data class PhotoVec( val captionEmbedding: ByteArray, ) +/** 某人物名下照片数(相册「人物文件夹」封面 + 计数)。 */ +data class PersonPhotoCount( + val personId: Long, + val count: Int, +) + /** 相册分层进度(进度条用)。 */ data class PhotoProgress( val total: Int, @@ -587,6 +641,10 @@ interface PhotoDao { ) fun observeGrid(limit: Int = 500): Flow> + /** 每个人物名下的照片数(相册「人物文件夹」用)。 */ + @Query("SELECT personId AS personId, COUNT(DISTINCT photoId) AS count FROM faces WHERE personId IS NOT NULL GROUP BY personId") + fun observePersonPhotoCounts(): Flow> + /** 某个人出现的所有照片(faces.personId join)。 */ @Query( """ @@ -630,6 +688,10 @@ interface PhotoDao { @Query("DELETE FROM photos WHERE id = :id") suspend fun delete(id: Long) + + /** 按 path 模糊查 id(清理旧版误导入的朋友圈图用,pat 形如 "%/moment__%")。 */ + @Query("SELECT id FROM photos WHERE path LIKE :pat") + suspend fun idsByPathLike(pat: String): List } @Dao @@ -675,6 +737,10 @@ interface MomentDao { @Query("DELETE FROM moments") suspend fun clear() + /** 删某人的朋友圈(一键下载该人朋友圈前先清,避免重复累积)。 */ + @Query("DELETE FROM moments WHERE userName = :u") + suspend fun clearUser(u: String) + @Query("SELECT COUNT(*) FROM moments") suspend fun count(): Int diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Entities.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Entities.kt index 7e071f0..b801e0d 100644 --- a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Entities.kt +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Entities.kt @@ -213,6 +213,25 @@ data class Mention( ) /** + * 待跟进(followup):本地 LLM 从录音转写里抽出的「待办/承诺」——谁要做什么。 + * 去重键 (sourceTs, text) 唯一(同一段同一条只记一次)。[done] 用户勾掉。 + */ +@Entity( + tableName = "followups", + indices = [Index("done"), Index(value = ["sourceTs", "text"], unique = true)], +) +data class Followup( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + val text: String, + /** 关联人物(可空,从文本匹配 Person) */ + val personId: Long? = null, + /** 来源录音时间(窗口内最新段的墙钟) */ + val sourceTs: Long, + val done: Boolean = false, + val createdAt: Long = System.currentTimeMillis(), +) + +/** * 人↔人「共现」无向边:同一会话(5min 静音切)里同时**在场**(声纹匹配)或**被提到**(mentions) * 的两个人 → 一条边,[weight] = 共现过的会话数。规范化存 [personA] < [personB],(A,B) 唯一。 */ diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/vision/WeChatSnsImporter.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/vision/WeChatSnsImporter.kt new file mode 100644 index 0000000..05042a5 --- /dev/null +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/vision/WeChatSnsImporter.kt @@ -0,0 +1,146 @@ +package com.lemon.lifelog.vision + +import android.content.Context +import android.database.sqlite.SQLiteDatabase +import android.os.Process +import android.util.Log +import java.io.File + +/** + * 纯本地(端上 root)读微信朋友圈缓存图,按发布者 wxid 归属。**不经 Mac**。 + * + * 可行性(已在真机验证,37/37 大图 100% 归属): + * - `SnsMicroMsg.db` 是**明文 SQLite**(不加密);sns 缓存大图 `snsb_` 是**明文 JPEG**。 + * - 归属:每条朋友圈的 `SnsInfo.content`(明文 protobuf 字节)里含其各图的 media id 十进制串, + * 缓存文件名正是 `snsb_` → 「media id 是否作为子串出现在某条 content」即可把图归到该条作者 userName。 + * + * 唯一门槛是读 `com.tencent.mm` 私有文件需 root:用 `su` 一次性把 SnsMicroMsg.db + sns 大图缓存 + * 拷进本 app 私有目录(chown 给本 app uid),之后全部在 app 内处理,无需再 su。 + * + * 注意:只导入「**当前微信本地已缓存**」的图(= 你在微信里浏览过的朋友圈)。没缓存的不会凭空有, + * 需要的话先在微信里刷一下 TA 的朋友圈。(content 里也有 CDN URL,未来可加联网兜底下载。) + */ +object WeChatSnsImporter { + private const val TAG = "WeChatSns" + + /** 一条朋友圈:发布时间(ms) + 正文 + 它自己的图片文件(已拷到 destDir,按 `_.jpg` 命名)。 */ + data class MomentData(val createTimeMs: Long, val text: String, val images: List) + + data class Result( + val moments: List = emptyList(), + val error: String? = null, + ) + + /** + * 一趟 su + 读库:按「条」拉 [wxid] 的朋友圈——每条的正文 + 它自己已缓存的大图(拷到 [destDir])。 + * 失败(无 root / 微信路径变 / 没缓存 / 没授权)返回空 + error。**必须在 IO 线程调用**。 + */ + fun importMomentsFor(ctx: Context, wxid: String, destDir: File): Result { + if (wxid.isBlank()) return Result(error = "空 wxid") + val stage = File(ctx.filesDir, "sns_stage").apply { deleteRecursively(); mkdirs() } + val stagePath = stage.absolutePath + val uid = Process.myUid() + + // 一次 su:定位微信用户哈希目录 → 拷明文 SnsMicroMsg.db(含 wal/shm)+ sns 大图缓存 → chown 给本 app。 + // 关键:app 经 su 起的 shell PATH 常为空,必须显式 export,否则 ls/grep/find/cp/chown 全找不到。 + // 定位微信用户目录用 glob 直接找 SnsMicroMsg.db(不依赖 grep 的 {n} 区间)。 + val script = buildString { + append("export PATH=/system/bin:/system/xbin:\$PATH; ") + append("BASE=/data/user/0/com.tencent.mm; ") + append("DB=\$(ls \$BASE/MicroMsg/*/SnsMicroMsg.db 2>/dev/null | head -1); ") + append("[ -z \"\$DB\" ] && exit 3; ") + append("H=\$(basename \$(dirname \"\$DB\")); SNS=\$BASE/cache/\$H/sns; ") + append("cp \"\$DB\" $stagePath/sns.db || exit 4; ") + append("[ -f \"\$DB-wal\" ] && cp \"\$DB-wal\" $stagePath/sns.db-wal; ") + append("[ -f \"\$DB-shm\" ] && cp \"\$DB-shm\" $stagePath/sns.db-shm; ") + append("mkdir -p $stagePath/cache; ") + append("[ -d \"\$SNS\" ] && find \"\$SNS\" -type f -name 'snsb_*' -exec cp {} $stagePath/cache/ \\; ; ") + append("chown -R $uid:$uid $stagePath; chmod -R u+rwX $stagePath") + } + val (code, out) = su(script) + Log.i(TAG, "su stage code=$code out=$out") + if (code != 0) { + Log.w(TAG, "su stage failed code=$code out=$out") + val hint = when (code) { + 3 -> "没找到微信数据目录" + 4 -> "没找到 SnsMicroMsg.db" + else -> "读微信数据失败(需 Magisk 授予本 app root 权限):${out.take(120)}" + } + return Result(emptyList(), error = hint) + } + + val db = File(stage, "sns.db") + if (!db.exists()) return Result(emptyList(), error = "没找到 SnsMicroMsg.db") + // 缓存的大图(可能为空——纯文字朋友圈仍要导)。 + val snsb = File(stage, "cache").listFiles()?.filter { it.isFile && it.name.startsWith("snsb_") }.orEmpty() + val byId = snsb.associateBy { it.name.removePrefix("snsb_") } + destDir.mkdirs() + + // 按「条」读该 wxid 的朋友圈:每条 = 正文 + 它自己的图(content 里命中的 media id → snsb 缓存)。 + // createTime 是秒,存 ms 方便 Date() 展示;图按 `_.jpg` 命名,UI 据 ms glob 回该条的图。 + val moments = ArrayList() + var imgTotal = 0 + try { + SQLiteDatabase.openDatabase(db.absolutePath, null, SQLiteDatabase.OPEN_READONLY).use { sdb -> + sdb.rawQuery( + "SELECT createTime, content FROM SnsInfo WHERE userName = ? AND content IS NOT NULL ORDER BY createTime DESC", + arrayOf(wxid), + ).use { c -> + while (c.moveToNext()) { + val ms = c.getLong(0) * 1000L + val content = c.getBlob(1) ?: continue + val raw = String(content, Charsets.ISO_8859_1) + val ids = byId.keys.filter { it in raw } + val text = momentText(content) + if (ids.isEmpty() && text.isBlank()) continue + val imgs = ArrayList() + ids.forEachIndexed { i, id -> + val src = byId[id] ?: return@forEachIndexed + val dst = File(destDir, "${ms}_$i.jpg") + runCatching { src.copyTo(dst, overwrite = true); imgs.add(dst); imgTotal++ } + } + moments.add(MomentData(ms, text, imgs)) + } + } + } + } catch (t: Throwable) { + stage.deleteRecursively() + return Result(error = "解析 SnsMicroMsg.db 失败:${t.message}") + } + stage.deleteRecursively() + if (moments.isEmpty()) return Result(error = "这台机器上还没缓存 TA 的朋友圈") + Log.i(TAG, "wxid=$wxid 朋友圈:${moments.size} 条,图 $imgTotal 张") + return Result(moments) + } + + /** + * 从朋友圈 content(protobuf 字节)抠出可读正文。 + * 思路同 Mac 端 extract-wechat-chat-moments.py:按字符类切出候选串,去掉 URL/wxid/纯字母数字, + * 取中文最多的那段(朋友圈正文)。content 里的文本字段是 UTF-8,二进制部分被字符类自然排除。 + */ + private fun momentText(content: ByteArray): String { + val s = String(content, Charsets.UTF_8) + val cand = Regex("[\\u4e00-\\u9fffA-Za-z0-9,。!?、,.!?#@::…~()()【】\\-—\\s]{2,}") + .findAll(s).map { it.value.trim() } + .filter { it.length >= 2 } + .filter { c -> !listOf("http", "wxid", "mmsns", "mp.weixin").any { c.startsWith(it) } } + .filter { !it.matches(Regex("[A-Za-z0-9]+")) } + .toList() + val cjk = Regex("[\\u4e00-\\u9fff]") + return cand.maxByOrNull { cjk.findAll(it).count() }?.trim().orEmpty() + } + + /** + * 跑一条 root 命令,返回 (exitCode, 合并输出)。su 不可用 / 被拒返回非 0。 + * `--mount-master`:app 申请的 su 默认在本 app 的挂载命名空间,看不到别 app 的 /data; + * 切到**全局挂载命名空间**才能读 com.tencent.mm 的数据目录。 + */ + private fun su(script: String): Pair = try { + val p = ProcessBuilder("su", "--mount-master", "-c", script).redirectErrorStream(true).start() + val out = p.inputStream.bufferedReader().readText() + p.waitFor() + p.exitValue() to out + } catch (t: Throwable) { + -1 to (t.message ?: "su 不可用") + } +} -- libgit2 0.21.4