本文件面向 AI 编码代理与开发者,规定项目结构、实现方案与编码约定。修改代码前必须通读。
LinuxAndroid(界面品牌名 ProotTerm)是一个免 root 的 Android 应用:内置 PRoot(Termux 适配版)二进制,在应用私有目录解压用户选择的 Linux rootfs(Alpine / Debian / Ubuntu),并通过一个简易文本终端驱动其中的 shell。
- applicationId / namespace / Kotlin 包名:com.li63050a.linuxandroid(三者一致,Manifest 的
.MainActivity依赖 namespace 解析,源码包名不得再改回其他值) - versionCode 1 / versionName
0.0.0.1 - 目标架构:仅
arm64-v8a - 语言:Kotlin 100%,界面:XML 布局 + AppCompat + Material Components(DrawerLayout / NavigationView / MaterialToolbar / MaterialCardView / RecyclerView,不引入 Compose)
- rootfs 解压后的目录:
filesDir/rootfs/<versionId>(如alpine-3.20) - 日志:
AppLogger→ 内存环形缓冲 +filesDir/logs/app.log(1MB 滚动.old)+ Logcat;LogActivity查看/复制/一键分享(FileProvidercontent://) - 不申请任何存储权限,全部使用应用私有目录
- Git 远程:
git@github.com:LiStudioorg/linuxandroid.git(SSH,禁止改回 HTTPS)
| 依赖 | 版本 | 用途 |
|---|---|---|
| OkHttp | 4.12.0 | rootfs 下载、Range 断点续传、多镜像回退 |
| Apache Commons Compress | 1.26.0 | tar 流解析 |
| org.tukaani:xz | 1.9 | tar.xz 解压 |
| kotlinx-coroutines-android | 1.8.0 | 异步任务、生命周期作用域 |
Android framework org.json |
内置 | 解析 rootfs_manifest.json(不引入 kotlinx-serialization) |
构建链:
| 项 | 版本 |
|---|---|
| AGP | 8.11.1 |
| Kotlin | 2.0.21 |
| Gradle(wrapper) | 8.13(gradle/wrapper/gradle-wrapper.properties) |
| JDK | 17 |
| compileSdk / targetSdk | 36 |
| minSdk | 24(Android 7.0 ~ Android 16) |
以下 API 为 26+,必须用 Build.VERSION.SDK_INT >= Build.VERSION_CODES.O 分支保护或改用低版本等价实现:
Process.isAlive/Process.waitFor(long,TimeUnit)/Process.destroyForcibly(见ProotSession)java.nio.file.Files/toPath()(禁止使用,见RootfsExtractor.deleteNoFollow改用Os.lstat+Os.remove)
android.system.Os.*(symlink/link/chmod/lstat/remove)自 API 21 可用,可直接使用。
优先级:环境变量(GitHub Actions 注入)> local.properties(本地)> 兜底 myapp.jks。
- 环境变量 / local.properties 键名一致:
KEYSTORE_PATH、KEYSTORE_PASSWORD、KEY_ALIAS、KEY_PASSWORD - local.properties 已 gitignore;CI 见第 7 节 Secrets
AGENTS.md / README.md
settings.gradle.kts # rootProject.name = "LinuxAndroid"
build.gradle.kts # AGP 8.11.1 + Kotlin 2.0.21
gradle.properties
gradlew / gradlew.bat # Gradle 8.13 wrapper(入库,CI 直接 ./gradlew)
gradle/wrapper/{gradle-wrapper.jar, gradle-wrapper.properties}
.gitignore # 不排除 jniLibs *.so(已入库)
.github/workflows/android.yml # tag → Release;workflow_dispatch → 仅产物(无 Fetch 步骤)
app/
build.gradle.kts # 包名/SDK/签名/packaging.jniLibs
proguard-rules.pro
src/main/
AndroidManifest.xml # extractNativeLibs="true",.App + MainActivity + LogActivity + FileProvider
assets/
rootfs_manifest.json # version 2:三发行版家族 → 多历史版本
native/arm64-v8a/ # 运行期依赖(入库)
libtalloc.so.2 # proot NEEDED;名字不以 .so 结尾不进 jniLibs
libandroid-shmem.so # proot NEEDED
jniLibs/arm64-v8a/ # *.so 已入库(.gitignore 不排除)
libproot.so # ← usr/bin/proot
libproot-loader.so # ← usr/libexec/proot/loader
README.txt # deb 提取步骤(兜底说明)
res/xml/file_paths.xml # FileProvider paths(logs)
res/layout/{activity_main,activity_log,nav_header,view_terminal,view_distros,
view_versions,view_settings,item_distro,item_version}.xml
res/menu/nav_menu.xml # 终端/发行版/日志/设置(默认发行版)
res/drawable/{ic_launcher,ic_menu,ic_search,ic_terminal,ic_distro,ic_settings,
ic_logs,ic_back,bg_search,bg_terminal,bg_circle}.xml
res/values/{strings,themes,colors}.xml
java/com/li63050a/linuxandroid/
App.kt # 全局 Application:AppLogger 初始化 + 未捕获异常落盘
AppLogger.kt # 日志:内存缓冲 + logs/app.log + Logcat
LogActivity.kt # 日志页:刷新/清空/复制/分享(FileProvider content://)
DistroInfo.kt # DistroFamily / DistroVersion / DistroInfo 数据类
ManifestLoader.kt # assets JSON 解析(version 2 嵌套结构)
RootfsManager.kt # 目录规划、安装标记、原子切换
RootfsDownloader.kt # OkHttp 下载(断点续传 + 多镜像)
RootfsExtractor.kt # tar 解压(符号链接/权限/防穿越,API24 安全)
ProotSession.kt # 持久 shell 进程生命周期 + LD_LIBRARY_PATH
NativeDeps.kt # 释放 assets 运行库到 filesDir/native
Sha256Utils.kt # 流式 SHA256
DistroAdapter.kt # 家族卡片 RecyclerView 适配器
VersionAdapter.kt # 版本列表 RecyclerView 适配器
MainActivity.kt # Drawer 四屏编排(终端/发行版/版本/设置)
下载 .part(断点续传)
→ SHA256 校验(清单 sha256 非空时)
→ 解压到 filesDir/tmp_<id>(保留符号链接与权限位,防路径穿越)
→ 删除旧 filesDir/rootfs/<id>,tmp 目录原子 rename 为最终目录
→ 在最终目录写入 .installed 标记,删除 .part 缓存
→ 按钮变为“启动”
失败:删半成品 tmp;.part 保留供续传;按钮恢复“下载”。
- 候选地址:
url→mirrors[],失败自动切换,.part保留 - 临时文件:
filesDir/<id>.<format>.part - 已有字节 > 0 时发
Range: bytes=<n>-+Accept-Encoding: identity 206:校验 Content-Range 起点后追加;200:截断覆盖;416:视为已完整- 实际字节 < 服务端总长 → 抛“下载不完整”进入下一镜像
- 进度回调节流 200ms;总长未知时 UI 兜底用清单
size(仅展示,不参与完整性判断) - 取消:
cancel()结束活动 Call →ensureActive()抛 CancellationException,不再切镜像
format→GzipCompressorInputStream/XZCompressorInputStream包TarArchiveInputStream- 路径穿越:条目名去前导
/拼接后,canonicalPath必须等于 dest 或以dest + separator开头 - 普通文件:
Os.chmod(path, entry.mode and 0x1FF)(mode 0 跳过) - 符号链接:
Os.symlink(entry.linkName, outFile.absolutePath),linkName 原文写入 - 删除已存在路径:
Os.lstat+Os.remove(不跟随链接;禁用 java.nio.file) - 硬链接:主循环收集,结束后多轮重试直至无进展
- 目录:解压期间保持默认可写,全部结束后逆序统一
chmod还原
<nativeLibraryDir>/libproot.so
-r <rootfs> -0 -w /root
-b /dev -b /proc -b /sys -b /dev/urandom:/dev/random
<defaultShell>
必设环境变量(全部绝对路径):
| 变量 | 值 |
|---|---|
PROOT_TMP_DIR |
filesDir/proot_tmp |
PROOT_LOADER |
nativeLibraryDir/libproot-loader.so |
LD_LIBRARY_PATH |
filesDir/native(NativeDeps 释放的 libtalloc/libandroid-shmem;proot 内置 RUNPATH 指向 Termux 私有目录,其他设备不可用) |
guest 侧环境:TERM=xterm-256color、HOME=/root、LANG=C.UTF-8、TMPDIR=/tmp、guest PATH。
redirectErrorStream(true);后台线程InputStreamReader(UTF_8)读取,主线程 Handler 回调- 启动前
NativeDeps.ensure()释放运行库;prootBin.setExecutable(true)兜底 - 非 PTY 无提示符/无回显:UI 本地回显
$ <cmd>;README 提示sh -i - API 24:
isRunning用exitValue();destroy 后台线程低版本仅waitFor()
- 结构:
DrawerLayout + NavigationView,默认「发行版管理」;顶栏MaterialToolbar汉堡开抽屉,副标题显示全局状态- 发行版管理(Screen
DISTROS)view_distros.xml+DistroAdapter:三家族卡片(Alpine/Debian/Ubuntu),点击进入版本页 - 版本选择(Screen
VERSIONS)view_versions.xml+VersionAdapter:该家族多历史版本(大小/描述/下载-启动-卸载);返回键回发行版页 - 终端页(Screen
TERMINAL)view_terminal.xml:搜索框过滤输出 + 纯黑等宽终端 + 快捷键行(ESC/CTRL/TAB/方向键,CTRL 组合经btn_key_<ch>查找)+ 命令输入/发送 - 设置页(Screen
SETTINGS)view_settings.xml:版本/包名/ABI/SDK/rootfs 路径、GitHub 链接、清空终端
- nav_logs 不占 Screen,直接
startActivity(LogActivity::class.java)
- 发行版管理(Screen
- 终端返回键:会话运行中弹「保持运行 / 关闭终端」对话框;否则回发行版页
- 图标:全部 vector drawable;应用图标
ic_launcher.xml(>_深色圆角方块) - 状态色:页面
#F5F5F5、强调粉#FFB6C1/ 浅蓝#ADD8E6;状态栏windowLightStatusBar - 同时仅一个安装任务;家族/版本卡片状态由
DistroAdapter.setState/VersionAdapter.setState驱动;下载中切屏用lastDownloadPct恢复进度 - 安装/启动全包在
Dispatchers.IO+ 全局 try-catch + 下载前RootfsManager空间检查;失败只 Toast 不闪退 - 终端:
outputRaw缓冲 + 搜索过滤渲染,超 200KB 截头;自动滚底 - 作用域
MainScope();onDestroy取消作用域/job、cancel 下载 Call、destroy shell
filesDir/
rootfs/<id>/ # 最终 rootfs(含 .installed)
tmp_<id>/ # 解压临时目录
<id>.<format>.part # 断点续传
native/ # NativeDeps 释放的 libtalloc.so.2 等
proot_tmp/ # PROOT_TMP_DIR
顶层 {"version": 2, "distros": [ { id/name/description/icon, versions: [...] } ]},版本字段均必填,mirrors/sha256 可为空:
| 字段 | 类型 | 说明 |
|---|---|---|
| id | string | 家族 id(alpine/debian/ubuntu);版本 id 如 alpine-3.20 作目录名 |
| name | string | 家族名 / 版本名 |
| description | string | 描述 |
| size | long | 预计字节(进度 UI 兜底,不参与完整性校验) |
| url | string | 主下载地址 |
| mirrors | string[] | 备用镜像(如 tuna) |
| sha256 | string | 小写十六进制;空串跳过校验 |
| format | string | tar.gz / tar.xz |
| defaultShell | string | guest shell 绝对路径 |
已知:Alpine 3.20/3.19/3.18(dl-cdn+sha256)、Debian 13/12/11(termux proot-distro tar.xz,sha256 空)、Ubuntu 24.04.5/22.04.5/20.04.5(cdimage arm64 tar.gz+sha256)。
# 1) PRoot 二进制已随仓库入库(app/src/main/jniLibs/arm64-v8a/*.so)
# 若需重新提取:见同目录 README.txt(Termux deb → 重命名放入)
# 2) 本地签名(可选,仅 assembleRelease 需要)
cat >> local.properties <<'EOF'
KEYSTORE_PATH=/abs/path/my.jks
KEYSTORE_PASSWORD=***
KEY_ALIAS=***
KEY_PASSWORD=***
EOF
# 3) 构建(wrapper 已入库,Gradle 8.13 自动下载)
./gradlew assembleDebug # 调试包,不依赖签名
./gradlew assembleRelease # 正式包,需签名四件套push任意 tag → 构建 + 自动创建 GitHub Release(附 APK)- workflow_dispatch → 仅构建 + Artifact 上传,不发 Release
- 步骤顺序:Checkout → JDK17 → Accept SDK Licenses → Decode Keystore → chmod gradlew →
./gradlew assembleRelease→ Upload → Release(无 Fetch PRoot 步骤,jniLibs两个 .so 已入库直接打包) - 所需 Secrets:
KEYSTORE_BASE64(jks 的 base64)、KEYSTORE_PASSWORD、KEY_ALIAS、KEY_PASSWORD
grep -r "com.example" app/src/main/java/ # 必须为空
ls -l gradle/wrapper/ # jar + properties 齐全
git remote -v # 必须是 git@github.com:LiStudioorg/linuxandroid.git
test -f app/src/main/jniLibs/arm64-v8a/libproot.so && echo local-so-ok
./gradlew assembleDebug- UI 使用 AppCompat + Material Components(DrawerLayout / NavigationView / RecyclerView / MaterialCardView / MaterialToolbar,浅色主题);布局 XML + Kotlin,不引入 Compose。
- 不申请存储权限;不读写应用私有目录以外路径。
- Manifest 必须
android:extractNativeLibs="true";Gradle 必须packaging { jniLibs { useLegacyPackaging = true } }。 - 仅
arm64-v8a。 - 包名三处一致:
com.li63050a.linuxandroid(namespace / applicationId / Kotlin package)。 - 禁止使用
java.nio.file;Process.isAlive/waitFor(timeout)/destroyForcibly必须 SDK_INT 分支。 - 下载必须
.part+ Range;解压必须先 tmp 再 rename;符号链接、权限位、路径穿越三者缺一不可。 PROOT_TMP_DIR、PROOT_LOADER、LD_LIBRARY_PATH必须设置为绝对路径。jniLibs/**/*.so必须入库(.gitignore不得排除;assets/native/**运行库也必须入库)。- 新增发行版/历史版本只改
rootfs_manifest.json嵌套结构,不动下载/解压核心逻辑;删除自定义发行版 FAB 功能已废弃,禁止加回。 - 远程仓库只用 SSH:
git@github.com:LiStudioorg/linuxandroid.git。 - 日志分享必须 FileProvider
content://,禁止file://;闪退前堆栈必须先写AppLogger再结束进程。