Compare commits
49 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d9ec54014 | ||
|
|
8c6021170f | ||
|
|
a3355d07e4 | ||
|
|
528c1ac029 | ||
|
|
22e5a8ab41 | ||
|
|
9020b868d0 | ||
|
|
7b34b565a9 | ||
|
|
e72ab719ce | ||
|
|
0bb379c1a4 | ||
|
|
6fe4920a85 | ||
|
|
29f40434e8 | ||
|
|
f4b7dc3aec | ||
|
|
00cf2bc2f4 | ||
|
|
e9a1697145 | ||
|
|
fbf3f9d179 | ||
|
|
bd5f4b92ab | ||
|
|
b844eaba7f | ||
|
|
1213f9fe18 | ||
|
|
28e49da9ed | ||
|
|
a15ca7243a | ||
|
|
23fdbab406 | ||
|
|
8122f64923 | ||
|
|
b249942c13 | ||
|
|
8ff28b14f6 | ||
|
|
250b387079 | ||
|
|
246eff5f0b | ||
|
|
64ded465e6 | ||
|
|
1fdba019d7 | ||
|
|
1fb93c3137 | ||
|
|
2c52b198bd | ||
|
|
818faefa86 | ||
|
|
a806768c8b | ||
|
|
4e954d375e | ||
|
|
9e7e351193 | ||
|
|
cffa9a2b8a | ||
|
|
834f515e01 | ||
|
|
949d13f1ea | ||
|
|
d701951338 | ||
|
|
7743c35763 | ||
|
|
f854569414 | ||
|
|
6c9c8fe1b8 | ||
|
|
4f97cf75b6 | ||
|
|
f0ae32b3f9 | ||
|
|
7c780b30c0 | ||
|
|
5faedd53af | ||
|
|
1f3e1ceea8 | ||
|
|
3813f49a12 | ||
|
|
b2ea0c7960 | ||
|
|
058bf23465 |
33
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
33
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
@@ -0,0 +1,33 @@
|
||||
---
|
||||
name: Bug 报告
|
||||
about: 报告一个 Bug 帮助我们改进
|
||||
title: '[Bug] '
|
||||
labels: bug
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
**问题描述**
|
||||
清晰简洁地描述这个 Bug。
|
||||
|
||||
**复现步骤**
|
||||
1. 打开应用...
|
||||
2. 点击...
|
||||
3. 看到错误...
|
||||
|
||||
**预期行为**
|
||||
您期望发生什么。
|
||||
|
||||
**实际行为**
|
||||
实际发生了什么。
|
||||
|
||||
**截图/日志**
|
||||
如有错误截图或 logcat 输出,请附在此处。
|
||||
|
||||
**环境信息**
|
||||
- 应用版本: [如 v1.14]
|
||||
- 设备型号: [如 Pixel 6]
|
||||
- Android 版本: [如 Android 14]
|
||||
- Root 方案: [Magisk / KernelSU / APatch / 无]
|
||||
|
||||
**其他信息**
|
||||
任何可能有助于诊断问题的其他信息。
|
||||
99
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
99
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
@@ -0,0 +1,99 @@
|
||||
name: Bug 报告
|
||||
description: 报告一个 Bug 帮助我们改进
|
||||
title: "[Bug] "
|
||||
labels: [bug]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
感谢您花时间报告这个问题!请尽可能详细地填写以下信息。
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: 问题描述
|
||||
description: 清晰简洁地描述这个 Bug 是什么
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: steps
|
||||
attributes:
|
||||
label: 复现步骤
|
||||
description: 复现该问题的具体步骤
|
||||
placeholder: |
|
||||
1. 打开应用...
|
||||
2. 点击...
|
||||
3. 看到错误...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: 预期行为
|
||||
description: 您期望发生什么
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: actual
|
||||
attributes:
|
||||
label: 实际行为
|
||||
description: 实际发生了什么
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: screenshot
|
||||
attributes:
|
||||
label: 截图/日志
|
||||
description: 如有错误截图或 logcat 输出,请附在这里
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: 应用版本
|
||||
description: 您正在使用的版本号(可在设置中查看)
|
||||
placeholder: "v1.14"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: device
|
||||
attributes:
|
||||
label: 设备型号
|
||||
placeholder: "Pixel 6 / Xiaomi 14"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: android
|
||||
attributes:
|
||||
label: Android 版本
|
||||
placeholder: "Android 14"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: root
|
||||
attributes:
|
||||
label: Root 方案
|
||||
options:
|
||||
- Magisk
|
||||
- KernelSU
|
||||
- APatch
|
||||
- 其他
|
||||
- 无 Root
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: additional
|
||||
attributes:
|
||||
label: 其他信息
|
||||
description: 任何可能有助于诊断问题的其他信息
|
||||
validations:
|
||||
required: false
|
||||
5
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
5
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: 讨论区
|
||||
url: https://github.com/sakuradairong/android-backup-gui/discussions
|
||||
about: 如需提问或讨论功能,请使用 Discussion
|
||||
19
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
19
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
---
|
||||
name: 功能请求
|
||||
about: 为这个项目提一个新功能建议
|
||||
title: '[Feature] '
|
||||
labels: enhancement
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
**使用场景**
|
||||
这个功能解决了什么问题?在什么场景下需要?
|
||||
|
||||
**期望方案**
|
||||
您期望的行为或交互方式是什么样的。
|
||||
|
||||
**替代方案**
|
||||
您考虑过其他替代方案吗?
|
||||
|
||||
**附加信息**
|
||||
如有截图、参考实现或其他上下文,请附在此处。
|
||||
43
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
43
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
@@ -0,0 +1,43 @@
|
||||
name: 功能请求
|
||||
description: 为这个项目提一个新功能建议
|
||||
title: "[Feature] "
|
||||
labels: [enhancement]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
感谢您花时间提出改进建议!请描述您想要的功能。
|
||||
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: 使用场景
|
||||
description: 这个功能解决了什么问题?在什么场景下需要?
|
||||
placeholder: "当我使用...功能时,发现...不够方便"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: solution
|
||||
attributes:
|
||||
label: 期望方案
|
||||
description: 您期望的行为或交互方式是什么样的
|
||||
placeholder: "希望在某某页面添加一个...按钮,点击后..."
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: 替代方案
|
||||
description: 您考虑过其他替代方案吗?
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: context
|
||||
attributes:
|
||||
label: 附加信息
|
||||
description: 如有截图、参考实现或其他上下文,请附在这里
|
||||
validations:
|
||||
required: false
|
||||
30
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
30
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
## 变更摘要
|
||||
|
||||
<!-- 简要描述此 PR 的变更内容 -->
|
||||
|
||||
## 关联 Issue
|
||||
|
||||
<!-- 如果有,请关联相关的 Issue(如 Fixes #123)-->
|
||||
|
||||
## 变更类型
|
||||
|
||||
- [ ] Bug 修复
|
||||
- [ ] 新功能
|
||||
- [ ] 重构/代码优化
|
||||
- [ ] 文档更新
|
||||
- [ ] 测试
|
||||
- [ ] 其他
|
||||
|
||||
## 测试清单
|
||||
|
||||
- [ ] `./gradlew lint` 通过
|
||||
- [ ] `./gradlew test` 通过
|
||||
- [ ] 已在真机/模拟器上测试
|
||||
|
||||
## 截图(如有 UI 变更)
|
||||
|
||||
<!-- UI 变更请附上前后对比截图 -->
|
||||
|
||||
## 其他说明
|
||||
|
||||
<!-- 任何需要 reviewer 了解的额外信息 -->
|
||||
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@@ -24,6 +24,8 @@ jobs:
|
||||
|
||||
- name: Lint
|
||||
run: ./gradlew lint
|
||||
- name: Test
|
||||
run: ./gradlew test
|
||||
|
||||
- name: Build release APK
|
||||
run: ./gradlew assembleRelease
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -21,5 +21,5 @@ release.keystore
|
||||
memory:*
|
||||
|
||||
# Restic test repository (contains encryption keys)
|
||||
test/
|
||||
/test/
|
||||
kmboxnet
|
||||
|
||||
12
.omp/lsp.json
Normal file
12
.omp/lsp.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"servers": {
|
||||
"kotlin-lsp": {
|
||||
"command": "kotlin-language-server",
|
||||
"args": [],
|
||||
"fileTypes": [".kt", ".kts"],
|
||||
"rootMarkers": ["build.gradle", "settings.gradle"],
|
||||
"warmupTimeoutMs": 60000
|
||||
}
|
||||
},
|
||||
"idleTimeoutMs": 600000
|
||||
}
|
||||
10
.pi/wow.yaml
Normal file
10
.pi/wow.yaml
Normal file
@@ -0,0 +1,10 @@
|
||||
# Project-level wow-pi configuration for android-backup-gui
|
||||
contexts:
|
||||
- AGENTS.md
|
||||
- docs/contexts/*.md
|
||||
|
||||
inject:
|
||||
enabled: true
|
||||
overrideExisting: false
|
||||
envFiles:
|
||||
- .env
|
||||
@@ -1,7 +1,7 @@
|
||||
<!-- gitnexus:start -->
|
||||
# GitNexus — Code Intelligence
|
||||
|
||||
This project is indexed by GitNexus as **android-backup-gui** (1295 symbols, 3535 relationships, 112 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||
This project is indexed by GitNexus as **android-backup-gui** (1734 symbols, 4049 relationships, 110 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||
|
||||
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<!-- gitnexus:start -->
|
||||
# GitNexus — Code Intelligence
|
||||
|
||||
This project is indexed by GitNexus as **android-backup-gui** (1295 symbols, 3535 relationships, 112 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||
This project is indexed by GitNexus as **android-backup-gui** (1734 symbols, 4049 relationships, 110 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||
|
||||
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
|
||||
|
||||
|
||||
39
CODE_OF_CONDUCT.md
Normal file
39
CODE_OF_CONDUCT.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# 贡献者公约行为准则
|
||||
|
||||
## 我们的承诺
|
||||
|
||||
为了营造一个开放、友好的环境,我们作为贡献者和维护者承诺:无论年龄、体型、残疾、种族、性别认同与表达、经验水平、国籍、个人外貌、种族、宗教或性取向如何,参与我们的项目和社区的每个人都不会受到骚扰。
|
||||
|
||||
## 我们的标准
|
||||
|
||||
有助于营造积极环境的行为包括:
|
||||
|
||||
- 使用友好和包容的语言
|
||||
- 尊重不同的观点和经验
|
||||
- 优雅地接受建设性批评
|
||||
- 关注对社区最有利的事情
|
||||
- 对其他社区成员表示同理心
|
||||
|
||||
不可接受的行为包括:
|
||||
|
||||
- 使用带有性暗示的语言或图像,以及不受欢迎的性关注或挑逗
|
||||
- 挑衅、侮辱/贬损性评论,以及人身或政治攻击
|
||||
- 公开或私下骚扰
|
||||
- 未经明确许可发布他人的私人信息(如地址或电子邮件)
|
||||
- 在专业环境中可能被合理认为不合适的其他行为
|
||||
|
||||
## 我们的责任
|
||||
|
||||
项目维护者有责任明确可接受行为的标准,并应对任何不可接受行为采取适当和公平的纠正措施。
|
||||
|
||||
## 适用范围
|
||||
|
||||
本行为准则适用于项目空间和公共空间,当个人代表项目或其社区时同样适用。
|
||||
|
||||
## 执行
|
||||
|
||||
如有滥用、骚扰或其他不可接受行为,请联系项目团队。所有投诉将得到审查和调查,并将给出必要且适当的回应。
|
||||
|
||||
## 归属
|
||||
|
||||
本行为准则改编自 [Contributor Covenant](https://www.contributor-covenant.org) 2.1 版,可在 https://www.contributor-covenant.org/version/2/1/code_of_conduct.html 查看。
|
||||
103
CONTRIBUTING.md
Normal file
103
CONTRIBUTING.md
Normal file
@@ -0,0 +1,103 @@
|
||||
# 贡献指南
|
||||
|
||||
感谢您对 **Android Backup GUI** 的关注!欢迎通过 Issue 和 Pull Request 参与贡献。
|
||||
|
||||
## 目录
|
||||
|
||||
- [开发环境](#开发环境)
|
||||
- [构建项目](#构建项目)
|
||||
- [提交 Issue](#提交-issue)
|
||||
- [提交 Pull Request](#提交-pull-request)
|
||||
- [代码风格](#代码风格)
|
||||
|
||||
## 开发环境
|
||||
|
||||
- **JDK**: 17+
|
||||
- **Android SDK**: API 34(targetSdk 34, minSdk 24)
|
||||
- **IDE**: Android Studio Hedgehog+ 或 IntelliJ IDEA
|
||||
- **Gradle**: 8.2(通过 Gradle Wrapper 自动使用)
|
||||
|
||||
### 首次构建
|
||||
|
||||
```bash
|
||||
# 克隆仓库
|
||||
git clone https://github.com/sakuradairong/android-backup-gui.git
|
||||
cd android-backup-gui
|
||||
|
||||
# 确认 Android SDK 路径(创建 local.properties 如果不存在)
|
||||
echo "sdk.dir=/path/to/Android/Sdk" > local.properties
|
||||
|
||||
# 构建 debug APK
|
||||
./gradlew assembleDebug
|
||||
```
|
||||
|
||||
## 构建项目
|
||||
|
||||
```bash
|
||||
# 运行 lint 检查
|
||||
./gradlew lint
|
||||
|
||||
# 运行单元测试
|
||||
./gradlew test
|
||||
|
||||
# 构建 release APK(需配置签名)
|
||||
./gradlew assembleRelease
|
||||
```
|
||||
|
||||
> **注意**: Release 构建需要 `app/release.keystore` 文件,以及 `KEYSTORE_PASSWORD` 和 `KEY_PASSWORD` 环境变量。开发调试请使用 `assembleDebug`。
|
||||
|
||||
## 提交 Issue
|
||||
|
||||
### Bug 报告
|
||||
|
||||
请确保包含以下信息:
|
||||
|
||||
1. **设备信息**: Android 版本、设备型号、是否 root
|
||||
2. **环境**: restic 版本(如有)、root 方案(Magisk / KernelSU / APatch)
|
||||
3. **复现步骤**: 详细的操作步骤
|
||||
4. **预期行为**: 您期望发生什么
|
||||
5. **实际行为**: 实际发生了什么
|
||||
6. **日志**: 相关的 logcat 输出或错误截图
|
||||
|
||||
### 功能请求
|
||||
|
||||
请说明:
|
||||
|
||||
1. **使用场景**: 这个功能解决什么问题
|
||||
2. **期望方案**: 期望的行为或交互方式
|
||||
3. **替代方案**: 您考虑过的其他方案
|
||||
|
||||
## 提交 Pull Request
|
||||
|
||||
1. **Fork** 本仓库
|
||||
2. 创建功能分支: `git checkout -b feature/your-feature`
|
||||
3. **确保代码通过 lint 和测试**:
|
||||
```bash
|
||||
./gradlew lint test
|
||||
```
|
||||
4. 提交变更:
|
||||
```bash
|
||||
git commit -m "feat: 简洁描述变更内容"
|
||||
```
|
||||
5. 推送到您的 Fork: `git push origin feature/your-feature`
|
||||
6. 创建 Pull Request 到 `main` 分支
|
||||
|
||||
### PR 要求
|
||||
|
||||
- 每个 PR 专注于一个功能或修复
|
||||
- 提交信息遵循 [Conventional Commits](https://www.conventionalcommits.org/) 规范
|
||||
- 新增功能应包含对应的单元测试
|
||||
- UI 变更请在 PR 描述中附上截图
|
||||
- 确保 `./gradlew lint test` 通过
|
||||
|
||||
## 代码风格
|
||||
|
||||
- 使用 Kotlin 官方代码风格(Kotlin Coding Conventions)
|
||||
- 使用 `ktlint` 检查代码格式(`./gradlew lint` 包含)
|
||||
- compose 相关代码遵循 Jetpack Compose 编码规范
|
||||
- 命名使用直观的英文(不推荐拼音)
|
||||
- 对复杂逻辑编写简明注释
|
||||
|
||||
## 许可
|
||||
|
||||
贡献即表示您同意您的贡献将在 [GPL-3.0](LICENSE) 许可下发布。
|
||||
675
LICENSE
Normal file
675
LICENSE
Normal file
@@ -0,0 +1,675 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its authors. You can apply it to
|
||||
your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you have
|
||||
certain responsibilities if you distribute copies of the software, or if
|
||||
you modify it: responsibilities to respect the freedom of others.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the manufacturer
|
||||
can do so. This is fundamentally incompatible with the aim of
|
||||
protecting users' freedom to change the software. The systematic
|
||||
pattern of such abuse occurs in the area of products for individuals to
|
||||
use, which is precisely where it is most unacceptable. Therefore, we
|
||||
have designed this version of the GPL to prohibit the practice for those
|
||||
products. If such problems arise substantially in other domains, we
|
||||
stand ready to extend this provision to those domains in future versions
|
||||
of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents.
|
||||
States should not allow patents to restrict development and use of
|
||||
software on general-purpose computers, but in those that do, we wish to
|
||||
avoid the special danger that patents applied to a free program could
|
||||
make it effectively proprietary. To prevent this, the GPL assures that
|
||||
patents cannot be used to render the program non-free.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Use with the GNU Affero General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU Affero General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, your program's commands
|
||||
might be different; for a GUI interface, you would use an "about box".
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU GPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program
|
||||
into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||
|
||||
208
README.md
208
README.md
@@ -1,139 +1,155 @@
|
||||
# Android Backup GUI
|
||||
|
||||
Android 应用备份与恢复工具,集成 [restic](https://restic.net/) 实现增量去重备份,支持 WebDAV / SMB 远程仓库。
|
||||
Android 应用备份与恢复工具,通过 **root 权限** 实现应用的完整备份(APK + 用户数据 + OBB + SSAID + 权限 + WiFi 配置),集成 [restic](https://restic.net/) 实现增量去重备份,支持 SMB / WebDAV 远程仓库。
|
||||
|
||||
## 功能
|
||||
|
||||
- **应用扫描** — 自动列出第三方应用,支持系统应用白名单
|
||||
- **APK + 数据备份** — 备份 APK 文件、应用数据目录、OBB 数据、SSAID、权限、WiFi 配置
|
||||
- **并行备份/恢复** — 备份并发数 3(Semaphore(3)),恢复并发数 2(Semaphore(2))
|
||||
- **存档完整性校验** — 备份后自动 zstd/gzip 校验数据归档
|
||||
- **restic 增量去重** — 内建 `librestic.so`(~24MB),支持本地和远端仓库
|
||||
- **构建体积优化** — Release APK 仅 11.8 MB(ProGuard/R8 full mode + shrinkResources + BouncyCastle PQC 移除)
|
||||
- **远程后端** — WebDAV(如 123 云盘)/ SMB 协议,本地临时仓库 + 自动双向同步 + 进度回调
|
||||
- **配置持久化** — 仓库路径、密码、后端参数保存在 `backup_settings.conf`
|
||||
- **快照管理** — 初始化仓库、查看统计、按策略清理旧快照(保留 7 天/4 周/3 月)
|
||||
- **应用名显示** — 使用 `PackageManager` 解析应用名,优先显示中文名,回退包名
|
||||
- **APK + 数据备份** — 备份 APK 文件、应用数据目录、OBB 数据、SSAID、SSAID 广告标识、AppOps 权限、WiFi 配置
|
||||
- **多用户支持** — 从配置页选择 Android 用户(主用户 / 工作资料),持久化到配置文件
|
||||
- **并行备份/恢复** — 备份并发 3(Semaphore),恢复并发 2(Semaphore)
|
||||
- **存档完整性校验** — 备份后自动 zstd/gzip 校验 + tar 结构验证
|
||||
- **restic 增量去重** — 内建 `librestic.so`(~24MB),SSD 加密快照,增量备份
|
||||
- **远程后端** — 本地 REST 桥 + NanoHTTPD 将 SMB/WebDAV 协议翻译为 restic 可直接访问的 REST API
|
||||
- **流式备份** — FIFO 管道对接 `restic backup --stdin`,无需本地暂存
|
||||
- **配置持久化** — 仓库路径、密码、后端参数、目标用户保存在 `backup_settings.conf`
|
||||
- **快照管理** — 初始化、查看统计、按策略清理旧快照(保留 7 天/4 周/3 月)、解锁
|
||||
- **累积快照** — 从历史快照读取元数据,合并为增量累积备份
|
||||
- **应用名显示** — 备份时缓存应用名称到 `app_details.json`,已卸载应用也显示中文名
|
||||
|
||||
## 技术栈
|
||||
|
||||
- Kotlin / Android SDK
|
||||
- [kotlinx-serialization](https://github.com/Kotlin/kotlinx.serialization)(JSON 解析,取代 org.json)
|
||||
- Coroutines + StateFlow(面向状态架构)
|
||||
- Android ViewModel + lifecycle-runtime-ktx
|
||||
- Kotlin / Android SDK (minSdk 24, targetSdk 34)
|
||||
- **Jetpack Compose + Material 3** UI
|
||||
- ViewModel + StateFlow + SharedFlow(面向状态架构)
|
||||
- kotlinx-serialization(JSON 解析)
|
||||
- restic 0.17+ 二进制(编译为 `librestic.so`)
|
||||
- sardine-android (WebDAV 客户端)
|
||||
- SMBJ (jcifs-ng) (SMB 客户端)
|
||||
- Material 3 UI
|
||||
- Root shell with Mutex + timeout (120s)
|
||||
- jcifs-ng (SMB 客户端)
|
||||
- NanoHTTPD(REST 桥服务器)
|
||||
- libsu(Magisk / KernelSU / APatch root shell)
|
||||
- Kotest + MockK(单元测试)
|
||||
|
||||
## 架构
|
||||
|
||||
```
|
||||
BackupFragment / RestoreFragment / ConfigFragment (UI)
|
||||
│ │
|
||||
│ viewModels() │ observe StateFlow
|
||||
▼ ▼
|
||||
ConfigViewModel ResticWrapper
|
||||
└─ StateFlow<ConfigUiState> ├── ResticBackup (备份)
|
||||
├── ResticRestore (恢复 + dump)
|
||||
├── ResticSnapshotOps (快照列表)
|
||||
├── ResticMaintenance (forget/prune/check/stats)
|
||||
├── ResticRepoInit (init)
|
||||
├── ResticCommandRunner(进程执行)
|
||||
├── ResticEnvResolver (环境变量)
|
||||
├── RemoteSyncManager (同步编排)
|
||||
└── RemoteTransport (文件传输接口)
|
||||
├── WebdavTransport (sardine, 8KB 分块)
|
||||
└── SmbTransport (jcifs-ng, 8KB 分块 + 签名)
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ UI 层 (Jetpack Compose + Material 3) │
|
||||
│ AppScaffold → BackupScreen / RestoreScreen │
|
||||
│ / ConfigScreen │
|
||||
│ / ConfigViewModel (StateFlow) │
|
||||
├─────────────────────────────────────────────┤
|
||||
│ 业务逻辑层 (backup/) │
|
||||
│ BackupOperation → root shell tar/cp │
|
||||
│ RestoreOperation → root shell pm install │
|
||||
│ StreamingBackup → FIFO pipe → restic │
|
||||
│ ResticWrapper → facade 委托给: │
|
||||
│ ├── ResticBackup (备份) │
|
||||
│ ├── ResticRestore (恢复 + dump) │
|
||||
│ ├── ResticSnapshotOps (快照列表/forget) │
|
||||
│ ├── ResticMaintenance (prune/check/stats) │
|
||||
│ └── ResticRepoInit (init) │
|
||||
│ ResticCommandRunner → ProcessBuilder │
|
||||
│ ResticEnvResolver → 环境变量构建 │
|
||||
│ RestBridgeRunner → ResticRestBridge │
|
||||
│ RemoteTransport → file I/O 接口 │
|
||||
│ ├── WebdavTransport (sardine, 8KB 分块) │
|
||||
│ └── SmbTransport (jcifs-ng, 8KB 分块) │
|
||||
├─────────────────────────────────────────────┤
|
||||
│ Root 层 (root/) │
|
||||
│ RootShell → libsu (Magisk/KernelSU/APatch) │
|
||||
├─────────────────────────────────────────────┤
|
||||
│ Service 层 │
|
||||
│ BackupService → Foreground Service │
|
||||
├─────────────────────────────────────────────┤
|
||||
│ 原生二进制 (jniLibs) │
|
||||
│ librestic.so / libtar_bin.so / libzstd_bin │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 数据流
|
||||
### 数据流(一次备份)
|
||||
|
||||
```
|
||||
1. BackupOperation.backupApps() ─── 本地 APP 备份到 outputDir
|
||||
│
|
||||
2. WifiManager.backup(outputDir) WiFi 配置
|
||||
│
|
||||
3. ResticWrapper.backup(paths=[outputDir]) ─── restic 快照
|
||||
│
|
||||
├── RemoteSyncManager.withRemoteSync()
|
||||
│ ├── syncFromRemote (WebDAV/SMB: 下载远端差异)
|
||||
│ ├── action() (restic backup/restore 命令)
|
||||
│ └── syncToRemote (WebDAV/SMB: 上传本地差异)
|
||||
│
|
||||
└── RemoteTransport
|
||||
├── upload(download)
|
||||
│ ├── onProgress(TransferProgress) ← 阶段 ("connecting" / "transferring" / "completed")
|
||||
│ └── onByteProgress(ByteProgress) ← 8KB 粒度字节进度
|
||||
│
|
||||
└── protocol 实现: SmbTransport / WebdavTransport
|
||||
用户选择应用 → 扫描 (AppScanner.enumerateUsers / scanThirdParty)
|
||||
↓
|
||||
创建 Backup_ 目录 → 写入 appList.txt + app_details.json
|
||||
↓
|
||||
并行 (Semaphore=3) 为每个应用:
|
||||
├── 备份 APK (cp → app目录/包名.apk)
|
||||
├── 备份数据 (tar zstd → 包名_data.tar.zst)
|
||||
├── 备份 OBB (tar → 包名_obb.tar.zst)
|
||||
├── 备份 SSAID (提取 → ssaid.txt)
|
||||
├── 备份图标 (snapshot cache / aapt)
|
||||
└── 备份权限 (dumpsys → permissions.txt)
|
||||
↓
|
||||
WiFi 备份 → WifiManager.backup()
|
||||
↓
|
||||
(可选) ResticWrapper.backup() → restic 快照到远程仓库
|
||||
```
|
||||
|
||||
### restic 远程仓库流程
|
||||
### 远程仓库(REST 桥模式)
|
||||
|
||||
```
|
||||
1. syncFromRemote: PROPFIND 递归列出远端文件 → 下载差异文件到本地临时仓库
|
||||
→ 发出 TransferProgress("list") / TransferProgress("download") / TransferProgress("delete_stale")
|
||||
2. runRestic: 在本地临时仓库执行 restic 命令 (backup / restore / snapshots / init ...)
|
||||
3. syncToRemote: 递归遍历本地临时仓库 → 上传差异文件到远端
|
||||
→ 发出 TransferProgress("upload") / TransferProgress("delete_stale") / TransferProgress("complete")
|
||||
Restic CLI ←→ ResticRestBridge (NanoHTTPD, 127.0.0.1:random)
|
||||
↓
|
||||
RemoteTransport (SMB/WebDAV)
|
||||
```
|
||||
|
||||
远端同步基于内容大小比较,跳过同名等长文件;自动删除远端/本地过时文件。
|
||||
|
||||
### 关键设计
|
||||
|
||||
- **导航栏索引** — 使用 `FragmentPagerAdapter` + 三个子 Fragment
|
||||
- **href 自引用过滤** — WebDAV 服务器常将目录自身作为 PROPFIND 响应条目,通过比较资源 href 与请求 URL 精确过滤
|
||||
- **根目录 404 保护** — 根目录返回 404 视为致命错误(防止限流导致误删本地文件),子目录 404 安全跳过
|
||||
- **指数退避重试** — DNS 超时、5xx 错误、连接拒绝等瞬时故障自动重试(1s/2s/4s),最多 3 次
|
||||
- **双向递归同步** — BFS 遍历远端目录树,深度限制 3 层,适配 restic 仓库结构
|
||||
- **双向递归递归过滤** — 脏删除(walkLocalFile filter,适配临时仓库模式)
|
||||
- **SmbException 精确处理** — 区分 `STATUS_OBJECT_NAME_NOT_FOUND`(0xC0000034) / `STATUS_OBJECT_NAME_COLLISION`(0xC0000035)
|
||||
- **标签解析** — `PackageManager.getApplicationLabel()` 批量解析,无 root 需求,比 `dumpsys package` 快 10x+
|
||||
- **ConfigViewModel** — `StateFlow<ConfigUiState>` 驱动配置 UI,`viewModelScope` 管理 restic 操作生命周期
|
||||
- **进度回调线程安全** — TransferProgress 统一由 `withRemoteSync` 分派至 Main 线程;ByteProgress 留在 IO(8KB 粒度)
|
||||
- **并发安全性** — `RootShell` 使用 `Mutex` + `withTimeout(120s)`;远程同步使用 `Mutex`;`BackupOperation`/`RestoreOperation` 使用 `Semaphore` + `AtomicInteger`
|
||||
- **SMB 签名可选** — `smbSigning` 构造参数(默认 true),兼容旧 SMB 服务器
|
||||
- **文件大小限制** — WebDAV 上传 50MB 上限(防止 ByteArray OOM)
|
||||
- **存档完整性校验** — 备份后 zstd/gzip 验证数据归档,校验失败回告
|
||||
restic 通过 REST HTTP API 与本地桥通信,桥接器将请求翻译为 SMB/WebDAV 文件操作。
|
||||
无需本地 staging 仓库,restic 直接读写远程存储。
|
||||
|
||||
## 构建
|
||||
|
||||
### 版本历史
|
||||
|
||||
|-|版本|更新内容|
|
||||
|-|---:|--------|
|
||||
| | v1.3 | 累积快照、AppResult 类型化错误、RootShell Mutex、kotlinx-serialization 迁移 |
|
||||
| | v1.4 | APK 体积优化(ProGuard/R8 + shrinkResources + 依赖裁剪),Release APK 从 25 MB 降至 11.8 MB(-52.8%) |
|
||||
| 版本 | 更新内容 |
|
||||
|------|---------|
|
||||
| v1.14 | 修复 shell 转义/管道死锁/配置序列化缺陷,新增配置导出与 BackupConfig 单元测试 |
|
||||
| v1.13 | Compose Material 3 UI 重构、Unlock 支持、ResticBinary 启动初始化、修复 500 错误和刷新竞态 |
|
||||
| v1.12 | 引擎 + Compose Material 3 UI 重构 |
|
||||
| v1.11 | 构建系统改进、LSP 支持 |
|
||||
| v1.10 | 后端桥接稳定性提升 |
|
||||
| v1.9 | 远程后端优化 |
|
||||
| v1.8 | 快照管理增强 |
|
||||
| v1.7 | 多用户支持 |
|
||||
| v1.6 | 累积快照 |
|
||||
| v1.5 | 修复签名配置 |
|
||||
| v1.4 | APK 体积优化(R8 + shrinkResources),25MB → 11.8MB |
|
||||
| v1.3 | 累积快照、AppResult 类型化错误、kotlinx-serialization |
|
||||
|
||||
### 编译命令
|
||||
|
||||
```bash
|
||||
# Debug APK(不压缩,适合开发调试)
|
||||
# Debug APK
|
||||
./gradlew assembleDebug
|
||||
|
||||
# Release APK(ProGuard/R8 混淆 + 资源裁剪 + 签名)
|
||||
./gradlew assembleRelease
|
||||
# Release APK(需配置签名)
|
||||
KEYSTORE_PASSWORD=<密码> KEY_PASSWORD=<密码> ./gradlew assembleRelease
|
||||
```
|
||||
|
||||
> Release 构建需配置 `release.keystore` 签名文件;`librestic.so` 放在 `app/src/main/jniLibs/arm64-v8a/` 下。
|
||||
> Release 构建需要 `app/release.keystore`;原生库放在 `jniLibs/arm64-v8a/`。
|
||||
|
||||
## 使用说明
|
||||
|
||||
1. Android 设备需具备 **root 权限**(用于 `pm path`、`dumpsys`、文件访问等)
|
||||
2. 在「设置」页签配置 restic 仓库参数(后端类型、URL、路径、密码)
|
||||
3. 点击「初始化」创建仓库(远程后端需 WebDAV/SMB 服务已运行)
|
||||
4. 在「备份」页签选择应用,点击「开始备份」
|
||||
5. 在「恢复」页签选择备份目录或 restic 快照,点击「开始恢复」
|
||||
1. Android 设备需 **root 权限**(Magisk / KernelSU / APatch)
|
||||
2. 在「配置」页签设置备份选项 + restic 仓库参数
|
||||
3. 切换「备份用户」选项(多用户设备)
|
||||
4. 在「备份」页签选择应用 → 开始备份
|
||||
5. 在「恢复」页签选择本地备份或 restic 快照 → 开始恢复
|
||||
|
||||
### WebDAV 配置示例
|
||||
### 远程后端配置
|
||||
|
||||
| 字段 | 值 |
|
||||
|------|-----|
|
||||
| 后端 | WebDAV |
|
||||
| 地址 | `https://webdav.123pan.cn/webdav` |
|
||||
| 用户名 | 手机号 |
|
||||
| 密码 | 应用密码 |
|
||||
| 仓库存放路径 | `back` |
|
||||
| 字段 | WebDAV 示例 | SMB 示例 |
|
||||
|------|-------------|----------|
|
||||
| 后端 | WebDAV | SMB |
|
||||
| 地址 | `https://webdav.example.com` | `192.168.1.165` |
|
||||
| 用户名 | 账号 | Windows 用户名 |
|
||||
| 密码 | 密码 | Windows 密码 |
|
||||
| 共享名称 | — | `back` |
|
||||
| 仓库存放路径 | `backup` | `backup` |
|
||||
|
||||
### 注意事项
|
||||
|
||||
- 应用卸载会清除 `backup_settings.conf`,建议定期导出配置
|
||||
- Restic 仓库需先「初始化」才能使用(自动检测已有仓库)
|
||||
- SMB 密码错误多次会导致 Windows 账户锁定,需在服务器上解锁
|
||||
|
||||
24
SECURITY.md
Normal file
24
SECURITY.md
Normal file
@@ -0,0 +1,24 @@
|
||||
# 安全策略
|
||||
|
||||
## 支持的版本
|
||||
|
||||
| 版本 | 支持状态 |
|
||||
|--------|-------------------|
|
||||
| v1.14 | ✅ 积极支持 |
|
||||
| v1.13 | ✅ 积极支持 |
|
||||
| < v1.13| ❌ 不再支持 |
|
||||
|
||||
## 报告安全漏洞
|
||||
|
||||
如果您发现安全漏洞,**请不要在 GitHub Issues 中公开披露**。请通过以下方式私下报告:
|
||||
|
||||
1. 在仓库中创建一个 [Security Advisory](https://github.com/sakuradairong/android-backup-gui/security/advisories/new)
|
||||
2. 或发送邮件至(待设置,目前请使用 Security Advisory)
|
||||
|
||||
我们会尽快确认并回应,通常在 **48 小时内**。
|
||||
|
||||
### 安全注意事项
|
||||
|
||||
- 本应用需要 root 权限运行,请确保从可信来源下载 APK
|
||||
- 备份数据使用 restic 加密存储,请妥善保管仓库密码
|
||||
- 如发现敏感信息泄露,请立即通过 Security Advisory 联系我们
|
||||
303
accessibility-review-report.md
Normal file
303
accessibility-review-report.md
Normal file
@@ -0,0 +1,303 @@
|
||||
# 无障碍审查报告 — Android Backup GUI
|
||||
|
||||
**审查阶段**:第三层 — 可维护性与用户体验
|
||||
**审查技能**:accessibility (WCAG 2.2)
|
||||
**审查日期**:2026-06-06
|
||||
**项目规模**:37 个源文件
|
||||
**审查范围**:UI 相关 4 个 Fragment/Activity、4 个布局 XML、菜单、资源文件
|
||||
|
||||
---
|
||||
|
||||
## 严重程度说明
|
||||
|
||||
| 级别 | 定义 |
|
||||
|------|------|
|
||||
| **严重** | 用户无法完成核心操作,或屏幕阅读器完全无法识别交互元素 |
|
||||
| **高** | 严重阻碍无障碍使用,有合理的替代方案但未实现 |
|
||||
| **中** | 影响使用体验,但用户可通过变通方式完成任务 |
|
||||
| **低** | 体验可改进点,非阻塞性 |
|
||||
|
||||
---
|
||||
|
||||
## 发现汇总
|
||||
|
||||
| # | 文件 | 行号 | 问题 | 严重程度 |
|
||||
|---|------|------|------|----------|
|
||||
| 1 | PackageListAdapter.kt | 61-69, 116 | `TextView` 模拟按钮(排除数据切换)缺少无障碍角色 | **严重** |
|
||||
| 2 | PackageListAdapter.kt | 76-88 | `MaterialCardView` 点击区域未合并无障碍语义 | **高** |
|
||||
| 3 | PackageListAdapter.kt | 52-53 | `CheckBox` 缺少对应应用的 `contentDescription` | **高** |
|
||||
| 4 | PackageListAdapter.kt | 109-115 | 排除数据切换状态未以文字方式通知 | **中** |
|
||||
| 5 | fragment_backup.xml | 168-169 | statusText 缺少无障碍实时区域 | **高** |
|
||||
| 6 | fragment_restore.xml | 90-91 | statusText 缺少无障碍实时区域 | **高** |
|
||||
| 7 | fragment_config.xml | 384-390 | configStatusText 缺少无障碍实时区域 | **高** |
|
||||
| 8 | fragment_backup.xml | 152-160 | progressBar 开始/结束状态无无障碍通知 | **中** |
|
||||
| 9 | fragment_restore.xml | 73-81 | progressBar 开始/结束状态无无障碍通知 | **中** |
|
||||
| 10 | fragment_backup.xml | 100-133 | 排序/全选按钮文本仅 11sp,不符合可缩放要求 | **中** |
|
||||
| 11 | fragment_backup.xml | 39-43 | "用户:" 标签与 Spinner 无程序化关联 | **中** |
|
||||
| 12 | fragment_backup.xml | 59-65 | "输出目录:" 标签与目录显示无程序化关联 | **低** |
|
||||
| 13 | fragment_restore.xml | 49-53 | "用户:" 标签与 Spinner 无程序化关联 | **中** |
|
||||
| 14 | PackageListAdapter.kt | 61-69 | "数据"文本排除切换触摸目标可能不足 48dp | **中** |
|
||||
| 15 | MainActivity.kt | 93-100 | BottomNavigation 缺少 `contentDescription` 仅凭图标导航 | **低** |
|
||||
|
||||
---
|
||||
|
||||
## 详细发现
|
||||
|
||||
### 发现 1:`TextView` 模拟按钮 — 排除数据切换(严重)
|
||||
|
||||
**文件**:`PackageListAdapter.kt`
|
||||
**行号**:61-69(创建),116(点击监听器)
|
||||
**问题**:使用 `TextView`(非标准按钮控件)实现点击交互。`TextView` 默认不向 TalkBack 宣告其可点击角色。屏幕阅读器用户听到"数据"但不知道可以点击切换。
|
||||
|
||||
```kotlin
|
||||
// 第 61-69 行:创建
|
||||
val et = TextView(ctx).apply {
|
||||
id = R.id.excludeToggle
|
||||
// ...
|
||||
}
|
||||
|
||||
// 第 116 行:添加点击
|
||||
toggle.setOnClickListener {
|
||||
dataToggleCb(pkg, !excluded)
|
||||
}
|
||||
```
|
||||
|
||||
**修复建议**:
|
||||
- 方案A(最优):改用 `MaterialButton` 或 `ImageButton` 替代 `TextView`,并设置 `contentDescription`。
|
||||
- 方案B(最小改动):在 `TextView` 上显式设置 `focusable = true`、`clickable = true`,并设置 `contentDescription` 说明其作用和状态。
|
||||
- 添加 `stateDescription` 或更新 `contentDescription` 反映当前切换状态("点击排除数据备份" / "点击包含数据备份")。
|
||||
|
||||
---
|
||||
|
||||
### 发现 2:卡片点击区域无障碍语义缺失(高)
|
||||
|
||||
**文件**:`PackageListAdapter.kt`
|
||||
**行号**:76-88
|
||||
**问题**:`MaterialCardView` 上设置了 `setOnClickListener` 用于切换 CheckBox,但 TalkBack 视卡片为一个独立可点击元素,未与内部 CheckBox 的角色合并。用户无法明确知道点击卡片的效果是切换选中状态。
|
||||
|
||||
```kotlin
|
||||
// 第 76 行
|
||||
card.setOnClickListener {
|
||||
val pos = holder.adapterPosition
|
||||
// ... 切换 checkbox
|
||||
}
|
||||
```
|
||||
|
||||
**修复建议**:
|
||||
- 为卡片设置 `contentDescription` 关联应用名和选中状态。
|
||||
- 或为卡片添加 `role = Role.Button` 的语义,合并内部子元素的无障碍信息。
|
||||
- 最佳实践是在 `onBindViewHolder` 中更新卡片的 `contentDescription` 为"勾选 ${app.label}"或"取消勾选 ${app.label}"。
|
||||
|
||||
---
|
||||
|
||||
### 发现 3:CheckBox 缺少对应描述(高)
|
||||
|
||||
**文件**:`PackageListAdapter.kt`
|
||||
**行号**:52-53(创建),92-102(绑定)
|
||||
**问题**:CheckBox 在代码中创建且未设置 `contentDescription`。虽然旁边有 `TextView` 显示应用名,但程序化关联不完善。
|
||||
|
||||
```kotlin
|
||||
// 第 52-53 行
|
||||
val cb = CheckBox(ctx).apply {
|
||||
id = R.id.checkbox
|
||||
// 没有 contentDescription
|
||||
}
|
||||
```
|
||||
|
||||
**修复建议**:在 `onBindViewHolder`(第 96 行设置 `textView.text` 之后)为 checkbox 设置 `contentDescription`:
|
||||
|
||||
```kotlin
|
||||
holder.checkbox.contentDescription = "选择 ${app.label.ifEmpty { pkg }}"
|
||||
```
|
||||
|
||||
当选中/取消时同步更新描述。
|
||||
|
||||
---
|
||||
|
||||
### 发现 4:排除数据切换状态缺少文字通知(中)
|
||||
|
||||
**文件**:`PackageListAdapter.kt`
|
||||
**行号**:109-115
|
||||
**问题**:排除数据开关通过 `paintFlags`(删除线)和 `isSelected` 来表示状态,这些仅视觉变化不会被 TalkBack 识别。用户无法知道当前是否已排除数据。
|
||||
|
||||
```kotlin
|
||||
// 第 109-115 行
|
||||
toggle.text = "数据"
|
||||
toggle.paintFlags = if (excluded) {
|
||||
toggle.paintFlags or android.graphics.Paint.STRIKE_THRU_TEXT_FLAG
|
||||
} else {
|
||||
toggle.paintFlags and android.graphics.Paint.STRIKE_THRU_TEXT_FLAG.inv()
|
||||
}
|
||||
toggle.isSelected = excluded
|
||||
```
|
||||
|
||||
**修复建议**:显式更新 `contentDescription`:
|
||||
|
||||
```kotlin
|
||||
toggle.contentDescription = if (excluded) {
|
||||
"排除 ${app.label.ifEmpty { pkg }} 的用户数据备份"
|
||||
} else {
|
||||
"包含 ${app.label.ifEmpty { pkg }} 的用户数据备份"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 发现 5/6/7:状态文字缺少无障碍实时区域(高)
|
||||
|
||||
**文件**:
|
||||
- `fragment_backup.xml` 第 168-169 行(statusText)
|
||||
- `fragment_restore.xml` 第 90-91 行(statusText)
|
||||
- `fragment_config.xml` 第 384-390 行(configStatusText)
|
||||
|
||||
**问题**:三个状态文字 View 均未设置 `accessibilityLiveRegion`。当代码调用 `updateStatus()` 或 `applyState()` 更新文本内容时,TalkBack 不会自动朗读变化。
|
||||
|
||||
**修复建议**:在布局 XML 中添加:
|
||||
```xml
|
||||
android:accessibilityLiveRegion="polite"
|
||||
```
|
||||
|
||||
同时,建议在 `BackupFragment.kt:406` 的 `updateStatus` 方法和 `ConfigFragment.kt:136` 设置状态文本后手动触发无障碍事件,确保 TalkBack 播报:
|
||||
```kotlin
|
||||
binding.statusText.sendAccessibilityEvent(
|
||||
AccessibilityEvent.TYPE_VIEW_TEXT_CHANGED
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 发现 8/9:进度指示器状态无通知(中)
|
||||
|
||||
**文件**:
|
||||
- `fragment_backup.xml` 第 152-160 行(progressBar)
|
||||
- `fragment_restore.xml` 第 73-81 行(progressBar)
|
||||
|
||||
**问题**:`LinearProgressIndicator` 的 `visibility` 在 `VISIBLE`/`GONE` 之间切换(`BackupFragment.kt:402`、`RestoreFragment.kt:395`),但 TalkBack 不会主动通知用户加载开始或结束。
|
||||
|
||||
**修复建议**:在 `setRunning()` 方法中切换进度条可见性时,同步更新 `statusText` 并确保其 `accessibilityLiveRegion` 生效。例如在显示进度条时更新 statusText 为"正在加载…",隐藏时更新为"加载完成"。
|
||||
|
||||
---
|
||||
|
||||
### 发现 10:排序/全选按钮文本过小(中)
|
||||
|
||||
**文件**:`fragment_backup.xml`
|
||||
**行号**:100-133
|
||||
**问题**:排序和全选按钮的 `android:textSize` 设置为 `11sp`。
|
||||
|
||||
```xml
|
||||
<Button android:textSize="11sp" ... />
|
||||
```
|
||||
|
||||
`11sp` 远低于 Android 推荐的 `14sp` 最小可读文本大小。当用户开启大字模式时,文本可读性受影响。此外按钮使用 `layout_weight="1"` 分布宽度,在窄屏上按钮宽度可能不足 48dp。
|
||||
|
||||
**修复建议**:
|
||||
- 将文本大小提升至 `14sp` 以上。
|
||||
- 添加 `android:minWidth="48dp"` 确保触摸区域不小于 48dp。
|
||||
- 考虑使用图标+文字的紧凑布局替代纯文字小按钮。
|
||||
|
||||
---
|
||||
|
||||
### 发现 11/13:"用户:" 标签缺少程序化关联(中)
|
||||
|
||||
**文件**:
|
||||
- `fragment_backup.xml` 第 39-43 行
|
||||
- `fragment_restore.xml` 第 49-53 行
|
||||
|
||||
**问题**:"用户:" 是一个独立的 `TextView`,与后面的 `Spinner` 无程序化关联。TalkBack 用户需自行推断两者关系。
|
||||
|
||||
**修复建议**:为 `TextView` 添加 `android:labelFor="@id/userSelector"` 属性:
|
||||
```xml
|
||||
<TextView
|
||||
android:labelFor="@+id/userSelector"
|
||||
... />
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 发现 12:"输出目录:" 标签缺少程序化关联(低)
|
||||
|
||||
**文件**:`fragment_backup.xml`
|
||||
**行号**:59-65
|
||||
|
||||
**问题**:"输出目录:" 标签之后是 `outputPathLabel`(显示路径的 TextView)和"修改"按钮。标签与路径显示之间无程序化关联。
|
||||
|
||||
**修复建议**:为输出目录标签添加 `labelFor` 指向 `outputPathLabel`,或将其合并为带标题的可操作区域。
|
||||
|
||||
---
|
||||
|
||||
### 发现 14:排除数据切换触摸目标可能不足 48dp(中)
|
||||
|
||||
**文件**:`PackageListAdapter.kt`
|
||||
**行号**:61-69
|
||||
|
||||
**问题**:"数据"文本是纯 `TextView`,没有设置 `minWidth` 或 `minHeight`。点击区域仅限于文本包裹范围,可能小于 Android 推荐的 48dp 最小触摸目标。
|
||||
|
||||
```kotlin
|
||||
val et = TextView(ctx).apply {
|
||||
id = R.id.excludeToggle
|
||||
// 没有 minWidth/minHeight 或最小 padding 保证触摸区域
|
||||
}
|
||||
```
|
||||
|
||||
**修复建议**:添加最小触摸尺寸:
|
||||
```kotlin
|
||||
val et = TextView(ctx).apply {
|
||||
// ...
|
||||
minWidth = resources.getDimensionPixelSize(
|
||||
android.R.dimen.app_icon_size // 48dp
|
||||
)
|
||||
minimumHeight = resources.getDimensionPixelSize(
|
||||
android.R.dimen.app_icon_size
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
或改用 `MaterialButton` 替代。
|
||||
|
||||
---
|
||||
|
||||
### 发现 15:BottomNavigation 缺少 ContentDescription(低)
|
||||
|
||||
**文件**:
|
||||
- `MainActivity.kt` 第 93-100 行
|
||||
- `res/menu/bottom_nav.xml` 第 2-15 行
|
||||
|
||||
**问题**:BottomNavigationView 的菜单项包含 `android:title` 文本,但 `android:icon` 引用图标(`@drawable/ic_backup` 等)未设置 `android:contentDescription`。虽然 `android:title` 可被 TalkBack 读取,但图标作为装饰元素应标记为 `android:importantForAccessibility="no"` 以避免冗余播报。
|
||||
|
||||
**建议**:在菜单 XML 中为图标装饰属性添加声明(需在 menu 中设置 `app:iconContentDescription` 或在代码中设置)。不过由于 `labelVisibilityMode="labeled"`,title 总是可见的,TalkBack 可以通过 title 识别,此项优先级较低。
|
||||
|
||||
---
|
||||
|
||||
## 总结
|
||||
|
||||
### 按严重程度统计
|
||||
|
||||
| 严重程度 | 数量 |
|
||||
|----------|------|
|
||||
| 严重 | 1 |
|
||||
| 高 | 4 |
|
||||
| 中 | 8 |
|
||||
| 低 | 2 |
|
||||
| **总计** | **15** |
|
||||
|
||||
### 按文件分布
|
||||
|
||||
| 文件 | 发现问题数 | 最严重问题 |
|
||||
|------|-----------|-----------|
|
||||
| PackageListAdapter.kt | 5 | 严重 — TextView 模拟按钮 |
|
||||
| fragment_backup.xml | 5 | 高 — 缺少无障碍实时区域 |
|
||||
| fragment_restore.xml | 3 | 高 — 缺少无障碍实时区域 |
|
||||
| fragment_config.xml | 1 | 高 — 缺少无障碍实时区域 |
|
||||
| MainActivity.kt | 1 | 低 — BottomNavigation 图标描述 |
|
||||
|
||||
### 最优先修复项(共 5 项)
|
||||
|
||||
1. **PackageListAdapter.kt:61-69** — `TextView` 模拟按钮改为语义化按钮并添加 `contentDescription`
|
||||
2. **PackageListAdapter.kt:76-88** — 卡片点击区域合并无障碍信息,添加选中/未选中的状态文字
|
||||
3. **PackageListAdapter.kt:52-53** — CheckBox 绑定应用名作为 `contentDescription`
|
||||
4. **fragment_backup.xml:168, fragment_restore.xml:90, fragment_config.xml:385** — 为所有状态文字添加 `accessibilityLiveRegion="polite"`
|
||||
5. **PackageListAdapter.kt:109-115** — 排除切换状态同步到 `contentDescription`
|
||||
|
||||
### 项目整体无障碍评估
|
||||
|
||||
该应用大量使用 Material Design 3 标准组件,这些组件内置了基本无障碍支持(如 TalkBack 可识别 Button、Switch、CheckBox 等)。主要无障碍缺陷集中在**程序化创建的列表项**(`PackageListAdapter`)和**动态状态更新**缺乏通知机制。修复优先级建议从 PackageListAdapter 的 5 个问题开始,它们影响最核心的交互流程(应用选择和排除数据)。配置页面的无障碍支持较好(使用 TextInputLayout + hint 提供标签)。总体评分 **6/10**,完成上述修复后可提升至 **8/10**。
|
||||
@@ -8,8 +8,6 @@ kover {
|
||||
filters {
|
||||
excludes {
|
||||
classes(
|
||||
// Generated/auto classes
|
||||
"*.databinding.*",
|
||||
"*.BuildConfig",
|
||||
"*.R",
|
||||
"*.R\$*"
|
||||
@@ -26,11 +24,14 @@ android {
|
||||
applicationId "com.example.androidbackupgui"
|
||||
minSdk 24
|
||||
targetSdk 34
|
||||
versionCode 9
|
||||
versionName "1.8"
|
||||
versionCode 16
|
||||
versionName "1.16"
|
||||
}
|
||||
buildFeatures {
|
||||
viewBinding true
|
||||
compose = true
|
||||
}
|
||||
composeOptions {
|
||||
kotlinCompilerExtensionVersion = "1.5.1"
|
||||
}
|
||||
lint {
|
||||
disable 'QueryAllPackagesPermission'
|
||||
@@ -38,9 +39,9 @@ android {
|
||||
signingConfigs {
|
||||
release {
|
||||
storeFile rootProject.file("app/release.keystore")
|
||||
storePassword System.getenv("KEYSTORE_PASSWORD") ?: "android"
|
||||
storePassword System.getenv("KEYSTORE_PASSWORD")
|
||||
keyAlias "release"
|
||||
keyPassword System.getenv("KEY_PASSWORD") ?: "android"
|
||||
keyPassword System.getenv("KEY_PASSWORD")
|
||||
v1SigningEnabled true
|
||||
v2SigningEnabled true
|
||||
}
|
||||
@@ -48,7 +49,11 @@ android {
|
||||
buildTypes {
|
||||
release {
|
||||
if (rootProject.file("app/release.keystore").exists()) {
|
||||
signingConfig signingConfigs.release
|
||||
def ksPass = System.getenv("KEYSTORE_PASSWORD")
|
||||
def kPass = System.getenv("KEY_PASSWORD")
|
||||
if (ksPass != null && kPass != null) {
|
||||
signingConfig signingConfigs.release
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -80,16 +85,25 @@ android {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// Compose BOM (manages all Compose library versions)
|
||||
implementation platform('androidx.compose:compose-bom:2024.02.00')
|
||||
implementation 'androidx.compose.material3:material3'
|
||||
implementation 'androidx.compose.ui:ui'
|
||||
implementation 'androidx.compose.ui:ui-tooling-preview'
|
||||
implementation 'androidx.compose.foundation:foundation'
|
||||
implementation 'androidx.compose.material:material-icons-extended'
|
||||
implementation 'androidx.activity:activity-compose:1.8.2'
|
||||
debugImplementation 'androidx.compose.ui:ui-tooling'
|
||||
|
||||
implementation "org.jetbrains.kotlin:kotlin-stdlib:1.9.0"
|
||||
implementation 'androidx.core:core-ktx:1.12.0'
|
||||
implementation 'androidx.security:security-crypto:1.1.0-alpha06'
|
||||
implementation 'androidx.appcompat:appcompat:1.6.1'
|
||||
implementation 'com.google.android.material:material:1.11.0'
|
||||
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
|
||||
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.7.0'
|
||||
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0'
|
||||
implementation 'androidx.preference:preference-ktx:1.2.1'
|
||||
implementation 'androidx.fragment:fragment-ktx:1.6.2'
|
||||
implementation 'androidx.viewpager2:viewpager2:1.0.0'
|
||||
implementation 'androidx.lifecycle:lifecycle-runtime-compose:2.7.0'
|
||||
implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0'
|
||||
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3"
|
||||
|
||||
// 方案A: jcifs-ng (SMB) + sardine-android (WebDAV) 替代 rclone serve
|
||||
|
||||
1742
app/lint-baseline.xml
Normal file
1742
app/lint-baseline.xml
Normal file
File diff suppressed because it is too large
Load Diff
2
app/proguard-rules.pro
vendored
2
app/proguard-rules.pro
vendored
@@ -51,7 +51,7 @@
|
||||
|
||||
|
||||
|
||||
# --- jcifs-ng (SMB) — keep class/member names for MD4Provider reflection ---
|
||||
# --- jcifs-ng (SMB) — keep class/member names for reflection (was MD4Provider) ---
|
||||
-keep class jcifs.util.Crypto { *; }
|
||||
-keep class jcifs.smb.NtlmUtil { *; }
|
||||
-keep class jcifs.ntlmssp.Type3Message { *; }
|
||||
|
||||
BIN
app/release/AndroidBackupGUI-release.apk
Normal file
BIN
app/release/AndroidBackupGUI-release.apk
Normal file
Binary file not shown.
@@ -10,7 +10,7 @@
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:allowBackup="false"
|
||||
android:extractNativeLibs="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
|
||||
@@ -1,118 +1,49 @@
|
||||
package com.example.androidbackupgui
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import androidx.viewpager2.adapter.FragmentStateAdapter
|
||||
import androidx.viewpager2.widget.ViewPager2
|
||||
import com.example.androidbackupgui.databinding.ActivityMainBinding
|
||||
import com.example.androidbackupgui.root.RootShell
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.example.androidbackupgui.backup.LogUtil
|
||||
import com.example.androidbackupgui.ui.BackupFragment
|
||||
import com.example.androidbackupgui.ui.ConfigFragment
|
||||
import com.example.androidbackupgui.ui.RestoreFragment
|
||||
import com.example.androidbackupgui.backup.MissingAlgoProvider
|
||||
import com.example.androidbackupgui.backup.PasswordManager
|
||||
import com.example.androidbackupgui.backup.ResticBinary
|
||||
import com.example.androidbackupgui.backup.defaultResticWrapper
|
||||
import com.example.androidbackupgui.root.RootShell
|
||||
import com.example.androidbackupgui.ui.AppScaffold
|
||||
import com.example.androidbackupgui.ui.theme.AppTheme
|
||||
import com.google.android.material.color.DynamicColors
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class MainActivity : AppCompatActivity() {
|
||||
|
||||
private lateinit var binding: ActivityMainBinding
|
||||
|
||||
private val pageTitles = listOf("应用备份", "应用恢复", "备份配置")
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
|
||||
// Apply Dynamic Colors (Material You) if available
|
||||
DynamicColors.applyToActivitiesIfAvailable(application)
|
||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||
|
||||
binding = ActivityMainBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
|
||||
// Configure libsu with global mount namespace support
|
||||
RootShell.configure()
|
||||
|
||||
// Request root access on startup
|
||||
lifecycleScope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
RootShell.ensureSession()
|
||||
// Initialize restic binary path
|
||||
ResticBinary.prepare(this)?.let { defaultResticWrapper.binaryPath = it }
|
||||
|
||||
// Initialize file-based logging and secure credential storage
|
||||
LogUtil.init(filesDir)
|
||||
PasswordManager.init(this)
|
||||
// 启动时初始化 SMB 加密库(MD4/AESCMAC),避免首次 SMB 操作时延迟失败
|
||||
MissingAlgoProvider.register()
|
||||
|
||||
setContent {
|
||||
AppTheme {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background,
|
||||
) {
|
||||
AppScaffold()
|
||||
}
|
||||
}
|
||||
// Initialize file-based logging
|
||||
LogUtil.init(filesDir)
|
||||
}
|
||||
|
||||
// Edge-to-edge: distribute system bar insets (status bar, nav bar, cutout) to children
|
||||
ViewCompat.setOnApplyWindowInsetsListener(binding.root) { _, insets ->
|
||||
val statusBars = insets.getInsets(WindowInsetsCompat.Type.statusBars())
|
||||
val navBars = insets.getInsets(WindowInsetsCompat.Type.navigationBars())
|
||||
|
||||
// Pad toolbar below status bar (preserve existing horizontal padding)
|
||||
binding.topAppBar.setPadding(
|
||||
binding.topAppBar.paddingLeft,
|
||||
statusBars.top,
|
||||
binding.topAppBar.paddingRight,
|
||||
binding.topAppBar.paddingBottom
|
||||
)
|
||||
|
||||
// Pad bottom nav above navigation bar so menu items are visible
|
||||
binding.bottomNav.setPadding(
|
||||
binding.bottomNav.paddingLeft,
|
||||
binding.bottomNav.paddingTop,
|
||||
binding.bottomNav.paddingRight,
|
||||
navBars.bottom
|
||||
)
|
||||
|
||||
// Pad view pager above navigation bar so fragment content doesn't overlap nav bar
|
||||
binding.viewPager.setPadding(
|
||||
binding.viewPager.paddingLeft,
|
||||
binding.viewPager.paddingTop,
|
||||
binding.viewPager.paddingRight,
|
||||
navBars.bottom
|
||||
)
|
||||
|
||||
insets
|
||||
}
|
||||
|
||||
val fragments = listOf(
|
||||
BackupFragment(),
|
||||
RestoreFragment(),
|
||||
ConfigFragment()
|
||||
)
|
||||
|
||||
binding.viewPager.adapter = TabAdapter(this, fragments)
|
||||
binding.viewPager.isUserInputEnabled = true
|
||||
binding.viewPager.offscreenPageLimit = 2
|
||||
|
||||
binding.bottomNav.setOnItemSelectedListener { item ->
|
||||
when (item.itemId) {
|
||||
R.id.nav_backup -> binding.viewPager.currentItem = 0
|
||||
R.id.nav_restore -> binding.viewPager.currentItem = 1
|
||||
R.id.nav_config -> binding.viewPager.currentItem = 2
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
// Sync ViewPager -> BottomNav + Toolbar title
|
||||
binding.viewPager.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() {
|
||||
override fun onPageSelected(position: Int) {
|
||||
binding.bottomNav.menu.getItem(position).isChecked = true
|
||||
binding.topAppBar.title = pageTitles[position]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private class TabAdapter(
|
||||
activity: FragmentActivity,
|
||||
private val fragments: List<Fragment>
|
||||
) : FragmentStateAdapter(activity) {
|
||||
override fun getItemCount(): Int = fragments.size
|
||||
override fun createFragment(position: Int): Fragment = fragments[position]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,15 +7,6 @@ import com.example.androidbackupgui.root.shellEscape
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.Serializable
|
||||
@Serializable
|
||||
data class DataSizes(
|
||||
val apkBytes: Long = 0,
|
||||
val userBytes: Long = 0,
|
||||
val userDeBytes: Long = 0,
|
||||
val dataBytes: Long = 0,
|
||||
val obbBytes: Long = 0,
|
||||
val mediaBytes: Long = 0,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AppInfo(
|
||||
@@ -30,7 +21,6 @@ data class AppInfo(
|
||||
val userId: UserId = UserId(0),
|
||||
val hasKeystore: Boolean = false,
|
||||
val iconPath: String? = null,
|
||||
val dataSizes: DataSizes = DataSizes(),
|
||||
)
|
||||
|
||||
object AppScanner {
|
||||
@@ -101,16 +91,6 @@ object AppScanner {
|
||||
.filter { it.isNotEmpty() }
|
||||
}
|
||||
|
||||
/** Get the app label/name. */
|
||||
suspend fun getAppLabel(packageName: String): String = withContext(Dispatchers.IO) {
|
||||
val result = RootShell.exec("dumpsys package '${packageName.shellEscape()}' | grep -A1 'ApplicationInfo' | grep 'label=' | head -1")
|
||||
val label = result.output
|
||||
.substringAfter("label=", "")
|
||||
.substringBefore(" ")
|
||||
.removeSurrounding("\"")
|
||||
.trim()
|
||||
label.ifEmpty { packageName }
|
||||
}
|
||||
|
||||
/** Check if a package has OBB data. */
|
||||
suspend fun hasObbData(packageName: String): Boolean = withContext(Dispatchers.IO) {
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
package com.example.androidbackupgui.backup
|
||||
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* 后端执行器——消除 [ResticBackup]、[ResticRestore]、[ResticSnapshotOps]、
|
||||
* [ResticMaintenance] 和 [ResticRepoInit] 中重复的 local-vs-remote 分支。
|
||||
*
|
||||
* 使用方式(替换所有子模块中的 if backend == "local" 模式):
|
||||
*
|
||||
* ```
|
||||
* executor.withBackend(
|
||||
* repoPath = repoPath, password = password, cacheDir = cacheDir,
|
||||
* backend = backend, backendUrl = backendUrl,
|
||||
* backendUser = backendUser, backendPass = backendPass,
|
||||
* backendShare = backendShare, backendDomain = backendDomain,
|
||||
* runner = runner, envResolver = envResolver, bridgeRunner = bridgeRunner,
|
||||
* ) { env ->
|
||||
* val result = runner.runRestic(env, args)
|
||||
* // parse result
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
class BackendExecutor {
|
||||
/**
|
||||
* 使用 [block] 执行 restic 操作。
|
||||
*
|
||||
* - "local" 后端:直接通过 [ResticEnvResolver.buildLocalEnv] 构建环境
|
||||
* - 远程后端:通过 [RestBridgeRunner.withBridge] 启动 REST 桥后再构建环境
|
||||
*
|
||||
* @param T 返回值的类型(例如 [AppResult])
|
||||
* @param block 接收环境变量 Map,返回 [T]
|
||||
*/
|
||||
suspend fun <T> withBackend(
|
||||
repoPath: String,
|
||||
password: String,
|
||||
cacheDir: String,
|
||||
backend: String,
|
||||
backendUrl: String,
|
||||
backendUser: String,
|
||||
backendPass: String,
|
||||
backendShare: String,
|
||||
backendDomain: String,
|
||||
runner: ResticCommandRunner,
|
||||
envResolver: ResticEnvResolver,
|
||||
bridgeRunner: RestBridgeRunner,
|
||||
block: suspend (Map<String, String>) -> T,
|
||||
): T {
|
||||
if (backend == "local") {
|
||||
val env = envResolver.buildLocalEnv(repoPath, password, cacheDir)
|
||||
return block(env)
|
||||
}
|
||||
return bridgeRunner.withBridge(
|
||||
backend,
|
||||
backendUrl,
|
||||
backendUser,
|
||||
backendPass,
|
||||
backendShare,
|
||||
backendDomain,
|
||||
repoPath,
|
||||
File(cacheDir),
|
||||
) { bridgeUrl, authToken ->
|
||||
val env = envResolver.buildBridgeEnv(password, bridgeUrl, cacheDir, authToken)
|
||||
block(env)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 与 [withBackend] 相同,但自动将 [args] 传给 [runner.runRestic]。
|
||||
*
|
||||
* 适用于 "run-and-parse-exit-code" 模式的简化调用。
|
||||
*/
|
||||
suspend fun runResticWithBackend(
|
||||
args: List<String>,
|
||||
repoPath: String,
|
||||
password: String,
|
||||
cacheDir: String,
|
||||
backend: String,
|
||||
backendUrl: String,
|
||||
backendUser: String,
|
||||
backendPass: String,
|
||||
backendShare: String,
|
||||
backendDomain: String,
|
||||
runner: ResticCommandRunner,
|
||||
envResolver: ResticEnvResolver,
|
||||
bridgeRunner: RestBridgeRunner,
|
||||
): ResticCommandRunner.CommandResult =
|
||||
withBackend(
|
||||
repoPath = repoPath,
|
||||
password = password,
|
||||
cacheDir = cacheDir,
|
||||
backend = backend,
|
||||
backendUrl = backendUrl,
|
||||
backendUser = backendUser,
|
||||
backendPass = backendPass,
|
||||
backendShare = backendShare,
|
||||
backendDomain = backendDomain,
|
||||
runner = runner,
|
||||
envResolver = envResolver,
|
||||
bridgeRunner = bridgeRunner,
|
||||
) { env -> runner.runRestic(env, args) }
|
||||
|
||||
/**
|
||||
* 与 [runResticWithBackend] 相同,但使用流式模式。
|
||||
*/
|
||||
suspend fun runResticStreamingWithBackend(
|
||||
args: List<String>,
|
||||
repoPath: String,
|
||||
password: String,
|
||||
cacheDir: String,
|
||||
backend: String,
|
||||
backendUrl: String,
|
||||
backendUser: String,
|
||||
backendPass: String,
|
||||
backendShare: String,
|
||||
backendDomain: String,
|
||||
runner: ResticCommandRunner,
|
||||
envResolver: ResticEnvResolver,
|
||||
bridgeRunner: RestBridgeRunner,
|
||||
onLine: suspend (String) -> Unit = {},
|
||||
): ResticCommandRunner.CommandResult =
|
||||
withBackend(
|
||||
repoPath = repoPath,
|
||||
password = password,
|
||||
cacheDir = cacheDir,
|
||||
backend = backend,
|
||||
backendUrl = backendUrl,
|
||||
backendUser = backendUser,
|
||||
backendPass = backendPass,
|
||||
backendShare = backendShare,
|
||||
backendDomain = backendDomain,
|
||||
runner = runner,
|
||||
envResolver = envResolver,
|
||||
bridgeRunner = bridgeRunner,
|
||||
) { env -> runner.runResticStreaming(env, args, onLine) }
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
package com.example.androidbackupgui.backup
|
||||
|
||||
import java.io.File
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Mirrors backup_settings.conf from backup_script.
|
||||
@@ -12,72 +12,113 @@ import kotlinx.serialization.Serializable
|
||||
@Serializable
|
||||
data class BackupConfig(
|
||||
// Operation mode
|
||||
val lo: Int = 0, // 0=volume key, 1=volume force, 2=keyboard
|
||||
val backgroundExecution: Int = 0, // 0=foreground, 1=background
|
||||
val setDisplayPowerMode: Int = 0, // 1=keep screen on during backup
|
||||
val shellLang: String = "", // ""=auto, "1"=zh-CN, "0"=zh-TW
|
||||
|
||||
val lo: Int = 0, // 0=volume key, 1=volume force, 2=keyboard
|
||||
val backgroundExecution: Int = 0, // 0=foreground, 1=background
|
||||
val setDisplayPowerMode: Int = 0, // 1=keep screen on during backup
|
||||
val shellLang: String = "", // ""=auto, "1"=zh-CN, "0"=zh-TW
|
||||
// Paths
|
||||
val outputPath: String = "", // Custom output dir
|
||||
val listLocation: String = "", // Custom appList.txt location
|
||||
|
||||
val outputPath: String = "", // Custom output dir
|
||||
val listLocation: String = "", // Custom appList.txt location
|
||||
// Update
|
||||
val update: Int = 1, // 1=auto update
|
||||
val cdn: Int = 1, // CDN node
|
||||
|
||||
val update: Int = 1, // 1=auto update
|
||||
val cdn: Int = 1, // CDN node
|
||||
// Filters
|
||||
val mountPoint: String = "rannki|0000-1",
|
||||
val user: String = "",
|
||||
|
||||
// Backup mode
|
||||
val backupMode: Int = 1, // 1=data+apk, 0=apk only
|
||||
val backupMode: Int = 1, // 1=data+apk, 0=apk only
|
||||
val backupUserData: Int = 1,
|
||||
val backupObbData: Int = 1,
|
||||
val backupMedia: Int = 0,
|
||||
val backgroundAppsIgnore: Int = 0,
|
||||
|
||||
val backupUserId: Int = 0, // Android user ID (0=Owner)
|
||||
// Custom paths
|
||||
val customPath: List<String> = listOf(
|
||||
"/storage/emulated/0/Pictures/",
|
||||
"/storage/emulated/0/Download/",
|
||||
"/storage/emulated/0/Music",
|
||||
"/storage/emulated/0/DCIM/",
|
||||
"/data/adb"
|
||||
),
|
||||
|
||||
val customPath: List<String> =
|
||||
listOf(
|
||||
"/storage/emulated/0/Pictures/",
|
||||
"/storage/emulated/0/Download/",
|
||||
"/storage/emulated/0/Music",
|
||||
"/storage/emulated/0/DCIM/",
|
||||
"/data/adb",
|
||||
),
|
||||
// Blacklist
|
||||
val blacklistMode: Int = 0, // 1=full ignore, 0=apk only
|
||||
val blacklistMode: Int = 0, // 1=full ignore, 0=apk only
|
||||
val blacklist: List<String> = emptyList(),
|
||||
|
||||
// Whitelists
|
||||
val whitelist: List<String> = emptyList(),
|
||||
val system: List<String> = emptyList(),
|
||||
|
||||
// Compression
|
||||
val compressionMethod: String = "zstd", // zstd or tar
|
||||
|
||||
// Terminal colors
|
||||
val rgbA: Int = 226,
|
||||
val rgbB: Int = 123,
|
||||
val rgbC: Int = 177,
|
||||
|
||||
val backupWifi: Int = 1,
|
||||
|
||||
// Restic deduplicated backup with rclone backend
|
||||
val resticEnabled: Int = 0,
|
||||
val resticRepo: String = "",
|
||||
/**
|
||||
* restic 密码不在配置文件中明文存储。始终通过 PasswordManager 存取。
|
||||
* 此字段仅保留默认值,用于反序列化兼容旧版配置文件。
|
||||
*/
|
||||
@Deprecated("Use PasswordManager.getResticPassword() instead; kept only for config file backward compat")
|
||||
val resticPassword: String = "",
|
||||
val resticBackend: String = "local", // local / webdav / smb
|
||||
val resticBackend: String = "local", // local / webdav / smb
|
||||
val resticBackendUrl: String = "",
|
||||
val resticBackendUser: String = "",
|
||||
/** @deprecated Use PasswordManager instead */
|
||||
@Deprecated("Use PasswordManager instead")
|
||||
val resticBackendPass: String = "",
|
||||
val resticBackendShare: String = "", // SMB share name
|
||||
val resticBackendDomain: String = "" // SMB domain (optional, for NTLM)
|
||||
val resticBackendShare: String = "", // SMB share name
|
||||
val resticBackendDomain: String = "", // SMB domain (optional, for NTLM)
|
||||
// Streaming backup: pipe tar data through FIFO directly into restic --stdin
|
||||
// 0=disabled (default, stable), 1=enabled (experimental, avoids temp files)
|
||||
val useStreaming: Int = 0,
|
||||
) {
|
||||
companion object {
|
||||
/**
|
||||
* Unescape a quoted config value. Reverses [escapeValue]: turns \\ and \"
|
||||
* back into \ and ". Applied only to values that were stored inside quotes.
|
||||
*/
|
||||
private fun unescapeValue(s: String): String {
|
||||
if (s.indexOf('\\') < 0) return s
|
||||
val sb = StringBuilder(s.length)
|
||||
var i = 0
|
||||
while (i < s.length) {
|
||||
val c = s[i]
|
||||
if (c == '\\' && i + 1 < s.length) {
|
||||
sb.append(s[i + 1])
|
||||
i += 2
|
||||
} else {
|
||||
sb.append(c)
|
||||
i++
|
||||
}
|
||||
}
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
/** Escape a value for safe storage inside double quotes. */
|
||||
private fun escapeValue(s: String): String = s.replace("\\", "\\\\").replace("\"", "\\\"")
|
||||
|
||||
fun fromFile(file: File): BackupConfig {
|
||||
if (!file.exists()) return BackupConfig()
|
||||
|
||||
// Quoted-string fields preserve their inner whitespace and may contain
|
||||
// escaped characters; bare fields are trimmed as before.
|
||||
val quotedKeys =
|
||||
setOf(
|
||||
"Output_path",
|
||||
"list_location",
|
||||
"mount_point",
|
||||
"restic_repo",
|
||||
"restic_password",
|
||||
"restic_backend_url",
|
||||
"restic_backend_user",
|
||||
"restic_backend_pass",
|
||||
"restic_backend_share",
|
||||
"restic_backend_domain",
|
||||
)
|
||||
|
||||
val props = mutableMapOf<String, String>()
|
||||
file.forEachLine { line ->
|
||||
val trimmed = line.trim()
|
||||
@@ -85,15 +126,35 @@ data class BackupConfig(
|
||||
val eq = trimmed.indexOf('=')
|
||||
if (eq < 0) return@forEachLine
|
||||
val key = trimmed.substring(0, eq).trim()
|
||||
val value = trimmed.substring(eq + 1).trim().removeSurrounding("\"")
|
||||
props[key] = value
|
||||
val rawValue = trimmed.substring(eq + 1)
|
||||
props[key] =
|
||||
if (key in quotedKeys) {
|
||||
// Strip the surrounding quotes (if present) WITHOUT trimming the
|
||||
// inner content, so leading/trailing spaces in e.g. a password
|
||||
// survive a save/load round trip. Then unescape.
|
||||
val v = rawValue
|
||||
if (v.length >= 2 && v.startsWith("\"") && v.endsWith("\"")) {
|
||||
unescapeValue(v.substring(1, v.length - 1))
|
||||
} else {
|
||||
// Legacy/unquoted value — fall back to trimmed form.
|
||||
unescapeValue(v.trim().removeSurrounding("\""))
|
||||
}
|
||||
} else {
|
||||
rawValue.trim().removeSurrounding("\"")
|
||||
}
|
||||
}
|
||||
|
||||
fun int(key: String, default: Int = 0) = props[key]?.toIntOrNull() ?: default
|
||||
fun int(
|
||||
key: String,
|
||||
default: Int = 0,
|
||||
) = props[key]?.toIntOrNull() ?: default
|
||||
|
||||
fun str(key: String) = props[key] ?: ""
|
||||
|
||||
fun lines(key: String): List<String> {
|
||||
val raw = props[key] ?: return emptyList()
|
||||
return raw.split("\\s+".toRegex())
|
||||
return raw
|
||||
.split("\\s+".toRegex())
|
||||
.filter { it.isNotBlank() && it != "\"\"" }
|
||||
.map { it.replace("%20", " ") }
|
||||
}
|
||||
@@ -114,6 +175,7 @@ data class BackupConfig(
|
||||
backupObbData = int("Backup_obb_data", default = 1),
|
||||
backupMedia = int("backup_media"),
|
||||
backgroundAppsIgnore = int("Background_apps_ignore"),
|
||||
backupUserId = int("backup_user_id"),
|
||||
customPath = lines("Custom_path"),
|
||||
blacklistMode = int("blacklist_mode"),
|
||||
blacklist = lines("blacklist"),
|
||||
@@ -126,63 +188,75 @@ data class BackupConfig(
|
||||
backupWifi = int("backup_wifi", default = 1),
|
||||
resticEnabled = int("restic_enabled"),
|
||||
resticRepo = str("restic_repo"),
|
||||
resticPassword = str("restic_password"),
|
||||
resticPassword = "", // 不用配置文件中的值,见下方迁移逻辑
|
||||
resticBackend = str("restic_backend").ifEmpty { "local" },
|
||||
resticBackendUrl = str("restic_backend_url"),
|
||||
resticBackendUser = str("restic_backend_user"),
|
||||
resticBackendPass = str("restic_backend_pass"),
|
||||
resticBackendPass = "", // 不用配置文件中的值
|
||||
resticBackendShare = str("restic_backend_share"),
|
||||
resticBackendDomain = str("restic_backend_domain"),
|
||||
useStreaming = int("streaming_backup"),
|
||||
)
|
||||
}
|
||||
|
||||
fun toFile(config: BackupConfig, file: File) {
|
||||
fun toFile(
|
||||
config: BackupConfig,
|
||||
file: File,
|
||||
) {
|
||||
file.parentFile?.mkdirs()
|
||||
file.writeText(buildString {
|
||||
appendLine("# SpeedBackup Configuration")
|
||||
appendLine("Lo=${config.lo}")
|
||||
appendLine("background_execution=${config.backgroundExecution}")
|
||||
appendLine("setDisplayPowerMode=${config.setDisplayPowerMode}")
|
||||
appendLine("Shell_LANG=${config.shellLang}")
|
||||
appendLine("Output_path=\"${config.outputPath}\"")
|
||||
appendLine("list_location=\"${config.listLocation}\"")
|
||||
appendLine("update=${config.update}")
|
||||
appendLine("cdn=${config.cdn}")
|
||||
appendLine("mount_point=\"${config.mountPoint}\"")
|
||||
appendLine("user=${config.user}")
|
||||
appendLine("Backup_Mode=${config.backupMode}")
|
||||
appendLine("Backup_user_data=${config.backupUserData}")
|
||||
appendLine("Backup_obb_data=${config.backupObbData}")
|
||||
appendLine("backup_media=${config.backupMedia}")
|
||||
appendLine("Background_apps_ignore=${config.backgroundAppsIgnore}")
|
||||
append("Custom_path=\"")
|
||||
config.customPath.forEach { append(" ${it.replace(" ", "%20")}") }
|
||||
appendLine(" \"")
|
||||
appendLine("blacklist_mode=${config.blacklistMode}")
|
||||
append("blacklist=\"")
|
||||
config.blacklist.forEach { append(" ${it.replace(" ", "%20")}") }
|
||||
appendLine(" \"")
|
||||
append("whitelist=\"")
|
||||
config.whitelist.forEach { append(" ${it.replace(" ", "%20")}") }
|
||||
appendLine(" \"")
|
||||
append("system=\"")
|
||||
config.system.forEach { append(" ${it.replace(" ", "%20")}") }
|
||||
appendLine(" \"")
|
||||
appendLine("Compression_method=${config.compressionMethod}")
|
||||
appendLine("rgb_a=${config.rgbA}")
|
||||
appendLine("rgb_b=${config.rgbB}")
|
||||
appendLine("rgb_c=${config.rgbC}")
|
||||
appendLine("backup_wifi=${config.backupWifi}")
|
||||
appendLine("restic_enabled=${config.resticEnabled}")
|
||||
appendLine("restic_repo=\"${config.resticRepo}\"")
|
||||
appendLine("restic_password=\"${config.resticPassword}\"")
|
||||
appendLine("restic_backend=${config.resticBackend}")
|
||||
appendLine("restic_backend_url=\"${config.resticBackendUrl}\"")
|
||||
appendLine("restic_backend_user=\"${config.resticBackendUser}\"")
|
||||
appendLine("restic_backend_pass=\"${config.resticBackendPass}\"")
|
||||
appendLine("restic_backend_share=\"${config.resticBackendShare}\"")
|
||||
appendLine("restic_backend_domain=\"${config.resticBackendDomain}\"")
|
||||
})
|
||||
file.writeText(
|
||||
buildString {
|
||||
appendLine("# SpeedBackup Configuration")
|
||||
appendLine("Lo=${config.lo}")
|
||||
appendLine("background_execution=${config.backgroundExecution}")
|
||||
appendLine("setDisplayPowerMode=${config.setDisplayPowerMode}")
|
||||
appendLine("Shell_LANG=${config.shellLang}")
|
||||
appendLine("Output_path=\"${escapeValue(config.outputPath)}\"")
|
||||
appendLine("list_location=\"${escapeValue(config.listLocation)}\"")
|
||||
appendLine("update=${config.update}")
|
||||
appendLine("cdn=${config.cdn}")
|
||||
appendLine("mount_point=\"${escapeValue(config.mountPoint)}\"")
|
||||
appendLine("user=${config.user}")
|
||||
appendLine("Backup_Mode=${config.backupMode}")
|
||||
appendLine("Backup_user_data=${config.backupUserData}")
|
||||
appendLine("Backup_obb_data=${config.backupObbData}")
|
||||
appendLine("backup_media=${config.backupMedia}")
|
||||
appendLine("backup_user_id=${config.backupUserId}")
|
||||
appendLine("Background_apps_ignore=${config.backgroundAppsIgnore}")
|
||||
append("Custom_path=\"")
|
||||
config.customPath.forEach { append(" ${it.replace(" ", "%20")}") }
|
||||
appendLine(" \"")
|
||||
appendLine("blacklist_mode=${config.blacklistMode}")
|
||||
append("blacklist=\"")
|
||||
config.blacklist.forEach { append(" ${it.replace(" ", "%20")}") }
|
||||
appendLine(" \"")
|
||||
append("whitelist=\"")
|
||||
config.whitelist.forEach { append(" ${it.replace(" ", "%20")}") }
|
||||
appendLine(" \"")
|
||||
append("system=\"")
|
||||
config.system.forEach { append(" ${it.replace(" ", "%20")}") }
|
||||
appendLine(" \"")
|
||||
appendLine("Compression_method=${config.compressionMethod}")
|
||||
appendLine("rgb_a=${config.rgbA}")
|
||||
appendLine("rgb_b=${config.rgbB}")
|
||||
appendLine("rgb_c=${config.rgbC}")
|
||||
appendLine("backup_wifi=${config.backupWifi}")
|
||||
appendLine("restic_enabled=${config.resticEnabled}")
|
||||
appendLine("restic_repo=\"${escapeValue(config.resticRepo)}\"")
|
||||
// 密码已存储在 KeyStore 中,配置文件中仅写入占位符
|
||||
appendLine("restic_password=\"stored-in-keystore\"")
|
||||
appendLine("restic_backend=${config.resticBackend}")
|
||||
appendLine("restic_backend_url=\"${escapeValue(config.resticBackendUrl)}\"")
|
||||
appendLine("restic_backend_user=\"${escapeValue(config.resticBackendUser)}\"")
|
||||
// 密码已存储在 KeyStore 中
|
||||
appendLine("restic_backend_pass=\"stored-in-keystore\"")
|
||||
appendLine("restic_backend_share=\"${escapeValue(config.resticBackendShare)}\"")
|
||||
appendLine("restic_backend_domain=\"${escapeValue(config.resticBackendDomain)}\"")
|
||||
appendLine("streaming_backup=${config.useStreaming}")
|
||||
},
|
||||
)
|
||||
file.setReadable(true, true) // owner only
|
||||
file.setWritable(true, true) // owner only
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,22 @@
|
||||
package com.example.androidbackupgui.backup
|
||||
|
||||
import android.util.Log
|
||||
import com.example.androidbackupgui.backup.ResticWrapper.SnapshotAppInfo
|
||||
import com.example.androidbackupgui.root.RootShell
|
||||
import android.util.Log
|
||||
import com.example.androidbackupgui.root.shellEscape
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.sync.Semaphore
|
||||
import kotlinx.coroutines.sync.withPermit
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.Serializable
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
/**
|
||||
@@ -23,7 +24,6 @@ import java.util.concurrent.atomic.AtomicInteger
|
||||
* Mirrors the logic from backup_script's modules/backup.sh.
|
||||
*/
|
||||
object BackupOperation {
|
||||
|
||||
private const val TAG = "BackupOperation"
|
||||
|
||||
@Serializable
|
||||
@@ -31,8 +31,8 @@ object BackupOperation {
|
||||
val current: Int,
|
||||
val total: Int,
|
||||
val packageName: String,
|
||||
val stage: String, // "apk", "data", "obb", "ssaid", "done"
|
||||
val message: String
|
||||
val stage: String, // "apk", "data", "obb", "ssaid", "done"
|
||||
val message: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
@@ -41,7 +41,7 @@ object BackupOperation {
|
||||
val failCount: Int,
|
||||
val skippedCount: Int,
|
||||
val outputDir: String,
|
||||
val elapsedMs: Long
|
||||
val elapsedMs: Long,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -65,136 +65,284 @@ object BackupOperation {
|
||||
noDataBackup: Set<String> = emptySet(),
|
||||
includePkgs: Set<String> = emptySet(),
|
||||
legacyApps: Map<String, SnapshotAppInfo>? = null,
|
||||
onProgress: suspend (BackupProgress) -> Unit = {}
|
||||
): BackupResult = withContext(Dispatchers.IO) {
|
||||
val emit: suspend (BackupProgress) -> Unit = { p -> withContext(Dispatchers.Main) { onProgress(p) } }
|
||||
val startTime = System.currentTimeMillis()
|
||||
onProgress: suspend (BackupProgress) -> Unit = {},
|
||||
): BackupResult =
|
||||
withContext(Dispatchers.IO) {
|
||||
val emit: suspend (BackupProgress) -> Unit = { p -> withContext(Dispatchers.Main) { onProgress(p) } }
|
||||
val startTime = System.currentTimeMillis()
|
||||
|
||||
// Create backup structure
|
||||
val backupRoot = File(outputDir, "Backup_${config.compressionMethod}_$userId")
|
||||
backupRoot.mkdirs()
|
||||
LogUtil.i(TAG, "backupApps: starting backup of ${apps.size} apps to ${backupRoot.absolutePath}")
|
||||
// Safety check: refuse to backup inside Android/data directories
|
||||
val absOut = outputDir.absolutePath
|
||||
if (absOut.contains("/Android/")) {
|
||||
LogUtil.e(TAG, "backupApps: refusing to backup inside Android/ directory: $absOut")
|
||||
return@withContext BackupResult(0, 0, 0, absOut, 0)
|
||||
}
|
||||
|
||||
// Write app list — includes ALL packages in [apps] (selected + legacy from snapshot)
|
||||
val appListFile = File(backupRoot, "appList.txt")
|
||||
appListFile.writeText(apps.joinToString("\n") { it.packageName.value })
|
||||
// Create backup structure
|
||||
val backupRoot = File(outputDir, "Backup_${config.compressionMethod}_$userId")
|
||||
if (!mkdirsForBackup(backupRoot)) {
|
||||
LogUtil.e(TAG, "backupApps: cannot create output dir ${backupRoot.absolutePath}")
|
||||
return@withContext BackupResult(0, 0, 0, outputDir.absolutePath, 0)
|
||||
}
|
||||
LogUtil.i(TAG, "backupApps: starting backup of ${apps.size} apps to ${backupRoot.absolutePath}")
|
||||
|
||||
// Write metadata JSON — fresh metadata for selected apps, legacy for historical apps
|
||||
val metaFile = File(backupRoot, "app_details.json")
|
||||
metaFile.writeText(buildAppDetailsJson(apps, legacyApps))
|
||||
|
||||
val backupTargets = if (includePkgs.isEmpty()) apps else apps.filter { it.packageName.value in includePkgs }
|
||||
val totalCount = backupTargets.size
|
||||
LogUtil.i(TAG, "backupApps: includePkgs=${includePkgs.size} targets=$totalCount")
|
||||
val semaphore = Semaphore(3)
|
||||
val successAtomic = AtomicInteger(0)
|
||||
val failAtomic = AtomicInteger(0)
|
||||
val skippedAtomic = AtomicInteger(0)
|
||||
|
||||
coroutineScope {
|
||||
backupTargets.mapIndexed { index, app ->
|
||||
async {
|
||||
semaphore.withPermit {
|
||||
ensureActive()
|
||||
val appDir = File(backupRoot, app.packageName.value)
|
||||
appDir.mkdirs()
|
||||
|
||||
emit(BackupProgress(index + 1, totalCount, app.packageName.value, "apk", "正在备份 APK…"))
|
||||
|
||||
// 1. Backup APK
|
||||
val paths = AppScanner.getApkPaths(app.packageName.value)
|
||||
val apkOk = if (paths.isNotEmpty()) {
|
||||
paths.withIndex().all { (i, apkPath) ->
|
||||
val destName = if (paths.size > 1) "${app.packageName}_split_$i.apk" else "${app.packageName}.apk"
|
||||
RootShell.exec("cp '${apkPath.shellEscape()}' '${appDir.absolutePath.shellEscape()}/${destName.shellEscape()}'").isSuccess
|
||||
}
|
||||
} else false
|
||||
|
||||
if (!apkOk) {
|
||||
failAtomic.incrementAndGet()
|
||||
emit(BackupProgress(index + 1, totalCount, app.packageName.value, "done", "APK 备份失败"))
|
||||
return@withPermit
|
||||
}
|
||||
|
||||
// 1.5 Keystore check — warn if app has keystore entries (keys can be lost)
|
||||
val hasKeystore = AppScanner.hasKeystore(app.packageName.value)
|
||||
if (hasKeystore) {
|
||||
emit(BackupProgress(index + 1, totalCount, app.packageName.value, "data", "⚠ 此应用包含密钥库条目,备份后密钥可能会丢失"))
|
||||
}
|
||||
|
||||
// 2. Backup user data (if configured)
|
||||
if (config.backupMode == 1 && config.backupUserData == 1) {
|
||||
if (app.packageName.value in noDataBackup) {
|
||||
emit(BackupProgress(index + 1, totalCount, app.packageName.value, "data", "跳过数据备份(已排除)"))
|
||||
} else {
|
||||
emit(BackupProgress(index + 1, totalCount, app.packageName.value, "data", "正在备份数据…"))
|
||||
if (!backupUserData(context, app.packageName.value, appDir, userId, config.compressionMethod)) {
|
||||
failAtomic.incrementAndGet()
|
||||
emit(BackupProgress(index + 1, totalCount, app.packageName.value, "done", "数据备份失败"))
|
||||
return@withPermit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Backup OBB (if configured and exists)
|
||||
if (config.backupMode == 1 && config.backupObbData == 1) {
|
||||
val hasObb = AppScanner.hasObbData(app.packageName.value)
|
||||
if (hasObb) {
|
||||
emit(BackupProgress(index + 1, totalCount, app.packageName.value, "obb", "正在备份 OBB…"))
|
||||
if (!backupObb(app.packageName.value, appDir, config.compressionMethod)) {
|
||||
failAtomic.incrementAndGet()
|
||||
emit(BackupProgress(index + 1, totalCount, app.packageName.value, "done", "OBB 备份失败"))
|
||||
return@withPermit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Backup SSAID
|
||||
emit(BackupProgress(index + 1, totalCount, app.packageName.value, "ssaid", "正在备份 SSAID…"))
|
||||
backupSsaid(app.packageName.value, appDir, userId)
|
||||
|
||||
// 4.5 Backup app icon
|
||||
val iconPath = AppScanner.extractIcon(app.packageName.value, appDir, app.userId.value)
|
||||
if (iconPath != null) {
|
||||
Log.d(TAG, "backupApps: saved icon for ${app.packageName} -> $iconPath")
|
||||
}
|
||||
|
||||
// 5. Backup runtime permissions
|
||||
backupPermissions(app.packageName.value, appDir)
|
||||
|
||||
successAtomic.incrementAndGet()
|
||||
emit(BackupProgress(index + 1, totalCount, app.packageName.value, "done", "完成"))
|
||||
// Read previous metadata for incremental backup comparison
|
||||
val oldMetaFile = File(backupRoot, "app_details.json")
|
||||
val oldMetaJson =
|
||||
if (oldMetaFile.exists()) {
|
||||
try {
|
||||
JSONObject(readTextFile(oldMetaFile) ?: "{}")
|
||||
} catch (_: Exception) {
|
||||
JSONObject()
|
||||
}
|
||||
} else {
|
||||
JSONObject()
|
||||
}
|
||||
}.awaitAll()
|
||||
|
||||
// Write app list — includes ALL packages in [apps] (selected + legacy from snapshot)
|
||||
val appListFile = File(backupRoot, "appList.txt")
|
||||
if (!writeFileForBackup(appListFile, apps.joinToString("\n") { it.packageName.value })) {
|
||||
LogUtil.e(TAG, "backupApps: failed to write appList.txt")
|
||||
return@withContext BackupResult(0, 0, 0, outputDir.absolutePath, 0)
|
||||
}
|
||||
|
||||
// Write metadata JSON — fresh metadata for selected apps, legacy for historical apps
|
||||
val metaFile = File(backupRoot, "app_details.json")
|
||||
if (!writeFileForBackup(metaFile, buildAppDetailsJson(apps, legacyApps))) {
|
||||
LogUtil.e(TAG, "backupApps: failed to write app_details.json")
|
||||
return@withContext BackupResult(0, 0, 0, outputDir.absolutePath, 0)
|
||||
}
|
||||
|
||||
val backupTargets = if (includePkgs.isEmpty()) apps else apps.filter { it.packageName.value in includePkgs }
|
||||
val totalCount = backupTargets.size
|
||||
LogUtil.i(TAG, "backupApps: includePkgs=${includePkgs.size} targets=$totalCount")
|
||||
val semaphore = Semaphore(3)
|
||||
val successAtomic = AtomicInteger(0)
|
||||
val failAtomic = AtomicInteger(0)
|
||||
val skippedAtomic = AtomicInteger(0)
|
||||
// Collect per-app extra metadata for app_details.json
|
||||
val perAppExtraMap = ConcurrentHashMap<String, PerAppExtra>()
|
||||
|
||||
coroutineScope {
|
||||
backupTargets
|
||||
.mapIndexed { index, app ->
|
||||
async {
|
||||
semaphore.withPermit {
|
||||
ensureActive()
|
||||
val pkgName = app.packageName.value
|
||||
val appDir = File(backupRoot, pkgName)
|
||||
appDir.mkdirs()
|
||||
|
||||
// ── Incremental check: compare APK version ──
|
||||
val oldEntry = oldMetaJson.optJSONObject(pkgName)
|
||||
val oldApkVersion = oldEntry?.optString("apk_version", null)
|
||||
var installedVersion: String? = null
|
||||
var apkChanged = true
|
||||
if (oldApkVersion != null) {
|
||||
val vResult = RootShell.exec("dumpsys package '$pkgName' | grep versionCode | head -1")
|
||||
installedVersion =
|
||||
vResult.output
|
||||
.substringAfter("versionCode=")
|
||||
.substringBefore(" ")
|
||||
.filter { it.isDigit() }
|
||||
.takeIf { it.isNotEmpty() }
|
||||
if (installedVersion != null && oldApkVersion == installedVersion) {
|
||||
apkChanged = false
|
||||
Log.d(TAG, "backupApps: $pkgName APK $oldApkVersion unchanged, skipping")
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Backup APK (only if version changed)
|
||||
if (apkChanged) {
|
||||
emit(BackupProgress(index + 1, totalCount, pkgName, "apk", "正在备份 APK…"))
|
||||
val paths = AppScanner.getApkPaths(pkgName)
|
||||
if (paths.isNotEmpty()) {
|
||||
val cpOk =
|
||||
paths.withIndex().all { (i, apkPath) ->
|
||||
val destName = if (paths.size > 1) "${pkgName}_split_$i.apk" else "$pkgName.apk"
|
||||
RootShell
|
||||
.exec(
|
||||
"cp '${apkPath.shellEscape()}' '${appDir.absolutePath.shellEscape()}/${destName.shellEscape()}'",
|
||||
).isSuccess
|
||||
}
|
||||
if (!cpOk) LogUtil.w(TAG, "backupApps: APK cp failed for $pkgName, continuing")
|
||||
}
|
||||
} else {
|
||||
skippedAtomic.incrementAndGet()
|
||||
emit(BackupProgress(index + 1, totalCount, pkgName, "apk", "APK无变化,跳过"))
|
||||
}
|
||||
|
||||
// Keystore check
|
||||
val hasKeystore = AppScanner.hasKeystore(pkgName)
|
||||
if (hasKeystore) emit(BackupProgress(index + 1, totalCount, pkgName, "data", "⚠ 包含密钥库条目"))
|
||||
|
||||
// ── Size-based data incremental skip ──
|
||||
var skipData = false
|
||||
if (!apkChanged) {
|
||||
// APK unchanged: check if data sizes match
|
||||
val oldUserSize =
|
||||
try {
|
||||
oldEntry?.optJSONObject("user")?.optString("Size", null)?.toLongOrNull()
|
||||
} catch (
|
||||
_: Exception,
|
||||
) {
|
||||
null
|
||||
}
|
||||
val oldObbSize =
|
||||
try {
|
||||
oldEntry?.optJSONObject("obb")?.optString("Size", null)?.toLongOrNull()
|
||||
} catch (
|
||||
_: Exception,
|
||||
) {
|
||||
null
|
||||
}
|
||||
if (oldUserSize != null || oldObbSize != null) {
|
||||
skipData = true
|
||||
Log.d(TAG, "backupApps: $pkgName data sizes known from backup, will compare after tar")
|
||||
}
|
||||
}
|
||||
|
||||
// ── Per-app size tracking ──
|
||||
var userSize: Long? = null
|
||||
var userDeSize: Long? = null
|
||||
var dataSize: Long? = null
|
||||
var obbSize: Long? = null
|
||||
|
||||
// Force-stop before data backup for consistency
|
||||
// 排除应用自身(避免自杀)和已知常驻应用
|
||||
if (config.backupMode == 1 && !skipData) {
|
||||
if (pkgName !in listOf("bin.mt.plus", "com.termux", "bin.mt.plus.canary", context.packageName)) {
|
||||
RootShell.exec("am force-stop --user $userId '$pkgName' 2>/dev/null")
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Backup user data
|
||||
if (config.backupMode == 1 && config.backupUserData == 1 && !skipData) {
|
||||
if (pkgName in noDataBackup) {
|
||||
emit(BackupProgress(index + 1, totalCount, pkgName, "data", "跳过数据备份(已排除)"))
|
||||
} else {
|
||||
emit(BackupProgress(index + 1, totalCount, pkgName, "data", "正在备份数据…"))
|
||||
val udResult = backupUserData(context, pkgName, appDir, userId, config.compressionMethod)
|
||||
userSize = udResult.first
|
||||
userDeSize = udResult.second
|
||||
if (udResult.first == null) {
|
||||
failAtomic.incrementAndGet()
|
||||
emit(BackupProgress(index + 1, totalCount, pkgName, "done", "数据备份失败"))
|
||||
return@withPermit
|
||||
}
|
||||
}
|
||||
} else if (skipData) {
|
||||
emit(BackupProgress(index + 1, totalCount, pkgName, "data", "数据无变化,跳过"))
|
||||
}
|
||||
|
||||
// 3. Backup OBB
|
||||
if (config.backupMode == 1 && config.backupObbData == 1 && !skipData) {
|
||||
val hasObb = AppScanner.hasObbData(pkgName)
|
||||
if (hasObb) {
|
||||
emit(BackupProgress(index + 1, totalCount, pkgName, "obb", "正在备份 OBB…"))
|
||||
obbSize = backupObb(pkgName, appDir, config.compressionMethod)
|
||||
if (obbSize == null) {
|
||||
failAtomic.incrementAndGet()
|
||||
emit(BackupProgress(index + 1, totalCount, pkgName, "done", "OBB 备份失败"))
|
||||
return@withPermit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3.5 Backup external data
|
||||
if (config.backupMode == 1 && config.backupUserData == 1 && !skipData) {
|
||||
if (pkgName !in noDataBackup) {
|
||||
emit(BackupProgress(index + 1, totalCount, pkgName, "data", "正在备份外部数据…"))
|
||||
dataSize = backupExternalData(pkgName, appDir, userId, config.compressionMethod)
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Backup SSAID
|
||||
emit(BackupProgress(index + 1, totalCount, pkgName, "ssaid", "正在备份 SSAID…"))
|
||||
backupSsaid(pkgName, appDir, userId)
|
||||
|
||||
// Icon + permissions (always, for completeness)
|
||||
val iconPath = AppScanner.extractIcon(pkgName, appDir, app.userId.value)
|
||||
if (iconPath != null) Log.d(TAG, "backupApps: saved icon for $pkgName -> $iconPath")
|
||||
backupPermissions(pkgName, appDir)
|
||||
|
||||
// Save per-app metadata for enhanced app_details.json
|
||||
val ssaidValue = readTextFile(File(appDir, "ssaid.txt"))?.trim()
|
||||
val permText = readTextFile(File(appDir, "permissions.txt"))
|
||||
val permissionsJson =
|
||||
if (permText != null) {
|
||||
try {
|
||||
val parsed = JSONObject()
|
||||
permText.lines().forEach { line ->
|
||||
val name = line.substringBefore(":").trim()
|
||||
val granted = line.contains("granted=true")
|
||||
if (name.contains(".")) parsed.put(name, if (granted) "granted:true" else "granted:false")
|
||||
}
|
||||
parsed
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
perAppExtraMap[pkgName] =
|
||||
PerAppExtra(
|
||||
ssaid = ssaidValue,
|
||||
permissions = permissionsJson,
|
||||
keystore = hasKeystore,
|
||||
userSize = userSize,
|
||||
userDeSize = userDeSize,
|
||||
dataSize = dataSize,
|
||||
obbSize = obbSize,
|
||||
)
|
||||
|
||||
successAtomic.incrementAndGet()
|
||||
emit(BackupProgress(index + 1, totalCount, pkgName, "done", "完成"))
|
||||
}
|
||||
}
|
||||
}.awaitAll()
|
||||
}
|
||||
|
||||
val elapsed = System.currentTimeMillis() - startTime
|
||||
RootShell.exec("chmod -R 0755 '${backupRoot.absolutePath}'")
|
||||
|
||||
val successCount = successAtomic.get()
|
||||
val failCount = failAtomic.get()
|
||||
val skippedCount = skippedAtomic.get()
|
||||
|
||||
LogUtil.i(TAG, "backupApps: completed — success=$successCount fail=$failCount skipped=$skippedCount elapsed=${elapsed}ms")
|
||||
|
||||
// Re-write metadata files with enhanced app_details.json (includes per-app extas)
|
||||
val metaJson = buildAppDetailsJson(apps, legacyApps, perAppExtraMap.ifEmpty { null })
|
||||
writeFileForBackup(File(backupRoot, "app_details.json"), metaJson)
|
||||
|
||||
BackupResult(
|
||||
successCount = successCount,
|
||||
failCount = failCount,
|
||||
skippedCount = skippedCount,
|
||||
outputDir = backupRoot.absolutePath,
|
||||
elapsedMs = elapsed,
|
||||
)
|
||||
}
|
||||
|
||||
val elapsed = System.currentTimeMillis() - startTime
|
||||
RootShell.exec("chmod -R 0755 '${backupRoot.absolutePath}'")
|
||||
|
||||
val successCount = successAtomic.get()
|
||||
val failCount = failAtomic.get()
|
||||
val skippedCount = skippedAtomic.get()
|
||||
|
||||
LogUtil.i(TAG, "backupApps: completed — success=$successCount fail=$failCount skipped=$skippedCount elapsed=${elapsed}ms")
|
||||
|
||||
BackupResult(
|
||||
successCount = successCount,
|
||||
failCount = failCount,
|
||||
skippedCount = skippedCount,
|
||||
outputDir = backupRoot.absolutePath,
|
||||
elapsedMs = elapsed
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
private suspend fun backupUserData(
|
||||
/**
|
||||
* 备份单个应用的用户数据(/data/data + /data/user_de)。
|
||||
*
|
||||
* 使用 tar + zstd/gzip 创建应用数据存档,支持 3 种回退策略:
|
||||
* 1. 通过 nsenter 直接 tar
|
||||
* 2. 直接 tar 路径(跳过 test -d)
|
||||
* 3. 通过 /proc/1/root 全局挂载命名空间
|
||||
*
|
||||
* @return Pair(userSize, userDeSize),任一失败时为 null
|
||||
*/
|
||||
internal suspend fun backupUserData(
|
||||
context: android.content.Context,
|
||||
packageName: String,
|
||||
appDir: File,
|
||||
userId: String,
|
||||
compression: String
|
||||
): Boolean {
|
||||
compression: String,
|
||||
): Pair<Long?, Long?> {
|
||||
val pkgEsc = packageName.shellEscape()
|
||||
val outputFile = "${appDir.absolutePath.shellEscape()}/${pkgEsc}_data.tar"
|
||||
|
||||
@@ -215,6 +363,11 @@ object BackupOperation {
|
||||
val archiveExt = if (isZstd) ".zst" else ".gz"
|
||||
val archiveRaw = File(appDir, "${packageName}_data.tar$archiveExt")
|
||||
|
||||
// Helper: check file exists and has size > 0, using root shell for FUSE paths
|
||||
suspend fun archiveHasData(): Boolean =
|
||||
BackupOperation.backupPathExists(archiveRaw) &&
|
||||
(archiveRaw.length() > 0 || BackupOperation.backupFileSize(archiveRaw) > 0L)
|
||||
|
||||
Log.d(TAG, "backupUserData: $packageName checking dirs (tar=$tarCmd zstd=$zstdCmd)")
|
||||
|
||||
val rawPkg = packageName
|
||||
@@ -229,12 +382,12 @@ object BackupOperation {
|
||||
if (dirs.isNotEmpty()) {
|
||||
Log.d(TAG, "backupUserData: $packageName test -d found dirs=$dirs")
|
||||
result = runTar(dirs, outputFile, isZstd, tarCmd, zstdCmd, excludes = dataExcludes)
|
||||
archiveCreated = archiveCreated || (archiveRaw.exists() && archiveRaw.length() > 0)
|
||||
archiveCreated = archiveHasData()
|
||||
Log.d(TAG, "backupUserData: $packageName step1 exit=${result?.exitCode} err='${result?.error?.take(100)}'")
|
||||
} else {
|
||||
Log.d(TAG, "backupUserData: $packageName test -d all failed, trying tar directly")
|
||||
result = runTar(dataPaths, outputFile, isZstd, tarCmd, zstdCmd, excludes = dataExcludes)
|
||||
archiveCreated = archiveCreated || (archiveRaw.exists() && archiveRaw.length() > 0)
|
||||
archiveCreated = archiveHasData()
|
||||
Log.d(TAG, "backupUserData: $packageName step2 exit=${result?.exitCode} err='${result?.error?.take(100)}'")
|
||||
}
|
||||
|
||||
@@ -242,137 +395,306 @@ object BackupOperation {
|
||||
if (!archiveCreated) {
|
||||
Log.w(TAG, "backupUserData: $packageName step3 trying /proc/1/root")
|
||||
val globalRelPaths = dataPaths.map { it.removePrefix("/") }
|
||||
val globalCmd = if (isZstd) {
|
||||
"cd /proc/1/root && set -o pipefail; $tarCmd --exclude='.ota' --exclude='cache' --exclude='code_cache' --exclude='lib' --exclude='no_backup' -cf - ${globalRelPaths.joinToString(" ") { "'${it.shellEscape()}'" }} 2>/dev/null | $zstdCmd -T0 -o '$outputFile.zst'"
|
||||
} else {
|
||||
"cd /proc/1/root && $tarCmd --exclude='.ota' --exclude='cache' --exclude='code_cache' --exclude='lib' --exclude='no_backup' -czf '$outputFile.gz' ${globalRelPaths.joinToString(" ") { "'${it.shellEscape()}'" }} 2>/dev/null"
|
||||
}
|
||||
val globalCmd =
|
||||
if (isZstd) {
|
||||
"cd /proc/1/root && set -o pipefail; $tarCmd --exclude='.ota' --exclude='cache' --exclude='code_cache' --exclude='lib' --exclude='no_backup' -cf - ${globalRelPaths.joinToString(
|
||||
" ",
|
||||
) { "'${it.shellEscape()}'" }} 2>/dev/null | $zstdCmd -T0 -o '$outputFile.zst'"
|
||||
} else {
|
||||
"cd /proc/1/root && $tarCmd --exclude='.ota' --exclude='cache' --exclude='code_cache' --exclude='lib' --exclude='no_backup' -czf '$outputFile.gz' ${globalRelPaths.joinToString(
|
||||
" ",
|
||||
) { "'${it.shellEscape()}'" }} 2>/dev/null"
|
||||
}
|
||||
result = RootShell.exec(globalCmd)
|
||||
archiveCreated = archiveCreated || (archiveRaw.exists() && archiveRaw.length() > 0)
|
||||
archiveCreated = archiveHasData()
|
||||
Log.d(TAG, "backupUserData: $packageName step3 exit=${result?.exitCode} err='${result?.error?.take(100)}'")
|
||||
}
|
||||
|
||||
if (!archiveCreated) {
|
||||
Log.w(TAG, "backupUserData: $packageName all methods failed — no data dirs (or inaccessible)")
|
||||
return true
|
||||
LogUtil.w(TAG, "backupUserData: $packageName all methods failed — no data dirs (or inaccessible)")
|
||||
return null to null
|
||||
}
|
||||
|
||||
// Verify compression integrity
|
||||
val verifyOk = if (isZstd) {
|
||||
RootShell.exec("$zstdCmd -t '$outputFile.zst' 2>/dev/null").isSuccess
|
||||
} else {
|
||||
RootShell.exec("gzip -t '$outputFile.gz' 2>/dev/null").isSuccess
|
||||
}
|
||||
val verifyOk =
|
||||
if (isZstd) {
|
||||
RootShell.exec("$zstdCmd -t '$outputFile.zst' 2>/dev/null").isSuccess
|
||||
} else {
|
||||
RootShell.exec("gzip -t '$outputFile.gz' 2>/dev/null").isSuccess
|
||||
}
|
||||
if (!verifyOk) {
|
||||
Log.e(TAG, "backupUserData: $packageName integrity check FAILED")
|
||||
return false
|
||||
return null to null
|
||||
}
|
||||
|
||||
// Validate tar archive structure (Android-DataBackup Tar.test() pattern)
|
||||
val tarValidateOk = if (isZstd) {
|
||||
RootShell.exec("$zstdCmd -d -c '$outputFile.zst' 2>/dev/null | tar -tf - > /dev/null 2>&1").isSuccess
|
||||
} else {
|
||||
RootShell.exec("tar -tf '$outputFile.gz' > /dev/null 2>&1").isSuccess
|
||||
}
|
||||
// Validate tar archive structure
|
||||
val tarValidateOk =
|
||||
if (isZstd) {
|
||||
RootShell.exec("$zstdCmd -d -c '$outputFile.zst' 2>/dev/null | tar -tf - > /dev/null 2>&1").isSuccess
|
||||
} else {
|
||||
RootShell.exec("tar -tf '$outputFile.gz' > /dev/null 2>&1").isSuccess
|
||||
}
|
||||
if (!tarValidateOk) {
|
||||
Log.e(TAG, "backupUserData: $packageName tar archive structure validation FAILED")
|
||||
return false
|
||||
return null to null
|
||||
}
|
||||
return true
|
||||
return archiveRaw.length() to 0L // Return (userSize, userDeSize) — combined in one file
|
||||
}
|
||||
|
||||
/** Run tar for given paths, building the appropriate zstd/gzip command. */
|
||||
private suspend fun runTar(
|
||||
/**
|
||||
* 运行 tar 命令,自动选择 zstd 或 gzip 压缩。
|
||||
*/
|
||||
internal suspend fun runTar(
|
||||
dirs: List<String>,
|
||||
outputFile: String,
|
||||
isZstd: Boolean,
|
||||
tarCmd: String = "tar",
|
||||
zstdCmd: String = "zstd",
|
||||
excludes: List<String> = emptyList()
|
||||
excludes: List<String> = emptyList(),
|
||||
): RootShell.ShellResult {
|
||||
val excludeArgs = if (excludes.isNotEmpty()) {
|
||||
excludes.joinToString(" ") { "--exclude='${it.shellEscape()}'" }
|
||||
} else ""
|
||||
val excludeArgs =
|
||||
if (excludes.isNotEmpty()) {
|
||||
excludes.joinToString(" ") { "--exclude='${it.shellEscape()}'" }
|
||||
} else {
|
||||
""
|
||||
}
|
||||
return if (isZstd) {
|
||||
RootShell.exec("set -o pipefail; $tarCmd -cf - $excludeArgs ${dirs.joinToString(" ") { "'${it.shellEscape()}'" }} 2>/dev/null | $zstdCmd -T0 -o '$outputFile.zst'")
|
||||
RootShell.exec(
|
||||
"set -o pipefail; $tarCmd -cf - $excludeArgs ${dirs.joinToString(
|
||||
" ",
|
||||
) { "'${it.shellEscape()}'" }} 2>/dev/null | $zstdCmd -T0 -o '$outputFile.zst'",
|
||||
)
|
||||
} else {
|
||||
RootShell.exec("$tarCmd -czf $excludeArgs '$outputFile.gz' ${dirs.joinToString(" ") { "'${it.shellEscape()}'" }} 2>/dev/null")
|
||||
}
|
||||
}
|
||||
private suspend fun backupObb(packageName: String, appDir: File, compression: String): Boolean {
|
||||
|
||||
/**
|
||||
* 备份单个应用的 OBB 数据文件夹。
|
||||
* @return obbSize 或 null(失败时)
|
||||
*/
|
||||
internal suspend fun backupObb(
|
||||
packageName: String,
|
||||
appDir: File,
|
||||
compression: String,
|
||||
): Long? {
|
||||
val obbDir = "/storage/emulated/0/Android/obb/${packageName.shellEscape()}"
|
||||
val escapedAppDir = appDir.absolutePath.shellEscape()
|
||||
val escapedPkg = packageName.shellEscape()
|
||||
// Exclude cache and backup temp files from OBB archive
|
||||
val obbExcludes = "--exclude='cache' --exclude='Backup_*'"
|
||||
val result = when (compression) {
|
||||
"zstd" -> RootShell.exec("set -o pipefail; tar -cf - $obbExcludes '$obbDir' 2>/dev/null | zstd -T0 -o '$escapedAppDir/${escapedPkg}_obb.tar.zst'")
|
||||
else -> RootShell.exec("tar -czf $obbExcludes '$escapedAppDir/${escapedPkg}_obb.tar.gz' '$obbDir' 2>/dev/null")
|
||||
}
|
||||
val result =
|
||||
when (compression) {
|
||||
"zstd" -> {
|
||||
RootShell.exec(
|
||||
"set -o pipefail; tar -cf - $obbExcludes '$obbDir' 2>/dev/null | zstd -T0 -o '$escapedAppDir/${escapedPkg}_obb.tar.zst'",
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
RootShell.exec("tar -czf $obbExcludes '$escapedAppDir/${escapedPkg}_obb.tar.gz' '$obbDir' 2>/dev/null")
|
||||
}
|
||||
}
|
||||
if (!result.isSuccess) {
|
||||
Log.e(TAG, "Failed to backup OBB for $packageName: exit=${result.exitCode} err=${result.error}")
|
||||
return false
|
||||
return null
|
||||
}
|
||||
val archive = if (compression == "zstd") "$escapedAppDir/${escapedPkg}_obb.tar.zst" else "$escapedAppDir/${escapedPkg}_obb.tar.gz"
|
||||
val verifyCmd = if (compression == "zstd") "zstd -t '$archive' 2>/dev/null" else "gzip -t '$archive' 2>/dev/null"
|
||||
val obbArchiveExt = if (compression == "zstd") ".zst" else ".gz"
|
||||
val obbFile = File(appDir, "${packageName}_obb.tar$obbArchiveExt")
|
||||
val obbArchivePath = obbFile.absolutePath.shellEscape()
|
||||
val verifyCmd = if (compression == "zstd") "zstd -t '$obbArchivePath' 2>/dev/null" else "gzip -t '$obbArchivePath' 2>/dev/null"
|
||||
val verificationOk = RootShell.exec(verifyCmd).isSuccess
|
||||
if (!verificationOk) {
|
||||
Log.e(TAG, "OBB archive integrity check FAILED for $packageName")
|
||||
}
|
||||
// Validate OBB tar structure
|
||||
val tarListCmd = if (compression == "zstd") "zstd -d -c '$archive' 2>/dev/null | tar -tf - > /dev/null 2>&1" else "tar -tf '$archive' > /dev/null 2>&1"
|
||||
val tarListCmd =
|
||||
if (compression == "zstd") {
|
||||
"zstd -d -c '$obbArchivePath' 2>/dev/null | tar -tf - > /dev/null 2>&1"
|
||||
} else {
|
||||
"tar -tf '$obbArchivePath' > /dev/null 2>&1"
|
||||
}
|
||||
val tarOk = RootShell.exec(tarListCmd).isSuccess
|
||||
if (!tarOk) {
|
||||
Log.e(TAG, "OBB tar structure validation FAILED for $packageName")
|
||||
}
|
||||
return verificationOk && tarOk
|
||||
return if (verificationOk && tarOk) BackupOperation.backupFileSize(obbFile) else null
|
||||
}
|
||||
|
||||
private suspend fun backupSsaid(packageName: String, appDir: File, userId: String) {
|
||||
/**
|
||||
* 备份单个应用的外部数据目录(/data/media/<userId>/Android/data/<pkg>)。
|
||||
* @return dataSize 或 null(目录不存在或失败)
|
||||
*/
|
||||
internal suspend fun backupExternalData(
|
||||
packageName: String,
|
||||
appDir: File,
|
||||
userId: String,
|
||||
compression: String,
|
||||
): Long? {
|
||||
val pkgEsc = packageName.shellEscape()
|
||||
val externalDataDir = "/data/media/$userId/Android/data/$pkgEsc"
|
||||
|
||||
// Check if the directory exists
|
||||
val checkResult = RootShell.exec("test -d '$externalDataDir' && echo 1 || echo 0")
|
||||
if (checkResult.output.trim() != "1") {
|
||||
Log.d(TAG, "backupExternalData: $packageName — no external data dir at $externalDataDir")
|
||||
return 0L // Not an error, just no data
|
||||
}
|
||||
|
||||
val archiveExt = if (compression == "zstd") ".zst" else ".gz"
|
||||
val archiveFile = File(appDir, "${packageName}_external_data.tar$archiveExt")
|
||||
val archivePath = archiveFile.absolutePath.shellEscape()
|
||||
val dataExcludes = "--exclude='cache' --exclude='Backup_*' --exclude='.ota'"
|
||||
|
||||
val result =
|
||||
if (compression == "zstd") {
|
||||
RootShell.exec(
|
||||
"set -o pipefail; tar -cf - $dataExcludes '$externalDataDir' 2>/dev/null | zstd -T0 -o '$archivePath'",
|
||||
)
|
||||
} else {
|
||||
RootShell.exec("tar -czf $dataExcludes '$archivePath' '$externalDataDir' 2>/dev/null")
|
||||
}
|
||||
|
||||
if (!result.isSuccess) {
|
||||
Log.w(TAG, "backupExternalData: $packageName tar failed: ${result.error}")
|
||||
return null
|
||||
}
|
||||
|
||||
// Verify compression integrity
|
||||
val verifyCmd = if (compression == "zstd") "zstd -t '$archivePath' 2>/dev/null" else "gzip -t '$archivePath' 2>/dev/null"
|
||||
val verificationOk = RootShell.exec(verifyCmd).isSuccess
|
||||
if (!verificationOk) {
|
||||
Log.e(TAG, "backupExternalData: $packageName integrity check FAILED")
|
||||
return null
|
||||
}
|
||||
|
||||
// Validate tar structure
|
||||
val tarListCmd =
|
||||
if (compression == "zstd") {
|
||||
"zstd -d -c '$archivePath' 2>/dev/null | tar -tf - > /dev/null 2>&1"
|
||||
} else {
|
||||
"tar -tf '$archivePath' > /dev/null 2>&1"
|
||||
}
|
||||
val tarOk = RootShell.exec(tarListCmd).isSuccess
|
||||
if (!tarOk) {
|
||||
Log.e(TAG, "backupExternalData: $packageName tar structure validation FAILED")
|
||||
return null
|
||||
}
|
||||
|
||||
Log.i(TAG, "backupExternalData: $packageName backed up (size=${archiveFile.length()})")
|
||||
return BackupOperation.backupFileSize(archiveFile)
|
||||
}
|
||||
|
||||
/**
|
||||
* 备份单个应用的 SSAID(设置安全标识符)。
|
||||
* 从 settings_ssaid.xml 中提取。
|
||||
*/
|
||||
internal suspend fun backupSsaid(
|
||||
packageName: String,
|
||||
appDir: File,
|
||||
userId: String,
|
||||
) {
|
||||
val ssaidFile = "/data/system/users/${userId.shellEscape()}/settings_ssaid.xml"
|
||||
// Parse XML value attribute for this package's SSAID entry
|
||||
val result = RootShell.exec("cat '$ssaidFile' 2>/dev/null")
|
||||
if (!result.isSuccess || result.output.isBlank()) return
|
||||
val ssaidLine = result.output.lines().firstOrNull { line ->
|
||||
line.contains("packageName=\"$packageName\"") || line.contains("packageName='$packageName'")
|
||||
}
|
||||
val value = ssaidLine
|
||||
?.substringAfter("value=\"")
|
||||
?.substringBefore("\"")
|
||||
?.takeIf { it.isNotBlank() }
|
||||
val ssaidLine =
|
||||
result.output.lines().firstOrNull { line ->
|
||||
line.contains("packageName=\"$packageName\"") || line.contains("packageName='$packageName'")
|
||||
}
|
||||
val value =
|
||||
ssaidLine
|
||||
?.substringAfter("value=\"")
|
||||
?.substringBefore("\"")
|
||||
?.takeIf { it.isNotBlank() }
|
||||
if (value != null) {
|
||||
File(appDir, "ssaid.txt").writeText(value)
|
||||
Log.d(TAG, "backupSsaid: backed up SSAID for $packageName = $value")
|
||||
val ssaidFile = File(appDir, "ssaid.txt")
|
||||
if (!writeFileForBackup(ssaidFile, value)) {
|
||||
Log.w(TAG, "backupSsaid: failed to write ssaid.txt for $packageName")
|
||||
} else {
|
||||
Log.d(TAG, "backupSsaid: backed up SSAID for $packageName = $value")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun backupPermissions(packageName: String, appDir: File) {
|
||||
/**
|
||||
* 备份单个应用的运行时权限状态。
|
||||
*/
|
||||
internal suspend fun backupPermissions(
|
||||
packageName: String,
|
||||
appDir: File,
|
||||
) {
|
||||
val result = RootShell.exec("dumpsys package '${packageName.shellEscape()}' | grep -E 'granted=(true|false)'")
|
||||
if (result.output.isNotBlank()) {
|
||||
File(appDir, "permissions.txt").writeText(result.output)
|
||||
val permFile = File(appDir, "permissions.txt")
|
||||
if (!writeFileForBackup(permFile, result.output)) {
|
||||
Log.w(TAG, "backupPermissions: failed to write permissions.txt for $packageName")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal suspend fun buildAppDetailsJson(
|
||||
apps: List<AppInfo>,
|
||||
legacyApps: Map<String, SnapshotAppInfo>? = null
|
||||
legacyApps: Map<String, SnapshotAppInfo>? = null,
|
||||
perAppExtra: Map<String, PerAppExtra>? = null,
|
||||
): String {
|
||||
val root = JSONObject()
|
||||
// Generate fresh metadata for apps in the current app list
|
||||
val now = java.text.SimpleDateFormat("yyyy.MM.dd HH:mm:ss", java.util.Locale.US).format(java.util.Date())
|
||||
for (app in apps) {
|
||||
val entry = JSONObject()
|
||||
entry.put("label", app.label)
|
||||
entry.put("isSystem", app.isSystem)
|
||||
// Record APK file sizes for change detection in incremental backup
|
||||
entry.put("PackageName", app.packageName.value)
|
||||
|
||||
// APK versionCode for incremental skip
|
||||
val versionResult = RootShell.exec("dumpsys package '${app.packageName.value.shellEscape()}' | grep versionCode | head -1")
|
||||
val apkVersion =
|
||||
versionResult.output
|
||||
.substringAfter("versionCode=")
|
||||
.substringBefore(" ")
|
||||
.filter { it.isDigit() }
|
||||
.takeIf { it.isNotEmpty() }
|
||||
if (apkVersion != null) entry.put("apk_version", apkVersion)
|
||||
|
||||
// APK file sizes
|
||||
val paths = AppScanner.getApkPaths(app.packageName.value)
|
||||
val sizes = paths.map { path ->
|
||||
val result = RootShell.exec("stat -c%s '${path.shellEscape()}'")
|
||||
if (result.isSuccess) result.output.trim().toLongOrNull() ?: 0L else 0L
|
||||
}
|
||||
val sizes =
|
||||
paths.map { path ->
|
||||
val result = RootShell.exec("stat -c%s '${path.shellEscape()}'")
|
||||
if (result.isSuccess) result.output.trim().toLongOrNull() ?: 0L else 0L
|
||||
}
|
||||
entry.put("apkSizes", JSONArray(sizes))
|
||||
|
||||
// Per-app extra data collected during backup
|
||||
val extra = perAppExtra?.get(app.packageName.value)
|
||||
if (extra != null) {
|
||||
if (extra.ssaid != null) entry.put("Ssaid", extra.ssaid)
|
||||
if (extra.permissions != null) entry.put("permissions", extra.permissions)
|
||||
if (extra.keystore) entry.put("keystore", "true")
|
||||
|
||||
fun putSize(
|
||||
key: String,
|
||||
value: Long?,
|
||||
) {
|
||||
if (value != null) {
|
||||
val obj = JSONObject()
|
||||
obj.put("Size", value.toString())
|
||||
entry.put(key, obj)
|
||||
}
|
||||
}
|
||||
putSize("user", extra.userSize)
|
||||
putSize("user_de", extra.userDeSize)
|
||||
putSize("data", extra.dataSize)
|
||||
putSize("obb", extra.obbSize)
|
||||
}
|
||||
|
||||
val timeObj = JSONObject()
|
||||
timeObj.put("date", now)
|
||||
entry.put("Backup time", timeObj)
|
||||
|
||||
root.put(app.packageName.value, entry)
|
||||
}
|
||||
// Include legacy apps not in current app list with preserved metadata
|
||||
// Legacy apps from previous snapshot
|
||||
val legacyMap = legacyApps ?: emptyMap()
|
||||
for ((pkg, legacy) in legacyMap) {
|
||||
if (!root.has(pkg)) {
|
||||
@@ -385,4 +707,109 @@ object BackupOperation {
|
||||
}
|
||||
return root.toString(2)
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-app extra metadata collected during backup write phase.
|
||||
*/
|
||||
internal data class PerAppExtra(
|
||||
val ssaid: String? = null,
|
||||
val permissions: org.json.JSONObject? = null,
|
||||
val keystore: Boolean = false,
|
||||
val userSize: Long? = null,
|
||||
val userDeSize: Long? = null,
|
||||
val dataSize: Long? = null,
|
||||
val obbSize: Long? = null,
|
||||
)
|
||||
|
||||
/** Create backup output directory, falling back to root shell [mkdir -p]. */
|
||||
internal suspend fun mkdirsForBackup(dir: File): Boolean {
|
||||
if (dir.isDirectory) return true
|
||||
if (dir.mkdirs()) return true
|
||||
val result = RootShell.exec("mkdir -p '${dir.absolutePath.shellEscape()}'")
|
||||
return result.isSuccess && dir.isDirectory
|
||||
}
|
||||
|
||||
/** Write text to a file, falling back to root shell (base64 + cat). */
|
||||
internal suspend fun writeFileForBackup(
|
||||
file: File,
|
||||
text: String,
|
||||
): Boolean {
|
||||
try {
|
||||
mkdirsForBackup(file.parentFile ?: return false)
|
||||
file.writeText(text)
|
||||
return true
|
||||
} catch (_: Exception) {
|
||||
// fall through
|
||||
}
|
||||
try {
|
||||
mkdirsForBackup(file.parentFile ?: return false)
|
||||
val b64 = android.util.Base64.encodeToString(text.toByteArray(), android.util.Base64.NO_WRAP)
|
||||
val result = RootShell.exec("echo '${b64.shellEscape()}' | base64 -d > '${file.absolutePath.shellEscape()}'")
|
||||
return result.isSuccess
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "writeFileForBackup: all methods failed for ${file.absolutePath}", e)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** Read file content, falling back to root shell [cat]. Returns null on failure. */
|
||||
internal suspend fun readTextFile(file: File): String? {
|
||||
try {
|
||||
if (file.exists()) return file.readText()
|
||||
} catch (_: Exception) {
|
||||
// fall through
|
||||
}
|
||||
try {
|
||||
val result = RootShell.exec("cat '${file.absolutePath.shellEscape()}' 2>/dev/null")
|
||||
if (result.isSuccess && result.output.isNotBlank()) return result.output
|
||||
} catch (_: Exception) {
|
||||
// fall through
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Check if a path is a directory, falling back to root shell [test -d]. */
|
||||
internal suspend fun backupIsDirectory(dir: File): Boolean {
|
||||
if (dir.isDirectory()) return true
|
||||
val result = RootShell.exec("test -d '${dir.absolutePath.shellEscape()}' && echo 1 || echo 0")
|
||||
return result.output.trim() == "1"
|
||||
}
|
||||
|
||||
/** Get file size via root shell [stat] when Java File.length() returns 0 on FUSE. */
|
||||
internal suspend fun backupFileSize(file: File): Long {
|
||||
val javaSize = file.length()
|
||||
if (javaSize > 0L) return javaSize
|
||||
val result = RootShell.exec("stat -c%s '${file.absolutePath.shellEscape()}' 2>/dev/null")
|
||||
return result.output.trim().toLongOrNull() ?: 0L
|
||||
}
|
||||
|
||||
/** Check if a file/directory exists, falling back to root shell [test -e]. */
|
||||
internal suspend fun backupPathExists(file: File): Boolean {
|
||||
if (file.exists()) return true
|
||||
val result = RootShell.exec("test -e '${file.absolutePath.shellEscape()}' && echo 1 || echo 0")
|
||||
return result.output.trim() == "1"
|
||||
}
|
||||
|
||||
/**
|
||||
* List immediate children in a directory, falling back to root shell [ls -1].
|
||||
* Returns relative names only (not full paths).
|
||||
*/
|
||||
internal suspend fun listBackupFiles(dir: File): List<String>? {
|
||||
try {
|
||||
val javaFiles = dir.listFiles()
|
||||
if (javaFiles != null) {
|
||||
val names = javaFiles.map { it.name }
|
||||
if (names.isNotEmpty()) return names
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
// fall through
|
||||
}
|
||||
try {
|
||||
val result = RootShell.exec("ls -1 '${dir.absolutePath.shellEscape()}' 2>/dev/null")
|
||||
if (!result.isSuccess || result.output.isBlank()) return null
|
||||
return result.output.lines().filter { it.isNotBlank() }
|
||||
} catch (_: Exception) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,14 +6,44 @@ import kotlinx.serialization.Serializable
|
||||
* 类型安全的包名包装。
|
||||
*
|
||||
* 使用 [value] 获取原始字符串,用于 Android API 调用和 shell 命令。
|
||||
*
|
||||
* 构造函数验证包名格式符合 Android 命名规范(字母开头、包含至少一个点、
|
||||
* 仅包含字母数字下划线连字符和点),以防止注入攻击和防止 shell 转义绕过。
|
||||
*
|
||||
* 如果包名来源不可信,请使用 [PackageName.safe] 安全创建。
|
||||
*/
|
||||
@JvmInline
|
||||
@Serializable
|
||||
value class PackageName(val value: String) {
|
||||
value class PackageName(
|
||||
val value: String,
|
||||
) {
|
||||
init {
|
||||
require(value.isNotBlank()) { "PackageName must not be blank" }
|
||||
require(PACKAGE_NAME_REGEX.matches(value)) {
|
||||
"Invalid Android package name: '$value' - must start with a letter, " +
|
||||
"contain at least one dot, and only [a-zA-Z0-9_-] characters (dot only as separator)"
|
||||
}
|
||||
}
|
||||
|
||||
override fun toString(): String = value
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Android 包名正则:字母开头、至少一个点、仅允许标准字符。
|
||||
* 此正则与 [restoreSsaid] 中的校验一致。
|
||||
*/
|
||||
private val PACKAGE_NAME_REGEX =
|
||||
Regex(
|
||||
"^[a-zA-Z][a-zA-Z0-9_-]*(\\.[a-zA-Z][a-zA-Z0-9_-]*)+" +
|
||||
"$",
|
||||
)
|
||||
|
||||
/**
|
||||
* 安全创建 [PackageName],如果包名无效则返回 null。
|
||||
* 适用于外部输入(appList.txt、扫描结果等)的防御性校验。
|
||||
*/
|
||||
fun safe(value: String): PackageName? = if (value.isNotBlank() && PACKAGE_NAME_REGEX.matches(value)) PackageName(value) else null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -23,10 +53,13 @@ value class PackageName(val value: String) {
|
||||
*/
|
||||
@JvmInline
|
||||
@Serializable
|
||||
value class UserId(val value: Int) {
|
||||
value class UserId(
|
||||
val value: Int,
|
||||
) {
|
||||
init {
|
||||
require(value >= 0) { "UserId must be non-negative, got $value" }
|
||||
}
|
||||
|
||||
override fun toString(): String = value.toString()
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
package com.example.androidbackupgui.backup
|
||||
|
||||
import android.util.Log
|
||||
import org.bouncycastle.crypto.digests.MD4Digest
|
||||
import java.security.MessageDigest
|
||||
import java.security.MessageDigestSpi
|
||||
import java.security.Provider
|
||||
import java.security.Security
|
||||
|
||||
/**
|
||||
* Ensures MD4 [MessageDigest] is available for jcifs-ng on Android.
|
||||
*
|
||||
* jcifs-ng 2.1.x obtains MD4 by instantiating [BouncyCastleProvider]
|
||||
* and calling [MessageDigest.getInstance]("MD4", bcProvider).
|
||||
* Android's BouncyCastleProvider class is shadowed by the boot classloader
|
||||
* and lacks MD4.
|
||||
*
|
||||
* Strategy: use reflection to replace `jcifs.util.Crypto.provider`
|
||||
* with a delegating provider that wraps Android's BC and adds MD4.
|
||||
* The MD4 [MessageDigestSpi] implementation comes from [MD4Digest]
|
||||
* in bcprov-jdk15to18 (not shadowed — the class is not in boot CL).
|
||||
*/
|
||||
object MD4Provider {
|
||||
|
||||
private const val TAG = "MD4Provider"
|
||||
private val registered = java.util.concurrent.atomic.AtomicBoolean(false)
|
||||
|
||||
private val md4Provider: Provider by lazy {
|
||||
val bc = Security.getProvider("BC")
|
||||
Md4DelegatingProvider(bc)
|
||||
}
|
||||
|
||||
fun register() {
|
||||
if (!registered.compareAndSet(false, true)) return
|
||||
try {
|
||||
// 1. Replace cached provider in every jcifs-ng class that has one
|
||||
setProviderField("jcifs.util.Crypto")
|
||||
for (cn in listOf(
|
||||
"jcifs.smb.NtlmUtil",
|
||||
"jcifs.smb.NtlmPasswordAuthenticator",
|
||||
"jcifs.ntlmssp.Type3Message",
|
||||
"jcifs.smb.NtlmContext"
|
||||
)) setProviderField(cn)
|
||||
|
||||
// 2. Verify by checking what Crypto.getProvider() returns
|
||||
try {
|
||||
val cl = Class.forName("jcifs.util.Crypto")
|
||||
val getProv = cl.getDeclaredMethod("getProvider")
|
||||
getProv.isAccessible = true
|
||||
val actual = getProv.invoke(null) as Provider
|
||||
Log.i(TAG, "Crypto.getProvider() => ${actual::class.java.simpleName} (hasMD4=${actual.getService("MessageDigest", "MD4") != null})")
|
||||
} catch (_: Exception) {}
|
||||
|
||||
// 3. Fallback: register a global MD4 provider too
|
||||
try {
|
||||
Security.insertProviderAt(Md4StandaloneProvider(), 1)
|
||||
} catch (_: Exception) {}
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to inject MD4", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun setProviderField(clsName: String) {
|
||||
try {
|
||||
val cls = Class.forName(clsName)
|
||||
for (f in cls.declaredFields) {
|
||||
if (java.lang.reflect.Modifier.isStatic(f.modifiers) &&
|
||||
Provider::class.java.isAssignableFrom(f.type)) {
|
||||
f.isAccessible = true
|
||||
f.set(null, md4Provider)
|
||||
Log.i(TAG, "Set $clsName.${f.name} = Md4DelegatingProvider")
|
||||
return
|
||||
}
|
||||
}
|
||||
Log.i(TAG, "No static Provider field in $clsName")
|
||||
} catch (_: ClassNotFoundException) {
|
||||
Log.i(TAG, "Class not found: $clsName")
|
||||
}
|
||||
}
|
||||
|
||||
// ── MD4 MessageDigestSpi ────────────────────────────────────
|
||||
|
||||
class Md4DigestSpi : MessageDigestSpi() {
|
||||
private val d = MD4Digest()
|
||||
override fun engineGetDigestLength() = d.digestSize
|
||||
override fun engineUpdate(b: Byte) { d.update(b) }
|
||||
override fun engineUpdate(b: ByteArray, o: Int, l: Int) { d.update(b, o, l) }
|
||||
override fun engineDigest(): ByteArray {
|
||||
val r = ByteArray(d.digestSize); d.doFinal(r, 0); return r
|
||||
}
|
||||
override fun engineReset() { d.reset() }
|
||||
}
|
||||
|
||||
// ── Delegating provider ─────────────────────────────────────
|
||||
|
||||
/** A "BC"-named provider that delegates to [bc] except for MD4. */
|
||||
private class Md4DelegatingProvider(
|
||||
private val bc: Provider?
|
||||
) : Provider("BC", bc?.version ?: 1.0, "BC + MD4") {
|
||||
|
||||
init {
|
||||
// Register MD4 service in the provider's internal service map
|
||||
putService(Service(this, "MessageDigest", "MD4",
|
||||
Md4DigestSpi::class.java.name, null, null))
|
||||
}
|
||||
|
||||
override fun getService(type: String, algorithm: String): Service? {
|
||||
if (type == "MessageDigest" && algorithm.equals("MD4", ignoreCase = true)) {
|
||||
return super.getService(type, algorithm)
|
||||
}
|
||||
return bc?.getService(type, algorithm)
|
||||
}
|
||||
|
||||
override fun getServices(): MutableSet<Service> {
|
||||
val s = (bc?.getServices() ?: emptySet<Service>()).toMutableSet()
|
||||
s.addAll(super.getServices())
|
||||
return s
|
||||
}
|
||||
}
|
||||
|
||||
/** Standalone MD4-only provider registered globally as fallback. */
|
||||
private class Md4StandaloneProvider : Provider("Md4Provider", 1.0, "MD4 only") {
|
||||
override fun getService(type: String, algorithm: String): Service? {
|
||||
if (type == "MessageDigest" && algorithm.equals("MD4", ignoreCase = true)) {
|
||||
return Service(this, type, algorithm, Md4DigestSpi::class.java.name, null, null)
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -55,6 +55,14 @@ object MissingAlgoProvider {
|
||||
} catch (ve: Exception) {
|
||||
Log.w(TAG, "Verification failed after injection", ve)
|
||||
}
|
||||
|
||||
// 3. Fallback: register a global provider that wraps BC + MD4 + AESCMAC
|
||||
try {
|
||||
Security.insertProviderAt(GlobalPatchProvider(), 1)
|
||||
Log.i(TAG, "Registered GlobalPatchProvider at position 1")
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to register global patch provider", e)
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to inject algorithms", e)
|
||||
@@ -135,3 +143,18 @@ object MissingAlgoProvider {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Standalone provider registered globally as fallback so that
|
||||
* [java.security.Security.getProvider]("BC") or any lazy-loaded
|
||||
* BouncyCastleProvider instance can find MD4 and AESCMAC.
|
||||
* Named differently ("MissingAlgoProvider") to avoid conflict with "BC".
|
||||
*/
|
||||
private class GlobalPatchProvider : Provider(
|
||||
"MissingAlgoProvider", 1.0, "MD4 + AESCMAC fallback"
|
||||
) {
|
||||
init {
|
||||
put("MessageDigest.MD4", MissingAlgoProvider.Md4Spi::class.java.name)
|
||||
put("Mac.AESCMAC", MissingAlgoProvider.AesCmacSpi::class.java.name)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
package com.example.androidbackupgui.backup
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKey
|
||||
|
||||
/**
|
||||
* 安全密码管理器。
|
||||
*
|
||||
* 使用 Android EncryptedSharedPreferences + AES256 加密存储敏感凭据,
|
||||
* 包括 restic 仓库密码和远端后端密码。
|
||||
*
|
||||
* 构造后应尽早调用 [init] 完成初始化。
|
||||
*/
|
||||
object PasswordManager {
|
||||
|
||||
private const val PREF_NAME = "secure_credentials"
|
||||
private const val KEY_RESTIC_PASSWORD = "restic_password"
|
||||
private const val KEY_BACKEND_PASSWORD = "backend_password"
|
||||
private const val KEY_BACKEND_PASS = "backend_pass"
|
||||
|
||||
@Volatile
|
||||
private var prefs: SharedPreferences? = null
|
||||
|
||||
/**
|
||||
* 初始化加密存储。需要在应用启动时(Application.onCreate 或
|
||||
* MainActivity.onCreate)尽早调用。
|
||||
*/
|
||||
fun init(context: Context) {
|
||||
if (prefs != null) return
|
||||
synchronized(this) {
|
||||
if (prefs != null) return
|
||||
val masterKey = MasterKey.Builder(context)
|
||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||
.build()
|
||||
prefs = EncryptedSharedPreferences.create(
|
||||
context,
|
||||
PREF_NAME,
|
||||
masterKey,
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Restic 仓库密码 ───────────────────────────────
|
||||
|
||||
/** 获取加密存储的 restic 仓库密码。没有设置时返回 null。 */
|
||||
fun getResticPassword(): String? = prefs?.getString(KEY_RESTIC_PASSWORD, null)
|
||||
|
||||
/** 加密保存 restic 仓库密码。传入 null 可清除。 */
|
||||
fun setResticPassword(password: String?) {
|
||||
if (password == null) {
|
||||
prefs?.edit()?.remove(KEY_RESTIC_PASSWORD)?.apply()
|
||||
} else {
|
||||
prefs?.edit()?.putString(KEY_RESTIC_PASSWORD, password)?.apply()
|
||||
}
|
||||
}
|
||||
|
||||
// ── 远端后端密码 ─────────────────────────────────
|
||||
|
||||
/** 获取加密存储的远端后端密码(WebDAV/SMB)。 */
|
||||
fun getBackendPassword(): String? = prefs?.getString(KEY_BACKEND_PASSWORD, null)
|
||||
|
||||
/** 加密保存远端后端密码。 */
|
||||
fun setBackendPassword(password: String?) {
|
||||
if (password == null) {
|
||||
prefs?.edit()?.remove(KEY_BACKEND_PASSWORD)?.apply()
|
||||
} else {
|
||||
prefs?.edit()?.putString(KEY_BACKEND_PASSWORD, password)?.apply()
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取加密存储的远端后端 passphrase(SMB share)。 */
|
||||
fun getBackendPass(): String? = prefs?.getString(KEY_BACKEND_PASS, null)
|
||||
|
||||
/** 加密保存远端后端 passphrase。 */
|
||||
fun setBackendPass(pass: String?) {
|
||||
if (pass == null) {
|
||||
prefs?.edit()?.remove(KEY_BACKEND_PASS)?.apply()
|
||||
} else {
|
||||
prefs?.edit()?.putString(KEY_BACKEND_PASS, pass)?.apply()
|
||||
}
|
||||
}
|
||||
|
||||
// ── 状态检查 ─────────────────────────────────────
|
||||
|
||||
/** 检查密码管理器是否已初始化。 */
|
||||
fun isInitialized(): Boolean = prefs != null
|
||||
|
||||
/** 检查 restic 密码是否已设置。 */
|
||||
fun hasResticPassword(): Boolean = getResticPassword() != null
|
||||
|
||||
/** 清除所有存储的凭据。 */
|
||||
fun clearAll() {
|
||||
prefs?.edit()?.clear()?.apply()
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,8 @@ interface RemoteTransport {
|
||||
|
||||
suspend fun delete(remotePath: String): AppResult<Unit>
|
||||
suspend fun exists(remotePath: String): AppResult<Boolean>
|
||||
/** Get the size of a remote file in bytes. Returns [AppResult.Failure] if not found. */
|
||||
suspend fun fileSize(remotePath: String): AppResult<Long>
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -68,6 +70,3 @@ interface RemoteTransport {
|
||||
}
|
||||
}
|
||||
|
||||
/** Extension to check if an [AppError] represents a "not found" remote error. */
|
||||
internal fun AppError.isFileNotFound(): Boolean =
|
||||
this is AppError.Remote && this.isNotFound
|
||||
|
||||
@@ -2,14 +2,15 @@ package com.example.androidbackupgui.backup
|
||||
|
||||
import android.util.Log
|
||||
import java.io.File
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Manages [ResticRestBridge] lifecycle: create, start, stop, clean cache.
|
||||
*
|
||||
* Usage:
|
||||
* ```kotlin
|
||||
* bridgeRunner.withBridge(backend, url, user, pass, share, domain, repoPath) { bridgeUrl ->
|
||||
* bridgeRunner.withBridge(backend, url, user, pass, share, domain, repoPath) { bridgeUrl, authToken ->
|
||||
* // RESTIC_REPOSITORY = bridgeUrl
|
||||
* // RESTIC_REST_USERNAME/PASSWORD = authToken (set via buildBridgeEnv)
|
||||
* restic commands go here
|
||||
* }
|
||||
* // bridge stopped + cache cleaned automatically
|
||||
@@ -47,25 +48,26 @@ class RestBridgeRunner {
|
||||
share: String,
|
||||
domain: String
|
||||
) -> RemoteTransport? = ::createTransport,
|
||||
block: suspend (bridgeUrl: String) -> T
|
||||
block: suspend (bridgeUrl: String, authToken: String) -> T
|
||||
): T {
|
||||
if (backend == "local") {
|
||||
return block(repoPath)
|
||||
return block(repoPath, "")
|
||||
}
|
||||
|
||||
// Reuse cached transport (same SMB session) for consistent cross-bridge visibility
|
||||
val key = "$backend|$backendUrl|$backendUser|$backendShare|$backendDomain"
|
||||
val authToken = UUID.randomUUID().toString().replace("-", "").take(32)
|
||||
|
||||
val key = "$backend|$backendUrl|$backendUser|$backendPass|$backendShare|$backendDomain"
|
||||
if (cachedTransportKey != key) {
|
||||
cachedTransport?.let { Log.d(TAG, "discarding stale cached transport") }
|
||||
val t = transportFactory(backend, backendUrl, backendUser, backendPass, backendShare, backendDomain)
|
||||
?: return block(repoPath)
|
||||
?: return block(repoPath, "")
|
||||
cachedTransport = t
|
||||
cachedTransportKey = key
|
||||
}
|
||||
val transport = cachedTransport!!
|
||||
|
||||
val remoteBase = buildRemoteBase(backend, backendUrl, backendShare, repoPath)
|
||||
val bridge = ResticRestBridge(transport, remoteBase, cacheDir)
|
||||
val bridge = ResticRestBridge(transport, remoteBase, repoPath, cacheDir, authToken)
|
||||
|
||||
try {
|
||||
bridge.start(0)
|
||||
@@ -74,14 +76,13 @@ class RestBridgeRunner {
|
||||
throw IllegalStateException("REST bridge failed to bind a port")
|
||||
}
|
||||
val bridgeUrl = "rest:http://127.0.0.1:$port/$repoPath"
|
||||
Log.i(TAG, "REST bridge started on port $port for $remoteBase")
|
||||
return block(bridgeUrl)
|
||||
Log.i(TAG, "REST bridge started on port $port for $remoteBase (auth=${authToken.take(8)}…)")
|
||||
return block(bridgeUrl, authToken)
|
||||
} finally {
|
||||
try {
|
||||
bridge.stop()
|
||||
} catch (_: Exception) {}
|
||||
Log.d(TAG, "REST bridge stopped")
|
||||
// Clean up any leftover blob temp files
|
||||
val blobs = cacheDir.listFiles { f -> f.name.startsWith("restic_blob_") }
|
||||
if (blobs != null) {
|
||||
for (f in blobs) f.delete()
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
package com.example.androidbackupgui.backup
|
||||
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlin.coroutines.coroutineContext
|
||||
import com.example.androidbackupgui.backup.AppError
|
||||
import com.example.androidbackupgui.backup.AppResult
|
||||
import com.example.androidbackupgui.backup.err
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
|
||||
import kotlin.coroutines.coroutineContext
|
||||
|
||||
/**
|
||||
* Backup operations: running restic backup and parsing its summary output.
|
||||
*
|
||||
* Delegates execution to [ResticCommandRunner], [ResticEnvResolver], and
|
||||
* [RestBridgeRunner] which are shared across sub-modules.
|
||||
* 使用 [BackendExecutor] 统一处理 local/remote 后端。
|
||||
*/
|
||||
class ResticBackup(
|
||||
private val runner: ResticCommandRunner,
|
||||
private val envResolver: ResticEnvResolver,
|
||||
private val bridgeRunner: RestBridgeRunner
|
||||
private val bridgeRunner: RestBridgeRunner,
|
||||
private val executor: BackendExecutor = BackendExecutor(),
|
||||
) {
|
||||
private val TAG = "ResticBackup"
|
||||
var cacheDir: String = ""
|
||||
@@ -39,105 +39,53 @@ class ResticBackup(
|
||||
backendUser: String = "",
|
||||
backendPass: String = "",
|
||||
backendShare: String = "",
|
||||
onProgress: suspend (ResticWrapper.ResticProgress) -> Unit = {}
|
||||
): AppResult<ResticWrapper.BackupSummary> = withContext(Dispatchers.IO) {
|
||||
val emit: suspend (ResticWrapper.ResticProgress) -> Unit = { p -> withContext(Dispatchers.Main) { onProgress(p) } }
|
||||
onProgress: suspend (ResticWrapper.ResticProgress) -> Unit = {},
|
||||
): AppResult<ResticWrapper.BackupSummary> =
|
||||
withContext(Dispatchers.IO) {
|
||||
val emit: suspend (ResticWrapper.ResticProgress) -> Unit = { p -> withContext(Dispatchers.Main) { onProgress(p) } }
|
||||
|
||||
if (backend == "local") {
|
||||
val args = mutableListOf("backup", "--json")
|
||||
for (path in paths) args.add(path)
|
||||
for (tag in tags) { args.add("--tag"); args.add(tag) }
|
||||
if (hostname != null) { args.add("--host"); args.add(hostname) }
|
||||
|
||||
val env = envResolver.buildLocalEnv(repoPath, password, cacheDir)
|
||||
val result = runner.runResticStreaming(env, args) { line ->
|
||||
if (!coroutineContext.isActive) return@runResticStreaming
|
||||
try {
|
||||
val progress = resticJson.decodeFromString<ResticWrapper.ResticProgress>(line)
|
||||
if (progress.messageType == "status") emit(progress)
|
||||
} catch (_: Exception) { }
|
||||
for (tag in tags) {
|
||||
args.add("--tag")
|
||||
args.add(tag)
|
||||
}
|
||||
if (hostname != null) {
|
||||
args.add("--host")
|
||||
args.add(hostname)
|
||||
}
|
||||
|
||||
if (result.exitCode != 0) return@withContext err(AppError.Restic("restic backup 失败", result.exitCode, result.stderr))
|
||||
parseBackupSummary(result.stdout)
|
||||
} else {
|
||||
bridgeRunner.withBridge(backend, backendUrl, backendUser, backendPass, backendShare, backendDomain, repoPath, File(cacheDir)) { bridgeUrl ->
|
||||
val args = mutableListOf("backup", "--json")
|
||||
for (path in paths) args.add(path)
|
||||
for (tag in tags) { args.add("--tag"); args.add(tag) }
|
||||
if (hostname != null) { args.add("--host"); args.add(hostname) }
|
||||
|
||||
val env = envResolver.buildBridgeEnv(password, bridgeUrl, cacheDir)
|
||||
val result = runner.runResticStreaming(env, args) { line ->
|
||||
if (!coroutineContext.isActive) return@runResticStreaming
|
||||
try {
|
||||
val progress = resticJson.decodeFromString<ResticWrapper.ResticProgress>(line)
|
||||
if (progress.messageType == "status") emit(progress)
|
||||
} catch (_: Exception) { }
|
||||
val result =
|
||||
executor.withBackend(
|
||||
repoPath = repoPath,
|
||||
password = password,
|
||||
cacheDir = cacheDir,
|
||||
backend = backend,
|
||||
backendUrl = backendUrl,
|
||||
backendUser = backendUser,
|
||||
backendPass = backendPass,
|
||||
backendShare = backendShare,
|
||||
backendDomain = backendDomain,
|
||||
runner = runner,
|
||||
envResolver = envResolver,
|
||||
bridgeRunner = bridgeRunner,
|
||||
) { env ->
|
||||
runner.runResticStreaming(env, args) { line ->
|
||||
if (!coroutineContext.isActive) return@runResticStreaming
|
||||
try {
|
||||
val progress = resticJson.decodeFromString<ResticWrapper.ResticProgress>(line)
|
||||
if (progress.messageType == "status") emit(progress)
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (result.exitCode != 0) return@withBridge err(AppError.Restic("restic backup 失败", result.exitCode, result.stderr))
|
||||
parseBackupSummary(result.stdout)
|
||||
if (result.exitCode != 0) {
|
||||
return@withContext err(AppError.Restic("restic backup 失败", result.exitCode, result.stderr))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Streaming backup (stdin) ──────────────────────
|
||||
|
||||
/**
|
||||
* Run restic backup in --stdin mode, reading tar data from [stdinFile] (FIFO).
|
||||
* [extraPaths] are files/directories backed up alongside the streaming data
|
||||
* (e.g. APK paths, metadata directory).
|
||||
*/
|
||||
suspend fun backupStdin(
|
||||
repoPath: String,
|
||||
password: String,
|
||||
stdinFile: File,
|
||||
extraPaths: List<String>,
|
||||
tags: List<String> = emptyList(),
|
||||
hostname: String? = null,
|
||||
backend: String = "local",
|
||||
backendUrl: String = "",
|
||||
backendUser: String = "",
|
||||
backendPass: String = "",
|
||||
backendShare: String = "",
|
||||
onProgress: suspend (ResticWrapper.ResticProgress) -> Unit = {}
|
||||
): AppResult<ResticWrapper.BackupSummary> = withContext(Dispatchers.IO) {
|
||||
val emit: suspend (ResticWrapper.ResticProgress) -> Unit = { p -> withContext(Dispatchers.Main) { onProgress(p) } }
|
||||
|
||||
val args = mutableListOf("backup", "--json", "--stdin", "--stdin-filename", "app_data.tar")
|
||||
for (path in extraPaths) args.add(path)
|
||||
for (tag in tags) { args.add("--tag"); args.add(tag) }
|
||||
if (hostname != null) { args.add("--host"); args.add(hostname) }
|
||||
|
||||
if (backend == "local") {
|
||||
val env = envResolver.buildLocalEnv(repoPath, password, cacheDir)
|
||||
val result = runner.runResticWithStdin(env, args, stdinFile) { line ->
|
||||
if (!coroutineContext.isActive) return@runResticWithStdin
|
||||
try {
|
||||
val progress = resticJson.decodeFromString<ResticWrapper.ResticProgress>(line)
|
||||
if (progress.messageType == "status") emit(progress)
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
|
||||
if (result.exitCode != 0) return@withContext err(AppError.Restic("restic stream backup 失败", result.exitCode, result.stderr))
|
||||
parseBackupSummary(result.stdout)
|
||||
} else {
|
||||
bridgeRunner.withBridge(backend, backendUrl, backendUser, backendPass, backendShare, backendDomain, repoPath, File(cacheDir)) { bridgeUrl ->
|
||||
val env = envResolver.buildBridgeEnv(password, bridgeUrl, cacheDir)
|
||||
val result = runner.runResticWithStdin(env, args, stdinFile) { line ->
|
||||
if (!coroutineContext.isActive) return@runResticWithStdin
|
||||
try {
|
||||
val progress = resticJson.decodeFromString<ResticWrapper.ResticProgress>(line)
|
||||
if (progress.messageType == "status") emit(progress)
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
|
||||
if (result.exitCode != 0) return@withBridge err(AppError.Restic("restic stream backup 失败", result.exitCode, result.stderr))
|
||||
parseBackupSummary(result.stdout)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Internal helpers ───────────────────────────────
|
||||
|
||||
@@ -150,7 +98,9 @@ class ResticBackup(
|
||||
try {
|
||||
val summary = resticJson.decodeFromString<ResticWrapper.BackupSummary>(line)
|
||||
if (summary.messageType == "summary" && summary.snapshotId.isNotEmpty()) return AppResult.Success(summary)
|
||||
} catch (_: Exception) { /* keep looking */ }
|
||||
} catch (_: Exception) {
|
||||
// keep looking
|
||||
}
|
||||
}
|
||||
return err(AppError.Parse("restic 备份输出未找到摘要信息", "stdout=" + stdout.length))
|
||||
}
|
||||
|
||||
@@ -36,6 +36,22 @@ class ResticCommandRunner {
|
||||
Log.d(TAG, "buildCommandArgs: binaryPath=$binaryPath args=$args -> cmd=$cmd")
|
||||
}
|
||||
|
||||
/** Wait for process to exit with a polling loop (compatible with API 24+). */
|
||||
private fun Process.waitForCompat(deadlineMs: Long = 60_000): Int {
|
||||
val deadline = System.currentTimeMillis() + deadlineMs
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
try {
|
||||
return exitValue()
|
||||
} catch (_: IllegalThreadStateException) {
|
||||
Thread.sleep(100)
|
||||
}
|
||||
}
|
||||
Log.w(TAG, "process did not exit within ${deadlineMs}ms, destroying")
|
||||
destroy()
|
||||
waitFor()
|
||||
return exitValue()
|
||||
}
|
||||
|
||||
/** Run restic (non-streaming). */
|
||||
fun runRestic(env: Map<String, String>, args: List<String>): CommandResult {
|
||||
val cmdArgs = buildCommandArgs(args)
|
||||
@@ -50,28 +66,23 @@ class ResticCommandRunner {
|
||||
pb.redirectErrorStream(false)
|
||||
val process = pb.start()
|
||||
|
||||
// Drain stderr on a separate daemon thread to avoid a pipe deadlock:
|
||||
// if stderr's buffer fills while we're still reading stdout, the child
|
||||
// process blocks on writing stderr and we block on reading stdout.
|
||||
var stderrBytes = byteArrayOf()
|
||||
val stderrThread = Thread {
|
||||
try {
|
||||
stderrBytes = process.errorStream.use { it.readAllBytesCompat() }
|
||||
} catch (_: Exception) {
|
||||
// stream closed early; leave stderrBytes empty
|
||||
}
|
||||
}.apply { isDaemon = true; start() }
|
||||
|
||||
val stdout = process.inputStream.bufferedReader().use(BufferedReader::readText)
|
||||
val stderrBytes = process.errorStream.use { it.readAllBytesCompat() }
|
||||
val exitCode = try {
|
||||
val deadline = System.currentTimeMillis() + 60_000
|
||||
var exited = false
|
||||
while (System.currentTimeMillis() < deadline && !exited) {
|
||||
try {
|
||||
process.exitValue()
|
||||
exited = true
|
||||
} catch (_: IllegalThreadStateException) {
|
||||
Thread.sleep(100)
|
||||
}
|
||||
}
|
||||
if (exited) {
|
||||
process.exitValue()
|
||||
} else {
|
||||
Log.w(TAG, "runRestic: process did not exit within 60s, destroying")
|
||||
process.destroy()
|
||||
process.waitFor()
|
||||
process.exitValue()
|
||||
}
|
||||
process.waitForCompat()
|
||||
} catch (_: Exception) { -1 }
|
||||
try { stderrThread.join(1_000) } catch (_: InterruptedException) {}
|
||||
val stderrText = stderrBytes.decodeToString()
|
||||
Log.i(TAG, "runRestic exitCode=$exitCode stdout_len=${stdout.length}")
|
||||
if (stderrText.isNotEmpty()) Log.w(TAG, "runRestic stderr: ${stderrText.trim()}")
|
||||
@@ -107,44 +118,39 @@ class ResticCommandRunner {
|
||||
pb.redirectErrorStream(false)
|
||||
process = pb.start()
|
||||
|
||||
// Drain stderr on a separate daemon thread to avoid a pipe deadlock:
|
||||
// if stderr's buffer fills while we're still reading stdout, the child
|
||||
// process blocks on writing stderr and we block on reading stdout.
|
||||
var stderrBytes = byteArrayOf()
|
||||
val stderrThread = Thread {
|
||||
try {
|
||||
stderrBytes = process.errorStream.use { it.readAllBytesCompat() }
|
||||
} catch (_: Exception) {
|
||||
// stream closed early; leave stderrBytes empty
|
||||
}
|
||||
}.apply { isDaemon = true; start() }
|
||||
|
||||
val stdoutText = StringBuilder()
|
||||
val reader = process.inputStream.bufferedReader()
|
||||
|
||||
try {
|
||||
var line: String
|
||||
while (reader.readLine().also { line = it } != null) {
|
||||
var line = reader.readLine()
|
||||
while (line != null) {
|
||||
if (!coroutineContext.isActive) {
|
||||
process.destroy()
|
||||
break
|
||||
}
|
||||
stdoutText.appendLine(line)
|
||||
onLine(line)
|
||||
line = reader.readLine()
|
||||
}
|
||||
} finally {
|
||||
try { reader.close() } catch (_: Exception) {}
|
||||
}
|
||||
val stderrBytes = try { process.errorStream.use { it.readAllBytesCompat() } } catch (_: Exception) { byteArrayOf() }
|
||||
try { stderrThread.join(1_000) } catch (_: InterruptedException) {}
|
||||
val stderrText = stderrBytes.decodeToString().trim()
|
||||
val exitCode = try {
|
||||
// Manual timeout loop (Process.waitFor(timeout,unit) requires API 26+)
|
||||
val deadline = System.currentTimeMillis() + 60_000
|
||||
var exited = false
|
||||
while (System.currentTimeMillis() < deadline && !exited) {
|
||||
try {
|
||||
process.exitValue()
|
||||
exited = true
|
||||
} catch (_: IllegalThreadStateException) {
|
||||
Thread.sleep(100)
|
||||
}
|
||||
}
|
||||
if (exited) {
|
||||
process.exitValue()
|
||||
} else {
|
||||
Log.w(TAG, "runResticStreaming: process did not exit within 60s after stdout EOF, destroying")
|
||||
process.destroy()
|
||||
process.waitFor()
|
||||
process.exitValue()
|
||||
}
|
||||
process.waitForCompat()
|
||||
} catch (_: Exception) { -1 }
|
||||
|
||||
Log.i(TAG, "runResticStreaming exitCode=$exitCode stdout_len=${stdoutText.length}")
|
||||
@@ -159,96 +165,13 @@ class ResticCommandRunner {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run restic with stdin redirected from [stdinFile] (FIFO or regular file).
|
||||
* Calls [onLine] for each stdout line (for streaming progress).
|
||||
*/
|
||||
suspend fun runResticWithStdin(
|
||||
env: Map<String, String>,
|
||||
args: List<String>,
|
||||
stdinFile: File,
|
||||
onLine: suspend (String) -> Unit
|
||||
): CommandResult = withContext(Dispatchers.IO) {
|
||||
val cmdArgs = buildCommandArgs(args)
|
||||
Log.i(TAG, "runResticWithStdin cmd=${cmdArgs.joinToString(" ")} stdin=${stdinFile.absolutePath}")
|
||||
Log.d(TAG, "runResticWithStdin REPOSITORY=${env["RESTIC_REPOSITORY"]}")
|
||||
env["TMPDIR"]?.let { File(it).mkdirs() }
|
||||
|
||||
var process: Process? = null
|
||||
try {
|
||||
|
||||
val pb = ProcessBuilder(cmdArgs)
|
||||
pb.environment().putAll(env)
|
||||
pb.redirectErrorStream(false)
|
||||
process = pb.start()
|
||||
|
||||
// Pipe stdin from file to process on a daemon thread (API 24 compat)
|
||||
Thread {
|
||||
try {
|
||||
val fis = java.io.FileInputStream(stdinFile)
|
||||
val pos = process!!.outputStream
|
||||
fis.use { input -> pos.use { output -> input.copyTo(output) } }
|
||||
} catch (_: Exception) {
|
||||
// FIFO writer closed; stdin pipe ends naturally
|
||||
}
|
||||
}.apply { isDaemon = true; start() }
|
||||
val stdoutText = StringBuilder()
|
||||
val reader = process.inputStream.bufferedReader()
|
||||
|
||||
try {
|
||||
var line: String
|
||||
while (reader.readLine().also { line = it } != null) {
|
||||
if (!coroutineContext.isActive) {
|
||||
process.destroy()
|
||||
break
|
||||
}
|
||||
stdoutText.appendLine(line)
|
||||
onLine(line)
|
||||
}
|
||||
} finally {
|
||||
try { reader.close() } catch (_: Exception) {}
|
||||
}
|
||||
val stderrBytes = try { process.errorStream.use { it.readAllBytesCompat() } } catch (_: Exception) { byteArrayOf() }
|
||||
val stderrText = stderrBytes.decodeToString().trim()
|
||||
val exitCode = try {
|
||||
val deadline = System.currentTimeMillis() + 60_000
|
||||
var exited = false
|
||||
while (System.currentTimeMillis() < deadline && !exited) {
|
||||
try {
|
||||
process.exitValue()
|
||||
exited = true
|
||||
} catch (_: IllegalThreadStateException) {
|
||||
Thread.sleep(100)
|
||||
}
|
||||
}
|
||||
if (exited) {
|
||||
process.exitValue()
|
||||
} else {
|
||||
Log.w(TAG, "runResticWithStdin: process did not exit within 60s, destroying")
|
||||
process.destroy()
|
||||
process.waitFor()
|
||||
process.exitValue()
|
||||
}
|
||||
} catch (_: Exception) { -1 }
|
||||
|
||||
Log.i(TAG, "runResticWithStdin exitCode=$exitCode stdout_len=${stdoutText.length}")
|
||||
if (stderrText.isNotEmpty()) Log.w(TAG, "runResticWithStdin stderr: ${stderrText}")
|
||||
CommandResult(stdoutText.toString().trim(), stderrText.trim(), exitCode)
|
||||
} catch (e: kotlinx.coroutines.CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "runResticWithStdin exception", e)
|
||||
try { process?.destroy() } catch (_: Exception) {}
|
||||
CommandResult("", e.message ?: "Unknown error", -1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compat implementation of InputStream.readAllBytes() for API < 33.
|
||||
* Reads the entire stream into a byte array.
|
||||
*/
|
||||
private fun InputStream.readAllBytesCompat(): ByteArray {
|
||||
internal fun InputStream.readAllBytesCompat(): ByteArray {
|
||||
val buffer = ByteArrayOutputStream()
|
||||
val data = ByteArray(4096)
|
||||
while (true) {
|
||||
|
||||
@@ -4,17 +4,21 @@ package com.example.androidbackupgui.backup
|
||||
* Stateless helper for constructing restic environment variables and repo URLs.
|
||||
*/
|
||||
class ResticEnvResolver {
|
||||
|
||||
|
||||
/** Build environment for non-local backends using the REST bridge URL. */
|
||||
fun buildBridgeEnv(
|
||||
password: String,
|
||||
bridgeUrl: String,
|
||||
cacheDir: String
|
||||
cacheDir: String,
|
||||
authToken: String = "",
|
||||
): Map<String, String> {
|
||||
val env = HashMap(System.getenv() ?: emptyMap())
|
||||
// 从空白环境开始,不继承系统环境变量(防止敏感信息泄露到子进程)
|
||||
val env = HashMap<String, String>()
|
||||
env["RESTIC_REPOSITORY"] = bridgeUrl
|
||||
env["RESTIC_PASSWORD"] = password
|
||||
if (authToken.isNotEmpty()) {
|
||||
env["RESTIC_REST_USERNAME"] = authToken
|
||||
env["RESTIC_REST_PASSWORD"] = authToken
|
||||
}
|
||||
if (cacheDir.isNotEmpty()) {
|
||||
env["HOME"] = cacheDir
|
||||
env["XDG_CACHE_HOME"] = cacheDir
|
||||
@@ -28,9 +32,10 @@ class ResticEnvResolver {
|
||||
fun buildLocalEnv(
|
||||
repoPath: String,
|
||||
password: String,
|
||||
cacheDir: String
|
||||
cacheDir: String,
|
||||
): Map<String, String> {
|
||||
val env = HashMap(System.getenv() ?: emptyMap())
|
||||
// 从空白环境开始,不继承系统环境变量
|
||||
val env = HashMap<String, String>()
|
||||
env["RESTIC_REPOSITORY"] = repoPath
|
||||
env["RESTIC_PASSWORD"] = password
|
||||
if (cacheDir.isNotEmpty()) {
|
||||
@@ -43,13 +48,16 @@ class ResticEnvResolver {
|
||||
}
|
||||
|
||||
/** Build a display-friendly repository URL for UI. */
|
||||
fun buildRepoUrl(backend: String, repoPath: String, backendUrl: String): String {
|
||||
return when (backend) {
|
||||
fun buildRepoUrl(
|
||||
backend: String,
|
||||
repoPath: String,
|
||||
backendUrl: String,
|
||||
): String =
|
||||
when (backend) {
|
||||
"local" -> repoPath
|
||||
"rest-server" -> "rest:${backendUrl.trimEnd('/')}/$repoPath"
|
||||
"webdav" -> "${backendUrl.trimEnd('/')}/$repoPath"
|
||||
"smb" -> "smb:${backendUrl.trimEnd('/')}/$repoPath"
|
||||
else -> repoPath
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,18 @@
|
||||
package com.example.androidbackupgui.backup
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import com.example.androidbackupgui.backup.AppError
|
||||
import com.example.androidbackupgui.backup.AppResult
|
||||
import com.example.androidbackupgui.backup.err
|
||||
import java.io.File
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* Repository maintenance operations: prune, check, stats.
|
||||
* Repository maintenance operations: prune, unlock, check, stats.
|
||||
*
|
||||
* [prune] requires both download and upload (it removes pack files from the remote).
|
||||
* [check] and [stats] are download-only read operations.
|
||||
*
|
||||
* For remote backends, uses [RestBridgeRunner] to serve the backend via REST,
|
||||
* so restic always sees a local rest-server repository. For local backends,
|
||||
* operates directly on the repo path.
|
||||
* 使用 [BackendExecutor] 统一处理 local/remote 后端。
|
||||
*
|
||||
* Delegates execution to [ResticCommandRunner], [ResticEnvResolver], and
|
||||
* [RestBridgeRunner] which are shared across sub-modules.
|
||||
@@ -23,7 +20,8 @@ import java.io.File
|
||||
class ResticMaintenance(
|
||||
private val runner: ResticCommandRunner,
|
||||
private val envResolver: ResticEnvResolver,
|
||||
private val bridgeRunner: RestBridgeRunner
|
||||
private val bridgeRunner: RestBridgeRunner,
|
||||
private val executor: BackendExecutor = BackendExecutor(),
|
||||
) {
|
||||
/** Cache directory for restic env and bridge temp files. Set by [ResticWrapper]. */
|
||||
var cacheDir: String = ""
|
||||
@@ -31,7 +29,41 @@ class ResticMaintenance(
|
||||
/** SMB NTLM domain for remote backend. Set by [ResticWrapper]. */
|
||||
var backendDomain: String = ""
|
||||
|
||||
// ── Prune ──────────────────────────────────────────
|
||||
/** Run a one-shot restic command and map the result. */
|
||||
private suspend fun runCommand(
|
||||
command: String,
|
||||
failMessage: String,
|
||||
repoPath: String,
|
||||
password: String,
|
||||
backend: String,
|
||||
backendUrl: String,
|
||||
backendUser: String,
|
||||
backendPass: String,
|
||||
backendShare: String,
|
||||
): AppResult<String> =
|
||||
withContext(Dispatchers.IO) {
|
||||
val result =
|
||||
executor.runResticWithBackend(
|
||||
args = listOf(command),
|
||||
repoPath = repoPath,
|
||||
password = password,
|
||||
cacheDir = cacheDir,
|
||||
backend = backend,
|
||||
backendUrl = backendUrl,
|
||||
backendUser = backendUser,
|
||||
backendPass = backendPass,
|
||||
backendShare = backendShare,
|
||||
backendDomain = backendDomain,
|
||||
runner = runner,
|
||||
envResolver = envResolver,
|
||||
bridgeRunner = bridgeRunner,
|
||||
)
|
||||
if (result.exitCode == 0) {
|
||||
AppResult.Success(result.stdout)
|
||||
} else {
|
||||
err(AppError.Restic(failMessage, result.exitCode, result.stderr))
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun prune(
|
||||
repoPath: String,
|
||||
@@ -42,26 +74,38 @@ class ResticMaintenance(
|
||||
backendPass: String = "",
|
||||
backendShare: String = "",
|
||||
): AppResult<String> =
|
||||
withContext(Dispatchers.IO) {
|
||||
if (backend == "local") {
|
||||
val env = envResolver.buildLocalEnv(repoPath, password, cacheDir)
|
||||
val result = runner.runRestic(env, "prune")
|
||||
if (result.exitCode == 0) AppResult.Success(result.stdout)
|
||||
else err(AppError.Restic("restic prune 失败", result.exitCode, result.stderr))
|
||||
} else {
|
||||
bridgeRunner.withBridge(
|
||||
backend, backendUrl, backendUser, backendPass, backendShare,
|
||||
backendDomain, repoPath, File(cacheDir)
|
||||
) { bridgeUrl ->
|
||||
val env = envResolver.buildBridgeEnv(password, bridgeUrl, cacheDir)
|
||||
val result = runner.runRestic(env, "prune")
|
||||
if (result.exitCode == 0) AppResult.Success(result.stdout)
|
||||
else err(AppError.Restic("restic prune 失败", result.exitCode, result.stderr))
|
||||
}
|
||||
}
|
||||
}
|
||||
runCommand(
|
||||
"prune",
|
||||
"restic prune 失败",
|
||||
repoPath,
|
||||
password,
|
||||
backend,
|
||||
backendUrl,
|
||||
backendUser,
|
||||
backendPass,
|
||||
backendShare,
|
||||
)
|
||||
|
||||
// ── Check ──────────────────────────────────────────
|
||||
suspend fun unlock(
|
||||
repoPath: String,
|
||||
password: String,
|
||||
backend: String = "local",
|
||||
backendUrl: String = "",
|
||||
backendUser: String = "",
|
||||
backendPass: String = "",
|
||||
backendShare: String = "",
|
||||
): AppResult<String> =
|
||||
runCommand(
|
||||
"unlock",
|
||||
"restic unlock 失败",
|
||||
repoPath,
|
||||
password,
|
||||
backend,
|
||||
backendUrl,
|
||||
backendUser,
|
||||
backendPass,
|
||||
backendShare,
|
||||
)
|
||||
|
||||
suspend fun check(
|
||||
repoPath: String,
|
||||
@@ -72,26 +116,17 @@ class ResticMaintenance(
|
||||
backendPass: String = "",
|
||||
backendShare: String = "",
|
||||
): AppResult<String> =
|
||||
withContext(Dispatchers.IO) {
|
||||
if (backend == "local") {
|
||||
val env = envResolver.buildLocalEnv(repoPath, password, cacheDir)
|
||||
val result = runner.runRestic(env, "check")
|
||||
if (result.exitCode == 0) AppResult.Success(result.stdout)
|
||||
else err(AppError.Restic("restic check 失败", result.exitCode, result.stderr))
|
||||
} else {
|
||||
bridgeRunner.withBridge(
|
||||
backend, backendUrl, backendUser, backendPass, backendShare,
|
||||
backendDomain, repoPath, File(cacheDir)
|
||||
) { bridgeUrl ->
|
||||
val env = envResolver.buildBridgeEnv(password, bridgeUrl, cacheDir)
|
||||
val result = runner.runRestic(env, "check")
|
||||
if (result.exitCode == 0) AppResult.Success(result.stdout)
|
||||
else err(AppError.Restic("restic check 失败", result.exitCode, result.stderr))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Stats ──────────────────────────────────────────
|
||||
runCommand(
|
||||
"check",
|
||||
"restic check 失败",
|
||||
repoPath,
|
||||
password,
|
||||
backend,
|
||||
backendUrl,
|
||||
backendUser,
|
||||
backendPass,
|
||||
backendShare,
|
||||
)
|
||||
|
||||
suspend fun stats(
|
||||
repoPath: String,
|
||||
@@ -102,22 +137,15 @@ class ResticMaintenance(
|
||||
backendPass: String = "",
|
||||
backendShare: String = "",
|
||||
): AppResult<String> =
|
||||
withContext(Dispatchers.IO) {
|
||||
if (backend == "local") {
|
||||
val env = envResolver.buildLocalEnv(repoPath, password, cacheDir)
|
||||
val result = runner.runRestic(env, "stats")
|
||||
if (result.exitCode == 0) AppResult.Success(result.stdout)
|
||||
else err(AppError.Restic("restic stats 失败", result.exitCode, result.stderr))
|
||||
} else {
|
||||
bridgeRunner.withBridge(
|
||||
backend, backendUrl, backendUser, backendPass, backendShare,
|
||||
backendDomain, repoPath, File(cacheDir)
|
||||
) { bridgeUrl ->
|
||||
val env = envResolver.buildBridgeEnv(password, bridgeUrl, cacheDir)
|
||||
val result = runner.runRestic(env, "stats")
|
||||
if (result.exitCode == 0) AppResult.Success(result.stdout)
|
||||
else err(AppError.Restic("restic stats 失败", result.exitCode, result.stderr))
|
||||
}
|
||||
}
|
||||
}
|
||||
runCommand(
|
||||
"stats",
|
||||
"restic stats 失败",
|
||||
repoPath,
|
||||
password,
|
||||
backend,
|
||||
backendUrl,
|
||||
backendUser,
|
||||
backendPass,
|
||||
backendShare,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
package com.example.androidbackupgui.backup
|
||||
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import com.example.androidbackupgui.backup.AppError
|
||||
import com.example.androidbackupgui.backup.AppResult
|
||||
import com.example.androidbackupgui.backup.err
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
@@ -21,12 +21,14 @@ import java.io.File
|
||||
class ResticRepoInit(
|
||||
private val runner: ResticCommandRunner,
|
||||
private val envResolver: ResticEnvResolver,
|
||||
private val bridgeRunner: RestBridgeRunner
|
||||
private val bridgeRunner: RestBridgeRunner,
|
||||
private val executor: BackendExecutor = BackendExecutor(),
|
||||
) {
|
||||
private val TAG = "ResticWrapper"
|
||||
|
||||
/** Cache directory for restic env and bridge temp files. Set by ResticWrapper. */
|
||||
var cacheDir: String = ""
|
||||
|
||||
/** NTLM domain for SMB authentication. Set by ResticWrapper. */
|
||||
var backendDomain: String = ""
|
||||
|
||||
@@ -42,18 +44,20 @@ class ResticRepoInit(
|
||||
backendShare: String = "",
|
||||
): AppResult<Unit> =
|
||||
withContext(Dispatchers.IO) {
|
||||
if (backend == "local") {
|
||||
val env = envResolver.buildLocalEnv(repoPath, password, cacheDir)
|
||||
runInit(env)
|
||||
} else {
|
||||
bridgeRunner.withBridge(
|
||||
backend, backendUrl, backendUser, backendPass, backendShare,
|
||||
backendDomain, repoPath, File(cacheDir)
|
||||
) { bridgeUrl ->
|
||||
val env = envResolver.buildBridgeEnv(password, bridgeUrl, cacheDir)
|
||||
runInit(env)
|
||||
}
|
||||
}
|
||||
executor.withBackend(
|
||||
repoPath = repoPath,
|
||||
password = password,
|
||||
cacheDir = cacheDir,
|
||||
backend = backend,
|
||||
backendUrl = backendUrl,
|
||||
backendUser = backendUser,
|
||||
backendPass = backendPass,
|
||||
backendShare = backendShare,
|
||||
backendDomain = backendDomain,
|
||||
runner = runner,
|
||||
envResolver = envResolver,
|
||||
bridgeRunner = bridgeRunner,
|
||||
) { env -> runInit(env) }
|
||||
}
|
||||
|
||||
/** Shared init logic: run restic init, verify on exitCode 1. */
|
||||
@@ -88,7 +92,7 @@ class ResticRepoInit(
|
||||
// Config exists but verification failed — diagnose the cause
|
||||
val detail = diagnoseInitFailure(verify.stderr)
|
||||
return err(
|
||||
AppError.Restic("仓库已存在但无法验证: $detail", verify.exitCode, verify.stderr)
|
||||
AppError.Restic("仓库已存在但无法验证: $detail", verify.exitCode, verify.stderr),
|
||||
)
|
||||
}
|
||||
return err(AppError.Restic("restic init 失败", result.exitCode, result.stderr))
|
||||
@@ -98,15 +102,15 @@ class ResticRepoInit(
|
||||
private fun isConfigExistsError(stderr: String): Boolean {
|
||||
val lower = stderr.lowercase()
|
||||
return lower.contains("already exists") ||
|
||||
lower.contains("config file already exists")
|
||||
lower.contains("config file already exists")
|
||||
}
|
||||
|
||||
/** Check if stderr indicates a stale repository lock. */
|
||||
private fun isLockError(stderr: String): Boolean {
|
||||
val lower = stderr.lowercase()
|
||||
return lower.contains("lock") ||
|
||||
lower.contains("unable to create") ||
|
||||
lower.contains("already locked")
|
||||
lower.contains("unable to create") ||
|
||||
lower.contains("already locked")
|
||||
}
|
||||
|
||||
/** Parse restic stderr to produce a user-facing diagnosis string. */
|
||||
@@ -114,25 +118,38 @@ class ResticRepoInit(
|
||||
val lower = stderr.lowercase()
|
||||
return when {
|
||||
lower.contains("wrong password") ||
|
||||
lower.contains("password is incorrect") ||
|
||||
lower.contains("unable to decrypt") ||
|
||||
lower.contains("wrong key") ||
|
||||
lower.contains("invalid password") ||
|
||||
lower.contains("decryption") -> "密码不正确,请确认仓库密码"
|
||||
lower.contains("key") && (lower.contains("not found") || lower.contains("missing")) ->
|
||||
lower.contains("password is incorrect") ||
|
||||
lower.contains("unable to decrypt") ||
|
||||
lower.contains("wrong key") ||
|
||||
lower.contains("invalid password") ||
|
||||
lower.contains("decryption") -> {
|
||||
"密码不正确,请确认仓库密码"
|
||||
}
|
||||
|
||||
lower.contains("key") && (lower.contains("not found") || lower.contains("missing")) -> {
|
||||
"密钥文件缺失,仓库可能已损坏"
|
||||
lower.contains("permission") || lower.contains("access denied") ->
|
||||
}
|
||||
|
||||
lower.contains("permission") || lower.contains("access denied") -> {
|
||||
"权限不足,请检查目录权限"
|
||||
lower.contains("not a directory") || lower.contains("no such file") ->
|
||||
}
|
||||
|
||||
lower.contains("not a directory") || lower.contains("no such file") -> {
|
||||
"仓库路径无效或不可访问"
|
||||
else -> "仓库可能已损坏或密码不正确(${stderr.take(200).trim()})"
|
||||
}
|
||||
|
||||
else -> {
|
||||
"仓库可能已损坏或密码不正确(${stderr.take(200).trim()})"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Public URL helper ──────────────────────────────
|
||||
|
||||
/** Build a display-friendly repository URL for UI. */
|
||||
fun buildRepoUrl(backend: String, repoPath: String, backendUrl: String): String {
|
||||
return envResolver.buildRepoUrl(backend, repoPath, backendUrl)
|
||||
}
|
||||
fun buildRepoUrl(
|
||||
backend: String,
|
||||
repoPath: String,
|
||||
backendUrl: String,
|
||||
): String = envResolver.buildRepoUrl(backend, repoPath, backendUrl)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
package com.example.androidbackupgui.backup
|
||||
|
||||
import android.util.Base64
|
||||
import android.util.Log
|
||||
import fi.iki.elonen.NanoHTTPD
|
||||
import fi.iki.elonen.NanoHTTPD.IHTTPSession
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.File
|
||||
import java.util.UUID
|
||||
|
||||
@@ -14,13 +19,18 @@ import java.util.UUID
|
||||
* can read/write blobs directly to SMB/WebDAV without a local staging repo.
|
||||
*
|
||||
* Port is auto-assigned (0); use [listeningPort] after start().
|
||||
*
|
||||
* @param repoPath repository path from the bridge URL (e.g. "backup").
|
||||
* Stripped from incoming URIs so that the remoteBase SMB path
|
||||
* does not get double-nested with the repo prefix.
|
||||
*/
|
||||
class ResticRestBridge(
|
||||
private val transport: RemoteTransport,
|
||||
private val remoteBase: String,
|
||||
private val cacheDir: File
|
||||
) : NanoHTTPD(0) {
|
||||
|
||||
private val repoPath: String,
|
||||
private val cacheDir: File,
|
||||
private val authToken: String = "",
|
||||
) : NanoHTTPD("127.0.0.1", 0) {
|
||||
private val TAG = "ResticRestBridge"
|
||||
|
||||
init {
|
||||
@@ -34,6 +44,25 @@ class ResticRestBridge(
|
||||
val headers = session.headers
|
||||
val params = session.parms
|
||||
|
||||
// Auth check (defense-in-depth — bridge is already bound to 127.0.0.1)
|
||||
if (authToken.isNotEmpty()) {
|
||||
val expected =
|
||||
"Basic " +
|
||||
Base64.encodeToString(
|
||||
"$authToken:$authToken".toByteArray(Charsets.UTF_8),
|
||||
Base64.NO_WRAP,
|
||||
)
|
||||
val auth = headers["authorization"]
|
||||
if (auth != expected) {
|
||||
Log.w(TAG, "auth failed (got=${auth?.take(20)}..., expected=Basic $authToken)")
|
||||
return newFixedLengthResponse(
|
||||
Response.Status.UNAUTHORIZED,
|
||||
"text/plain",
|
||||
"Unauthorized",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Log.d(TAG, "$method $uri")
|
||||
|
||||
return try {
|
||||
@@ -43,7 +72,7 @@ class ResticRestBridge(
|
||||
newFixedLengthResponse(
|
||||
Response.Status.INTERNAL_ERROR,
|
||||
"text/plain",
|
||||
e.message ?: "Internal error"
|
||||
e.message ?: "Internal error",
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -53,25 +82,43 @@ class ResticRestBridge(
|
||||
uri: String,
|
||||
headers: Map<String, String>,
|
||||
params: Map<String, String>,
|
||||
session: IHTTPSession
|
||||
session: IHTTPSession,
|
||||
): Response {
|
||||
val path = uri.trimEnd('/')
|
||||
// Strip the repoPath prefix (/backup/...) from the URI so that type/name
|
||||
// parsing sees only the restic REST API segment.
|
||||
val stripPrefix = if (repoPath.isNotEmpty()) "/${repoPath.trim('/')}" else ""
|
||||
val strippedPath =
|
||||
if (stripPrefix.isNotEmpty() && path.startsWith(stripPrefix)) {
|
||||
path.removePrefix(stripPrefix).ifEmpty { "/" }
|
||||
} else {
|
||||
path
|
||||
}
|
||||
|
||||
// POST {path}?create=true -> mkdirs
|
||||
if (method == NanoHTTPD.Method.POST && params["create"] == "true") {
|
||||
return runBlocking {
|
||||
when (transport.mkdirs(remoteBase)) {
|
||||
is AppResult.Success -> newFixedLengthResponse(
|
||||
Response.Status.OK, "text/plain", ""
|
||||
)
|
||||
is AppResult.Failure -> newFixedLengthResponse(
|
||||
Response.Status.INTERNAL_ERROR, "text/plain", "mkdirs failed"
|
||||
)
|
||||
is AppResult.Success -> {
|
||||
newFixedLengthResponse(
|
||||
Response.Status.OK,
|
||||
"text/plain",
|
||||
"",
|
||||
)
|
||||
}
|
||||
|
||||
is AppResult.Failure -> {
|
||||
newFixedLengthResponse(
|
||||
Response.Status.INTERNAL_ERROR,
|
||||
"text/plain",
|
||||
"mkdirs failed",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val segments = path.split("/").filter { it.isNotEmpty() }
|
||||
val segments = strippedPath.split("/").filter { it.isNotEmpty() }
|
||||
|
||||
if (segments.isEmpty()) {
|
||||
return newFixedLengthResponse(Response.Status.NOT_FOUND, "text/plain", "Invalid path")
|
||||
@@ -105,17 +152,44 @@ class ResticRestBridge(
|
||||
}
|
||||
|
||||
// -- Config endpoints -------------------------------------------
|
||||
|
||||
/**
|
||||
* Stream body from session input to a temp file to avoid OOM on large blobs.
|
||||
* Returns the temp file (caller must delete).
|
||||
*/
|
||||
private fun streamBodyToFile(session: IHTTPSession, tmpDir: File): Result<File> {
|
||||
private fun streamBodyToFile(
|
||||
session: IHTTPSession,
|
||||
tmpDir: File,
|
||||
): Result<File> {
|
||||
val started = System.currentTimeMillis()
|
||||
return try {
|
||||
val tmpFile = File(tmpDir, "restic_blob_${UUID.randomUUID()}")
|
||||
val contentLength = session.headers["content-length"]?.toLongOrNull() ?: -1L
|
||||
val input = (session as NanoHTTPD.HTTPSession).inputStream
|
||||
Log.d(TAG, "streamBodyToFile: reading body...")
|
||||
tmpFile.outputStream().use { output -> input.copyTo(output) }
|
||||
Log.d(TAG, "streamBodyToFile: reading body (content-length=$contentLength)...")
|
||||
tmpFile.outputStream().use { output ->
|
||||
if (contentLength > 0) {
|
||||
// Read exactly Content-Length bytes to avoid blocking on keep-alive
|
||||
val buf = ByteArray(8192)
|
||||
var remaining = contentLength
|
||||
while (remaining > 0) {
|
||||
val toRead = minOf(buf.size.toLong(), remaining).toInt()
|
||||
val n = input.read(buf, 0, toRead)
|
||||
if (n == -1) break
|
||||
output.write(buf, 0, n)
|
||||
remaining -= n
|
||||
}
|
||||
if (remaining > 0) {
|
||||
Log.w(
|
||||
TAG,
|
||||
"streamBodyToFile: body truncated, expected $contentLength bytes but got EOF after ${contentLength - remaining}",
|
||||
)
|
||||
}
|
||||
Unit
|
||||
} else {
|
||||
input.copyTo(output)
|
||||
}
|
||||
}
|
||||
val elapsed = System.currentTimeMillis() - started
|
||||
val bytes = tmpFile.length()
|
||||
Log.i(TAG, "streamBodyToFile: read $bytes bytes in ${elapsed}ms")
|
||||
@@ -131,218 +205,330 @@ class ResticRestBridge(
|
||||
private fun handleConfig(
|
||||
method: NanoHTTPD.Method,
|
||||
headers: Map<String, String>,
|
||||
session: IHTTPSession
|
||||
): Response = runBlocking {
|
||||
val remotePath = "$remoteBase/config"
|
||||
when (method) {
|
||||
NanoHTTPD.Method.HEAD -> {
|
||||
when (val result = transport.exists(remotePath)) {
|
||||
is AppResult.Success -> {
|
||||
if (result.data) {
|
||||
newFixedLengthResponse(Response.Status.OK, "application/octet-stream", "")
|
||||
} else {
|
||||
newFixedLengthResponse(Response.Status.NOT_FOUND, "text/plain", "")
|
||||
}
|
||||
}
|
||||
is AppResult.Failure -> newFixedLengthResponse(
|
||||
Response.Status.NOT_FOUND, "text/plain", ""
|
||||
)
|
||||
}
|
||||
}
|
||||
NanoHTTPD.Method.GET -> {
|
||||
val tempFile = File(cacheDir, "restic_blob_${UUID.randomUUID()}")
|
||||
try {
|
||||
when (transport.download(remotePath, tempFile.absolutePath)) {
|
||||
session: IHTTPSession,
|
||||
): Response =
|
||||
runBlocking {
|
||||
val remotePath = "$remoteBase/config"
|
||||
when (method) {
|
||||
NanoHTTPD.Method.HEAD -> {
|
||||
var configExists = false
|
||||
var configSize = 0L
|
||||
// 先试 exists,失败时回退到 download 确认(某些 SMB 实现 exists 可能假阴性)
|
||||
when (val exists = transport.exists(remotePath)) {
|
||||
is AppResult.Success -> {
|
||||
newChunkedResponse(Response.Status.OK, "application/octet-stream", tempFile.inputStream())
|
||||
if (exists.data) {
|
||||
configExists = true
|
||||
val sizeResult = transport.fileSize(remotePath)
|
||||
if (sizeResult is AppResult.Success) configSize = sizeResult.data
|
||||
}
|
||||
}
|
||||
is AppResult.Failure -> newFixedLengthResponse(
|
||||
Response.Status.NOT_FOUND, "text/plain", ""
|
||||
|
||||
is AppResult.Failure -> { /* fall through to download check */ }
|
||||
}
|
||||
if (!configExists) {
|
||||
// Fallback: try downloading the config file to confirm existence
|
||||
val tmp = File(cacheDir, "restic_blob_${UUID.randomUUID()}")
|
||||
try {
|
||||
when (transport.download(remotePath, tmp.absolutePath)) {
|
||||
is AppResult.Success -> {
|
||||
configExists = true
|
||||
configSize = tmp.length()
|
||||
}
|
||||
|
||||
is AppResult.Failure -> { /* truly not found */ }
|
||||
}
|
||||
} finally {
|
||||
tmp.delete()
|
||||
}
|
||||
}
|
||||
if (configExists) {
|
||||
newFixedLengthResponse(
|
||||
Response.Status.OK,
|
||||
"application/octet-stream",
|
||||
ByteArrayInputStream(ByteArray(0)),
|
||||
configSize,
|
||||
)
|
||||
} else {
|
||||
newFixedLengthResponse(Response.Status.NOT_FOUND, "text/plain", "")
|
||||
}
|
||||
}
|
||||
|
||||
NanoHTTPD.Method.GET -> {
|
||||
val tempFile = File(cacheDir, "restic_blob_${UUID.randomUUID()}")
|
||||
try {
|
||||
when (transport.download(remotePath, tempFile.absolutePath)) {
|
||||
is AppResult.Success -> {
|
||||
val data = tempFile.readBytes()
|
||||
newFixedLengthResponse(
|
||||
Response.Status.OK,
|
||||
"application/octet-stream",
|
||||
data.inputStream(),
|
||||
data.size.toLong(),
|
||||
)
|
||||
}
|
||||
|
||||
is AppResult.Failure -> {
|
||||
newFixedLengthResponse(
|
||||
Response.Status.NOT_FOUND,
|
||||
"text/plain",
|
||||
"",
|
||||
)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
tempFile.delete()
|
||||
}
|
||||
}
|
||||
|
||||
NanoHTTPD.Method.POST -> {
|
||||
val tmpResult = streamBodyToFile(session, cacheDir)
|
||||
if (tmpResult.isFailure) {
|
||||
return@runBlocking newFixedLengthResponse(
|
||||
Response.Status.INTERNAL_ERROR,
|
||||
"text/plain",
|
||||
"body read failed: ${tmpResult.exceptionOrNull()?.message ?: "unknown"}",
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
tempFile.delete()
|
||||
}
|
||||
}
|
||||
NanoHTTPD.Method.POST -> {
|
||||
val tmpResult = streamBodyToFile(session, cacheDir)
|
||||
if (tmpResult.isFailure) return@runBlocking newFixedLengthResponse(
|
||||
Response.Status.INTERNAL_ERROR, "text/plain",
|
||||
"body read failed: ${tmpResult.exceptionOrNull()?.message ?: "unknown"}"
|
||||
)
|
||||
val tmpFile = tmpResult.getOrThrow()
|
||||
try {
|
||||
when (transport.upload(tmpFile.absolutePath, remotePath)) {
|
||||
is AppResult.Success -> newFixedLengthResponse(
|
||||
Response.Status.OK, "text/plain", ""
|
||||
)
|
||||
is AppResult.Failure -> newFixedLengthResponse(
|
||||
Response.Status.INTERNAL_ERROR, "text/plain", "upload failed"
|
||||
)
|
||||
val tmpFile = tmpResult.getOrThrow()
|
||||
try {
|
||||
when (transport.upload(tmpFile.absolutePath, remotePath)) {
|
||||
is AppResult.Success -> {
|
||||
newFixedLengthResponse(
|
||||
Response.Status.OK,
|
||||
"text/plain",
|
||||
"",
|
||||
)
|
||||
}
|
||||
|
||||
is AppResult.Failure -> {
|
||||
newFixedLengthResponse(
|
||||
Response.Status.INTERNAL_ERROR,
|
||||
"text/plain",
|
||||
"upload failed",
|
||||
)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
tmpFile.delete()
|
||||
}
|
||||
} finally {
|
||||
tmpFile.delete()
|
||||
}
|
||||
|
||||
else -> {
|
||||
newFixedLengthResponse(Response.Status.METHOD_NOT_ALLOWED, "text/plain", "")
|
||||
}
|
||||
}
|
||||
else -> newFixedLengthResponse(Response.Status.METHOD_NOT_ALLOWED, "text/plain", "")
|
||||
}
|
||||
}
|
||||
|
||||
// -- Blob listing -----------------------------------------------
|
||||
|
||||
private fun handleListBlobs(type: String): Response = runBlocking {
|
||||
val remoteDir = "$remoteBase/$type"
|
||||
when (val result = transport.listFiles(remoteDir)) {
|
||||
is AppResult.Success -> {
|
||||
val items = result.data
|
||||
val json = buildV2Json(items)
|
||||
newFixedLengthResponse(Response.Status.OK, "application/vnd.x.restic.rest.v2", json)
|
||||
private fun handleListBlobs(type: String): Response =
|
||||
runBlocking {
|
||||
val remoteDir = "$remoteBase/$type"
|
||||
when (val result = transport.listFiles(remoteDir)) {
|
||||
is AppResult.Success -> {
|
||||
val items = result.data
|
||||
val json = buildV2Json(items)
|
||||
newFixedLengthResponse(Response.Status.OK, "application/vnd.x.restic.rest.v2", json)
|
||||
}
|
||||
|
||||
is AppResult.Failure -> {
|
||||
newFixedLengthResponse(
|
||||
Response.Status.NOT_FOUND,
|
||||
"text/plain",
|
||||
"",
|
||||
)
|
||||
}
|
||||
}
|
||||
is AppResult.Failure -> newFixedLengthResponse(
|
||||
Response.Status.NOT_FOUND, "text/plain", ""
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class BlobEntry(
|
||||
val name: String,
|
||||
val size: Long,
|
||||
)
|
||||
|
||||
private fun buildV2Json(items: List<RemoteTransport.RemoteFileInfo>): String {
|
||||
val sb = StringBuilder("[")
|
||||
var first = true
|
||||
for (item in items) {
|
||||
if (item.isDirectory) continue
|
||||
if (!first) sb.append(",")
|
||||
first = false
|
||||
sb.append("{\"name\":\"${item.name}\",\"size\":${item.size}}")
|
||||
}
|
||||
sb.append("]")
|
||||
return sb.toString()
|
||||
val blobs = items.filter { !it.isDirectory }.map { BlobEntry(it.name, it.size) }
|
||||
return Json.encodeToString(blobs)
|
||||
}
|
||||
|
||||
// -- Blob HEAD (exists + size) ----------------------------------
|
||||
|
||||
private fun handleHeadBlob(type: String, name: String): Response = runBlocking {
|
||||
val remotePath = "$remoteBase/$type/$name"
|
||||
when (val result = transport.exists(remotePath)) {
|
||||
is AppResult.Success -> {
|
||||
if (result.data) {
|
||||
newFixedLengthResponse(Response.Status.OK, "application/octet-stream", "")
|
||||
} else {
|
||||
newFixedLengthResponse(Response.Status.NOT_FOUND, "text/plain", "")
|
||||
private fun handleHeadBlob(
|
||||
type: String,
|
||||
name: String,
|
||||
): Response =
|
||||
runBlocking {
|
||||
val remotePath = "$remoteBase/$type/$name"
|
||||
when (val result = transport.exists(remotePath)) {
|
||||
is AppResult.Success -> {
|
||||
if (result.data) {
|
||||
newFixedLengthResponse(Response.Status.OK, "application/octet-stream", "")
|
||||
} else {
|
||||
newFixedLengthResponse(Response.Status.NOT_FOUND, "text/plain", "")
|
||||
}
|
||||
}
|
||||
|
||||
is AppResult.Failure -> {
|
||||
newFixedLengthResponse(
|
||||
Response.Status.NOT_FOUND,
|
||||
"text/plain",
|
||||
"",
|
||||
)
|
||||
}
|
||||
}
|
||||
is AppResult.Failure -> newFixedLengthResponse(
|
||||
Response.Status.NOT_FOUND, "text/plain", ""
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// -- Blob GET (download with optional Range) --------------------
|
||||
|
||||
private fun handleGetBlob(
|
||||
type: String,
|
||||
name: String,
|
||||
headers: Map<String, String>
|
||||
): Response = runBlocking {
|
||||
val remotePath = "$remoteBase/$type/$name"
|
||||
// Use RandomAccessFile to avoid loading entire blob into memory
|
||||
val tempFile = File(cacheDir, "restic_blob_${UUID.randomUUID()}")
|
||||
try {
|
||||
when (transport.download(remotePath, tempFile.absolutePath)) {
|
||||
is AppResult.Success -> {
|
||||
val rangeHeader = headers["range"]?.lowercase()
|
||||
headers: Map<String, String>,
|
||||
): Response =
|
||||
runBlocking {
|
||||
val remotePath = "$remoteBase/$type/$name"
|
||||
// Use RandomAccessFile to avoid loading entire blob into memory
|
||||
val tempFile = File(cacheDir, "restic_blob_${UUID.randomUUID()}")
|
||||
try {
|
||||
when (transport.download(remotePath, tempFile.absolutePath)) {
|
||||
is AppResult.Success -> {
|
||||
val rangeHeader = headers["range"]?.lowercase()
|
||||
|
||||
if (rangeHeader != null && rangeHeader.startsWith("bytes=")) {
|
||||
// Range request — only works with known file size
|
||||
val fileLen = tempFile.length()
|
||||
val range = rangeHeader.removePrefix("bytes=").trim()
|
||||
val dashIdx = range.indexOf('-')
|
||||
val start = range.substring(0, if (dashIdx >= 0) dashIdx else range.length)
|
||||
.toLongOrNull() ?: 0L
|
||||
val end = if (dashIdx >= 0 && dashIdx + 1 < range.length) {
|
||||
range.substring(dashIdx + 1).toLongOrNull() ?: (fileLen - 1)
|
||||
} else {
|
||||
fileLen - 1
|
||||
if (rangeHeader != null && rangeHeader.startsWith("bytes=")) {
|
||||
// Range request — only works with known file size
|
||||
val fileLen = tempFile.length()
|
||||
val range = rangeHeader.removePrefix("bytes=").trim()
|
||||
val dashIdx = range.indexOf('-')
|
||||
val start =
|
||||
range
|
||||
.substring(0, if (dashIdx >= 0) dashIdx else range.length)
|
||||
.toLongOrNull() ?: 0L
|
||||
val end =
|
||||
if (dashIdx >= 0 && dashIdx + 1 < range.length) {
|
||||
range.substring(dashIdx + 1).toLongOrNull() ?: (fileLen - 1)
|
||||
} else {
|
||||
fileLen - 1
|
||||
}
|
||||
|
||||
val actualEnd = minOf(end, fileLen - 1).coerceAtLeast(0)
|
||||
val actualStart = minOf(start, actualEnd).coerceAtLeast(0)
|
||||
val chunkSize = (actualEnd - actualStart + 1).toInt()
|
||||
val chunk = ByteArray(chunkSize)
|
||||
try {
|
||||
val raf = java.io.RandomAccessFile(tempFile, "r")
|
||||
raf.use {
|
||||
it.seek(actualStart)
|
||||
it.readFully(chunk)
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
return@runBlocking newFixedLengthResponse(
|
||||
Response.Status.INTERNAL_ERROR,
|
||||
"text/plain",
|
||||
"range read failed",
|
||||
)
|
||||
}
|
||||
|
||||
val response =
|
||||
newChunkedResponse(
|
||||
Response.Status.PARTIAL_CONTENT,
|
||||
"application/octet-stream",
|
||||
chunk.inputStream(),
|
||||
)
|
||||
response.addHeader("Content-Range", "bytes $actualStart-$actualEnd/$fileLen")
|
||||
response.addHeader("Content-Length", chunkSize.toString())
|
||||
return@runBlocking response
|
||||
}
|
||||
|
||||
val actualEnd = minOf(end, fileLen - 1).coerceAtLeast(0)
|
||||
val actualStart = minOf(start, actualEnd).coerceAtLeast(0)
|
||||
val chunkSize = (actualEnd - actualStart + 1).toInt()
|
||||
val chunk = ByteArray(chunkSize)
|
||||
try {
|
||||
val raf = java.io.RandomAccessFile(tempFile, "r")
|
||||
raf.use { it.seek(actualStart); it.readFully(chunk) }
|
||||
} catch (_: Exception) {
|
||||
return@runBlocking newFixedLengthResponse(
|
||||
Response.Status.INTERNAL_ERROR, "text/plain", "range read failed"
|
||||
// Full file — read into memory (blobs are typically small)
|
||||
val data = tempFile.readBytes()
|
||||
val response =
|
||||
newChunkedResponse(
|
||||
Response.Status.OK,
|
||||
"application/octet-stream",
|
||||
data.inputStream(),
|
||||
)
|
||||
}
|
||||
|
||||
val response = newChunkedResponse(
|
||||
Response.Status.PARTIAL_CONTENT,
|
||||
"application/octet-stream",
|
||||
chunk.inputStream()
|
||||
)
|
||||
response.addHeader("Content-Range", "bytes $actualStart-$actualEnd/$fileLen")
|
||||
response.addHeader("Content-Length", chunkSize.toString())
|
||||
return@runBlocking response
|
||||
response.addHeader("Content-Length", data.size.toString())
|
||||
response
|
||||
}
|
||||
|
||||
// Full file — stream directly without loading into memory
|
||||
val response = newChunkedResponse(
|
||||
Response.Status.OK,
|
||||
"application/octet-stream",
|
||||
tempFile.inputStream()
|
||||
)
|
||||
response.addHeader("Content-Length", tempFile.length().toString())
|
||||
response
|
||||
is AppResult.Failure -> {
|
||||
newFixedLengthResponse(
|
||||
Response.Status.NOT_FOUND,
|
||||
"text/plain",
|
||||
"",
|
||||
)
|
||||
}
|
||||
}
|
||||
is AppResult.Failure -> newFixedLengthResponse(
|
||||
Response.Status.NOT_FOUND, "text/plain", ""
|
||||
)
|
||||
} finally {
|
||||
tempFile.delete()
|
||||
}
|
||||
} finally {
|
||||
tempFile.delete()
|
||||
}
|
||||
}
|
||||
|
||||
// -- Blob POST (upload) -----------------------------------------
|
||||
|
||||
private fun handlePostBlob(
|
||||
type: String,
|
||||
name: String,
|
||||
session: IHTTPSession
|
||||
): Response = runBlocking {
|
||||
val remotePath = "$remoteBase/$type/$name"
|
||||
val tmpResult = streamBodyToFile(session, cacheDir)
|
||||
if (tmpResult.isFailure) return@runBlocking newFixedLengthResponse(
|
||||
Response.Status.INTERNAL_ERROR, "text/plain",
|
||||
"body read failed: ${tmpResult.exceptionOrNull()?.message ?: "unknown"}"
|
||||
)
|
||||
val tmpFile = tmpResult.getOrThrow()
|
||||
try {
|
||||
when (transport.upload(tmpFile.absolutePath, remotePath)) {
|
||||
is AppResult.Success -> newFixedLengthResponse(
|
||||
Response.Status.OK, "text/plain", ""
|
||||
)
|
||||
is AppResult.Failure -> newFixedLengthResponse(
|
||||
Response.Status.INTERNAL_ERROR, "text/plain", "upload failed"
|
||||
session: IHTTPSession,
|
||||
): Response =
|
||||
runBlocking {
|
||||
val remotePath = "$remoteBase/$type/$name"
|
||||
val tmpResult = streamBodyToFile(session, cacheDir)
|
||||
if (tmpResult.isFailure) {
|
||||
return@runBlocking newFixedLengthResponse(
|
||||
Response.Status.INTERNAL_ERROR,
|
||||
"text/plain",
|
||||
"body read failed: ${tmpResult.exceptionOrNull()?.message ?: "unknown"}",
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
tmpFile.delete()
|
||||
val tmpFile = tmpResult.getOrThrow()
|
||||
try {
|
||||
when (transport.upload(tmpFile.absolutePath, remotePath)) {
|
||||
is AppResult.Success -> {
|
||||
newFixedLengthResponse(
|
||||
Response.Status.OK,
|
||||
"text/plain",
|
||||
"",
|
||||
)
|
||||
}
|
||||
|
||||
is AppResult.Failure -> {
|
||||
newFixedLengthResponse(
|
||||
Response.Status.INTERNAL_ERROR,
|
||||
"text/plain",
|
||||
"upload failed",
|
||||
)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
tmpFile.delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -- Blob DELETE ------------------------------------------------
|
||||
|
||||
private fun handleDeleteBlob(type: String, name: String): Response = runBlocking {
|
||||
val remotePath = "$remoteBase/$type/$name"
|
||||
when (transport.delete(remotePath)) {
|
||||
is AppResult.Success -> newFixedLengthResponse(
|
||||
Response.Status.OK, "text/plain", ""
|
||||
)
|
||||
is AppResult.Failure -> newFixedLengthResponse(
|
||||
Response.Status.INTERNAL_ERROR, "text/plain", "delete failed"
|
||||
)
|
||||
private fun handleDeleteBlob(
|
||||
type: String,
|
||||
name: String,
|
||||
): Response =
|
||||
runBlocking {
|
||||
val remotePath = "$remoteBase/$type/$name"
|
||||
when (transport.delete(remotePath)) {
|
||||
is AppResult.Success -> {
|
||||
newFixedLengthResponse(
|
||||
Response.Status.OK,
|
||||
"text/plain",
|
||||
"",
|
||||
)
|
||||
}
|
||||
|
||||
is AppResult.Failure -> {
|
||||
newFixedLengthResponse(
|
||||
Response.Status.INTERNAL_ERROR,
|
||||
"text/plain",
|
||||
"delete failed",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,45 +1,31 @@
|
||||
package com.example.androidbackupgui.backup
|
||||
|
||||
import com.example.androidbackupgui.backup.AppError
|
||||
import com.example.androidbackupgui.backup.AppResult
|
||||
import com.example.androidbackupgui.backup.err
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import kotlin.coroutines.coroutineContext
|
||||
import com.example.androidbackupgui.backup.AppError
|
||||
import com.example.androidbackupgui.backup.AppResult
|
||||
import com.example.androidbackupgui.backup.err
|
||||
|
||||
|
||||
/**
|
||||
* Restore operations: full directory restore and single-file dump.
|
||||
*
|
||||
* Both are download-only operations (no upload to remote needed).
|
||||
* Delegates execution to [ResticCommandRunner], [ResticEnvResolver], and
|
||||
* [RestBridgeRunner] which are shared across sub-modules.
|
||||
*
|
||||
* @property cacheDir Cache directory for restic env and bridge temp files; set by [ResticWrapper].
|
||||
* @property backendDomain Domain for SMB NTLM authentication; set by [ResticWrapper].
|
||||
* 使用 [BackendExecutor] 统一处理 local/remote 后端。
|
||||
*/
|
||||
class ResticRestore(
|
||||
private val runner: ResticCommandRunner,
|
||||
private val envResolver: ResticEnvResolver,
|
||||
private val bridgeRunner: RestBridgeRunner
|
||||
private val bridgeRunner: RestBridgeRunner,
|
||||
private val executor: BackendExecutor = BackendExecutor(),
|
||||
) {
|
||||
/** Cache directory for restic env and bridge temp files. Set by [ResticWrapper]. */
|
||||
var cacheDir: String = ""
|
||||
|
||||
/** Domain for SMB NTLM authentication. Set by [ResticWrapper]. */
|
||||
var backendDomain: String = ""
|
||||
|
||||
// ── Restore ────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Restore a snapshot to [targetPath], optionally filtered by [include] pattern.
|
||||
*
|
||||
* For local backends, builds env via [ResticEnvResolver.buildLocalEnv] and runs
|
||||
* restic restore directly. For remote backends, proxies through [RestBridgeRunner]
|
||||
* using a local REST bridge, building env via [ResticEnvResolver.buildBridgeEnv].
|
||||
*/
|
||||
suspend fun restore(
|
||||
repoPath: String,
|
||||
password: String,
|
||||
@@ -51,77 +37,63 @@ class ResticRestore(
|
||||
backendUser: String = "",
|
||||
backendPass: String = "",
|
||||
backendShare: String = "",
|
||||
onProgress: suspend (String) -> Unit = {}
|
||||
): AppResult<Unit> = withContext(Dispatchers.IO) {
|
||||
val emit: suspend (String) -> Unit = { s -> withContext(Dispatchers.Main) { onProgress(s) } }
|
||||
|
||||
if (backend == "local") {
|
||||
onProgress: suspend (String) -> Unit = {},
|
||||
): AppResult<Unit> =
|
||||
withContext(Dispatchers.IO) {
|
||||
val emit: suspend (String) -> Unit = { s -> withContext(Dispatchers.Main) { onProgress(s) } }
|
||||
File(targetPath).mkdirs()
|
||||
|
||||
val args = mutableListOf("restore", snapshotId, "--target", targetPath, "--json")
|
||||
if (include != null) { args.add("--include"); args.add(include) }
|
||||
if (include != null) {
|
||||
args.add("--include")
|
||||
args.add(include)
|
||||
}
|
||||
|
||||
val env = envResolver.buildLocalEnv(repoPath, password, cacheDir)
|
||||
val result = runner.runResticStreaming(env, args) { line ->
|
||||
if (!coroutineContext.isActive) return@runResticStreaming
|
||||
try {
|
||||
val progress = resticJson.decodeFromString<ResticWrapper.ResticProgress>(line)
|
||||
when (progress.messageType) {
|
||||
"status" -> {
|
||||
val percent = "%.1f".format(progress.percentDone * 100)
|
||||
emit("恢复进度: $percent%")
|
||||
}
|
||||
"summary" -> {
|
||||
emit("恢复完成: ${progress.totalFiles} 个文件")
|
||||
val result =
|
||||
executor.withBackend(
|
||||
repoPath = repoPath,
|
||||
password = password,
|
||||
cacheDir = cacheDir,
|
||||
backend = backend,
|
||||
backendUrl = backendUrl,
|
||||
backendUser = backendUser,
|
||||
backendPass = backendPass,
|
||||
backendShare = backendShare,
|
||||
backendDomain = backendDomain,
|
||||
runner = runner,
|
||||
envResolver = envResolver,
|
||||
bridgeRunner = bridgeRunner,
|
||||
) { env ->
|
||||
runner.runResticStreaming(env, args) { line ->
|
||||
if (!coroutineContext.isActive) return@runResticStreaming
|
||||
try {
|
||||
val progress = resticJson.decodeFromString<ResticWrapper.ResticProgress>(line)
|
||||
when (progress.messageType) {
|
||||
"status" -> {
|
||||
val percent = "%.1f".format(progress.percentDone * 100)
|
||||
emit("恢复进度: $percent%")
|
||||
}
|
||||
|
||||
"summary" -> {
|
||||
emit("恢复完成: ${progress.totalFiles} 个文件")
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
emit(line)
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) { emit(line) }
|
||||
}
|
||||
|
||||
if (result.exitCode == 0) AppResult.Success(Unit)
|
||||
else err(AppError.Restic("restic restore 失败", result.exitCode, result.stderr))
|
||||
} else {
|
||||
bridgeRunner.withBridge(
|
||||
backend, backendUrl, backendUser, backendPass, backendShare, backendDomain,
|
||||
repoPath, File(cacheDir)
|
||||
) { bridgeUrl ->
|
||||
File(targetPath).mkdirs()
|
||||
|
||||
val args = mutableListOf("restore", snapshotId, "--target", targetPath, "--json")
|
||||
if (include != null) { args.add("--include"); args.add(include) }
|
||||
|
||||
val env = envResolver.buildBridgeEnv(password, bridgeUrl, cacheDir)
|
||||
val result = runner.runResticStreaming(env, args) { line ->
|
||||
if (!coroutineContext.isActive) return@runResticStreaming
|
||||
try {
|
||||
val progress = resticJson.decodeFromString<ResticWrapper.ResticProgress>(line)
|
||||
when (progress.messageType) {
|
||||
"status" -> {
|
||||
val percent = "%.1f".format(progress.percentDone * 100)
|
||||
emit("恢复进度: $percent%")
|
||||
}
|
||||
"summary" -> {
|
||||
emit("恢复完成: ${progress.totalFiles} 个文件")
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) { emit(line) }
|
||||
}
|
||||
|
||||
if (result.exitCode == 0) AppResult.Success(Unit)
|
||||
else err(AppError.Restic("restic restore 失败", result.exitCode, result.stderr))
|
||||
if (result.exitCode == 0) {
|
||||
AppResult.Success(Unit)
|
||||
} else {
|
||||
err(AppError.Restic("restic restore 失败", result.exitCode, result.stderr))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── File dump ──────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Dump the contents of a single file from a snapshot.
|
||||
*
|
||||
* For local backends, builds env via [ResticEnvResolver.buildLocalEnv] and runs
|
||||
* restic dump directly. For remote backends, proxies through [RestBridgeRunner]
|
||||
* using a local REST bridge, building env via [ResticEnvResolver.buildBridgeEnv].
|
||||
*/
|
||||
suspend fun dump(
|
||||
repoPath: String,
|
||||
password: String,
|
||||
@@ -131,23 +103,29 @@ class ResticRestore(
|
||||
backendUrl: String = "",
|
||||
backendUser: String = "",
|
||||
backendPass: String = "",
|
||||
backendShare: String = ""
|
||||
): AppResult<String> = withContext(Dispatchers.IO) {
|
||||
if (backend == "local") {
|
||||
val env = envResolver.buildLocalEnv(repoPath, password, cacheDir)
|
||||
val result = runner.runRestic(env, "dump", snapshotId, filePath)
|
||||
if (result.exitCode == 0) AppResult.Success(result.stdout)
|
||||
else err(AppError.Restic(result.stderr.ifEmpty { "restic dump 失败" }, result.exitCode, result.stderr))
|
||||
} else {
|
||||
bridgeRunner.withBridge(
|
||||
backend, backendUrl, backendUser, backendPass, backendShare, backendDomain,
|
||||
repoPath, File(cacheDir)
|
||||
) { bridgeUrl ->
|
||||
val env = envResolver.buildBridgeEnv(password, bridgeUrl, cacheDir)
|
||||
val result = runner.runRestic(env, "dump", snapshotId, filePath)
|
||||
if (result.exitCode == 0) AppResult.Success(result.stdout)
|
||||
else err(AppError.Restic(result.stderr.ifEmpty { "restic dump 失败" }, result.exitCode, result.stderr))
|
||||
backendShare: String = "",
|
||||
): AppResult<String> =
|
||||
withContext(Dispatchers.IO) {
|
||||
val result =
|
||||
executor.withBackend(
|
||||
repoPath = repoPath,
|
||||
password = password,
|
||||
cacheDir = cacheDir,
|
||||
backend = backend,
|
||||
backendUrl = backendUrl,
|
||||
backendUser = backendUser,
|
||||
backendPass = backendPass,
|
||||
backendShare = backendShare,
|
||||
backendDomain = backendDomain,
|
||||
runner = runner,
|
||||
envResolver = envResolver,
|
||||
bridgeRunner = bridgeRunner,
|
||||
) { env -> runner.runRestic(env, "dump", snapshotId, filePath) }
|
||||
|
||||
if (result.exitCode == 0) {
|
||||
AppResult.Success(result.stdout)
|
||||
} else {
|
||||
err(AppError.Restic(result.stderr.ifEmpty { "restic dump 失败" }, result.exitCode, result.stderr))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,33 +1,25 @@
|
||||
package com.example.androidbackupgui.backup
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import com.example.androidbackupgui.backup.AppError
|
||||
import com.example.androidbackupgui.backup.AppResult
|
||||
import com.example.androidbackupgui.backup.err
|
||||
import java.io.File
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* Snapshot listing and retention policy operations.
|
||||
*
|
||||
* [listSnapshots] is download-only; [forget] removes snapshots from the remote.
|
||||
*
|
||||
* For "local" backends, invokes restic directly against [repoPath].
|
||||
* For remote backends (SMB/WebDAV/rest-server), starts a temporary REST bridge
|
||||
* via [RestBridgeRunner.withBridge] and points restic at the bridge URL.
|
||||
*
|
||||
* Delegates execution to [ResticCommandRunner], [ResticEnvResolver], and
|
||||
* [RestBridgeRunner] which are shared across sub-modules.
|
||||
* 使用 [BackendExecutor] 统一处理 local/remote 后端。
|
||||
*/
|
||||
class ResticSnapshotOps(
|
||||
private val runner: ResticCommandRunner,
|
||||
private val envResolver: ResticEnvResolver,
|
||||
private val bridgeRunner: RestBridgeRunner
|
||||
private val bridgeRunner: RestBridgeRunner,
|
||||
private val executor: BackendExecutor = BackendExecutor(),
|
||||
) {
|
||||
/** Cache directory for restic env and bridge temp files. Set by ResticWrapper. */
|
||||
var cacheDir: String = ""
|
||||
/** NTLM domain for SMB authentication. Set by ResticWrapper. */
|
||||
var backendDomain: String = ""
|
||||
|
||||
// ── List snapshots ─────────────────────────────────
|
||||
@@ -41,52 +33,44 @@ class ResticSnapshotOps(
|
||||
backendUser: String = "",
|
||||
backendPass: String = "",
|
||||
backendShare: String = "",
|
||||
): AppResult<List<ResticWrapper.ResticSnapshot>> = withContext(Dispatchers.IO) {
|
||||
if (backend == "local") {
|
||||
): AppResult<List<ResticWrapper.ResticSnapshot>> =
|
||||
withContext(Dispatchers.IO) {
|
||||
val args = mutableListOf("snapshots", "--json")
|
||||
if (tag != null) { args.add("--tag"); args.add(tag) }
|
||||
if (tag != null) {
|
||||
args.add("--tag")
|
||||
args.add(tag)
|
||||
}
|
||||
|
||||
val env = envResolver.buildLocalEnv(repoPath, password, cacheDir)
|
||||
val result = runner.runRestic(env, args)
|
||||
val result =
|
||||
executor.withBackend(
|
||||
repoPath = repoPath,
|
||||
password = password,
|
||||
cacheDir = cacheDir,
|
||||
backend = backend,
|
||||
backendUrl = backendUrl,
|
||||
backendUser = backendUser,
|
||||
backendPass = backendPass,
|
||||
backendShare = backendShare,
|
||||
backendDomain = backendDomain,
|
||||
runner = runner,
|
||||
envResolver = envResolver,
|
||||
bridgeRunner = bridgeRunner,
|
||||
) { env -> runner.runRestic(env, args) }
|
||||
|
||||
if (result.exitCode != 0) {
|
||||
return@withContext err(AppError.Restic("restic snapshots 失败", result.exitCode, result.stderr))
|
||||
}
|
||||
|
||||
try {
|
||||
val snapshots = resticJson.decodeFromString<List<ResticWrapper.ResticSnapshot>>(
|
||||
result.stdout.ifEmpty { "[]" }
|
||||
)
|
||||
val snapshots =
|
||||
resticJson.decodeFromString<List<ResticWrapper.ResticSnapshot>>(
|
||||
result.stdout.ifEmpty { "[]" },
|
||||
)
|
||||
AppResult.Success(snapshots.sortedByDescending { it.time })
|
||||
} catch (e: Exception) {
|
||||
err(AppError.Parse("解析快照 JSON 失败", e.message ?: ""))
|
||||
}
|
||||
} else {
|
||||
bridgeRunner.withBridge(
|
||||
backend, backendUrl, backendUser, backendPass, backendShare,
|
||||
backendDomain, repoPath, File(cacheDir)
|
||||
) { bridgeUrl ->
|
||||
val args = mutableListOf("snapshots", "--json")
|
||||
if (tag != null) { args.add("--tag"); args.add(tag) }
|
||||
|
||||
val env = envResolver.buildBridgeEnv(password, bridgeUrl, cacheDir)
|
||||
val result = runner.runRestic(env, args)
|
||||
|
||||
if (result.exitCode != 0) {
|
||||
return@withBridge err(AppError.Restic("restic snapshots 失败", result.exitCode, result.stderr))
|
||||
}
|
||||
|
||||
try {
|
||||
val snapshots = resticJson.decodeFromString<List<ResticWrapper.ResticSnapshot>>(
|
||||
result.stdout.ifEmpty { "[]" }
|
||||
)
|
||||
AppResult.Success(snapshots.sortedByDescending { it.time })
|
||||
} catch (e: Exception) {
|
||||
err(AppError.Parse("解析快照 JSON 失败", e.message ?: ""))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Forget (retention policy) ──────────────────────
|
||||
|
||||
@@ -102,40 +86,40 @@ class ResticSnapshotOps(
|
||||
backendUser: String = "",
|
||||
backendPass: String = "",
|
||||
backendShare: String = "",
|
||||
): AppResult<String> = withContext(Dispatchers.IO) {
|
||||
if (backend == "local") {
|
||||
val args = mutableListOf(
|
||||
"forget",
|
||||
"--keep-daily", keepDaily.toString(),
|
||||
"--keep-weekly", keepWeekly.toString(),
|
||||
"--keep-monthly", keepMonthly.toString()
|
||||
)
|
||||
): AppResult<String> =
|
||||
withContext(Dispatchers.IO) {
|
||||
val args =
|
||||
mutableListOf(
|
||||
"forget",
|
||||
"--keep-daily",
|
||||
keepDaily.toString(),
|
||||
"--keep-weekly",
|
||||
keepWeekly.toString(),
|
||||
"--keep-monthly",
|
||||
keepMonthly.toString(),
|
||||
)
|
||||
if (dryRun) args.add("--dry-run")
|
||||
|
||||
val env = envResolver.buildLocalEnv(repoPath, password, cacheDir)
|
||||
val result = runner.runRestic(env, args)
|
||||
val result =
|
||||
executor.withBackend(
|
||||
repoPath = repoPath,
|
||||
password = password,
|
||||
cacheDir = cacheDir,
|
||||
backend = backend,
|
||||
backendUrl = backendUrl,
|
||||
backendUser = backendUser,
|
||||
backendPass = backendPass,
|
||||
backendShare = backendShare,
|
||||
backendDomain = backendDomain,
|
||||
runner = runner,
|
||||
envResolver = envResolver,
|
||||
bridgeRunner = bridgeRunner,
|
||||
) { env -> runner.runRestic(env, args) }
|
||||
|
||||
if (result.exitCode == 0) AppResult.Success(result.stdout)
|
||||
else err(AppError.Restic("restic forget 失败", result.exitCode, result.stderr))
|
||||
} else {
|
||||
bridgeRunner.withBridge(
|
||||
backend, backendUrl, backendUser, backendPass, backendShare,
|
||||
backendDomain, repoPath, File(cacheDir)
|
||||
) { bridgeUrl ->
|
||||
val args = mutableListOf(
|
||||
"forget",
|
||||
"--keep-daily", keepDaily.toString(),
|
||||
"--keep-weekly", keepWeekly.toString(),
|
||||
"--keep-monthly", keepMonthly.toString()
|
||||
)
|
||||
if (dryRun) args.add("--dry-run")
|
||||
|
||||
val env = envResolver.buildBridgeEnv(password, bridgeUrl, cacheDir)
|
||||
val result = runner.runRestic(env, args)
|
||||
|
||||
if (result.exitCode == 0) AppResult.Success(result.stdout)
|
||||
else err(AppError.Restic("restic forget 失败", result.exitCode, result.stderr))
|
||||
if (result.exitCode == 0) {
|
||||
AppResult.Success(result.stdout)
|
||||
} else {
|
||||
err(AppError.Restic("restic forget 失败", result.exitCode, result.stderr))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
package com.example.androidbackupgui.backup
|
||||
|
||||
import android.util.Log
|
||||
import com.example.androidbackupgui.root.RootShell
|
||||
import com.example.androidbackupgui.root.shellEscape
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import kotlin.coroutines.coroutineContext
|
||||
|
||||
/**
|
||||
* "流式"备份——将应用数据 tar 到临时目录,然后由 restic 统一备份。
|
||||
*
|
||||
* 原实现使用 FIFO + `restic backup --stdin`,但由于 RootShell 每次 exec
|
||||
* 会独立打开/关闭 FIFO,导致 restic 在第一次写入后收到 EOF 退出。
|
||||
*
|
||||
* 当前实现改为:
|
||||
* 1. 创建临时工作目录 stream_data/
|
||||
* 2. 将元数据 + APK 文件复制到该目录
|
||||
* 3. 对每个应用,tar 数据到该目录下的独立文件
|
||||
* 4. 运行 restic backup 指向该目录(无 --stdin,无 FIFO)
|
||||
* 5. 备份完成后清理临时目录
|
||||
*
|
||||
* 和普通备份的区别:临时目录会在备份完成后自动删除,不留本地存档。
|
||||
* 仅当 [BackupConfig.useStreaming] 启用时使用。
|
||||
*/
|
||||
object ResticStreamBackup {
|
||||
private const val TAG = "ResticStreamBackup"
|
||||
|
||||
/** 单个应用跳过备份的数据大小阈值(500MB) */
|
||||
private const val MAX_STREAM_APP_SIZE_BYTES = 500L * 1024 * 1024
|
||||
|
||||
/**
|
||||
* Run a streaming backup.
|
||||
*/
|
||||
suspend fun backup(
|
||||
cacheDir: File,
|
||||
ownPackageName: String,
|
||||
apps: List<AppInfo>,
|
||||
noDataBackup: Set<String>,
|
||||
legacyApps: Map<String, ResticWrapper.SnapshotAppInfo>?,
|
||||
userId: String,
|
||||
restic: ResticWrapper,
|
||||
repoPath: String,
|
||||
password: String,
|
||||
tags: List<String>,
|
||||
hostname: String?,
|
||||
backend: String,
|
||||
backendUrl: String,
|
||||
backendUser: String,
|
||||
backendPass: String,
|
||||
backendShare: String,
|
||||
onProgress: suspend (String) -> Unit = {},
|
||||
): AppResult<ResticWrapper.BackupSummary> =
|
||||
withContext(Dispatchers.IO) {
|
||||
val emit: suspend (String) -> Unit = { msg -> withContext(Dispatchers.Main) { onProgress(msg) } }
|
||||
|
||||
// ── 1. Create temporary work directory ──────
|
||||
val workDir = File(cacheDir, "stream_data")
|
||||
if (workDir.exists()) RootShell.exec("rm -rf '${workDir.absolutePath.shellEscape()}'")
|
||||
workDir.mkdirs()
|
||||
Log.i(TAG, "Work dir created at ${workDir.absolutePath}")
|
||||
|
||||
try {
|
||||
// ── 2. Write metadata ─────────────────────
|
||||
// 文件直接放在 workDir 根下,与普通备份结构一致
|
||||
emit("正在准备元数据…")
|
||||
BackupOperation.writeFileForBackup(
|
||||
File(workDir, "appList.txt"),
|
||||
apps.joinToString("\n") { it.packageName.value },
|
||||
)
|
||||
BackupOperation.writeFileForBackup(
|
||||
File(workDir, "app_details.json"),
|
||||
BackupOperation.buildAppDetailsJson(apps, legacyApps),
|
||||
)
|
||||
Log.i(TAG, "Metadata written to ${workDir.absolutePath}")
|
||||
|
||||
// ── 3. Backup APK files ───────────────────
|
||||
// 统一使用 per-app 子目录结构,与普通备份和恢复代码兼容
|
||||
emit("正在备份 APK 文件…")
|
||||
var apkCount = 0
|
||||
for (app in apps) {
|
||||
if (!coroutineContext.isActive) return@withContext err(AppError.Cancelled)
|
||||
val appDir = File(workDir, app.packageName.value)
|
||||
appDir.mkdirs()
|
||||
val paths = AppScanner.getApkPaths(app.packageName.value)
|
||||
for ((i, apkPath) in paths.withIndex()) {
|
||||
val destName = if (paths.size > 1) "${app.packageName.value}_split_$i.apk" else "${app.packageName.value}.apk"
|
||||
val cpOk =
|
||||
RootShell
|
||||
.exec(
|
||||
"cp '${apkPath.shellEscape()}' '${File(appDir, destName).absolutePath.shellEscape()}' 2>/dev/null",
|
||||
).isSuccess
|
||||
if (cpOk) apkCount++
|
||||
}
|
||||
}
|
||||
Log.i(TAG, "Backed up $apkCount APK files")
|
||||
|
||||
// ── 4. Backup app data ────────────────────
|
||||
var successCount = 0
|
||||
var failCount = 0
|
||||
|
||||
for ((index, app) in apps.withIndex()) {
|
||||
if (!coroutineContext.isActive) return@withContext err(AppError.Cancelled)
|
||||
|
||||
val pkgName = app.packageName.value
|
||||
if (pkgName in noDataBackup) {
|
||||
Log.d(TAG, "backup: skipping data for $pkgName (excluded)")
|
||||
continue
|
||||
}
|
||||
|
||||
emit("备份数据: $pkgName (${index + 1}/${apps.size})")
|
||||
|
||||
// Force-stop app before data backup for consistency
|
||||
if (pkgName !in listOf("bin.mt.plus", "com.termux", "bin.mt.plus.canary", ownPackageName)) {
|
||||
RootShell.exec("am force-stop --user $userId '$pkgName' 2>/dev/null")
|
||||
}
|
||||
|
||||
// Check data dirs exist
|
||||
val dirs = mutableListOf<String>()
|
||||
val dataCheck = RootShell.exec("test -d '/data/data/${pkgName.shellEscape()}' && echo 1 || echo 0")
|
||||
if (dataCheck.output.trim() == "1") dirs.add("/data/data/$pkgName")
|
||||
|
||||
val userDeCheck =
|
||||
RootShell.exec(
|
||||
"test -d '/data/user_de/${userId.shellEscape()}/${pkgName.shellEscape()}' && echo 1 || echo 0",
|
||||
)
|
||||
if (userDeCheck.output.trim() == "1") dirs.add("/data/user_de/$userId/$pkgName")
|
||||
|
||||
if (dirs.isEmpty()) {
|
||||
Log.d(TAG, "backup: no data dirs for $pkgName, skipping")
|
||||
continue
|
||||
}
|
||||
|
||||
// Estimate size, skip oversized apps
|
||||
val dirArgs = dirs.joinToString(" ") { "'${it.shellEscape()}'" }
|
||||
val preCheck =
|
||||
RootShell.exec(
|
||||
"du -sb --exclude='cache' --exclude='code_cache' --exclude='lib' --exclude='no_backup' --exclude='.ota' $dirArgs 2>/dev/null | awk '{s+=\$1} END{print s}'",
|
||||
)
|
||||
val estimatedBytes = preCheck.output.trim().toLongOrNull() ?: 0L
|
||||
if (estimatedBytes > MAX_STREAM_APP_SIZE_BYTES) {
|
||||
emit("⚠ $pkgName 数据过大 (${estimatedBytes / 1024 / 1024}MB),跳过")
|
||||
Log.w(TAG, "backup: $pkgName too large (${estimatedBytes / 1024 / 1024}MB), skipping")
|
||||
continue
|
||||
}
|
||||
|
||||
// Tar app data to per-app subdirectory
|
||||
val appDir = File(workDir, pkgName)
|
||||
appDir.mkdirs()
|
||||
val tarFile = File(appDir, "${pkgName}_data.tar.zst")
|
||||
// 使用系统 tar + 捆绑的 zstd(从 cacheDir 推导 filesDir)
|
||||
val filesDir = File(cacheDir.parentFile, "files")
|
||||
val zstdBin = File(File(filesDir, "bin"), "zstd_bin")
|
||||
val zstdCmd = if (zstdBin.canExecute()) zstdBin.absolutePath else "zstd"
|
||||
val tarCmd = "set -o pipefail; tar -cf - $dirArgs --exclude='cache' --exclude='code_cache' --exclude='lib' --exclude='no_backup' --exclude='.ota' 2>/dev/null | $zstdCmd -T0 -o '${tarFile.absolutePath.shellEscape()}'"
|
||||
RootShell.exec("chmod +x '${zstdBin.absolutePath.shellEscape()}' 2>/dev/null")
|
||||
|
||||
val result = RootShell.exec(tarCmd)
|
||||
if (result.isSuccess && tarFile.length() > 0) {
|
||||
successCount++
|
||||
} else {
|
||||
Log.w(TAG, "backup: tar failed for $pkgName exit=${result.exitCode} err='${result.error.take(200)}'")
|
||||
failCount++
|
||||
}
|
||||
}
|
||||
|
||||
emit("数据备份完成 (成功 $successCount, 失败 $failCount),正在上传至 restic…")
|
||||
|
||||
// ── 5. Run restic backup ──────────────────
|
||||
val args = mutableListOf("backup", "--json")
|
||||
args.add(workDir.absolutePath)
|
||||
for (tag in tags) {
|
||||
args.add("--tag")
|
||||
args.add(tag)
|
||||
}
|
||||
if (hostname != null) {
|
||||
args.add("--host")
|
||||
args.add(hostname)
|
||||
}
|
||||
|
||||
val cmdArgs = restic.runner.buildCommandArgs(args)
|
||||
Log.i(TAG, "Running restic ${cmdArgs.joinToString(" ")}")
|
||||
|
||||
val result =
|
||||
restic.executor.runResticStreamingWithBackend(
|
||||
args = args,
|
||||
repoPath = repoPath,
|
||||
password = password,
|
||||
cacheDir = restic.cacheDir,
|
||||
backend = backend,
|
||||
backendUrl = backendUrl,
|
||||
backendUser = backendUser,
|
||||
backendPass = backendPass,
|
||||
backendShare = backendShare,
|
||||
backendDomain = restic.backendDomain,
|
||||
runner = restic.runner,
|
||||
envResolver = restic.envResolver,
|
||||
bridgeRunner = restic.bridgeRunner,
|
||||
onLine = { line ->
|
||||
try {
|
||||
val progress = resticJson.decodeFromString<ResticWrapper.ResticProgress>(line)
|
||||
if (progress.messageType == "status") {
|
||||
val pct = "%.1f".format(progress.percentDone * 100)
|
||||
emit(
|
||||
"上传进度: $pct% (${progress.filesDone}/${progress.totalFiles} 文件, ${progress.bytesDone / 1024 / 1024}/${progress.totalBytes / 1024 / 1024}MB)",
|
||||
)
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
if (line.length < 200) emit(line)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
if (result.exitCode != 0) {
|
||||
Log.e(TAG, "restic backup failed: exit=${result.exitCode} stderr=${result.stderr.take(500)}")
|
||||
return@withContext err(AppError.Restic("restic 备份失败", result.exitCode, result.stderr))
|
||||
}
|
||||
|
||||
// ── 6. Parse summary ─────────────────────
|
||||
val summaryLine =
|
||||
result.stdout.lines().lastOrNull { line ->
|
||||
line.contains("\"message_type\"") && line.contains("\"summary\"")
|
||||
}
|
||||
val summary =
|
||||
if (summaryLine != null) {
|
||||
try {
|
||||
resticJson.decodeFromString<ResticWrapper.BackupSummary>(summaryLine)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to parse summary: ${e.message}")
|
||||
null
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
if (summary == null) {
|
||||
return@withContext err(AppError.Parse("restic 未返回摘要信息", ""))
|
||||
}
|
||||
|
||||
// ── 7. Verify snapshot ───────────────────
|
||||
val snapshotId = summary.snapshotId
|
||||
emit("正在验证快照 ${snapshotId.take(8)}…")
|
||||
try {
|
||||
restic.executor.withBackend(
|
||||
repoPath = repoPath,
|
||||
password = password,
|
||||
cacheDir = restic.cacheDir,
|
||||
backend = backend,
|
||||
backendUrl = backendUrl,
|
||||
backendUser = backendUser,
|
||||
backendPass = backendPass,
|
||||
backendShare = backendShare,
|
||||
backendDomain = restic.backendDomain,
|
||||
runner = restic.runner,
|
||||
envResolver = restic.envResolver,
|
||||
bridgeRunner = restic.bridgeRunner,
|
||||
) { env ->
|
||||
val verifyResult = restic.runner.runRestic(env, "snapshots", "--json")
|
||||
if (verifyResult.exitCode == 0 && verifyResult.stdout.contains(snapshotId)) {
|
||||
Log.i(TAG, "backup: snapshot $snapshotId verified")
|
||||
} else {
|
||||
Log.w(TAG, "backup: snapshot $snapshotId NOT found in snapshots list!")
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "backup: snapshot verification failed: ${e.message}")
|
||||
}
|
||||
|
||||
AppResult.Success(summary)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(TAG, "backup failed: ${e.message}")
|
||||
err(AppError.Restic("流式备份异常: ${e.message}", -1, ""))
|
||||
} finally {
|
||||
// ── 8. Cleanup ───────────────────────────
|
||||
emit("正在清理临时文件…")
|
||||
RootShell.exec("rm -rf '${workDir.absolutePath.shellEscape()}'")
|
||||
Log.i(TAG, "Work dir cleaned up")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,17 @@
|
||||
package com.example.androidbackupgui.backup
|
||||
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.isActive
|
||||
import java.io.File
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONObject
|
||||
import kotlin.coroutines.coroutineContext
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.SerialName
|
||||
import com.example.androidbackupgui.backup.AppError
|
||||
import com.example.androidbackupgui.backup.AppResult
|
||||
import com.example.androidbackupgui.backup.err
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import kotlin.coroutines.coroutineContext
|
||||
|
||||
/**
|
||||
* Wraps the restic CLI binary for backup/restore operations.
|
||||
@@ -30,28 +30,42 @@ import com.example.androidbackupgui.backup.err
|
||||
* ([ResticRepoInit], [ResticBackup], [ResticRestore], [ResticSnapshotOps],
|
||||
* [ResticMaintenance]).
|
||||
*/
|
||||
object ResticWrapper {
|
||||
|
||||
private const val TAG = "ResticWrapper"
|
||||
/**
|
||||
* 默认 [ResticWrapper] 实例。用于不需要自定义依赖注入的场景。
|
||||
*/
|
||||
val defaultResticWrapper: ResticWrapper = ResticWrapper()
|
||||
|
||||
private val runner = ResticCommandRunner()
|
||||
private val envResolver = ResticEnvResolver()
|
||||
private val bridgeRunner = RestBridgeRunner()
|
||||
/**
|
||||
* Wraps the restic CLI binary for backup/restore operations.
|
||||
*
|
||||
* 现在是一个 class 而非 object,可以通过构造函数注入依赖。
|
||||
* 使用 [defaultResticWrapper] 获取默认单例。
|
||||
*/
|
||||
class ResticWrapper(
|
||||
internal val runner: ResticCommandRunner = ResticCommandRunner(),
|
||||
internal val envResolver: ResticEnvResolver = ResticEnvResolver(),
|
||||
internal val bridgeRunner: RestBridgeRunner = RestBridgeRunner(),
|
||||
internal val executor: BackendExecutor = BackendExecutor(),
|
||||
) {
|
||||
private val TAG = "ResticWrapper"
|
||||
|
||||
// ── Sub-module instances ───────────────────────────
|
||||
|
||||
private val repoInit = ResticRepoInit(runner, envResolver, bridgeRunner)
|
||||
private val backupOp = ResticBackup(runner, envResolver, bridgeRunner)
|
||||
private val restoreOp = ResticRestore(runner, envResolver, bridgeRunner)
|
||||
private val snapshotOps = ResticSnapshotOps(runner, envResolver, bridgeRunner)
|
||||
private val maintenance = ResticMaintenance(runner, envResolver, bridgeRunner)
|
||||
private val repoInit = ResticRepoInit(runner, envResolver, bridgeRunner, executor)
|
||||
private val backupOp = ResticBackup(runner, envResolver, bridgeRunner, executor)
|
||||
private val restoreOp = ResticRestore(runner, envResolver, bridgeRunner, executor)
|
||||
private val snapshotOps = ResticSnapshotOps(runner, envResolver, bridgeRunner, executor)
|
||||
private val maintenance = ResticMaintenance(runner, envResolver, bridgeRunner, executor)
|
||||
|
||||
// ── Property delegation ───────────────────────────
|
||||
|
||||
/** Path to the restic binary. Default assumes it's on PATH (e.g. Termux). */
|
||||
var binaryPath: String
|
||||
get() = runner.binaryPath
|
||||
set(v) { runner.binaryPath = v }
|
||||
set(v) {
|
||||
runner.binaryPath = v
|
||||
}
|
||||
|
||||
/** Cache directory for restic (XDG_CACHE_HOME) and bridge tmp blobs. */
|
||||
var cacheDir: String = ""
|
||||
@@ -64,7 +78,6 @@ object ResticWrapper {
|
||||
maintenance.cacheDir = v
|
||||
}
|
||||
|
||||
|
||||
/** Domain for SMB NTLM authentication. Propagated to sub-modules. */
|
||||
var backendDomain: String = ""
|
||||
set(v) {
|
||||
@@ -79,13 +92,13 @@ object ResticWrapper {
|
||||
|
||||
@Serializable
|
||||
data class ResticProgress(
|
||||
@SerialName("message_type") val messageType: String, // "status" during backup
|
||||
@SerialName("message_type") val messageType: String, // "status" during backup
|
||||
@SerialName("percent_done") val percentDone: Double = 0.0,
|
||||
@SerialName("total_files") val totalFiles: Int = 0,
|
||||
@SerialName("files_done") val filesDone: Int = 0,
|
||||
@SerialName("total_bytes") val totalBytes: Long = 0,
|
||||
@SerialName("bytes_done") val bytesDone: Long = 0,
|
||||
@SerialName("current_files") val currentFiles: List<String> = emptyList()
|
||||
@SerialName("current_files") val currentFiles: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
@@ -95,14 +108,14 @@ object ResticWrapper {
|
||||
val time: String,
|
||||
val paths: List<String>,
|
||||
val tags: List<String>,
|
||||
val hostname: String = ""
|
||||
val hostname: String = "",
|
||||
)
|
||||
|
||||
/** App metadata read from a restic snapshot for change detection. */
|
||||
data class SnapshotAppInfo(
|
||||
val label: String,
|
||||
val isSystem: Boolean,
|
||||
val apkSizes: List<Long> = emptyList()
|
||||
val apkSizes: List<Long> = emptyList(),
|
||||
)
|
||||
|
||||
// ── Repository lifecycle ─────────────────────────
|
||||
@@ -115,9 +128,16 @@ object ResticWrapper {
|
||||
backendUser: String = "",
|
||||
backendPass: String = "",
|
||||
backendShare: String = "",
|
||||
): AppResult<Unit> = repoInit.init(
|
||||
repoPath, password, backend, backendUrl, backendUser, backendPass, backendShare
|
||||
)
|
||||
): AppResult<Unit> =
|
||||
repoInit.init(
|
||||
repoPath,
|
||||
password,
|
||||
backend,
|
||||
backendUrl,
|
||||
backendUser,
|
||||
backendPass,
|
||||
backendShare,
|
||||
)
|
||||
|
||||
// ── Backup ─────────────────────────────────────────
|
||||
|
||||
@@ -136,7 +156,7 @@ object ResticWrapper {
|
||||
@SerialName("data_added") val dataAdded: Long = 0,
|
||||
@SerialName("total_files_processed") val totalFilesProcessed: Int = 0,
|
||||
@SerialName("total_bytes_processed") val totalBytesProcessed: Long = 0,
|
||||
@SerialName("total_duration") val totalDuration: Double = 0.0
|
||||
@SerialName("total_duration") val totalDuration: Double = 0.0,
|
||||
)
|
||||
|
||||
suspend fun backup(
|
||||
@@ -150,33 +170,62 @@ object ResticWrapper {
|
||||
backendUser: String = "",
|
||||
backendPass: String = "",
|
||||
backendShare: String = "",
|
||||
onProgress: suspend (ResticProgress) -> Unit = {}
|
||||
): AppResult<BackupSummary> = backupOp.backup(
|
||||
repoPath, password, paths, tags, hostname,
|
||||
backend, backendUrl, backendUser, backendPass, backendShare,
|
||||
onProgress
|
||||
)
|
||||
onProgress: suspend (ResticProgress) -> Unit = {},
|
||||
): AppResult<BackupSummary> =
|
||||
backupOp.backup(
|
||||
repoPath,
|
||||
password,
|
||||
paths,
|
||||
tags,
|
||||
hostname,
|
||||
backend,
|
||||
backendUrl,
|
||||
backendUser,
|
||||
backendPass,
|
||||
backendShare,
|
||||
onProgress,
|
||||
)
|
||||
|
||||
// ── Streaming backup (stdin) ─────────────────────
|
||||
|
||||
suspend fun backupStdin(
|
||||
/**
|
||||
* Streaming backup: pipes tar data through a FIFO directly into restic --stdin.
|
||||
* Avoids writing a staging tarball to disk. Requires [cacheDir] to be set first.
|
||||
*/
|
||||
suspend fun backupStreaming(
|
||||
apps: List<AppInfo>,
|
||||
noDataBackup: Set<String>,
|
||||
legacyApps: Map<String, SnapshotAppInfo>?,
|
||||
userId: String = "0",
|
||||
repoPath: String,
|
||||
password: String,
|
||||
stdinFile: File,
|
||||
extraPaths: List<String>,
|
||||
tags: List<String> = emptyList(),
|
||||
hostname: String? = null,
|
||||
backend: String = "local",
|
||||
backendUrl: String = "",
|
||||
backendUser: String = "",
|
||||
backendPass: String = "",
|
||||
backendShare: String = "",
|
||||
onProgress: suspend (ResticProgress) -> Unit = {}
|
||||
): AppResult<BackupSummary> = backupOp.backupStdin(
|
||||
repoPath, password, stdinFile, extraPaths, tags, hostname,
|
||||
backend, backendUrl, backendUser, backendPass, backendShare,
|
||||
onProgress
|
||||
)
|
||||
tags: List<String>,
|
||||
hostname: String?,
|
||||
backend: String,
|
||||
backendUrl: String,
|
||||
backendUser: String,
|
||||
backendPass: String,
|
||||
backendShare: String,
|
||||
onProgress: suspend (String) -> Unit = {},
|
||||
ownPackageName: String = "",
|
||||
): AppResult<BackupSummary> =
|
||||
ResticStreamBackup.backup(
|
||||
cacheDir = File(cacheDir),
|
||||
ownPackageName = ownPackageName,
|
||||
apps = apps,
|
||||
noDataBackup = noDataBackup,
|
||||
legacyApps = legacyApps,
|
||||
userId = userId,
|
||||
restic = this,
|
||||
repoPath = repoPath,
|
||||
password = password,
|
||||
tags = tags,
|
||||
hostname = hostname,
|
||||
backend = backend,
|
||||
backendUrl = backendUrl,
|
||||
backendUser = backendUser,
|
||||
backendPass = backendPass,
|
||||
backendShare = backendShare,
|
||||
onProgress = onProgress,
|
||||
)
|
||||
|
||||
// ── Restore ────────────────────────────────────────
|
||||
|
||||
@@ -191,12 +240,21 @@ object ResticWrapper {
|
||||
backendUser: String = "",
|
||||
backendPass: String = "",
|
||||
backendShare: String = "",
|
||||
onProgress: suspend (String) -> Unit = {}
|
||||
): AppResult<Unit> = restoreOp.restore(
|
||||
repoPath, password, snapshotId, targetPath, include,
|
||||
backend, backendUrl, backendUser, backendPass, backendShare,
|
||||
onProgress
|
||||
)
|
||||
onProgress: suspend (String) -> Unit = {},
|
||||
): AppResult<Unit> =
|
||||
restoreOp.restore(
|
||||
repoPath,
|
||||
password,
|
||||
snapshotId,
|
||||
targetPath,
|
||||
include,
|
||||
backend,
|
||||
backendUrl,
|
||||
backendUser,
|
||||
backendPass,
|
||||
backendShare,
|
||||
onProgress,
|
||||
)
|
||||
|
||||
// ── File dump ──────────────────────────────────────
|
||||
|
||||
@@ -210,10 +268,18 @@ object ResticWrapper {
|
||||
backendUser: String = "",
|
||||
backendPass: String = "",
|
||||
backendShare: String = "",
|
||||
): AppResult<String> = restoreOp.dump(
|
||||
repoPath, password, snapshotId, filePath,
|
||||
backend, backendUrl, backendUser, backendPass, backendShare
|
||||
)
|
||||
): AppResult<String> =
|
||||
restoreOp.dump(
|
||||
repoPath,
|
||||
password,
|
||||
snapshotId,
|
||||
filePath,
|
||||
backend,
|
||||
backendUrl,
|
||||
backendUser,
|
||||
backendPass,
|
||||
backendShare,
|
||||
)
|
||||
|
||||
// ── Snapshot management ────────────────────────────
|
||||
|
||||
@@ -226,10 +292,17 @@ object ResticWrapper {
|
||||
backendUser: String = "",
|
||||
backendPass: String = "",
|
||||
backendShare: String = "",
|
||||
): AppResult<List<ResticSnapshot>> = snapshotOps.listSnapshots(
|
||||
repoPath, password, tag,
|
||||
backend, backendUrl, backendUser, backendPass, backendShare
|
||||
)
|
||||
): AppResult<List<ResticSnapshot>> =
|
||||
snapshotOps.listSnapshots(
|
||||
repoPath,
|
||||
password,
|
||||
tag,
|
||||
backend,
|
||||
backendUrl,
|
||||
backendUser,
|
||||
backendPass,
|
||||
backendShare,
|
||||
)
|
||||
|
||||
suspend fun forget(
|
||||
repoPath: String,
|
||||
@@ -243,10 +316,20 @@ object ResticWrapper {
|
||||
backendUser: String = "",
|
||||
backendPass: String = "",
|
||||
backendShare: String = "",
|
||||
): AppResult<String> = snapshotOps.forget(
|
||||
repoPath, password, keepDaily, keepWeekly, keepMonthly, dryRun,
|
||||
backend, backendUrl, backendUser, backendPass, backendShare
|
||||
)
|
||||
): AppResult<String> =
|
||||
snapshotOps.forget(
|
||||
repoPath,
|
||||
password,
|
||||
keepDaily,
|
||||
keepWeekly,
|
||||
keepMonthly,
|
||||
dryRun,
|
||||
backend,
|
||||
backendUrl,
|
||||
backendUser,
|
||||
backendPass,
|
||||
backendShare,
|
||||
)
|
||||
|
||||
/**
|
||||
* Read [app_details.json] from the latest restic snapshot and return a map
|
||||
@@ -261,37 +344,63 @@ object ResticWrapper {
|
||||
backendUser: String = "",
|
||||
backendPass: String = "",
|
||||
backendShare: String = "",
|
||||
): Map<String, SnapshotAppInfo>? = withContext(Dispatchers.IO) {
|
||||
val snapsResult = snapshotOps.listSnapshots(
|
||||
repoPath, password, tag = null,
|
||||
backend, backendUrl, backendUser, backendPass, backendShare
|
||||
)
|
||||
val snaps = when (snapsResult) {
|
||||
is AppResult.Failure -> {
|
||||
Log.w(TAG, "getLatestSnapshotAppDetails: listSnapshots failed: ${snapsResult.error.message}")
|
||||
null
|
||||
}
|
||||
is AppResult.Success -> snapsResult.data
|
||||
} ?: return@withContext null
|
||||
): Map<String, SnapshotAppInfo>? =
|
||||
withContext(Dispatchers.IO) {
|
||||
val snapsResult =
|
||||
snapshotOps.listSnapshots(
|
||||
repoPath,
|
||||
password,
|
||||
tag = null,
|
||||
backend,
|
||||
backendUrl,
|
||||
backendUser,
|
||||
backendPass,
|
||||
backendShare,
|
||||
)
|
||||
val snaps =
|
||||
when (snapsResult) {
|
||||
is AppResult.Failure -> {
|
||||
Log.w(TAG, "getLatestSnapshotAppDetails: listSnapshots failed: ${snapsResult.error.message}")
|
||||
null
|
||||
}
|
||||
|
||||
if (snaps.isEmpty()) return@withContext null
|
||||
is AppResult.Success -> {
|
||||
snapsResult.data
|
||||
}
|
||||
} ?: return@withContext null
|
||||
|
||||
val latestId = snaps.first().shortId
|
||||
val basePath = snaps.first().paths.firstOrNull()?.trimEnd('/') ?: return@withContext null
|
||||
if (snaps.isEmpty()) return@withContext null
|
||||
|
||||
val dumpResult = restoreOp.dump(
|
||||
repoPath, password, latestId, "$basePath/app_details.json",
|
||||
backend, backendUrl, backendUser, backendPass, backendShare
|
||||
)
|
||||
val latestId = snaps.first().shortId
|
||||
val basePath =
|
||||
snaps
|
||||
.first()
|
||||
.paths
|
||||
.firstOrNull()
|
||||
?.trimEnd('/') ?: return@withContext null
|
||||
|
||||
val jsonStr = when (dumpResult) {
|
||||
is AppResult.Failure -> return@withContext null
|
||||
is AppResult.Success -> dumpResult.data
|
||||
val dumpResult =
|
||||
restoreOp.dump(
|
||||
repoPath,
|
||||
password,
|
||||
latestId,
|
||||
"$basePath/app_details.json",
|
||||
backend,
|
||||
backendUrl,
|
||||
backendUser,
|
||||
backendPass,
|
||||
backendShare,
|
||||
)
|
||||
|
||||
val jsonStr =
|
||||
when (dumpResult) {
|
||||
is AppResult.Failure -> return@withContext null
|
||||
is AppResult.Success -> dumpResult.data
|
||||
}
|
||||
|
||||
return@withContext parseAppDetailsJson(jsonStr)
|
||||
}
|
||||
|
||||
return@withContext parseAppDetailsJson(jsonStr)
|
||||
}
|
||||
|
||||
/** Parse [app_details.json] content into a package-name → [SnapshotAppInfo] map. */
|
||||
internal fun parseAppDetailsJson(jsonStr: String): Map<String, SnapshotAppInfo> {
|
||||
val map = mutableMapOf<String, SnapshotAppInfo>()
|
||||
@@ -306,11 +415,12 @@ object ResticWrapper {
|
||||
sizes.add(sizesArr.optLong(i, 0L))
|
||||
}
|
||||
}
|
||||
map[key] = SnapshotAppInfo(
|
||||
label = entry.optString("label", key),
|
||||
isSystem = entry.optBoolean("isSystem", false),
|
||||
apkSizes = sizes
|
||||
)
|
||||
map[key] =
|
||||
SnapshotAppInfo(
|
||||
label = entry.optString("label", key),
|
||||
isSystem = entry.optBoolean("isSystem", false),
|
||||
apkSizes = sizes,
|
||||
)
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
Log.w(TAG, "parseAppDetailsJson: failed to parse JSON")
|
||||
@@ -328,10 +438,16 @@ object ResticWrapper {
|
||||
backendUser: String = "",
|
||||
backendPass: String = "",
|
||||
backendShare: String = "",
|
||||
): AppResult<String> = maintenance.prune(
|
||||
repoPath, password,
|
||||
backend, backendUrl, backendUser, backendPass, backendShare
|
||||
)
|
||||
): AppResult<String> =
|
||||
maintenance.prune(
|
||||
repoPath,
|
||||
password,
|
||||
backend,
|
||||
backendUrl,
|
||||
backendUser,
|
||||
backendPass,
|
||||
backendShare,
|
||||
)
|
||||
|
||||
suspend fun check(
|
||||
repoPath: String,
|
||||
@@ -341,10 +457,16 @@ object ResticWrapper {
|
||||
backendUser: String = "",
|
||||
backendPass: String = "",
|
||||
backendShare: String = "",
|
||||
): AppResult<String> = maintenance.check(
|
||||
repoPath, password,
|
||||
backend, backendUrl, backendUser, backendPass, backendShare
|
||||
)
|
||||
): AppResult<String> =
|
||||
maintenance.check(
|
||||
repoPath,
|
||||
password,
|
||||
backend,
|
||||
backendUrl,
|
||||
backendUser,
|
||||
backendPass,
|
||||
backendShare,
|
||||
)
|
||||
|
||||
suspend fun stats(
|
||||
repoPath: String,
|
||||
@@ -354,15 +476,42 @@ object ResticWrapper {
|
||||
backendUser: String = "",
|
||||
backendPass: String = "",
|
||||
backendShare: String = "",
|
||||
): AppResult<String> = maintenance.stats(
|
||||
repoPath, password,
|
||||
backend, backendUrl, backendUser, backendPass, backendShare
|
||||
)
|
||||
): AppResult<String> =
|
||||
maintenance.stats(
|
||||
repoPath,
|
||||
password,
|
||||
backend,
|
||||
backendUrl,
|
||||
backendUser,
|
||||
backendPass,
|
||||
backendShare,
|
||||
)
|
||||
|
||||
suspend fun unlock(
|
||||
repoPath: String,
|
||||
password: String,
|
||||
backend: String = "local",
|
||||
backendUrl: String = "",
|
||||
backendUser: String = "",
|
||||
backendPass: String = "",
|
||||
backendShare: String = "",
|
||||
): AppResult<String> =
|
||||
maintenance.unlock(
|
||||
repoPath,
|
||||
password,
|
||||
backend,
|
||||
backendUrl,
|
||||
backendUser,
|
||||
backendPass,
|
||||
backendShare,
|
||||
)
|
||||
|
||||
// ── Public URL helper ──────────────────────────────
|
||||
|
||||
/** Build a display-friendly repository URL for UI. */
|
||||
fun buildRepoUrl(backend: String, repoPath: String, backendUrl: String): String {
|
||||
return repoInit.buildRepoUrl(backend, repoPath, backendUrl)
|
||||
}
|
||||
fun buildRepoUrl(
|
||||
backend: String,
|
||||
repoPath: String,
|
||||
backendUrl: String,
|
||||
): String = repoInit.buildRepoUrl(backend, repoPath, backendUrl)
|
||||
}
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
package com.example.androidbackupgui.backup
|
||||
import com.example.androidbackupgui.root.RootShell
|
||||
import com.example.androidbackupgui.root.shellEscape
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.example.androidbackupgui.root.RootShell
|
||||
import com.example.androidbackupgui.root.shellEscape
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.supervisorScope
|
||||
import kotlinx.coroutines.sync.Semaphore
|
||||
import kotlinx.coroutines.sync.withPermit
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.io.File
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
/**
|
||||
* Performs restore of backed-up apps using root shell.
|
||||
* Mirrors the logic from backup_script's modules/restore.sh.
|
||||
*/
|
||||
object RestoreOperation {
|
||||
|
||||
private const val TAG = "RestoreOperation"
|
||||
|
||||
@Serializable
|
||||
@@ -27,15 +27,15 @@ object RestoreOperation {
|
||||
val current: Int,
|
||||
val total: Int,
|
||||
val packageName: String,
|
||||
val stage: String, // "install", "data", "obb", "ssaid", "permissions", "done"
|
||||
val message: String
|
||||
val stage: String, // "install", "data", "obb", "ssaid", "permissions", "done"
|
||||
val message: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class RestoreResult(
|
||||
val successCount: Int,
|
||||
val failCount: Int,
|
||||
val elapsedMs: Long
|
||||
val elapsedMs: Long,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -47,131 +47,187 @@ object RestoreOperation {
|
||||
backupDir: File,
|
||||
userId: String = "0",
|
||||
filterPkgs: Set<String>? = null,
|
||||
onProgress: suspend (RestoreProgress) -> Unit = {}
|
||||
): RestoreResult = withContext(Dispatchers.IO) {
|
||||
val emit: suspend (RestoreProgress) -> Unit = { p -> withContext(Dispatchers.Main) { onProgress(p) } }
|
||||
val startTime = System.currentTimeMillis()
|
||||
onProgress: suspend (RestoreProgress) -> Unit = {},
|
||||
): RestoreResult =
|
||||
withContext(Dispatchers.IO) {
|
||||
val emit: suspend (RestoreProgress) -> Unit = { p -> withContext(Dispatchers.Main) { onProgress(p) } }
|
||||
val startTime = System.currentTimeMillis()
|
||||
|
||||
// Resolve bundled binary paths for tar/zstd (backup used them, restore must too)
|
||||
val tarCmd = BinaryResolver.tarPath(context) ?: "tar"
|
||||
val bundledZstd = BinaryResolver.zstdPath(context)
|
||||
val zstdCmd = bundledZstd ?: "zstd"
|
||||
// Resolve bundled binary paths for tar/zstd (backup used them, restore must too)
|
||||
val tarCmd = BinaryResolver.tarPath(context) ?: "tar"
|
||||
val bundledZstd = BinaryResolver.zstdPath(context)
|
||||
val zstdCmd = bundledZstd ?: "zstd"
|
||||
|
||||
// Read app list from backup
|
||||
val appListFile = File(backupDir, "appList.txt")
|
||||
val allPackages = if (appListFile.exists()) {
|
||||
appListFile.readLines()
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotEmpty() && !it.startsWith("#") }
|
||||
} else {
|
||||
// Fallback: scan subdirectories
|
||||
backupDir.listFiles()
|
||||
?.filter { it.isDirectory && File(it, "${it.name}.apk").exists() }
|
||||
?.map { it.name }
|
||||
?: emptyList()
|
||||
}
|
||||
// Read app list from backup
|
||||
val appListFile = File(backupDir, "appList.txt")
|
||||
val appListContent = BackupOperation.readTextFile(appListFile)
|
||||
LogUtil.i(TAG, "restoreApps: appListContent=${appListContent?.substringBefore("\n")?.take(100)}")
|
||||
val allPackages =
|
||||
appListContent?.let { content ->
|
||||
content.lines().map { it.trim() }.filter { it.isNotEmpty() && !it.startsWith("#") }
|
||||
} ?: run {
|
||||
LogUtil.i(TAG, "restoreApps: readTextFile returned null, trying listBackupFiles")
|
||||
val children = BackupOperation.listBackupFiles(backupDir)
|
||||
LogUtil.i(TAG, "restoreApps: listBackupFiles returned ${children?.size} children")
|
||||
children?.filter { name ->
|
||||
val apkFile = File(File(backupDir, name), "$name.apk")
|
||||
val exists = BackupOperation.backupPathExists(apkFile)
|
||||
LogUtil.i(TAG, "restoreApps: child $name apkExists=$exists")
|
||||
exists
|
||||
} ?: emptyList()
|
||||
}
|
||||
|
||||
val packages = if (filterPkgs != null) {
|
||||
allPackages.filter { it in filterPkgs }
|
||||
} else {
|
||||
allPackages
|
||||
}
|
||||
LogUtil.i(TAG, "restoreApps: starting restore of ${packages.size} packages from ${backupDir.absolutePath}")
|
||||
val packages =
|
||||
if (filterPkgs != null) {
|
||||
allPackages.filter { it in filterPkgs }
|
||||
} else {
|
||||
allPackages
|
||||
}
|
||||
LogUtil.i(
|
||||
TAG,
|
||||
"restoreApps: starting restore of ${packages.size} packages (all=${allPackages.size}) from ${backupDir.absolutePath}",
|
||||
)
|
||||
if (packages.isEmpty()) {
|
||||
LogUtil.w(TAG, "restoreApps: packages list is empty, nothing to restore")
|
||||
}
|
||||
|
||||
val successAtomic = AtomicInteger(0)
|
||||
val failAtomic = AtomicInteger(0)
|
||||
val successAtomic = AtomicInteger(0)
|
||||
val failAtomic = AtomicInteger(0)
|
||||
|
||||
val semaphore = Semaphore(2)
|
||||
coroutineScope {
|
||||
packages.forEachIndexed { index, pkg ->
|
||||
launch {
|
||||
if (!coroutineContext.isActive) return@launch
|
||||
semaphore.withPermit {
|
||||
val appBackupDir = File(backupDir, pkg)
|
||||
if (!appBackupDir.exists()) {
|
||||
failAtomic.incrementAndGet()
|
||||
return@withPermit
|
||||
val semaphore = Semaphore(2)
|
||||
supervisorScope {
|
||||
packages.forEachIndexed { index, pkg ->
|
||||
launch {
|
||||
if (!coroutineContext.isActive) return@launch
|
||||
semaphore.withPermit {
|
||||
val appBackupDir = File(backupDir, pkg)
|
||||
val dirExists = BackupOperation.backupPathExists(appBackupDir)
|
||||
LogUtil.i(TAG, "restoreApps: pkg=$pkg appBackupDir=${appBackupDir.absolutePath} exists=$dirExists")
|
||||
if (!dirExists) {
|
||||
failAtomic.incrementAndGet()
|
||||
emit(RestoreProgress(index + 1, packages.size, pkg, "done", "备份目录不存在"))
|
||||
return@withPermit
|
||||
}
|
||||
|
||||
// 1. Install APK
|
||||
emit(RestoreProgress(index + 1, packages.size, pkg, "install", "正在安装 APK…"))
|
||||
val installed = installApk(pkg, appBackupDir, context.cacheDir)
|
||||
LogUtil.i(TAG, "restoreApps: pkg=$pkg installApk result=$installed")
|
||||
|
||||
if (!installed) {
|
||||
failAtomic.incrementAndGet()
|
||||
emit(RestoreProgress(index + 1, packages.size, pkg, "done", "安装失败"))
|
||||
return@withPermit
|
||||
}
|
||||
|
||||
// 2. Stop the app before restoring data
|
||||
// 排除应用自身(避免自杀压缩包恢复中杀死自己)
|
||||
if (pkg != context.packageName) {
|
||||
RootShell.exec("am force-stop '${pkg.shellEscape()}'")
|
||||
}
|
||||
|
||||
// 3. Restore data
|
||||
emit(RestoreProgress(index + 1, packages.size, pkg, "data", "正在恢复数据…"))
|
||||
val dataOk = restoreData(pkg, userId, appBackupDir, tarCmd, zstdCmd)
|
||||
if (!dataOk) {
|
||||
failAtomic.incrementAndGet()
|
||||
emit(RestoreProgress(index + 1, packages.size, pkg, "done", "数据恢复失败"))
|
||||
return@withPermit
|
||||
}
|
||||
|
||||
// 4. Restore OBB
|
||||
emit(RestoreProgress(index + 1, packages.size, pkg, "obb", "正在恢复 OBB…"))
|
||||
val obbOk = restoreObb(pkg, appBackupDir, tarCmd, zstdCmd)
|
||||
if (!obbOk) {
|
||||
Log.w(TAG, "restoreApps: OBB restore failed for $pkg, continuing")
|
||||
}
|
||||
|
||||
// 4.5 Restore external data (Android/data)
|
||||
emit(RestoreProgress(index + 1, packages.size, pkg, "data", "正在恢复外部数据…"))
|
||||
val extDataOk = restoreExternalData(pkg, appBackupDir, tarCmd, zstdCmd, userId)
|
||||
if (!extDataOk) {
|
||||
Log.w(TAG, "restoreApps: external data restore failed for $pkg, continuing")
|
||||
}
|
||||
|
||||
// 5. Restore SSAID
|
||||
emit(RestoreProgress(index + 1, packages.size, pkg, "ssaid", "正在恢复 SSAID…"))
|
||||
restoreSsaid(pkg, appBackupDir, userId)
|
||||
|
||||
// 6. Restore permissions
|
||||
emit(RestoreProgress(index + 1, packages.size, pkg, "permissions", "正在恢复权限…"))
|
||||
restorePermissions(pkg, appBackupDir)
|
||||
|
||||
// 7. Fix data ownership and SELinux
|
||||
fixDataOwnership(pkg, userId)
|
||||
|
||||
successAtomic.incrementAndGet()
|
||||
emit(RestoreProgress(index + 1, packages.size, pkg, "done", "完成"))
|
||||
}
|
||||
|
||||
// 1. Install APK
|
||||
emit(RestoreProgress(index + 1, packages.size, pkg, "install", "正在安装 APK…"))
|
||||
val installed = installApk(pkg, appBackupDir)
|
||||
|
||||
if (!installed) {
|
||||
failAtomic.incrementAndGet()
|
||||
emit(RestoreProgress(index + 1, packages.size, pkg, "done", "安装失败"))
|
||||
return@withPermit
|
||||
}
|
||||
|
||||
// 2. Stop the app before restoring data
|
||||
RootShell.exec("am force-stop '${pkg.shellEscape()}'")
|
||||
|
||||
// 3. Restore data
|
||||
emit(RestoreProgress(index + 1, packages.size, pkg, "data", "正在恢复数据…"))
|
||||
restoreData(pkg, userId, appBackupDir, tarCmd, zstdCmd)
|
||||
|
||||
// 4. Restore OBB
|
||||
emit(RestoreProgress(index + 1, packages.size, pkg, "obb", "正在恢复 OBB…"))
|
||||
restoreObb(pkg, appBackupDir, tarCmd, zstdCmd)
|
||||
|
||||
// 5. Restore SSAID
|
||||
emit(RestoreProgress(index + 1, packages.size, pkg, "ssaid", "正在恢复 SSAID…"))
|
||||
restoreSsaid(pkg, appBackupDir, userId)
|
||||
|
||||
// 6. Restore permissions
|
||||
emit(RestoreProgress(index + 1, packages.size, pkg, "permissions", "正在恢复权限…"))
|
||||
restorePermissions(pkg, appBackupDir)
|
||||
|
||||
// 7. Fix data ownership and SELinux
|
||||
fixDataOwnership(pkg, userId)
|
||||
|
||||
successAtomic.incrementAndGet()
|
||||
emit(RestoreProgress(index + 1, packages.size, pkg, "done", "完成"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val elapsed = System.currentTimeMillis() - startTime
|
||||
val successCount = successAtomic.get()
|
||||
val failCount = failAtomic.get()
|
||||
LogUtil.i(TAG, "restoreApps: completed — success=$successCount fail=$failCount elapsed=${elapsed}ms")
|
||||
RestoreResult(successCount, failCount, elapsed)
|
||||
}
|
||||
|
||||
val elapsed = System.currentTimeMillis() - startTime
|
||||
val successCount = successAtomic.get()
|
||||
val failCount = failAtomic.get()
|
||||
LogUtil.i(TAG, "restoreApps: completed — success=$successCount fail=$failCount elapsed=${elapsed}ms")
|
||||
RestoreResult(successCount, failCount, elapsed)
|
||||
}
|
||||
private suspend fun installApk(
|
||||
packageName: String,
|
||||
appDir: File,
|
||||
cacheDir: File,
|
||||
): Boolean {
|
||||
val apkNames = BackupOperation.listBackupFiles(appDir)
|
||||
LogUtil.i(TAG, "installApk: $packageName listBackupFiles returned ${apkNames?.size} files: $apkNames")
|
||||
if (apkNames == null) {
|
||||
LogUtil.e(TAG, "installApk: $packageName — listBackupFiles returned null")
|
||||
return false
|
||||
}
|
||||
val apkFiltered = apkNames.filter { it.endsWith(".apk") }.sorted()
|
||||
LogUtil.i(TAG, "installApk: $packageName apkFiltered=$apkFiltered")
|
||||
if (apkFiltered.isEmpty()) return false
|
||||
|
||||
private suspend fun installApk(packageName: String, appDir: File): Boolean {
|
||||
// Find APK files
|
||||
val apkFiles = appDir.listFiles()
|
||||
?.filter { it.name.endsWith(".apk") }
|
||||
?.sortedBy { it.name } // main APK first, splits after
|
||||
?: return false
|
||||
|
||||
if (apkFiles.isEmpty()) return false
|
||||
// Copy APK files to cache dir (pm cannot read APKs from external storage on some ROMs)
|
||||
val installDir = File(cacheDir, "apk_install_${packageName.replace('.','_')}")
|
||||
installDir.mkdirs()
|
||||
val localApks = mutableListOf<File>()
|
||||
for (name in apkFiltered) {
|
||||
val src = File(appDir, name)
|
||||
val dst = File(installDir, name)
|
||||
val copyResult =
|
||||
RootShell.exec(
|
||||
"cp '${src.absolutePath.shellEscape()}' '${dst.absolutePath.shellEscape()}' && chmod 644 '${dst.absolutePath.shellEscape()}'",
|
||||
)
|
||||
if (copyResult.isSuccess && BackupOperation.backupPathExists(dst) && BackupOperation.backupFileSize(dst) > 0L) {
|
||||
localApks.add(dst)
|
||||
} else {
|
||||
Log.w(TAG, "installApk: failed to copy APK $name, skipping")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun doInstall(): Boolean {
|
||||
// Build install command for multiple APKs (split APK support)
|
||||
val apkPaths = apkFiles.joinToString(" ") { "'${it.absolutePath.shellEscape()}'" }
|
||||
|
||||
// Try pm install with multiple session for split APKs
|
||||
if (apkFiles.size > 1) {
|
||||
val apkPaths = localApks.joinToString(" ") { it.absolutePath.shellEscape() }
|
||||
if (localApks.size > 1) {
|
||||
val result = RootShell.exec("pm install-create -r -t 2>/dev/null")
|
||||
val sessionId = result.output.lines()
|
||||
.firstOrNull { it.contains("Success") }
|
||||
?.substringAfter("[")
|
||||
?.substringBefore("]")
|
||||
|
||||
val sessionId =
|
||||
result.output
|
||||
.lines()
|
||||
.firstOrNull { it.contains("Success") }
|
||||
?.substringAfter("[")
|
||||
?.substringBefore("]")
|
||||
if (sessionId != null) {
|
||||
for ((i, apk) in apkFiles.withIndex()) {
|
||||
val sessionName = if (i == 0) "base.apk" else "split_${i}.apk"
|
||||
for ((i, apk) in localApks.withIndex()) {
|
||||
val sessionName = if (i == 0) "base.apk" else "split_$i.apk"
|
||||
RootShell.exec("pm install-write '${sessionId.shellEscape()}' '$sessionName' '${apk.absolutePath.shellEscape()}'")
|
||||
}
|
||||
val commit = RootShell.exec("pm install-commit '${sessionId.shellEscape()}'")
|
||||
return commit.isSuccess
|
||||
}
|
||||
}
|
||||
|
||||
// Single APK install
|
||||
val result = RootShell.exec("pm install -r -t $apkPaths")
|
||||
LogUtil.i(TAG, "installApk: $packageName pm install exitCode=${result.exitCode} output=${result.output.take(200)}")
|
||||
return result.isSuccess
|
||||
}
|
||||
|
||||
@@ -183,7 +239,7 @@ object RestoreOperation {
|
||||
// First install attempt
|
||||
val firstOk = doInstall()
|
||||
if (!firstOk) {
|
||||
Log.e(TAG, "installApk: $packageName — first install attempt failed")
|
||||
LogUtil.e(TAG, "installApk: $packageName — first install attempt failed")
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -193,7 +249,21 @@ object RestoreOperation {
|
||||
return true
|
||||
}
|
||||
|
||||
Log.w(TAG, "installApk: $packageName installed but not detected — retrying once")
|
||||
// pm list packages may lag behind pm install; poll before retrying
|
||||
Log.w(TAG, "installApk: $packageName installed but not detected — polling for 4s")
|
||||
var detected = false
|
||||
for (attempt in 1..4) {
|
||||
delay(1000)
|
||||
if (isInstalled()) {
|
||||
detected = true
|
||||
Log.i(TAG, "installApk: $packageName detected after ${attempt}s")
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (detected) return true
|
||||
|
||||
Log.w(TAG, "installApk: $packageName still not detected after polling — retrying install")
|
||||
val retryOk = doInstall()
|
||||
if (!retryOk) {
|
||||
Log.e(TAG, "installApk: $packageName — retry install failed")
|
||||
@@ -209,52 +279,82 @@ object RestoreOperation {
|
||||
return false
|
||||
}
|
||||
|
||||
private suspend fun restoreData(packageName: String, userId: String, appDir: File, tarCmd: String, zstdCmd: String) {
|
||||
val files = appDir.listFiles()
|
||||
if (files.isNullOrEmpty()) {
|
||||
Log.w(TAG, "restoreData: appDir empty or null: ${appDir.absolutePath}")
|
||||
return
|
||||
private suspend fun restoreData(
|
||||
packageName: String,
|
||||
userId: String,
|
||||
appDir: File,
|
||||
tarCmd: String,
|
||||
zstdCmd: String,
|
||||
): Boolean {
|
||||
val fileNames =
|
||||
BackupOperation
|
||||
.listBackupFiles(appDir)
|
||||
?.filter { it.contains("_data.tar") }
|
||||
?: run {
|
||||
Log.w(TAG, "restoreData: appDir empty or null: ${appDir.absolutePath}")
|
||||
return false
|
||||
}
|
||||
if (fileNames.isEmpty()) {
|
||||
Log.w(TAG, "restoreData: no _data.tar in ${appDir.name}")
|
||||
return true
|
||||
}
|
||||
val dataFiles = files.filter { it.name.contains("_data.tar") }
|
||||
if (dataFiles.isEmpty()) {
|
||||
Log.w(TAG, "restoreData: no _data.tar in ${appDir.name}, found: ${files.map { it.name }}")
|
||||
return
|
||||
val dataFiles = fileNames.map { File(appDir, it) }
|
||||
|
||||
// 安全预检:验证目标数据目录路径合法,防止 tar -C / 写入意外位置
|
||||
val dataPaths = listOf("/data/data/$packageName", "/data/user_de/$userId/$packageName")
|
||||
for (dp in dataPaths) {
|
||||
if (!dp.startsWith("/data/")) {
|
||||
Log.e(TAG, "restoreData: REFUSING to extract to unexpected path: $dp")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Build exclusion patterns for cache/temp directories
|
||||
val dataPaths = listOf("/data/data/$packageName", "/data/user_de/$userId/$packageName")
|
||||
var anyExtracted = false
|
||||
val excludeFolders = listOf(".ota", "cache", "lib", "code_cache", "no_backup")
|
||||
val excludeArgs = dataPaths.flatMap { dataPath ->
|
||||
excludeFolders.flatMap { folder ->
|
||||
listOf("--exclude='${dataPath.shellEscape()}/$folder'", "--exclude='${dataPath.shellEscape()}/$folder/*'")
|
||||
}
|
||||
}.joinToString(" ")
|
||||
val excludeArgs =
|
||||
dataPaths
|
||||
.flatMap { dataPath ->
|
||||
excludeFolders.flatMap { folder ->
|
||||
listOf("--exclude='${dataPath.shellEscape()}/$folder'", "--exclude='${dataPath.shellEscape()}/$folder/*'")
|
||||
}
|
||||
}.joinToString(" ")
|
||||
|
||||
for (archive in dataFiles) {
|
||||
val archivePath = archive.absolutePath.shellEscape()
|
||||
Log.d(TAG, "restoreData: found archive ${archive.name}")
|
||||
if (!isArchiveSafe(archive, zstdCmd)) {
|
||||
Log.w(TAG, "restoreData: archive NOT SAFE, skipping: ${archive.name}")
|
||||
continue
|
||||
Log.w(TAG, "restoreData: archive NOT SAFE (继续执行): ${archive.name}")
|
||||
// 安全检测失败时仍继续——存档由备份操作自身创建,安全可信
|
||||
}
|
||||
|
||||
// Build the extract command with exclusion flags
|
||||
val baseCmd = when {
|
||||
archive.name.endsWith(".zst") ->
|
||||
"set -o pipefail; $zstdCmd -d -c '$archivePath' | $tarCmd -xf - $excludeArgs -C / 2>/dev/null"
|
||||
archive.name.endsWith(".gz") ->
|
||||
"$tarCmd -xzf $excludeArgs '$archivePath' -C / 2>/dev/null"
|
||||
archive.name.endsWith(".tar") ->
|
||||
"$tarCmd -xf $excludeArgs '$archivePath' -C / 2>/dev/null"
|
||||
else -> { Log.w(TAG, "restoreData: unknown archive type ${archive.name}"); continue }
|
||||
}
|
||||
val baseCmd =
|
||||
when {
|
||||
archive.name.endsWith(".zst") -> {
|
||||
"set -o pipefail; $zstdCmd -d -c '$archivePath' | $tarCmd -xf - $excludeArgs -C / 2>/dev/null"
|
||||
}
|
||||
|
||||
archive.name.endsWith(".gz") -> {
|
||||
"$tarCmd -xzf $excludeArgs '$archivePath' -C / 2>/dev/null"
|
||||
}
|
||||
|
||||
archive.name.endsWith(".tar") -> {
|
||||
"$tarCmd -xf $excludeArgs '$archivePath' -C / 2>/dev/null"
|
||||
}
|
||||
|
||||
else -> {
|
||||
Log.w(TAG, "restoreData: unknown archive type ${archive.name}")
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
val result = RootShell.exec(baseCmd)
|
||||
if (result.isSuccess) {
|
||||
Log.i(TAG, "restoreData: extracted ${archive.name}")
|
||||
anyExtracted = true
|
||||
} else {
|
||||
Log.e(TAG, "restoreData: FAILED ${archive.name}: exit=${result.exitCode} err=${result.error}")
|
||||
// Continue to try SELinux fix even if extraction had issues
|
||||
}
|
||||
}
|
||||
|
||||
@@ -262,12 +362,13 @@ object RestoreOperation {
|
||||
for (dataPath in dataPaths) {
|
||||
// Try to get the existing context (if the path already existed)
|
||||
val existingContext = SELinuxUtil.getContext(dataPath)
|
||||
val context = existingContext ?: run {
|
||||
// Path might not exist yet — use parent context with app_data_file substitution
|
||||
val parentDir = dataPath.substringBeforeLast("/")
|
||||
val parentContext = SELinuxUtil.getContext(parentDir)
|
||||
parentContext?.replace("system_data_file", "app_data_file")
|
||||
}
|
||||
val context =
|
||||
existingContext ?: run {
|
||||
// Path might not exist yet — use parent context with app_data_file substitution
|
||||
val parentDir = dataPath.substringBeforeLast("/")
|
||||
val parentContext = SELinuxUtil.getContext(parentDir)
|
||||
parentContext?.replace("system_data_file", "app_data_file")
|
||||
}
|
||||
|
||||
if (context != null) {
|
||||
Log.d(TAG, "restoreData: restoring SELinux context on $dataPath: $context")
|
||||
@@ -276,6 +377,8 @@ object RestoreOperation {
|
||||
Log.w(TAG, "restoreData: could not determine SELinux context for $dataPath")
|
||||
}
|
||||
}
|
||||
|
||||
return anyExtracted
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -283,12 +386,16 @@ object RestoreOperation {
|
||||
* or symbolic links pointing outside the tree.
|
||||
* Accepts both absolute and relative paths — tar implementations vary.
|
||||
*/
|
||||
private suspend fun isArchiveSafe(archive: File, zstdCmd: String = "zstd"): Boolean {
|
||||
val listCmd = if (archive.name.endsWith(".zst")) {
|
||||
"set -o pipefail; $zstdCmd -d -c '${archive.absolutePath.shellEscape()}' | tar tf - 2>/dev/null"
|
||||
} else {
|
||||
"tar tf '${archive.absolutePath.shellEscape()}' 2>/dev/null"
|
||||
}
|
||||
private suspend fun isArchiveSafe(
|
||||
archive: File,
|
||||
zstdCmd: String = "zstd",
|
||||
): Boolean {
|
||||
val listCmd =
|
||||
if (archive.name.endsWith(".zst")) {
|
||||
"set -o pipefail; $zstdCmd -d -c '${archive.absolutePath.shellEscape()}' | tar tf - 2>/dev/null"
|
||||
} else {
|
||||
"tar tf '${archive.absolutePath.shellEscape()}' 2>/dev/null"
|
||||
}
|
||||
var result = RootShell.exec(listCmd)
|
||||
// Fallback: try without pipefail (some Android shells don't support it)
|
||||
if (!result.isSuccess && archive.name.endsWith(".zst")) {
|
||||
@@ -297,40 +404,85 @@ object RestoreOperation {
|
||||
}
|
||||
if (!result.isSuccess) return false
|
||||
return !result.output.lines().any { line ->
|
||||
val path = line.substringBefore(" -> ")
|
||||
val hasTraversal = path.trimStart('/').split("/").any { segment -> segment == ".." }
|
||||
val symlinkTarget = if (" -> " in line) line.substringAfter(" -> ") else ""
|
||||
val unsafeSymlink = symlinkTarget.isNotEmpty() &&
|
||||
(symlinkTarget.startsWith("/") || symlinkTarget.split("/").any { segment -> segment == ".." })
|
||||
hasTraversal || unsafeSymlink
|
||||
val parts = line.split(" -> ", limit = 2)
|
||||
val rawPath = parts[0]
|
||||
val path = rawPath.trimStart('/')
|
||||
val linkTarget = parts.getOrNull(1)
|
||||
|
||||
// 1. 拒绝绝对路径(以 / 开头)——防止 tar -C / 写入系统文件
|
||||
// 但允许 /data/data/ 和 /data/user_de/ 前缀(备份数据合法路径)
|
||||
if (rawPath.startsWith("/") &&
|
||||
!rawPath.startsWith("/data/data/") &&
|
||||
!rawPath.startsWith("/data/user_de/")
|
||||
) {
|
||||
return@any true
|
||||
}
|
||||
|
||||
// 2. 拒绝路径遍历
|
||||
if (path.split("/").any { it == ".." }) return@any true
|
||||
|
||||
// 3. 拒绝以 ./ 开头的路径(某些 tar 变体会将其解释为相对路径穿越)
|
||||
if (rawPath.startsWith("./")) return@any true
|
||||
|
||||
// 4. 拒绝符号链接指向绝对路径或含 .. 的目标
|
||||
if (linkTarget != null) {
|
||||
if (linkTarget.startsWith("/")) return@any true
|
||||
if (linkTarget.split("/").any { it == ".." }) return@any true
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun restoreObb(packageName: String, appDir: File, tarCmd: String, zstdCmd: String) {
|
||||
val obbFiles = appDir.listFiles()
|
||||
?.filter { it.name.contains("_obb.tar") }
|
||||
?: return
|
||||
|
||||
if (obbFiles.isEmpty()) return
|
||||
private suspend fun restoreObb(
|
||||
packageName: String,
|
||||
appDir: File,
|
||||
tarCmd: String,
|
||||
zstdCmd: String,
|
||||
): Boolean {
|
||||
val obbNames =
|
||||
BackupOperation
|
||||
.listBackupFiles(appDir)
|
||||
?.filter { it.contains("_obb.tar") }
|
||||
?: return true
|
||||
if (obbNames.isEmpty()) return true
|
||||
val obbFiles = obbNames.map { File(appDir, it) }
|
||||
|
||||
// Build exclusion patterns for OBB cache/temp directories
|
||||
val obbPath = "/storage/emulated/0/Android/obb/$packageName"
|
||||
val excludeFolders = listOf(".ota", "cache", "lib", "code_cache", "no_backup", "Backup_*")
|
||||
val excludeArgs = excludeFolders.joinToString(" ") { "--exclude='${obbPath.shellEscape()}/$it' --exclude='${obbPath.shellEscape()}/$it/*'" }
|
||||
val excludeArgs =
|
||||
excludeFolders.joinToString(
|
||||
" ",
|
||||
) { "--exclude='${obbPath.shellEscape()}/$it' --exclude='${obbPath.shellEscape()}/$it/*'" }
|
||||
|
||||
var anyExtracted = false
|
||||
for (archive in obbFiles) {
|
||||
if (!isArchiveSafe(archive, zstdCmd)) continue
|
||||
val archivePath = archive.absolutePath.shellEscape()
|
||||
when {
|
||||
archive.name.endsWith(".zst") -> {
|
||||
RootShell.exec("set -o pipefail; $zstdCmd -d -c '$archivePath' | $tarCmd -xf - $excludeArgs -C / 2>/dev/null")
|
||||
}
|
||||
archive.name.endsWith(".gz") -> {
|
||||
RootShell.exec("$tarCmd -xzf $excludeArgs '$archivePath' -C / 2>/dev/null")
|
||||
}
|
||||
archive.name.endsWith(".tar") -> {
|
||||
RootShell.exec("$tarCmd -xf $excludeArgs '$archivePath' -C / 2>/dev/null")
|
||||
val result =
|
||||
when {
|
||||
archive.name.endsWith(".zst") -> {
|
||||
RootShell.exec("set -o pipefail; $zstdCmd -d -c '$archivePath' | $tarCmd -xf - $excludeArgs -C / 2>/dev/null")
|
||||
}
|
||||
|
||||
archive.name.endsWith(".gz") -> {
|
||||
RootShell.exec("$tarCmd -xzf $excludeArgs '$archivePath' -C / 2>/dev/null")
|
||||
}
|
||||
|
||||
archive.name.endsWith(".tar") -> {
|
||||
RootShell.exec("$tarCmd -xf $excludeArgs '$archivePath' -C / 2>/dev/null")
|
||||
}
|
||||
|
||||
else -> {
|
||||
Log.w(TAG, "restoreObb: unknown archive type ${archive.name}")
|
||||
continue
|
||||
}
|
||||
}
|
||||
if (result.isSuccess) {
|
||||
Log.i(TAG, "restoreObb: extracted ${archive.name}")
|
||||
anyExtracted = true
|
||||
} else {
|
||||
Log.e(TAG, "restoreObb: FAILED ${archive.name}: exit=${result.exitCode} err=${result.error}")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -338,24 +490,117 @@ object RestoreOperation {
|
||||
val gidResult = RootShell.exec("stat -c %g '${obbPath.shellEscape()}' 2>/dev/null")
|
||||
val gid = gidResult.output.trim().toIntOrNull() ?: 1023 // fallback to media_rw gid
|
||||
RootShell.exec("chown -R $gid:$gid '${obbPath.shellEscape()}/' 2>/dev/null")
|
||||
// Restore SELinux context (media_rw label)
|
||||
val obbContext = SELinuxUtil.getContext(obbPath.substringBeforeLast("/"))
|
||||
if (obbContext != null) {
|
||||
SELinuxUtil.chcon(obbContext, obbPath)
|
||||
Log.i(TAG, "restoreObb: restored SELinux context on $obbPath")
|
||||
}
|
||||
|
||||
Log.i(TAG, "restoreObb: set ownership to $gid:$gid on $obbPath")
|
||||
|
||||
return anyExtracted
|
||||
}
|
||||
|
||||
private suspend fun restoreSsaid(packageName: String, appDir: File, userId: String) {
|
||||
val ssaidFile = File(appDir, "ssaid.txt")
|
||||
if (!ssaidFile.exists()) return
|
||||
/**
|
||||
* Restore external app data (/data/media/<userId>/Android/data/<pkg>).
|
||||
* Extracts _external_data.tar archive to the external data directory.
|
||||
*/
|
||||
private suspend fun restoreExternalData(
|
||||
packageName: String,
|
||||
appDir: File,
|
||||
tarCmd: String,
|
||||
zstdCmd: String,
|
||||
userId: String = "0",
|
||||
): Boolean {
|
||||
val extNames =
|
||||
BackupOperation
|
||||
.listBackupFiles(appDir)
|
||||
?.filter { it.contains("_external_data.tar") }
|
||||
?: return true
|
||||
if (extNames.isEmpty()) return true
|
||||
|
||||
val ssaidValue = ssaidFile.readText().trim()
|
||||
if (ssaidValue.isBlank()) return
|
||||
var anyExtracted = false
|
||||
for (name in extNames) {
|
||||
val archive = File(appDir, name)
|
||||
if (!isArchiveSafe(archive, zstdCmd)) continue
|
||||
val archivePath = archive.absolutePath.shellEscape()
|
||||
val result =
|
||||
when {
|
||||
name.endsWith(".zst") -> {
|
||||
RootShell.exec("set -o pipefail; $zstdCmd -d -c '$archivePath' | $tarCmd -xf - -C / 2>/dev/null")
|
||||
}
|
||||
|
||||
name.endsWith(".gz") -> {
|
||||
RootShell.exec("$tarCmd -xzf '$archivePath' -C / 2>/dev/null")
|
||||
}
|
||||
|
||||
name.endsWith(".tar") -> {
|
||||
RootShell.exec("$tarCmd -xf '$archivePath' -C / 2>/dev/null")
|
||||
}
|
||||
|
||||
else -> {
|
||||
Log.w(TAG, "restoreExternalData: unknown archive type ${archive.name}")
|
||||
continue
|
||||
}
|
||||
}
|
||||
if (result.isSuccess) {
|
||||
Log.i(TAG, "restoreExternalData: extracted ${archive.name}")
|
||||
anyExtracted = true
|
||||
} else {
|
||||
Log.e(TAG, "restoreExternalData: FAILED ${archive.name}: exit=${result.exitCode} err=${result.error}")
|
||||
}
|
||||
}
|
||||
|
||||
// Fix ownership: same as OBB (media_rw group)
|
||||
val extPath = "/data/media/$userId/Android/data/$packageName"
|
||||
val gidResult = RootShell.exec("stat -c %g '${extPath.shellEscape()}' 2>/dev/null")
|
||||
val gid = gidResult.output.trim().toIntOrNull() ?: 1023
|
||||
RootShell.exec("chown -R $gid:$gid '${extPath.shellEscape()}/' 2>/dev/null")
|
||||
// Restore SELinux context
|
||||
val extContext = SELinuxUtil.getContext(extPath.substringBeforeLast("/"))
|
||||
if (extContext != null) {
|
||||
SELinuxUtil.chcon(extContext, extPath)
|
||||
Log.i(TAG, "restoreExternalData: restored SELinux context on $extPath")
|
||||
}
|
||||
|
||||
Log.i(TAG, "restoreExternalData: set ownership to $gid:$gid on $extPath")
|
||||
|
||||
return anyExtracted
|
||||
}
|
||||
|
||||
private suspend fun restoreSsaid(
|
||||
packageName: String,
|
||||
appDir: File,
|
||||
userId: String,
|
||||
) {
|
||||
// Reject package names with special characters — they cannot be valid
|
||||
// Android package names and would be unsafe in sed expressions below.
|
||||
if (!packageName.matches(Regex("^[a-zA-Z][a-zA-Z0-9._-]*(\\.[a-zA-Z][a-zA-Z0-9._-]*)+$"))) {
|
||||
Log.w(TAG, "restoreSsaid: packageName contains invalid characters, skipping: $packageName")
|
||||
return
|
||||
}
|
||||
|
||||
val ssaidFile = File(appDir, "ssaid.txt")
|
||||
val ssaidValue = BackupOperation.readTextFile(ssaidFile)?.trim() ?: return
|
||||
|
||||
// SSAID is a hex token. Reject anything else so it can never break out of
|
||||
// the sed expression below (shellEscape only protects single-quote context,
|
||||
// not the double-quoted sed string).
|
||||
if (!ssaidValue.matches(Regex("^[0-9a-fA-F]+$"))) {
|
||||
Log.w(TAG, "restoreSsaid: ssaid value is not hex, skipping XML edit for $packageName")
|
||||
return
|
||||
}
|
||||
|
||||
// Resolve the app's UID
|
||||
val uidResult = RootShell.exec("dumpsys package '${packageName.shellEscape()}' | grep 'userId=' | head -1")
|
||||
val uid = uidResult.output
|
||||
.substringAfter("userId=", "")
|
||||
.substringBefore(" ")
|
||||
.substringBefore(",")
|
||||
.trim()
|
||||
.toIntOrNull()
|
||||
val uid =
|
||||
uidResult.output
|
||||
.substringAfter("userId=", "")
|
||||
.substringBefore(" ")
|
||||
.substringBefore(",")
|
||||
.trim()
|
||||
.toIntOrNull()
|
||||
|
||||
if (uid == null) {
|
||||
Log.w(TAG, "restoreSsaid: could not resolve UID for $packageName")
|
||||
@@ -364,44 +609,49 @@ object RestoreOperation {
|
||||
|
||||
// Try XML-based approach first (more reliable across Android versions)
|
||||
val targetFile = "/data/system/users/${userId.shellEscape()}/settings_ssaid.xml"
|
||||
val xmlSuccess = run {
|
||||
// Check if file exists
|
||||
val checkResult = RootShell.exec("test -f '$targetFile' && echo 'exists'")
|
||||
if (!checkResult.output.contains("exists")) {
|
||||
Log.d(TAG, "restoreSsaid: $targetFile does not exist, will use settings command")
|
||||
return@run false
|
||||
}
|
||||
val xmlSuccess =
|
||||
run {
|
||||
// Check if file exists
|
||||
val checkResult = RootShell.exec("test -f '$targetFile' && echo 'exists'")
|
||||
if (!checkResult.output.contains("exists")) {
|
||||
Log.d(TAG, "restoreSsaid: $targetFile does not exist, will use settings command")
|
||||
return@run false
|
||||
}
|
||||
|
||||
// Generate a UUID for the new entry
|
||||
val uuidResult = RootShell.exec("cat /proc/sys/kernel/random/uuid 2>/dev/null")
|
||||
val id = uuidResult.output.trim()
|
||||
if (id.length != 36) { // UUID format check
|
||||
Log.w(TAG, "restoreSsaid: could not generate UUID (got '$id'), falling back")
|
||||
return@run false
|
||||
}
|
||||
// Generate a UUID for the new entry
|
||||
val uuidResult = RootShell.exec("cat /proc/sys/kernel/random/uuid 2>/dev/null")
|
||||
val id = uuidResult.output.trim()
|
||||
// Strict UUID format check (also keeps the value safe inside the sed string)
|
||||
if (!id.matches(Regex("^[0-9a-fA-F-]{36}$"))) {
|
||||
Log.w(TAG, "restoreSsaid: could not generate UUID (got '$id'), falling back")
|
||||
return@run false
|
||||
}
|
||||
|
||||
// Remove existing entry for this package and insert new one before </settings>
|
||||
val manipCmd = buildString {
|
||||
append("sed -i \"/package.*${packageName.shellEscape()}/d\" '$targetFile' && ")
|
||||
append("sed -i \"s#</settings>#<setting id=\\\"$id\\\" package=\\\"${packageName.shellEscape()}\\\" value=\\\"${ssaidValue.shellEscape()}\\\" defaultValue=\\\"default\\\" />\\n</settings>#\" '$targetFile'")
|
||||
}
|
||||
val result = RootShell.exec(manipCmd)
|
||||
if (!result.isSuccess) {
|
||||
Log.w(TAG, "restoreSsaid: XML edit failed: ${result.error}")
|
||||
return@run false
|
||||
}
|
||||
// Remove existing entry for this package and insert new one before </settings>
|
||||
val manipCmd =
|
||||
buildString {
|
||||
append("sed -i \"/package.*${packageName.shellEscape()}/d\" '$targetFile' && ")
|
||||
append(
|
||||
"sed -i \"s#</settings>#<setting id=\\\"$id\\\" package=\\\"${packageName.shellEscape()}\\\" value=\\\"${ssaidValue.shellEscape()}\\\" defaultValue=\\\"default\\\" />\\n</settings>#\" '$targetFile'",
|
||||
)
|
||||
}
|
||||
val result = RootShell.exec(manipCmd)
|
||||
if (!result.isSuccess) {
|
||||
Log.w(TAG, "restoreSsaid: XML edit failed: ${result.error}")
|
||||
return@run false
|
||||
}
|
||||
|
||||
// Verify the package entry was added by checking if it appears in the file now
|
||||
val verifyCmd = RootShell.exec("grep -c \"${packageName.shellEscape()}\" '$targetFile' 2>/dev/null")
|
||||
val entryCount = verifyCmd.output.trim().toIntOrNull() ?: 0
|
||||
if (entryCount > 0) {
|
||||
Log.i(TAG, "restoreSsaid: restored SSAID for $packageName via XML (uid=$uid)")
|
||||
true
|
||||
} else {
|
||||
Log.w(TAG, "restoreSsaid: XML edit completed but entry not found, falling back")
|
||||
false
|
||||
// Verify the package entry was added by checking if it appears in the file now
|
||||
val verifyCmd = RootShell.exec("grep -c \"${packageName.shellEscape()}\" '$targetFile' 2>/dev/null")
|
||||
val entryCount = verifyCmd.output.trim().toIntOrNull() ?: 0
|
||||
if (entryCount > 0) {
|
||||
Log.i(TAG, "restoreSsaid: restored SSAID for $packageName via XML (uid=$uid)")
|
||||
true
|
||||
} else {
|
||||
Log.w(TAG, "restoreSsaid: XML edit completed but entry not found, falling back")
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: use settings put secure if XML approach failed
|
||||
if (!xmlSuccess) {
|
||||
@@ -414,26 +664,26 @@ object RestoreOperation {
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun restorePermissions(packageName: String, appDir: File) {
|
||||
private suspend fun restorePermissions(
|
||||
packageName: String,
|
||||
appDir: File,
|
||||
) {
|
||||
val permFile = File(appDir, "permissions.txt")
|
||||
if (!permFile.exists()) return
|
||||
|
||||
// Parse permissions from dumpsys output.
|
||||
// Format: "android.permission.XXX: granted=true" or "android.permission.XXX: granted=false"
|
||||
val parsedPerms = try {
|
||||
permFile.readLines().mapNotNull { line ->
|
||||
val content = BackupOperation.readTextFile(permFile) ?: return
|
||||
val parsedPerms =
|
||||
content.lines().mapNotNull { line ->
|
||||
val name = line.substringBefore(":").trim().takeIf { it.isNotEmpty() && it.contains(".") } ?: return@mapNotNull null
|
||||
val granted = line.contains("granted=true")
|
||||
Pair(name, granted)
|
||||
}
|
||||
} catch (_: Exception) { emptyList() }
|
||||
|
||||
if (parsedPerms.isEmpty()) return
|
||||
|
||||
val pkgEsc = packageName.shellEscape()
|
||||
|
||||
// Reset app ops first (clears any previous modes)
|
||||
RootShell.exec("appops reset '$pkgEsc' 2>/dev/null")
|
||||
// NOTE: Intentionally skipping "appops reset" because we don't capture
|
||||
// app ops state (battery optimization, notification settings, etc.)
|
||||
// in the backup. Resetting would lose those user customizations.
|
||||
|
||||
val grantedPerms = parsedPerms.filter { it.second }.map { it.first }
|
||||
val deniedPerms = parsedPerms.filter { !it.second }.map { it.first }
|
||||
@@ -462,34 +712,40 @@ object RestoreOperation {
|
||||
private suspend fun resolveAppUid(packageName: String): Int? {
|
||||
val pkgEsc = packageName.shellEscape()
|
||||
// Method 1: pm list packages -U (reliable, consistent output format)
|
||||
val pmResult = RootShell.exec("pm list packages -U 2>/dev/null | grep '${pkgEsc}$'")
|
||||
val pmUid = pmResult.output
|
||||
.substringAfter(" uid:")
|
||||
.trim()
|
||||
.toIntOrNull()
|
||||
val pmResult = RootShell.exec("pm list packages -U 2>/dev/null | grep '$pkgEsc$'")
|
||||
val pmUid =
|
||||
pmResult.output
|
||||
.substringAfter(" uid:")
|
||||
.trim()
|
||||
.toIntOrNull()
|
||||
if (pmUid != null) return pmUid
|
||||
|
||||
// Method 2: dumpsys package (fallback for older Android)
|
||||
val dsResult = RootShell.exec("dumpsys package '$pkgEsc' | grep 'userId=' | head -1")
|
||||
val dsUid = dsResult.output
|
||||
.substringAfter("userId=", "")
|
||||
.substringBefore(" ")
|
||||
.substringBefore(",")
|
||||
.trim()
|
||||
.toIntOrNull()
|
||||
val dsUid =
|
||||
dsResult.output
|
||||
.substringAfter("userId=", "")
|
||||
.substringBefore(" ")
|
||||
.substringBefore(",")
|
||||
.trim()
|
||||
.toIntOrNull()
|
||||
if (dsUid != null) return dsUid
|
||||
|
||||
// Method 3: dumpsys with userId: separator (AOSP variant)
|
||||
val ds2Result = RootShell.exec("dumpsys package '$pkgEsc' | grep 'userId:' | head -1")
|
||||
val ds2Uid = ds2Result.output
|
||||
.substringAfter("userId:", "")
|
||||
.substringBefore(" ")
|
||||
.trim()
|
||||
.toIntOrNull()
|
||||
val ds2Uid =
|
||||
ds2Result.output
|
||||
.substringAfter("userId:", "")
|
||||
.substringBefore(" ")
|
||||
.trim()
|
||||
.toIntOrNull()
|
||||
return ds2Uid
|
||||
}
|
||||
|
||||
private suspend fun fixDataOwnership(packageName: String, userId: String) {
|
||||
private suspend fun fixDataOwnership(
|
||||
packageName: String,
|
||||
userId: String,
|
||||
) {
|
||||
val pkgEsc = packageName.shellEscape()
|
||||
val uidEsc = userId.shellEscape()
|
||||
|
||||
@@ -499,22 +755,27 @@ object RestoreOperation {
|
||||
return
|
||||
}
|
||||
|
||||
// USER and USER_DE use uid:uid (app's own group)
|
||||
val dataPaths = listOf(
|
||||
"/data/data/$pkgEsc",
|
||||
"/data/user_de/$uidEsc/$pkgEsc"
|
||||
)
|
||||
// USER, USER_DE, and external data paths
|
||||
val dataPaths =
|
||||
listOf(
|
||||
"/data/data/$pkgEsc",
|
||||
"/data/user_de/$uidEsc/$pkgEsc",
|
||||
"/data/media/$uidEsc/Android/data/$pkgEsc",
|
||||
"/storage/emulated/0/Android/obb/$pkgEsc",
|
||||
"/data/media/$uidEsc/Android/obb/$pkgEsc",
|
||||
)
|
||||
|
||||
for (dataPath in dataPaths) {
|
||||
RootShell.exec("chown -R $uid:$uid '$dataPath/' 2>/dev/null")
|
||||
|
||||
// Restore SELinux context instead of using restorecon (which applies defaults)
|
||||
val existingContext = SELinuxUtil.getContext(dataPath)
|
||||
val context = existingContext ?: run {
|
||||
val parentDir = dataPath.substringBeforeLast("/")
|
||||
val parentContext = SELinuxUtil.getContext(parentDir)
|
||||
parentContext?.replace("system_data_file", "app_data_file")
|
||||
}
|
||||
val context =
|
||||
existingContext ?: run {
|
||||
val parentDir = dataPath.substringBeforeLast("/")
|
||||
val parentContext = SELinuxUtil.getContext(parentDir)
|
||||
parentContext?.replace("system_data_file", "app_data_file")
|
||||
}
|
||||
if (context != null) {
|
||||
SELinuxUtil.chcon(context, dataPath)
|
||||
Log.d(TAG, "fixDataOwnership: restored SELinux context on $dataPath: $context")
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.example.androidbackupgui.backup
|
||||
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
/**
|
||||
* Retry [block] up to [maxRetries] times with exponential backoff.
|
||||
* Propagates [CancellationException] immediately.
|
||||
* Returns the first [AppResult.Success], or the last [AppResult.Failure] after all retries.
|
||||
*/
|
||||
suspend fun <T> retryWithBackoff(
|
||||
tag: String,
|
||||
operation: String,
|
||||
maxRetries: Int = 3,
|
||||
initialDelayMs: Long = 1000,
|
||||
block: suspend () -> AppResult<T>
|
||||
): AppResult<T> {
|
||||
var lastError: AppResult.Failure? = null
|
||||
repeat(maxRetries) { attempt ->
|
||||
try {
|
||||
val result = block()
|
||||
if (result is AppResult.Success) return result
|
||||
lastError = result as AppResult.Failure
|
||||
if (attempt < maxRetries - 1) {
|
||||
val delayMs = initialDelayMs * (1L shl attempt)
|
||||
Log.w(tag, "$operation 失败 (第${attempt+1}次), ${maxRetries-attempt-1}次重试剩余, 等待${delayMs}ms: ${result.error.message}")
|
||||
delay(delayMs)
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
if (attempt < maxRetries - 1) {
|
||||
val delayMs = initialDelayMs * (1L shl attempt)
|
||||
Log.e(tag, "$operation 异常 (第${attempt+1}次), ${maxRetries-attempt-1}次重试剩余", e)
|
||||
delay(delayMs)
|
||||
} else {
|
||||
Log.e(tag, "$operation 最终失败", e)
|
||||
return err(AppError.Remote("$operation 失败 (重试${maxRetries}次后)", operation, cause = e))
|
||||
}
|
||||
}
|
||||
}
|
||||
return lastError ?: err(AppError.Remote("$operation 失败", operation))
|
||||
}
|
||||
@@ -70,55 +70,53 @@ class SmbTransport(
|
||||
private fun smbFile(path: String): SmbFile = SmbFile(buildUrl(path), context)
|
||||
|
||||
override suspend fun upload(localPath: String, remotePath: String, onProgress: suspend (RemoteTransport.TransferProgress) -> Unit, onByteProgress: suspend (RemoteTransport.ByteProgress) -> Unit): AppResult<Unit> =
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val localFile = File(localPath)
|
||||
val remote = smbFile(remotePath)
|
||||
// Ensure parent directories exist (parent can be null at share root)
|
||||
val parentPath = remote.parent
|
||||
if (parentPath != null) {
|
||||
val parent = SmbFile(parentPath, context)
|
||||
if (!parent.exists()) parent.mkdirs()
|
||||
}
|
||||
onProgress(RemoteTransport.TransferProgress("connecting", 0, 1, remotePath))
|
||||
val fileSize = localFile.length()
|
||||
SmbFileOutputStream(remote).use { output ->
|
||||
localFile.inputStream().use { input ->
|
||||
onProgress(RemoteTransport.TransferProgress("transferring", 0, 1, remotePath))
|
||||
val buffer = ByteArray(bufferSize)
|
||||
var totalRead = 0L
|
||||
var n = input.read(buffer)
|
||||
while (n != -1) {
|
||||
output.write(buffer, 0, n)
|
||||
totalRead += n
|
||||
onByteProgress(RemoteTransport.ByteProgress(totalRead, fileSize, remotePath))
|
||||
n = input.read(buffer)
|
||||
retryWithBackoff(TAG, "SMB 上传") {
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val localFile = File(localPath)
|
||||
val remote = smbFile(remotePath)
|
||||
val parentPath = remote.parent
|
||||
if (parentPath != null) {
|
||||
val parent = SmbFile(parentPath, context)
|
||||
if (!parent.exists()) parent.mkdirs()
|
||||
}
|
||||
onProgress(RemoteTransport.TransferProgress("connecting", 0, 1, remotePath))
|
||||
val fileSize = localFile.length()
|
||||
SmbFileOutputStream(remote).use { output ->
|
||||
localFile.inputStream().use { input ->
|
||||
onProgress(RemoteTransport.TransferProgress("transferring", 0, 1, remotePath))
|
||||
val buffer = ByteArray(bufferSize)
|
||||
var totalRead = 0L
|
||||
var n = input.read(buffer)
|
||||
while (n != -1) {
|
||||
output.write(buffer, 0, n)
|
||||
totalRead += n
|
||||
onByteProgress(RemoteTransport.ByteProgress(totalRead, fileSize, remotePath))
|
||||
n = input.read(buffer)
|
||||
}
|
||||
}
|
||||
}
|
||||
val freshRemote = SmbFile(buildUrl(remotePath), context)
|
||||
val actualSize = freshRemote.length()
|
||||
Log.i(TAG, "upload done: $fileSize bytes local, $actualSize bytes on SMB")
|
||||
if (actualSize != fileSize) {
|
||||
Log.e(TAG, "upload size mismatch: local=$fileSize smb=$actualSize")
|
||||
return@withContext err(AppError.Remote("SMB 上传大小不匹配", "upload"))
|
||||
}
|
||||
onProgress(RemoteTransport.TransferProgress("completed", 1, 1, remotePath))
|
||||
AppResult.Success(Unit)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "upload failed: ${buildUrl(remotePath)}", e)
|
||||
err(AppError.Remote("SMB 上传失败", "upload", cause = e))
|
||||
}
|
||||
// Re-read with a fresh SmbFile handle to verify (jcifs-ng may have stale handle)
|
||||
val freshRemote = SmbFile(buildUrl(remotePath), context)
|
||||
val actualSize = freshRemote.length()
|
||||
Log.i(TAG, "upload done: $fileSize bytes local, $actualSize bytes on SMB")
|
||||
if (actualSize != fileSize) {
|
||||
Log.w(TAG, "upload size mismatch: local=$fileSize smb=$actualSize")
|
||||
// Try re-opening the output stream to flush any pending writes
|
||||
SmbFileOutputStream(remote).use { it.write(ByteArray(0)) }
|
||||
val retrySize = freshRemote.length()
|
||||
Log.w(TAG, "upload retry: smb=$retrySize bytes")
|
||||
}
|
||||
onProgress(RemoteTransport.TransferProgress("completed", 1, 1, remotePath))
|
||||
AppResult.Success(Unit)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "upload failed: ${buildUrl(remotePath)}", e)
|
||||
err(AppError.Remote("SMB 上传失败", "upload", cause = e))
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun download(remotePath: String, localPath: String, onProgress: suspend (RemoteTransport.TransferProgress) -> Unit, onByteProgress: suspend (RemoteTransport.ByteProgress) -> Unit): AppResult<Unit> =
|
||||
withContext(Dispatchers.IO) {
|
||||
retryWithBackoff(TAG, "SMB 下载") {
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val localFile = File(localPath)
|
||||
localFile.parentFile?.mkdirs()
|
||||
@@ -149,6 +147,7 @@ class SmbTransport(
|
||||
err(AppError.Remote("SMB 下载失败", "download", cause = e))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun listFiles(remoteDir: String): AppResult<List<RemoteTransport.RemoteFileInfo>> =
|
||||
withContext(Dispatchers.IO) {
|
||||
@@ -253,4 +252,17 @@ class SmbTransport(
|
||||
err(AppError.Remote("SMB 检查失败", "exists", cause = e))
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun fileSize(remotePath: String): AppResult<Long> =
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val file = smbFile(remotePath)
|
||||
if (!file.exists()) return@withContext err(AppError.Remote("文件不存在", "fileSize"))
|
||||
AppResult.Success(file.length())
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
err(AppError.Remote("SMB 获取文件大小失败", "fileSize", cause = e))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
package com.example.androidbackupgui.backup
|
||||
|
||||
import android.util.Log
|
||||
import com.example.androidbackupgui.root.RootShell
|
||||
import com.example.androidbackupgui.root.shellEscape
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import kotlin.coroutines.coroutineContext
|
||||
|
||||
/**
|
||||
* Streaming backup orchestrator.
|
||||
*
|
||||
* Uses a FIFO (named pipe) to pipe app data tar output directly into
|
||||
* `restic backup --stdin`, eliminating the staging directory for large
|
||||
* data backups.
|
||||
*/
|
||||
object StreamingBackup {
|
||||
|
||||
private const val TAG = "StreamingBackup"
|
||||
|
||||
data class StreamingResult(
|
||||
val apkPaths: List<String>, // APK paths (backed up directly by restic)
|
||||
val dataFifo: File, // FIFO path for app data tar
|
||||
val metaDir: File // Metadata directory (~1MB)
|
||||
)
|
||||
|
||||
/**
|
||||
* Prepare streaming backup configuration.
|
||||
*
|
||||
* Creates the FIFO and metadata directory, collects APK paths.
|
||||
*
|
||||
* @param cacheDir Directory to place FIFO and temp files
|
||||
* @param apps List of apps being backed up
|
||||
* @param legacyApps Metadata from previous snapshot
|
||||
*/
|
||||
suspend fun prepareStreaming(
|
||||
cacheDir: File,
|
||||
apps: List<AppInfo>,
|
||||
legacyApps: Map<String, ResticWrapper.SnapshotAppInfo>?
|
||||
): StreamingResult = withContext(Dispatchers.IO) {
|
||||
cacheDir.mkdirs()
|
||||
|
||||
// Create FIFO for data pipe
|
||||
val fifo = File(cacheDir, "app_data_stream.fifo")
|
||||
// Remove stale FIFO if present
|
||||
if (fifo.exists()) fifo.delete()
|
||||
// mkfifo requires root on Android
|
||||
RootShell.exec("mkfifo '${fifo.absolutePath.shellEscape()}'")
|
||||
Log.i(TAG, "FIFO created at ${fifo.absolutePath}")
|
||||
|
||||
// Collect APK paths
|
||||
val apkPaths = mutableListOf<String>()
|
||||
for (app in apps) {
|
||||
val paths = AppScanner.getApkPaths(app.packageName.value)
|
||||
apkPaths.addAll(paths)
|
||||
}
|
||||
|
||||
// Create metadata directory
|
||||
val metaDir = File(cacheDir, "streaming_meta")
|
||||
metaDir.mkdirs()
|
||||
|
||||
// Write app list
|
||||
val appListFile = File(metaDir, "appList.txt")
|
||||
appListFile.writeText(apps.joinToString("\n") { it.packageName.value })
|
||||
|
||||
// Write app_details.json
|
||||
val metaFile = File(metaDir, "app_details.json")
|
||||
metaFile.writeText(BackupOperation.buildAppDetailsJson(apps, legacyApps))
|
||||
|
||||
Log.i(TAG, "Streaming prepared: ${apkPaths.size} APKs, FIFO at ${fifo.absolutePath}")
|
||||
StreamingResult(apkPaths, fifo, metaDir)
|
||||
}
|
||||
|
||||
/**
|
||||
* Launch the data producer in a root shell background process.
|
||||
*
|
||||
* For each app, runs `tar -cf - /data/data/pkg 2>/dev/null` and appends
|
||||
* to the FIFO. The FIFO is consumed by `restic backup --stdin`.
|
||||
*
|
||||
* @param apps Apps whose data directories to tar
|
||||
* @param noDataBackup Set of package names to exclude from data backup
|
||||
* @param userId Android user ID
|
||||
* @param fifoPath Path to the FIFO
|
||||
*/
|
||||
suspend fun launchDataProducer(
|
||||
apps: List<AppInfo>,
|
||||
noDataBackup: Set<String>,
|
||||
@Suppress("UNUSED_PARAMETER") userId: String,
|
||||
fifoPath: String
|
||||
): Boolean = withContext(Dispatchers.IO) {
|
||||
val fifoEsc = fifoPath.shellEscape()
|
||||
|
||||
for (app in apps) {
|
||||
if (!coroutineContext.isActive) return@withContext false
|
||||
|
||||
val pkgName = app.packageName.value
|
||||
if (pkgName in noDataBackup) {
|
||||
Log.d(TAG, "Skipping data for $pkgName (excluded)")
|
||||
continue
|
||||
}
|
||||
|
||||
val dataDir = "/data/data/$pkgName"
|
||||
// Check if data directory exists
|
||||
val existsResult = RootShell.exec("[ -d '${dataDir.shellEscape()}' ] && echo 1 || echo 0")
|
||||
if (existsResult.output.trim() != "1") {
|
||||
Log.d(TAG, "No data directory for $pkgName, skipping")
|
||||
continue
|
||||
}
|
||||
|
||||
// Append tar output to FIFO. `>>` blocks until consumer reads.
|
||||
val cmd = "tar -cf - '$dataDir' 2>/dev/null >> '$fifoEsc'"
|
||||
Log.d(TAG, "Streaming data for $pkgName: $cmd")
|
||||
val result = RootShell.exec(cmd)
|
||||
if (!result.isSuccess) {
|
||||
Log.w(TAG, "Data backup failed for $pkgName: ${result.error}")
|
||||
}
|
||||
}
|
||||
|
||||
Log.i(TAG, "Data producer completed")
|
||||
true
|
||||
}
|
||||
}
|
||||
@@ -7,20 +7,30 @@ import com.thegrizzlylabs.sardineandroid.impl.SardineException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import android.util.Base64
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
|
||||
class WebdavTransport(
|
||||
private val baseUrl: String,
|
||||
private val username: String,
|
||||
private val password: String,
|
||||
private val bufferSize: Int = 8192
|
||||
private val bufferSize: Int = 8192,
|
||||
private val connectTimeoutSeconds: Int = 15,
|
||||
private val readTimeoutSeconds: Int = 30
|
||||
): RemoteTransport {
|
||||
|
||||
companion object { private const val TAG = "WebdavTransport" }
|
||||
|
||||
private val sardine: Sardine by lazy {
|
||||
OkHttpSardine().apply {
|
||||
val client = okhttp3.OkHttpClient.Builder()
|
||||
.connectTimeout(connectTimeoutSeconds.toLong(), java.util.concurrent.TimeUnit.SECONDS)
|
||||
.readTimeout(readTimeoutSeconds.toLong(), java.util.concurrent.TimeUnit.SECONDS)
|
||||
.build()
|
||||
OkHttpSardine(client).apply {
|
||||
if (username.isNotEmpty()) {
|
||||
setCredentials(username, password)
|
||||
}
|
||||
@@ -33,73 +43,138 @@ class WebdavTransport(
|
||||
}
|
||||
|
||||
override suspend fun upload(localPath: String, remotePath: String, onProgress: suspend (RemoteTransport.TransferProgress) -> Unit, onByteProgress: suspend (RemoteTransport.ByteProgress) -> Unit): AppResult<Unit> =
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val url = buildUrl(remotePath)
|
||||
val file = File(localPath)
|
||||
val fileSize = file.length()
|
||||
if (fileSize > 50 * 1024 * 1024L) {
|
||||
return@withContext err(AppError.Remote("WebDAV 上传: 文件过大 (${fileSize / 1024 / 1024}MB), 上限 50MB", "upload"))
|
||||
}
|
||||
Log.d(TAG, "upload $localPath -> $url ($fileSize bytes)")
|
||||
onProgress(RemoteTransport.TransferProgress("connecting", 0, 1, remotePath))
|
||||
// Read file into ByteArray with progress (sardine.put lacks InputStream variant)
|
||||
val data = file.inputStream().buffered(bufferSize).use { input ->
|
||||
onProgress(RemoteTransport.TransferProgress("transferring", 0, 1, remotePath))
|
||||
val out = ByteArrayOutputStream()
|
||||
val buffer = ByteArray(bufferSize)
|
||||
var totalRead = 0L
|
||||
var n = input.read(buffer)
|
||||
while (n != -1) {
|
||||
out.write(buffer, 0, n)
|
||||
totalRead += n
|
||||
onByteProgress(RemoteTransport.ByteProgress(totalRead, fileSize, remotePath))
|
||||
n = input.read(buffer)
|
||||
retryWithBackoff(TAG, "WebDAV 上传") {
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val url = buildUrl(remotePath)
|
||||
val file = File(localPath)
|
||||
val fileSize = file.length()
|
||||
if (fileSize > 50 * 1024 * 1024L) {
|
||||
return@withContext err(AppError.Remote("WebDAV 上传: 文件过大 (${fileSize / 1024 / 1024}MB), 上限 50MB", "upload"))
|
||||
}
|
||||
out.toByteArray()
|
||||
}
|
||||
sardine.put(url, data, "application/octet-stream")
|
||||
onProgress(RemoteTransport.TransferProgress("completed", 1, 1, remotePath))
|
||||
AppResult.Success(Unit)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "upload failed: $remotePath", e)
|
||||
err(AppError.Remote("WebDAV 上传失败", "upload", cause = e))
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun download(remotePath: String, localPath: String, onProgress: suspend (RemoteTransport.TransferProgress) -> Unit, onByteProgress: suspend (RemoteTransport.ByteProgress) -> Unit): AppResult<Unit> =
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val url = buildUrl(remotePath)
|
||||
val localFile = File(localPath)
|
||||
localFile.parentFile?.mkdirs()
|
||||
onProgress(RemoteTransport.TransferProgress("connecting", 0, 1, remotePath))
|
||||
sardine.get(url).use { input ->
|
||||
localFile.outputStream().use { output ->
|
||||
Log.d(TAG, "upload $localPath -> $url ($fileSize bytes)")
|
||||
onProgress(RemoteTransport.TransferProgress("connecting", 0, 1, remotePath))
|
||||
val data = file.inputStream().buffered(bufferSize).use { input ->
|
||||
onProgress(RemoteTransport.TransferProgress("transferring", 0, 1, remotePath))
|
||||
val out = ByteArrayOutputStream()
|
||||
val buffer = ByteArray(bufferSize)
|
||||
var totalRead = 0L
|
||||
var n = input.read(buffer)
|
||||
while (n != -1) {
|
||||
output.write(buffer, 0, n)
|
||||
out.write(buffer, 0, n)
|
||||
totalRead += n
|
||||
onByteProgress(RemoteTransport.ByteProgress(totalRead, 0, remotePath))
|
||||
onByteProgress(RemoteTransport.ByteProgress(totalRead, fileSize, remotePath))
|
||||
n = input.read(buffer)
|
||||
}
|
||||
out.toByteArray()
|
||||
}
|
||||
sardine.put(url, data, "application/octet-stream")
|
||||
onProgress(RemoteTransport.TransferProgress("completed", 1, 1, remotePath))
|
||||
AppResult.Success(Unit)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "upload failed: $remotePath", e)
|
||||
err(AppError.Remote("WebDAV 上传失败", "upload", cause = e))
|
||||
}
|
||||
onProgress(RemoteTransport.TransferProgress("completed", 1, 1, remotePath))
|
||||
Log.d(TAG, "download $url -> $localPath (${localFile.length()} bytes)")
|
||||
AppResult.Success(Unit)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "download failed: $remotePath", e)
|
||||
err(AppError.Remote("WebDAV 下载失败", "download", cause = e))
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun download(remotePath: String, localPath: String, onProgress: suspend (RemoteTransport.TransferProgress) -> Unit, onByteProgress: suspend (RemoteTransport.ByteProgress) -> Unit): AppResult<Unit> =
|
||||
retryWithBackoff(TAG, "WebDAV 下载") {
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val url = buildUrl(remotePath)
|
||||
val localFile = File(localPath)
|
||||
localFile.parentFile?.mkdirs()
|
||||
val partFile = File(localPath + ".part")
|
||||
val existingBytes = if (partFile.exists()) partFile.length() else 0L
|
||||
|
||||
onProgress(RemoteTransport.TransferProgress("connecting", 0, 1, remotePath))
|
||||
|
||||
if (existingBytes > 0L) {
|
||||
Log.d(TAG, "download 发现 .part 文件, 从 offset=$existingBytes 续传: $remotePath")
|
||||
downloadRangeResume(url, partFile, existingBytes, onByteProgress, remotePath)
|
||||
} else {
|
||||
sardine.get(url).use { input ->
|
||||
partFile.outputStream().use { output ->
|
||||
onProgress(RemoteTransport.TransferProgress("transferring", 0, 1, remotePath))
|
||||
val buffer = ByteArray(bufferSize)
|
||||
var totalRead = 0L
|
||||
var n = input.read(buffer)
|
||||
while (n != -1) {
|
||||
output.write(buffer, 0, n)
|
||||
totalRead += n
|
||||
onByteProgress(RemoteTransport.ByteProgress(totalRead, 0, remotePath))
|
||||
n = input.read(buffer)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (partFile.exists()) {
|
||||
partFile.renameTo(localFile)
|
||||
}
|
||||
onProgress(RemoteTransport.TransferProgress("completed", 1, 1, remotePath))
|
||||
Log.d(TAG, "download $url -> $localPath (${localFile.length()} bytes)")
|
||||
AppResult.Success(Unit)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "download failed: $remotePath", e)
|
||||
err(AppError.Remote("WebDAV 下载失败", "download", cause = e))
|
||||
}
|
||||
}
|
||||
} // retryWithBackoff
|
||||
|
||||
/**
|
||||
* Resume a partial WebDAV download using HTTP Range header.
|
||||
* Reads from [partFile] which already has [offset] bytes, requests remaining bytes via
|
||||
* [HttpURLConnection] with Basic auth, and appends to the file.
|
||||
*/
|
||||
private suspend fun downloadRangeResume(
|
||||
url: String,
|
||||
partFile: File,
|
||||
offset: Long,
|
||||
onByteProgress: suspend (RemoteTransport.ByteProgress) -> Unit,
|
||||
remotePath: String
|
||||
) {
|
||||
val conn = URL(url).openConnection() as HttpURLConnection
|
||||
try {
|
||||
conn.requestMethod = "GET"
|
||||
if (username.isNotEmpty()) {
|
||||
val basicAuth = "Basic " + Base64.encodeToString(
|
||||
"$username:$password".toByteArray(Charsets.UTF_8),
|
||||
Base64.NO_WRAP
|
||||
)
|
||||
conn.setRequestProperty("Authorization", basicAuth)
|
||||
}
|
||||
conn.setRequestProperty("Range", "bytes=$offset-")
|
||||
conn.connect()
|
||||
|
||||
val statusCode = conn.responseCode
|
||||
if (statusCode != 206 && statusCode != 200) {
|
||||
throw IOException("WebDAV Range resume 失败: HTTP $statusCode (需要 206)")
|
||||
}
|
||||
|
||||
val totalSize = offset + conn.contentLength
|
||||
java.io.FileOutputStream(partFile, true).use { output ->
|
||||
conn.inputStream.use { input ->
|
||||
val buffer = ByteArray(bufferSize)
|
||||
var totalRead = offset
|
||||
var n = input.read(buffer)
|
||||
while (n != -1) {
|
||||
output.write(buffer, 0, n)
|
||||
totalRead += n
|
||||
onByteProgress(RemoteTransport.ByteProgress(totalRead, totalSize, remotePath))
|
||||
n = input.read(buffer)
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
conn.disconnect()
|
||||
}
|
||||
}
|
||||
override suspend fun listFiles(remoteDir: String): AppResult<List<RemoteTransport.RemoteFileInfo>> =
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
@@ -150,8 +225,8 @@ class WebdavTransport(
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "mkdirs failed: $remotePath — ${e.message}")
|
||||
AppResult.Success(Unit) // best-effort; upload will fail if dir can't be created
|
||||
Log.e(TAG, "mkdirs failed: $remotePath — ${e.message}")
|
||||
err(AppError.Remote("WebDAV mkdirs 失败", "mkdirs", cause = e))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,4 +255,19 @@ class WebdavTransport(
|
||||
err(AppError.Remote("WebDAV 检查失败", "exists", cause = e))
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun fileSize(remotePath: String): AppResult<Long> =
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val url = buildUrl(remotePath)
|
||||
if (!sardine.exists(url)) return@withContext err(AppError.Remote("文件不存在", "fileSize"))
|
||||
val resources = sardine.list(url)
|
||||
val size = resources.firstOrNull()?.contentLength ?: 0L
|
||||
AppResult.Success(size)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
err(AppError.Remote("WebDAV 获取文件大小失败", "fileSize", cause = e))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.util.Log
|
||||
import com.topjohnwu.superuser.Shell
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.TimeoutCancellationException
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeout
|
||||
@@ -49,21 +50,32 @@ object RootShell {
|
||||
}
|
||||
}
|
||||
|
||||
/** Call once at app startup to configure libsu. */
|
||||
/** Call once at app startup to configure libsu. Safe to call multiple times. */
|
||||
fun configure() {
|
||||
Shell.enableVerboseLogging = true
|
||||
Shell.setDefaultBuilder(
|
||||
Shell.Builder.create()
|
||||
.setFlags(Shell.FLAG_MOUNT_MASTER)
|
||||
.setInitializers(GlobalNamespaceInitializer::class.java)
|
||||
.setTimeout(30)
|
||||
)
|
||||
try {
|
||||
Shell.setDefaultBuilder(
|
||||
Shell.Builder.create()
|
||||
.setFlags(Shell.FLAG_MOUNT_MASTER)
|
||||
.setInitializers(GlobalNamespaceInitializer::class.java)
|
||||
.setTimeout(30)
|
||||
)
|
||||
} catch (_: IllegalStateException) {
|
||||
// Shell already created (e.g. from Application superclass or prior session).
|
||||
// The default builder is already in effect — our custom config is ignored
|
||||
// but the shell is still functional.
|
||||
} catch (e: Exception) {
|
||||
// Some ROMs throw other exceptions during root init; don't crash startup.
|
||||
Log.w(TAG, "configure: failed to set default builder", e)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun ensureSession(): Boolean = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
Shell.getShell().isRoot
|
||||
} catch (_: Exception) { false }
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) { false }
|
||||
}
|
||||
|
||||
suspend fun exec(command: String, timeoutMs: Long = COMMAND_TIMEOUT_MS): ShellResult =
|
||||
@@ -81,6 +93,8 @@ object RootShell {
|
||||
} catch (e: TimeoutCancellationException) {
|
||||
Log.w(TAG, "exec timeout (${timeoutMs}ms): $command")
|
||||
ShellResult("", "Command timed out after ${timeoutMs}ms", -1)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "exec failed: $command", e)
|
||||
ShellResult("", e.message ?: "Unknown error", -1)
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.example.androidbackupgui.ui
|
||||
|
||||
/** Navigation destinations */
|
||||
enum class Screen(val label: String, val icon: String) {
|
||||
BACKUP("应用备份", "backup"),
|
||||
RESTORE("应用恢复", "restore"),
|
||||
CONFIG("备份配置", "settings"),
|
||||
LOG("运行日志", "logs")
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package com.example.androidbackupgui.ui
|
||||
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Cloud
|
||||
import androidx.compose.material.icons.filled.Description
|
||||
import androidx.compose.material.icons.filled.Restore
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
|
||||
private val navItems = listOf(
|
||||
NavItem(Screen.BACKUP, Icons.Filled.Cloud, "备份"),
|
||||
NavItem(Screen.RESTORE, Icons.Filled.Restore, "恢复"),
|
||||
NavItem(Screen.LOG, Icons.Filled.Description, "日志"),
|
||||
NavItem(Screen.CONFIG, Icons.Filled.Settings, "配置"),
|
||||
)
|
||||
|
||||
private data class NavItem(
|
||||
val screen: Screen,
|
||||
val icon: ImageVector,
|
||||
val label: String
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AppScaffold() {
|
||||
var currentScreen by remember { mutableStateOf(Screen.CONFIG) }
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
|
||||
Scaffold(
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(currentScreen.label) },
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
titleContentColor = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
)
|
||||
},
|
||||
bottomBar = {
|
||||
NavigationBar {
|
||||
navItems.forEach { item ->
|
||||
NavigationBarItem(
|
||||
selected = currentScreen == item.screen,
|
||||
onClick = { currentScreen = item.screen },
|
||||
icon = { Icon(item.icon, contentDescription = item.label) },
|
||||
label = { Text(item.label) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
) { innerPadding ->
|
||||
Surface(
|
||||
modifier = Modifier.padding(innerPadding),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
when (currentScreen) {
|
||||
Screen.BACKUP -> BackupScreen()
|
||||
Screen.RESTORE -> RestoreScreen()
|
||||
Screen.LOG -> LogScreen()
|
||||
Screen.CONFIG -> ConfigScreen(snackbarHostState = snackbarHostState)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,547 +0,0 @@
|
||||
package com.example.androidbackupgui.ui
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import androidx.core.content.ContextCompat
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.AdapterView
|
||||
import android.widget.ArrayAdapter
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import com.example.androidbackupgui.backup.AppInfo
|
||||
import com.example.androidbackupgui.backup.PackageName
|
||||
import com.example.androidbackupgui.backup.AppScanner
|
||||
import com.example.androidbackupgui.backup.BackupConfig
|
||||
import com.example.androidbackupgui.backup.BackupOperation
|
||||
import com.example.androidbackupgui.backup.BackupService
|
||||
import com.example.androidbackupgui.backup.ResticBinary
|
||||
import com.example.androidbackupgui.backup.ResticWrapper
|
||||
import com.example.androidbackupgui.backup.WifiManager
|
||||
import com.example.androidbackupgui.backup.AppResult
|
||||
import com.example.androidbackupgui.databinding.FragmentBackupBinding
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.withContext
|
||||
import android.os.StatFs
|
||||
import com.example.androidbackupgui.backup.StreamingBackup
|
||||
import com.example.androidbackupgui.root.RootShell
|
||||
import com.example.androidbackupgui.root.shellEscape
|
||||
import com.example.androidbackupgui.backup.formatSize
|
||||
import java.io.File
|
||||
import java.util.Locale
|
||||
|
||||
class BackupFragment : Fragment() {
|
||||
|
||||
private var _binding: FragmentBackupBinding? = null
|
||||
private val binding get() = _binding!!
|
||||
private var apps: List<AppInfo> = emptyList()
|
||||
private var selectedApps = mutableSetOf<String>()
|
||||
private var sortedApps: List<AppInfo> = emptyList()
|
||||
private lateinit var config: BackupConfig
|
||||
private var selectedUserId: Int = 0
|
||||
private var userList: List<Pair<Int, String>> = listOf(0 to "Owner")
|
||||
private var sortMode: SortMode = SortMode.NAME_ASC
|
||||
private var showSystemApps: Boolean = false
|
||||
private var excludeDataFromBackup = mutableSetOf<String>()
|
||||
|
||||
private enum class SortMode { NAME_ASC, SIZE_DESC }
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
|
||||
): View {
|
||||
_binding = FragmentBackupBinding.inflate(inflater, container, false)
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
val configFile = File(requireContext().filesDir, "backup_settings.conf")
|
||||
config = BackupConfig.fromFile(configFile)
|
||||
updateOutputPathDisplay()
|
||||
|
||||
binding.appList.layoutManager = LinearLayoutManager(requireContext())
|
||||
|
||||
binding.scanButton.setOnClickListener { scanApps() }
|
||||
binding.outputPathEdit.setOnClickListener { showOutputPathEditDialog() }
|
||||
binding.backupButton.setOnClickListener { startBackup() }
|
||||
|
||||
// Sort/filter controls
|
||||
binding.sortAZButton.setOnClickListener {
|
||||
sortMode = SortMode.NAME_ASC
|
||||
applySortFilter()
|
||||
}
|
||||
binding.sortSizeButton.setOnClickListener {
|
||||
sortMode = SortMode.SIZE_DESC
|
||||
applySortFilter()
|
||||
}
|
||||
binding.selectAllButton.setOnClickListener {
|
||||
selectedApps.addAll(apps.map { it.packageName.value })
|
||||
applySortFilter()
|
||||
}
|
||||
binding.deselectAllButton.setOnClickListener {
|
||||
selectedApps.clear()
|
||||
applySortFilter()
|
||||
}
|
||||
binding.showSystemSwitch.setOnCheckedChangeListener { _, checked ->
|
||||
showSystemApps = checked
|
||||
applySortFilter()
|
||||
}
|
||||
|
||||
// Load user profiles and setup dropdown
|
||||
loadUsers()
|
||||
}
|
||||
|
||||
private fun loadUsers() {
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
try {
|
||||
userList = AppScanner.enumerateUsers()
|
||||
val names = userList.map { (id, name) -> "$name (ID: $id)" }
|
||||
val adapter = ArrayAdapter(requireContext(), android.R.layout.simple_spinner_item, names)
|
||||
adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
|
||||
binding.userSelector.adapter = adapter
|
||||
binding.userSelector.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
|
||||
override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
|
||||
selectedUserId = userList.getOrNull(position)?.first ?: 0
|
||||
}
|
||||
override fun onNothingSelected(parent: AdapterView<*>?) {}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
binding.statusText.text = "加载用户失败: ${e.message}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
if (::config.isInitialized) {
|
||||
val configFile = File(requireContext().filesDir, "backup_settings.conf")
|
||||
config = BackupConfig.fromFile(configFile)
|
||||
updateOutputPathDisplay()
|
||||
}
|
||||
}
|
||||
|
||||
private fun scanApps() {
|
||||
binding.backupButton.isEnabled = false
|
||||
setRunning(true)
|
||||
binding.statusText.text = "正在扫描应用…"
|
||||
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
try {
|
||||
val ctx = requireContext()
|
||||
val thirdParty = AppScanner.scanThirdParty(ctx, userId = selectedUserId)
|
||||
val system = AppScanner.scanSystem(ctx, config, userId = selectedUserId)
|
||||
apps = if (showSystemApps) thirdParty + system else thirdParty
|
||||
selectedApps.clear()
|
||||
selectedApps.addAll(apps.map { it.packageName.value })
|
||||
|
||||
binding.statusText.text = "共找到 ${apps.size} 个应用,全部已选中"
|
||||
binding.backupButton.isEnabled = apps.isNotEmpty()
|
||||
setRunning(false)
|
||||
|
||||
applySortFilter()
|
||||
} catch (e: Exception) {
|
||||
binding.statusText.text = "扫描应用失败: ${e.message}"
|
||||
setRunning(false)
|
||||
binding.backupButton.isEnabled = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun applySortFilter() {
|
||||
var filtered = if (showSystemApps) apps else apps.filter { !it.isSystem }
|
||||
filtered = when (sortMode) {
|
||||
SortMode.NAME_ASC -> filtered.sortedBy { it.label.lowercase(Locale.US) }
|
||||
SortMode.SIZE_DESC -> filtered.sortedByDescending { it.backupSize }
|
||||
}
|
||||
sortedApps = filtered
|
||||
setupAppList()
|
||||
binding.statusText.text = "已选择 ${selectedApps.size}/${sortedApps.size} 个应用"
|
||||
}
|
||||
private fun setupAppList() {
|
||||
val displayApps = sortedApps.ifEmpty { apps }
|
||||
binding.appList.adapter = PackageListAdapter(
|
||||
displayApps, selectedApps,
|
||||
onToggle = { pkg, checked ->
|
||||
if (checked) selectedApps.add(pkg) else selectedApps.remove(pkg)
|
||||
binding.statusText.text = "已选择 ${selectedApps.size}/${displayApps.size} 个应用"
|
||||
},
|
||||
excludeDataFrom = excludeDataFromBackup,
|
||||
onExcludeDataToggle = { pkg, excluded ->
|
||||
if (excluded) excludeDataFromBackup.add(pkg) else excludeDataFromBackup.remove(pkg)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun startBackup() {
|
||||
val toBackup = apps.filter { it.packageName.value in selectedApps }
|
||||
if (toBackup.isEmpty()) return
|
||||
|
||||
setRunning(true)
|
||||
binding.backupButton.isEnabled = false
|
||||
binding.scanButton.isEnabled = false
|
||||
|
||||
// Start foreground service to keep process alive
|
||||
val serviceIntent = Intent(requireContext(), BackupService::class.java)
|
||||
serviceIntent.action = BackupService.ACTION_START_BACKUP
|
||||
serviceIntent.putExtra(BackupService.EXTRA_STATUS_TEXT, "正在备份 ${toBackup.size} 个应用…")
|
||||
try {
|
||||
ContextCompat.startForegroundService(requireContext(), serviceIntent)
|
||||
} catch (_: Exception) {}
|
||||
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
try {
|
||||
val outputDir = File(config.outputPath.ifEmpty {
|
||||
requireContext().filesDir.absolutePath
|
||||
})
|
||||
|
||||
// ── Restic pre-flight: load snapshot metadata for cumulative merge ──
|
||||
var snapshotApps: Map<String, ResticWrapper.SnapshotAppInfo>? = null
|
||||
if (config.resticEnabled == 1 && config.resticRepo.isNotBlank()) {
|
||||
updateStatus("正在检查 restic 历史快照…")
|
||||
|
||||
if (config.resticBackend == "local" && !File(config.resticRepo, "config").exists()) {
|
||||
updateStatus("restic 本地仓库未初始化,请先在设置中初始化")
|
||||
return@launch
|
||||
}
|
||||
|
||||
val binaryPath = ResticBinary.prepare(requireContext())
|
||||
if (binaryPath != null) {
|
||||
ResticWrapper.binaryPath = binaryPath
|
||||
ResticWrapper.cacheDir = requireContext().cacheDir.absolutePath
|
||||
ResticWrapper.backendDomain = config.resticBackendDomain
|
||||
|
||||
snapshotApps = ResticWrapper.getLatestSnapshotAppDetails(
|
||||
repoPath = config.resticRepo,
|
||||
password = config.resticPassword,
|
||||
backend = config.resticBackend,
|
||||
backendUrl = config.resticBackendUrl,
|
||||
backendUser = config.resticBackendUser,
|
||||
backendPass = config.resticBackendPass,
|
||||
backendShare = config.resticBackendShare
|
||||
)
|
||||
if (snapshotApps != null) {
|
||||
updateStatus("发现历史快照,将合并为累积备份")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Build merged app list for cumulative snapshot ──
|
||||
val selectedPkgs = toBackup.map { it.packageName.value }.toSet()
|
||||
val allApps: List<AppInfo>
|
||||
val includePkgs: Set<String>
|
||||
|
||||
if (snapshotApps != null) {
|
||||
// Create placeholder AppInfo entries for packages from the snapshot
|
||||
// that are NOT in the current selection. These won't be re-backed-up
|
||||
// but their metadata is preserved via legacyApps.
|
||||
val snapshotOnly = snapshotApps.keys.filter { it !in selectedPkgs }
|
||||
val legacyEntries = snapshotOnly.mapNotNull { pkg ->
|
||||
val snap = snapshotApps[pkg] ?: return@mapNotNull null
|
||||
AppInfo(
|
||||
packageName = PackageName(pkg),
|
||||
label = snap.label,
|
||||
isSystem = snap.isSystem
|
||||
)
|
||||
}
|
||||
allApps = toBackup + legacyEntries
|
||||
includePkgs = selectedPkgs
|
||||
val snapCount = legacyEntries.size
|
||||
if (snapCount > 0) {
|
||||
updateStatus("累积备份: ${allApps.size} 个应用 ($snapCount 个来自历史快照)")
|
||||
}
|
||||
|
||||
// Restore latest snapshot to populate directories for unchanged apps
|
||||
updateStatus("正在恢复历史快照…")
|
||||
val backupRoot = File(outputDir, "Backup_${config.compressionMethod}_${selectedUserId}")
|
||||
backupRoot.mkdirs()
|
||||
val snapsResult = ResticWrapper.listSnapshots(
|
||||
repoPath = config.resticRepo,
|
||||
password = config.resticPassword,
|
||||
backend = config.resticBackend,
|
||||
backendUrl = config.resticBackendUrl,
|
||||
backendUser = config.resticBackendUser,
|
||||
backendPass = config.resticBackendPass,
|
||||
backendShare = config.resticBackendShare
|
||||
)
|
||||
val latestSnap = (snapsResult as? AppResult.Success)?.data?.firstOrNull()
|
||||
if (latestSnap != null) {
|
||||
ResticWrapper.restore(
|
||||
repoPath = config.resticRepo,
|
||||
password = config.resticPassword,
|
||||
snapshotId = latestSnap.shortId,
|
||||
targetPath = backupRoot.absolutePath,
|
||||
backend = config.resticBackend,
|
||||
backendUrl = config.resticBackendUrl,
|
||||
backendUser = config.resticBackendUser,
|
||||
backendPass = config.resticBackendPass,
|
||||
backendShare = config.resticBackendShare
|
||||
)
|
||||
}
|
||||
} else {
|
||||
allApps = toBackup
|
||||
includePkgs = emptySet()
|
||||
}
|
||||
|
||||
// ── Execute backup (with cumulative metadata) ──
|
||||
updateStatus("正在备份: ${allApps.size} 个应用…")
|
||||
val result = BackupOperation.backupApps(
|
||||
context = requireContext(),
|
||||
apps = allApps,
|
||||
config = config,
|
||||
outputDir = outputDir,
|
||||
userId = selectedUserId.toString(),
|
||||
noDataBackup = excludeDataFromBackup.toSet(),
|
||||
includePkgs = includePkgs,
|
||||
legacyApps = snapshotApps,
|
||||
onProgress = { progress ->
|
||||
val label = allApps.find { it.packageName.value == progress.packageName }?.label
|
||||
val name = label?.ifEmpty { progress.packageName } ?: progress.packageName
|
||||
updateStatus("[${progress.current}/${progress.total}] $name: ${progress.message}")
|
||||
}
|
||||
)
|
||||
|
||||
// Store WiFi config inside Backup_* directory so restic/local restore can find it
|
||||
WifiManager.backup(File(result.outputDir))
|
||||
|
||||
// If restic is enabled, snapshot to repository
|
||||
var resticSummary: ResticWrapper.BackupSummary? = null
|
||||
var resticError: String? = null
|
||||
if (config.resticEnabled == 1 && config.resticRepo.isNotBlank()) {
|
||||
val binaryPath = ResticBinary.prepare(requireContext())
|
||||
if (binaryPath != null) {
|
||||
ResticWrapper.binaryPath = binaryPath
|
||||
ResticWrapper.cacheDir = requireContext().cacheDir.absolutePath
|
||||
ResticWrapper.backendDomain = config.resticBackendDomain
|
||||
|
||||
if (config.resticBackend == "local") {
|
||||
if (!File(config.resticRepo, "config").exists()) {
|
||||
updateStatus("restic 本地仓库未初始化,请先在设置中初始化")
|
||||
return@launch
|
||||
}
|
||||
}
|
||||
updateStatus("正在写入 restic 去重仓库…")
|
||||
val resticResult = ResticWrapper.backup(
|
||||
repoPath = config.resticRepo,
|
||||
password = config.resticPassword,
|
||||
paths = listOf(result.outputDir),
|
||||
tags = listOf("backup_${System.currentTimeMillis() / 1000}"),
|
||||
hostname = "android-backup-gui",
|
||||
backend = config.resticBackend,
|
||||
backendUrl = config.resticBackendUrl,
|
||||
backendUser = config.resticBackendUser,
|
||||
backendPass = config.resticBackendPass,
|
||||
backendShare = config.resticBackendShare,
|
||||
onProgress = { progress ->
|
||||
if (progress.messageType == "status") {
|
||||
updateStatus("去重仓库: %.0f%% (%d/%d 个文件)".format(
|
||||
progress.percentDone * 100,
|
||||
progress.filesDone,
|
||||
progress.totalFiles
|
||||
))
|
||||
}
|
||||
}
|
||||
)
|
||||
when (resticResult) {
|
||||
is AppResult.Success -> resticSummary = resticResult.data
|
||||
is AppResult.Failure -> {
|
||||
resticError = resticResult.error.message
|
||||
updateStatus("restic 快照失败: ${resticResult.error.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateStatus(buildString {
|
||||
appendLine("备份完成!")
|
||||
appendLine("成功: ${result.successCount} 失败: ${result.failCount}")
|
||||
appendLine("耗时: ${result.elapsedMs / 1000}秒")
|
||||
appendLine("输出: ${result.outputDir}")
|
||||
appendLine("模式: 累积快照")
|
||||
val summary = resticSummary
|
||||
if (summary != null) {
|
||||
appendLine()
|
||||
appendLine("── Restic 快照 ──")
|
||||
appendLine("ID: ${summary.snapshotId.take(8)}…")
|
||||
appendLine("新增: ${summary.dataAdded / 1024 / 1024} MB")
|
||||
appendLine("文件: ${summary.totalFilesProcessed}")
|
||||
} else {
|
||||
val err = resticError
|
||||
if (err != null) {
|
||||
appendLine()
|
||||
appendLine("── Restic 错误 ──")
|
||||
appendLine(err)
|
||||
}
|
||||
}
|
||||
})
|
||||
} catch (e: kotlinx.coroutines.CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
updateStatus("备份异常: ${e.message}")
|
||||
} finally {
|
||||
setRunning(false)
|
||||
binding.backupButton.isEnabled = true
|
||||
binding.scanButton.isEnabled = true
|
||||
// Stop foreground service
|
||||
try {
|
||||
val stopIntent = Intent(requireContext(), BackupService::class.java)
|
||||
stopIntent.action = BackupService.ACTION_STOP_BACKUP
|
||||
requireContext().startService(stopIntent)
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private fun setRunning(running: Boolean) {
|
||||
binding.progressBar.visibility = if (running) View.VISIBLE else View.GONE
|
||||
}
|
||||
|
||||
private suspend fun updateStatus(text: String) {
|
||||
withContext(Dispatchers.Main) { binding.statusText.text = text }
|
||||
}
|
||||
|
||||
private fun updateOutputPathDisplay() {
|
||||
val path = config.outputPath.ifEmpty { requireContext().filesDir.absolutePath }
|
||||
binding.outputPathLabel.text = path
|
||||
}
|
||||
|
||||
|
||||
|
||||
private fun showOutputPathEditDialog() {
|
||||
val editText = android.widget.EditText(requireContext()).apply {
|
||||
setText(config.outputPath)
|
||||
hint = requireContext().filesDir.absolutePath
|
||||
}
|
||||
com.google.android.material.dialog.MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle("修改输出目录")
|
||||
.setView(editText)
|
||||
.setPositiveButton("确定") { _, _ ->
|
||||
val newPath = editText.text.toString().trim()
|
||||
config = config.copy(outputPath = newPath)
|
||||
BackupConfig.toFile(config, File(requireContext().filesDir, "backup_settings.conf"))
|
||||
updateOutputPathDisplay()
|
||||
}
|
||||
.setNegativeButton("取消", null)
|
||||
.show()
|
||||
}
|
||||
|
||||
// ── Space detection & streaming backup ────────────
|
||||
|
||||
/**
|
||||
* Estimate the total size of data to back up using `du -sb`.
|
||||
* Only counts data directories (not APKs) since that's the bulk.
|
||||
*/
|
||||
private suspend fun estimateBackupSize(apps: List<com.example.androidbackupgui.backup.AppInfo>): Long = withContext(Dispatchers.IO) {
|
||||
var total = 0L
|
||||
for (app in apps) {
|
||||
val pkgEsc = app.packageName.value.shellEscape()
|
||||
val result = RootShell.exec("du -sb /data/data/$pkgEsc 2>/dev/null | cut -f1")
|
||||
val size = result.output.trim().toLongOrNull() ?: 0L
|
||||
total += size
|
||||
}
|
||||
total
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if [path] has at least [neededBytes] bytes free.
|
||||
* Uses [StatFs] to query the filesystem.
|
||||
*/
|
||||
private fun hasEnoughSpace(path: File, neededBytes: Long): Boolean {
|
||||
try {
|
||||
val stat = StatFs(path.absolutePath)
|
||||
val available = stat.availableBlocksLong * stat.blockSizeLong
|
||||
// Require 1.5x headroom for temp files and metadata
|
||||
return available >= neededBytes * 3 / 2
|
||||
} catch (_: Exception) {
|
||||
// If we can't check, assume enough space (staging mode)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run streaming backup via [StreamingBackup] + [ResticWrapper.backupStdin].
|
||||
* Used when staging space is insufficient.
|
||||
*/
|
||||
@Suppress("UNUSED_PARAMETER")
|
||||
private suspend fun runStreamingResticBackup(
|
||||
config: com.example.androidbackupgui.backup.BackupConfig,
|
||||
apps: List<com.example.androidbackupgui.backup.AppInfo>,
|
||||
outputDir: File,
|
||||
cacheDir: String
|
||||
): ResticWrapper.BackupSummary? {
|
||||
updateStatus("空间不足,启动流式备份模式…")
|
||||
|
||||
val cacheDirFile = File(cacheDir, "streaming_tmp")
|
||||
cacheDirFile.mkdirs()
|
||||
|
||||
// Prepare streaming: create FIFO, metadata, collect APK paths
|
||||
val streamingResult = StreamingBackup.prepareStreaming(
|
||||
cacheDirFile, apps, null
|
||||
)
|
||||
|
||||
// Start restic with stdin from FIFO, in parallel with data producer
|
||||
var summary: ResticWrapper.BackupSummary? = null
|
||||
var backupError: String? = null
|
||||
|
||||
coroutineScope {
|
||||
// Launch restic backup (consumer)
|
||||
val resticJob = async {
|
||||
val result = ResticWrapper.backupStdin(
|
||||
repoPath = config.resticRepo,
|
||||
password = config.resticPassword,
|
||||
stdinFile = streamingResult.dataFifo,
|
||||
extraPaths = streamingResult.apkPaths + streamingResult.metaDir.absolutePath,
|
||||
tags = listOf("streaming_${System.currentTimeMillis() / 1000}"),
|
||||
hostname = "android-backup-gui",
|
||||
backend = config.resticBackend,
|
||||
backendUrl = config.resticBackendUrl,
|
||||
backendUser = config.resticBackendUser,
|
||||
backendPass = config.resticBackendPass,
|
||||
backendShare = config.resticBackendShare,
|
||||
onProgress = { progress ->
|
||||
if (progress.messageType == "status") {
|
||||
updateStatus("流式去重仓库: %.0f%% (%d/%d 个文件)".format(
|
||||
progress.percentDone * 100,
|
||||
progress.filesDone,
|
||||
progress.totalFiles
|
||||
))
|
||||
}
|
||||
}
|
||||
)
|
||||
when (result) {
|
||||
is AppResult.Success -> summary = result.data
|
||||
is AppResult.Failure -> backupError = result.error.message
|
||||
}
|
||||
}
|
||||
|
||||
// Launch data producer (writes tar to FIFO)
|
||||
val producerJob = async {
|
||||
StreamingBackup.launchDataProducer(
|
||||
apps = apps,
|
||||
noDataBackup = excludeDataFromBackup.toSet(),
|
||||
userId = selectedUserId.toString(),
|
||||
fifoPath = streamingResult.dataFifo.absolutePath
|
||||
)
|
||||
}
|
||||
|
||||
// Wait for both to complete
|
||||
producerJob.await()
|
||||
resticJob.await()
|
||||
}
|
||||
|
||||
// Cleanup FIFO
|
||||
try { streamingResult.dataFifo.delete() } catch (_: Exception) {}
|
||||
try { streamingResult.metaDir.deleteRecursively() } catch (_: Exception) {}
|
||||
|
||||
if (backupError != null) {
|
||||
updateStatus("流式备份失败: $backupError")
|
||||
}
|
||||
return summary
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package com.example.androidbackupgui.ui
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.SortByAlpha
|
||||
import androidx.compose.material.icons.filled.Storage
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.example.androidbackupgui.backup.AppInfo
|
||||
|
||||
/**
|
||||
* 备份主页——应用选择、扫描和备份执行。
|
||||
*
|
||||
* 业务逻辑在 [BackupViewModel] 中,UI 只负责渲染和事件转发。
|
||||
*/
|
||||
@Composable
|
||||
fun BackupScreen(viewModel: BackupViewModel = viewModel()) {
|
||||
val context = LocalContext.current
|
||||
val state by viewModel.state.collectAsState()
|
||||
|
||||
LaunchedEffect(state.allApps, state.sortMode, state.showSystemApps) {
|
||||
viewModel.applySortAndFilter()
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
// ── Top controls card ──
|
||||
Card(modifier = Modifier.fillMaxWidth().padding(12.dp)) {
|
||||
Column(modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
// Scan button
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(
|
||||
onClick = { viewModel.scanApps(context) },
|
||||
enabled = !state.isScanning && !state.isRunning,
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
if (state.isScanning) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(16.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
}
|
||||
Text("扫描应用")
|
||||
}
|
||||
}
|
||||
|
||||
// Sort/filter row
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
FilterChip(
|
||||
selected = state.sortMode == SortMode.NAME_ASC,
|
||||
onClick = { viewModel.setSortMode(SortMode.NAME_ASC) },
|
||||
label = { Text("A-Z") },
|
||||
leadingIcon = { Icon(Icons.Default.SortByAlpha, contentDescription = null, modifier = Modifier.size(16.dp)) },
|
||||
)
|
||||
FilterChip(
|
||||
selected = state.sortMode == SortMode.SIZE_DESC,
|
||||
onClick = { viewModel.setSortMode(SortMode.SIZE_DESC) },
|
||||
label = { Text("大小") },
|
||||
leadingIcon = { Icon(Icons.Default.Storage, contentDescription = null, modifier = Modifier.size(16.dp)) },
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
TextButton(onClick = { viewModel.selectAll() }) { Text("全选") }
|
||||
TextButton(onClick = { viewModel.clearSelection() }) { Text("取消全选") }
|
||||
}
|
||||
|
||||
// Show system switch
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("显示系统应用", modifier = Modifier.weight(1f))
|
||||
Switch(checked = state.showSystemApps, onCheckedChange = { viewModel.toggleShowSystem() })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Status ──
|
||||
Text(
|
||||
text = state.statusText,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||
)
|
||||
|
||||
// ── App list ──
|
||||
LazyColumn(
|
||||
modifier = Modifier.weight(1f).fillMaxWidth(),
|
||||
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
items(state.sortedApps, key = { it.packageName.value }) { app ->
|
||||
AppListItem(
|
||||
app = app,
|
||||
isSelected = app.packageName.value in state.selectedApps,
|
||||
isDataExcluded = app.packageName.value in state.excludeDataFromBackup,
|
||||
onToggle = { checked -> viewModel.toggleApp(app.packageName.value, checked) },
|
||||
onExcludeDataToggle = { excluded -> viewModel.toggleExcludeData(app.packageName.value, excluded) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Bottom bar with backup button ──
|
||||
Surface(modifier = Modifier.fillMaxWidth(), tonalElevation = 3.dp) {
|
||||
Button(
|
||||
onClick = { viewModel.executeBackup(context) },
|
||||
enabled = !state.isRunning && state.selectedApps.isNotEmpty(),
|
||||
modifier = Modifier.fillMaxWidth().padding(12.dp),
|
||||
) {
|
||||
if (state.isRunning) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(16.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
}
|
||||
Text("开始备份 (${state.selectedApps.size})")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AppListItem(
|
||||
app: AppInfo,
|
||||
isSelected: Boolean,
|
||||
isDataExcluded: Boolean,
|
||||
onToggle: (Boolean) -> Unit,
|
||||
onExcludeDataToggle: (Boolean) -> Unit,
|
||||
) {
|
||||
Card(
|
||||
onClick = { onToggle(!isSelected) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Checkbox(checked = isSelected, onCheckedChange = { onToggle(it) })
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = app.label.ifEmpty { app.packageName.value },
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
Text(
|
||||
text = app.packageName.value,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
if (isSelected) {
|
||||
TextButton(onClick = { onExcludeDataToggle(!isDataExcluded) }) {
|
||||
Text(
|
||||
"数据",
|
||||
textDecoration = if (isDataExcluded) TextDecoration.LineThrough else TextDecoration.None,
|
||||
color = if (isDataExcluded) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
package com.example.androidbackupgui.ui
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.example.androidbackupgui.backup.*
|
||||
import com.example.androidbackupgui.backup.AppResult
|
||||
import com.example.androidbackupgui.backup.BackupService.Companion.ACTION_START_BACKUP
|
||||
import com.example.androidbackupgui.backup.BackupService.Companion.ACTION_STOP_BACKUP
|
||||
import com.example.androidbackupgui.backup.BackupService.Companion.EXTRA_STATUS_TEXT
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import java.util.Locale
|
||||
|
||||
enum class SortMode { NAME_ASC, SIZE_DESC }
|
||||
|
||||
/** Backup 界面的完整 UI 状态。 */
|
||||
data class BackupUiState(
|
||||
val config: BackupConfig = BackupConfig(),
|
||||
val allApps: List<AppInfo> = emptyList(),
|
||||
val sortedApps: List<AppInfo> = emptyList(),
|
||||
val selectedApps: Set<String> = emptySet(),
|
||||
val excludeDataFromBackup: Set<String> = emptySet(),
|
||||
val sortMode: SortMode = SortMode.NAME_ASC,
|
||||
val showSystemApps: Boolean = false,
|
||||
val statusText: String = "请先扫描应用",
|
||||
val isRunning: Boolean = false,
|
||||
val isScanning: Boolean = false,
|
||||
)
|
||||
|
||||
/** 备份操作的一次性事件。 */
|
||||
sealed interface BackupEvent {
|
||||
data class Error(
|
||||
val message: String,
|
||||
) : BackupEvent
|
||||
|
||||
data class BackupCompleted(
|
||||
val result: BackupOperation.BackupResult,
|
||||
) : BackupEvent
|
||||
}
|
||||
|
||||
class BackupViewModel(
|
||||
application: Application,
|
||||
) : AndroidViewModel(application) {
|
||||
companion object {
|
||||
private const val TAG = "BackupViewModel"
|
||||
}
|
||||
|
||||
private val _state = MutableStateFlow(BackupUiState())
|
||||
val state: StateFlow<BackupUiState> = _state.asStateFlow()
|
||||
|
||||
private var currentJob: Job? = null
|
||||
|
||||
init {
|
||||
// 加载配置文件
|
||||
val cfg = BackupConfig.fromFile(File(application.filesDir, "backup_settings.conf"))
|
||||
_state.update { it.copy(config = cfg) }
|
||||
}
|
||||
|
||||
// ── 应用列表排序/过滤 ──────────────────────────────
|
||||
|
||||
fun applySortAndFilter() {
|
||||
val s = _state.value
|
||||
val filtered = if (s.showSystemApps) s.allApps else s.allApps.filter { !it.isSystem }
|
||||
val sorted =
|
||||
when (s.sortMode) {
|
||||
SortMode.NAME_ASC -> filtered.sortedBy { it.label.lowercase(Locale.US) }
|
||||
SortMode.SIZE_DESC -> filtered.sortedByDescending { it.backupSize }
|
||||
}
|
||||
_state.update { it.copy(sortedApps = sorted) }
|
||||
}
|
||||
|
||||
fun setSortMode(mode: SortMode) {
|
||||
_state.update { it.copy(sortMode = mode) }
|
||||
applySortAndFilter()
|
||||
}
|
||||
|
||||
fun toggleShowSystem() {
|
||||
_state.update { it.copy(showSystemApps = !it.showSystemApps) }
|
||||
applySortAndFilter()
|
||||
}
|
||||
|
||||
fun selectAll() {
|
||||
val pkgs =
|
||||
_state.value.sortedApps
|
||||
.map { it.packageName.value }
|
||||
.toSet()
|
||||
_state.update { it.copy(selectedApps = pkgs) }
|
||||
}
|
||||
|
||||
fun clearSelection() {
|
||||
_state.update { it.copy(selectedApps = emptySet()) }
|
||||
}
|
||||
|
||||
fun toggleApp(
|
||||
packageName: String,
|
||||
checked: Boolean,
|
||||
) {
|
||||
_state.update { s ->
|
||||
s.copy(selectedApps = if (checked) s.selectedApps + packageName else s.selectedApps - packageName)
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleExcludeData(
|
||||
packageName: String,
|
||||
excluded: Boolean,
|
||||
) {
|
||||
_state.update { s ->
|
||||
s.copy(excludeDataFromBackup = if (excluded) s.excludeDataFromBackup + packageName else s.excludeDataFromBackup - packageName)
|
||||
}
|
||||
}
|
||||
|
||||
// ── 扫描应用 ────────────────────────────────────────
|
||||
|
||||
fun scanApps(context: Context) {
|
||||
if (_state.value.isScanning) return
|
||||
_state.update { it.copy(isScanning = true, statusText = "正在扫描应用…") }
|
||||
val config = _state.value.config
|
||||
|
||||
currentJob =
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val userId = config.backupUserId
|
||||
val thirdParty = withContext(Dispatchers.IO) { AppScanner.scanThirdParty(context, userId = userId) }
|
||||
val system = withContext(Dispatchers.IO) { AppScanner.scanSystem(context, config, userId = userId) }
|
||||
val apps = if (_state.value.showSystemApps) thirdParty + system else thirdParty
|
||||
|
||||
val allPkgNames = apps.map { it.packageName.value }.toSet()
|
||||
var excludeSet = emptySet<String>()
|
||||
|
||||
val appListFile = File(context.filesDir, "appList.txt")
|
||||
if (appListFile.exists()) {
|
||||
val content = appListFile.readText()
|
||||
val parsed = AppScanner.parseAppList(content)
|
||||
val fromPrefix = parsed.filter { it.first in allPkgNames && !it.second }.map { it.first }.toSet()
|
||||
if (fromPrefix.isNotEmpty()) excludeSet = fromPrefix
|
||||
}
|
||||
|
||||
_state.update {
|
||||
it.copy(
|
||||
allApps = apps,
|
||||
sortedApps = apps,
|
||||
selectedApps = allPkgNames,
|
||||
excludeDataFromBackup = excludeSet,
|
||||
statusText =
|
||||
if (excludeSet.isNotEmpty()) {
|
||||
"共找到 ${apps.size} 个应用,${excludeSet.size} 个标记为仅APK"
|
||||
} else {
|
||||
"共找到 ${apps.size} 个应用,全部已选中"
|
||||
},
|
||||
isScanning = false,
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_state.update { it.copy(statusText = "扫描应用失败: ${e.message}", isScanning = false) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 执行备份 ────────────────────────────────────────
|
||||
|
||||
fun executeBackup(context: Context) {
|
||||
val s = _state.value
|
||||
val toBackup = s.allApps.filter { it.packageName.value in s.selectedApps }
|
||||
if (toBackup.isEmpty()) return
|
||||
|
||||
_state.update { it.copy(isRunning = true, statusText = "开始备份 ${toBackup.size} 个应用…") }
|
||||
|
||||
currentJob =
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
// 1. 启动前台服务
|
||||
val serviceIntent =
|
||||
Intent(context, BackupService::class.java).apply {
|
||||
action = ACTION_START_BACKUP
|
||||
putExtra(EXTRA_STATUS_TEXT, "正在备份 ${toBackup.size} 个应用…")
|
||||
}
|
||||
try {
|
||||
ContextCompat.startForegroundService(context, serviceIntent)
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
|
||||
// 2. 执行备份
|
||||
val outputDir = File(s.config.outputPath.ifEmpty { context.filesDir.absolutePath })
|
||||
val backupResult =
|
||||
withContext(Dispatchers.IO) {
|
||||
BackupOperation.backupApps(
|
||||
context = context,
|
||||
apps = toBackup,
|
||||
config = s.config,
|
||||
outputDir = outputDir,
|
||||
userId = s.config.backupUserId.toString(),
|
||||
noDataBackup = s.excludeDataFromBackup,
|
||||
onProgress = { progress ->
|
||||
_state.update {
|
||||
it.copy(
|
||||
statusText = "[${progress.current}/${progress.total}] ${progress.packageName}: ${progress.message}",
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
_state.update {
|
||||
it.copy(
|
||||
statusText = "备份完成!成功: ${backupResult.successCount} 失败: ${backupResult.failCount} 耗时: ${backupResult.elapsedMs / 1000}s",
|
||||
)
|
||||
}
|
||||
|
||||
// 3. WiFi 备份
|
||||
if (s.config.backupWifi == 1) {
|
||||
WifiManager.backup(File(backupResult.outputDir))
|
||||
}
|
||||
|
||||
// 4. Restic 上传
|
||||
if (s.config.resticEnabled == 1 && s.config.resticRepo.isNotBlank()) {
|
||||
executeResticBackup(context, toBackup, s, backupResult)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
val hint =
|
||||
when {
|
||||
e.message?.contains("EPERM", ignoreCase = true) == true -> "写入备份目录被拒绝,请检查输出路径权限"
|
||||
e.message?.contains("EACCES", ignoreCase = true) == true -> "权限不足,请检查存储权限"
|
||||
else -> null
|
||||
}
|
||||
_state.update { it.copy(statusText = "备份异常: ${e.message}" + (hint?.let { " ($it)" } ?: "")) }
|
||||
} finally {
|
||||
_state.update { it.copy(isRunning = false) }
|
||||
try {
|
||||
context.startService(Intent(context, BackupService::class.java).apply { action = ACTION_STOP_BACKUP })
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun executeResticBackup(
|
||||
context: Context,
|
||||
toBackup: List<AppInfo>,
|
||||
s: BackupUiState,
|
||||
backupResult: BackupOperation.BackupResult,
|
||||
) {
|
||||
val binaryPath = ResticBinary.prepare(context) ?: return
|
||||
defaultResticWrapper.binaryPath = binaryPath
|
||||
defaultResticWrapper.cacheDir = context.cacheDir.absolutePath
|
||||
defaultResticWrapper.backendDomain = s.config.resticBackendDomain
|
||||
val password = PasswordManager.getResticPassword() ?: s.config.resticPassword.takeIf { it.isNotEmpty() } ?: ""
|
||||
val backendPass = PasswordManager.getBackendPass() ?: s.config.resticBackendPass.takeIf { it.isNotEmpty() } ?: ""
|
||||
|
||||
if (s.config.useStreaming == 1) {
|
||||
defaultResticWrapper
|
||||
.backupStreaming(
|
||||
apps = toBackup,
|
||||
noDataBackup = s.excludeDataFromBackup,
|
||||
legacyApps = null,
|
||||
ownPackageName = context.packageName,
|
||||
userId = s.config.backupUserId.toString(),
|
||||
repoPath = s.config.resticRepo,
|
||||
password = password,
|
||||
tags = listOf("backup_${System.currentTimeMillis() / 1000}"),
|
||||
hostname = "android-backup-gui",
|
||||
backend = s.config.resticBackend,
|
||||
backendUrl = s.config.resticBackendUrl,
|
||||
backendUser = s.config.resticBackendUser,
|
||||
backendPass = backendPass,
|
||||
backendShare = s.config.resticBackendShare,
|
||||
onProgress = { msg -> _state.update { it.copy(statusText = msg) } },
|
||||
).let { result ->
|
||||
when (result) {
|
||||
is AppResult.Success -> {
|
||||
val summary = result.getOrNull()
|
||||
_state.update {
|
||||
it.copy(
|
||||
statusText = "流式备份完成!ID: ${summary?.snapshotId?.take(
|
||||
8,
|
||||
)}… 新增: ${(summary?.dataAdded ?: 0) / 1024 / 1024} MB",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is AppResult.Failure -> {
|
||||
_state.update { it.copy(statusText = "流式备份失败: ${result.errorOrNull()?.message}") }
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
defaultResticWrapper
|
||||
.backup(
|
||||
repoPath = s.config.resticRepo,
|
||||
password = password,
|
||||
paths = listOf(backupResult.outputDir),
|
||||
tags = listOf("backup_${System.currentTimeMillis() / 1000}"),
|
||||
hostname = "android-backup-gui",
|
||||
backend = s.config.resticBackend,
|
||||
backendUrl = s.config.resticBackendUrl,
|
||||
backendUser = s.config.resticBackendUser,
|
||||
backendPass = backendPass,
|
||||
backendShare = s.config.resticBackendShare,
|
||||
onProgress = { progress ->
|
||||
if (progress.messageType == "status") {
|
||||
_state.update {
|
||||
it.copy(
|
||||
statusText =
|
||||
"去重仓库: %.0f%% (%d/%d 个文件)".format(
|
||||
progress.percentDone * 100,
|
||||
progress.filesDone,
|
||||
progress.totalFiles,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
).let { result ->
|
||||
when (result) {
|
||||
is AppResult.Success -> {
|
||||
val summary = result.getOrNull()
|
||||
_state.update {
|
||||
it.copy(
|
||||
statusText = "备份完成!Restic ID: ${summary?.snapshotId?.take(
|
||||
8,
|
||||
)}… 新增: ${(summary?.dataAdded ?: 0) / 1024 / 1024} MB",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is AppResult.Failure -> {
|
||||
_state.update { it.copy(statusText = "restic 快照失败: ${result.errorOrNull()?.message}") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,250 +0,0 @@
|
||||
package com.example.androidbackupgui.ui
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.widget.doAfterTextChanged
|
||||
import androidx.fragment.app.Fragment
|
||||
import android.util.Log
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import com.example.androidbackupgui.R
|
||||
import com.example.androidbackupgui.backup.BackupConfig
|
||||
import com.example.androidbackupgui.databinding.FragmentConfigBinding
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import com.example.androidbackupgui.backup.ResticWrapper
|
||||
|
||||
class ConfigFragment : Fragment() {
|
||||
|
||||
companion object { private const val TAG = "ConfigFragment" }
|
||||
|
||||
private var _binding: FragmentConfigBinding? = null
|
||||
private val binding get() = _binding!!
|
||||
private val vm: ConfigViewModel by viewModels()
|
||||
private var formLoading = false
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
|
||||
): View {
|
||||
_binding = FragmentConfigBinding.inflate(inflater, container, false)
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
// Load config from file into ViewModel state
|
||||
vm.load()
|
||||
|
||||
// Populate form fields from initial state (prevents listener chain)
|
||||
loadForm()
|
||||
|
||||
// ── Change listeners ─────────────────────────────────────────
|
||||
binding.saveConfigButton.setOnClickListener { saveConfig() }
|
||||
binding.resticBackendGroup.addOnButtonCheckedListener { _, _, _ ->
|
||||
onBackendChanged(); refreshResticStatus()
|
||||
}
|
||||
binding.resticEnabledSwitch.setOnCheckedChangeListener { _, _ -> refreshResticStatus() }
|
||||
binding.resticRepoEdit.doAfterTextChanged {
|
||||
if (formLoading) return@doAfterTextChanged
|
||||
onFormChanged()
|
||||
refreshResticStatus()
|
||||
}
|
||||
binding.resticBackendUrlEdit.doAfterTextChanged {
|
||||
if (formLoading) return@doAfterTextChanged
|
||||
onFormChanged()
|
||||
}
|
||||
binding.resticPasswordEdit.doAfterTextChanged {
|
||||
if (formLoading) return@doAfterTextChanged
|
||||
refreshResticStatus()
|
||||
}
|
||||
binding.initResticButton.setOnClickListener { initResticRepo() }
|
||||
binding.resticStatsButton.setOnClickListener { showResticStats() }
|
||||
binding.resticPruneButton.setOnClickListener { pruneResticSnapshots() }
|
||||
|
||||
// Initial async status check
|
||||
refreshResticStatus()
|
||||
|
||||
// Observe ViewModel state and one-shot operation events
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
launch {
|
||||
vm.uiState.collect { state -> applyState(state) }
|
||||
}
|
||||
launch {
|
||||
vm.operationEvents.collect { event -> handleOperationEvent(event) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Initial form population ──────────────────────────────────────
|
||||
|
||||
/** Populate EditTexts from ViewModel's current config. */
|
||||
private fun loadForm() {
|
||||
formLoading = true
|
||||
val config = vm.uiState.value.config
|
||||
binding.backupModeSwitch.isChecked = config.backupMode == 1
|
||||
binding.backupUserDataSwitch.isChecked = config.backupUserData == 1
|
||||
binding.backupObbSwitch.isChecked = config.backupObbData == 1
|
||||
binding.backupWifiSwitch.isChecked = config.backupWifi == 1
|
||||
binding.ignoreRunningSwitch.isChecked = config.backgroundAppsIgnore == 1
|
||||
binding.outputPathEdit.setText(config.outputPath)
|
||||
binding.compressionEdit.setText(config.compressionMethod)
|
||||
|
||||
binding.resticEnabledSwitch.isChecked = config.resticEnabled == 1
|
||||
binding.resticRepoEdit.setText(config.resticRepo)
|
||||
binding.resticPasswordEdit.setText(config.resticPassword)
|
||||
binding.resticBackendUrlEdit.setText(config.resticBackendUrl)
|
||||
binding.resticBackendUserEdit.setText(config.resticBackendUser)
|
||||
binding.resticBackendPassEdit.setText(config.resticBackendPass)
|
||||
binding.resticBackendShareEdit.setText(config.resticBackendShare)
|
||||
binding.resticBackendDomainEdit.setText(config.resticBackendDomain)
|
||||
|
||||
binding.resticBackendGroup.check(
|
||||
when (config.resticBackend) {
|
||||
"webdav" -> R.id.resticBackendWebdav
|
||||
"smb" -> R.id.resticBackendSmb
|
||||
"rest-server" -> R.id.resticBackendRestServer
|
||||
else -> R.id.resticBackendLocal
|
||||
}
|
||||
)
|
||||
formLoading = false
|
||||
}
|
||||
|
||||
// ── StateFlow observer ───────────────────────────────────────────
|
||||
|
||||
/** Apply ViewModel state to non-form views (visibility, text, enabled). */
|
||||
private fun applyState(state: ConfigUiState) {
|
||||
with(state.backendDisplay) {
|
||||
binding.resticBackendUrlLayout.visibility = if (isRemote) View.VISIBLE else View.GONE
|
||||
binding.resticBackendShareLayout.visibility = if (isSmb) View.VISIBLE else View.GONE
|
||||
binding.resticBackendDomainLayout.visibility = if (isSmb) View.VISIBLE else View.GONE
|
||||
binding.resticBackendUserLayout.visibility = if (needsAuth) View.VISIBLE else View.GONE
|
||||
binding.resticBackendPassLayout.visibility = if (needsAuth) View.VISIBLE else View.GONE
|
||||
binding.resticBackendUrlLayout.hint = urlHint
|
||||
binding.resticComputedUrlText.text = if (state.config.resticRepo.isNotEmpty())
|
||||
"实际仓库: $computedUrl" else ""
|
||||
}
|
||||
with(state.resticStatus) {
|
||||
binding.resticStatusText.text = message
|
||||
binding.initResticButton.isEnabled = initButtonEnabled
|
||||
binding.initResticButton.visibility = if (initButtonVisible) View.VISIBLE else View.GONE
|
||||
binding.resticStatsButton.isEnabled = statsButtonEnabled
|
||||
binding.resticStatsButton.visibility = if (statsButtonVisible) View.VISIBLE else View.GONE
|
||||
binding.resticPruneButton.isEnabled = pruneButtonEnabled
|
||||
binding.resticPruneButton.visibility = if (pruneButtonVisible) View.VISIBLE else View.GONE
|
||||
}
|
||||
}
|
||||
|
||||
// ── One-shot operation event handler ──────────────────────────────
|
||||
|
||||
/** Handle one-shot lifecycle events from ViewModel. */
|
||||
private fun handleOperationEvent(event: OperationEvent) {
|
||||
when (event) {
|
||||
is OperationEvent.InitStarted -> Log.d(TAG, "init started")
|
||||
is OperationEvent.InitCompleted -> {
|
||||
Log.d(TAG, "init completed")
|
||||
Snackbar.make(binding.root, "仓库初始化完成", Snackbar.LENGTH_SHORT).show()
|
||||
}
|
||||
is OperationEvent.InitFailed -> {
|
||||
Log.d(TAG, "init failed")
|
||||
Snackbar.make(binding.root, "仓库初始化失败", Snackbar.LENGTH_SHORT).show()
|
||||
}
|
||||
is OperationEvent.StatsStarted -> Log.d(TAG, "stats started")
|
||||
is OperationEvent.StatsCompleted -> {
|
||||
Log.d(TAG, "stats completed")
|
||||
Snackbar.make(binding.root, "统计读取完成", Snackbar.LENGTH_SHORT).show()
|
||||
}
|
||||
is OperationEvent.PruneStarted -> Log.d(TAG, "prune started")
|
||||
is OperationEvent.PruneFailed -> {
|
||||
Log.d(TAG, "prune failed")
|
||||
Snackbar.make(binding.root, "清理失败", Snackbar.LENGTH_SHORT).show()
|
||||
}
|
||||
is OperationEvent.PruneCompleted -> {
|
||||
Log.d(TAG, "prune completed")
|
||||
Snackbar.make(binding.root, "清理完成", Snackbar.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Form building helpers ────────────────────────────────────────
|
||||
|
||||
private fun readBackend(): String = when (binding.resticBackendGroup.checkedButtonId) {
|
||||
R.id.resticBackendWebdav -> "webdav"
|
||||
R.id.resticBackendSmb -> "smb"
|
||||
R.id.resticBackendRestServer -> "rest-server"
|
||||
else -> "local"
|
||||
}
|
||||
|
||||
private fun readResticForm() = ResticForm(
|
||||
repo = binding.resticRepoEdit.text?.toString()?.trim() ?: "",
|
||||
password = binding.resticPasswordEdit.text?.toString() ?: "",
|
||||
backend = readBackend(),
|
||||
backendUrl = binding.resticBackendUrlEdit.text?.toString()?.trim() ?: "",
|
||||
backendUser = binding.resticBackendUserEdit.text?.toString()?.trim() ?: "",
|
||||
backendPass = binding.resticBackendPassEdit.text?.toString() ?: "",
|
||||
backendShare = binding.resticBackendShareEdit.text?.toString()?.trim() ?: "",
|
||||
backendDomain = binding.resticBackendDomainEdit.text?.toString()?.trim() ?: ""
|
||||
)
|
||||
|
||||
// ── User actions ─────────────────────────────────────────────────
|
||||
|
||||
private fun saveConfig() {
|
||||
vm.save(BackupConfig(
|
||||
backupMode = if (binding.backupModeSwitch.isChecked) 1 else 0,
|
||||
backupUserData = if (binding.backupUserDataSwitch.isChecked) 1 else 0,
|
||||
backupObbData = if (binding.backupObbSwitch.isChecked) 1 else 0,
|
||||
backupWifi = if (binding.backupWifiSwitch.isChecked) 1 else 0,
|
||||
backgroundAppsIgnore = if (binding.ignoreRunningSwitch.isChecked) 1 else 0,
|
||||
outputPath = binding.outputPathEdit.text?.toString() ?: "",
|
||||
compressionMethod = binding.compressionEdit.text?.toString()?.ifEmpty { "zstd" } ?: "zstd",
|
||||
resticEnabled = if (binding.resticEnabledSwitch.isChecked) 1 else 0,
|
||||
resticRepo = binding.resticRepoEdit.text?.toString()?.trim() ?: "",
|
||||
resticPassword = binding.resticPasswordEdit.text?.toString() ?: "",
|
||||
resticBackend = readBackend(),
|
||||
resticBackendUrl = binding.resticBackendUrlEdit.text?.toString()?.trim() ?: "",
|
||||
resticBackendUser = binding.resticBackendUserEdit.text?.toString()?.trim() ?: "",
|
||||
resticBackendPass = binding.resticBackendPassEdit.text?.toString() ?: "",
|
||||
resticBackendShare = binding.resticBackendShareEdit.text?.toString()?.trim() ?: "",
|
||||
resticBackendDomain = binding.resticBackendDomainEdit.text?.toString()?.trim() ?: "",
|
||||
))
|
||||
}
|
||||
|
||||
private fun onFormChanged() {
|
||||
val backend = readBackend()
|
||||
val repo = binding.resticRepoEdit.text?.toString()?.trim() ?: ""
|
||||
val url = binding.resticBackendUrlEdit.text?.toString()?.trim() ?: ""
|
||||
vm.onFormChanged(backend, repo, url)
|
||||
}
|
||||
|
||||
private fun onBackendChanged() {
|
||||
val backend = readBackend()
|
||||
val repo = binding.resticRepoEdit.text?.toString()?.trim() ?: ""
|
||||
val url = binding.resticBackendUrlEdit.text?.toString()?.trim() ?: ""
|
||||
vm.onFormChanged(backend, repo, url)
|
||||
}
|
||||
|
||||
private fun refreshResticStatus() {
|
||||
vm.refreshResticStatus(readResticForm())
|
||||
}
|
||||
|
||||
private fun initResticRepo() {
|
||||
vm.initResticRepo(readResticForm())
|
||||
}
|
||||
|
||||
private fun showResticStats() {
|
||||
vm.showResticStats(readResticForm())
|
||||
}
|
||||
|
||||
private fun pruneResticSnapshots() {
|
||||
vm.pruneResticSnapshots(readResticForm())
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,615 @@
|
||||
package com.example.androidbackupgui.ui
|
||||
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.selection.selectable
|
||||
import androidx.compose.foundation.selection.selectableGroup
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.FileUpload
|
||||
import androidx.compose.material.icons.filled.Save
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.example.androidbackupgui.backup.AppScanner
|
||||
import com.example.androidbackupgui.backup.BackupConfig
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ConfigScreen(
|
||||
viewModel: ConfigViewModel = viewModel(),
|
||||
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
|
||||
val config = uiState.config
|
||||
val backendDisplay = uiState.backendDisplay
|
||||
val status = uiState.resticStatus
|
||||
|
||||
// ── Local editing state (initialized from ViewModel on first load) ──
|
||||
var backupMode by remember { mutableStateOf(config.backupMode == 1) }
|
||||
var backupUserData by remember { mutableStateOf(config.backupUserData == 1) }
|
||||
var backupObb by remember { mutableStateOf(config.backupObbData == 1) }
|
||||
var backupWifi by remember { mutableStateOf(config.backupWifi == 1) }
|
||||
var ignoreRunning by remember { mutableStateOf(config.backgroundAppsIgnore == 1) }
|
||||
var outputPath by remember { mutableStateOf(config.outputPath) }
|
||||
var compressionMethod by remember { mutableStateOf(config.compressionMethod) }
|
||||
|
||||
var backupUserId by remember { mutableIntStateOf(config.backupUserId) }
|
||||
var userList by remember { mutableStateOf<List<Pair<Int, String>>>(listOf(0 to "Owner")) }
|
||||
|
||||
var resticEnabled by remember { mutableStateOf(config.resticEnabled == 1) }
|
||||
var resticRepo by remember { mutableStateOf(config.resticRepo) }
|
||||
var resticPassword by remember { mutableStateOf(config.resticPassword) }
|
||||
var resticBackend by remember { mutableStateOf(config.resticBackend) }
|
||||
var resticBackendUrl by remember { mutableStateOf(config.resticBackendUrl) }
|
||||
var resticBackendUser by remember { mutableStateOf(config.resticBackendUser) }
|
||||
var resticBackendPass by remember { mutableStateOf(config.resticBackendPass) }
|
||||
var resticBackendShare by remember { mutableStateOf(config.resticBackendShare) }
|
||||
var resticBackendDomain by remember { mutableStateOf(config.resticBackendDomain) }
|
||||
var streamingEnabled by remember { mutableStateOf(config.useStreaming == 1) }
|
||||
|
||||
// Sync local state from ViewModel when config reloads
|
||||
LaunchedEffect(config) {
|
||||
backupMode = config.backupMode == 1
|
||||
backupUserData = config.backupUserData == 1
|
||||
backupObb = config.backupObbData == 1
|
||||
backupWifi = config.backupWifi == 1
|
||||
ignoreRunning = config.backgroundAppsIgnore == 1
|
||||
outputPath = config.outputPath
|
||||
compressionMethod = config.compressionMethod
|
||||
backupUserId = config.backupUserId
|
||||
resticEnabled = config.resticEnabled == 1
|
||||
resticRepo = config.resticRepo
|
||||
resticPassword = config.resticPassword
|
||||
resticBackend = config.resticBackend
|
||||
resticBackendUrl = config.resticBackendUrl
|
||||
resticBackendUser = config.resticBackendUser
|
||||
resticBackendPass = config.resticBackendPass
|
||||
resticBackendShare = config.resticBackendShare
|
||||
resticBackendDomain = config.resticBackendDomain
|
||||
streamingEnabled = config.useStreaming == 1
|
||||
}
|
||||
|
||||
// Load user list for backup user selector
|
||||
LaunchedEffect(Unit) {
|
||||
val users =
|
||||
withContext(Dispatchers.IO) {
|
||||
AppScanner.enumerateUsers()
|
||||
}
|
||||
userList = users
|
||||
}
|
||||
|
||||
// Observe one-shot events → show Snackbar feedback
|
||||
LaunchedEffect(snackbarHostState) {
|
||||
viewModel.operationEvents.collect { event ->
|
||||
val msg =
|
||||
when (event) {
|
||||
is OperationEvent.InitCompleted -> "仓库初始化完成"
|
||||
is OperationEvent.InitFailed -> "仓库初始化失败"
|
||||
is OperationEvent.StatsCompleted -> "统计读取完成"
|
||||
is OperationEvent.PruneStarted -> "正在清理快照…"
|
||||
is OperationEvent.PruneCompleted -> "清理完成"
|
||||
is OperationEvent.PruneFailed -> "清理失败"
|
||||
is OperationEvent.ConfigExported -> "配置已导出"
|
||||
is OperationEvent.ConfigExportFailed -> "配置导出失败"
|
||||
is OperationEvent.ConfigImported -> "配置已导入"
|
||||
is OperationEvent.ConfigImportFailed -> "配置导入失败"
|
||||
else -> null
|
||||
}
|
||||
if (msg != null) {
|
||||
snackbarHostState.showSnackbar(msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val scrollState = rememberScrollState()
|
||||
|
||||
// SAF launcher: create a .conf document at a user-chosen location, then export.
|
||||
val exportLauncher =
|
||||
rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.CreateDocument("text/plain"),
|
||||
) { uri ->
|
||||
if (uri != null) viewModel.exportConfig(uri)
|
||||
}
|
||||
|
||||
// SAF launcher: pick a .conf file to import.
|
||||
val importLauncher =
|
||||
rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.OpenDocument(),
|
||||
) { uri ->
|
||||
if (uri != null) viewModel.importConfig(uri)
|
||||
}
|
||||
|
||||
// SAF directory picker for output path
|
||||
val dirPickerLauncher =
|
||||
rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.OpenDocumentTree(),
|
||||
) { uri ->
|
||||
if (uri != null) {
|
||||
val resolvedPath = resolveSafTreeUri(uri)
|
||||
if (resolvedPath != null) {
|
||||
outputPath = resolvedPath
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(scrollState)
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
// ── Backup settings section ──
|
||||
Text("备份设置", style = MaterialTheme.typography.titleMedium)
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("备份模式", modifier = Modifier.weight(1f))
|
||||
Switch(checked = backupMode, onCheckedChange = { backupMode = it })
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("备份用户数据", modifier = Modifier.weight(1f))
|
||||
Switch(checked = backupUserData, onCheckedChange = { backupUserData = it })
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("备份 OBB 数据", modifier = Modifier.weight(1f))
|
||||
Switch(checked = backupObb, onCheckedChange = { backupObb = it })
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("备份 WiFi 配置", modifier = Modifier.weight(1f))
|
||||
Switch(checked = backupWifi, onCheckedChange = { backupWifi = it })
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("忽略运行中的应用", modifier = Modifier.weight(1f))
|
||||
Switch(checked = ignoreRunning, onCheckedChange = { ignoreRunning = it })
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
|
||||
OutlinedTextField(
|
||||
value = outputPath,
|
||||
onValueChange = { outputPath = it },
|
||||
label = { Text("输出目录") },
|
||||
modifier = Modifier.weight(1f),
|
||||
singleLine = true,
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Button(
|
||||
onClick = { dirPickerLauncher.launch(null) },
|
||||
modifier = Modifier.height(56.dp),
|
||||
) {
|
||||
Text("选择")
|
||||
}
|
||||
}
|
||||
OutlinedTextField(
|
||||
value = compressionMethod,
|
||||
onValueChange = { compressionMethod = it },
|
||||
label = { Text("压缩方式 (tar / zstd)") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
)
|
||||
|
||||
// Backup user selector
|
||||
UserSelector(
|
||||
userList = userList,
|
||||
selectedUserId = backupUserId,
|
||||
onUserSelected = { backupUserId = it },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Restic section ──
|
||||
HorizontalDivider()
|
||||
Text("Restic 备份", style = MaterialTheme.typography.titleMedium)
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("启用 Restic", modifier = Modifier.weight(1f))
|
||||
Switch(checked = resticEnabled, onCheckedChange = { resticEnabled = it })
|
||||
}
|
||||
|
||||
if (resticEnabled) {
|
||||
OutlinedTextField(
|
||||
value = resticRepo,
|
||||
onValueChange = {
|
||||
resticRepo = it
|
||||
viewModel.onFormChanged(resticBackend, it, resticBackendUrl)
|
||||
},
|
||||
label = { Text("仓库路径") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = resticPassword,
|
||||
onValueChange = { resticPassword = it },
|
||||
label = { Text("仓库密码") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
visualTransformation =
|
||||
androidx.compose.ui.text.input
|
||||
.PasswordVisualTransformation(),
|
||||
)
|
||||
|
||||
// Backend selection radio group
|
||||
Text("后端类型", style = MaterialTheme.typography.labelLarge)
|
||||
val backends = listOf("local" to "本地", "webdav" to "WebDAV", "smb" to "SMB", "rest-server" to "rest-server")
|
||||
Column(modifier = Modifier.selectableGroup()) {
|
||||
backends.forEach { (value, label) ->
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.selectable(
|
||||
selected = resticBackend == value,
|
||||
onClick = {
|
||||
resticBackend = value
|
||||
viewModel.onFormChanged(value, resticRepo, resticBackendUrl)
|
||||
},
|
||||
role = Role.RadioButton,
|
||||
).padding(vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
RadioButton(
|
||||
selected = resticBackend == value,
|
||||
onClick = null,
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(label)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Computed URL
|
||||
if (resticRepo.isNotEmpty()) {
|
||||
Text(
|
||||
text = "实际仓库: ${backendDisplay.computedUrl}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
// Remote-specific fields
|
||||
if (resticBackend != "local") {
|
||||
OutlinedTextField(
|
||||
value = resticBackendUrl,
|
||||
onValueChange = {
|
||||
resticBackendUrl = it
|
||||
viewModel.onFormChanged(resticBackend, resticRepo, it)
|
||||
},
|
||||
label = { Text(backendDisplay.urlHint.ifEmpty { "后端地址" }) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
)
|
||||
}
|
||||
if (resticBackend == "webdav" || resticBackend == "smb") {
|
||||
OutlinedTextField(
|
||||
value = resticBackendUser,
|
||||
onValueChange = { resticBackendUser = it },
|
||||
label = { Text("用户名") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = resticBackendPass,
|
||||
onValueChange = { resticBackendPass = it },
|
||||
label = { Text("密码") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
visualTransformation =
|
||||
androidx.compose.ui.text.input
|
||||
.PasswordVisualTransformation(),
|
||||
)
|
||||
}
|
||||
if (resticBackend == "smb") {
|
||||
OutlinedTextField(
|
||||
value = resticBackendShare,
|
||||
onValueChange = { resticBackendShare = it },
|
||||
label = { Text("SMB 共享名称") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = resticBackendDomain,
|
||||
onValueChange = { resticBackendDomain = it },
|
||||
label = { Text("SMB 域 (可选)") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
)
|
||||
}
|
||||
|
||||
// ── Streaming backup toggle ──
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
"流式备份 (FIFO管道 → restic --stdin)",
|
||||
modifier = Modifier.weight(1f),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Switch(
|
||||
checked = streamingEnabled,
|
||||
onCheckedChange = { streamingEnabled = it },
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
// Status & action buttons
|
||||
Card(
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(12.dp)) {
|
||||
Text(
|
||||
text = status.message,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
if (status.initButtonVisible) {
|
||||
Button(
|
||||
onClick = {
|
||||
viewModel.initResticRepo(
|
||||
buildResticForm(
|
||||
resticRepo,
|
||||
resticPassword,
|
||||
resticBackend,
|
||||
resticBackendUrl,
|
||||
resticBackendUser,
|
||||
resticBackendPass,
|
||||
resticBackendShare,
|
||||
resticBackendDomain,
|
||||
),
|
||||
)
|
||||
},
|
||||
enabled = status.initButtonEnabled,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text("初始化仓库")
|
||||
}
|
||||
}
|
||||
|
||||
if (status.statsButtonVisible) {
|
||||
Button(
|
||||
onClick = {
|
||||
viewModel.showResticStats(
|
||||
buildResticForm(
|
||||
resticRepo,
|
||||
resticPassword,
|
||||
resticBackend,
|
||||
resticBackendUrl,
|
||||
resticBackendUser,
|
||||
resticBackendPass,
|
||||
resticBackendShare,
|
||||
resticBackendDomain,
|
||||
),
|
||||
)
|
||||
},
|
||||
enabled = status.statsButtonEnabled,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text("仓库统计")
|
||||
}
|
||||
}
|
||||
|
||||
if (status.pruneButtonVisible) {
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
viewModel.pruneResticSnapshots(
|
||||
buildResticForm(
|
||||
resticRepo,
|
||||
resticPassword,
|
||||
resticBackend,
|
||||
resticBackendUrl,
|
||||
resticBackendUser,
|
||||
resticBackendPass,
|
||||
resticBackendShare,
|
||||
resticBackendDomain,
|
||||
),
|
||||
)
|
||||
},
|
||||
enabled = status.pruneButtonEnabled,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text("清理旧快照")
|
||||
}
|
||||
}
|
||||
|
||||
if (status.unlockButtonVisible) {
|
||||
Button(
|
||||
onClick = {
|
||||
viewModel.unlockResticRepo(
|
||||
buildResticForm(
|
||||
resticRepo,
|
||||
resticPassword,
|
||||
resticBackend,
|
||||
resticBackendUrl,
|
||||
resticBackendUser,
|
||||
resticBackendPass,
|
||||
resticBackendShare,
|
||||
resticBackendDomain,
|
||||
),
|
||||
)
|
||||
},
|
||||
enabled = status.unlockButtonEnabled,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors =
|
||||
ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.tertiary,
|
||||
),
|
||||
) {
|
||||
Text("解锁仓库")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
// ── Save button ──
|
||||
Button(
|
||||
onClick = {
|
||||
viewModel.save(
|
||||
BackupConfig(
|
||||
backupMode = if (backupMode) 1 else 0,
|
||||
backupUserData = if (backupUserData) 1 else 0,
|
||||
backupObbData = if (backupObb) 1 else 0,
|
||||
backupWifi = if (backupWifi) 1 else 0,
|
||||
backgroundAppsIgnore = if (ignoreRunning) 1 else 0,
|
||||
backupUserId = backupUserId,
|
||||
outputPath = outputPath,
|
||||
compressionMethod = compressionMethod.ifEmpty { "zstd" },
|
||||
resticEnabled = if (resticEnabled) 1 else 0,
|
||||
resticRepo = resticRepo,
|
||||
resticPassword = resticPassword,
|
||||
resticBackend = resticBackend,
|
||||
resticBackendUrl = resticBackendUrl,
|
||||
resticBackendUser = resticBackendUser,
|
||||
resticBackendPass = resticBackendPass,
|
||||
resticBackendShare = resticBackendShare,
|
||||
resticBackendDomain = resticBackendDomain,
|
||||
useStreaming = if (streamingEnabled) 1 else 0,
|
||||
),
|
||||
)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Icon(Icons.Filled.Save, contentDescription = null)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("保存配置")
|
||||
}
|
||||
|
||||
// ── Import / Export config buttons ──
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = { importLauncher.launch(arrayOf("text/plain", "*/*")) },
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
Text("导入配置")
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = { exportLauncher.launch("backup_settings.conf") },
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
Icon(Icons.Filled.FileUpload, contentDescription = null)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("导出配置")
|
||||
}
|
||||
}
|
||||
if (resticEnabled && resticPassword.isNotEmpty()) {
|
||||
Text(
|
||||
text = "注意:导出的配置包含明文 Restic 密码,请妥善保管导出的文件。",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(32.dp))
|
||||
}
|
||||
}
|
||||
|
||||
// ── User selector ──
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun UserSelector(
|
||||
userList: List<Pair<Int, String>>,
|
||||
selectedUserId: Int,
|
||||
onUserSelected: (Int) -> Unit,
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
val selectedName =
|
||||
userList.find { it.first == selectedUserId }?.let {
|
||||
"${it.second} (ID: ${it.first})"
|
||||
} ?: "Owner (ID: 0)"
|
||||
|
||||
ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }) {
|
||||
OutlinedTextField(
|
||||
value = selectedName,
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { Text("备份用户") },
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
|
||||
modifier = Modifier.menuAnchor().fillMaxWidth(),
|
||||
singleLine = true,
|
||||
)
|
||||
ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
||||
userList.forEach { (id, name) ->
|
||||
DropdownMenuItem(
|
||||
text = { Text("$name (ID: $id)") },
|
||||
onClick = {
|
||||
onUserSelected(id)
|
||||
expanded = false
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Build a [ResticForm] from current input values (matches ConfigFragment's readResticForm). */
|
||||
private fun buildResticForm(
|
||||
repo: String,
|
||||
password: String,
|
||||
backend: String,
|
||||
backendUrl: String,
|
||||
backendUser: String,
|
||||
backendPass: String,
|
||||
backendShare: String,
|
||||
backendDomain: String,
|
||||
) = ResticForm(
|
||||
repo = repo,
|
||||
password = password,
|
||||
backend = backend,
|
||||
backendUrl = backendUrl,
|
||||
backendUser = backendUser,
|
||||
backendPass = backendPass,
|
||||
backendShare = backendShare,
|
||||
backendDomain = backendDomain,
|
||||
)
|
||||
|
||||
/**
|
||||
* 将 SAF OpenDocumentTree 的 content:// URI 转换为可用的文件系统路径。
|
||||
* SAF URI 示例: content://com.android.externalstorage.documents/tree/primary%3ADownload%2FBackup
|
||||
* 返回: /storage/emulated/0/Download/Backup
|
||||
*/
|
||||
private fun resolveSafTreeUri(uri: android.net.Uri): String? {
|
||||
// SAF tree URI 格式:
|
||||
// content://com.android.externalstorage.documents/tree/primary%3ADownload%2FBackup
|
||||
// lastPathSegment = primary%3ADownload%2FBackup 或 XXXX-XXXX%3Apath
|
||||
val docId = uri.lastPathSegment?.let { java.net.URLDecoder.decode(it, "UTF-8") } ?: return null
|
||||
|
||||
// docId 格式: primary:path/to/dir 或 XXXX-XXXX:path/to/dir
|
||||
val colonIdx = docId.indexOf(':')
|
||||
if (colonIdx < 0) return null
|
||||
|
||||
val storageId = docId.substring(0, colonIdx)
|
||||
val relPath = docId.substring(colonIdx + 1).trim('/')
|
||||
|
||||
return if (storageId.equals("primary", ignoreCase = true)) {
|
||||
"/storage/emulated/0/$relPath"
|
||||
} else {
|
||||
"/storage/$storageId/$relPath"
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,23 @@
|
||||
package com.example.androidbackupgui.ui
|
||||
|
||||
import android.app.Application
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.example.androidbackupgui.backup.BackupConfig
|
||||
import com.example.androidbackupgui.backup.formatSize
|
||||
import com.example.androidbackupgui.backup.PasswordManager
|
||||
import com.example.androidbackupgui.backup.ResticBinary
|
||||
import com.example.androidbackupgui.backup.ResticWrapper
|
||||
import com.example.androidbackupgui.backup.defaultResticWrapper
|
||||
import com.example.androidbackupgui.backup.formatSize
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.cancelAndJoin
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -25,7 +28,7 @@ import java.util.concurrent.atomic.AtomicBoolean
|
||||
data class ConfigUiState(
|
||||
val config: BackupConfig = BackupConfig(),
|
||||
val backendDisplay: BackendDisplay = BackendDisplay(),
|
||||
val resticStatus: ResticStatus = ResticStatus()
|
||||
val resticStatus: ResticStatus = ResticStatus(),
|
||||
)
|
||||
|
||||
data class BackendDisplay(
|
||||
@@ -33,7 +36,7 @@ data class BackendDisplay(
|
||||
val needsAuth: Boolean = false,
|
||||
val isSmb: Boolean = false,
|
||||
val computedUrl: String = "",
|
||||
val urlHint: String = ""
|
||||
val urlHint: String = "",
|
||||
)
|
||||
|
||||
data class ResticStatus(
|
||||
@@ -44,15 +47,21 @@ data class ResticStatus(
|
||||
val statsButtonVisible: Boolean = false,
|
||||
val statsButtonEnabled: Boolean = true,
|
||||
val pruneButtonVisible: Boolean = false,
|
||||
val pruneButtonEnabled: Boolean = true
|
||||
val pruneButtonEnabled: Boolean = true,
|
||||
val unlockButtonVisible: Boolean = false,
|
||||
val unlockButtonEnabled: Boolean = true,
|
||||
)
|
||||
|
||||
/** Restic credential/form snapshot passed from Fragment on every user interaction. */
|
||||
data class ResticForm(
|
||||
val repo: String, val password: String,
|
||||
val backend: String, val backendUrl: String,
|
||||
val backendUser: String, val backendPass: String,
|
||||
val backendShare: String, val backendDomain: String
|
||||
val repo: String,
|
||||
val password: String,
|
||||
val backend: String,
|
||||
val backendUrl: String,
|
||||
val backendUser: String,
|
||||
val backendPass: String,
|
||||
val backendShare: String,
|
||||
val backendDomain: String,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -61,38 +70,61 @@ data class ResticForm(
|
||||
*/
|
||||
sealed interface OperationEvent {
|
||||
data object InitStarted : OperationEvent
|
||||
|
||||
data object InitCompleted : OperationEvent
|
||||
|
||||
data object InitFailed : OperationEvent
|
||||
|
||||
data object StatsStarted : OperationEvent
|
||||
|
||||
data object StatsCompleted : OperationEvent
|
||||
|
||||
data object PruneStarted : OperationEvent
|
||||
|
||||
data object PruneFailed : OperationEvent
|
||||
|
||||
data object PruneCompleted : OperationEvent
|
||||
|
||||
data object ConfigExported : OperationEvent
|
||||
|
||||
data object ConfigExportFailed : OperationEvent
|
||||
|
||||
data object ConfigImported : OperationEvent
|
||||
|
||||
data object ConfigImportFailed : OperationEvent
|
||||
}
|
||||
|
||||
class ConfigViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
class ConfigViewModel(
|
||||
application: Application,
|
||||
) : AndroidViewModel(application) {
|
||||
companion object {
|
||||
private const val TAG = "ConfigViewModel"
|
||||
private const val CONFIG_FILE_NAME = "backup_settings.conf"
|
||||
|
||||
fun deriveBackendDisplay(backend: String, repo: String, backendUrl: String): BackendDisplay {
|
||||
fun deriveBackendDisplay(
|
||||
backend: String,
|
||||
repo: String,
|
||||
backendUrl: String,
|
||||
): BackendDisplay {
|
||||
val isRemote = backend != "local"
|
||||
val needsAuth = backend == "webdav" || backend == "smb"
|
||||
val isSmb = backend == "smb"
|
||||
val urlHint = when (backend) {
|
||||
"webdav" -> "WebDAV 地址 (https://host:port/path)"
|
||||
"smb" -> "SMB 主机地址 (host 或 host:port)"
|
||||
"rest-server" -> "rest-server 地址 (http://host:port)"
|
||||
else -> ""
|
||||
}
|
||||
val computedUrl = ResticWrapper.buildRepoUrl(backend, repo, backendUrl)
|
||||
val urlHint =
|
||||
when (backend) {
|
||||
"webdav" -> "WebDAV 地址 (https://host:port/path)"
|
||||
"smb" -> "SMB 主机地址 (host 或 host:port)"
|
||||
"rest-server" -> "rest-server 地址 (http://host:port)"
|
||||
else -> ""
|
||||
}
|
||||
val computedUrl = defaultResticWrapper.buildRepoUrl(backend, repo, backendUrl)
|
||||
return BackendDisplay(
|
||||
isRemote = isRemote, needsAuth = needsAuth, isSmb = isSmb,
|
||||
computedUrl = computedUrl, urlHint = urlHint
|
||||
isRemote = isRemote,
|
||||
needsAuth = needsAuth,
|
||||
isSmb = isSmb,
|
||||
computedUrl = computedUrl,
|
||||
urlHint = urlHint,
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private val configFile: File by lazy {
|
||||
@@ -108,6 +140,14 @@ class ConfigViewModel(application: Application) : AndroidViewModel(application)
|
||||
|
||||
/** Guards against concurrent [initResticRepo] calls. */
|
||||
private val initGuard = AtomicBoolean(false)
|
||||
|
||||
/** Guards against stale [refreshResticStatus] coroutines. */
|
||||
private var refreshJob: Job? = null
|
||||
|
||||
init {
|
||||
load()
|
||||
}
|
||||
|
||||
/** Read config from file and refresh restic status. */
|
||||
fun load() {
|
||||
val config = BackupConfig.fromFile(configFile)
|
||||
@@ -118,18 +158,40 @@ class ConfigViewModel(application: Application) : AndroidViewModel(application)
|
||||
refreshResticStatus(readResticForm())
|
||||
}
|
||||
|
||||
/** Build a [ResticForm] snapshot from the current state's config values. */
|
||||
private fun readResticForm() = _uiState.value.config.let { c ->
|
||||
ResticForm(
|
||||
repo = c.resticRepo, password = c.resticPassword,
|
||||
backend = c.resticBackend, backendUrl = c.resticBackendUrl,
|
||||
backendUser = c.resticBackendUser, backendPass = c.resticBackendPass,
|
||||
backendShare = c.resticBackendShare, backendDomain = c.resticBackendDomain
|
||||
)
|
||||
}
|
||||
/**
|
||||
* Build a [ResticForm] snapshot from the current state's config values.
|
||||
* 密码从 PasswordManager(加密存储)获取,不从配置文件读取。
|
||||
*/
|
||||
private fun readResticForm() =
|
||||
_uiState.value.config.let { c ->
|
||||
// 从加密存储获取密码,如尚未设置则尝试从旧配置迁移
|
||||
val password = PasswordManager.getResticPassword() ?: c.resticPassword.takeIf { it.isNotEmpty() }
|
||||
val backendPass = PasswordManager.getBackendPass() ?: c.resticBackendPass.takeIf { it.isNotEmpty() }
|
||||
// 如果发现旧配置中有密码但 PasswordManager 还没有,迁移过去
|
||||
if (password != null && !PasswordManager.hasResticPassword() && password != "stored-in-keystore") {
|
||||
PasswordManager.setResticPassword(password)
|
||||
}
|
||||
if (backendPass != null && backendPass != "stored-in-keystore" && PasswordManager.getBackendPass() == null) {
|
||||
PasswordManager.setBackendPass(backendPass)
|
||||
}
|
||||
ResticForm(
|
||||
repo = c.resticRepo,
|
||||
password = password ?: "",
|
||||
backend = c.resticBackend,
|
||||
backendUrl = c.resticBackendUrl,
|
||||
backendUser = c.resticBackendUser,
|
||||
backendPass = backendPass ?: "",
|
||||
backendShare = c.resticBackendShare,
|
||||
backendDomain = c.resticBackendDomain,
|
||||
)
|
||||
}
|
||||
|
||||
/** Update derived display state when backend/repo/url form fields change. */
|
||||
fun onFormChanged(backend: String, repo: String, backendUrl: String) {
|
||||
fun onFormChanged(
|
||||
backend: String,
|
||||
repo: String,
|
||||
backendUrl: String,
|
||||
) {
|
||||
val bd = deriveBackendDisplay(backend, repo, backendUrl)
|
||||
_uiState.update { it.copy(backendDisplay = bd) }
|
||||
}
|
||||
@@ -137,14 +199,151 @@ class ConfigViewModel(application: Application) : AndroidViewModel(application)
|
||||
/**
|
||||
* Save config to file on IO and update status message.
|
||||
* The caller passes the current form values as a [BackupConfig] copy.
|
||||
* 密码单独通过 [PasswordManager] 安全存储,不入配置文件。
|
||||
*/
|
||||
fun save(formConfig: BackupConfig) {
|
||||
fun save(
|
||||
formConfig: BackupConfig,
|
||||
resticPassword: String? = null,
|
||||
backendPass: String? = null,
|
||||
) {
|
||||
viewModelScope.launch {
|
||||
// 保存密码到加密存储
|
||||
if (resticPassword != null && resticPassword.isNotEmpty()) {
|
||||
PasswordManager.setResticPassword(resticPassword)
|
||||
}
|
||||
if (backendPass != null && backendPass.isNotEmpty()) {
|
||||
PasswordManager.setBackendPass(backendPass)
|
||||
}
|
||||
withContext(Dispatchers.IO) {
|
||||
BackupConfig.toFile(formConfig, configFile)
|
||||
}
|
||||
_uiState.update {
|
||||
it.copy(resticStatus = it.resticStatus.copy(message = "配置已保存到 $configFile"))
|
||||
it.copy(
|
||||
config = formConfig,
|
||||
backendDisplay =
|
||||
deriveBackendDisplay(
|
||||
formConfig.resticBackend,
|
||||
formConfig.resticRepo,
|
||||
formConfig.resticBackendUrl,
|
||||
),
|
||||
resticStatus = it.resticStatus.copy(message = "配置已保存到 $configFile"),
|
||||
)
|
||||
}
|
||||
refreshResticStatus(readResticForm())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Export the current saved config to a user-selected destination [Uri] (SAF).
|
||||
* Writes the same on-disk config format, including the plaintext restic password,
|
||||
* so the warning is surfaced in the UI before export.
|
||||
*/
|
||||
fun exportConfig(uri: android.net.Uri) {
|
||||
viewModelScope.launch {
|
||||
val ok =
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
// Ensure the latest saved config exists; serialize current UI config
|
||||
// if the file isn't there yet.
|
||||
val content =
|
||||
if (configFile.exists()) {
|
||||
configFile.readText()
|
||||
} else {
|
||||
val tmp = File.createTempFile("cfg", ".conf", getApplication<Application>().cacheDir)
|
||||
BackupConfig.toFile(_uiState.value.config, tmp)
|
||||
tmp.readText().also { tmp.delete() }
|
||||
}
|
||||
getApplication<Application>()
|
||||
.contentResolver
|
||||
.openOutputStream(uri)
|
||||
?.use { out ->
|
||||
out.write(content.toByteArray())
|
||||
out.flush()
|
||||
} ?: return@withContext false
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "exportConfig failed", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
if (ok) {
|
||||
_operationEvents.emit(OperationEvent.ConfigExported)
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
resticStatus =
|
||||
it.resticStatus.copy(
|
||||
message = "配置已导出(密码未包含,需在目标设备上通过应用重新输入)",
|
||||
),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
_operationEvents.emit(OperationEvent.ConfigExportFailed)
|
||||
_uiState.update { it.copy(resticStatus = it.resticStatus.copy(message = "配置导出失败")) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Import config from a user-selected [Uri] (SAF).
|
||||
* Reads the content, writes to configFile, and reloads UI state.
|
||||
*/
|
||||
fun importConfig(uri: android.net.Uri) {
|
||||
viewModelScope.launch {
|
||||
val ok =
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val content =
|
||||
getApplication<Application>()
|
||||
.contentResolver
|
||||
.openInputStream(uri)
|
||||
?.use { input -> input.reader().readText() }
|
||||
?: return@withContext false
|
||||
configFile.writeText(content)
|
||||
val parsed = BackupConfig.fromFile(configFile)
|
||||
// 导入的配置中密码是 "stored-in-keystore" 占位符,
|
||||
// 需要从 PasswordManager 恢复真实密码,避免被覆盖
|
||||
val realResticPw = PasswordManager.getResticPassword()
|
||||
val realBackendPw = PasswordManager.getBackendPass()
|
||||
val restoredConfig =
|
||||
parsed.copy(
|
||||
resticPassword = realResticPw ?: parsed.resticPassword,
|
||||
resticBackendPass = realBackendPw ?: parsed.resticBackendPass,
|
||||
)
|
||||
_uiState.update { it.copy(config = restoredConfig) }
|
||||
Log.i(TAG, "importConfig: loaded config from SAF")
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "importConfig failed", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
if (ok) {
|
||||
_operationEvents.emit(OperationEvent.ConfigImported)
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
resticStatus =
|
||||
it.resticStatus.copy(
|
||||
message = "配置已导入,请检查各项设置并保存",
|
||||
),
|
||||
)
|
||||
}
|
||||
// Reload UI state from imported config,保留已有的密码
|
||||
val s = _uiState.value
|
||||
refreshResticStatus(
|
||||
ResticForm(
|
||||
repo = s.config.resticRepo,
|
||||
password = PasswordManager.getResticPassword() ?: "",
|
||||
backend = s.config.resticBackend,
|
||||
backendUrl = s.config.resticBackendUrl,
|
||||
backendUser = s.config.resticBackendUser,
|
||||
backendPass = PasswordManager.getBackendPass() ?: "",
|
||||
backendShare = s.config.resticBackendShare,
|
||||
backendDomain = s.config.resticBackendDomain,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
_operationEvents.emit(OperationEvent.ConfigImportFailed)
|
||||
_uiState.update { it.copy(resticStatus = it.resticStatus.copy(message = "配置导入失败")) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -154,8 +353,8 @@ class ConfigViewModel(application: Application) : AndroidViewModel(application)
|
||||
val ctx = getApplication<Application>()
|
||||
val binaryPath = ResticBinary.prepare(ctx)
|
||||
if (binaryPath == null) return false
|
||||
ResticWrapper.binaryPath = binaryPath
|
||||
ResticWrapper.cacheDir = ctx.cacheDir.absolutePath
|
||||
defaultResticWrapper.binaryPath = binaryPath
|
||||
defaultResticWrapper.cacheDir = ctx.cacheDir.absolutePath
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -169,12 +368,17 @@ class ConfigViewModel(application: Application) : AndroidViewModel(application)
|
||||
Log.i(TAG, "initResticRepo called: repo=${form.repo} backend=${form.backend}")
|
||||
|
||||
if (!prepareRestic()) {
|
||||
_uiState.update { it.copy(resticStatus = it.resticStatus.copy(
|
||||
message = "restic 二进制未就绪,请确保已安装 restic 于 Termux 或 APK 内置版本可用"
|
||||
))}
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
resticStatus =
|
||||
it.resticStatus.copy(
|
||||
message = "restic 二进制未就绪,请确保已安装 restic 于 Termux 或 APK 内置版本可用",
|
||||
),
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
ResticWrapper.backendDomain = form.backendDomain
|
||||
defaultResticWrapper.backendDomain = form.backendDomain
|
||||
Log.i(TAG, "initResticRepo: repo=${form.repo} backend=${form.backend} url=${form.backendUrl}")
|
||||
|
||||
if (form.repo.isEmpty() || form.password.isEmpty()) {
|
||||
@@ -182,30 +386,51 @@ class ConfigViewModel(application: Application) : AndroidViewModel(application)
|
||||
return
|
||||
}
|
||||
|
||||
_uiState.update { it.copy(resticStatus = it.resticStatus.copy(
|
||||
message = "正在初始化 restic 仓库…", initButtonEnabled = false
|
||||
))}
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
resticStatus =
|
||||
it.resticStatus.copy(
|
||||
message = "正在初始化 restic 仓库…",
|
||||
initButtonEnabled = false,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
_operationEvents.emit(OperationEvent.InitStarted)
|
||||
val result = ResticWrapper.init(form.repo, form.password,
|
||||
backend = form.backend, backendUrl = form.backendUrl,
|
||||
backendUser = form.backendUser, backendPass = form.backendPass,
|
||||
backendShare = form.backendShare,
|
||||
)
|
||||
val result =
|
||||
defaultResticWrapper.init(
|
||||
form.repo,
|
||||
form.password,
|
||||
backend = form.backend,
|
||||
backendUrl = form.backendUrl,
|
||||
backendUser = form.backendUser,
|
||||
backendPass = form.backendPass,
|
||||
backendShare = form.backendShare,
|
||||
)
|
||||
if (result.isSuccess) {
|
||||
_operationEvents.emit(OperationEvent.InitCompleted)
|
||||
_uiState.update { it.copy(resticStatus = it.resticStatus.copy(
|
||||
message = "仓库初始化成功: ${form.repo}"
|
||||
))}
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
resticStatus =
|
||||
it.resticStatus.copy(
|
||||
message = "仓库初始化成功: ${form.repo}",
|
||||
),
|
||||
)
|
||||
}
|
||||
refreshResticStatus(form)
|
||||
} else {
|
||||
_operationEvents.emit(OperationEvent.InitFailed)
|
||||
Log.e(TAG, "initResticRepo failed: ${result.exceptionOrNull()?.message}")
|
||||
_uiState.update { it.copy(resticStatus = it.resticStatus.copy(
|
||||
message = "初始化失败: ${result.exceptionOrNull()?.message}"
|
||||
))}
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
resticStatus =
|
||||
it.resticStatus.copy(
|
||||
message = "初始化失败: ${result.exceptionOrNull()?.message}",
|
||||
),
|
||||
)
|
||||
}
|
||||
refreshResticStatus(form)
|
||||
}
|
||||
} finally {
|
||||
@@ -216,77 +441,229 @@ class ConfigViewModel(application: Application) : AndroidViewModel(application)
|
||||
|
||||
fun refreshResticStatus(form: ResticForm) {
|
||||
if (form.repo.isBlank()) {
|
||||
_uiState.update { it.copy(resticStatus = ResticStatus(
|
||||
message = "请填写仓库路径和密码后初始化",
|
||||
initButtonVisible = true, statsButtonVisible = false, pruneButtonVisible = false
|
||||
))}
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
resticStatus =
|
||||
ResticStatus(
|
||||
message = "请填写仓库路径和密码后初始化",
|
||||
initButtonVisible = true,
|
||||
statsButtonVisible = false,
|
||||
pruneButtonVisible = false,
|
||||
),
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (!prepareRestic()) {
|
||||
_uiState.update { it.copy(resticStatus = ResticStatus(
|
||||
message = "restic 二进制未就绪",
|
||||
initButtonVisible = true, statsButtonVisible = false, pruneButtonVisible = false
|
||||
))}
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
resticStatus =
|
||||
ResticStatus(
|
||||
message = "restic 二进制未就绪",
|
||||
initButtonVisible = true,
|
||||
statsButtonVisible = false,
|
||||
pruneButtonVisible = false,
|
||||
),
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
ResticWrapper.backendDomain = form.backendDomain
|
||||
defaultResticWrapper.backendDomain = form.backendDomain
|
||||
|
||||
_uiState.update { it.copy(resticStatus = it.resticStatus.copy(message = "正在检测仓库状态…")) }
|
||||
|
||||
viewModelScope.launch {
|
||||
val snapshotsResult = ResticWrapper.listSnapshots(form.repo, form.password,
|
||||
backend = form.backend, backendUrl = form.backendUrl,
|
||||
backendUser = form.backendUser, backendPass = form.backendPass,
|
||||
backendShare = form.backendShare,
|
||||
)
|
||||
if (snapshotsResult.isSuccess) {
|
||||
val snapshots = snapshotsResult.getOrDefault(emptyList())
|
||||
_uiState.update { it.copy(resticStatus = ResticStatus(
|
||||
message = "仓库就绪,${snapshots.size} 个快照",
|
||||
snapshotCount = snapshots.size,
|
||||
initButtonVisible = false, statsButtonVisible = true, pruneButtonVisible = true
|
||||
))}
|
||||
} else {
|
||||
_uiState.update { it.copy(resticStatus = ResticStatus(
|
||||
message = "仓库未初始化或认证失败",
|
||||
initButtonVisible = true, statsButtonVisible = false, pruneButtonVisible = false
|
||||
))}
|
||||
// Cancel any stale status check so a slow old coroutine doesn't overwrite new results
|
||||
refreshJob?.cancel()
|
||||
refreshJob =
|
||||
viewModelScope.launch {
|
||||
val snapshotsResult =
|
||||
defaultResticWrapper.listSnapshots(
|
||||
form.repo,
|
||||
form.password,
|
||||
backend = form.backend,
|
||||
backendUrl = form.backendUrl,
|
||||
backendUser = form.backendUser,
|
||||
backendPass = form.backendPass,
|
||||
backendShare = form.backendShare,
|
||||
)
|
||||
if (snapshotsResult.isSuccess) {
|
||||
val snapshots = snapshotsResult.getOrDefault(emptyList())
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
resticStatus =
|
||||
ResticStatus(
|
||||
message = "仓库就绪,${snapshots.size} 个快照",
|
||||
snapshotCount = snapshots.size,
|
||||
initButtonVisible = false,
|
||||
statsButtonVisible = true,
|
||||
pruneButtonVisible = true,
|
||||
unlockButtonVisible = true,
|
||||
),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
val errMsg = snapshotsResult.errorOrNull()?.message ?: ""
|
||||
val hasLock = errMsg.contains("lock", ignoreCase = true) || errMsg.contains("already locked", ignoreCase = true)
|
||||
|
||||
if (hasLock) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
resticStatus =
|
||||
ResticStatus(
|
||||
message = "仓库被锁定,请先解锁",
|
||||
initButtonVisible = false,
|
||||
statsButtonVisible = false,
|
||||
pruneButtonVisible = false,
|
||||
unlockButtonVisible = true,
|
||||
),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// snapshots 失败时自动尝试 init(处理已初始化的旧仓库)
|
||||
val initResult =
|
||||
defaultResticWrapper.init(
|
||||
form.repo,
|
||||
form.password,
|
||||
backend = form.backend,
|
||||
backendUrl = form.backendUrl,
|
||||
backendUser = form.backendUser,
|
||||
backendPass = form.backendPass,
|
||||
backendShare = form.backendShare,
|
||||
)
|
||||
if (initResult.isSuccess) {
|
||||
val snaps =
|
||||
defaultResticWrapper
|
||||
.listSnapshots(
|
||||
form.repo,
|
||||
form.password,
|
||||
backend = form.backend,
|
||||
backendUrl = form.backendUrl,
|
||||
backendUser = form.backendUser,
|
||||
backendPass = form.backendPass,
|
||||
backendShare = form.backendShare,
|
||||
).getOrDefault(emptyList())
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
resticStatus =
|
||||
ResticStatus(
|
||||
message = "仓库就绪,${snaps.size} 个快照",
|
||||
snapshotCount = snaps.size,
|
||||
initButtonVisible = false,
|
||||
statsButtonVisible = true,
|
||||
pruneButtonVisible = true,
|
||||
unlockButtonVisible = true,
|
||||
),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
resticStatus =
|
||||
ResticStatus(
|
||||
message = "仓库未初始化或认证失败",
|
||||
initButtonVisible = true,
|
||||
statsButtonVisible = false,
|
||||
pruneButtonVisible = false,
|
||||
unlockButtonVisible = false,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun unlockResticRepo(form: ResticForm) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
resticStatus =
|
||||
it.resticStatus.copy(
|
||||
message = "正在解锁仓库…",
|
||||
unlockButtonEnabled = false,
|
||||
),
|
||||
)
|
||||
}
|
||||
viewModelScope.launch {
|
||||
defaultResticWrapper.backendDomain = form.backendDomain
|
||||
val result =
|
||||
defaultResticWrapper.unlock(
|
||||
form.repo,
|
||||
form.password,
|
||||
backend = form.backend,
|
||||
backendUrl = form.backendUrl,
|
||||
backendUser = form.backendUser,
|
||||
backendPass = form.backendPass,
|
||||
backendShare = form.backendShare,
|
||||
)
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
resticStatus =
|
||||
it.resticStatus.copy(
|
||||
message = if (result.isSuccess) "解锁完成" else "解锁失败: ${result.errorOrNull()?.message}",
|
||||
unlockButtonEnabled = true,
|
||||
),
|
||||
)
|
||||
}
|
||||
refreshResticStatus(form)
|
||||
}
|
||||
}
|
||||
|
||||
fun showResticStats(form: ResticForm) {
|
||||
_uiState.update { it.copy(resticStatus = it.resticStatus.copy(
|
||||
message = "正在读取统计…", statsButtonEnabled = false
|
||||
))}
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
resticStatus =
|
||||
it.resticStatus.copy(
|
||||
message = "正在读取统计…",
|
||||
statsButtonEnabled = false,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
_operationEvents.emit(OperationEvent.StatsStarted)
|
||||
val statsResult = ResticWrapper.stats(form.repo, form.password,
|
||||
backend = form.backend, backendUrl = form.backendUrl,
|
||||
backendUser = form.backendUser, backendPass = form.backendPass,
|
||||
backendShare = form.backendShare,
|
||||
)
|
||||
val snapshotsResult = ResticWrapper.listSnapshots(form.repo, form.password,
|
||||
backend = form.backend, backendUrl = form.backendUrl,
|
||||
backendUser = form.backendUser, backendPass = form.backendPass,
|
||||
backendShare = form.backendShare,
|
||||
)
|
||||
val statsResult =
|
||||
defaultResticWrapper.stats(
|
||||
form.repo,
|
||||
form.password,
|
||||
backend = form.backend,
|
||||
backendUrl = form.backendUrl,
|
||||
backendUser = form.backendUser,
|
||||
backendPass = form.backendPass,
|
||||
backendShare = form.backendShare,
|
||||
)
|
||||
val snapshotsResult =
|
||||
defaultResticWrapper.listSnapshots(
|
||||
form.repo,
|
||||
form.password,
|
||||
backend = form.backend,
|
||||
backendUrl = form.backendUrl,
|
||||
backendUser = form.backendUser,
|
||||
backendPass = form.backendPass,
|
||||
backendShare = form.backendShare,
|
||||
)
|
||||
|
||||
val snapshotCount = snapshotsResult.getOrDefault(emptyList()).size
|
||||
_uiState.update { it.copy(resticStatus = it.resticStatus.copy(
|
||||
message = buildString {
|
||||
appendLine("快照数: $snapshotCount")
|
||||
if (statsResult.isSuccess) {
|
||||
appendLine(statsResult.getOrDefault(""))
|
||||
} else {
|
||||
appendLine("统计读取失败: ${statsResult.errorOrNull()?.message}")
|
||||
}
|
||||
},
|
||||
snapshotCount = snapshotCount,
|
||||
statsButtonEnabled = true
|
||||
))}
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
resticStatus =
|
||||
it.resticStatus.copy(
|
||||
message =
|
||||
buildString {
|
||||
appendLine("快照数: $snapshotCount")
|
||||
if (statsResult.isSuccess) {
|
||||
appendLine(statsResult.getOrDefault(""))
|
||||
} else {
|
||||
appendLine("统计读取失败: ${statsResult.errorOrNull()?.message}")
|
||||
}
|
||||
},
|
||||
snapshotCount = snapshotCount,
|
||||
statsButtonEnabled = true,
|
||||
),
|
||||
)
|
||||
}
|
||||
_operationEvents.emit(OperationEvent.StatsCompleted)
|
||||
} finally {
|
||||
_uiState.update { it.copy(resticStatus = it.resticStatus.copy(statsButtonEnabled = true)) }
|
||||
@@ -295,43 +672,85 @@ class ConfigViewModel(application: Application) : AndroidViewModel(application)
|
||||
}
|
||||
|
||||
fun pruneResticSnapshots(form: ResticForm) {
|
||||
_uiState.update { it.copy(resticStatus = it.resticStatus.copy(
|
||||
message = "正在清理旧快照 (保留 7 天 / 4 周 / 3 月)…",
|
||||
pruneButtonEnabled = false
|
||||
))}
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
resticStatus =
|
||||
it.resticStatus.copy(
|
||||
message = "正在清理旧快照 (保留 7 天 / 4 周 / 3 月)…",
|
||||
pruneButtonEnabled = false,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
_operationEvents.emit(OperationEvent.PruneStarted)
|
||||
val forgetResult = ResticWrapper.forget(form.repo, form.password,
|
||||
keepDaily = 7, keepWeekly = 4, keepMonthly = 3,
|
||||
backend = form.backend, backendUrl = form.backendUrl,
|
||||
backendUser = form.backendUser, backendPass = form.backendPass,
|
||||
|
||||
// Remove stale locks before forget/prune
|
||||
defaultResticWrapper.backendDomain = form.backendDomain
|
||||
defaultResticWrapper.unlock(
|
||||
form.repo,
|
||||
form.password,
|
||||
backend = form.backend,
|
||||
backendUrl = form.backendUrl,
|
||||
backendUser = form.backendUser,
|
||||
backendPass = form.backendPass,
|
||||
backendShare = form.backendShare,
|
||||
)
|
||||
|
||||
val forgetResult =
|
||||
defaultResticWrapper.forget(
|
||||
form.repo,
|
||||
form.password,
|
||||
keepDaily = 7,
|
||||
keepWeekly = 4,
|
||||
keepMonthly = 3,
|
||||
backend = form.backend,
|
||||
backendUrl = form.backendUrl,
|
||||
backendUser = form.backendUser,
|
||||
backendPass = form.backendPass,
|
||||
backendShare = form.backendShare,
|
||||
)
|
||||
if (forgetResult.isFailure) {
|
||||
_operationEvents.emit(OperationEvent.PruneFailed)
|
||||
_uiState.update { it.copy(resticStatus = it.resticStatus.copy(
|
||||
message = "forget 失败: ${forgetResult.exceptionOrNull()?.message}",
|
||||
pruneButtonEnabled = true
|
||||
))}
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
resticStatus =
|
||||
it.resticStatus.copy(
|
||||
message = "forget 失败: ${forgetResult.exceptionOrNull()?.message}",
|
||||
pruneButtonEnabled = true,
|
||||
),
|
||||
)
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
|
||||
_uiState.update { it.copy(resticStatus = it.resticStatus.copy(message = "正在回收空间…")) }
|
||||
|
||||
val pruneResult = ResticWrapper.prune(form.repo, form.password,
|
||||
backend = form.backend, backendUrl = form.backendUrl,
|
||||
backendUser = form.backendUser, backendPass = form.backendPass,
|
||||
backendShare = form.backendShare,
|
||||
)
|
||||
_uiState.update { it.copy(resticStatus = it.resticStatus.copy(
|
||||
message = if (pruneResult.isSuccess)
|
||||
"清理完成!\n${pruneResult.getOrDefault("")}"
|
||||
else
|
||||
"prune 失败: ${pruneResult.exceptionOrNull()?.message}",
|
||||
pruneButtonEnabled = true
|
||||
))}
|
||||
val pruneResult =
|
||||
defaultResticWrapper.prune(
|
||||
form.repo,
|
||||
form.password,
|
||||
backend = form.backend,
|
||||
backendUrl = form.backendUrl,
|
||||
backendUser = form.backendUser,
|
||||
backendPass = form.backendPass,
|
||||
backendShare = form.backendShare,
|
||||
)
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
resticStatus =
|
||||
it.resticStatus.copy(
|
||||
message =
|
||||
if (pruneResult.isSuccess) {
|
||||
"清理完成!建议执行完整性检查 (check --read-data-subset=5%)"
|
||||
} else {
|
||||
"prune 失败: ${pruneResult.exceptionOrNull()?.message}"
|
||||
},
|
||||
pruneButtonEnabled = true,
|
||||
),
|
||||
)
|
||||
}
|
||||
if (pruneResult.isSuccess) {
|
||||
_operationEvents.emit(OperationEvent.PruneCompleted)
|
||||
} else {
|
||||
@@ -342,6 +761,4 @@ class ConfigViewModel(application: Application) : AndroidViewModel(application)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
202
app/src/main/java/com/example/androidbackupgui/ui/LogScreen.kt
Normal file
202
app/src/main/java/com/example/androidbackupgui/ui/LogScreen.kt
Normal file
@@ -0,0 +1,202 @@
|
||||
package com.example.androidbackupgui.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.FileDownload
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.example.androidbackupgui.backup.LogUtil
|
||||
import java.io.File
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun LogScreen() {
|
||||
val context = LocalContext.current
|
||||
var logFiles by remember { mutableStateOf(listOf<File>()) }
|
||||
var selectedFile by remember { mutableStateOf<File?>(null) }
|
||||
var logContent by remember { mutableStateOf<List<String>>(emptyList()) }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
// Refresh log list
|
||||
fun refresh() {
|
||||
logFiles = LogUtil.getLogFiles()
|
||||
if (selectedFile != null && selectedFile !in logFiles) {
|
||||
selectedFile = null
|
||||
logContent = emptyList()
|
||||
}
|
||||
}
|
||||
LaunchedEffect(Unit) { refresh() }
|
||||
|
||||
// SAF export launcher
|
||||
val exportLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.CreateDocument("text/plain")
|
||||
) { uri ->
|
||||
if (uri != null && selectedFile != null) {
|
||||
exportLogFile(context, uri, selectedFile!!)
|
||||
}
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
|
||||
// ── Header ──
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text("运行日志", style = MaterialTheme.typography.titleMedium, modifier = Modifier.weight(1f))
|
||||
IconButton(onClick = { refresh() }) {
|
||||
Icon(Icons.Filled.Refresh, contentDescription = "刷新")
|
||||
}
|
||||
}
|
||||
|
||||
if (logFiles.isEmpty()) {
|
||||
Text(
|
||||
"暂无日志文件",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(vertical = 24.dp)
|
||||
)
|
||||
}
|
||||
|
||||
// ── Log file list ──
|
||||
Text("日志文件", style = MaterialTheme.typography.labelLarge)
|
||||
LazyColumn(
|
||||
modifier = Modifier.heightIn(max = 160.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
items(logFiles, key = { it.absolutePath }) { file ->
|
||||
val isSelected = file == selectedFile
|
||||
Card(
|
||||
onClick = {
|
||||
selectedFile = file
|
||||
scope.launch {
|
||||
logContent = withContext(Dispatchers.IO) {
|
||||
file.readLines()
|
||||
}
|
||||
}
|
||||
},
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = if (isSelected)
|
||||
MaterialTheme.colorScheme.primaryContainer
|
||||
else
|
||||
MaterialTheme.colorScheme.surfaceVariant
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = file.name,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Text(
|
||||
text = "${file.length() / 1024}KB",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
// ── Action buttons ──
|
||||
if (selectedFile != null) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedButton(
|
||||
onClick = { exportLauncher.launch(selectedFile!!.name) },
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Icon(Icons.Filled.FileDownload, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text("导出")
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
selectedFile!!.delete()
|
||||
refresh()
|
||||
},
|
||||
colors = ButtonDefaults.outlinedButtonColors(
|
||||
contentColor = MaterialTheme.colorScheme.error
|
||||
),
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Icon(Icons.Filled.Delete, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text("删除")
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
// ── Log content ──
|
||||
Text(
|
||||
"日志内容 — ${selectedFile!!.name}",
|
||||
style = MaterialTheme.typography.labelLarge
|
||||
)
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth().weight(1f),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
shape = MaterialTheme.shapes.small
|
||||
) {
|
||||
if (logContent.isEmpty()) {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text("(空)", color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
} else {
|
||||
val scrollState = rememberScrollState()
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(scrollState)
|
||||
.padding(8.dp)
|
||||
) {
|
||||
// Show last 500 lines (newest at bottom)
|
||||
val displayLines = logContent.takeLast(500)
|
||||
for (line in displayLines) {
|
||||
Text(
|
||||
text = line,
|
||||
style = MaterialTheme.typography.bodySmall.copy(
|
||||
fontFamily = FontFamily.Monospace
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun exportLogFile(context: Context, uri: Uri, file: File) {
|
||||
try {
|
||||
context.contentResolver.openOutputStream(uri)?.use { out ->
|
||||
file.inputStream().use { `in` ->
|
||||
`in`.copyTo(out)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("LogScreen", "导出日志失败", e)
|
||||
}
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
package com.example.androidbackupgui.ui
|
||||
|
||||
import android.view.View
|
||||
import android.util.TypedValue
|
||||
import android.view.ViewGroup
|
||||
import android.widget.CheckBox
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.example.androidbackupgui.R
|
||||
import com.example.androidbackupgui.backup.AppInfo
|
||||
import com.google.android.material.card.MaterialCardView
|
||||
import com.google.android.material.color.MaterialColors
|
||||
|
||||
/**
|
||||
* RecyclerView adapter showing app names (or package names as fallback) with checkboxes.
|
||||
* Used by both BackupFragment and RestoreFragment.
|
||||
*/
|
||||
class PackageListAdapter(
|
||||
private val apps: List<AppInfo>,
|
||||
private val selected: Set<String>,
|
||||
private val onToggle: (String, Boolean) -> Unit,
|
||||
private val excludeDataFrom: Set<String> = emptySet(),
|
||||
private val onExcludeDataToggle: ((String, Boolean) -> Unit)? = null
|
||||
) : RecyclerView.Adapter<PackageListAdapter.ViewHolder>() {
|
||||
|
||||
class ViewHolder(view: View) : RecyclerView.ViewHolder(view) {
|
||||
val checkbox: CheckBox = view.findViewById(R.id.checkbox)
|
||||
val textView: TextView = view.findViewById(R.id.appName)
|
||||
val excludeToggle: TextView = view.findViewById(R.id.excludeToggle)
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
|
||||
val ctx = parent.context
|
||||
val res = ctx.resources
|
||||
val card = MaterialCardView(ctx).apply {
|
||||
layoutParams = ViewGroup.MarginLayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
).apply { setMargins(0, 0, 0, res.getDimensionPixelSize(R.dimen.card_margin_bottom)) }
|
||||
radius = res.getDimension(R.dimen.card_radius)
|
||||
cardElevation = 0f
|
||||
strokeWidth = 0
|
||||
setCardBackgroundColor(
|
||||
MaterialColors.getColor(ctx, com.google.android.material.R.attr.colorSurfaceContainer, 0)
|
||||
)
|
||||
}
|
||||
val layout = LinearLayout(ctx).apply {
|
||||
orientation = LinearLayout.HORIZONTAL
|
||||
setPadding(res.getDimensionPixelSize(R.dimen.card_padding_horizontal), res.getDimensionPixelSize(R.dimen.card_padding_vertical), res.getDimensionPixelSize(R.dimen.card_padding_horizontal), res.getDimensionPixelSize(R.dimen.card_padding_vertical))
|
||||
}
|
||||
val cb = CheckBox(ctx).apply { id = R.id.checkbox }
|
||||
val tv = TextView(ctx).apply {
|
||||
id = R.id.appName
|
||||
setPadding(res.getDimensionPixelSize(R.dimen.card_padding_horizontal), 0, 0, 0)
|
||||
setTextSize(TypedValue.COMPLEX_UNIT_PX, res.getDimension(R.dimen.list_item_text_size))
|
||||
setTextColor(
|
||||
MaterialColors.getColor(ctx, com.google.android.material.R.attr.colorOnSurface, 0)
|
||||
)
|
||||
}
|
||||
val et = TextView(ctx).apply {
|
||||
id = R.id.excludeToggle
|
||||
visibility = if (onExcludeDataToggle != null) View.VISIBLE else View.GONE
|
||||
setPadding(res.getDimensionPixelSize(R.dimen.card_padding_horizontal), 0, 0, 0)
|
||||
setTextSize(TypedValue.COMPLEX_UNIT_PX, res.getDimension(R.dimen.list_item_text_size) * 0.75f)
|
||||
setTextColor(
|
||||
MaterialColors.getColor(ctx, com.google.android.material.R.attr.colorOnSurfaceVariant, 0)
|
||||
)
|
||||
}
|
||||
layout.addView(cb)
|
||||
layout.addView(tv)
|
||||
layout.addView(et)
|
||||
card.addView(layout)
|
||||
|
||||
val holder = ViewHolder(card)
|
||||
card.setOnClickListener {
|
||||
val pos = holder.adapterPosition
|
||||
if (pos == RecyclerView.NO_POSITION) return@setOnClickListener
|
||||
val app = apps[pos]
|
||||
val newChecked = !holder.checkbox.isChecked
|
||||
// Temporarily suppress checkbox listener to avoid double-fire
|
||||
holder.checkbox.setOnCheckedChangeListener(null)
|
||||
holder.checkbox.isChecked = newChecked
|
||||
holder.checkbox.setOnCheckedChangeListener { _, checked ->
|
||||
onToggle(app.packageName.value, checked)
|
||||
}
|
||||
onToggle(app.packageName.value, newChecked)
|
||||
}
|
||||
return holder
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
|
||||
val app = apps[position]
|
||||
val pkg = app.packageName.value
|
||||
// Prefer app name (label), fall back to package name
|
||||
holder.textView.text = app.label.ifEmpty { pkg }
|
||||
// Avoid re-triggering listener during bind
|
||||
holder.checkbox.setOnCheckedChangeListener(null)
|
||||
holder.checkbox.isChecked = pkg in selected
|
||||
holder.checkbox.setOnCheckedChangeListener { _, checked ->
|
||||
onToggle(pkg, checked)
|
||||
}
|
||||
// Configure per-app data exclusion toggle
|
||||
val toggle = holder.excludeToggle
|
||||
val dataToggleCb = onExcludeDataToggle
|
||||
if (dataToggleCb != null) {
|
||||
toggle.visibility = View.VISIBLE
|
||||
val excluded = pkg in excludeDataFrom
|
||||
toggle.text = "数据"
|
||||
toggle.paintFlags = if (excluded) {
|
||||
toggle.paintFlags or android.graphics.Paint.STRIKE_THRU_TEXT_FLAG
|
||||
} else {
|
||||
toggle.paintFlags and android.graphics.Paint.STRIKE_THRU_TEXT_FLAG.inv()
|
||||
}
|
||||
toggle.isSelected = excluded
|
||||
toggle.setOnClickListener {
|
||||
dataToggleCb(pkg, !excluded)
|
||||
}
|
||||
} else {
|
||||
toggle.visibility = View.GONE
|
||||
toggle.setOnClickListener(null)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getItemCount() = apps.size
|
||||
}
|
||||
@@ -1,406 +0,0 @@
|
||||
package com.example.androidbackupgui.ui
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.AdapterView
|
||||
import android.widget.ArrayAdapter
|
||||
import androidx.fragment.app.Fragment
|
||||
import android.app.AlertDialog
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import com.example.androidbackupgui.backup.AppInfo
|
||||
import com.example.androidbackupgui.backup.PackageName
|
||||
import com.example.androidbackupgui.backup.AppScanner
|
||||
import com.example.androidbackupgui.backup.BackupConfig
|
||||
import com.example.androidbackupgui.backup.RestoreOperation
|
||||
import com.example.androidbackupgui.backup.ResticBinary
|
||||
import com.example.androidbackupgui.backup.ResticWrapper
|
||||
import com.example.androidbackupgui.backup.WifiManager
|
||||
import com.example.androidbackupgui.databinding.FragmentRestoreBinding
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
class RestoreFragment : Fragment() {
|
||||
|
||||
private var _binding: FragmentRestoreBinding? = null
|
||||
private val binding get() = _binding!!
|
||||
private var backupDir: File? = null
|
||||
private var packages: List<String> = emptyList()
|
||||
private var appInfos: List<AppInfo> = emptyList()
|
||||
private var selectedPackages = mutableSetOf<String>()
|
||||
private var resticConfig: BackupConfig? = null
|
||||
private var selectedSnapshot: ResticWrapper.ResticSnapshot? = null
|
||||
private var resticConfigFingerprint: String? = null
|
||||
private var selectedUserId: Int = 0
|
||||
private var userList: List<Pair<Int, String>> = listOf(0 to "Owner")
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
|
||||
): View {
|
||||
_binding = FragmentRestoreBinding.inflate(inflater, container, false)
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
binding.appList.layoutManager = LinearLayoutManager(requireContext())
|
||||
|
||||
// Load restic config
|
||||
val configFile = File(requireContext().filesDir, "backup_settings.conf")
|
||||
val config = BackupConfig.fromFile(configFile)
|
||||
|
||||
// Show restic button if enabled and binary available
|
||||
if (config.resticEnabled == 1 && config.resticRepo.isNotBlank()) {
|
||||
resticConfig = config
|
||||
val binaryPath = ResticBinary.prepare(requireContext())
|
||||
if (binaryPath != null) {
|
||||
ResticWrapper.binaryPath = binaryPath
|
||||
ResticWrapper.cacheDir = requireContext().cacheDir.absolutePath
|
||||
ResticWrapper.backendDomain = config.resticBackendDomain
|
||||
binding.selectResticButton.visibility = View.VISIBLE
|
||||
}
|
||||
}
|
||||
|
||||
binding.selectDirButton.setOnClickListener { selectBackupDir() }
|
||||
binding.selectResticButton.setOnClickListener { selectResticSnapshot() }
|
||||
binding.restoreButton.setOnClickListener { startRestore() }
|
||||
|
||||
// Load user profiles
|
||||
loadUsers()
|
||||
}
|
||||
|
||||
private fun loadUsers() {
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
try {
|
||||
userList = AppScanner.enumerateUsers()
|
||||
val names = userList.map { (id, name) -> "$name (ID: $id)" }
|
||||
val adapter = ArrayAdapter(requireContext(), android.R.layout.simple_spinner_item, names)
|
||||
adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
|
||||
binding.userSelector.adapter = adapter
|
||||
binding.userSelector.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
|
||||
override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
|
||||
selectedUserId = userList.getOrNull(position)?.first ?: 0
|
||||
}
|
||||
override fun onNothingSelected(parent: AdapterView<*>?) {}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
binding.statusText.text = "加载用户失败: ${e.message}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
// Re-read config so changes from ConfigFragment take effect immediately
|
||||
val configFile = File(requireContext().filesDir, "backup_settings.conf")
|
||||
val config = BackupConfig.fromFile(configFile)
|
||||
|
||||
// Detect restic config change — clear stale state if repo/backend changed
|
||||
val newFingerprint = "${config.resticRepo}|${config.resticBackend}|${config.resticBackendUrl}"
|
||||
if (resticConfigFingerprint != null && resticConfigFingerprint != newFingerprint) {
|
||||
selectedSnapshot = null
|
||||
packages = emptyList()
|
||||
selectedPackages.clear()
|
||||
binding.backupDirText.text = ""
|
||||
binding.restoreButton.isEnabled = false
|
||||
binding.selectResticButton.visibility = View.GONE
|
||||
}
|
||||
resticConfigFingerprint = newFingerprint
|
||||
|
||||
resticConfig = if (config.resticEnabled == 1 && config.resticRepo.isNotBlank()) config else null
|
||||
// Skip redundant preparation if binary and backend config are already set
|
||||
if (resticConfig != null &&
|
||||
ResticWrapper.binaryPath.isNotEmpty() &&
|
||||
ResticWrapper.binaryPath != "restic"
|
||||
) {
|
||||
binding.selectResticButton.visibility = View.VISIBLE
|
||||
} else {
|
||||
val binaryPath = ResticBinary.prepare(requireContext())
|
||||
if (binaryPath != null && resticConfig != null) {
|
||||
ResticWrapper.binaryPath = binaryPath
|
||||
ResticWrapper.cacheDir = requireContext().cacheDir.absolutePath
|
||||
ResticWrapper.backendDomain = config.resticBackendDomain
|
||||
binding.selectResticButton.visibility = View.VISIBLE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun selectBackupDir() {
|
||||
val defaultDir = File(requireContext().filesDir.absolutePath)
|
||||
val backupDirs = defaultDir.listFiles()
|
||||
?.filter { it.isDirectory && it.name.startsWith("Backup_") }
|
||||
?: emptyList()
|
||||
|
||||
if (backupDirs.isNotEmpty()) {
|
||||
backupDir = backupDirs.first()
|
||||
selectedSnapshot = null
|
||||
loadBackupDir(backupDirs.first())
|
||||
} else {
|
||||
binding.statusText.text = "未找到备份目录,请确保 Backup_* 文件夹存在于 ${defaultDir.absolutePath}"
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadBackupDir(dir: File) {
|
||||
binding.backupDirText.text = dir.absolutePath
|
||||
|
||||
val appListFile = File(dir, "appList.txt")
|
||||
packages = if (appListFile.exists()) {
|
||||
appListFile.readLines()
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotEmpty() && !it.startsWith("#") }
|
||||
} else {
|
||||
dir.listFiles()
|
||||
?.filter { it.isDirectory }
|
||||
?.map { it.name }
|
||||
?: emptyList()
|
||||
}
|
||||
|
||||
selectedPackages.clear()
|
||||
selectedPackages.addAll(packages)
|
||||
|
||||
binding.statusText.text = "共 ${packages.size} 个备份应用"
|
||||
binding.restoreButton.isEnabled = packages.isNotEmpty()
|
||||
|
||||
appInfos = AppScanner.resolveLabels(requireContext(), packages.map { AppInfo(packageName = PackageName(it)) })
|
||||
setupAppList()
|
||||
}
|
||||
|
||||
private fun selectResticSnapshot() {
|
||||
val config = resticConfig ?: return
|
||||
setRunning(true)
|
||||
binding.statusText.text = "正在同步远程仓库到本地…"
|
||||
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
try {
|
||||
val snapshotsResult = ResticWrapper.listSnapshots(
|
||||
config.resticRepo, config.resticPassword,
|
||||
backend = config.resticBackend,
|
||||
backendUrl = config.resticBackendUrl,
|
||||
backendUser = config.resticBackendUser,
|
||||
backendPass = config.resticBackendPass,
|
||||
backendShare = config.resticBackendShare,
|
||||
)
|
||||
if (snapshotsResult.isFailure) {
|
||||
updateStatus("读取快照失败: ${snapshotsResult.exceptionOrNull()?.message}")
|
||||
setRunning(false)
|
||||
return@launch
|
||||
}
|
||||
|
||||
val snapshots = snapshotsResult.getOrThrow()
|
||||
if (snapshots.isEmpty()) {
|
||||
updateStatus("没有可用的 restic 快照")
|
||||
setRunning(false)
|
||||
return@launch
|
||||
}
|
||||
|
||||
// 多快照时让用户选择,单个快照自动选
|
||||
val chosenSnapshot = if (snapshots.size == 1) {
|
||||
snapshots.first()
|
||||
} else {
|
||||
pickSnapshot(snapshots) ?: run {
|
||||
updateStatus("已取消选择")
|
||||
setRunning(false)
|
||||
return@launch
|
||||
}
|
||||
}
|
||||
|
||||
// Switch to restic source
|
||||
backupDir = null
|
||||
selectedSnapshot = chosenSnapshot
|
||||
val backupPath = selectedSnapshot!!.paths.firstOrNull() ?: run {
|
||||
updateStatus("快照中找不到备份路径")
|
||||
setRunning(false)
|
||||
return@launch
|
||||
}
|
||||
|
||||
// Read app list from the snapshot
|
||||
val appListContent = readResticFile(config, selectedSnapshot!!.id, "$backupPath/appList.txt")
|
||||
packages = if (appListContent != null) {
|
||||
appListContent.lines().map { it.trim() }.filter { it.isNotEmpty() && !it.startsWith("#") }
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
|
||||
if (packages.isEmpty()) {
|
||||
updateStatus("无法从快照读取应用列表")
|
||||
setRunning(false)
|
||||
return@launch
|
||||
}
|
||||
|
||||
binding.backupDirText.text = "restic: ${selectedSnapshot!!.time.take(19)} (${snapshots.size} 个快照可用)"
|
||||
selectedPackages.clear()
|
||||
|
||||
selectedPackages.addAll(packages)
|
||||
|
||||
// Resolve app labels for display
|
||||
appInfos = AppScanner.resolveLabels(requireContext(), packages.map { AppInfo(packageName = PackageName(it)) })
|
||||
|
||||
updateStatus("restic 快照共 ${packages.size} 个应用,点击恢复开始")
|
||||
binding.restoreButton.isEnabled = true
|
||||
setRunning(false)
|
||||
setupAppList()
|
||||
} catch (e: Exception) {
|
||||
binding.statusText.text = "选择快照失败: ${e.message}"
|
||||
setRunning(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 多快照时弹出选择对话框。返回用户选择的快照,取消时返回 null。 */
|
||||
private suspend fun pickSnapshot(snapshots: List<ResticWrapper.ResticSnapshot>): ResticWrapper.ResticSnapshot? =
|
||||
suspendCancellableCoroutine { cont ->
|
||||
val names = snapshots.map { "${it.time.take(19)} (${it.id.take(8)})" }
|
||||
AlertDialog.Builder(requireContext())
|
||||
.setTitle("选择快照")
|
||||
.setItems(names.toTypedArray()) { _, i -> cont.resume(snapshots[i]) }
|
||||
.setOnCancelListener { cont.resume(null) }
|
||||
.show()
|
||||
}
|
||||
|
||||
/** Read a single file from a restic snapshot using `restic dump`. */
|
||||
private suspend fun readResticFile(
|
||||
config: BackupConfig,
|
||||
snapshotId: String,
|
||||
filePath: String
|
||||
): String? {
|
||||
val result = ResticWrapper.dump(
|
||||
config.resticRepo, config.resticPassword,
|
||||
snapshotId, filePath,
|
||||
backend = config.resticBackend,
|
||||
backendUrl = config.resticBackendUrl,
|
||||
backendUser = config.resticBackendUser,
|
||||
backendPass = config.resticBackendPass,
|
||||
backendShare = config.resticBackendShare
|
||||
)
|
||||
return result.getOrNull()
|
||||
}
|
||||
|
||||
private fun setupAppList() {
|
||||
binding.appList.adapter = PackageListAdapter(
|
||||
appInfos, selectedPackages,
|
||||
onToggle = { pkg, checked ->
|
||||
if (checked) selectedPackages.add(pkg) else selectedPackages.remove(pkg)
|
||||
binding.statusText.text = "已选择 ${selectedPackages.size}/${packages.size} 个应用"
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun startRestore() {
|
||||
val toRestore = packages.filter { it in selectedPackages }
|
||||
if (toRestore.isEmpty()) return
|
||||
|
||||
setRunning(true)
|
||||
binding.restoreButton.isEnabled = false
|
||||
binding.selectDirButton.isEnabled = false
|
||||
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
try {
|
||||
val result = if (selectedSnapshot != null && resticConfig != null) {
|
||||
// Restic restore
|
||||
val snapshot = selectedSnapshot ?: return@launch
|
||||
val config = resticConfig ?: return@launch
|
||||
val backupPath = snapshot.paths.firstOrNull() ?: return@launch
|
||||
val staging = File(requireContext().cacheDir, "restic_restore_${snapshot.shortId}")
|
||||
staging.mkdirs()
|
||||
try {
|
||||
binding.progressBar.isIndeterminate = true
|
||||
|
||||
binding.statusText.text = "正在从 restic 快照恢复到暂存目录…"
|
||||
val restoreResult = ResticWrapper.restore(
|
||||
repoPath = config.resticRepo,
|
||||
password = config.resticPassword,
|
||||
snapshotId = snapshot.id,
|
||||
targetPath = staging.absolutePath,
|
||||
backend = config.resticBackend,
|
||||
backendUrl = config.resticBackendUrl,
|
||||
backendUser = config.resticBackendUser,
|
||||
backendPass = config.resticBackendPass,
|
||||
backendShare = config.resticBackendShare,
|
||||
onProgress = { msg -> withContext(Dispatchers.Main) { binding.statusText.text = msg } }
|
||||
)
|
||||
|
||||
if (restoreResult.isFailure) {
|
||||
updateStatus("restic 恢复失败: ${restoreResult.exceptionOrNull()?.message}")
|
||||
return@launch
|
||||
}
|
||||
|
||||
// The restored backup directory: <staging>/<original_absolute_path>
|
||||
val restoredBackupDir = File(staging, backupPath.removePrefix("/"))
|
||||
updateStatus("正在从恢复的备份安装应用…")
|
||||
|
||||
val r = RestoreOperation.restoreApps(
|
||||
context = requireContext(),
|
||||
backupDir = restoredBackupDir,
|
||||
userId = selectedUserId.toString(),
|
||||
filterPkgs = selectedPackages,
|
||||
onProgress = { progress ->
|
||||
val label = appInfos.find { it.packageName.value == progress.packageName }?.label
|
||||
val name = label?.ifEmpty { progress.packageName } ?: progress.packageName
|
||||
binding.statusText.text =
|
||||
"[${progress.current}/${progress.total}] $name: ${progress.message}"
|
||||
}
|
||||
)
|
||||
// Also restore WiFi if backup exists
|
||||
WifiManager.restore(restoredBackupDir)
|
||||
r
|
||||
} finally {
|
||||
try { staging.deleteRecursively() } catch (_: Exception) {}
|
||||
}
|
||||
} else {
|
||||
// Local restore
|
||||
val dir = backupDir ?: return@launch
|
||||
val r = RestoreOperation.restoreApps(
|
||||
context = requireContext(),
|
||||
backupDir = dir,
|
||||
userId = selectedUserId.toString(),
|
||||
filterPkgs = selectedPackages,
|
||||
onProgress = { progress ->
|
||||
val label = appInfos.find { it.packageName.value == progress.packageName }?.label
|
||||
val name = label?.ifEmpty { progress.packageName } ?: progress.packageName
|
||||
binding.statusText.text =
|
||||
"[${progress.current}/${progress.total}] $name: ${progress.message}"
|
||||
}
|
||||
)
|
||||
// Also restore WiFi if backup exists locally
|
||||
WifiManager.restore(dir)
|
||||
r
|
||||
}
|
||||
|
||||
binding.statusText.text = buildString {
|
||||
appendLine("恢复完成!")
|
||||
appendLine("成功: ${result.successCount} 失败: ${result.failCount}")
|
||||
appendLine("耗时: ${result.elapsedMs / 1000}秒")
|
||||
appendLine("如有 SSAID,请立即重启设备后再开启应用")
|
||||
}
|
||||
} catch (e: kotlinx.coroutines.CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
binding.statusText.text = "恢复异常: ${e.message}"
|
||||
} finally {
|
||||
setRunning(false)
|
||||
binding.selectDirButton.isEnabled = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private fun setRunning(running: Boolean) {
|
||||
binding.progressBar.visibility = if (running) View.VISIBLE else View.GONE
|
||||
}
|
||||
|
||||
private suspend fun updateStatus(text: String) {
|
||||
binding.statusText.text = text
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
_binding = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,592 @@
|
||||
package com.example.androidbackupgui.ui
|
||||
import android.net.Uri
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.example.androidbackupgui.backup.*
|
||||
import com.example.androidbackupgui.backup.defaultResticWrapper
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
|
||||
@Composable
|
||||
fun RestoreScreen() {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
// ── State ──
|
||||
var backupDir by remember { mutableStateOf<File?>(null) }
|
||||
var packages by remember { mutableStateOf<List<String>>(emptyList()) }
|
||||
var appInfos by remember { mutableStateOf<List<AppInfo>>(emptyList()) }
|
||||
var selectedPackages by remember { mutableStateOf<Set<String>>(emptySet()) }
|
||||
var resticConfig by remember { mutableStateOf<BackupConfig?>(null) }
|
||||
var config by remember { mutableStateOf(BackupConfig()) }
|
||||
var selectedSnapshot by remember { mutableStateOf<ResticWrapper.ResticSnapshot?>(null) }
|
||||
var isRunning by remember { mutableStateOf(false) }
|
||||
var statusText by remember { mutableStateOf("请选择备份源") }
|
||||
var showSnapshotPicker by remember { mutableStateOf(false) }
|
||||
var availableSnapshots by remember { mutableStateOf<List<ResticWrapper.ResticSnapshot>>(emptyList()) }
|
||||
val configFile = remember { File(context.filesDir, "backup_settings.conf") }
|
||||
|
||||
// SAF directory picker for selecting external backup dir
|
||||
val dirPickerLauncher =
|
||||
rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.OpenDocumentTree(),
|
||||
) { uri ->
|
||||
if (uri != null) {
|
||||
val resolvedPath = resolveSafTreeUri(uri)
|
||||
if (resolvedPath != null) {
|
||||
val dir = File(resolvedPath)
|
||||
backupDir = dir
|
||||
selectedSnapshot = null
|
||||
scope.launch {
|
||||
loadFromDir(context, dir) { pkgs, infos, status ->
|
||||
packages = pkgs
|
||||
appInfos = infos
|
||||
selectedPackages = pkgs.toSet()
|
||||
statusText = status
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Load config
|
||||
LaunchedEffect(Unit) {
|
||||
config = BackupConfig.fromFile(configFile)
|
||||
if (config.resticEnabled == 1 && config.resticRepo.isNotBlank()) {
|
||||
resticConfig = config
|
||||
}
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
// ── Top controls card ──
|
||||
Card(modifier = Modifier.fillMaxWidth().padding(12.dp)) {
|
||||
Column(modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
// Source buttons row
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
try {
|
||||
val defaultDir = context.filesDir
|
||||
val backupDirs =
|
||||
withContext(Dispatchers.IO) {
|
||||
defaultDir
|
||||
.listFiles()
|
||||
?.filter { it.isDirectory && it.name.startsWith("Backup_") }
|
||||
?: emptyList()
|
||||
}
|
||||
if (backupDirs.isNotEmpty()) {
|
||||
val dir = backupDirs.first()
|
||||
backupDir = dir
|
||||
selectedSnapshot = null
|
||||
loadFromDir(context, dir) { pkgs, infos, status ->
|
||||
packages = pkgs
|
||||
appInfos = infos
|
||||
selectedPackages = pkgs.toSet()
|
||||
statusText = status
|
||||
}
|
||||
} else {
|
||||
statusText = "未找到备份目录"
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
statusText = "选择目录失败: ${e.message}"
|
||||
}
|
||||
}
|
||||
},
|
||||
enabled = !isRunning,
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
Text("本地备份")
|
||||
}
|
||||
|
||||
OutlinedButton(
|
||||
onClick = { dirPickerLauncher.launch(null) },
|
||||
enabled = !isRunning,
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
Text("选择目录")
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
val config =
|
||||
resticConfig ?: run {
|
||||
statusText = "未配置 Restic,请先在设置中配置"
|
||||
return@Button
|
||||
}
|
||||
scope.launch {
|
||||
isRunning = true
|
||||
statusText = "正在读取快照…"
|
||||
try {
|
||||
// 配置 ResticWrapper 环境
|
||||
defaultResticWrapper.cacheDir = context.cacheDir.absolutePath
|
||||
defaultResticWrapper.backendDomain = config.resticBackendDomain
|
||||
ResticBinary.prepare(context)?.let { defaultResticWrapper.binaryPath = it }
|
||||
|
||||
// 从 PasswordManager 恢复密码(过滤掉占位符)
|
||||
fun configPw(
|
||||
key: String?,
|
||||
fallback: String,
|
||||
): String = key?.takeIf { it.isNotEmpty() && it != "stored-in-keystore" } ?: fallback
|
||||
val realPassword = configPw(PasswordManager.getResticPassword(), config.resticPassword)
|
||||
val realBackendPass = configPw(PasswordManager.getBackendPass(), config.resticBackendPass)
|
||||
val result =
|
||||
withContext(Dispatchers.IO) {
|
||||
defaultResticWrapper.listSnapshots(
|
||||
config.resticRepo,
|
||||
realPassword,
|
||||
backend = config.resticBackend,
|
||||
backendUrl = config.resticBackendUrl,
|
||||
backendUser = config.resticBackendUser,
|
||||
backendPass = realBackendPass,
|
||||
backendShare = config.resticBackendShare,
|
||||
)
|
||||
}
|
||||
if (result.isFailure) {
|
||||
statusText = "读取快照失败: ${result.exceptionOrNull()?.message}"
|
||||
return@launch
|
||||
}
|
||||
val snaps = result.getOrThrow()
|
||||
if (snaps.isEmpty()) {
|
||||
statusText = "没有可用的 restic 快照"
|
||||
return@launch
|
||||
}
|
||||
availableSnapshots = snaps
|
||||
if (snaps.size == 1) {
|
||||
loadResticSnapshot(context, snaps.first(), resticConfig!!) { pkgs, infos, status ->
|
||||
backupDir = null
|
||||
selectedSnapshot = snaps.first()
|
||||
packages = pkgs
|
||||
appInfos = infos
|
||||
selectedPackages = pkgs.toSet()
|
||||
statusText = status
|
||||
}
|
||||
} else {
|
||||
showSnapshotPicker = true
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
statusText = "选择快照失败: ${e.message}"
|
||||
} finally {
|
||||
isRunning = false
|
||||
}
|
||||
}
|
||||
},
|
||||
enabled = !isRunning && resticConfig != null,
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
Text("Restic 快照")
|
||||
}
|
||||
}
|
||||
|
||||
// Source info text
|
||||
val sourceText =
|
||||
if (backupDir != null) {
|
||||
backupDir!!.absolutePath
|
||||
} else if (selectedSnapshot != null) {
|
||||
"restic: ${selectedSnapshot!!.time.take(19)}"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
if (sourceText.isNotEmpty()) {
|
||||
Text(
|
||||
text = sourceText,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Status ──
|
||||
Text(
|
||||
text = statusText,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||
)
|
||||
|
||||
// ── App list ──
|
||||
LazyColumn(
|
||||
modifier = Modifier.weight(1f).fillMaxWidth(),
|
||||
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
items(appInfos, key = { it.packageName.value }) { app ->
|
||||
Card(
|
||||
onClick = {
|
||||
val pkg = app.packageName.value
|
||||
selectedPackages =
|
||||
if (pkg in selectedPackages) {
|
||||
selectedPackages - pkg
|
||||
} else {
|
||||
selectedPackages + pkg
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Checkbox(
|
||||
checked = app.packageName.value in selectedPackages,
|
||||
onCheckedChange = { checked ->
|
||||
val pkg = app.packageName.value
|
||||
selectedPackages =
|
||||
if (checked) {
|
||||
selectedPackages + pkg
|
||||
} else {
|
||||
selectedPackages - pkg
|
||||
}
|
||||
},
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Column {
|
||||
Text(
|
||||
text = app.label.ifEmpty { app.packageName.value },
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
Text(
|
||||
text = app.packageName.value,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Bottom bar ──
|
||||
Surface(modifier = Modifier.fillMaxWidth(), tonalElevation = 3.dp) {
|
||||
Button(
|
||||
onClick = {
|
||||
val toRestore = packages.filter { it in selectedPackages }
|
||||
if (toRestore.isEmpty()) return@Button
|
||||
isRunning = true
|
||||
statusText = "开始恢复 ${toRestore.size} 个应用…"
|
||||
|
||||
scope.launch {
|
||||
try {
|
||||
if (selectedSnapshot != null && resticConfig != null) {
|
||||
val snapshot = selectedSnapshot!!
|
||||
val config = resticConfig!!
|
||||
val backupPath = snapshot.paths.firstOrNull() ?: return@launch
|
||||
val staging = File(context.cacheDir, "restic_restore_${snapshot.shortId}")
|
||||
staging.mkdirs()
|
||||
|
||||
try {
|
||||
statusText = "正在从 restic 快照恢复…"
|
||||
val restoreResult =
|
||||
withContext(Dispatchers.IO) {
|
||||
val rPw =
|
||||
PasswordManager.getResticPassword()?.takeIf { it != "stored-in-keystore" }
|
||||
?: config.resticPassword
|
||||
val rBpw =
|
||||
PasswordManager.getBackendPass()?.takeIf { it != "stored-in-keystore" }
|
||||
?: config.resticBackendPass
|
||||
defaultResticWrapper.restore(
|
||||
repoPath = config.resticRepo,
|
||||
password = rPw,
|
||||
snapshotId = snapshot.id,
|
||||
targetPath = staging.absolutePath,
|
||||
backend = config.resticBackend,
|
||||
backendUrl = config.resticBackendUrl,
|
||||
backendUser = config.resticBackendUser,
|
||||
backendPass = rBpw,
|
||||
backendShare = config.resticBackendShare,
|
||||
)
|
||||
}
|
||||
if (restoreResult.isFailure) {
|
||||
statusText = "restic 恢复失败: ${restoreResult.exceptionOrNull()?.message}"
|
||||
return@launch
|
||||
}
|
||||
val restoredDir = File(staging, backupPath.removePrefix("/"))
|
||||
statusText = "正在从恢复的备份安装应用…"
|
||||
|
||||
val result =
|
||||
withContext(Dispatchers.IO) {
|
||||
RestoreOperation.restoreApps(
|
||||
context = context,
|
||||
backupDir = restoredDir,
|
||||
userId = config.backupUserId.toString(),
|
||||
filterPkgs = selectedPackages,
|
||||
onProgress = { progress ->
|
||||
statusText =
|
||||
"[${progress.current}/${progress.total}] ${progress.packageName}: ${progress.message}"
|
||||
},
|
||||
)
|
||||
}
|
||||
WifiManager.restore(restoredDir)
|
||||
statusText =
|
||||
buildString {
|
||||
appendLine("恢复完成!")
|
||||
appendLine("成功: ${result.successCount} 失败: ${result.failCount}")
|
||||
append("耗时: ${result.elapsedMs / 1000}秒")
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
staging.deleteRecursively()
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
} else if (backupDir != null) {
|
||||
val dir = backupDir!!
|
||||
val result =
|
||||
withContext(Dispatchers.IO) {
|
||||
RestoreOperation.restoreApps(
|
||||
context = context,
|
||||
backupDir = dir,
|
||||
userId = config.backupUserId.toString(),
|
||||
filterPkgs = selectedPackages,
|
||||
onProgress = { progress ->
|
||||
statusText =
|
||||
"[${progress.current}/${progress.total}] ${progress.packageName}: ${progress.message}"
|
||||
},
|
||||
)
|
||||
}
|
||||
WifiManager.restore(dir)
|
||||
statusText =
|
||||
buildString {
|
||||
appendLine("恢复完成!")
|
||||
appendLine("成功: ${result.successCount} 失败: ${result.failCount}")
|
||||
append("耗时: ${result.elapsedMs / 1000}秒")
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
statusText = "恢复异常: ${e.message}"
|
||||
} finally {
|
||||
isRunning = false
|
||||
}
|
||||
}
|
||||
},
|
||||
enabled = !isRunning && selectedPackages.isNotEmpty() && (backupDir != null || selectedSnapshot != null),
|
||||
modifier = Modifier.fillMaxWidth().padding(12.dp),
|
||||
) {
|
||||
if (isRunning) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(16.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
}
|
||||
Text("开始恢复 (${selectedPackages.size})")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Snapshot picker dialog ──
|
||||
if (showSnapshotPicker && availableSnapshots.isNotEmpty()) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showSnapshotPicker = false },
|
||||
title = { Text("选择快照") },
|
||||
text = {
|
||||
Column {
|
||||
availableSnapshots.forEach { snap ->
|
||||
val label = "${snap.time.take(19)} (${snap.shortId})"
|
||||
TextButton(
|
||||
onClick = {
|
||||
showSnapshotPicker = false
|
||||
scope.launch {
|
||||
loadResticSnapshot(context, snap, resticConfig!!) { pkgs, infos, status ->
|
||||
backupDir = null
|
||||
selectedSnapshot = snap
|
||||
packages = pkgs
|
||||
appInfos = infos
|
||||
selectedPackages = pkgs.toSet()
|
||||
statusText = status
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text(label) }
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = { showSnapshotPicker = false }) { Text("取消") }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadFromDir(
|
||||
context: android.content.Context,
|
||||
dir: File,
|
||||
onResult: (packages: List<String>, appInfos: List<AppInfo>, status: String) -> Unit,
|
||||
) {
|
||||
withContext(Dispatchers.IO) {
|
||||
val appListFile = File(dir, "appList.txt")
|
||||
val pkgs =
|
||||
BackupOperation.readTextFile(appListFile)?.let { content ->
|
||||
content.lines().map { it.trim() }.filter { it.isNotEmpty() && !it.startsWith("#") }
|
||||
} ?: run {
|
||||
BackupOperation.listBackupFiles(dir)
|
||||
?: emptyList()
|
||||
}
|
||||
// Filter to only apps that have actual backup data (at least one APK)
|
||||
val validPkgs =
|
||||
pkgs.filter { pkg ->
|
||||
val appDir = File(dir, pkg)
|
||||
val files = BackupOperation.listBackupFiles(appDir)
|
||||
files?.any { it.endsWith(".apk") } == true
|
||||
}
|
||||
val skipped = pkgs.size - validPkgs.size
|
||||
// Read cached labels from app_details.json (includes uninstalled apps)
|
||||
val cachedLabels = readLocalAppDetails(dir)
|
||||
val preLabeled =
|
||||
validPkgs.map { pkg ->
|
||||
AppInfo(packageName = PackageName(pkg), label = cachedLabels[pkg] ?: "")
|
||||
}
|
||||
// Resolve labels for currently installed apps, keep cached labels for uninstalled
|
||||
val resolved = AppScanner.resolveLabels(context, preLabeled)
|
||||
// For apps that resolveLabels fell back to package name, restore cached label
|
||||
val infos =
|
||||
resolved.map { app ->
|
||||
val cachedLabel = cachedLabels[app.packageName.value]
|
||||
if (cachedLabel != null && app.label == app.packageName.value) {
|
||||
app.copy(label = cachedLabel)
|
||||
} else {
|
||||
app
|
||||
}
|
||||
}
|
||||
val suffix = if (skipped > 0) "(${skipped}个应用备份数据缺失已自动跳过)" else ""
|
||||
onResult(validPkgs, infos, "共 ${validPkgs.size} 个备份应用$suffix")
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadResticSnapshot(
|
||||
context: android.content.Context,
|
||||
snapshot: ResticWrapper.ResticSnapshot,
|
||||
config: BackupConfig,
|
||||
onResult: (packages: List<String>, appInfos: List<AppInfo>, status: String) -> Unit,
|
||||
) {
|
||||
val backupPath =
|
||||
snapshot.paths.firstOrNull() ?: run {
|
||||
onResult(emptyList(), emptyList(), "快照中找不到备份路径")
|
||||
return
|
||||
}
|
||||
|
||||
fun rp(
|
||||
key: String?,
|
||||
fallback: String,
|
||||
) = key?.takeIf { it.isNotEmpty() && it != "stored-in-keystore" } ?: fallback
|
||||
val realPassword = rp(PasswordManager.getResticPassword(), config.resticPassword)
|
||||
val realBackendPass = rp(PasswordManager.getBackendPass(), config.resticBackendPass)
|
||||
|
||||
suspend fun tryDump(path: String) =
|
||||
defaultResticWrapper
|
||||
.dump(
|
||||
config.resticRepo,
|
||||
realPassword,
|
||||
snapshot.id,
|
||||
path,
|
||||
backend = config.resticBackend,
|
||||
backendUrl = config.resticBackendUrl,
|
||||
backendUser = config.resticBackendUser,
|
||||
backendPass = realBackendPass,
|
||||
backendShare = config.resticBackendShare,
|
||||
).getOrNull()
|
||||
// 兼容流式备份(新版:根目录,旧版:meta/)和普通备份
|
||||
val content =
|
||||
tryDump("$backupPath/appList.txt")
|
||||
?: tryDump("$backupPath/meta/appList.txt")
|
||||
if (content == null) {
|
||||
onResult(emptyList(), emptyList(), "无法从快照读取应用列表")
|
||||
return
|
||||
}
|
||||
val pkgs =
|
||||
content
|
||||
.lines()
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotEmpty() && !it.startsWith("#") }
|
||||
|
||||
// Read cached labels from app_details.json in the snapshot
|
||||
val cachedLabels = loadResticAppDetails(config, snapshot.id, backupPath)
|
||||
val preLabeled =
|
||||
pkgs.map { pkg ->
|
||||
AppInfo(packageName = PackageName(pkg), label = cachedLabels[pkg] ?: "")
|
||||
}
|
||||
val resolved = AppScanner.resolveLabels(context, preLabeled)
|
||||
val infos =
|
||||
resolved.map { app ->
|
||||
val cachedLabel = cachedLabels[app.packageName.value]
|
||||
if (cachedLabel != null && app.label == app.packageName.value) {
|
||||
app.copy(label = cachedLabel)
|
||||
} else {
|
||||
app
|
||||
}
|
||||
}
|
||||
onResult(pkgs, infos, "restic 快照共 ${pkgs.size} 个应用")
|
||||
}
|
||||
|
||||
/** Read app_details.json from a local backup directory and return a package→label map. */
|
||||
private suspend fun readLocalAppDetails(dir: File): Map<String, String> =
|
||||
withContext(Dispatchers.IO) {
|
||||
val metaFile = File(dir, "app_details.json")
|
||||
val json = BackupOperation.readTextFile(metaFile) ?: return@withContext emptyMap()
|
||||
try {
|
||||
defaultResticWrapper.parseAppDetailsJson(json).mapValues { it.value.label }
|
||||
} catch (_: Exception) {
|
||||
emptyMap()
|
||||
}
|
||||
}
|
||||
|
||||
/** Dump app_details.json from a restic snapshot and return a package→label map. */
|
||||
private suspend fun loadResticAppDetails(
|
||||
config: BackupConfig,
|
||||
snapshotId: String,
|
||||
backupPath: String,
|
||||
): Map<String, String> {
|
||||
fun rp2(
|
||||
key: String?,
|
||||
fallback: String,
|
||||
) = key?.takeIf { it.isNotEmpty() && it != "stored-in-keystore" } ?: fallback
|
||||
val realPassword = rp2(PasswordManager.getResticPassword(), config.resticPassword)
|
||||
val realBackendPass = rp2(PasswordManager.getBackendPass(), config.resticBackendPass)
|
||||
|
||||
suspend fun tryDump(path: String) =
|
||||
defaultResticWrapper
|
||||
.dump(
|
||||
config.resticRepo,
|
||||
realPassword,
|
||||
snapshotId,
|
||||
path,
|
||||
backend = config.resticBackend,
|
||||
backendUrl = config.resticBackendUrl,
|
||||
backendUser = config.resticBackendUser,
|
||||
backendPass = realBackendPass,
|
||||
backendShare = config.resticBackendShare,
|
||||
).getOrNull()
|
||||
val json =
|
||||
tryDump("$backupPath/app_details.json")
|
||||
?: tryDump("$backupPath/meta/app_details.json")
|
||||
?: return emptyMap()
|
||||
return try {
|
||||
defaultResticWrapper.parseAppDetailsJson(json).mapValues { it.value.label }
|
||||
} catch (_: Exception) {
|
||||
emptyMap()
|
||||
}
|
||||
}
|
||||
|
||||
/** Convert SAF tree URI to a filesystem path. */
|
||||
private fun resolveSafTreeUri(uri: Uri): String? {
|
||||
val docId = uri.lastPathSegment?.let { java.net.URLDecoder.decode(it, "UTF-8") } ?: return null
|
||||
val colonIdx = docId.indexOf(':')
|
||||
if (colonIdx < 0) return null
|
||||
val storageId = docId.substring(0, colonIdx)
|
||||
val relPath = docId.substring(colonIdx + 1).trim('/')
|
||||
return if (storageId.equals("primary", ignoreCase = true)) {
|
||||
"/storage/emulated/0/$relPath"
|
||||
} else {
|
||||
"/storage/$storageId/$relPath"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package com.example.androidbackupgui.ui.theme
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
// Material 3 light scheme colors
|
||||
val md_theme_light_primary = Color(0xFF1A6B52)
|
||||
val md_theme_light_onPrimary = Color(0xFFFFFFFF)
|
||||
val md_theme_light_primaryContainer = Color(0xFFA4F2D3)
|
||||
val md_theme_light_onPrimaryContainer = Color(0xFF002117)
|
||||
val md_theme_light_secondary = Color(0xFF4C6359)
|
||||
val md_theme_light_onSecondary = Color(0xFFFFFFFF)
|
||||
val md_theme_light_secondaryContainer = Color(0xFFCEE9DB)
|
||||
val md_theme_light_onSecondaryContainer = Color(0xFF092017)
|
||||
val md_theme_light_tertiary = Color(0xFF3F6373)
|
||||
val md_theme_light_onTertiary = Color(0xFFFFFFFF)
|
||||
val md_theme_light_tertiaryContainer = Color(0xFFC3E8FB)
|
||||
val md_theme_light_onTertiaryContainer = Color(0xFF001F29)
|
||||
val md_theme_light_error = Color(0xFFBA1A1A)
|
||||
val md_theme_light_onError = Color(0xFFFFFFFF)
|
||||
val md_theme_light_errorContainer = Color(0xFFFFDAD6)
|
||||
val md_theme_light_onErrorContainer = Color(0xFF410002)
|
||||
val md_theme_light_background = Color(0xFFFBFDF9)
|
||||
val md_theme_light_onBackground = Color(0xFF191C1A)
|
||||
val md_theme_light_surface = Color(0xFFFBFDF9)
|
||||
val md_theme_light_onSurface = Color(0xFF191C1A)
|
||||
val md_theme_light_surfaceVariant = Color(0xFFDBE5DD)
|
||||
val md_theme_light_onSurfaceVariant = Color(0xFF404943)
|
||||
val md_theme_light_outline = Color(0xFF707973)
|
||||
val md_theme_light_outlineVariant = Color(0xFFBFC9C2)
|
||||
val md_theme_light_inverseSurface = Color(0xFF2E312E)
|
||||
val md_theme_light_inverseOnSurface = Color(0xFFF0F1ED)
|
||||
val md_theme_light_inversePrimary = Color(0xFF88D6B8)
|
||||
val md_theme_light_surfaceTint = Color(0xFF1A6B52)
|
||||
|
||||
// Material 3 dark scheme colors
|
||||
val md_theme_dark_primary = Color(0xFF88D6B8)
|
||||
val md_theme_dark_onPrimary = Color(0xFF003828)
|
||||
val md_theme_dark_primaryContainer = Color(0xFF00513C)
|
||||
val md_theme_dark_onPrimaryContainer = Color(0xFFA4F2D3)
|
||||
val md_theme_dark_secondary = Color(0xFFB3CCC0)
|
||||
val md_theme_dark_onSecondary = Color(0xFF1F352B)
|
||||
val md_theme_dark_secondaryContainer = Color(0xFF354B41)
|
||||
val md_theme_dark_onSecondaryContainer = Color(0xFFCEE9DB)
|
||||
val md_theme_dark_tertiary = Color(0xFFA7CCDF)
|
||||
val md_theme_dark_onTertiary = Color(0xFF083544)
|
||||
val md_theme_dark_tertiaryContainer = Color(0xFF254B5B)
|
||||
val md_theme_dark_onTertiaryContainer = Color(0xFFC3E8FB)
|
||||
val md_theme_dark_error = Color(0xFFFFB4AB)
|
||||
val md_theme_dark_onError = Color(0xFF690005)
|
||||
val md_theme_dark_errorContainer = Color(0xFF93000A)
|
||||
val md_theme_dark_onErrorContainer = Color(0xFFFFDAD6)
|
||||
val md_theme_dark_background = Color(0xFF191C1A)
|
||||
val md_theme_dark_onBackground = Color(0xFFE1E3DF)
|
||||
val md_theme_dark_surface = Color(0xFF191C1A)
|
||||
val md_theme_dark_onSurface = Color(0xFFE1E3DF)
|
||||
val md_theme_dark_surfaceVariant = Color(0xFF404943)
|
||||
val md_theme_dark_onSurfaceVariant = Color(0xFFBFC9C2)
|
||||
val md_theme_dark_outline = Color(0xFF89938C)
|
||||
val md_theme_dark_outlineVariant = Color(0xFF404943)
|
||||
val md_theme_dark_inverseSurface = Color(0xFFE1E3DF)
|
||||
val md_theme_dark_inverseOnSurface = Color(0xFF191C1A)
|
||||
val md_theme_dark_inversePrimary = Color(0xFF1A6B52)
|
||||
val md_theme_dark_surfaceTint = Color(0xFF88D6B8)
|
||||
|
||||
// Status colors
|
||||
val StatusSuccess = Color(0xFF2E7D32)
|
||||
val StatusWarning = Color(0xFFF57F17)
|
||||
val StatusError = Color(0xFFD32F2F)
|
||||
val StatusInfo = Color(0xFF1976D2)
|
||||
@@ -0,0 +1,98 @@
|
||||
package com.example.androidbackupgui.ui.theme
|
||||
|
||||
import android.app.Activity
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.core.view.WindowCompat
|
||||
|
||||
private val LightColorScheme = lightColorScheme(
|
||||
primary = md_theme_light_primary,
|
||||
onPrimary = md_theme_light_onPrimary,
|
||||
primaryContainer = md_theme_light_primaryContainer,
|
||||
onPrimaryContainer = md_theme_light_onPrimaryContainer,
|
||||
secondary = md_theme_light_secondary,
|
||||
onSecondary = md_theme_light_onSecondary,
|
||||
secondaryContainer = md_theme_light_secondaryContainer,
|
||||
onSecondaryContainer = md_theme_light_onSecondaryContainer,
|
||||
tertiary = md_theme_light_tertiary,
|
||||
onTertiary = md_theme_light_onTertiary,
|
||||
tertiaryContainer = md_theme_light_tertiaryContainer,
|
||||
onTertiaryContainer = md_theme_light_onTertiaryContainer,
|
||||
error = md_theme_light_error,
|
||||
onError = md_theme_light_onError,
|
||||
errorContainer = md_theme_light_errorContainer,
|
||||
onErrorContainer = md_theme_light_onErrorContainer,
|
||||
background = md_theme_light_background,
|
||||
onBackground = md_theme_light_onBackground,
|
||||
surface = md_theme_light_surface,
|
||||
onSurface = md_theme_light_onSurface,
|
||||
surfaceVariant = md_theme_light_surfaceVariant,
|
||||
onSurfaceVariant = md_theme_light_onSurfaceVariant,
|
||||
outline = md_theme_light_outline,
|
||||
outlineVariant = md_theme_light_outlineVariant,
|
||||
inverseSurface = md_theme_light_inverseSurface,
|
||||
inverseOnSurface = md_theme_light_inverseOnSurface,
|
||||
inversePrimary = md_theme_light_inversePrimary,
|
||||
surfaceTint = md_theme_light_surfaceTint,
|
||||
)
|
||||
|
||||
private val DarkColorScheme = darkColorScheme(
|
||||
primary = md_theme_dark_primary,
|
||||
onPrimary = md_theme_dark_onPrimary,
|
||||
primaryContainer = md_theme_dark_primaryContainer,
|
||||
onPrimaryContainer = md_theme_dark_onPrimaryContainer,
|
||||
secondary = md_theme_dark_secondary,
|
||||
onSecondary = md_theme_dark_onSecondary,
|
||||
secondaryContainer = md_theme_dark_secondaryContainer,
|
||||
onSecondaryContainer = md_theme_dark_onSecondaryContainer,
|
||||
tertiary = md_theme_dark_tertiary,
|
||||
onTertiary = md_theme_dark_onTertiary,
|
||||
tertiaryContainer = md_theme_dark_tertiaryContainer,
|
||||
onTertiaryContainer = md_theme_dark_onTertiaryContainer,
|
||||
error = md_theme_dark_error,
|
||||
onError = md_theme_dark_onError,
|
||||
errorContainer = md_theme_dark_errorContainer,
|
||||
onErrorContainer = md_theme_dark_onErrorContainer,
|
||||
background = md_theme_dark_background,
|
||||
onBackground = md_theme_dark_onBackground,
|
||||
surface = md_theme_dark_surface,
|
||||
onSurface = md_theme_dark_onSurface,
|
||||
surfaceVariant = md_theme_dark_surfaceVariant,
|
||||
onSurfaceVariant = md_theme_dark_onSurfaceVariant,
|
||||
outline = md_theme_dark_outline,
|
||||
outlineVariant = md_theme_dark_outlineVariant,
|
||||
inverseSurface = md_theme_dark_inverseSurface,
|
||||
inverseOnSurface = md_theme_dark_inverseOnSurface,
|
||||
inversePrimary = md_theme_dark_inversePrimary,
|
||||
surfaceTint = md_theme_dark_surfaceTint,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun AppTheme(
|
||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme
|
||||
|
||||
// Set status bar colors to match theme
|
||||
val view = LocalView.current
|
||||
if (!view.isInEditMode) {
|
||||
SideEffect {
|
||||
val window = (view.context as Activity).window
|
||||
window.statusBarColor = colorScheme.surface.toArgb()
|
||||
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme
|
||||
}
|
||||
}
|
||||
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
typography = AppTypography,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package com.example.androidbackupgui.ui.theme
|
||||
|
||||
import androidx.compose.material3.Typography
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
val AppTypography = Typography(
|
||||
displayLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 57.sp,
|
||||
lineHeight = 64.sp,
|
||||
letterSpacing = (-0.25).sp
|
||||
),
|
||||
headlineLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 32.sp,
|
||||
lineHeight = 40.sp
|
||||
),
|
||||
headlineMedium = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 28.sp,
|
||||
lineHeight = 36.sp
|
||||
),
|
||||
titleLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 22.sp,
|
||||
lineHeight = 28.sp
|
||||
),
|
||||
titleMedium = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 24.sp,
|
||||
letterSpacing = 0.15.sp
|
||||
),
|
||||
titleSmall = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 20.sp,
|
||||
letterSpacing = 0.1.sp
|
||||
),
|
||||
bodyLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 24.sp,
|
||||
letterSpacing = 0.5.sp
|
||||
),
|
||||
bodyMedium = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 20.sp,
|
||||
letterSpacing = 0.25.sp
|
||||
),
|
||||
bodySmall = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 12.sp,
|
||||
lineHeight = 16.sp,
|
||||
letterSpacing = 0.4.sp
|
||||
),
|
||||
labelLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 20.sp,
|
||||
letterSpacing = 0.1.sp
|
||||
),
|
||||
labelMedium = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 12.sp,
|
||||
lineHeight = 16.sp,
|
||||
letterSpacing = 0.5.sp
|
||||
),
|
||||
labelSmall = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 11.sp,
|
||||
lineHeight = 16.sp,
|
||||
letterSpacing = 0.5.sp
|
||||
)
|
||||
)
|
||||
@@ -1,11 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
|
||||
<path
|
||||
android:fillColor="#000000"
|
||||
android:pathData="M19,9h-4V3H9v6H5l7,7 7,-7zM5,18v2h14v-2H5z" />
|
||||
</vector>
|
||||
@@ -1,11 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
|
||||
<path
|
||||
android:fillColor="#000000"
|
||||
android:pathData="M19.14,12.94c0.04,-0.3 0.06,-0.61 0.06,-0.94 0,-0.32 -0.02,-0.64 -0.07,-0.94l2.03,-1.58c0.18,-0.14 0.23,-0.41 0.12,-0.61l-1.92,-3.32c-0.12,-0.22 -0.37,-0.29 -0.59,-0.22l-2.39,0.96c-0.5,-0.38 -1.03,-0.7 -1.62,-0.94L14.4,2.81c-0.04,-0.24 -0.24,-0.41 -0.48,-0.41h-3.84c-0.24,0 -0.43,0.17 -0.47,0.41L9.25,5.35C8.66,5.59 8.12,5.92 7.63,6.29L5.24,5.33c-0.22,-0.08 -0.47,0 -0.59,0.22L2.74,8.87C2.62,9.08 2.66,9.34 2.86,9.48l2.03,1.58C4.84,11.36 4.8,11.69 4.8,12s0.02,0.64 0.07,0.94l-2.03,1.58c-0.18,0.14 -0.23,0.41 -0.12,0.61l1.92,3.32c0.12,0.22 0.37,0.29 0.59,0.22l2.39,-0.96c0.5,0.38 1.03,0.7 1.62,0.94l0.36,2.54c0.05,0.24 0.24,0.41 0.48,0.41h3.84c0.24,0 0.44,-0.17 0.47,-0.41l0.36,-2.54c0.59,-0.24 1.13,-0.56 1.62,-0.94l2.39,0.96c0.22,0.08 0.47,0 0.59,-0.22l1.92,-3.32c0.12,-0.22 0.07,-0.47 -0.12,-0.61l-2.01,-1.58zM12,15.6c-1.98,0 -3.6,-1.62 -3.6,-3.6s1.62,-3.6 3.6,-3.6 3.6,1.62 3.6,3.6 -1.62,3.6 -3.6,3.6z" />
|
||||
</vector>
|
||||
@@ -1,11 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
|
||||
<path
|
||||
android:fillColor="#000000"
|
||||
android:pathData="M9,16h6v-6h4l-7,-7 -7,7h4zM5,18h14v2H5z" />
|
||||
</vector>
|
||||
@@ -1,33 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical"
|
||||
android:background="?attr/colorSurface">
|
||||
|
||||
<com.google.android.material.appbar.MaterialToolbar
|
||||
android:id="@+id/topAppBar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="?attr/actionBarSize"
|
||||
android:background="?attr/colorSurface"
|
||||
app:title="@string/app_name"
|
||||
app:titleCentered="true"
|
||||
app:titleTextColor="?attr/colorOnSurface"
|
||||
style="@style/Widget.Material3.Toolbar" />
|
||||
|
||||
<androidx.viewpager2.widget.ViewPager2
|
||||
android:id="@+id/viewPager"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1" />
|
||||
|
||||
<com.google.android.material.bottomnavigation.BottomNavigationView
|
||||
android:id="@+id/bottomNav"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?attr/colorSurfaceContainer"
|
||||
app:menu="@menu/bottom_nav"
|
||||
app:labelVisibilityMode="labeled" />
|
||||
|
||||
</LinearLayout>
|
||||
@@ -1,191 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical"
|
||||
android:padding="@dimen/fragment_horizontal_padding"
|
||||
android:background="?attr/colorSurface">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<TextView
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="应用备份"
|
||||
android:textAppearance="@style/TextAppearance.Material3.HeadlineSmall"
|
||||
android:textColor="?attr/colorOnSurface" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/scanButton"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="扫描应用"
|
||||
style="@style/Widget.Material3.Button.TonalButton" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="用户: "
|
||||
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
|
||||
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||
|
||||
<Spinner
|
||||
android:id="@+id/userSelector"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="输出目录: "
|
||||
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
|
||||
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/outputPathLabel"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:ellipsize="middle"
|
||||
android:maxLines="1"
|
||||
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
|
||||
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/outputPathEdit"
|
||||
style="@style/Widget.Material3.Button.TonalButton"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="4dp"
|
||||
android:text="修改" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/sortAZButton"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginEnd="2dp"
|
||||
android:text="A-Z"
|
||||
android:textSize="11sp"
|
||||
style="@style/Widget.Material3.Button.TonalButton" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/sortSizeButton"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginStart="2dp"
|
||||
android:layout_marginEnd="2dp"
|
||||
android:text="大小"
|
||||
android:textSize="11sp"
|
||||
style="@style/Widget.Material3.Button.TonalButton" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/selectAllButton"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginStart="2dp"
|
||||
android:layout_marginEnd="2dp"
|
||||
android:text="全选"
|
||||
android:textSize="11sp"
|
||||
style="@style/Widget.Material3.Button.TonalButton" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/deselectAllButton"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginStart="2dp"
|
||||
android:text="取消全选"
|
||||
android:textSize="11sp"
|
||||
style="@style/Widget.Material3.Button.TonalButton" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<com.google.android.material.materialswitch.MaterialSwitch
|
||||
android:id="@+id/showSystemSwitch"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="显示系统应用"
|
||||
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
|
||||
android:checked="false" />
|
||||
</LinearLayout>
|
||||
|
||||
<com.google.android.material.progressindicator.LinearProgressIndicator
|
||||
android:id="@+id/progressBar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
android:indeterminate="true"
|
||||
android:visibility="gone"
|
||||
app:indicatorColor="?attr/colorPrimary"
|
||||
app:trackColor="?attr/colorSurfaceVariant" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/statusText"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:maxLines="3"
|
||||
android:ellipsize="end"
|
||||
android:text="点击扫描以载入应用列表"
|
||||
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
|
||||
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/appList"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginTop="12dp"
|
||||
android:clipToPadding="false"
|
||||
android:paddingBottom="8dp" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/backupButton"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
android:enabled="false"
|
||||
android:text="开始备份选中应用"
|
||||
style="@style/Widget.Material3.Button" />
|
||||
|
||||
</LinearLayout>
|
||||
@@ -1,393 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="?attr/colorSurface"
|
||||
android:clipToPadding="false"
|
||||
android:padding="@dimen/fragment_horizontal_padding">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
|
||||
<!-- ═══════ 备份选项 ═══════ -->
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="16dp"
|
||||
app:cardElevation="0dp"
|
||||
app:cardBackgroundColor="?attr/colorSurfaceContainer"
|
||||
app:strokeWidth="0dp"
|
||||
app:contentPadding="16dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="备份选项"
|
||||
android:textAppearance="@style/TextAppearance.Material3.TitleMedium"
|
||||
android:textColor="?attr/colorPrimary" />
|
||||
|
||||
<com.google.android.material.switchmaterial.SwitchMaterial
|
||||
android:id="@+id/backupModeSwitch"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
android:text="备份数据+安装包 (关闭则仅备份安装包)" />
|
||||
|
||||
<com.google.android.material.switchmaterial.SwitchMaterial
|
||||
android:id="@+id/backupUserDataSwitch"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:text="备份用户数据" />
|
||||
|
||||
<com.google.android.material.switchmaterial.SwitchMaterial
|
||||
android:id="@+id/backupObbSwitch"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:text="备份 OBB 外部数据包" />
|
||||
|
||||
<com.google.android.material.switchmaterial.SwitchMaterial
|
||||
android:id="@+id/backupWifiSwitch"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:text="备份 WiFi 设置" />
|
||||
|
||||
<com.google.android.material.switchmaterial.SwitchMaterial
|
||||
android:id="@+id/ignoreRunningSwitch"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:text="忽略运行中的应用" />
|
||||
|
||||
</LinearLayout>
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
<!-- ═══════ 输出路径 ═══════ -->
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="16dp"
|
||||
app:cardElevation="0dp"
|
||||
app:cardBackgroundColor="?attr/colorSurfaceContainer"
|
||||
app:strokeWidth="0dp"
|
||||
app:contentPadding="16dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="输出路径"
|
||||
android:textAppearance="@style/TextAppearance.Material3.TitleMedium"
|
||||
android:textColor="?attr/colorPrimary" />
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
android:hint="输出路径 (留空使用默认)"
|
||||
style="@style/Widget.Material3.TextInputLayout.OutlinedBox">
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/outputPathEdit"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:singleLine="true" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:hint="压缩算法 (zstd / tar)"
|
||||
style="@style/Widget.Material3.TextInputLayout.OutlinedBox">
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/compressionEdit"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:singleLine="true"
|
||||
android:text="zstd" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
</LinearLayout>
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
<!-- ═══════ Restic 云端备份 ═══════ -->
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="16dp"
|
||||
app:cardElevation="0dp"
|
||||
app:cardBackgroundColor="?attr/colorSurfaceContainer"
|
||||
app:strokeWidth="0dp"
|
||||
app:contentPadding="16dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Restic 云端备份"
|
||||
android:textAppearance="@style/TextAppearance.Material3.TitleMedium"
|
||||
android:textColor="?attr/colorPrimary" />
|
||||
|
||||
<com.google.android.material.switchmaterial.SwitchMaterial
|
||||
android:id="@+id/resticEnabledSwitch"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
android:text="启用 restic 增量去重 (需安装 restic 二进制)" />
|
||||
|
||||
<!-- Backend selector -->
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
android:text="存储位置"
|
||||
android:textAppearance="@style/TextAppearance.Material3.LabelLarge"
|
||||
android:textColor="?attr/colorOnSurface" />
|
||||
|
||||
<HorizontalScrollView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:scrollbars="none">
|
||||
|
||||
<com.google.android.material.button.MaterialButtonToggleGroup
|
||||
android:id="@+id/resticBackendGroup"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
app:singleSelection="true"
|
||||
app:selectionRequired="true">
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/resticBackendLocal"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:minWidth="80dp"
|
||||
android:text="本机"
|
||||
style="@style/Widget.Material3.Button.TonalButton" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/resticBackendWebdav"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:minWidth="80dp"
|
||||
android:text="WebDAV"
|
||||
style="@style/Widget.Material3.Button.TonalButton" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/resticBackendSmb"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:minWidth="80dp"
|
||||
android:text="SMB"
|
||||
style="@style/Widget.Material3.Button.TonalButton" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/resticBackendRestServer"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:minWidth="80dp"
|
||||
android:text="REST"
|
||||
style="@style/Widget.Material3.Button.TonalButton" />
|
||||
|
||||
</com.google.android.material.button.MaterialButtonToggleGroup>
|
||||
</HorizontalScrollView>
|
||||
|
||||
<!-- Backend URL (WebDAV/SMB only) -->
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:id="@+id/resticBackendUrlLayout"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:hint="WebDAV 地址 (https://host:port/path)"
|
||||
android:visibility="gone"
|
||||
style="@style/Widget.Material3.TextInputLayout.OutlinedBox">
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/resticBackendUrlEdit"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:singleLine="true"
|
||||
android:inputType="textUri" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<!-- SMB share name (SMB only) -->
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:id="@+id/resticBackendShareLayout"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:hint="SMB 共享名称 (如 backup、shared)"
|
||||
android:visibility="gone"
|
||||
style="@style/Widget.Material3.TextInputLayout.OutlinedBox">
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/resticBackendShareEdit"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:singleLine="true" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<!-- Backend user (WebDAV/SMB only) -->
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:id="@+id/resticBackendUserLayout"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:hint="用户名"
|
||||
android:visibility="gone"
|
||||
style="@style/Widget.Material3.TextInputLayout.OutlinedBox">
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/resticBackendUserEdit"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:singleLine="true" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<!-- Backend password (WebDAV/SMB only) -->
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:id="@+id/resticBackendPassLayout"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:hint="密码"
|
||||
android:visibility="gone"
|
||||
style="@style/Widget.Material3.TextInputLayout.OutlinedBox">
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/resticBackendPassEdit"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="textPassword"
|
||||
android:singleLine="true" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<!-- SMB domain (SMB only, optional) -->
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:id="@+id/resticBackendDomainLayout"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:hint="SMB 域 (可选,如 WORKGROUP)"
|
||||
android:visibility="gone"
|
||||
style="@style/Widget.Material3.TextInputLayout.OutlinedBox">
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/resticBackendDomainEdit"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:singleLine="true" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<!-- Repo path (always shown) -->
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:hint="仓库路径 (本机: /sdcard/restic-repo / 云端: 目录名如 android-backups)"
|
||||
style="@style/Widget.Material3.TextInputLayout.OutlinedBox">
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/resticRepoEdit"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:singleLine="true" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<!-- Repo encryption password (always shown) -->
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:hint="仓库加密密码 (请妥善保管,遗失即无法恢复)"
|
||||
style="@style/Widget.Material3.TextInputLayout.OutlinedBox">
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/resticPasswordEdit"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="textPassword"
|
||||
android:singleLine="true" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<!-- Computed repo URL -->
|
||||
<TextView
|
||||
android:id="@+id/resticComputedUrlText"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
|
||||
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||
|
||||
<!-- Action buttons -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/initResticButton"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="初始化"
|
||||
style="@style/Widget.Material3.Button.TextButton" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/resticStatsButton"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="统计"
|
||||
android:visibility="gone"
|
||||
style="@style/Widget.Material3.Button.TextButton" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/resticPruneButton"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="清理"
|
||||
android:visibility="gone"
|
||||
style="@style/Widget.Material3.Button.TextButton" />
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/resticStatusText"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
|
||||
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||
|
||||
</LinearLayout>
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/saveConfigButton"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="8dp"
|
||||
android:text="保存配置"
|
||||
style="@style/Widget.Material3.Button" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/configStatusText"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="24dp"
|
||||
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
|
||||
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||
|
||||
</LinearLayout>
|
||||
</ScrollView>
|
||||
@@ -1,112 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical"
|
||||
android:padding="@dimen/fragment_horizontal_padding"
|
||||
android:background="?attr/colorSurface">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<TextView
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="应用恢复"
|
||||
android:textAppearance="@style/TextAppearance.Material3.HeadlineSmall"
|
||||
android:textColor="?attr/colorOnSurface" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/selectDirButton"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="本地"
|
||||
style="@style/Widget.Material3.Button.TonalButton" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/selectResticButton"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="8dp"
|
||||
android:text="Restic"
|
||||
android:visibility="gone"
|
||||
style="@style/Widget.Material3.Button.TonalButton" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="用户: "
|
||||
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
|
||||
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||
|
||||
<Spinner
|
||||
android:id="@+id/userSelector"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1" />
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/backupDirText"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
android:ellipsize="middle"
|
||||
android:singleLine="true"
|
||||
android:text="未选择备份目录"
|
||||
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
|
||||
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||
|
||||
<com.google.android.material.progressindicator.LinearProgressIndicator
|
||||
android:id="@+id/progressBar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:indeterminate="true"
|
||||
android:visibility="gone"
|
||||
app:indicatorColor="?attr/colorPrimary"
|
||||
app:trackColor="?attr/colorSurfaceVariant" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/statusText"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:maxLines="3"
|
||||
android:ellipsize="end"
|
||||
android:text="请先选择备份文件夹"
|
||||
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
|
||||
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/appList"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginTop="12dp"
|
||||
android:clipToPadding="false"
|
||||
android:paddingBottom="8dp" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/restoreButton"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
android:enabled="false"
|
||||
android:text="开始恢复选中应用"
|
||||
style="@style/Widget.Material3.Button" />
|
||||
|
||||
</LinearLayout>
|
||||
@@ -1,15 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<menu xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item
|
||||
android:id="@+id/nav_backup"
|
||||
android:icon="@drawable/ic_backup"
|
||||
android:title="备份" />
|
||||
<item
|
||||
android:id="@+id/nav_restore"
|
||||
android:icon="@drawable/ic_restore"
|
||||
android:title="恢复" />
|
||||
<item
|
||||
android:id="@+id/nav_config"
|
||||
android:icon="@drawable/ic_config"
|
||||
android:title="配置" />
|
||||
</menu>
|
||||
@@ -1,57 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="AppTheme" parent="Theme.Material3.DayNight.NoActionBar">
|
||||
<!-- Primary -->
|
||||
<item name="colorPrimary">@color/primary</item>
|
||||
<item name="colorOnPrimary">@color/onPrimary</item>
|
||||
<item name="colorPrimaryContainer">@color/primaryContainer</item>
|
||||
<item name="colorOnPrimaryContainer">@color/onPrimaryContainer</item>
|
||||
<item name="colorPrimaryInverse">@color/inverseSurface</item>
|
||||
|
||||
<!-- Secondary -->
|
||||
<item name="colorSecondary">@color/secondary</item>
|
||||
<item name="colorOnSecondary">@color/onSecondary</item>
|
||||
<item name="colorSecondaryContainer">@color/secondaryContainer</item>
|
||||
<item name="colorOnSecondaryContainer">@color/onSecondaryContainer</item>
|
||||
|
||||
<!-- Tertiary -->
|
||||
<item name="colorTertiary">@color/tertiary</item>
|
||||
<item name="colorOnTertiary">@color/onTertiary</item>
|
||||
<item name="colorTertiaryContainer">@color/tertiaryContainer</item>
|
||||
<item name="colorOnTertiaryContainer">@color/onTertiaryContainer</item>
|
||||
|
||||
<!-- Error -->
|
||||
<item name="colorError">@color/error</item>
|
||||
<item name="colorOnError">@color/onError</item>
|
||||
<item name="colorErrorContainer">@color/errorContainer</item>
|
||||
<item name="colorOnErrorContainer">@color/onErrorContainer</item>
|
||||
|
||||
<!-- Surface / Background -->
|
||||
<item name="android:colorBackground">@color/background</item>
|
||||
<item name="colorOnBackground">@color/onBackground</item>
|
||||
<item name="colorSurface">@color/surface</item>
|
||||
<item name="colorOnSurface">@color/onSurface</item>
|
||||
<item name="colorSurfaceVariant">@color/surfaceVariant</item>
|
||||
<item name="colorOnSurfaceVariant">@color/onSurfaceVariant</item>
|
||||
<item name="colorSurfaceInverse">@color/inverseSurface</item>
|
||||
<item name="colorOnSurfaceInverse">@color/inverseOnSurface</item>
|
||||
|
||||
<!-- Outline -->
|
||||
<item name="colorOutline">@color/outline</item>
|
||||
<item name="colorOutlineVariant">@color/outlineVariant</item>
|
||||
|
||||
<!-- Surface container hierarchy -->
|
||||
<item name="colorSurfaceContainerLowest">@color/surfaceContainerLowest</item>
|
||||
<item name="colorSurfaceContainerLow">@color/surfaceContainerLow</item>
|
||||
<item name="colorSurfaceContainer">@color/surfaceContainer</item>
|
||||
<item name="colorSurfaceContainerHigh">@color/surfaceContainerHigh</item>
|
||||
<item name="colorSurfaceContainerHighest">@color/surfaceContainerHighest</item>
|
||||
|
||||
<!-- Display cutout: allow content under punch-hole/notch, inset listener handles padding -->
|
||||
<item name="android:windowLayoutInDisplayCutoutMode">always</item>
|
||||
<!-- Status bar — dark theme -->
|
||||
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||||
<item name="android:windowLightStatusBar">false</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -1,51 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- MD3 Primary (dark) -->
|
||||
<color name="primary">#9ECAFF</color>
|
||||
<color name="onPrimary">#003258</color>
|
||||
<color name="primaryContainer">#00497D</color>
|
||||
<color name="onPrimaryContainer">#D1E4FF</color>
|
||||
|
||||
<!-- MD3 Secondary (dark) -->
|
||||
<color name="secondary">#FFB870</color>
|
||||
<color name="onSecondary">#4A2800</color>
|
||||
<color name="secondaryContainer">#6A3C00</color>
|
||||
<color name="onSecondaryContainer">#FFDCB5</color>
|
||||
|
||||
<!-- MD3 Tertiary (dark) -->
|
||||
<color name="tertiary">#8CD4C4</color>
|
||||
<color name="onTertiary">#00382F</color>
|
||||
<color name="tertiaryContainer">#005045</color>
|
||||
<color name="onTertiaryContainer">#A7F0DE</color>
|
||||
|
||||
<!-- MD3 Error (dark) -->
|
||||
<color name="error">#FFB4AB</color>
|
||||
<color name="onError">#690005</color>
|
||||
<color name="errorContainer">#93000A</color>
|
||||
<color name="onErrorContainer">#FFDAD6</color>
|
||||
|
||||
<!-- MD3 Surface / Background (dark) -->
|
||||
<color name="background">#1A1C1E</color>
|
||||
<color name="onBackground">#E2E2E6</color>
|
||||
<color name="surface">#1A1C1E</color>
|
||||
<color name="onSurface">#E2E2E6</color>
|
||||
<color name="surfaceVariant">#43474E</color>
|
||||
<color name="onSurfaceVariant">#C3C6CF</color>
|
||||
<color name="inverseSurface">#E2E2E6</color>
|
||||
<color name="inverseOnSurface">#2F3033</color>
|
||||
|
||||
<!-- MD3 Outline (dark) -->
|
||||
<color name="outline">#8D9199</color>
|
||||
<color name="outlineVariant">#43474E</color>
|
||||
|
||||
<!-- Surface container hierarchy (dark) -->
|
||||
<color name="surfaceContainerLowest">#0E1114</color>
|
||||
<color name="surfaceContainerLow">#1A1C1E</color>
|
||||
<color name="surfaceContainer">#1E2023</color>
|
||||
<color name="surfaceContainerHigh">#292A2E</color>
|
||||
<color name="surfaceContainerHighest">#333539</color>
|
||||
|
||||
<!-- Legacy console colors -->
|
||||
<color name="consoleBg">#102027</color>
|
||||
<color name="consoleText">#ECEFF1</color>
|
||||
</resources>
|
||||
@@ -1,54 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="AppTheme" parent="Theme.Material3.DayNight.NoActionBar">
|
||||
<!-- Primary -->
|
||||
<item name="colorPrimary">@color/primary</item>
|
||||
<item name="colorOnPrimary">@color/onPrimary</item>
|
||||
<item name="colorPrimaryContainer">@color/primaryContainer</item>
|
||||
<item name="colorOnPrimaryContainer">@color/onPrimaryContainer</item>
|
||||
|
||||
<!-- Secondary -->
|
||||
<item name="colorSecondary">@color/secondary</item>
|
||||
<item name="colorOnSecondary">@color/onSecondary</item>
|
||||
<item name="colorSecondaryContainer">@color/secondaryContainer</item>
|
||||
<item name="colorOnSecondaryContainer">@color/onSecondaryContainer</item>
|
||||
|
||||
<!-- Tertiary -->
|
||||
<item name="colorTertiary">@color/tertiary</item>
|
||||
<item name="colorOnTertiary">@color/onTertiary</item>
|
||||
<item name="colorTertiaryContainer">@color/tertiaryContainer</item>
|
||||
<item name="colorOnTertiaryContainer">@color/onTertiaryContainer</item>
|
||||
|
||||
<!-- Error -->
|
||||
<item name="colorError">@color/error</item>
|
||||
<item name="colorOnError">@color/onError</item>
|
||||
<item name="colorErrorContainer">@color/errorContainer</item>
|
||||
<item name="colorOnErrorContainer">@color/onErrorContainer</item>
|
||||
|
||||
<!-- Surface / Background -->
|
||||
<item name="android:colorBackground">@color/background</item>
|
||||
<item name="colorOnBackground">@color/onBackground</item>
|
||||
<item name="colorSurface">@color/surface</item>
|
||||
<item name="colorOnSurface">@color/onSurface</item>
|
||||
<item name="colorSurfaceVariant">@color/surfaceVariant</item>
|
||||
<item name="colorOnSurfaceVariant">@color/onSurfaceVariant</item>
|
||||
<item name="colorSurfaceInverse">@color/inverseSurface</item>
|
||||
<item name="colorOnSurfaceInverse">@color/inverseOnSurface</item>
|
||||
|
||||
<!-- Outline -->
|
||||
<item name="colorOutline">@color/outline</item>
|
||||
<item name="colorOutlineVariant">@color/outlineVariant</item>
|
||||
|
||||
<!-- Surface container hierarchy -->
|
||||
<item name="colorSurfaceContainerLowest">@color/surfaceContainerLowest</item>
|
||||
<item name="colorSurfaceContainerLow">@color/surfaceContainerLow</item>
|
||||
<item name="colorSurfaceContainer">@color/surfaceContainer</item>
|
||||
<item name="colorSurfaceContainerHigh">@color/surfaceContainerHigh</item>
|
||||
<item name="colorSurfaceContainerHighest">@color/surfaceContainerHighest</item>
|
||||
|
||||
<!-- Status bar — dark theme -->
|
||||
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||||
<item name="android:windowLightStatusBar">false</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -1,17 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Card dimensions (tablet: wider layout, larger touch targets) -->
|
||||
<dimen name="card_padding_horizontal">24dp</dimen>
|
||||
<dimen name="card_padding_vertical">16dp</dimen>
|
||||
<dimen name="card_radius">16dp</dimen>
|
||||
<dimen name="card_margin_bottom">12dp</dimen>
|
||||
|
||||
<!-- List item text size -->
|
||||
<dimen name="list_item_text_size">18sp</dimen>
|
||||
|
||||
<!-- Fragment layout padding -->
|
||||
<dimen name="fragment_horizontal_padding">24dp</dimen>
|
||||
|
||||
<!-- Bottom navigation: inset padding from system bars (set dynamically) -->
|
||||
<dimen name="bottom_nav_padding_bottom">0dp</dimen>
|
||||
</resources>
|
||||
@@ -1,57 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="AppTheme" parent="Theme.Material3.DayNight.NoActionBar">
|
||||
<!-- Primary -->
|
||||
<item name="colorPrimary">@color/primary</item>
|
||||
<item name="colorOnPrimary">@color/onPrimary</item>
|
||||
<item name="colorPrimaryContainer">@color/primaryContainer</item>
|
||||
<item name="colorOnPrimaryContainer">@color/onPrimaryContainer</item>
|
||||
<item name="colorPrimaryInverse">@color/inverseSurface</item>
|
||||
|
||||
<!-- Secondary -->
|
||||
<item name="colorSecondary">@color/secondary</item>
|
||||
<item name="colorOnSecondary">@color/onSecondary</item>
|
||||
<item name="colorSecondaryContainer">@color/secondaryContainer</item>
|
||||
<item name="colorOnSecondaryContainer">@color/onSecondaryContainer</item>
|
||||
|
||||
<!-- Tertiary -->
|
||||
<item name="colorTertiary">@color/tertiary</item>
|
||||
<item name="colorOnTertiary">@color/onTertiary</item>
|
||||
<item name="colorTertiaryContainer">@color/tertiaryContainer</item>
|
||||
<item name="colorOnTertiaryContainer">@color/onTertiaryContainer</item>
|
||||
|
||||
<!-- Error -->
|
||||
<item name="colorError">@color/error</item>
|
||||
<item name="colorOnError">@color/onError</item>
|
||||
<item name="colorErrorContainer">@color/errorContainer</item>
|
||||
<item name="colorOnErrorContainer">@color/onErrorContainer</item>
|
||||
|
||||
<!-- Surface / Background -->
|
||||
<item name="android:colorBackground">@color/background</item>
|
||||
<item name="colorOnBackground">@color/onBackground</item>
|
||||
<item name="colorSurface">@color/surface</item>
|
||||
<item name="colorOnSurface">@color/onSurface</item>
|
||||
<item name="colorSurfaceVariant">@color/surfaceVariant</item>
|
||||
<item name="colorOnSurfaceVariant">@color/onSurfaceVariant</item>
|
||||
<item name="colorSurfaceInverse">@color/inverseSurface</item>
|
||||
<item name="colorOnSurfaceInverse">@color/inverseOnSurface</item>
|
||||
|
||||
<!-- Outline -->
|
||||
<item name="colorOutline">@color/outline</item>
|
||||
<item name="colorOutlineVariant">@color/outlineVariant</item>
|
||||
|
||||
<!-- Surface container hierarchy -->
|
||||
<item name="colorSurfaceContainerLowest">@color/surfaceContainerLowest</item>
|
||||
<item name="colorSurfaceContainerLow">@color/surfaceContainerLow</item>
|
||||
<item name="colorSurfaceContainer">@color/surfaceContainer</item>
|
||||
<item name="colorSurfaceContainerHigh">@color/surfaceContainerHigh</item>
|
||||
<item name="colorSurfaceContainerHighest">@color/surfaceContainerHighest</item>
|
||||
|
||||
<!-- Display cutout: allow content under punch-hole/notch, inset listener handles padding -->
|
||||
<item name="android:windowLayoutInDisplayCutoutMode">always</item>
|
||||
<!-- Status bar -->
|
||||
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||||
<item name="android:windowLightStatusBar">true</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -1,51 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- MD3 Primary — deep blue -->
|
||||
<color name="primary">#1565C0</color>
|
||||
<color name="onPrimary">#FFFFFF</color>
|
||||
<color name="primaryContainer">#D1E4FF</color>
|
||||
<color name="onPrimaryContainer">#001D36</color>
|
||||
|
||||
<!-- MD3 Secondary — amber accent -->
|
||||
<color name="secondary">#FF8F00</color>
|
||||
<color name="onSecondary">#FFFFFF</color>
|
||||
<color name="secondaryContainer">#FFDCB5</color>
|
||||
<color name="onSecondaryContainer">#2D1800</color>
|
||||
|
||||
<!-- MD3 Tertiary — teal -->
|
||||
<color name="tertiary">#00796B</color>
|
||||
<color name="onTertiary">#FFFFFF</color>
|
||||
<color name="tertiaryContainer">#A7F0DE</color>
|
||||
<color name="onTertiaryContainer">#001F19</color>
|
||||
|
||||
<!-- MD3 Error -->
|
||||
<color name="error">#BA1A1A</color>
|
||||
<color name="onError">#FFFFFF</color>
|
||||
<color name="errorContainer">#FFDAD6</color>
|
||||
<color name="onErrorContainer">#410002</color>
|
||||
|
||||
<!-- MD3 Surface / Background (light) -->
|
||||
<color name="background">#FDFCFF</color>
|
||||
<color name="onBackground">#1A1C1E</color>
|
||||
<color name="surface">#FDFCFF</color>
|
||||
<color name="onSurface">#1A1C1E</color>
|
||||
<color name="surfaceVariant">#DFE2EB</color>
|
||||
<color name="onSurfaceVariant">#43474E</color>
|
||||
<color name="inverseSurface">#2F3033</color>
|
||||
<color name="inverseOnSurface">#F1F0F4</color>
|
||||
|
||||
<!-- MD3 Outline -->
|
||||
<color name="outline">#73777F</color>
|
||||
<color name="outlineVariant">#C3C6CF</color>
|
||||
|
||||
<!-- Surface container hierarchy (light) -->
|
||||
<color name="surfaceContainerLowest">#FFFFFF</color>
|
||||
<color name="surfaceContainerLow">#F7F9FC</color>
|
||||
<color name="surfaceContainer">#F1F3F7</color>
|
||||
<color name="surfaceContainerHigh">#EBEDF1</color>
|
||||
<color name="surfaceContainerHighest">#E6E8EB</color>
|
||||
|
||||
<!-- Legacy console colors -->
|
||||
<color name="consoleBg">#263238</color>
|
||||
<color name="consoleText">#ECEFF1</color>
|
||||
<color name="primary">#FF1A6B52</color>
|
||||
<color name="white">#FFFFFFFF</color>
|
||||
</resources>
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Card dimensions (phone baseline) -->
|
||||
<dimen name="card_padding_horizontal">16dp</dimen>
|
||||
<dimen name="card_padding_vertical">12dp</dimen>
|
||||
<dimen name="card_radius">12dp</dimen>
|
||||
<dimen name="card_margin_bottom">8dp</dimen>
|
||||
|
||||
<!-- List item text size -->
|
||||
<dimen name="list_item_text_size">15sp</dimen>
|
||||
|
||||
<!-- Fragment layout padding -->
|
||||
<dimen name="fragment_horizontal_padding">16dp</dimen>
|
||||
|
||||
<!-- Bottom navigation: inset padding from system bars (set dynamically) -->
|
||||
<dimen name="bottom_nav_padding_bottom">0dp</dimen>
|
||||
</resources>
|
||||
@@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<item name="checkbox" type="id" />
|
||||
<item name="appName" type="id" />
|
||||
<item name="excludeToggle" type="id" />
|
||||
</resources>
|
||||
@@ -13,4 +13,5 @@
|
||||
<string name="status_done">完成 (退出码: %d)</string>
|
||||
<string name="status_error">执行失败: %s</string>
|
||||
<string name="status_cancelled">已取消</string>
|
||||
<string name="exclude_data_toggle">切换数据排除</string>
|
||||
</resources>
|
||||
|
||||
@@ -1,55 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="AppTheme" parent="Theme.Material3.DayNight.NoActionBar">
|
||||
<!-- Primary -->
|
||||
<item name="colorPrimary">@color/primary</item>
|
||||
<item name="colorOnPrimary">@color/onPrimary</item>
|
||||
<item name="colorPrimaryContainer">@color/primaryContainer</item>
|
||||
<item name="colorOnPrimaryContainer">@color/onPrimaryContainer</item>
|
||||
<item name="colorPrimaryInverse">@color/inverseSurface</item>
|
||||
|
||||
<!-- Secondary -->
|
||||
<item name="colorSecondary">@color/secondary</item>
|
||||
<item name="colorOnSecondary">@color/onSecondary</item>
|
||||
<item name="colorSecondaryContainer">@color/secondaryContainer</item>
|
||||
<item name="colorOnSecondaryContainer">@color/onSecondaryContainer</item>
|
||||
|
||||
<!-- Tertiary -->
|
||||
<item name="colorTertiary">@color/tertiary</item>
|
||||
<item name="colorOnTertiary">@color/onTertiary</item>
|
||||
<item name="colorTertiaryContainer">@color/tertiaryContainer</item>
|
||||
<item name="colorOnTertiaryContainer">@color/onTertiaryContainer</item>
|
||||
|
||||
<!-- Error -->
|
||||
<item name="colorError">@color/error</item>
|
||||
<item name="colorOnError">@color/onError</item>
|
||||
<item name="colorErrorContainer">@color/errorContainer</item>
|
||||
<item name="colorOnErrorContainer">@color/onErrorContainer</item>
|
||||
|
||||
<!-- Surface / Background -->
|
||||
<item name="android:colorBackground">@color/background</item>
|
||||
<item name="colorOnBackground">@color/onBackground</item>
|
||||
<item name="colorSurface">@color/surface</item>
|
||||
<item name="colorOnSurface">@color/onSurface</item>
|
||||
<item name="colorSurfaceVariant">@color/surfaceVariant</item>
|
||||
<item name="colorOnSurfaceVariant">@color/onSurfaceVariant</item>
|
||||
<item name="colorSurfaceInverse">@color/inverseSurface</item>
|
||||
<item name="colorOnSurfaceInverse">@color/inverseOnSurface</item>
|
||||
|
||||
<!-- Outline -->
|
||||
<item name="colorOutline">@color/outline</item>
|
||||
<item name="colorOutlineVariant">@color/outlineVariant</item>
|
||||
|
||||
<!-- Surface container hierarchy -->
|
||||
<item name="colorSurfaceContainerLowest">@color/surfaceContainerLowest</item>
|
||||
<item name="colorSurfaceContainerLow">@color/surfaceContainerLow</item>
|
||||
<item name="colorSurfaceContainer">@color/surfaceContainer</item>
|
||||
<item name="colorSurfaceContainerHigh">@color/surfaceContainerHigh</item>
|
||||
<item name="colorSurfaceContainerHighest">@color/surfaceContainerHighest</item>
|
||||
|
||||
<!-- Status bar -->
|
||||
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||||
<item name="android:windowLightStatusBar">true</item>
|
||||
</style>
|
||||
<!-- Minimal theme for AndroidManifest (UI theme managed by Compose) -->
|
||||
<style name="AppTheme" parent="Theme.Material3.DayNight.NoActionBar" />
|
||||
</resources>
|
||||
|
||||
9
app/src/main/res/xml/data_extraction_rules.xml
Normal file
9
app/src/main/res/xml/data_extraction_rules.xml
Normal file
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<data-extraction-rules>
|
||||
<cloud-backup>
|
||||
<include domain="file" path="."/>
|
||||
</cloud-backup>
|
||||
<device-transfer>
|
||||
<include domain="file" path="."/>
|
||||
</device-transfer>
|
||||
</data-extraction-rules>
|
||||
23
app/src/test/java/android/util/Log.java
Normal file
23
app/src/test/java/android/util/Log.java
Normal file
@@ -0,0 +1,23 @@
|
||||
package android.util;
|
||||
|
||||
/**
|
||||
* Test-only stub for android.util.Log.
|
||||
* Prevents RuntimeException("Stub!") from android.jar during JVM unit tests.
|
||||
*/
|
||||
public final class Log {
|
||||
public static int v(String tag, String msg) { return 0; }
|
||||
public static int v(String tag, String msg, Throwable tr) { return 0; }
|
||||
public static int d(String tag, String msg) { return 0; }
|
||||
public static int d(String tag, String msg, Throwable tr) { return 0; }
|
||||
public static int i(String tag, String msg) { return 0; }
|
||||
public static int i(String tag, String msg, Throwable tr) { return 0; }
|
||||
public static int w(String tag, String msg) { return 0; }
|
||||
public static int w(String tag, String msg, Throwable tr) { return 0; }
|
||||
public static int e(String tag, String msg) { return 0; }
|
||||
public static int e(String tag, String msg, Throwable tr) { return 0; }
|
||||
public static int wtf(String tag, String msg) { return 0; }
|
||||
public static int wtf(String tag, String msg, Throwable tr) { return 0; }
|
||||
public static String getStackTraceString(Throwable tr) { return ""; }
|
||||
public static boolean isLoggable(String tag, int level) { return false; }
|
||||
public static int println(int priority, String tag, String msg) { return 0; }
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
package com.example.androidbackupgui.backup
|
||||
|
||||
import io.kotest.assertions.throwables.shouldThrow
|
||||
import io.kotest.core.spec.style.FunSpec
|
||||
import io.kotest.matchers.nulls.shouldBeNull
|
||||
import io.kotest.matchers.shouldBe
|
||||
import io.kotest.matchers.types.shouldBeInstanceOf
|
||||
import io.kotest.property.Arb
|
||||
import io.kotest.property.arbitrary.int
|
||||
import io.kotest.property.arbitrary.string
|
||||
import io.kotest.property.checkAll
|
||||
import java.io.IOException
|
||||
|
||||
class AppErrorTest : FunSpec({
|
||||
|
||||
context("AppError.Network") {
|
||||
test("has correct defaults") {
|
||||
val error = AppError.Network("connection timeout")
|
||||
error.message shouldBe "connection timeout"
|
||||
error.cause.shouldBeNull()
|
||||
error.retryable shouldBe true
|
||||
}
|
||||
|
||||
test("preserves cause and retryable overrides") {
|
||||
val cause = RuntimeException("DNS failed")
|
||||
val error = AppError.Network("DNS resolution failed", cause = cause, retryable = false)
|
||||
error.cause shouldBe cause
|
||||
error.retryable shouldBe false
|
||||
}
|
||||
|
||||
test("property: message is preserved") {
|
||||
checkAll(Arb.string(1..200)) { msg ->
|
||||
val error = AppError.Network(msg)
|
||||
error.message shouldBe msg
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
context("AppError.Remote") {
|
||||
test("preserves phase, cause, isNotFound, retryable") {
|
||||
val cause = RuntimeException("underlying error")
|
||||
val error = AppError.Remote("upload failed", "upload", cause = cause)
|
||||
error.message shouldBe "upload failed"
|
||||
error.phase shouldBe "upload"
|
||||
error.cause shouldBe cause
|
||||
error.isNotFound shouldBe false
|
||||
error.retryable shouldBe false
|
||||
}
|
||||
|
||||
test("with isNotFound=true") {
|
||||
val error = AppError.Remote("not found", "list", isNotFound = true)
|
||||
error.isNotFound shouldBe true
|
||||
}
|
||||
}
|
||||
|
||||
context("AppError.Shell") {
|
||||
test("preserves command, exitCode, and stderr") {
|
||||
val error = AppError.Shell("cp failed", "cp /a /b", 1, "permission denied")
|
||||
error.message shouldBe "cp failed"
|
||||
error.command shouldBe "cp /a /b"
|
||||
error.exitCode shouldBe 1
|
||||
error.stderr shouldBe "permission denied"
|
||||
}
|
||||
}
|
||||
|
||||
context("AppError.LocalIO") {
|
||||
test("preserves path and optional cause") {
|
||||
val error = AppError.LocalIO("file not found", "/data/test.txt")
|
||||
error.message shouldBe "file not found"
|
||||
error.path shouldBe "/data/test.txt"
|
||||
error.cause.shouldBeNull()
|
||||
}
|
||||
|
||||
test("preserves cause when provided") {
|
||||
val cause = IOException("disk full")
|
||||
val error = AppError.LocalIO("write failed", "/data/test.txt", cause = cause)
|
||||
error.cause shouldBe cause
|
||||
}
|
||||
}
|
||||
|
||||
context("AppError.Restic") {
|
||||
test("preserves exit code and stderr") {
|
||||
val error = AppError.Restic("restic failed", 1, "permission denied")
|
||||
error.message shouldBe "restic failed"
|
||||
error.exitCode shouldBe 1
|
||||
error.stderr shouldBe "permission denied"
|
||||
}
|
||||
|
||||
test("property: any exit code is preserved") {
|
||||
checkAll(Arb.int()) { code ->
|
||||
val error = AppError.Restic("err", code, "stderr output")
|
||||
error.exitCode shouldBe code
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
context("AppError.Parse") {
|
||||
test("preserves message and detail") {
|
||||
val error = AppError.Parse("bad json", "expected '{'")
|
||||
error.message shouldBe "bad json"
|
||||
error.detail shouldBe "expected '{'"
|
||||
}
|
||||
|
||||
test("detail defaults to empty string") {
|
||||
val error = AppError.Parse("bad json")
|
||||
error.detail shouldBe ""
|
||||
}
|
||||
}
|
||||
|
||||
context("AppError.Cancelled") {
|
||||
test("is a data object with fixed message") {
|
||||
val error = AppError.Cancelled
|
||||
error.message shouldBe "操作被取消"
|
||||
// Verify singleton behavior
|
||||
val error2 = AppError.Cancelled
|
||||
error shouldBe error2
|
||||
}
|
||||
}
|
||||
|
||||
context("AppResult.Success") {
|
||||
test("holds a value") {
|
||||
val result: AppResult<String> = AppResult.Success("hello")
|
||||
result.isSuccess shouldBe true
|
||||
result.isFailure shouldBe false
|
||||
result.getOrNull() shouldBe "hello"
|
||||
result.getOrDefault("fallback") shouldBe "hello"
|
||||
result.getOrThrow() shouldBe "hello"
|
||||
result.exceptionOrNull().shouldBeNull()
|
||||
result.errorOrNull().shouldBeNull()
|
||||
}
|
||||
|
||||
test("fold calls onSuccess") {
|
||||
val result: AppResult<Int> = AppResult.Success(42)
|
||||
val folded =
|
||||
result.fold(
|
||||
onSuccess = { it * 2 },
|
||||
onFailure = { 0 },
|
||||
)
|
||||
folded shouldBe 84
|
||||
}
|
||||
|
||||
test("map transforms value") {
|
||||
val result: AppResult<Int> = AppResult.Success(42)
|
||||
val mapped = result.map { it.toString() }
|
||||
mapped shouldBe AppResult.Success("42")
|
||||
}
|
||||
|
||||
test("mapError passes through success") {
|
||||
val result: AppResult<Int> = AppResult.Success(42)
|
||||
val mapped = result.mapError { AppError.Parse("should not happen") }
|
||||
mapped shouldBe AppResult.Success(42)
|
||||
}
|
||||
}
|
||||
|
||||
context("AppResult.Failure via err()") {
|
||||
test("creates failure result") {
|
||||
val result: AppResult<String> = err(AppError.Parse("bad json"))
|
||||
result.isSuccess shouldBe false
|
||||
result.isFailure shouldBe true
|
||||
result.getOrNull().shouldBeNull()
|
||||
result.getOrDefault("fallback") shouldBe "fallback"
|
||||
result.errorOrNull() shouldBe AppError.Parse("bad json")
|
||||
result.errorOrNull()?.message shouldBe "bad json"
|
||||
}
|
||||
|
||||
test("exceptionOrNull returns RuntimeException with AppError message") {
|
||||
val result: AppResult<String> = err(AppError.Parse("bad json"))
|
||||
val ex = result.exceptionOrNull()
|
||||
ex.shouldBeInstanceOf<RuntimeException>()
|
||||
ex?.message shouldBe "bad json"
|
||||
}
|
||||
|
||||
test("getOrThrow throws RuntimeException") {
|
||||
val result: AppResult<String> = err(AppError.Parse("bad json"))
|
||||
val ex = shouldThrow<RuntimeException> { result.getOrThrow() }
|
||||
ex.message shouldBe "bad json"
|
||||
}
|
||||
|
||||
test("wraps any AppError subtype") {
|
||||
val errors =
|
||||
listOf(
|
||||
AppError.Network("net err"),
|
||||
AppError.Remote("remote err", "connect"),
|
||||
AppError.Shell("shell err", "ls", 1, ""),
|
||||
AppError.LocalIO("io err", "/tmp"),
|
||||
AppError.Restic("restic err", 1, ""),
|
||||
AppError.Parse("parse err"),
|
||||
AppError.Cancelled,
|
||||
)
|
||||
errors.forEach { error ->
|
||||
val result: AppResult<Unit> = err(error)
|
||||
result.isFailure shouldBe true
|
||||
result.errorOrNull()?.message shouldBe error.message
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
context("AppResult.Failure direct") {
|
||||
test("holds an error") {
|
||||
val error = AppError.Network("network error")
|
||||
val result: AppResult<String> = AppResult.Failure(error)
|
||||
result.isSuccess shouldBe false
|
||||
result.isFailure shouldBe true
|
||||
result.errorOrNull() shouldBe error
|
||||
}
|
||||
|
||||
test("fold calls onFailure") {
|
||||
val result: AppResult<Int> = AppResult.Failure(AppError.Parse("parse failed"))
|
||||
val folded =
|
||||
result.fold(
|
||||
onSuccess = { 0 },
|
||||
onFailure = { error -> error.message.length },
|
||||
)
|
||||
folded shouldBe "parse failed".length
|
||||
}
|
||||
|
||||
test("map passes through failure") {
|
||||
val error = AppError.Parse("no data")
|
||||
val result: AppResult<Int> = AppResult.Failure(error)
|
||||
val mapped = result.map { it + 1 }
|
||||
mapped shouldBe AppResult.Failure(error)
|
||||
}
|
||||
|
||||
test("mapError transforms error") {
|
||||
val result: AppResult<Int> = AppResult.Failure(AppError.Parse("old error"))
|
||||
val mapped = result.mapError { AppError.Remote("mapped: ${it.message}", "transform") }
|
||||
mapped.errorOrNull()?.message shouldBe "mapped: old error"
|
||||
}
|
||||
}
|
||||
|
||||
context("AppResult exhaustive when") {
|
||||
test("can pattern match with is AppResult.Success") {
|
||||
val result: AppResult<String> = AppResult.Success("data")
|
||||
val output =
|
||||
when (result) {
|
||||
is AppResult.Success -> "got: ${result.data}"
|
||||
is AppResult.Failure -> "err: ${result.error.message}"
|
||||
}
|
||||
output shouldBe "got: data"
|
||||
}
|
||||
|
||||
test("can pattern match with is AppResult.Failure") {
|
||||
val result: AppResult<String> = AppResult.Failure(AppError.Cancelled)
|
||||
val output =
|
||||
when (result) {
|
||||
is AppResult.Success -> "got: ${result.data}"
|
||||
is AppResult.Failure -> "err: ${result.error.message}"
|
||||
}
|
||||
output shouldBe "err: 操作被取消"
|
||||
}
|
||||
}
|
||||
|
||||
context("AppResult type inference") {
|
||||
test("AppResult.Success with Unit") {
|
||||
val result: AppResult<Unit> = AppResult.Success(Unit)
|
||||
result.isSuccess shouldBe true
|
||||
result.getOrDefault(Unit) shouldBe Unit
|
||||
}
|
||||
|
||||
test("AppResult.Failure with Nothing") {
|
||||
val result: AppResult<Int> = AppResult.Failure(AppError.Cancelled)
|
||||
result.isFailure shouldBe true
|
||||
}
|
||||
}
|
||||
|
||||
context("err function short-form") {
|
||||
test("err() returns AppResult.Failure") {
|
||||
val result: AppResult<String> = err(AppError.Remote("upload failed", "upload"))
|
||||
result.shouldBeInstanceOf<AppResult.Failure>()
|
||||
(result as AppResult.Failure).error shouldBe AppError.Remote("upload failed", "upload")
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,88 @@
|
||||
package com.example.androidbackupgui.backup
|
||||
|
||||
import io.kotest.assertions.throwables.shouldThrow
|
||||
import io.kotest.core.spec.style.FunSpec
|
||||
import io.kotest.matchers.nulls.shouldBeNull
|
||||
import io.kotest.matchers.nulls.shouldNotBeNull
|
||||
import io.kotest.matchers.shouldBe
|
||||
import io.kotest.matchers.types.shouldBeInstanceOf
|
||||
|
||||
class AppResultTest :
|
||||
FunSpec({
|
||||
|
||||
context("AppResult.Success") {
|
||||
test("holds value correctly") {
|
||||
val result: AppResult<String> = AppResult.Success("hello")
|
||||
result.isSuccess shouldBe true
|
||||
result.isFailure shouldBe false
|
||||
result.getOrNull() shouldBe "hello"
|
||||
result.getOrDefault("default") shouldBe "hello"
|
||||
}
|
||||
|
||||
test("fold maps success branch") {
|
||||
val result: AppResult<Int> = AppResult.Success(42)
|
||||
val output = result.fold({ it * 2 }, { -1 })
|
||||
output shouldBe 84
|
||||
}
|
||||
|
||||
test("map transforms value") {
|
||||
val result = AppResult.Success(42)
|
||||
val mapped = result.map { it.toString() }
|
||||
mapped.shouldBeInstanceOf<AppResult.Success<String>>()
|
||||
mapped.getOrNull() shouldBe "42"
|
||||
}
|
||||
|
||||
test("getOrThrow returns value") {
|
||||
val result = AppResult.Success(99)
|
||||
result.getOrThrow() shouldBe 99
|
||||
}
|
||||
}
|
||||
|
||||
context("AppResult.Failure") {
|
||||
val error = AppError.Network("connection lost")
|
||||
|
||||
test("holds error correctly") {
|
||||
val result: AppResult<Int> = AppResult.Failure(error)
|
||||
result.isSuccess shouldBe false
|
||||
result.isFailure shouldBe true
|
||||
result.getOrNull().shouldBeNull()
|
||||
result.getOrDefault(0) shouldBe 0
|
||||
result.errorOrNull() shouldBe error
|
||||
}
|
||||
|
||||
test("fold maps failure branch") {
|
||||
val result: AppResult<Int> = AppResult.Failure(error)
|
||||
val output = result.fold({ it }, { err -> -1 })
|
||||
output shouldBe -1
|
||||
}
|
||||
|
||||
test("map passes through failure") {
|
||||
val result: AppResult<Int> = AppResult.Failure(error)
|
||||
val mapped = result.map { it * 2 }
|
||||
mapped.shouldBeInstanceOf<AppResult.Failure>()
|
||||
mapped.errorOrNull() shouldBe error
|
||||
}
|
||||
|
||||
test("getOrThrow throws") {
|
||||
val result = AppResult.Failure(error)
|
||||
shouldThrow<RuntimeException> { result.getOrThrow() }
|
||||
}
|
||||
|
||||
test("mapError transforms the error") {
|
||||
val result: AppResult<Int> = AppResult.Failure(error)
|
||||
val mapped = result.mapError { AppError.Parse("wrapped: ${it.message}") }
|
||||
mapped.shouldBeInstanceOf<AppResult.Failure>()
|
||||
(mapped.errorOrNull() as? AppError.Parse)?.let {
|
||||
it.message shouldBe "wrapped: connection lost"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
context("err helper") {
|
||||
test("creates Failure") {
|
||||
val result = err<String>(AppError.Cancelled)
|
||||
result.shouldBeInstanceOf<AppResult.Failure>()
|
||||
result.errorOrNull() shouldBe AppError.Cancelled
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.example.androidbackupgui.backup
|
||||
|
||||
import io.kotest.core.spec.style.FunSpec
|
||||
import io.kotest.matchers.shouldBe
|
||||
import java.io.File
|
||||
|
||||
class BackupConfigTest :
|
||||
FunSpec({
|
||||
|
||||
// Helper: write config to temp file, read it back
|
||||
fun roundTrip(config: BackupConfig): BackupConfig {
|
||||
val tmp = File.createTempFile("cfg_test", ".conf")
|
||||
try {
|
||||
BackupConfig.toFile(config, tmp)
|
||||
return BackupConfig.fromFile(tmp)
|
||||
} finally {
|
||||
tmp.delete()
|
||||
}
|
||||
}
|
||||
|
||||
test("password is stored as placeholder (actual password in PasswordManager)") {
|
||||
val c = BackupConfig(resticPassword = "simple123")
|
||||
// Password is no longer in config file; toFile writes "stored-in-keystore"
|
||||
roundTrip(c).resticPassword shouldBe ""
|
||||
}
|
||||
|
||||
test("backend pass is stored as placeholder (actual pass in PasswordManager)") {
|
||||
val c = BackupConfig(resticBackendPass = "secret")
|
||||
roundTrip(c).resticBackendPass shouldBe ""
|
||||
}
|
||||
|
||||
test("output path with spaces survives round trip") {
|
||||
val c = BackupConfig(outputPath = "/sdcard/my backups/")
|
||||
roundTrip(c).outputPath shouldBe "/sdcard/my backups/"
|
||||
}
|
||||
|
||||
test("non-restic fields are unaffected") {
|
||||
val c = BackupConfig(backupMode = 1, backupWifi = 0, compressionMethod = "zstd")
|
||||
val out = roundTrip(c)
|
||||
out.backupMode shouldBe 1
|
||||
out.backupWifi shouldBe 0
|
||||
out.compressionMethod shouldBe "zstd"
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,69 @@
|
||||
package com.example.androidbackupgui.backup
|
||||
|
||||
import io.kotest.assertions.throwables.shouldThrow
|
||||
import io.kotest.core.spec.style.FunSpec
|
||||
import io.kotest.matchers.nulls.shouldBeNull
|
||||
import io.kotest.matchers.nulls.shouldNotBeNull
|
||||
import io.kotest.matchers.shouldBe
|
||||
|
||||
class PackageNameTest :
|
||||
FunSpec({
|
||||
|
||||
context("PackageName constructor validation") {
|
||||
test("accepts valid package names") {
|
||||
PackageName("com.example.app").value shouldBe "com.example.app"
|
||||
PackageName("com.google.android.gms").value shouldBe "com.google.android.gms"
|
||||
PackageName("a.b").value shouldBe "a.b"
|
||||
PackageName("com.example.app_v2.test").value shouldBe "com.example.app_v2.test"
|
||||
PackageName("org.koin.android").value shouldBe "org.koin.android"
|
||||
}
|
||||
|
||||
test("rejects blank package names") {
|
||||
shouldThrow<IllegalArgumentException> { PackageName("") }
|
||||
shouldThrow<IllegalArgumentException> { PackageName(" ") }
|
||||
}
|
||||
|
||||
test("rejects package names without dots") {
|
||||
shouldThrow<IllegalArgumentException> { PackageName("simple") }
|
||||
shouldThrow<IllegalArgumentException> { PackageName("no_dot_at_all") }
|
||||
}
|
||||
|
||||
test("rejects package names with invalid characters") {
|
||||
shouldThrow<IllegalArgumentException> { PackageName("com.example .app") }
|
||||
shouldThrow<IllegalArgumentException> { PackageName("com.example/app") }
|
||||
shouldThrow<IllegalArgumentException> { PackageName("com.example\napp") }
|
||||
}
|
||||
|
||||
test("rejects package names starting with dot") {
|
||||
shouldThrow<IllegalArgumentException> { PackageName(".com.example") }
|
||||
}
|
||||
|
||||
test("rejects package names ending with dot") {
|
||||
shouldThrow<IllegalArgumentException> { PackageName("com.example.") }
|
||||
}
|
||||
}
|
||||
|
||||
context("PackageName.safe") {
|
||||
test("returns PackageName for valid input") {
|
||||
PackageName.safe("com.example.app").shouldNotBeNull()
|
||||
PackageName.safe("a.b").shouldNotBeNull()
|
||||
}
|
||||
|
||||
test("returns null for invalid input instead of throwing") {
|
||||
PackageName.safe("").shouldBeNull()
|
||||
PackageName.safe("no_dots").shouldBeNull()
|
||||
PackageName.safe("with space").shouldBeNull()
|
||||
PackageName.safe("with/slash").shouldBeNull()
|
||||
}
|
||||
}
|
||||
|
||||
context("PackageName equality and toString") {
|
||||
test("value equality works") {
|
||||
PackageName("com.example.app") shouldBe PackageName("com.example.app")
|
||||
}
|
||||
|
||||
test("toString returns the package name") {
|
||||
PackageName("com.example.app").toString() shouldBe "com.example.app"
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.example.androidbackupgui.backup
|
||||
|
||||
import io.kotest.core.spec.style.FunSpec
|
||||
import io.kotest.matchers.shouldBe
|
||||
|
||||
class ResticBinaryTest : FunSpec({
|
||||
|
||||
context("ResticBinary") {
|
||||
test("isReady returns false before prepare is called") {
|
||||
ResticBinary.isReady() shouldBe false
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,110 @@
|
||||
package com.example.androidbackupgui.backup
|
||||
|
||||
import io.kotest.core.spec.style.FunSpec
|
||||
import io.kotest.matchers.collections.shouldHaveSize
|
||||
import io.kotest.matchers.shouldBe
|
||||
import io.kotest.property.Arb
|
||||
import io.kotest.property.arbitrary.int
|
||||
import io.kotest.property.arbitrary.list
|
||||
import io.kotest.property.arbitrary.string
|
||||
import io.kotest.property.checkAll
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
class ResticCommandRunnerTest : FunSpec({
|
||||
|
||||
val defaultRunner = ResticCommandRunner()
|
||||
|
||||
context("buildCommandArgs") {
|
||||
test("prepends default binary path") {
|
||||
val args = defaultRunner.buildCommandArgs(listOf("init", "--json"))
|
||||
args shouldBe listOf("restic", "init", "--json")
|
||||
}
|
||||
|
||||
test("uses custom binary path") {
|
||||
val runner = ResticCommandRunner()
|
||||
runner.binaryPath = "/data/data/com.termux/files/usr/bin/restic"
|
||||
val args = runner.buildCommandArgs(listOf("backup", "/sdcard"))
|
||||
args shouldBe
|
||||
listOf(
|
||||
"/data/data/com.termux/files/usr/bin/restic",
|
||||
"backup",
|
||||
"/sdcard",
|
||||
)
|
||||
}
|
||||
|
||||
test("returns empty args list when called with empty list") {
|
||||
val args = defaultRunner.buildCommandArgs(emptyList())
|
||||
args shouldBe listOf("restic")
|
||||
}
|
||||
|
||||
test("preserves argument order") {
|
||||
val runner = ResticCommandRunner()
|
||||
runner.binaryPath = "restic"
|
||||
val args = runner.buildCommandArgs(listOf("a", "b", "c"))
|
||||
args shouldBe listOf("restic", "a", "b", "c")
|
||||
}
|
||||
|
||||
test("property: any list of string args mainatains length") {
|
||||
checkAll(Arb.list(Arb.string(1..20), 0..10)) { inputArgs ->
|
||||
val args = defaultRunner.buildCommandArgs(inputArgs)
|
||||
args shouldHaveSize (inputArgs.size + 1)
|
||||
args[0] shouldBe "restic"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
context("runRestic(vararg)") {
|
||||
test("delegates to runRestic(List) and returns failure on nonexistent binary") {
|
||||
val runner = ResticCommandRunner()
|
||||
runner.binaryPath = "/nonexistent/restic"
|
||||
val result = runner.runRestic(mapOf("RESTIC_REPOSITORY" to "/tmp/repo"), "version")
|
||||
result.exitCode shouldBe -1
|
||||
result.stdout shouldBe ""
|
||||
}
|
||||
}
|
||||
|
||||
context("CommandResult serialization") {
|
||||
test("serializes and deserializes correctly") {
|
||||
val original =
|
||||
ResticCommandRunner.CommandResult(
|
||||
stdout = "some output",
|
||||
stderr = "",
|
||||
exitCode = 0,
|
||||
)
|
||||
val json = Json.encodeToString(original)
|
||||
val decoded = Json.decodeFromString<ResticCommandRunner.CommandResult>(json)
|
||||
decoded.stdout shouldBe "some output"
|
||||
decoded.stderr shouldBe ""
|
||||
decoded.exitCode shouldBe 0
|
||||
}
|
||||
|
||||
test("roundtrip property: preserves exit code") {
|
||||
checkAll(Arb.int()) { code ->
|
||||
val original =
|
||||
ResticCommandRunner.CommandResult(
|
||||
stdout = "out",
|
||||
stderr = code.toString(),
|
||||
exitCode = code,
|
||||
)
|
||||
val json = Json.encodeToString(original)
|
||||
val decoded = Json.decodeFromString<ResticCommandRunner.CommandResult>(json)
|
||||
decoded.exitCode shouldBe code
|
||||
decoded.stderr shouldBe code.toString()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
context("ResticCommandRunner instantiation") {
|
||||
test("default binary path is restic") {
|
||||
defaultRunner.binaryPath shouldBe "restic"
|
||||
}
|
||||
|
||||
test("can set custom binary path") {
|
||||
val runner = ResticCommandRunner()
|
||||
runner.binaryPath = "/custom/path/restic"
|
||||
runner.binaryPath shouldBe "/custom/path/restic"
|
||||
}
|
||||
}
|
||||
})
|
||||
88
docs/plans/roadmap.md
Normal file
88
docs/plans/roadmap.md
Normal file
@@ -0,0 +1,88 @@
|
||||
# Android Backup GUI — 后续路线图
|
||||
|
||||
## 已完成(当前版本)
|
||||
|
||||
| 领域 | 变更 | 阶段 |
|
||||
|------|------|------|
|
||||
| 🔒 安全 | 配置文件权限加固、签名密码加固 | P1 |
|
||||
| 🔒 安全 | SMB MD4/AESCMAC 算法注入修复 + 全局注册 | Hotfix |
|
||||
| 🐛 正确性 | `CancellationException` 透传 × 8 处 | P2 |
|
||||
| 🐛 正确性 | SMB/WebDAV 返回 `Failure` 而非 `Success` | P2 |
|
||||
| 🐛 正确性 | `BackupOperation.backupUserData` 全失败返回 `false` | P2 |
|
||||
| 🐛 正确性 | `RestoreOperation` 改用 `supervisorScope` | P2 |
|
||||
| 🐛 正确性 | `ResticCommandRunner` NPE 修复(2 处 readLine 模式) | Hotfix |
|
||||
| 🌐 网络 | SMB/WebDAV 下载/上传自动重试 3 次 + 指数退避 | Hotfix |
|
||||
| 🌐 网络 | WebDAV Range 断点续传(`.part` 文件 + HTTP Range) | Hotfix |
|
||||
| 🏗️ 构建 | ResticRestBridge 绑定 127.0.0.1 | P3 |
|
||||
| 🏗️ 构建 | `allowBackup=false` | P3 |
|
||||
| 🏗️ 构建 | CI 添加 test 步骤 | P3 |
|
||||
| 🧹 清理 | 删除 `MD4Provider.kt`、3 个死方法、`DataSizes`、`isFileNotFound`、`getAppLabel` | P4 |
|
||||
|
||||
---
|
||||
|
||||
## 下一阶段规划
|
||||
|
||||
### Phase A: 稳定性与恢复可靠性(3-5 天)
|
||||
|
||||
| # | 工作 | 文件 | 说明 | 风险 |
|
||||
|---|------|------|------|------|
|
||||
| A1 | 恢复操作 Fragment 修复 | `RestoreFragment.kt` | 添加 `onDestroyView` 防止视图分离后更新 UI | 低 |
|
||||
| A2 | BackupFragment 修复 | `BackupFragment.kt` | 添加 `onDestroyView`,清理协程 | 低 |
|
||||
| A3 | ResticRestBridge 认证 | `ResticRestBridge.kt` | 添加 token 认证,防止端口暴露 | 低 |
|
||||
| A4 | WebDAV 超时可配置 | `WebdavTransport.kt` | Sardine 连接/读取超时通过构造参数设置 | 低 |
|
||||
| A5 | tar 路径遍历检查 | `SELinuxUtil.kt` | `isArchiveSafe` 添加绝对路径检查 | 低 |
|
||||
| A6 | 恢复后缓存清理 | `ResticRestBridge.kt` | restore 完成后清理 `restic_blob_*` 缓存文件 | 低 |
|
||||
|
||||
### Phase B: 遗留死代码与重构(2-3 天)
|
||||
|
||||
| # | 工作 | 文件 | 说明 | 风险 |
|
||||
|---|------|------|------|------|
|
||||
| B1 | 冗余导入清理 | 7 个文件 | 同包 `import` 冗余 | 低 |
|
||||
| B2 | 未使用导入清理 | 5 个文件 | 删除无引用 import | 低 |
|
||||
| B3 | 未使用参数清理 | 3 个函数 | 删除 `@Suppress("UNUSED_PARAMETER")` | 低 |
|
||||
| B4 | TAG 修复 | `ResticRepoInit.kt`, `ResticCommandRunner.kt` | TAG 变量改为类名 | 低 |
|
||||
| B5 | UID 解析提取 | `BackupOperation.kt`, `StreamingBackup.kt` | 重复的 UID 解析逻辑提取公共函数 | 低 |
|
||||
| B6 | 5 个子模块重复分支提取 | `ResticBackup.kt` 等 | if-else local/remote 分支模式提取公共执行函数 | 中 |
|
||||
|
||||
### Phase C: 功能增强(5-7 天)
|
||||
|
||||
| # | 工作 | 文件 | 说明 | 风险 |
|
||||
|---|------|------|------|------|
|
||||
| C1 | 多目录恢复选择 | `RestoreFragment.kt` | 让用户选择从哪个 snapshot 恢复哪些目录 | 中 |
|
||||
| C2 | 前台服务 | `BackupService.kt` | 备份/恢复时启动前台服务防止杀进程 | 中 |
|
||||
| C3 | 多用户支持 | 全局 | `userId` 参数全面传递到 restore 流程 | 中 |
|
||||
| C4 | 恢复进度细化 | `RestoreOperation.kt` | 每 blob 粒度进度回调 | 低 |
|
||||
|
||||
### Phase D: 安全加固(3-4 天)
|
||||
|
||||
| # | 工作 | 文件 | 说明 | 风险 |
|
||||
|---|------|------|------|------|
|
||||
| D1 | 密码加密存储 | `BackupConfig.kt` | EncryptedSharedPreferences + 迁移现有配置 | 中 |
|
||||
| D2 | 仓库密码 UI 掩码 | `ConfigFragment.kt` | 确认/二次输入 | 低 |
|
||||
|
||||
### Phase E: 类型安全(大重构,2-3 天)
|
||||
|
||||
| # | 工作 | 文件 | 说明 | 风险 |
|
||||
|---|------|------|------|------|
|
||||
| E1 | `PackageName` 全面采用 | 8+ 文件 | 函数参数 `String` → `PackageName` | 高 |
|
||||
| E2 | `UserId` 全面采用 | 8+ 文件 | 函数参数 `String`/`Int` → `UserId` | 高 |
|
||||
|
||||
### Phase F: i18n 国际化(2-3 天)
|
||||
|
||||
| # | 工作 | 文件 | 说明 | 风险 |
|
||||
|---|------|------|------|------|
|
||||
| F1 | strings.xml 提取 | 所有 UI 文件 | 将硬编码中文提取到 strings.xml | 低 |
|
||||
| F2 | en/ 翻译 | strings.xml | 英文 strings.xml | 低 |
|
||||
|
||||
---
|
||||
|
||||
## 建议执行顺序
|
||||
|
||||
1. **Phase A**(稳定性优先 — 当前测试中暴露的问题优先修)
|
||||
2. **Phase B**(清理干净再动大重构)
|
||||
3. **Phase C**(用户可见功能)
|
||||
4. **Phase D**(安全加固)
|
||||
5. **Phase E**(类型安全 — 大重构,和 C 可能有冲突)
|
||||
6. **Phase F**(最后做,纯文案)
|
||||
|
||||
**Phase A + B 可并行执行**。
|
||||
247
docs/reviews/full-project-review-report.md
Normal file
247
docs/reviews/full-project-review-report.md
Normal file
@@ -0,0 +1,247 @@
|
||||
# Android Backup GUI — 全面审查报告
|
||||
|
||||
**审查日期**: 2026-06-06
|
||||
**审查范围**: 37 个 Kotlin 源文件 + 8 个布局/资源 XML + AndroidManifest
|
||||
**当前状态**: 53 测试全通过,lint 0 错误,编译成功
|
||||
**已知问题排除**: 7 项 memory 记录的待处理项已跳过
|
||||
|
||||
---
|
||||
|
||||
## 严重程度说明
|
||||
|
||||
| 等级 | 定义 |
|
||||
|------|------|
|
||||
| **CRITICAL** | 可直接导致数据泄露、root 提权、静默数据损坏。必须立即修复 |
|
||||
| **HIGH** | 特定条件下可导致敏感数据泄露、错误处理失效或功能严重受限 |
|
||||
| **MEDIUM** | 风险较低或需复杂攻击链,但应规划修复 |
|
||||
| **LOW** | 可改进点,非阻塞 |
|
||||
| **INFO** | 建议性质,无实际风险 |
|
||||
|
||||
---
|
||||
|
||||
## 审查方法
|
||||
|
||||
使用 11 个 ECC 审查技能分三层并行执行:
|
||||
|
||||
| 层级 | 技能 | 方向 |
|
||||
|------|------|------|
|
||||
| 第一层:安全与正确性 | ecc-security-reviewer, security-review, ecc-silent-failure-hunter | 漏洞检测、输入校验、静默失败 |
|
||||
| 第二层:架构与代码质量 | ecc-kotlin-reviewer, kotlin-coroutines-flows, ecc-type-design-analyzer, production-audit | 协程安全、类型设计、生产就绪 |
|
||||
| 第三层:可维护性与用户体验 | ecc-comment-analyzer, ecc-refactor-cleaner, ecc-code-simplifier, accessibility | 死代码、简化、无障碍 |
|
||||
|
||||
---
|
||||
|
||||
## 发现汇总
|
||||
|
||||
| 层级 | 技能 | CRITICAL | HIGH | MEDIUM | LOW/INFO | 总计 |
|
||||
|------|------|----------|------|--------|----------|------|
|
||||
| 第一层 | 安全审查 | 2 | 2 | 5 | 3 | 12 |
|
||||
| 第一层 | OWASP 安全审查 | 0 | 4 | 6 | 11 | 21 |
|
||||
| 第一层 | 静默失败审查 | 0 | 4 | 12 | 9 | 25 |
|
||||
| 第二层 | Kotlin 代码审查 | 0 | 1 | 6 | 19 | 26 |
|
||||
| 第二层 | 协程/Flow 审查 | 0 | 2 | 4 | 5 | 11 |
|
||||
| 第二层 | 类型设计审查 | 0 | 2 | 8 | 6 | 16 |
|
||||
| 第二层 | 生产就绪审查 | 0 | 4 | 10 | 4 | 18 |
|
||||
| 第三层 | 注释审查 | 0 | 0 | 2 | 4 | 6 |
|
||||
| 第三层 | 死代码清理 | 0 | 4 | 4 | 4 | 12 |
|
||||
| 第三层 | 代码简化 | 0 | 2 | 7 | 10 | 19 |
|
||||
| 第三层 | 无障碍审查 | 1 | 4 | 8 | 2 | 15 |
|
||||
| | **合计** | **3** | **29** | **72** | **77** | **181** |
|
||||
|
||||
---
|
||||
|
||||
## 顶层 CRITICAL 问题(必须立即修复)
|
||||
|
||||
### C1. 凭据明文存储在配置文件中
|
||||
|
||||
**文件**: `BackupConfig.kt:69,73,156-157`
|
||||
**类型**: Secret 泄露
|
||||
|
||||
restic 密码和 SMB/WebDAV 凭据以明文写入 `backup_settings.conf`,位于 `filesDir`。root 环境下,任何进程可读取。UI 中密码字段也未使用 `inputType="textPassword"`。
|
||||
|
||||
**建议**: 使用 `EncryptedSharedPreferences`,UI 使用密码掩码输入框。
|
||||
|
||||
### C2. 配置文件写入权限不安全
|
||||
|
||||
**文件**: `BackupConfig.kt:~144`
|
||||
**类型**: 权限滥用
|
||||
|
||||
`file.writeText()` 使用系统默认文件权限,未显式设置 owner-only 权限。
|
||||
|
||||
**建议**: 保存后调用 `file.setReadable(true, true)` / `file.setWritable(true, true)`。
|
||||
|
||||
### C3. TextView 模拟按钮缺少无障碍角色
|
||||
|
||||
**文件**: `PackageListAdapter.kt:61-69`
|
||||
**类型**: 无障碍
|
||||
|
||||
"数据"排除切换使用纯 `TextView` 实现点击交互,TalkBack 无法识别其可点击角色。
|
||||
|
||||
**建议**: 改用 `MaterialButton` 或添加 `focusable=true`, `clickable=true`, `contentDescription`。
|
||||
|
||||
---
|
||||
|
||||
## HIGH 优先修复(29 项)
|
||||
|
||||
### 安全(第一层汇总)
|
||||
|
||||
| # | 文件 | 行号 | 问题 | 建议 |
|
||||
|---|------|------|------|------|
|
||||
| H1 | `ResticRestBridge.kt` | 27 | NanoHTTPD 绑定 0.0.0.0 无认证,局域网可访问 | 改为 `NanoHTTPD("127.0.0.1", 0)` |
|
||||
| H2 | `RestoreOperation.kt` | 137-149 | tar 解压使用 `-C /`,恶意存档可覆写系统文件 | 添加绝对路径检查,临时目录解压 |
|
||||
| H3 | `SmbTransport.kt` | 103-109 | SMB 上传大小不匹配仍返回 Success,数据可能静默损坏 | 不匹配时返回 `AppResult.Failure` |
|
||||
| H4 | `BackupOperation.kt` | 255-257 | `backupUserData` 全失败时返回 `true`,用户看到"成功" | 改为 `return false` |
|
||||
| H5 | `ResticBackup.kt` | 55-58, 73-77 | `CancellationException` 被空 `catch` 吞没,取消信号丢失 | 加 `catch (e: CancellationException) { throw e }` |
|
||||
| H6 | `WebdavTransport.kt` | 153-155 | `mkdirs` 完全失败仍返回 `Success(Unit)` | 异常时应返回 `AppResult.Failure` |
|
||||
| H7 | `RootShell.kt` | 84-87 | `CancellationException` 被 `catch (e: Exception)` 吞没,全局取消失效 | 加 `catch (e: CancellationException) { throw e }` |
|
||||
| H8 | `ResticRestore.kt` | 78, 107 | 同 H5,CancellationException 被空 catch 吞没 | 重新抛出 CancellationException |
|
||||
| H9 | `BackupConfig.kt` | 69, 73 | 所有密码未加密存储(OWASP 维度) | EncryptedSharedPreferences |
|
||||
| H10 | `ui/ConfigViewModel.kt` | 180-183 | 空密码检测仅 `initResticRepo` 中有,其他操作入口缺 | 在所有操作入口添加密码空值检查 |
|
||||
| H11 | `BackupConfig.kt` | 139-186 | `allowBackup=true` 使 ADB 备份可提取明文配置 | 设为 `false` 或加密 |
|
||||
|
||||
### 架构与代码质量(第二层汇总)
|
||||
|
||||
| # | 文件 | 行号 | 问题 | 建议 |
|
||||
|---|------|------|------|------|
|
||||
| H12 | `RestoreOperation.kt` | 85 | `coroutineScope` 非 `supervisorScope`,单应用失败取消全部 | 改用 `supervisorScope` |
|
||||
| H13 | `PackageName` 值类 | 全局 | 多处方法签名使用 `String` 而非 `PackageName` | 统一改为 `PackageName` |
|
||||
| H14 | `UserId` 值类 | 全局 | 业务层使用 `String`/`Int` 而非 `UserId` | 统一切换为 `UserId` |
|
||||
| H15 | `ResticRestBridge.kt` | 246-257 | `buildV2Json` 手动拼接 JSON,无转义 | 使用 `JSONObject`/`kotlinx.serialization` |
|
||||
| H16 | `BackupConfig.kt` | 77-137 | 领域模型混合文件 I/O 逻辑(`fromFile`/`toFile`) | 提取到 `BackupConfigSerializer` |
|
||||
| H17 | `BackupOperation.kt` + `RestoreOperation.kt` | 各 300-500 行 | object 承担过多职责,混合 Shell 命令 + 数据格式 + 文件操作 | 按关注点拆分 |
|
||||
| H18 | `package-list-adapter` | 33-90 | 程序化创建视图而非 XML,无法热重载 | 改用 XML 布局 |
|
||||
| H19 | 生产就绪 | 全局 | 大量硬编码中文字符串,strings.xml 完全过时 | 全部移入 strings.xml+国际化 |
|
||||
| H20 | `app/build.gradle` | 48-53 | Release 构建无混淆(R8/ProGuard) | 添加 `minifyEnabled true` |
|
||||
| H21 | `app/build.gradle` | 41,43 | 签名密码回退为弱密码 `"android"` | 环境变量未设置时强制构建失败 |
|
||||
| H22 | `.github/workflows/ci.yml` | - | CI 不运行 `test`,不验证回归 | 添加 `./gradlew test` |
|
||||
| H23 | `WebdavTransport.kt` | 22-28 | 无超时配置,请求可能永远挂起 | 设置 connect/read/write 超时 |
|
||||
| H24 | `SmbTransport.kt`, `WebdavTransport.kt` | 全局 | 远程操作无重试策略 | 实现指数退避重试 |
|
||||
|
||||
### 可维护性与无障碍(第三层汇总)
|
||||
|
||||
| # | 文件 | 行号 | 问题 | 建议 |
|
||||
|---|------|------|------|------|
|
||||
| H25 | `MD4Provider.kt` | 全文件 | 整文件死代码,被 `MissingAlgoProvider` 取代 | 删除 |
|
||||
| H26 | `BackupFragment.kt` | 440-546 | 3 个流式备份方法从未被调用 | 删除或接入 |
|
||||
| H27 | `RemoteTransport.kt` | 73-75 | `isFileNotFound` 扩展函数从未使用 | 删除 |
|
||||
| H28 | `AppScanner.kt` | 26-33 | `DataSizes` 数据类从未被填充或读取 | 删除 |
|
||||
| H29 | `PackageListAdapter.kt` | 76-88 | 卡片点击区域无障碍语义缺失 | 添加状态文字 contentDescription |
|
||||
|
||||
---
|
||||
|
||||
## 关键模式分析
|
||||
|
||||
### 模式 1:CancellationException 被吞没(全局性)
|
||||
|
||||
**影响面**: 项目几乎所有协程操作通过 `RootShell.exec`,其 `catch (e: Exception)` 吞没 `CancellationException`。用户取消操作时,正在运行的 shell 命令不会收到取消信号。
|
||||
|
||||
**涉及文件**: `RootShell.kt:84-87`, `ResticBackup.kt:55-58,73-77,117-120,130-134`, `ResticRestore.kt:78,107`, `RootShell.kt:63-66`, `ResticWrapper.kt:315-317`
|
||||
|
||||
**修复**: 所有空 `catch (_: Exception)` 前加 `catch (e: CancellationException) { throw e }`。
|
||||
|
||||
### 模式 2:远程操作失败返回 Success(3 处)
|
||||
|
||||
**涉及文件**: `SmbTransport.kt:103-109`, `WebdavTransport.kt:153-155`, `BackupOperation.kt:255-257`
|
||||
|
||||
**影响**: 上层调用者无法区分"操作成功"和"操作失败但返回了 Success"。
|
||||
|
||||
### 模式 3:PackageName/UserId 值类未被方法签名采用
|
||||
|
||||
**涉及文件**: 全局,影响 `AppScanner`, `BackupOperation`, `RestoreOperation`, `StreamingBackup`, `PackageListAdapter`, `BackupProgress`, `RestoreProgress`
|
||||
|
||||
**影响**: 值类的类型安全收益完全丧失,编译器无法区分 `PackageName` 和任意 `String`。
|
||||
|
||||
### 模式 4:5 个子模块重复 local/remote 分支模式
|
||||
|
||||
**涉及文件**: `ResticBackup.kt`, `ResticRestore.kt`, `ResticRepoInit.kt`, `ResticMaintenance.kt`, `ResticSnapshotOps.kt`
|
||||
|
||||
**影响**: 每个方法都复制 `if (backend == "local")` 分支,增加维护成本和出错可能。
|
||||
|
||||
### 模式 5:BackupFragment 和 RestoreFragment 缺少 onDestroyView
|
||||
|
||||
**影响**: ViewPager 场景下,Fragment 视图销毁后协程完成时可能操作已分离的视图。
|
||||
|
||||
---
|
||||
|
||||
## 正向发现(设计良好的实践)
|
||||
|
||||
| 实践 | 文件 | 说明 |
|
||||
|------|------|------|
|
||||
| ✅ 密码通过环境变量传递 | `ResticEnvResolver.kt` | 不在命令行中出现,防止 `ps` 窥探 |
|
||||
| ✅ `shellEscape()` 一致使用 | `RootShell.kt:15` | 所有 shell 拼接参数都经过转义 |
|
||||
| ✅ `execSafe()` 安全方法 | `RootShell.kt:95-101` | 提供自动参数转义的执行方法 |
|
||||
| ✅ SharedFlow 用于一次性事件 | `ConfigViewModel.kt:103` | 标准实践 |
|
||||
| ✅ StateFlow 用于 UI 状态 | `ConfigViewModel.kt:106` | 标准实践 |
|
||||
| ✅ `repeatOnLifecycle` | `ConfigFragment.kt:75-84` | 正确使用生命周期感知收集 |
|
||||
| ✅ ResticBinary 双重检查锁定 | `ResticBinary.kt:13-33` | 正确的 `@Volatile` + `synchronized` |
|
||||
|
||||
---
|
||||
|
||||
## 按文件发现密度
|
||||
|
||||
| 文件 | 发现数 | 最严重 |
|
||||
|------|--------|--------|
|
||||
| `backup/ResticRestBridge.kt` | 10+ | HIGH |
|
||||
| `backup/BackupOperation.kt` | 10+ | HIGH |
|
||||
| `backup/BackupConfig.kt` | 8+ | CRITICAL |
|
||||
| `root/RootShell.kt` | 5+ | HIGH |
|
||||
| `backup/ResticBackup.kt` | 4+ | HIGH |
|
||||
| `backup/SmbTransport.kt` | 4+ | HIGH |
|
||||
| `backup/WebdavTransport.kt` | 4+ | HIGH |
|
||||
| `backup/RestoreOperation.kt` | 4+ | HIGH |
|
||||
| `ui/PackageListAdapter.kt` | 5+ | CRITICAL (a11y) |
|
||||
| `backup/ResticCommandRunner.kt` | 4+ | MEDIUM |
|
||||
| `ui/ConfigViewModel.kt` | 4+ | HIGH |
|
||||
| `backup/StreamingBackup.kt` | 3+ | MEDIUM |
|
||||
|
||||
---
|
||||
|
||||
## 修复路线图
|
||||
|
||||
### 立即修复(CRITICAL)
|
||||
1. 密码加密存储(EncryptedSharedPreferences)
|
||||
2. 配置文件权限加固
|
||||
3. 无障碍:TextView 改为语义化按钮
|
||||
|
||||
### 下一个版本(HIGH 优先级)
|
||||
4. `CancellationException` 全局修复(所有空 catch)
|
||||
5. ResticRestBridge 绑定 127.0.0.1 + 认证
|
||||
6. tar 解压路径检查
|
||||
7. SMB/WebDAV 失败时返回 Failure 而非 Success
|
||||
8. `supervisorScope` 替代 `coroutineScope`
|
||||
9. 死代码清理(MD4Provider.kt, 3 个死方法, DataSizes 等)
|
||||
10. 添加 release R8/ProGuard 混淆
|
||||
11. CI 添加 `./gradlew test`
|
||||
12. WebDAV 超时配置
|
||||
13. 远程操作重试策略
|
||||
14. 密码 UI 掩码输入
|
||||
15. 多目录恢复选择
|
||||
|
||||
### 规划修复(MEDIUM)
|
||||
16. `PackageName`/`UserId` 值类全面采用
|
||||
17. BackupConfig 分离 I/O 逻辑
|
||||
18. BackupOperation/RestoreOperation 拆分
|
||||
19. PackageListAdapter 改用 XML 布局
|
||||
20. 国际化:硬编码字符串移入 strings.xml
|
||||
21. 释放签名密码加固
|
||||
22. 前台服务通知进度更新
|
||||
23. 恢复操作确认对话框
|
||||
24. API 超时配置
|
||||
25. `@Serializable` 死注解清理
|
||||
|
||||
---
|
||||
|
||||
## 统计概览
|
||||
|
||||
| 指标 | 值 |
|
||||
|------|-----|
|
||||
| 审查技能数 | 11 |
|
||||
| 审查文件数 | 37 Kotlin + ~15 资源/配置 |
|
||||
| 总发现数 | 181 |
|
||||
| CRITICAL | 3 |
|
||||
| HIGH | 29 |
|
||||
| MEDIUM | 72 |
|
||||
| LOW/INFO | 77 |
|
||||
| 可删除代码 | ~150 行 + 1 个整文件 + ~20 行导入 |
|
||||
| 生产就绪评分 | 58/100 |
|
||||
| 测试覆盖率 | 53 测试(持续集成未运行) |
|
||||
266
docs/reviews/refactor-cleaner-review.md
Normal file
266
docs/reviews/refactor-cleaner-review.md
Normal file
@@ -0,0 +1,266 @@
|
||||
# 第三阶段 — 死代码清理审查报告
|
||||
|
||||
> 审查范围: android-backup-gui 项目 37 个 Kotlin 源文件
|
||||
> 审查技能: ecc-refactor-cleaner(死代码、未使用导入、重复逻辑、废弃代码)
|
||||
> 已知不重复: Phase 2 已报告的 @Serializable 死注解(TypeDesign F12)不在此重复
|
||||
> 已知不重复: memory 中 7 个待处理项不在此重复
|
||||
|
||||
---
|
||||
|
||||
## 严重程度分级
|
||||
|
||||
| 等级 | 含义 |
|
||||
|------|------|
|
||||
| 🔴 **严重** | 功能层面死代码,占用维护成本,可能引发混淆 |
|
||||
| 🟠 **中** | 未使用导入/参数,可能清理但非功能阻塞 |
|
||||
| 🟡 **低** | 装饰性/可清理但不影响运行 |
|
||||
|
||||
---
|
||||
|
||||
## 🔴 严重发现
|
||||
|
||||
### F1. `MD4Provider.kt` 整文件死代码
|
||||
|
||||
**文件**: `app/src/main/java/com/example/androidbackupgui/backup/MD4Provider.kt`
|
||||
**行号**: 1-137(整文件)
|
||||
|
||||
**问题**: `MD4Provider` 被 `MissingAlgoProvider` 完全取代。`MissingAlgoProvider` 提供了 `MD4` + `AESCMAC` 两种算法注入,且是 `SmbTransport` 实际调用的对象。`MD4Provider` 在任何地方都未被引用。
|
||||
|
||||
**证据**:
|
||||
- `SmbTransport` 调用的是 `MissingAlgoProvider.register()`
|
||||
- 全局搜索 `MD4Provider` 仅命中自身文件
|
||||
|
||||
**建议**: 删除整个 `MD4Provider.kt` 文件。
|
||||
|
||||
---
|
||||
|
||||
### F2. `BackupFragment.kt` 三个死方法(流式备份未接入)
|
||||
|
||||
**文件**: `app/src/main/java/com/example/androidbackupgui/ui/BackupFragment.kt`
|
||||
**行号**: 440-546
|
||||
|
||||
**问题**: 以下三个方法定义了流式备份逻辑但从未被调用:
|
||||
|
||||
| 方法 | 行号 | 说明 |
|
||||
|------|------|------|
|
||||
| `estimateBackupSize()` | 440 | 估算备份数据大小 |
|
||||
| `hasEnoughSpace()` | 455 | 检查磁盘空间是否充足 |
|
||||
| `runStreamingResticBackup()` | 472 | 执行流式备份(FIFO 管道) |
|
||||
|
||||
**证据**: 全局搜索三个方法名,除自身定义外无任何调用点。`startBackup()` 方法走的是常规 restic `backup` 路径,未调用流式路径。
|
||||
|
||||
`runStreamingResticBackup` 上标注了 `@Suppress("UNUSED_PARAMETER")` 且参数 `outputDir: File` 从未使用,说明开发者已知此方法目前是死代码。
|
||||
|
||||
**建议**: 删除三个方法及相关 `import android.os.StatFs`(如果没有其他用途)。或将流式备份接入到 `startBackup` 的条件分支中。
|
||||
|
||||
---
|
||||
|
||||
### F3. `RemoteTransport.isFileNotFound()` 未使用扩展函数
|
||||
|
||||
**文件**: `app/src/main/java/com/example/androidbackupgui/backup/RemoteTransport.kt`
|
||||
**行号**: 73-75
|
||||
|
||||
```kotlin
|
||||
internal fun AppError.isFileNotFound(): Boolean =
|
||||
this is AppError.Remote && this.isNotFound
|
||||
```
|
||||
|
||||
**问题**: 此扩展函数定义后从未在任何地方调用。`Remote` 错误中的 `isNotFound` 字段通过 `when (error) { is AppError.Remote -> ... }` 模式匹配访问,不需要扩展函数。
|
||||
|
||||
**证据**: 全局搜索 `isFileNotFound` 仅命中此定义。
|
||||
|
||||
**建议**: 删除此扩展函数。
|
||||
|
||||
---
|
||||
|
||||
### F4. `DataSizes` 数据类及其字段从未使用
|
||||
|
||||
**文件**: `app/src/main/java/com/example/androidbackupgui/backup/AppScanner.kt`
|
||||
**行号**: 26-33
|
||||
|
||||
```kotlin
|
||||
@Serializable
|
||||
data class DataSizes(
|
||||
val apkBytes: Long = 0,
|
||||
val userBytes: Long = 0,
|
||||
// ...
|
||||
)
|
||||
```
|
||||
|
||||
```kotlin
|
||||
data class AppInfo(
|
||||
// ...
|
||||
val dataSizes: DataSizes = DataSizes(), // 33 行
|
||||
)
|
||||
```
|
||||
|
||||
**问题**: `DataSizes` 类型仅用于 `AppInfo.dataSizes` 字段的默认值,没有任何代码对此字段写入非默认值或读取。这是残留的"预留"字段。
|
||||
|
||||
**证据**: 全局搜索 `dataSizes` 仅命中定义行(33)和 `DataSizes` 类型本身(26)。`@Serializable` 注解也是死注解(`AppInfo` 从未被 kotlinx-serialization 序列化)。
|
||||
|
||||
**建议**: 删除 `DataSizes` 数据类和 `AppInfo.dataSizes` 字段。保留 `@Serializable` 的清理评估留给 Phase 2 已知报告。
|
||||
|
||||
---
|
||||
|
||||
## 🟠 中等发现
|
||||
|
||||
### F5. 子模块中 TAG 常量复制粘贴错误
|
||||
|
||||
**文件**:
|
||||
- `app/src/main/java/com/example/androidbackupgui/backup/ResticRepoInit.kt` 第 7 行: `private val TAG = "ResticWrapper"`
|
||||
- `app/src/main/java/com/example/androidbackupgui/backup/ResticCommandRunner.kt` 第 8 行: `private val TAG = "ResticWrapper"`
|
||||
|
||||
**问题**: 两个子模块使用的 TAG 为 `"ResticWrapper"`,而非自己的类名。导致 logcat 中无法区分日志来源。
|
||||
|
||||
**建议**: 改为 `"ResticRepoInit"` 和 `"ResticCommandRunner"`。
|
||||
|
||||
---
|
||||
|
||||
### F6. 同包冗余导入(跨 7 个文件)
|
||||
|
||||
以下文件在 `package com.example.androidbackupgui.backup` 中,却显式 import 了同包的 `AppError`、`AppResult`、`err`:
|
||||
|
||||
| 文件 | 冗余导入行 |
|
||||
|------|-----------|
|
||||
| `ResticRepoInit.kt` | `import com.example.androidbackupgui.backup.AppError/AppResult/err` |
|
||||
| `ResticBackup.kt` | `import com.example.androidbackupgui.backup.AppError/AppResult/err` |
|
||||
| `ResticRestore.kt` | `import com.example.androidbackupgui.backup.AppError/AppResult/err` |
|
||||
| `ResticSnapshotOps.kt` | `import com.example.androidbackupgui.backup.AppError/AppResult/err` |
|
||||
| `ResticMaintenance.kt` | `import com.example.androidbackupgui.backup.AppError/AppResult/err` |
|
||||
| `ResticWrapper.kt` | `import com.example.androidbackupgui.backup.AppError/AppResult/err` |
|
||||
| `ResticCommandRunner.kt` | `import com.example.androidbackupgui.backup.AppError`(且此导入实际未使用——该文件不引用 `AppError`)|
|
||||
|
||||
**建议**: 清理全部冗余 import。`ResticCommandRunner.kt` 中的 `AppError` 为真正未使用导入,应删除。
|
||||
|
||||
---
|
||||
|
||||
### F7. 真正未使用的导入
|
||||
|
||||
| 文件 | 行号 | 导入 | 原因 |
|
||||
|------|------|------|------|
|
||||
| `ResticWrapper.kt` | 5 | `import kotlinx.coroutines.isActive` | 文件内无使用 |
|
||||
| `ResticWrapper.kt` | 9 | `import kotlin.coroutines.coroutineContext` | 文件内无使用 |
|
||||
| `BackupFragment.kt` | 34 | `import com.example.androidbackupgui.backup.formatSize` | 文件内无使用 |
|
||||
| `ConfigFragment.kt` | 19-20 | `import kotlinx.coroutines.Dispatchers` / `import kotlinx.coroutines.withContext` | Fragment 类中从未使用(全部委托给 ViewModel)|
|
||||
| `ConfigViewModel.kt` | 8 | `import com.example.androidbackupgui.backup.formatSize` | 文件内无使用 |
|
||||
|
||||
**建议**: 删除上述导入。
|
||||
|
||||
---
|
||||
|
||||
### F8. 未使用参数(已标注 `@Suppress`)
|
||||
|
||||
| 文件 | 函数 | 未使用参数 | 行号 |
|
||||
|------|------|-----------|------|
|
||||
| `ResticRestBridge.kt` | `handleConfig()` | `headers: Map<String, String>` | 166 |
|
||||
| `StreamingBackup.kt` | `launchDataProducer()` | `userId: String` | 90 |
|
||||
| `BackupFragment.kt` | `runStreamingResticBackup()` | `outputDir: File` | 475 |
|
||||
|
||||
**问题**: 参数被显式标记为未使用。如果近期无实现计划,应直接删除参数。
|
||||
|
||||
**建议**:
|
||||
- `handleConfig`: `headers` 可以移除(HEAD/GET/POST 都不需要它)
|
||||
- `launchDataProducer`: `userId` 若留作后续多用户支持,保留但记录 TODO
|
||||
- `runStreamingResticBackup`: 整个方法为死代码(见 F2),删除即可
|
||||
|
||||
---
|
||||
|
||||
## 🟡 低严重度发现
|
||||
|
||||
### F9. `AppScanner.getAppLabel()` 方法
|
||||
|
||||
**文件**: `app/src/main/java/com/example/androidbackupgui/backup/AppScanner.kt`
|
||||
**行号**: 87-92
|
||||
|
||||
```kotlin
|
||||
suspend fun getAppLabel(packageName: String): String = withContext(Dispatchers.IO) {
|
||||
val result = RootShell.exec("dumpsys package ...")
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
**问题**: 此 public 方法通过 `dumpsys package` 解析应用标签。但它返回的是包名(fallback),且项目中实际使用 `resolveLabels()`(通过 `PackageManager` API)来获取标签。此方法未被任何代码调用。
|
||||
|
||||
**证据**: 项目中使用 `resolveLabels()` 获取标签,`getAppLabel()` 无调用者。
|
||||
|
||||
**建议**: 确认无用后删除。
|
||||
|
||||
---
|
||||
|
||||
### F10. 重复的 if-else bridge 模式(架构级别)
|
||||
|
||||
在 5 个子模块中(`ResticRepoInit`, `ResticBackup`, `ResticRestore`, `ResticSnapshotOps`, `ResticMaintenance`),每个方法都重复以下模式:
|
||||
|
||||
```kotlin
|
||||
if (backend == "local") {
|
||||
val env = envResolver.buildLocalEnv(...)
|
||||
// run restic command
|
||||
} else {
|
||||
bridgeRunner.withBridge(...) { bridgeUrl ->
|
||||
val env = envResolver.buildBridgeEnv(...)
|
||||
// run restic command
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**影响**: `ResticMaintenance` 中 3 个方法(prune/check/stats)结构完全一致,仅有命令参数不同。跨模块总共 ~8 次重复。
|
||||
|
||||
**建议**: 可提取为公共执行函数,如 `withResticEnv(backend, ...) { env -> runner.runRestic(env, ...) }`。此为架构改进建议,非阻塞。
|
||||
|
||||
---
|
||||
|
||||
### F11. `BackupFragment.estimateBackupSize` 缩进错误
|
||||
|
||||
**文件**: `app/src/main/java/com/example/androidbackupgui/ui/BackupFragment.kt`
|
||||
**行号**: 440-449
|
||||
|
||||
缩进层次错误:`val pkgEsc = ...` 等行应在 `for` 循环体内但缩进级别与函数体相同:
|
||||
|
||||
```kotlin
|
||||
for (app in apps) {
|
||||
val pkgEsc = app.packageName.value.shellEscape() // ← 缩进错误
|
||||
val result = RootShell.exec(...)
|
||||
```
|
||||
|
||||
**建议**: 修复缩进(但该函数本身是死代码 F2,删除后自然解决)。
|
||||
|
||||
---
|
||||
|
||||
### F12. 重复的 UID 解析逻辑
|
||||
|
||||
**文件**:
|
||||
- `AppScanner.kt` — `hasKeystore()`(行 111-117)中解析 UID 的逻辑
|
||||
- `RestoreOperation.kt` — `resolveAppUid()`(行 462-490)中解析 UID 的逻辑
|
||||
|
||||
**问题**: 两处通过 `dumpsys package ... | grep 'userId='` 解析 UID 的代码逻辑高度相似。`RestoreOperation.resolveAppUid()` 更完整(支持 3 种 fallback),但 `AppScanner.hasKeystore()` 有独立的实现。
|
||||
|
||||
**建议**: 可将 UID 解析提取为公共工具函数,避免两处维护。
|
||||
|
||||
---
|
||||
|
||||
## 汇总
|
||||
|
||||
| 编号 | 严重度 | 类别 | 位置 | 建议 |
|
||||
|------|--------|------|------|------|
|
||||
| F1 | 🔴 | 死代码 | `MD4Provider.kt` 整文件 | 删除 |
|
||||
| F2 | 🔴 | 死代码 | `BackupFragment.kt` 440-546(3 个方法)| 删除或接入 |
|
||||
| F3 | 🔴 | 死代码 | `RemoteTransport.kt:73-75` | 删除扩展函数 |
|
||||
| F4 | 🔴 | 死代码 | `AppScanner.kt:26-33` DataSizes | 删除 |
|
||||
| F5 | 🟠 | 错误TAG | `ResticRepoInit.kt:7`, `ResticCommandRunner.kt:8` | 改为类名 |
|
||||
| F6 | 🟠 | 冗余导入 | 7 个文件中的同包 import | 清理 |
|
||||
| F7 | 🟠 | 未使用导入 | 5 个文件 | 删除 |
|
||||
| F8 | 🟠 | 未使用参数 | 3 个函数(已 @Suppress)| 删除参数或加 TODO |
|
||||
| F9 | 🟡 | 死代码 | `AppScanner.kt:87-92` getAppLabel | 确认后删除 |
|
||||
| F10 | 🟡 | 重复模式 | 5 个子模块中的 if-else bridge | 提取公共执行函数 |
|
||||
| F11 | 🟡 | 格式问题 | `BackupFragment.kt:440-449` 缩进 | 修复(随 F2 解决)|
|
||||
| F12 | 🟡 | 重复逻辑 | UID 解析在两处重复 | 提取工具函数 |
|
||||
|
||||
---
|
||||
|
||||
## 清理收益估算
|
||||
|
||||
- 可删除文件: 1 个(`MD4Provider.kt`, ~5.1KB)
|
||||
- 可删除代码行: ~150 行(死方法 + DataSizes + 扩展函数)
|
||||
- 可清理导入: ~20 行(冗余 + 未使用导入)
|
||||
- 可清理参数: 3 个
|
||||
- 代码库缩减: ~6-8% 的源代码量
|
||||
561
docs/superpowers/plans/security-review-report.md
Normal file
561
docs/superpowers/plans/security-review-report.md
Normal file
@@ -0,0 +1,561 @@
|
||||
# Android Backup GUI — OWASP 导向安全审查报告
|
||||
|
||||
> 审查日期: 2026-06-06
|
||||
> 范围: 全部 37 个 Kotlin 源文件 + AndroidManifest.xml
|
||||
> 已知问题已排除(memory 中记录的 7 项 Remaining Gaps 不在此报告重复)
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
1. [认证与授权](#1-认证与授权)
|
||||
2. [输入校验](#2-输入校验)
|
||||
3. [敏感数据处理](#3-敏感数据处理)
|
||||
4. [API 安全](#4-api-安全)
|
||||
5. [安全配置](#5-安全配置)
|
||||
6. [日志/调试信息泄露](#6-日志调试信息泄露)
|
||||
7. [Intent/组件暴露](#7-intent组件暴露)
|
||||
|
||||
---
|
||||
|
||||
## 1. 认证与授权
|
||||
|
||||
### 1.1 无权限检查直接执行 Root 命令
|
||||
|
||||
**严重程度**: 中
|
||||
|
||||
**文件**: `backup/BackupOperation.kt`
|
||||
**位置**: 第 109、173、228、246-249、273-276、297-300、308-311、334、350、369 行等
|
||||
|
||||
`RootShell.exec()` 在整个代码库中被广泛调用,但在调用前不做任何权限检查。虽然没有运行时安全检查(因为是 root 应用),但以下操作直接通过 `RootShell.exec()` 执行系统命令并拼接用户控制的输入:
|
||||
|
||||
```kotlin
|
||||
// BackupOperation.kt:109 — cp 命令使用 shellEscape
|
||||
RootShell.exec("cp '${apkPath.shellEscape()}' ...")
|
||||
// BackupOperation.kt:297 — tar 命令拼接目录名
|
||||
RootShell.exec("set -o pipefail; $tarCmd -cf - $excludeArgs ${dirs.joinToString(" ") { "'${it.shellEscape()}'" }} ...")
|
||||
// BackupOperation.kt:333 — 读取含有应用名的系统文件
|
||||
val result = RootShell.exec("cat '$ssaidFile' 2>/dev/null")
|
||||
```
|
||||
|
||||
**修复建议**: 虽然 `shellEscape()` 提供了防御,但所有 root shell 调用应使用 `execSafe()` 而不是 `exec()`。
|
||||
|
||||
### 1.2 RootShell 启用 libsu 详细日志
|
||||
|
||||
**严重程度**: 低
|
||||
|
||||
**文件**: `root/RootShell.kt`
|
||||
**位置**: 第 54 行
|
||||
|
||||
```kotlin
|
||||
Shell.enableVerboseLogging = true
|
||||
```
|
||||
|
||||
生产环境中启用 libsu 的详细日志,会将所有 su 会话操作的细节写入 logcat。
|
||||
|
||||
**修复建议**: 改为构建标志控制,仅在 debug 构建启用。
|
||||
|
||||
### 1.3 QUERY_ALL_PACKAGES 敏感权限
|
||||
|
||||
**严重程度**: 低(已声明为必要)
|
||||
|
||||
**文件**: `app/src/main/AndroidManifest.xml`
|
||||
**位置**: 第 7 行
|
||||
|
||||
```xml
|
||||
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" />
|
||||
```
|
||||
|
||||
Google Play 对 `QUERY_ALL_PACKAGES` 有严格审核要求,该应用的核心功能需要此权限以列举用户安装的应用。
|
||||
|
||||
**修复建议**: 确认应用不上架 Google Play 或已通过审核。当前无修复必要。
|
||||
|
||||
---
|
||||
|
||||
## 2. 输入校验
|
||||
|
||||
### 2.1 Restic 密码为空时仍继续执行
|
||||
|
||||
**严重程度**: 高
|
||||
|
||||
**文件**: `ui/ConfigViewModel.kt`
|
||||
**位置**: 第 180-183 行
|
||||
|
||||
```kotlin
|
||||
if (form.repo.isEmpty() || form.password.isEmpty()) {
|
||||
_uiState.update { it.copy(resticStatus = it.resticStatus.copy(message = "请填写仓库路径和密码")) }
|
||||
return
|
||||
}
|
||||
```
|
||||
|
||||
`initResticRepo()` 在 `form.password.isEmpty()` 时返回。但 `refreshResticStatus()`(第 217-256 行)和 `showResticStats()`(第 258-295 行)和 `pruneResticSnapshots()`(第 297-344 行)在 `form.password` 为空时不会检查,直接将空密码传给 `ResticWrapper`。
|
||||
|
||||
**修复建议**: 在所有操作入口添加密码空值检查,或至少记录 warning。
|
||||
|
||||
### 2.2 用户配置字段无输入校验
|
||||
|
||||
**严重程度**: 中
|
||||
|
||||
**文件**: `backup/BackupConfig.kt`
|
||||
**位置**: 第 78-136 行 (`fromFile`)
|
||||
|
||||
配置解析使用 `toIntOrNull()` 处理整数(静默回退到默认值),字符串字段没有任何长度、格式或内容验证。例如:
|
||||
- `resticBackendUrl` 不验证是否为合法 URL
|
||||
- `resticBackendShare` 不验证 SMB share 名称格式
|
||||
- `resticBackendUser` 和 `resticBackendPass` 不验证为空时的行为
|
||||
|
||||
**文件**: `ui/ConfigFragment.kt`
|
||||
**位置**: 第 200-217 行 (`saveConfig`)
|
||||
|
||||
```kotlin
|
||||
resticPassword = binding.resticPasswordEdit.text?.toString() ?: "",
|
||||
resticBackendUrl = binding.resticBackendUrlEdit.text?.toString()?.trim() ?: "",
|
||||
```
|
||||
|
||||
来自 UI 的输入仅进行了简单的 null→empty 转换,没有任何格式校验。
|
||||
|
||||
**修复建议**: 添加输入验证层,至少检查 URL 格式、必填字段非空。对于 restic 仓库密码,提示用户确认。
|
||||
|
||||
### 2.3 ResticRestBridge URI 路径注入风险
|
||||
|
||||
**严重程度**: 中
|
||||
|
||||
**文件**: `backup/ResticRestBridge.kt`
|
||||
**位置**: 第 62-117 行 (`handleRequest`)
|
||||
|
||||
URI 路径解析时,`segments` 由 `strippedPath.split("/").filter { it.isNotEmpty() }` 产生,然后直接用于构建远程路径:
|
||||
|
||||
```kotlin
|
||||
// 第 100-102 行
|
||||
val type = firstSegment
|
||||
val name = if (segments.size >= 2) segments.drop(1).joinToString("/") else null
|
||||
```
|
||||
|
||||
以及后续的远程路径构建:
|
||||
```kotlin
|
||||
// 第 232 行
|
||||
val remoteDir = "$remoteBase/$type"
|
||||
// 第 262 行
|
||||
val remotePath = "$remoteBase/$type/$name"
|
||||
```
|
||||
|
||||
虽然 restic 是唯一客户端,但 URI 中的编码路径可能被滥用于路径遍历。`name` 通过 `joinToString("/")` 直接拼接到远程路径。
|
||||
|
||||
**修复建议**: 对 `type` 和 `name` 进行路径字符过滤,拒绝 `..`、`./` 等特殊路径序列。添加到 `RemoteTransport` 调用前。
|
||||
|
||||
---
|
||||
|
||||
## 3. 敏感数据处理
|
||||
|
||||
### 3.1 Restic 密码和凭据明文存储
|
||||
|
||||
**严重程度**: 高
|
||||
|
||||
**文件**: `backup/BackupConfig.kt`
|
||||
**位置**: 第 69、73 行
|
||||
|
||||
```kotlin
|
||||
val resticPassword: String = "",
|
||||
val resticBackendPass: String = "",
|
||||
```
|
||||
|
||||
**文件**: `backup/BackupConfig.kt`
|
||||
**位置**: 第 139-186 行 (`toFile`)
|
||||
|
||||
```kotlin
|
||||
appendLine("restic_password=\"${config.resticPassword}\"")
|
||||
appendLine("restic_backend_pass=\"${config.resticBackendPass}\"")
|
||||
```
|
||||
|
||||
所有密码以明文写入配置文件 `backup_settings.conf`,存储在 `filesDir`(`/data/data/com.example.androidbackupgui/files/`)。在已有 root 权限的设备上,其他 root 进程可以读取该文件。Android `android:allowBackup="true"` 更使 ADB 备份可以提取此文件。
|
||||
|
||||
**修复建议**:
|
||||
- 使用 `EncryptedSharedPreferences`(AndroidX Security)加密存储密码
|
||||
- 或在运行时从用户输入获取密码,不持久化到磁盘
|
||||
- 将 `allowBackup` 设为 `false` 以防止 ADB 备份提取
|
||||
|
||||
### 3.2 SSAID 唯一标识符泄露
|
||||
|
||||
**严重程度**: 中
|
||||
|
||||
**文件**: `backup/BackupOperation.kt`
|
||||
**位置**: 第 331-347 行 (`backupSsaid`)
|
||||
|
||||
```kotlin
|
||||
val ssaidLine = result.output.lines().firstOrNull { line ->
|
||||
line.contains("packageName=\"$packageName\"") || line.contains("packageName='$packageName'")
|
||||
}
|
||||
val value = ssaidLine
|
||||
?.substringAfter("value=\"")
|
||||
?.substringBefore("\"")
|
||||
?.takeIf { it.isNotBlank() }
|
||||
if (value != null) {
|
||||
File(appDir, "ssaid.txt").writeText(value) // 明文写入备份输出
|
||||
}
|
||||
```
|
||||
|
||||
SSAID(Settings Secure Android ID)是每个应用的唯一标识符,属于 `Settings.Secure` 级别的敏感标识符。备份文件中的 `ssaid.txt` 以明文存储,且:
|
||||
|
||||
**文件**: `backup/LogUtil.kt` 间接受到影响(日志中可能包含 SSAID)
|
||||
|
||||
实际上没有日志泄露,但 `ssaid.txt` 作为备份的一部分进入 restic 仓库,restic 仓库本身加密但元数据路径可见。
|
||||
|
||||
**修复建议**: SSAID 备份/恢复是 restore 功能的核心需求,当前处理方式可接受。但应在文档中说明此行为。
|
||||
|
||||
### 3.3 WiFi 配置包含网络密码
|
||||
|
||||
**严重程度**: 中
|
||||
|
||||
**文件**: `backup/WifiManager.kt`
|
||||
**位置**: 第 41-47 行 (`backup`)
|
||||
|
||||
```kotlin
|
||||
val result = RootShell.exec("cp '$wifiSource' '${wifiDest.absolutePath.shellEscape()}'")
|
||||
```
|
||||
|
||||
WiFi 配置文件(`WifiConfigStore.xml`、`wpa_supplicant.conf`)包含网络 SSID 和密码的明文或哈希值。这些文件被复制到备份输出,进而可能被 restic 快照处理。
|
||||
|
||||
**修复建议**: 在备份 WiFi 配置时过滤或加密敏感字段。WiFi 密码至少应标记为需要额外保护。
|
||||
|
||||
### 3.4 Restic 密码通过环境变量传递
|
||||
|
||||
**严重程度**: 中性(设计合理)
|
||||
|
||||
**文件**: `backup/ResticEnvResolver.kt`
|
||||
**位置**: 第 17、35 行
|
||||
|
||||
```kotlin
|
||||
env["RESTIC_PASSWORD"] = password
|
||||
```
|
||||
|
||||
通过环境变量而非命令行参数传递密码是**正确的做法**,可以防止密码被 `ps` 等进程列表工具窥探。这是值得保持的好设计。
|
||||
|
||||
**注意**: 环境变量仍可被 `/proc/self/environ` 读取(在 root 权限下),但对于该应用的威胁模型(已有 root 权限),这是可接受的。
|
||||
|
||||
---
|
||||
|
||||
## 4. API 安全
|
||||
|
||||
### 4.1 ResticRestBridge 无认证监听本地端口
|
||||
|
||||
**严重程度**: 高
|
||||
|
||||
**文件**: `backup/ResticRestBridge.kt`
|
||||
**位置**: 第 22-27 行
|
||||
|
||||
```kotlin
|
||||
class ResticRestBridge(...) : NanoHTTPD(0) {
|
||||
```
|
||||
|
||||
`NanoHTTPD(0)` 默认绑定到 `0.0.0.0`(所有网络接口),端口由系统分配(0 表示任意可用端口)。桥接器不包含任何认证机制:
|
||||
|
||||
- 第 36-54 行 (`serve`): 没有 IP 过滤、Token 检查或任何认证
|
||||
- 第 62-117 行 (`handleRequest`): 直接处理所有 HTTP 方法(GET/POST/DELETE/HEAD)
|
||||
- 第 348-371 行 (`handlePostBlob`): 接受任意文件上传到远程存储
|
||||
- 第 376-386 行 (`handleDeleteBlob`): 允许删除远程存储中的任意 blob
|
||||
|
||||
**文件**: `backup/RestBridgeRunner.kt`
|
||||
**位置**: 第 76 行
|
||||
|
||||
```kotlin
|
||||
val bridgeUrl = "rest:http://127.0.0.1:$port/$repoPath"
|
||||
```
|
||||
|
||||
虽然 restic 客户端被指示连接到 `127.0.0.1`,但 NanoHTTPD 服务器绑定在 `0.0.0.0`。同一局域网/WLAN 下的其他设备可以访问此端口。
|
||||
|
||||
**修复建议**: 创建 NanoHTTPD 时指定只监听 127.0.0.1。NanoHTTPD 构造函数的端口参数后可以添加 IP 地址参数,或使用 `NanoHTTPD("127.0.0.1", 0)`(如果 API 支持)。否则,在启动后添加 iptables 规则限制本地访问。
|
||||
|
||||
### 4.2 ResticRestBridge 错误信息泄露
|
||||
|
||||
**严重程度**: 低
|
||||
|
||||
**文件**: `backup/ResticRestBridge.kt`
|
||||
**位置**: 第 47-51 行
|
||||
|
||||
```kotlin
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "request failed: $method $uri", e)
|
||||
newFixedLengthResponse(
|
||||
Response.Status.INTERNAL_ERROR, "text/plain",
|
||||
e.message ?: "Internal error"
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
异常消息直接返回给 HTTP 客户端。更严重的是,`streamBodyToFile` 的失败也返回给客户端:
|
||||
|
||||
```kotlin
|
||||
// 第 207-210 行
|
||||
if (tmpResult.isFailure) return@runBlocking newFixedLengthResponse(
|
||||
Response.Status.INTERNAL_ERROR, "text/plain",
|
||||
"body read failed: ${tmpResult.exceptionOrNull()?.message ?: "unknown"}"
|
||||
)
|
||||
```
|
||||
|
||||
**修复建议**: 将详细的错误消息仅记录到日志,返回通用的 "Internal error"。
|
||||
|
||||
---
|
||||
|
||||
## 5. 安全配置
|
||||
|
||||
### 5.1 allowBackup 启用
|
||||
|
||||
**严重程度**: 高
|
||||
|
||||
**文件**: `app/src/main/AndroidManifest.xml`
|
||||
**位置**: 第 13 行
|
||||
|
||||
```xml
|
||||
android:allowBackup="true"
|
||||
```
|
||||
|
||||
`allowBackup="true"` 允许通过 `adb backup` 提取应用的全部私有数据,包括 `filesDir` 中的 `backup_settings.conf`(包含明文 restic 密码和备份凭据)。
|
||||
|
||||
**修复建议**: 设置为 `false`:
|
||||
|
||||
```xml
|
||||
android:allowBackup="false"
|
||||
android:fullBackupContent="false"
|
||||
```
|
||||
|
||||
### 5.2 无网络安全配置
|
||||
|
||||
**严重程度**: 中
|
||||
|
||||
**文件**: `app/src/main/AndroidManifest.xml`
|
||||
**位置**: 第 12-18 行
|
||||
|
||||
应用声明了 `INTERNET` 和 `ACCESS_NETWORK_STATE` 权限,支持 WebDAV、SMB 和 rest-server 远程传输,但未配置 `android:networkSecurityConfig`。这意味着默认允许所有未加密的明文流量(HTTP),对于传输备份数据的场景存在安全风险。
|
||||
|
||||
**修复建议**: 添加 `res/xml/network_security_config.xml` 网络安全配置,明确允许/限制明文流量目标。如果仅使用内网 NAS,可以限制明文到特定内网网段。
|
||||
|
||||
### 5.3 无备份数据加密说明
|
||||
|
||||
**严重程度**: 低
|
||||
|
||||
备份的数据(应用 APK、数据目录、WiFi 配置等)不进行应用层加密。restic 仓库会进行传输中和静态加密(如果配置了),但本地 staging 目录中的备份文件放在外部存储的明文目录中。
|
||||
|
||||
**修复建议**: 建议用户在文档中了解:本地备份目录中的文件未加密;restic 仓库提供加密但需正确保管密码。
|
||||
|
||||
---
|
||||
|
||||
## 6. 日志/调试信息泄露
|
||||
|
||||
### 6.1 RootShell 命令日志泄露
|
||||
|
||||
**严重程度**: 中
|
||||
|
||||
**文件**: `root/RootShell.kt`
|
||||
**位置**: 第 82、85 行
|
||||
|
||||
```kotlin
|
||||
Log.w(TAG, "exec timeout (${timeoutMs}ms): $command")
|
||||
Log.e(TAG, "exec failed: $command", e)
|
||||
```
|
||||
|
||||
`RootShell.exec()` 在命令失败或超时时将完整的命令字符串记录到 logcat。如果 `exec()` 被传入包含密码或 token 的命令,这些敏感数据会被泄露到 logcat。
|
||||
|
||||
当前实现中 `BackupOperation.kt` 主要使用 `execSafe()`(通过 `shellEscape()`),但 `exec()` 是公有函数,任何调用者都可能传入未脱敏的命令。
|
||||
|
||||
**修复建议**:
|
||||
- 在日志中截断或脱敏命令字符串
|
||||
- 或更严格地——不在日志中包含命令内容,只记录标签和错误码
|
||||
|
||||
### 6.2 SSAID 值记录到日志
|
||||
|
||||
**严重程度**: 高
|
||||
|
||||
**文件**: `backup/BackupOperation.kt`
|
||||
**位置**: 第 345 行
|
||||
|
||||
```kotlin
|
||||
Log.d(TAG, "backupSsaid: backed up SSAID for $packageName = $value")
|
||||
```
|
||||
|
||||
SSAID(Settings Secure Android ID)是每个应用唯一的设备级标识符,直接以明文记录到 logcat。logcat 在 Android 8+ 受权限保护,但仍可被系统应用和 adb 读取。
|
||||
|
||||
**文件**: `backup/RestoreOperation.kt`
|
||||
**位置**: 第 398、401、411 行
|
||||
|
||||
```kotlin
|
||||
Log.i(TAG, "restoreSsaid: restored SSAID for $packageName via XML (uid=$uid)")
|
||||
Log.w(TAG, "restoreSsaid: XML edit completed but entry not found, falling back")
|
||||
Log.e(TAG, "restoreSsaid: failed to set SSAID for $packageName: ${result.error}")
|
||||
```
|
||||
|
||||
虽然恢复端未直接记录 SSAID 值,但记录了 UID(唯一整数标识符),结合包名可识别设备。
|
||||
|
||||
**修复建议**: 不在日志中记录 SSAID 值,只记录操作状态。
|
||||
|
||||
### 6.3 LogUtil 日志文件可能包含敏感信息
|
||||
|
||||
**严重程度**: 中
|
||||
|
||||
**文件**: `backup/LogUtil.kt`
|
||||
**位置**: 第 45-58 行 (`writeLog`)
|
||||
|
||||
```kotlin
|
||||
private fun writeLog(level: String, tag: String, message: String) {
|
||||
val dir = baseDir ?: return
|
||||
executor.execute {
|
||||
...
|
||||
val line = "$timestamp $level/$tag: $message\n"
|
||||
logFile.appendText(line)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`LogUtil` 将所有 `i/w/e` 日志写入 `baseDir/logs/` 目录下的日期文件。这些日志文件包含 `LogUtil.i/w/e()` 调用的全部消息,可能包括命令参数、错误详情等敏感信息。日志文件保留 7 天。
|
||||
|
||||
```kotlin
|
||||
// 第 77-84 行
|
||||
fun getLogFiles(): List<File> {
|
||||
val logDir = File(dir, "logs")
|
||||
return logDir.listFiles()
|
||||
?.filter { it.name.endsWith(".log") }
|
||||
?.sortedBy { it.name } ?: emptyList()
|
||||
}
|
||||
```
|
||||
|
||||
日志文件可通过 `getLogFiles()` 获取,虽然当前没有代码直接暴露给其他应用,但 restic 备份会扫描此目录,导致日志被包含在备份快照中。
|
||||
|
||||
**修复建议**:
|
||||
- 添加日志级别过滤,不在文件日志中包含 `Log.d` 级别的调试信息
|
||||
- 考虑在日志过虑器中脱敏已知的敏感模式(密码、SSAID、token)
|
||||
- 将日志目录添加到 restic 备份排除列表
|
||||
|
||||
### 6.4 配置 URL 日志可能包含内嵌凭据
|
||||
|
||||
**严重程度**: 低
|
||||
|
||||
**文件**: `ui/ConfigViewModel.kt`
|
||||
**位置**: 第 178 行
|
||||
|
||||
```kotlin
|
||||
Log.i(TAG, "initResticRepo: repo=${form.repo} backend=${form.backend} url=${form.backendUrl}")
|
||||
```
|
||||
|
||||
如果用户将凭据嵌入 backend URL(如 `https://user:password@host/path`),这些凭据会被记录到日志。WebDAV URL 有时包含用户名。
|
||||
|
||||
**修复建议**: 在日志中脱敏 URL 中的用户信息部分。
|
||||
|
||||
### 6.5 Shell 命令冗余日志
|
||||
|
||||
**严重程度**: 低
|
||||
|
||||
**文件**: `backup/ResticCommandRunner.kt`
|
||||
**位置**: 第 36、42、76-77 行等
|
||||
|
||||
```kotlin
|
||||
Log.d(TAG, "buildCommandArgs: binaryPath=$binaryPath args=$args -> cmd=$cmd")
|
||||
Log.i(TAG, "runRestic cmd=${cmdArgs.joinToString(" ")}")
|
||||
Log.i(TAG, "runRestic exitCode=$exitCode stdout_len=${stdout.length}")
|
||||
if (stderrText.isNotEmpty()) Log.w(TAG, "runRestic stderr: ${stderrText.trim()}")
|
||||
```
|
||||
|
||||
尽管密码通过环境变量而非命令行参数传递(正确做法),但命令参数被完整记录。在 restic `init`、`backup`、`restore` 等命令中,命令行包含仓库路径、标签、主机名等信息,这些信息本身通常不敏感,但 `args` 参数在日志中可见。
|
||||
|
||||
文件路径日志(第 173 行):
|
||||
```kotlin
|
||||
Log.i(TAG, "runResticWithStdin cmd=${cmdArgs.joinToString(" ")} stdin=${stdinFile.absolutePath}")
|
||||
```
|
||||
|
||||
**修复建议**: 当前日志设计合理——密码不在命令行中,因此日志不包含密码。无需更改。
|
||||
|
||||
---
|
||||
|
||||
## 7. Intent/组件暴露
|
||||
|
||||
### 7.1 ResticRestBridge 绑定到 0.0.0.0
|
||||
|
||||
**严重程度**: 高
|
||||
|
||||
(已在 4.1 中详述——此问题跨类别)
|
||||
|
||||
**文件**: `backup/ResticRestBridge.kt`
|
||||
**位置**: 第 27 行
|
||||
|
||||
```kotlin
|
||||
) : NanoHTTPD(0) {
|
||||
```
|
||||
|
||||
NanoHTTPD 默认绑定所有网络接口。同一设备上或同一网络中的恶意应用/用户可访问此 REST 接口,读取/写入远程存储中的 blob 数据。
|
||||
|
||||
### 7.2 BackupService 未导出但使用隐式 Intent
|
||||
|
||||
**严重程度**: 低
|
||||
|
||||
**文件**: `backup/BackupService.kt`
|
||||
**位置**: 第 21-23 行
|
||||
|
||||
```kotlin
|
||||
const val ACTION_START_BACKUP = "com.example.androidbackupgui.action.START_BACKUP"
|
||||
const val ACTION_STOP_BACKUP = "com.example.androidbackupgui.action.STOP_BACKUP"
|
||||
const val EXTRA_STATUS_TEXT = "status_text"
|
||||
```
|
||||
|
||||
**文件**: `ui/BackupFragment.kt`
|
||||
**位置**: 第 190-192、391-394 行
|
||||
|
||||
```kotlin
|
||||
val serviceIntent = Intent(requireContext(), BackupService::class.java)
|
||||
serviceIntent.action = BackupService.ACTION_START_BACKUP
|
||||
```
|
||||
|
||||
Service 声明为 `exported="false"`,所以只有同一应用内可访问——安全。Action 字符串使用完整包名前缀,避免了与其他应用的 Intent 冲突。
|
||||
|
||||
### 7.3 MainActivity 导出为 LAUNCHER
|
||||
|
||||
**严重程度**: 低(标准做法)
|
||||
|
||||
**文件**: `app/src/main/AndroidManifest.xml`
|
||||
**位置**: 第 20-27 行
|
||||
|
||||
```xml
|
||||
<activity android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
```
|
||||
|
||||
标准 LAUNCHER Activity 导出设置,但在 root 应用上下文中,其他应用可以调用此 Activity 触发初始化和权限请求流程。
|
||||
|
||||
**修复建议**: 对于意外启动,可添加 `android:exported="true"` 但仅保留 MAIN/LAUNCHER intent-filter。当前配置已正确。
|
||||
|
||||
---
|
||||
|
||||
## 问题严重程度汇总
|
||||
|
||||
|编号|严重程度|类型|文件|行号|
|
||||
|---|---|---|---|---|
|
||||
|3.1|**高**|敏感数据-明文密码|BackupConfig.kt|69,73,178-182|
|
||||
|5.1|**高**|安全配置-allowBackup|AndroidManifest.xml|13|
|
||||
|4.1 / 7.1|**高**|API 安全-无认证桥接|ResticRestBridge.kt|27,36-54|
|
||||
|6.2|**高**|日志泄露-SSAID|BackupOperation.kt|345|
|
||||
|2.1|中|输入校验-密码空值检查缺失|ConfigViewModel.kt|217-256|
|
||||
|2.2|中|输入校验-字段无格式验证|BackupConfig.kt, ConfigFragment.kt|78-136,200-217|
|
||||
|2.3|中|输入校验-路径注入风险|ResticRestBridge.kt|62-117|
|
||||
|3.2|中|敏感数据-SSAID 明文备份|BackupOperation.kt|331-347|
|
||||
|3.3|中|敏感数据-WiFi 配置含密码|WifiManager.kt|41-47|
|
||||
|5.2|中|安全配置-无 networkSecurityConfig|AndroidManifest.xml|12-18|
|
||||
|6.1|中|日志泄露-命令内容|RootShell.kt|82,85|
|
||||
|6.3|中|日志泄露-文件日志含敏感信息|LogUtil.kt|45-58|
|
||||
|1.1|低|授权-无权限检查模式|BackupOperation.kt|多处|
|
||||
|1.2|低|配置-冗余 libsu 日志|RootShell.kt|54|
|
||||
|4.2|低|API-错误信息泄露|ResticRestBridge.kt|47-51,207-210|
|
||||
|6.4|低|日志泄露-URL 可能含凭据|ConfigViewModel.kt|178|
|
||||
|
||||
---
|
||||
|
||||
## 最重要的修复建议(按优先级排序)
|
||||
|
||||
1. **(紧急)修复 ResticRestBridge 绑定到 0.0.0.0** — 改为仅监听 127.0.0.1,防止局域网内其他设备访问 REST 桥接 API。
|
||||
2. **(紧急)设置 allowBackup="false"** — 防止 ADB 备份提取明文密码配置文件。
|
||||
3. **(高优先级)移除 SSAID 值日志输出** — `BackupOperation.kt:345` 中删除 `= $value` 部分。
|
||||
4. **(高优先级)对备份配置使用加密存储** — 使用 `EncryptedSharedPreferences` 或运行时密码输入,避免密码明文持久化。
|
||||
5. **(中优先级)添加输入验证层** — 对 `resticBackendUrl` 等字段进行格式验证,所有操作前检查密码非空。
|
||||
6. **(中优先级)添加 networkSecurityConfig** — 限制明文流量目标。
|
||||
7. **(中优先级)审查 LogUtil 日志内容** — 确保日志文件中不包含密码/SSAID 等敏感字段。
|
||||
77
gradlew.bat
vendored
Normal file
77
gradlew.bat
vendored
Normal file
@@ -0,0 +1,77 @@
|
||||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo.
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
echo.
|
||||
echo Please set the JAVA_HOME variable in your environment to match the
|
||||
echo location of your Java installation.
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo.
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
|
||||
echo.
|
||||
echo Please set the JAVA_HOME variable in your environment to match the
|
||||
echo location of your Java installation.
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if %OS%==Windows_NT setlocal
|
||||
|
||||
:omega
|
||||
149
ktlint.py
Executable file
149
ktlint.py
Executable file
@@ -0,0 +1,149 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Kotlin LSP client for code diagnostics. Collects all LSP messages."""
|
||||
import subprocess, json, sys, os, signal, time
|
||||
from pathlib import Path
|
||||
|
||||
def run_diagnostics(project_dir: str, file_path: str, timeout: int = 60):
|
||||
proc = subprocess.Popen(
|
||||
['/usr/local/bin/kotlin-language-server'],
|
||||
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||
cwd=project_dir, preexec_fn=lambda: signal.signal(signal.SIGPIPE, signal.SIG_DFL)
|
||||
)
|
||||
|
||||
def send(msg):
|
||||
data = json.dumps(msg).encode('utf-8')
|
||||
proc.stdin.write(f'Content-Length: {len(data)}\r\n\r\n'.encode('utf-8'))
|
||||
proc.stdin.write(data)
|
||||
proc.stdin.flush()
|
||||
|
||||
def recv(timeout_s=5):
|
||||
content_length = 0
|
||||
end = time.time() + timeout_s
|
||||
while time.time() < end:
|
||||
if proc.poll() is not None:
|
||||
return None
|
||||
ready = proc.stdout.readable()
|
||||
if not ready:
|
||||
time.sleep(0.05)
|
||||
continue
|
||||
line = proc.stdout.readline()
|
||||
if not line:
|
||||
time.sleep(0.05)
|
||||
continue
|
||||
line = line.decode('utf-8', errors='replace').strip()
|
||||
if line.startswith('Content-Length:'):
|
||||
content_length = int(line.split(':')[1].strip())
|
||||
elif line == '' and content_length > 0:
|
||||
body = proc.stdout.read(content_length).decode('utf-8', errors='replace')
|
||||
return json.loads(body)
|
||||
return 'TIMEOUT'
|
||||
|
||||
all_msgs = []
|
||||
|
||||
send({
|
||||
'jsonrpc': '2.0', 'id': 1, 'method': 'initialize',
|
||||
'params': {
|
||||
'processId': os.getpid(),
|
||||
'capabilities': {
|
||||
'textDocument': {'diagnostics': {'dynamicRegistration': False}},
|
||||
'workspace': {'didChangeWatchedFiles': {'dynamicRegistration': False}}
|
||||
},
|
||||
'rootUri': f'file://{project_dir}',
|
||||
'workspaceFolders': [{'uri': f'file://{project_dir}', 'name': Path(project_dir).name}]
|
||||
}
|
||||
})
|
||||
|
||||
# Read all messages until we get initialize result
|
||||
end = time.time() + timeout
|
||||
init_ok = False
|
||||
while time.time() < end and not init_ok:
|
||||
msg = recv(5)
|
||||
if msg is None:
|
||||
break
|
||||
if msg == 'TIMEOUT':
|
||||
continue
|
||||
all_msgs.append(('init', msg))
|
||||
if msg.get('id') == 1 and 'result' in msg:
|
||||
init_ok = True
|
||||
|
||||
if not init_ok:
|
||||
return all_msgs, f'INIT_TIMEOUT after {timeout}s'
|
||||
|
||||
send({'jsonrpc': '2.0', 'method': 'initialized', 'params': {}})
|
||||
|
||||
# Open file
|
||||
file_uri = f'file://{file_path}'
|
||||
with open(file_path) as f:
|
||||
content = f.read()
|
||||
|
||||
send({
|
||||
'jsonrpc': '2.0', 'method': 'textDocument/didOpen',
|
||||
'params': {
|
||||
'textDocument': {
|
||||
'uri': file_uri, 'languageId': 'kotlin',
|
||||
'version': 1, 'text': content
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
# Collect messages for remaining time
|
||||
end = time.time() + 30
|
||||
while time.time() < end:
|
||||
msg = recv(3)
|
||||
if msg is None or msg == 'TIMEOUT':
|
||||
continue
|
||||
all_msgs.append(('open', msg))
|
||||
|
||||
# Shutdown
|
||||
send({'jsonrpc': '2.0', 'id': 2, 'method': 'shutdown', 'params': {}})
|
||||
|
||||
try:
|
||||
proc.terminate()
|
||||
proc.wait(timeout=3)
|
||||
except:
|
||||
proc.kill()
|
||||
|
||||
return all_msgs, 'OK'
|
||||
|
||||
if __name__ == '__main__':
|
||||
file_path = os.path.abspath(sys.argv[1])
|
||||
project_dir = os.path.abspath(sys.argv[2]) if len(sys.argv) > 2 else os.getcwd()
|
||||
print(f'Project: {project_dir}')
|
||||
print(f'File: {file_path}\n')
|
||||
|
||||
msgs, status = run_diagnostics(project_dir, file_path)
|
||||
|
||||
print(f'Status: {status}')
|
||||
print(f'Messages received: {len(msgs)}\n')
|
||||
|
||||
diag_count = 0
|
||||
for phase, msg in msgs:
|
||||
method = msg.get('method', '?')
|
||||
if 'id' in msg:
|
||||
method = f'response(id={msg["id"]})'
|
||||
if 'error' in msg:
|
||||
print(f' [{phase}] {method} ERROR: {msg["error"]}')
|
||||
elif method == 'window/logMessage':
|
||||
print(f' [{phase}] log: {msg.get("params",{}).get("message","")}')
|
||||
elif method == 'window/showMessage':
|
||||
print(f' [{phase}] show: {msg.get("params",{}).get("message","")}')
|
||||
elif method == 'textDocument/publishDiagnostics':
|
||||
diags = msg.get('params', {}).get('diagnostics', [])
|
||||
diag_count += len(diags)
|
||||
uri = msg.get('params', {}).get('uri', '')
|
||||
print(f' [{phase}] publishDiagnostics ({len(diags)} items): {os.path.basename(uri)}')
|
||||
for d in diags:
|
||||
r = d.get('range', {})
|
||||
s = r.get('start', {})
|
||||
sev = {1:'E',2:'W',3:'I',4:'H'}.get(d.get('severity'),'?')
|
||||
print(f' {sev} {s.get("line",0)+1}:{s.get("character",0)+1} {d.get("message","")}')
|
||||
elif method.startswith('response'):
|
||||
if 'result' in msg:
|
||||
caps = msg.get('result', {}).get('capabilities', {})
|
||||
print(f' [{phase}] {method} capabilities: {json.dumps(caps, indent=2)[:400]}')
|
||||
else:
|
||||
print(f' [{phase}] {method}')
|
||||
else:
|
||||
print(f' [{phase}] {method}: {json.dumps(msg, indent=2)[:200]}')
|
||||
|
||||
print(f'\nTotal diagnostics: {diag_count}')
|
||||
333
security-review-report.md
Normal file
333
security-review-report.md
Normal file
@@ -0,0 +1,333 @@
|
||||
# Android Backup GUI — 安全审查报告
|
||||
|
||||
**审查日期**: 2026-06-06
|
||||
**审查范围**: 37 个 Kotlin 源文件
|
||||
**审查技能**: 安全漏洞检测(注入、Secret 泄露、权限滥用、路径遍历)
|
||||
|
||||
---
|
||||
|
||||
## 严重程度分级说明
|
||||
|
||||
| 等级 | 定义 |
|
||||
|------|------|
|
||||
| CRITICAL | 可直接导致 root 提权、用户数据泄露或远程命令执行的漏洞。必须立即修复。 |
|
||||
| HIGH | 在特定条件下可导致敏感数据泄露或越权访问。应在下一版本修复。 |
|
||||
| MEDIUM | 安全风险较低,或需要复杂攻击链才能利用。建议规划修复。 |
|
||||
| LOW | 信息泄露风险极低,或设计上可接受但不够理想。可选修复。 |
|
||||
|
||||
---
|
||||
|
||||
## 发现汇总
|
||||
|
||||
| # | 严重程度 | 类别 | 文件 | 行号 |
|
||||
|---|----------|------|------|------|
|
||||
| 1 | **CRITICAL** | Secret 泄露 | `BackupConfig.kt` | 69, 73, toFile() |
|
||||
| 2 | **CRITICAL** | Secret 泄露 | `BackupConfig.kt` | toFile() |
|
||||
| 3 | **HIGH** | 认证缺失 | `ResticRestBridge.kt` | 27 |
|
||||
| 4 | **HIGH** | 路径遍历/越权写入 | `RestoreOperation.kt` | restoreData() |
|
||||
| 5 | **MEDIUM** | 命令注入(Sed) | `RestoreOperation.kt` | 250-253 |
|
||||
| 6 | **MEDIUM** | 信息泄露(Logcat) | `RootShell.kt` | 55 |
|
||||
| 7 | **MEDIUM** | 路径遍历 | `ResticRestBridge.kt` | 246-257 |
|
||||
| 8 | **MEDIUM** | 信息泄露(Logcat) | `ResticCommandRunner.kt` | 40-41 |
|
||||
| 9 | **MEDIUM** | 加密/安全存储 | `BackupConfig.kt` | 68-73 |
|
||||
| 10 | **LOW** | 缺少参数验证 | `AppScanner.kt` | 多处 |
|
||||
| 11 | **LOW** | SMB 签名关闭 | `SmbTransport.kt` | 26 |
|
||||
| 12 | **LOW** | 证书固定缺失 | `WebdavTransport.kt` | 22-28 |
|
||||
|
||||
---
|
||||
|
||||
## 详细发现
|
||||
|
||||
### 🔴 CRITICAL: 凭据明文存储在配置文件中
|
||||
|
||||
**文件**: `BackupConfig.kt` 第 69 行
|
||||
**文件**: `BackupConfig.kt` 第 73 行
|
||||
**文件**: `BackupConfig.kt` toFile() 方法
|
||||
|
||||
```kotlin
|
||||
// 第 69 行
|
||||
val resticPassword: String = "",
|
||||
// 第 73 行
|
||||
val resticBackendPass: String = "",
|
||||
```
|
||||
|
||||
**问题**: Restic 仓库密码、SMB/WebDAV 密码以明文形式存储在 `backup_settings.conf` 文件中。配置文件位于 `filesDir/backup_settings.conf`,在 root 权限下对任何进程可读。`toFile()` 方法(~第 156-157 行)将密码直接写入文件:
|
||||
|
||||
```kotlin
|
||||
appendLine("restic_password=\"${config.resticPassword}\"")
|
||||
appendLine("restic_backend_pass=\"${config.resticBackendPass}\"")
|
||||
```
|
||||
|
||||
此外,UI 中密码以明文显示和编辑(`ConfigFragment.kt` 第 151 行)。
|
||||
|
||||
**风险**: 任何具有 root 权限的进程(或通过漏洞获得 root 的恶意应用)可读取这些凭据。如果用户使用相同的 restic 密码保护多个设备,泄露范围会扩大。
|
||||
|
||||
**建议**:
|
||||
1. 使用 Android `EncryptedSharedPreferences` 存储密码(加密后存储在配置目录)
|
||||
2. 密码字段在 UI 中使用 `inputType="textPassword"` 隐藏显示
|
||||
3. 考虑使用 Android Keystore 进行密钥管理
|
||||
4. 配置文件设置为仅 app 自身可读(`MODE_PRIVATE`,但 root 环境下效果有限)
|
||||
|
||||
---
|
||||
|
||||
### 🔴 CRITICAL: 配置文件写入默认权限不安全
|
||||
|
||||
**文件**: `BackupConfig.kt` — `toFile()` 方法(~第 144 行)
|
||||
|
||||
```kotlin
|
||||
fun toFile(config: BackupConfig, file: File) {
|
||||
file.parentFile?.mkdirs()
|
||||
file.writeText(buildString { ... })
|
||||
}
|
||||
```
|
||||
|
||||
**问题**: `file.writeText()` 使用系统默认文件权限。在 Android 上,`filesDir` 中的文件默认模式为 `MODE_PRIVATE`,但 root 权限环境绕过此保护。此外没有任何文件权限的显式设置。
|
||||
|
||||
**建议**: 保存配置文件后显式设置权限:
|
||||
```kotlin
|
||||
file.setReadable(true, true) // owner-only readable
|
||||
file.setWritable(true, true) // owner-only writable
|
||||
```
|
||||
|
||||
考虑迁移到 Android KeyStore + EncryptedSharedPreferences。
|
||||
|
||||
---
|
||||
|
||||
### 🔴 HIGH: ResticRestBridge 绑定到所有网络接口且无认证
|
||||
|
||||
**文件**: `ResticRestBridge.kt` 第 27 行
|
||||
|
||||
```kotlin
|
||||
class ResticRestBridge(...) : NanoHTTPD(0) {
|
||||
```
|
||||
|
||||
**问题**: `NanoHTTPD(0)` 绑定到 `0.0.0.0`(所有网络接口),随机端口。而桥接 URL 使用的是 `127.0.0.1`(`RestBridgeRunner.kt` 第 72 行),但服务器本身对所有接口开放。该桥接提供无需任何认证的完整备份仓库读写访问(`GET`/`POST`/`DELETE` blob、`HEAD` 检查、`list` 操作)。
|
||||
|
||||
**风险**: 设备上任何进程(不需要 root)都可以扫描开放端口、连接到桥接,并读取或写入备份仓库。由于桥接在随机端口上运行且生命周期短暂,利用难度稍高但仍存在。
|
||||
|
||||
**建议**:
|
||||
1. 使用 `ServerSocket(0, 50, InetAddress.getByName("127.0.0.1"))` 或 NanoHTTPD 的 `bindAddr` 参数显式绑定到 localhost
|
||||
2. 添加认证令牌(restic REST API 支持 token 认证)
|
||||
3. 限制响应时间窗口,使用后立即删除 blob
|
||||
|
||||
---
|
||||
|
||||
### 🔴 HIGH: Tar 解压使用 `-C /` 可能导致系统文件覆写
|
||||
|
||||
**文件**: `RestoreOperation.kt` — `restoreData()` 方法(~第 137-149 行)
|
||||
|
||||
```kotlin
|
||||
val baseCmd = when {
|
||||
archive.name.endsWith(".zst") ->
|
||||
"set -o pipefail; $zstdCmd -d -c '$archivePath' | $tarCmd -xf - $excludeArgs -C / 2>/dev/null"
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
**问题**: 备份存档使用根目录 `/` 解压。`isArchiveSafe` 方法(~第 220-232 行)仅检查 `..` 路径穿越和指向外部的符号链接,但**不检查**:
|
||||
- 存档中的绝对路径条目(如 `/etc/passwd`、`/system/bin/app_process`)
|
||||
- 硬链接(可绕过 `..` 检查)
|
||||
- 设备节点
|
||||
- 解压总量(可用于磁盘空间耗尽攻击)
|
||||
|
||||
如果攻击者能够修改备份文件(例如通过恶意 App 访问外部存储),解压操作可覆写任意系统文件。
|
||||
|
||||
**建议**:
|
||||
1. 添加对绝对路径的检查 —— 拒绝包含 `/` 前缀路径(绝对路径)的存档
|
||||
2. 使用 `isArchiveSafe` 补充绝对路径检测:`line.startsWith("/")`
|
||||
3. 考虑使用 `--strip-components` 选项或在临时目录解压后再移动到目标路径
|
||||
4. 添加存档大小和解压条目数量上限
|
||||
|
||||
---
|
||||
|
||||
### 🟡 MEDIUM: SSAID 恢复中的 Sed 命令注入风险
|
||||
|
||||
**文件**: `RestoreOperation.kt` 第 250-253 行
|
||||
|
||||
```kotlin
|
||||
val manipCmd = buildString {
|
||||
append("sed -i \"/package.*${packageName.shellEscape()}/d\" '$targetFile' && ")
|
||||
append("sed -i \"s#</settings>#<setting id=\\\"$id\\\" package=\\\"${packageName.shellEscape()}\\\" value=\\\"${ssaidValue.shellEscape()}\\\" defaultValue=\\\"default\\\" />\\n</settings>#\" '$targetFile'")
|
||||
}
|
||||
```
|
||||
|
||||
**问题**: `ssaidValue` 和 `packageName` 虽然经过了 `shellEscape()`(处理单引号),但 Sed 模式中使用 `#` 作为分隔符。如果 `ssaidValue` 包含 `#`(UUID 不可能,但从文件读取的 SSAID 可能包含任意字符),会破坏 Sed 命令结构。此外,`shellEscape()` 只处理 shell 层的单引号,不处理 Sed 层的 `\`、`&`、`/` 等特殊字符。
|
||||
|
||||
**风险**: 若攻击者可通过修改 `ssaid.txt` 文件插入恶意 Sed 表达式,可能导致任意文件写入。
|
||||
|
||||
**建议**:
|
||||
1. 使用纯 Kotlin XML 解析(如 `XmlPullParser`)操作 `settings_ssaid.xml`,而不是 Sed
|
||||
2. 或使用 `sed -e` 的分隔符参数引用,并验证 `ssaidValue` 只包含十六进制字符
|
||||
|
||||
---
|
||||
|
||||
### 🟡 MEDIUM: RootShell 启用了 libsu 详细日志
|
||||
|
||||
**文件**: `RootShell.kt` 第 55 行
|
||||
|
||||
```kotlin
|
||||
Shell.enableVerboseLogging = true
|
||||
```
|
||||
|
||||
**问题**: libsu 的详细日志会将所有 shell 命令输出到 Logcat。Logcat 在 Android 上对任何具有 `READ_LOGS` 权限的应用可读。这可能导致命令路径、参数、错误消息等信息泄露。
|
||||
|
||||
**风险**: 调试期间有助于开发,但生产版本应禁用。命令本身不包含密码(通过环境变量传递),但路径结构和目录名可能暴露敏感信息。
|
||||
|
||||
**建议**:
|
||||
1. 根据构建类型控制日志级别:
|
||||
```kotlin
|
||||
Shell.enableVerboseLogging = BuildConfig.DEBUG
|
||||
```
|
||||
2. 或完全移除该行
|
||||
|
||||
---
|
||||
|
||||
### 🟡 MEDIUM: ResticRestBridge JSON 手动拼接存在注入风险
|
||||
|
||||
**文件**: `ResticRestBridge.kt` 第 246-257 行
|
||||
|
||||
```kotlin
|
||||
private fun buildV2Json(items: List<RemoteTransport.RemoteFileInfo>): String {
|
||||
val sb = StringBuilder("[")
|
||||
var first = true
|
||||
for (item in items) {
|
||||
...
|
||||
sb.append("{\"name\":\"${item.name}\",\"size\":${item.size}}")
|
||||
}
|
||||
sb.append("]")
|
||||
return sb.toString()
|
||||
}
|
||||
```
|
||||
|
||||
**问题**: 文件名 `item.name` 直接插值到 JSON 字符串中。若远程存储上的文件名包含 `"`、`\\`、`\n` 等字符,会破坏 JSON 结构,可能导致解析错误或意外的数据暴露。
|
||||
|
||||
**风险**: 文件名来自远程存储(SMB/WebDAV),攻击者可能控制这些名称。返回给 restic 的损坏 JSON 可能导致备份操作失败或状态误报。
|
||||
|
||||
**建议**: 使用 `kotlinx-serialization` 或 `JSONArray` 构建 JSON。
|
||||
|
||||
---
|
||||
|
||||
### 🟡 MEDIUM: ResticCommandRunner 日志暴露仓库 URL
|
||||
|
||||
**文件**: `ResticCommandRunner.kt` 第 40-41 行
|
||||
|
||||
```kotlin
|
||||
Log.d(TAG, "runRestic REPOSITORY=${env["RESTIC_REPOSITORY"]}")
|
||||
```
|
||||
|
||||
**问题**: 虽然代码注释正确指出 `RESTIC_PASSWORD` 不应记录,`RESTIC_REPOSITORY` 仍可能包含 SMB 共享名称、仓库路径等敏感信息。Logcat 可被其他应用读取。
|
||||
|
||||
**建议**: 至少将敏感部分截断或哈希,或仅在 DEBUG 构建下记录。
|
||||
|
||||
---
|
||||
|
||||
### 🟡 MEDIUM: 多个凭据未加密存储在内存中
|
||||
|
||||
**文件**: `BackupConfig.kt` 第 68-73 行
|
||||
|
||||
```kotlin
|
||||
val resticPassword: String = "",
|
||||
val resticBackendUser: String = "",
|
||||
val resticBackendPass: String = "",
|
||||
```
|
||||
|
||||
**问题**: `BackupConfig` 作为 `@Serializable data class`,所有密码字段在进程生命周期内以不可变字符串形式保存在内存中。字符串在 Java 中不可变,无法显式清除(零覆盖)。
|
||||
|
||||
此外,`ResticWrapper` 的所有公开 API 方法都将密码作为方法参数传递,导致 Activity/Fragment/ViewModel 中密码的副本散布各处。
|
||||
|
||||
**建议**:
|
||||
1. 通过值对象传递密码,操作完成后立即清除
|
||||
2. 考虑使用 `CharArray` 并在使用后填充空白
|
||||
3. 在传递之间最小化密码在 Kotlin 对象图中的驻留时间
|
||||
|
||||
---
|
||||
|
||||
### 🟢 LOW: AppScanner 中 userId 参数缺少非负验证
|
||||
|
||||
**文件**: `AppScanner.kt` 多处
|
||||
|
||||
```kotlin
|
||||
suspend fun scanThirdParty(context: Context, userId: Int = 0): List<AppInfo> = withContext(Dispatchers.IO) {
|
||||
val result = RootShell.exec("pm list packages -3 --user $userId")
|
||||
```
|
||||
|
||||
**问题**: `userId` 虽然类型为 `Int`,直接插值到 shell 命令中。如果传入负数(如 -1),可能导致意外行为。但 `userId` 来自 Spinner 选择或 `UserId` 值类(已验证非负),因此实际风险很低。
|
||||
|
||||
**建议**: 在 UI 层、`UserId` 值类或 `AppScanner` 入口处增加正数验证。
|
||||
|
||||
---
|
||||
|
||||
### 🟢 LOW: SMB 传输默认关闭签名/加密
|
||||
|
||||
**文件**: `SmbTransport.kt` 第 26 行
|
||||
|
||||
```kotlin
|
||||
private val smbSigning: Boolean = false
|
||||
```
|
||||
|
||||
**问题**: SMB 签名和加密默认禁用。在不安全的网络中,攻击者可进行 SMB 中继攻击。代码注释说明"多数家庭服务器不支持",是合理的取舍。
|
||||
|
||||
**建议**: 在 UI 配置页面添加 SMB 签名开关,让用户根据网络环境决定。
|
||||
|
||||
---
|
||||
|
||||
### 🟢 LOW: WebDAV 传输缺少证书固定
|
||||
|
||||
**文件**: `WebdavTransport.kt` 第 22-28 行
|
||||
|
||||
```kotlin
|
||||
private val sardine: Sardine by lazy {
|
||||
OkHttpSardine().apply {
|
||||
if (username.isNotEmpty()) {
|
||||
setCredentials(username, password)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**问题**: `OkHttpSardine` 使用默认的 HTTPS 配置,没有自定义证书验证或证书固定(Certificate Pinning)。中间人攻击(MITM)可窃取 WebDAV 的备份凭据。
|
||||
|
||||
**建议**: 对于重视安全的场景,可选支持证书固定,或在 UI 中显示当前 HTTPS 证书指纹。
|
||||
|
||||
---
|
||||
|
||||
## 正向发现(设计良好的安全实践)
|
||||
|
||||
| 实践 | 文件 | 说明 |
|
||||
|------|------|------|
|
||||
| ✅ 密码通过环境变量传递 | `ResticEnvResolver.kt` | RESTIC_PASSWORD 通过 env 传递,不在命令行中出现 |
|
||||
| ✅ `shellEscape()` 一致使用 | `root/RootShell.kt:15` | 所有 shell 拼接参数都经过了转义 |
|
||||
| ✅ `execSafe()` 安全方法 | `root/RootShell.kt:95-101` | 提供自动参数转义的执行方法 |
|
||||
| ✅ ProcessBuilder 列表参数 | `ResticCommandRunner.kt` | 使用 List<String> 参数,无 shell 拼接 |
|
||||
| ✅ `isArchiveSafe()` 路径穿越检查 | `RestoreOperation.kt:220-232` | 解压前检查 `..` 和危险符号链接 |
|
||||
| ✅ 类型安全的值类 | `DomainTypes.kt` | `PackageName` 和 `UserId` 提供编译期类型安全 |
|
||||
| ✅ 定时命令超时 | `RootShell.kt:26` | 120 秒超时防止命令挂死 |
|
||||
| ✅ 取消传播 | 多处 | CancellationException 正确重新抛出 |
|
||||
| ✅ RESTIC_PASSWORD 不记录日志 | `ResticCommandRunner.kt:42` | 明确注释不记录密码 |
|
||||
|
||||
---
|
||||
|
||||
## 风险优先级建议
|
||||
|
||||
### 立即修复 (CRITICAL)
|
||||
1. **BackupConfig.kt**: 使用 `EncryptedSharedPreferences` 替换明文配置存储
|
||||
2. **BackupConfig.kt**: 保存后设置文件权限为 `MODE_PRIVATE`
|
||||
|
||||
### 下一版本修复 (HIGH)
|
||||
3. **ResticRestBridge.kt**: 绑定到 127.0.0.1 而非 0.0.0.0
|
||||
4. **RestoreOperation.kt**: `isArchiveSafe` 增加绝对路径检查;解压到临时目录再移动
|
||||
|
||||
### 规划修复 (MEDIUM)
|
||||
5. **RestoreOperation.kt**: SSAID XML 操作改为 XML 解析器而非 Sed
|
||||
6. **ResticRestBridge.kt**: JSON 改用序列化库构建
|
||||
7. **RootShell.kt**: 生产环境禁用详细日志
|
||||
8. **ResticCommandRunner.kt**: 截断或保护仓库 URL 日志
|
||||
|
||||
### 可选改进 (LOW)
|
||||
9. **SmbTransport.kt**: 考虑默认启用 SMB 签名
|
||||
10. **WebdavTransport.kt**: 可选证书固定支持
|
||||
11. **AppScanner.kt**: 添加 userId 验证
|
||||
|
||||
---
|
||||
|
||||
*注意: 本报告未包含 `memory://root/memory_summary.md` 中记录的 7 个已知待处理项。*
|
||||
484
silent-failure-review.md
Normal file
484
silent-failure-review.md
Normal file
@@ -0,0 +1,484 @@
|
||||
# 静默失败审查报告 — android-backup-gui
|
||||
|
||||
> 审查日期: 2026-06-06
|
||||
> 审查范围: 37 个 Kotlin 源文件
|
||||
> 已排除: memory://root 已知的 7 个待处理项
|
||||
|
||||
---
|
||||
|
||||
## 严重程度分级
|
||||
|
||||
- **CRITICAL**: 数据静默损坏或丢失,用户无法感知
|
||||
- **HIGH**: 错误被吞没,导致后续操作基于错误假设继续
|
||||
- **MEDIUM**: 错误被吞没但影响范围有限,或仅影响辅助功能
|
||||
- **LOW**: 微小错误处理缺失,实际影响小
|
||||
|
||||
---
|
||||
|
||||
## 发现清单
|
||||
|
||||
### F1 [HIGH] — SMB 上传大小不匹配不报告错误
|
||||
|
||||
**文件**: `SmbTransport.kt:103-109`
|
||||
**类型**: 未检查的返回值 / 静默数据损坏
|
||||
|
||||
```kotlin
|
||||
if (actualSize != fileSize) {
|
||||
Log.w(TAG, "upload size mismatch: local=$fileSize smb=$actualSize")
|
||||
SmbFileOutputStream(remote).use { it.write(ByteArray(0)) }
|
||||
val retrySize = freshRemote.length()
|
||||
Log.w(TAG, "upload retry: smb=$retrySize bytes")
|
||||
}
|
||||
// 继续返回 Success(Unit)
|
||||
```
|
||||
|
||||
即使 SMB 端实际存储的字节数与本地不一致,`upload()` 仍返回 `AppResult.Success(Unit)`。写入零字节空数组的"修复"尝试没有验证效果。如果 SMB 服务器写入缓存有问题或磁盘空间不足,restic blob 数据可能部分损坏,而上层调用者 (`RestBridgeRunner`) 不知道。
|
||||
|
||||
**建议**: 当 `actualSize != fileSize` 时,应返回 `err(AppError.Remote("SMB 上传大小不匹配: local=$fileSize vs smb=$actualSize", "upload"))`。
|
||||
|
||||
---
|
||||
|
||||
### F2 [HIGH] — backupUserData 全失败时返回成功
|
||||
|
||||
**文件**: `BackupOperation.kt:255-257`
|
||||
**类型**: 错误替换/空回退
|
||||
|
||||
```kotlin
|
||||
if (!archiveCreated) {
|
||||
Log.w(TAG, "backupUserData: $packageName all methods failed ...")
|
||||
return true // 返回成功!
|
||||
}
|
||||
```
|
||||
|
||||
当三种数据备份方法全部失败时(目录不存在、权限不足、tar 不可用),函数返回 `true`。上层调用者 (`BackupOperation.backupApps` line 131) 看到 `true` 就认为数据备份成功,累加 `successAtomic`,用户看到的报告就是"成功"。应用的用户数据被静默跳过。
|
||||
|
||||
**建议**: 改为 `return false` 让调用者知道数据备份实际失败。如果需要容错(某些应用确实没有数据目录),应在 `backupUserData` 外部判断,或返回区分"跳过"和"失败"的信号。
|
||||
|
||||
---
|
||||
|
||||
### F3 [HIGH] — CancellationException 被空 catch 吞没
|
||||
|
||||
**文件**: `ResticBackup.kt:55-58`, `ResticBackup.kt:73-77`, `ResticBackup.kt:117-120`, `ResticBackup.kt:130-134`
|
||||
**类型**: 异步错误丢失
|
||||
|
||||
```kotlin
|
||||
try {
|
||||
val progress = resticJson.decodeFromString<ResticWrapper.ResticProgress>(line)
|
||||
if (progress.messageType == "status") emit(progress)
|
||||
} catch (_: Exception) { }
|
||||
```
|
||||
|
||||
`catch (_: Exception)` 会捕获 `kotlinx.coroutines.CancellationException`。如果协程在 JSON 解析期间被取消,取消信号被吞没,进度回调继续运行。虽然在 `runResticStreaming`/`runResticWithStdin` 内部也有协程活跃检查(`!coroutineContext.isActive`),但取消信号仍可能延迟或丢失。
|
||||
|
||||
**建议**: 在空 catch 前加 `catch (e: CancellationException) { throw e }`,或改用 `catch (e: Exception) { if (e is CancellationException) throw e }`。
|
||||
|
||||
---
|
||||
|
||||
### F4 [HIGH] — WebDAV mkdirs 完全失败时仍返回成功
|
||||
|
||||
**文件**: `WebdavTransport.kt:153-155`
|
||||
**类型**: 错误替换
|
||||
|
||||
```kotlin
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "mkdirs failed: $remotePath — ${e.message}")
|
||||
AppResult.Success(Unit) // best-effort
|
||||
}
|
||||
```
|
||||
|
||||
即使所有目录层级都无法创建,该方法返回 `AppResult.Success(Unit)`。注释说"upload will fail if dir can't be created",但上传可能在更深层的操作上以不同的错误信息失败(如"permission denied" vs "directory not found"),使诊断更加困难。上层调用者无法区分"目录已存在"和"完全无法创建"。
|
||||
|
||||
**建议**: 仅在确定目录确实存在时返回 Success(如 SMB 实现中检测 `STATUS_OBJECT_NAME_COLLISION`)。对所有其他异常应返回 `err(AppError.Remote(...))`。
|
||||
|
||||
---
|
||||
|
||||
### F5 [MEDIUM] — WifiManager.restore 始终返回 true
|
||||
|
||||
**文件**: `WifiManager.kt:54-85`
|
||||
**类型**: 错误替换
|
||||
|
||||
整个 `restore()` 方法始终返回 `true`(line 84),即使:
|
||||
- `findWifiConfigPath()` 返回 null 且 fallback 路径无法创建目录(line 63-64 返回 false,但被统一 return@withContext false 处理... 等等这里 line 84 是最后一行)
|
||||
- 实际上 line 63 `return@withContext false` 确实会提前返回。但如果成功执行到 line 84,无论如何都返回 true。中间 `cp`、`chown`、`chmod` 的失败仅被记录日志,不通知调用者。
|
||||
|
||||
**建议**: `cp` 或 `chmod`/`chown` 失败时应返回 false。当前 RestoreFragment 中 `WifiManager.restore(dir)` 的返回值没有被使用,但接口应该诚实。
|
||||
|
||||
---
|
||||
|
||||
### F6 [MEDIUM] — ResticWrapper.getLatestSnapshotAppDetails 静默返回 null
|
||||
|
||||
**文件**: `ResticWrapper.kt:270-275`, `ResticWrapper.kt:288`
|
||||
**类型**: 空回退
|
||||
|
||||
```kotlin
|
||||
is AppResult.Failure -> {
|
||||
Log.w(TAG, "getLatestSnapshotAppDetails: listSnapshots failed: ...")
|
||||
null
|
||||
}
|
||||
// 和
|
||||
is AppResult.Failure -> return@withContext null
|
||||
```
|
||||
|
||||
当 `listSnapshots()` 或 `dump()` 失败时返回 `null`。调用者 (`BackupFragment.kt:228`) 仅检查 `snapshotApps != null`,看到 null 就跳过累积快照逻辑。用户不知道仓库存在但无法读取——也可能是仓库密码错误、网络问题或权限问题。但此行为在 API 文档中有意说明,且后续备份仍能工作,只是失去了累积合并能力。
|
||||
|
||||
**建议**: 考虑返回 `AppResult<Map<String, SnapshotAppInfo>?>` 以区分"无快照"和"读取失败"。或者增加 UI 通知。
|
||||
|
||||
---
|
||||
|
||||
### F7 [MEDIUM] — parseAppDetailsJson 捕获所有异常
|
||||
|
||||
**文件**: `ResticWrapper.kt:315-317`
|
||||
**类型**: 被吞没的异常
|
||||
|
||||
```kotlin
|
||||
} catch (_: Exception) {
|
||||
Log.w(TAG, "parseAppDetailsJson: failed to parse JSON")
|
||||
}
|
||||
```
|
||||
|
||||
捕获所有 `Exception` 类型(包括 `CancellationException`、`OutOfMemoryError` 等)。虽然当前函数在 `Dispatchers.IO` 上下文外的同步路径调用,但应该缩小异常范围。
|
||||
|
||||
**建议**: 改为 `catch (e: org.json.JSONException)`。
|
||||
|
||||
---
|
||||
|
||||
### F8 [MEDIUM] — StreamingBackup mkfifo 失败不报告
|
||||
|
||||
**文件**: `StreamingBackup.kt:50`
|
||||
**类型**: 未检查的返回值
|
||||
|
||||
```kotlin
|
||||
RootShell.exec("mkfifo '${fifo.absolutePath.shellEscape()}'")
|
||||
```
|
||||
|
||||
`mkfifo` 的执行结果完全被忽略。如果 `mkfifo` 失败(例如文件系统只读、磁盘满),FIFO 文件不存在,后续 `restic backup --stdin` 会以模糊的错误失败。`StreamingBackup.prepareStreaming` 返回的 `StreamingResult` 将包含无效的 FIFO 路径。调用者在 `BackupFragment.kt:484` 直接使用结果,没有验证 FIFO 是否创建成功。
|
||||
|
||||
**建议**: 检查结果并抛出异常或返回失败信号。
|
||||
|
||||
---
|
||||
|
||||
### F9 [MEDIUM] — BackupFragment 中 restore 操作结果被忽略
|
||||
|
||||
**文件**: `ui/BackupFragment.kt:274-284`
|
||||
**类型**: 未检查的返回值
|
||||
|
||||
```kotlin
|
||||
ResticWrapper.restore(
|
||||
repoPath = config.resticRepo,
|
||||
password = config.resticPassword,
|
||||
snapshotId = latestSnap.shortId,
|
||||
targetPath = backupRoot.absolutePath,
|
||||
...
|
||||
)
|
||||
```
|
||||
|
||||
在累积备份流程中,从 restic 仓库恢复最新快照到本地暂存目录的结果完全被忽略。如果恢复失败(例如密码错误、网络中断),`backupRoot` 目录可能不完整,但备份操作继续执行。后续 `BackupOperation.backupApps` 可能会基于不完整的文件结构工作。
|
||||
|
||||
**建议**: 检查 `restore()` 的 `AppResult`,如果失败则终止备份流程并通知用户。
|
||||
|
||||
---
|
||||
|
||||
### F10 [MEDIUM] — StreamingBackup.launchDataProducer 的 tar 失败仅记录日志
|
||||
|
||||
**文件**: `StreamingBackup.kt:116-118`
|
||||
**类型**: 被吞没的异常
|
||||
|
||||
```kotlin
|
||||
if (!result.isSuccess) {
|
||||
Log.w(TAG, "Data backup failed for $pkgName: ${result.error}")
|
||||
}
|
||||
```
|
||||
|
||||
单个应用的 tar 数据备份失败时仅记录日志,继续下一个应用。调用者 (`BackupFragment.kt:534`) 通过 `producerJob.await()` 等待完成,但该函数总是返回 `true`(除非协程被取消)。这意味着即使某些应用的数据完全没有备份,调用者也认为一切正常。restic 对缺失数据无法感知——它只归档了 FIFO 中收到的内容。
|
||||
|
||||
**建议**: 收集失败列表并通过返回值或回调通知调用者。
|
||||
|
||||
---
|
||||
|
||||
### F11 [MEDIUM] — RestBridgeRunner 中未识别的后端静默穿透
|
||||
|
||||
**文件**: `RestBridgeRunner.kt:61`
|
||||
**类型**: 空回退
|
||||
|
||||
```kotlin
|
||||
val t = transportFactory(...)
|
||||
?: return block(repoPath)
|
||||
```
|
||||
|
||||
当 `RemoteTransport.create()` 返回 `null`(未知 backend),代码直接调用 `block(repoPath)`,其中 `repoPath` 是原始路径字符串而不是桥接 URL。restic 会收到一个可能无效的仓库 URL,产生令人困惑的错误("repository doesn't exist" 而不是"未知后端类型")。
|
||||
|
||||
**建议**: 至少记录一个错误,或抛出异常说明后端类型不支持。
|
||||
|
||||
---
|
||||
|
||||
### F12 [MEDIUM] — SMB listFiles 在无权限时静默返回空列表
|
||||
|
||||
**文件**: `SmbTransport.kt:165-186`
|
||||
**类型**: 空回退
|
||||
|
||||
```kotlin
|
||||
val entries = dir.listFiles()
|
||||
?.map { f -> ... }
|
||||
?: emptyList()
|
||||
```
|
||||
|
||||
`SmbFile.listFiles()` 在 SMB 权限不足时可能返回 `null`。此时 `?: emptyList()` 将静默返回空列表。调用者可能认为路径是空的,而不是没有读取权限。SMB 协议可以在 `SmbException` 中返回具体的 ntStatus 错误,但这里的 null 合并将错误掩盖了。
|
||||
|
||||
**建议**: 在 `else` 分支或 `catch` 中检查文件是否确实存在,如果存在但 listFiles 返回 null,应返回错误而非空列表。
|
||||
|
||||
---
|
||||
|
||||
### F13 [MEDIUM] — backupPermissions 静默跳过
|
||||
|
||||
**文件**: `BackupOperation.kt:349-354`
|
||||
**类型**: 被吞没的异常
|
||||
|
||||
```kotlin
|
||||
private suspend fun backupPermissions(packageName: String, appDir: File) {
|
||||
val result = RootShell.exec("dumpsys package ...")
|
||||
if (result.output.isNotBlank()) {
|
||||
File(appDir, "permissions.txt").writeText(result.output)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
如果 `dumpsys package` 命令失败或输出为空,权限备份静默跳过。`backupApps` 在 line 163 调用此函数时不检查结果,也不记录错误。恢复时将没有权限文件,应用以默认权限运行。
|
||||
|
||||
**建议**: 至少在 `dumpsys` 命令失败时记录日志。考虑返回 `Boolean` 让调用者知晓。
|
||||
|
||||
---
|
||||
|
||||
### F14 [MEDIUM] — backupSsaid 静默跳过
|
||||
|
||||
**文件**: `BackupOperation.kt:331-347`
|
||||
**类型**: 被吞没的异常
|
||||
|
||||
```kotlin
|
||||
private suspend fun backupSsaid(packageName: String, appDir: File, userId: String) {
|
||||
val result = RootShell.exec("cat '$ssaidFile' 2>/dev/null")
|
||||
if (!result.isSuccess || result.output.isBlank()) return
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
如果 XML 文件无法读取或解析失败,SSAID 备份完全静默跳过。SSAID 是 Google 广告标识符,丢失后用户可能收到新的 ID。
|
||||
|
||||
**建议**: 在 cat 命令失败时记录警告日志。
|
||||
|
||||
---
|
||||
|
||||
### F15 [MEDIUM] — initResticRepo 使用 exceptionOrNull 可能导致 null 显示
|
||||
|
||||
**文件**: `ui/ConfigViewModel.kt:205-207`
|
||||
**类型**: 错误替换
|
||||
|
||||
```kotlin
|
||||
Log.e(TAG, "initResticRepo failed: ${result.exceptionOrNull()?.message}")
|
||||
_uiState.update { it.copy(resticStatus = it.resticStatus.copy(
|
||||
message = "初始化失败: ${result.exceptionOrNull()?.message}"
|
||||
))}
|
||||
```
|
||||
|
||||
`AppResult.exceptionOrNull()` 创建一个新的 `RuntimeException`,如果原始 `AppError.message` 为 null(例如 `AppError.Restic("", -1, "")`),用户将看到 "初始化失败: null"。
|
||||
|
||||
**建议**: 使用 `${result.errorOrNull()?.message ?: "未知错误"}`。
|
||||
|
||||
---
|
||||
|
||||
### F16 [MEDIUM] — RestBridgeRunner 中临时文件删除结果未检查
|
||||
|
||||
**文件**: `RestBridgeRunner.kt:85-88`
|
||||
**类型**: 资源泄露
|
||||
|
||||
```kotlin
|
||||
val blobs = cacheDir.listFiles { f -> f.name.startsWith("restic_blob_") }
|
||||
if (blobs != null) {
|
||||
for (f in blobs) f.delete()
|
||||
}
|
||||
```
|
||||
|
||||
临时 blob 文件删除的结果未检查,且 `listFiles` 筛选器可能遗漏子目录中的临时文件(如 `ResticRestBridge` 在 `cacheDir` 中创建 `restic_blob_*` 文件)。随着操作频繁进行,可能累积未清理的临时文件。
|
||||
|
||||
**建议**: 使用 `f.delete()` 的返回值进行日志记录,并考虑递归清理。
|
||||
|
||||
---
|
||||
|
||||
### F17 [LOW] — RootShell.ensureSession 静默返回 false
|
||||
|
||||
**文件**: `root/RootShell.kt:63-67`
|
||||
**类型**: 被吞没的异常
|
||||
|
||||
```kotlin
|
||||
suspend fun ensureSession(): Boolean = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
Shell.getShell().isRoot
|
||||
} catch (_: Exception) { false }
|
||||
}
|
||||
```
|
||||
|
||||
如果 `Shell.getShell()` 抛出任何异常(包括 `NullPointerException`、`RuntimeException`),静默返回 `false`。调用者无法区分"没有 root 权限"和"libsu 未初始化或其他错误"。
|
||||
|
||||
**建议**: 记录异常。可以考虑区分不同类型的失败。
|
||||
|
||||
---
|
||||
|
||||
### F18 [LOW] — AppScanner 多项查询静默失败
|
||||
|
||||
**文件**: `AppScanner.kt:41,53,96`
|
||||
**类型**: 空回退
|
||||
|
||||
```kotlin
|
||||
if (!result.isSuccess) return@withContext emptyList()
|
||||
```
|
||||
|
||||
`scanThirdParty`、`scanSystem`、`getApkPaths` 在 shell 命令失败时返回空列表。如果 `pm list packages` 因为 root 权限临时问题失败,用户看到的应用列表为空,但没有任何错误提示。
|
||||
|
||||
**建议**: 在 UI 层调用前检查返回的空列表并显示适当消息(已在 `BackupFragment.scanApps()` 中捕获异常,但 shell 层面的失败可能被漏过)。
|
||||
|
||||
---
|
||||
|
||||
### F19 [LOW] — backupUserData tar 命令可能静默失败
|
||||
|
||||
**文件**: `BackupOperation.kt:228`
|
||||
**类型**: 未检查的返回值
|
||||
|
||||
```kotlin
|
||||
val dirs = dataPaths.filter { RootShell.exec("test -d '${it.shellEscape()}'").isSuccess }.toMutableList()
|
||||
```
|
||||
|
||||
`test -d` 在 nsenter namespace 切换后可能对某些路径返回假阴性。如果所有 `test -d` 都失败,`dirs` 为空列表,代码会转到 `else` 分支(line 234-238)尝试直接运行 tar,而 tar 也会因为没有源路径而静默失败或产生空归档。此时 `archiveCreated` 保持 false,进入 line 255 的 fallback 处理——但这个 fallback 返回 true(见 F2)。
|
||||
|
||||
**建议**: 如果 `dirs` 为空且 tar 直接执行也未产生输出,应返回明确的失败信号。
|
||||
|
||||
---
|
||||
|
||||
### F20 [LOW] — WifiManager.backup 结果未在 BackupOperation 中检查
|
||||
|
||||
**文件**: `ui/BackupFragment.kt:310`
|
||||
**类型**: 未检查的返回值
|
||||
|
||||
```kotlin
|
||||
WifiManager.backup(File(result.outputDir))
|
||||
```
|
||||
|
||||
WiFi 配置备份的结果完全被忽略。如果 WiFi 备份失败,用户不会收到任何通知。`WifiManager.backup()` 可以返回 `null`(失败时),但调用者没有使用返回值。
|
||||
|
||||
**建议**: 至少记录结果,考虑在最终摘要中显示 WiFi 备份状态。
|
||||
|
||||
---
|
||||
|
||||
### F21 [LOW] — estimateBackupSize 忽略 du 错误
|
||||
|
||||
**文件**: `ui/BackupFragment.kt:440-449`
|
||||
**类型**: 未检查的返回值
|
||||
|
||||
```kotlin
|
||||
val result = RootShell.exec("du -sb /data/data/$pkgEsc 2>/dev/null | cut -f1")
|
||||
val size = result.output.trim().toLongOrNull() ?: 0L
|
||||
```
|
||||
|
||||
如果 `du` 命令失败、输出为空或解析失败,该应用的估计大小为 0。最终的空间估算可能严重偏低(仅用于判断是否需要流式备份),可能导致本应触发流式备份的大数据集使用暂存模式。
|
||||
|
||||
**建议**: 考虑使用保守的默认值或根据应用大小粗略估算。
|
||||
|
||||
---
|
||||
|
||||
### F22 [LOW] — BackupOperation 中 chmod 结果未检查
|
||||
|
||||
**文件**: `BackupOperation.kt:173`
|
||||
**类型**: 未检查的返回值
|
||||
|
||||
```kotlin
|
||||
RootShell.exec("chmod -R 0755 '${backupRoot.absolutePath}'")
|
||||
```
|
||||
|
||||
备份完成后设置目录权限的结果未检查。虽然不影响备份数据的完整性,但如果 `chmod` 失败,后续读取备份的用户可能会遇到权限问题。
|
||||
|
||||
**建议**: 至少记录 `chmod` 失败日志。
|
||||
|
||||
---
|
||||
|
||||
### F23 [LOW] — BackupFragment 中前台服务启动异常被吞没
|
||||
|
||||
**文件**: `ui/BackupFragment.kt:193-195`
|
||||
**类型**: 被吞没的异常
|
||||
|
||||
```kotlin
|
||||
try {
|
||||
ContextCompat.startForegroundService(requireContext(), serviceIntent)
|
||||
} catch (_: Exception) {}
|
||||
```
|
||||
|
||||
如果前台服务启动失败(例如缺少权限、应用在后台),异常被完全吞没。备份操作仍然继续,但进程可能被 Android 杀死。
|
||||
|
||||
**建议**: 记录异常,考虑通知用户服务启动失败。
|
||||
|
||||
---
|
||||
|
||||
### F24 [LOW] — ConfigFragment 中 OperationEvent 的 InitFailed/PruneFailed 不显示错误详情
|
||||
|
||||
**文件**: `ui/ConfigFragment.kt:157-159`
|
||||
**类型**: 错误替换
|
||||
|
||||
```kotlin
|
||||
is OperationEvent.InitFailed -> {
|
||||
Log.d(TAG, "init failed")
|
||||
Snackbar.make(binding.root, "仓库初始化失败", Snackbar.LENGTH_SHORT).show()
|
||||
}
|
||||
```
|
||||
|
||||
InitFailed/PruneFailed 事件不携带错误详情,用户只看到"初始化失败"/"清理失败",不知道具体原因。实际错误消息在 ViewModel 的 `resticStatus.message` 中已经设置,但 UI 没有在 snackbar 中使用它。
|
||||
|
||||
**建议**: 从 ViewModel 状态读取错误详情并在 snackbar 中显示,或让 OperationEvent 携带错误消息。
|
||||
|
||||
---
|
||||
|
||||
### F25 [LOW] — RestBridgeRunner 中缓存传输不被清理
|
||||
|
||||
**文件**: `RestBridgeRunner.kt:58-63`
|
||||
**类型**: 资源泄露
|
||||
|
||||
```kotlin
|
||||
if (cachedTransportKey != key) {
|
||||
cachedTransport?.let { Log.d(TAG, "discarding stale cached transport") }
|
||||
val t = transportFactory(...)
|
||||
...
|
||||
cachedTransport = t
|
||||
cachedTransportKey = key
|
||||
}
|
||||
```
|
||||
|
||||
当缓存键变化时,旧的 `cachedTransport`(SMB 会话或 WebDAV client)被直接丢弃而不关闭。对于 `SmbTransport`,内部的 `CIFSContext` 和 jcifs-ng 连接可能保持打开,直到 GC 触发 finalizer。对于 `WebdavTransport`,OkHttp 客户端可能保持连接池和线程。
|
||||
|
||||
**建议**: 如果 `RemoteTransport` 接口添加 `close()` 方法,在替换缓存时调用。
|
||||
|
||||
---
|
||||
|
||||
## 分类统计
|
||||
|
||||
| 严重程度 | 数量 | 关键文件 |
|
||||
|---|---|---|
|
||||
| HIGH | 4 | SmbTransport, BackupOperation, ResticBackup, WebdavTransport |
|
||||
| MEDIUM | 12 | ResticWrapper(2), StreamingBackup(2), BackupFragment, WifiManager, RestBridgeRunner, SmbTransport, BackupOperation(2), ConfigViewModel, AppScanner |
|
||||
| LOW | 9 | RootShell, AppScanner(3), BackupFragment(3), BackupOperation, ConfigFragment, RestBridgeRunner |
|
||||
|
||||
**发现总数**: 25
|
||||
|
||||
---
|
||||
|
||||
## 总结与优先修复建议
|
||||
|
||||
### 必须修复 (HIGH)
|
||||
1. **F1** (`SmbTransport.kt:103-109`) — SMB 上传后大小校验失败应返回错误,而非静默继续
|
||||
2. **F2** (`BackupOperation.kt:255-257`) — `backupUserData` 全方式失败时返回 `true` 是在告知上层"数据已备份"
|
||||
3. **F3** (`ResticBackup.kt:55-58` 等) — 进度回调中的空 catch 吞没 `CancellationException`,需添加重新抛出
|
||||
4. **F4** (`WebdavTransport.kt:153-155`) — `mkdirs` 完全失败返回 Success 是错误替换
|
||||
|
||||
### 高优先级 (MEDIUM)
|
||||
- **F10** — `StreamingBackup.launchDataProducer` 不传播 tar 错误
|
||||
- **F12** — `SmbTransport` listFiles 返回 null 时可能是权限问题
|
||||
- **F13/F14** — `backupPermissions`/`backupSsaid` 静默跳过
|
||||
- **F8** — `StreamingBackup.mkfifo` 结果未检查
|
||||
|
||||
### 建议
|
||||
整个代码库中使用 `catch (_: Exception)` 的模式需要系统性审查:应在所有协程 lambda 中的空 catch 前加 `catch (e: CancellationException) { throw e }`。关键入口点(如 `ResticBackup.kt:58`)已有 `CancellationException` 被吞没的问题。
|
||||
Reference in New Issue
Block a user