Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@
后生通过通知使用权读取通知,并按本人设定的规则清理。以下属于安全问题:

- 通知内容、应用清单或统计数据离开本机,或被其他应用读到。
- 发行版获得网络权限,或绕过“发行版不联网”的约束。
- 不联网版“后生”获得网络权限,或“后生 AI”在未开启筛选时联网。
- 其他应用不经本人操作就能改变后生的规则或开关。
- 调试版保存的 API 凭据以明文落盘或被其他应用读到。
- “后生 AI”保存的 API 凭据以明文落盘或被其他应用读到。
- 发行 APK 与本仓库源码和签名不一致。

用户本人主动设置的规则按设定执行,不属于安全问题。
21 changes: 10 additions & 11 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,24 +36,23 @@ jobs:
- name: 单元测试、静态检查与全部构建
run: >-
./gradlew --no-daemon --console=plain
:app:testDebugUnitTest :app:lintDebug :fixture:lintDebug
:app:testOfflineDebugUnitTest :app:testOnlineDebugUnitTest
:app:lintOnlineDebug :app:lintOfflineRelease :fixture:lintDebug
:app:assembleDebug :fixture:assembleDebug
:app:assembleDebugAndroidTest :fixture:assembleDebugAndroidTest
:app:assembleOfflineDebugAndroidTest :app:assembleOnlineDebugAndroidTest :fixture:assembleDebugAndroidTest
:app:assembleRelease
- name: 确认发行构建不请求网络权限
shell: bash
run: |
permissions="$($ANDROID_HOME/build-tools/35.0.0/aapt2 dump permissions app/build/outputs/apk/release/app-release-unsigned.apk)"
if grep -q 'android.permission.INTERNET' <<< "$permissions"; then
echo '::error::发行构建不应包含网络权限'
exit 1
fi
- name: 确认不联网版无权限、联网版只有网络权限
run: >-
scripts/check-permissions.sh "$ANDROID_HOME/build-tools/35.0.0/aapt2"
app/build/outputs/apk/offline/release/app-offline-release-unsigned.apk
app/build/outputs/apk/online/release/app-online-release-unsigned.apk
- name: 保存家庭试用安装包
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: housheng-debug-apks
path: |
app/build/outputs/apk/debug/app-debug.apk
app/build/outputs/apk/offline/debug/app-offline-debug.apk
app/build/outputs/apk/online/debug/app-online-debug.apk
fixture/build/outputs/apk/debug/fixture-debug.apk
if-no-files-found: error
retention-days: 14
Expand Down
26 changes: 14 additions & 12 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ concurrency:

jobs:
release:
name: 签名发行包并创建 Release
name: 签名两版发行包并创建 Release
runs-on: ubuntu-24.04
timeout-minutes: 25
steps:
Expand Down Expand Up @@ -57,21 +57,23 @@ jobs:
HOUSHENG_KEYSTORE_PASSWORD: ${{ secrets.HOUSHENG_KEYSTORE_PASSWORD }}
HOUSHENG_KEY_ALIAS: ${{ secrets.HOUSHENG_KEY_ALIAS }}
HOUSHENG_KEY_PASSWORD: ${{ secrets.HOUSHENG_KEY_PASSWORD }}
run: ./gradlew --no-daemon --console=plain :app:testDebugUnitTest :app:lintDebug :app:assembleRelease
run: >-
./gradlew --no-daemon --console=plain
:app:testOfflineDebugUnitTest :app:testOnlineDebugUnitTest
:app:lintOnlineDebug :app:lintOfflineRelease :app:assembleRelease
- name: 检查权限与签名并计算校验和
shell: bash
run: |
tools="$ANDROID_HOME/build-tools/35.0.0"
apk=app/build/outputs/apk/release/app-release.apk
permissions="$("$tools/aapt2" dump permissions "$apk")"
if grep -q 'android.permission.INTERNET' <<< "$permissions"; then
echo '::error::发行构建不应包含网络权限'
exit 1
fi
"$tools/apksigner" verify --print-certs "$apk"
offline=app/build/outputs/apk/offline/release/app-offline-release.apk
online=app/build/outputs/apk/online/release/app-online-release.apk
scripts/check-permissions.sh "$tools/aapt2" "$offline" "$online"
"$tools/apksigner" verify --print-certs "$offline"
"$tools/apksigner" verify --print-certs "$online"
mkdir dist
cp "$apk" "dist/housheng-$VERSION.apk"
(cd dist && sha256sum "housheng-$VERSION.apk" > SHA256SUMS && cat SHA256SUMS)
cp "$offline" "dist/housheng-$VERSION.apk"
cp "$online" "dist/housheng-online-$VERSION.apk"
(cd dist && sha256sum "housheng-$VERSION.apk" "housheng-online-$VERSION.apk" > SHA256SUMS && cat SHA256SUMS)
- name: 创建 GitHub Release
shell: bash
env:
Expand All @@ -83,7 +85,7 @@ jobs:
if [ -f "$notes" ]; then args+=(--notes-file "$notes"); else args+=(--generate-notes); fi
if [[ "$VERSION" == *-* ]]; then args+=(--prerelease); fi
gh release create "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" "${args[@]}" \
"dist/housheng-$VERSION.apk" dist/SHA256SUMS
"dist/housheng-$VERSION.apk" "dist/housheng-online-$VERSION.apk" dist/SHA256SUMS
- name: 删除签名密钥
if: always()
run: rm -f "$RUNNER_TEMP/release.jks"
19 changes: 11 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,13 +89,16 @@
截图来自 Android 13 模拟器,数字是演示数据。

> [!NOTE]
> 调试预览版有**小红书 9.49.0 发现页标题筛选**,以及**抖音、抖音极速版 40.6.0 和快手、快手极速版 14.8.40 推荐页视频文案筛选**。每个应用需单独确认开启,命中的卡片或视频模糊遮挡并可恢复,视频不会自动划走。视频文案筛选尚未在真机验证,视频画面识别未实现。见 [当前限制](docs/development.md#当前限制)。
> 联网版“后生 AI”有**小红书 9.49.0 发现页标题筛选**,以及**抖音、抖音极速版 40.6.0 和快手、快手极速版 14.8.40 推荐页视频文案筛选**。每个应用需单独确认开启,命中的卡片或视频模糊遮挡并可恢复,视频不会自动划走。视频文案筛选尚未在真机验证,视频画面识别未实现。见 [当前限制](docs/development.md#当前限制)。

## 安装

需要 Android 11 或以上。当前下载提供 Clef 调试预览包,供家人验证使用。
需要 Android 11 或以上。每个版本有两个安装包,可以同时装在一台手机上:

1. 打开 [发行版页面](https://github.com/PathGao/housheng/releases),下载 `housheng-<版本>.apk`,可用同页的 `SHA256SUMS` 校验。
- `housheng-<版本>.apk`:桌面名“后生”,不联网,只有通知清理、家庭报告和应用清单。
- `housheng-online-<版本>.apk`:桌面名“后生 AI”,另有 Clef 信息流筛选和模糊遮挡,带网络权限,所有筛选默认关闭。

1. 打开 [发行版页面](https://github.com/PathGao/housheng/releases),下载需要的安装包,可用同页的 `SHA256SUMS` 校验。
2. 打开后生,在首页点“去系统设置允许”,授予通知使用权。
3. 在“通知清理”里选要管理的应用。先保持“按规则清理通知”关闭,观察几天次数。
4. 和家人一起看“家庭报告”,定好关键词和放行来源,再开启清理。
Expand All @@ -107,11 +110,11 @@

## 隐私

- **通知在本机处理。** 标准 release 构建没有网络权限。当前 Clef 调试预览包有网络权限,只有主动开启的信息流筛选会调用 Cloudflare。
- **通知在本机处理。** “后生”没有网络权限。“后生 AI”有网络权限,只有主动开启的信息流筛选会调用 Cloudflare。
- **只看你勾选的应用。** 浏览器、聊天和名单外应用一概不采集。
- **不存正文。** 通知标题和正文不落盘,本机只留最近 30 天的次数汇总。
- **报告不自动发送。** 报告不含正文、验证码或私聊,由爸妈预览后自己分享。
- **现在没有账号。** 无远程控制,无同步。调试版可主动启用 Clef API,单独开启的应用只发送小红书公开卡片标题或短视频推荐页当前一条的文案,凭据由本人配置并在手机加密保存。
- **现在没有账号。** 无远程控制,无同步。“后生 AI”可主动启用 Clef API,单独开启的应用只发送小红书公开卡片标题或短视频推荐页当前一条的文案,凭据由本人配置并在手机加密保存。
- **后续 AI 设置单独开启。** 提示词整理和孩子账号同步尚在规划,发送范围和授权要求见 [路线图](docs/roadmap.md#ai-与联网)。
- **不越权。** 不屏蔽广告,不点“跳过”,不绕过应用限制,不自动卸载。

Expand All @@ -134,14 +137,14 @@
需要 JDK 17 和 Android SDK 35,Gradle 用仓库自带的 Wrapper。

```sh
./gradlew :app:testDebugUnitTest :app:lintDebug :app:assembleDebug
./gradlew :app:testOfflineDebugUnitTest :app:testOnlineDebugUnitTest :app:lintOnlineDebug :app:assembleDebug
```

调试包在 `app/build/outputs/apk/debug/app-debug.apk`。CI 会跑单元测试、Lint、调试与发行构建,并检查发行版没有网络权限。
两版调试包在 `app/build/outputs/apk/offline/debug/` 和 `app/build/outputs/apk/online/debug/`。CI 会跑两版单元测试、Lint、调试与发行构建,并检查“后生”没有任何权限、“后生 AI”只有网络权限。

| 目录 / 文档 | 内容 |
|---|---|
| `app/` | 主程序:通知清理、家庭报告、应用清单 |
| `app/` | 主程序:通知清理、家庭报告、应用清单。`src/online` 是“后生 AI”独有的 Clef 与信息流筛选 |
| `fixture/` | 合成验证场,测试翻页与保护边界 |
| `validation/clef/` | Clef 手机直连、USB 对照与固定样本评估,见 [说明](validation/clef/README.md) |
| [开发入口](docs/development.md) | 本机构建、真实应用验证与清理范围 |
Expand Down
10 changes: 8 additions & 2 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,16 @@ android {
applicationId = "io.github.pathgao.housheng"
minSdk = 30
targetSdk = 35
versionCode = 5
versionName = "0.2.3-clef-preview"
versionCode = 6
versionName = "0.2.4"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
// offline 不带网络权限;online 加 Clef 与真实应用筛选,包名不同,两版可同时安装。
flavorDimensions += "network"
productFlavors {
create("offline") { dimension = "network" }
create("online") { dimension = "network"; applicationIdSuffix = ".online" }
}
// 发行签名只从环境变量读取,缺省时 assembleRelease 产出未签名包,CI 照常通过。
val keystorePath = System.getenv("HOUSHENG_KEYSTORE_PATH")
if (keystorePath != null) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import java.util.concurrent.atomic.AtomicBoolean
class DeviceValidationTest {
companion object {
@JvmStatic @BeforeClass fun connectServices() {
val component = "io.github.pathgao.housheng/io.github.pathgao.housheng.NotificationService"
val component = ComponentName(InstrumentationRegistry.getInstrumentation().targetContext, NotificationService::class.java).flattenToString()
DeviceUi.shell("cmd notification disallow_listener $component")
DeviceUi.shell("cmd notification allow_listener $component")
DevicePreparation().reconnectServicesForInstrumentation()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ class PlatformValidationTest {
private fun open(type: Class<out Activity>): Activity {
// MIUI blocks launches from the background after instrumentation force-stops its target.
instrumentation.getUiAutomation(UiAutomation.FLAG_DONT_SUPPRESS_ACCESSIBILITY_SERVICES)
.executeShellCommand("am start -W -n ${context.packageName}/.MainActivity").use { descriptor ->
.executeShellCommand("am start -W -n ${context.packageName}/${MainActivity::class.java.name}").use { descriptor ->
FileInputStream(descriptor.fileDescriptor).use { it.readBytes() }
}
return instrumentation.startActivitySync(Intent(context, type).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ class ProductFlowTest {
return view
}
private fun home(): Activity {
DeviceUi.shell("am start -W -f 0x10008000 -n ${context.packageName}/.MainActivity")
DeviceUi.shell("am start -W -f 0x10008000 -n ${context.packageName}/${MainActivity::class.java.name}")
var activity: Activity? = null
val deadline = Session.now() + 5000
while (Session.now() < deadline) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ class ClefConfigurationTest {
val config = try { JSONObject(file.readText()) } finally { file.delete() }
val store = ClefCredentials(context)
store.save(config.getString("account"), config.getString("token"))
store.direct = InstrumentationRegistry.getArguments().getString("modelTransport") == "direct"
ModelTransport.setDirect(context, InstrumentationRegistry.getArguments().getString("modelTransport") == "direct")
val loaded = ClefCredentials(context).load()
assertEquals(config.getString("account"), loaded?.first)
assertTrue("encrypted credentials must round-trip", loaded?.second == config.getString("token"))
Expand All @@ -28,7 +28,7 @@ class ClefConfigurationTest {
val context = instrumentation.targetContext
val store = ClefCredentials(context)
val original = store.load()
DeviceUi.shell("am start -W -f 0x10008000 -n io.github.pathgao.housheng/.MainActivity")
DeviceUi.shell("am start -W -f 0x10008000 -n ${context.packageName}/${MainActivity::class.java.name}")
val monitor = instrumentation.addMonitor(ClefSettingsActivity::class.java.name, null, false)
instrumentation.runOnMainSync {
context.startActivity(android.content.Intent(context, ClefSettingsActivity::class.java).addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ class ModelClientTest {
}
@Test fun realClefOnSelectedTransport() {
val start = Session.now()
val choice = ModelValidation.classify(InstrumentationRegistry.getInstrumentation().targetContext, "这条消息必须转发二十个群,不转发的家庭一定会遭灾!")
val choice = ModelTransport.classify(InstrumentationRegistry.getInstrumentation().targetContext, "这条消息必须转发二十个群,不转发的家庭一定会遭灾!")
assertEquals("filter", choice)
InstrumentationRegistry.getInstrumentation().sendStatus(0, Bundle().apply {
putString("modelRoundtrip", "$choice · ${Session.now() - start}ms")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ class RealAppValidationTest {
}
assertTrue("Actual scroll must reveal different cards", changed)
assertTrue("Clef must resume after actual scroll: ${Session.journal()}", resumed)
DeviceUi.shell("am start -W -f 0x10008000 -n io.github.pathgao.housheng/.MainActivity")
DeviceUi.shell("am start -W -f 0x10008000 -n ${instrumentation.targetContext.packageName}/${MainActivity::class.java.name}")
Thread.sleep(300)
assertNull("Leaving the feed must remove overlays", revealNode())
instrumentation.sendStatus(0, Bundle().apply { putString("realScroll", "Real list advanced, Clef resumed, overlays cleared on app switch") })
Expand Down
6 changes: 3 additions & 3 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
<queries>
<intent><action android:name="android.intent.action.MAIN"/><category android:name="android.intent.category.LAUNCHER"/></intent>
</queries>
<application android:label="后生" android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher" android:theme="@style/Theme.Housheng" android:allowBackup="false" android:fullBackupContent="false" android:dataExtractionRules="@xml/data_extraction_rules" android:supportsRtl="true">
<application android:label="@string/app_name" android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher" android:theme="@style/Theme.Housheng" android:allowBackup="false" android:fullBackupContent="false" android:dataExtractionRules="@xml/data_extraction_rules" android:supportsRtl="true">
<activity android:name=".NotificationsActivity" android:exported="false"/>
<activity android:name=".ReportActivity" android:exported="false"/>
<activity android:name=".DiagnosticsActivity" android:exported="false"/>
Expand All @@ -11,10 +11,10 @@
<activity android:name=".MainActivity" android:exported="true">
<intent-filter><action android:name="android.intent.action.MAIN"/><category android:name="android.intent.category.LAUNCHER"/></intent-filter>
</activity>
<service android:name=".NotificationService" android:label="后生通知观察" android:permission="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE" android:exported="true">
<service android:name=".NotificationService" android:label="@string/notification_service_label" android:permission="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE" android:exported="true">
<intent-filter><action android:name="android.service.notification.NotificationListenerService"/></intent-filter>
</service>
<service android:name=".FeedService" android:label="后生页面观察" android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE" android:exported="true">
<service android:name=".FeedService" android:label="@string/page_service_label" android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE" android:exported="true">
<intent-filter><action android:name="android.accessibilityservice.AccessibilityService"/></intent-filter>
<meta-data android:name="android.accessibilityservice" android:resource="@xml/accessibility_service"/>
</service>
Expand Down
6 changes: 3 additions & 3 deletions app/src/main/java/io/github/pathgao/housheng/AboutActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import android.app.Activity
import android.os.Bundle

class AboutActivity : Activity() {
private val sections = listOf(
private val sections by lazy { listOf(
"会做的事" to listOf(
"只处理你选的应用发来的通知。",
"按天记录通知次数,保存最近 30 天。",
Expand All @@ -17,13 +17,13 @@ class AboutActivity : Activity() {
"不跳过广告,不自动卸载应用。"
),
"还没开放" to listOf(
"抖音、快手仍未开放。调试版可另行启用小红书发现页标题筛选,标题会发送给 Cloudflare Clef,图片和私信不上传。"
getString(R.string.about_pending)
),
"需要知道的" to listOf(
"清理在通知到达后进行,已经响起的提示音无法撤回。",
"想彻底关闭某个应用的通知,用系统通知设置。"
)
)
) }

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Expand Down
Loading
Loading