Compare commits
97 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 | ||
|
|
7fec4c52a1 | ||
|
|
32182b592e | ||
|
|
bb7dc9a700 | ||
|
|
b01569416d | ||
|
|
26823fcb6f | ||
|
|
6f6549d897 | ||
|
|
c10505fc10 | ||
|
|
7e98e0f78e | ||
|
|
922a8f0381 | ||
|
|
5fcf261025 | ||
|
|
14b914252e | ||
|
|
c01428b866 | ||
|
|
51fe8e22c0 | ||
|
|
f5dd61a83b | ||
|
|
40f03e5bad | ||
|
|
45f7af00b8 | ||
|
|
7ef0b2c9da | ||
|
|
6fa15af565 | ||
|
|
6cdad04905 | ||
|
|
5cbd21577b | ||
|
|
1bae01de72 | ||
|
|
e710c36ee2 | ||
|
|
c1bbef4eef | ||
|
|
4c4542e059 | ||
|
|
ef78ab8bec | ||
|
|
a38a483c70 | ||
|
|
d0bfef41c8 | ||
|
|
0bde3b0a75 | ||
|
|
d2ea9f532f | ||
|
|
ac0fd8b063 | ||
|
|
fde6d05b83 | ||
|
|
2ff096ee8a | ||
|
|
eae7f4b369 | ||
|
|
420d960ce1 | ||
|
|
88e81e956c | ||
|
|
c12f7a9c81 | ||
|
|
a43638698c | ||
|
|
12fe29f841 | ||
|
|
b7addcca6b | ||
|
|
98d4029fd4 | ||
|
|
07366f744f | ||
|
|
88e18f4c57 | ||
|
|
d8992c3931 | ||
|
|
c7e9d8b5f1 | ||
|
|
3e1f8a5937 | ||
|
|
80b84f6cff | ||
|
|
0b017e853b | ||
|
|
2351cce99e |
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
|
||||
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -16,3 +16,10 @@ Thumbs.db
|
||||
# Keystore (regenerate if needed)
|
||||
debug.keystore
|
||||
release.keystore
|
||||
|
||||
# Memory files from agent harness
|
||||
memory:*
|
||||
|
||||
# Restic test repository (contains encryption keys)
|
||||
/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** (933 symbols, 2388 relationships, 80 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** (933 symbols, 2388 relationships, 80 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>.
|
||||
|
||||
204
README.md
204
README.md
@@ -1,129 +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),支持本地和远端仓库
|
||||
- **远程后端** — 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)
|
||||
```
|
||||
|
||||
远端同步基于内容大小比较,跳过同名等长文件;自动删除远端/本地过时文件。
|
||||
restic 通过 REST HTTP API 与本地桥通信,桥接器将请求翻译为 SMB/WebDAV 文件操作。
|
||||
无需本地 staging 仓库,restic 直接读写远程存储。
|
||||
|
||||
### 关键设计
|
||||
## 构建
|
||||
|
||||
- **导航栏索引** — 使用 `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 验证数据归档,校验失败回告
|
||||
### 版本历史
|
||||
|
||||
## 编译
|
||||
| 版本 | 更新内容 |
|
||||
|------|---------|
|
||||
| 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
|
||||
./gradlew assembleDebug
|
||||
|
||||
# Release (需配置签名)
|
||||
./gradlew assembleRelease
|
||||
# Release APK(需配置签名)
|
||||
KEYSTORE_PASSWORD=<密码> KEY_PASSWORD=<密码> ./gradlew assembleRelease
|
||||
```
|
||||
|
||||
`librestic.so` 需放在 `app/src/main/jniLibs/arm64-v8a/` 目录下,在 `build.gradle` 中禁用 `extractNativeLibs` 前的 `useLegacyPackaging`。
|
||||
> 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**。
|
||||
@@ -1,6 +1,21 @@
|
||||
apply plugin: 'com.android.application'
|
||||
apply plugin: 'kotlin-android'
|
||||
apply plugin: 'kotlinx-serialization'
|
||||
apply plugin: 'org.jetbrains.kotlinx.kover'
|
||||
|
||||
kover {
|
||||
reports {
|
||||
filters {
|
||||
excludes {
|
||||
classes(
|
||||
"*.BuildConfig",
|
||||
"*.R",
|
||||
"*.R\$*"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
android {
|
||||
namespace "com.example.androidbackupgui"
|
||||
@@ -9,28 +24,43 @@ android {
|
||||
applicationId "com.example.androidbackupgui"
|
||||
minSdk 24
|
||||
targetSdk 34
|
||||
versionCode 2
|
||||
versionName "1.1"
|
||||
versionCode 16
|
||||
versionName "1.16"
|
||||
}
|
||||
buildFeatures {
|
||||
viewBinding true
|
||||
compose = true
|
||||
}
|
||||
composeOptions {
|
||||
kotlinCompilerExtensionVersion = "1.5.1"
|
||||
}
|
||||
lint {
|
||||
disable 'QueryAllPackagesPermission'
|
||||
}
|
||||
signingConfigs {
|
||||
release {
|
||||
storeFile file("release.keystore")
|
||||
storePassword System.getenv("KEYSTORE_PASSWORD") ?: "android"
|
||||
storeFile rootProject.file("app/release.keystore")
|
||||
storePassword System.getenv("KEYSTORE_PASSWORD")
|
||||
keyAlias "release"
|
||||
keyPassword System.getenv("KEY_PASSWORD") ?: "android"
|
||||
keyPassword System.getenv("KEY_PASSWORD")
|
||||
v1SigningEnabled true
|
||||
v2SigningEnabled true
|
||||
}
|
||||
}
|
||||
buildTypes {
|
||||
release {
|
||||
minifyEnabled false
|
||||
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||
signingConfig signingConfigs.release
|
||||
if (rootProject.file("app/release.keystore").exists()) {
|
||||
def ksPass = System.getenv("KEYSTORE_PASSWORD")
|
||||
def kPass = System.getenv("KEY_PASSWORD")
|
||||
if (ksPass != null && kPass != null) {
|
||||
signingConfig signingConfigs.release
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
testOptions {
|
||||
unitTests.all {
|
||||
|
||||
useJUnitPlatform()
|
||||
}
|
||||
}
|
||||
compileOptions {
|
||||
@@ -44,24 +74,56 @@ android {
|
||||
jniLibs {
|
||||
useLegacyPackaging true
|
||||
}
|
||||
resources {
|
||||
excludes += [
|
||||
'org/bouncycastle/pqc/crypto/picnic/lowmcL5.bin.properties',
|
||||
'org/bouncycastle/pqc/crypto/picnic/lowmcL3.bin.properties',
|
||||
'org/bouncycastle/pqc/crypto/picnic/lowmcL1.bin.properties',
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
implementation "eu.agno3.jcifs:jcifs-ng:2.1.10"
|
||||
implementation "com.github.thegrizzlylabs:sardine-android:v0.9"
|
||||
implementation("eu.agno3.jcifs:jcifs-ng:2.1.10") {
|
||||
exclude group: 'org.bouncycastle'
|
||||
}
|
||||
implementation("com.github.thegrizzlylabs:sardine-android:v0.9") {
|
||||
exclude group: 'xpp3'
|
||||
exclude group: 'stax'
|
||||
}
|
||||
implementation "org.slf4j:slf4j-android:1.7.36"
|
||||
|
||||
// root shell via libsu (Magisk/KernelSU/APatch)
|
||||
implementation 'com.github.topjohnwu:libsu:6.0.0'
|
||||
// Full BouncyCastle provider (includes MD4 required by jcifs-ng SMB)
|
||||
implementation 'org.bouncycastle:bcprov-jdk15to18:1.77'
|
||||
implementation 'org.nanohttpd:nanohttpd:2.3.1'
|
||||
testImplementation "io.kotest:kotest-runner-junit5:5.9.1"
|
||||
testImplementation "io.kotest:kotest-assertions-core:5.9.1"
|
||||
testImplementation "io.kotest:kotest-property:5.9.1"
|
||||
testImplementation "io.mockk:mockk:1.13.12"
|
||||
testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3"
|
||||
}
|
||||
|
||||
1742
app/lint-baseline.xml
Normal file
1742
app/lint-baseline.xml
Normal file
File diff suppressed because it is too large
Load Diff
59
app/proguard-rules.pro
vendored
59
app/proguard-rules.pro
vendored
@@ -1 +1,58 @@
|
||||
# Add project specific ProGuard rules here.
|
||||
# ProGuard/R8 rules for Android Backup GUI
|
||||
# ==========================================
|
||||
|
||||
# --- kotlinx.serialization ---
|
||||
# Keep @SerialName classes and companion serializer fields
|
||||
-keepattributes *Annotation*, InnerClasses
|
||||
-dontnote kotlinx.serialization.AnnotationsKt
|
||||
-keepclassmembers class kotlinx.serialization.json.** {
|
||||
*** Companion;
|
||||
}
|
||||
-keepclasseswithmembers class kotlinx.serialization.json.** {
|
||||
kotlinx.serialization.KSerializer serializer(...);
|
||||
}
|
||||
-keep,includedescriptorclasses class com.example.androidbackupgui.**$$serializer { *; }
|
||||
-keepclassmembers class com.example.androidbackupgui.** {
|
||||
*** Companion;
|
||||
}
|
||||
-keepclasseswithmembers class com.example.androidbackupgui.** {
|
||||
kotlinx.serialization.KSerializer serializer(...);
|
||||
}
|
||||
|
||||
# --- NanoHTTPD ---
|
||||
# NanoHTTPD (package fi.iki.elonen despite Maven group org.nanohttpd)
|
||||
-keep class fi.iki.elonen.** { *; }
|
||||
|
||||
# --- RemoteTransport (WebDAV/SMB) ---
|
||||
-keep class com.example.androidbackupgui.backup.RemoteTransport { *; }
|
||||
|
||||
# --- Data classes (serialization) ---
|
||||
-keep class com.example.androidbackupgui.backup.ResticProgress { *; }
|
||||
-keep class com.example.androidbackupgui.backup.BackupSummary { *; }
|
||||
-keep class com.example.androidbackupgui.backup.ResticSnapshot { *; }
|
||||
-keep class com.example.androidbackupgui.backup.RestoreProgress { *; }
|
||||
-keep class com.example.androidbackupgui.backup.BackupConfig { *; }
|
||||
-keep class com.example.androidbackupgui.backup.AppError { *; }
|
||||
-keep class com.example.androidbackupgui.backup.AppResult { *; }
|
||||
|
||||
|
||||
# --- RemoteTransport implementations ---
|
||||
-keep class com.example.androidbackupgui.backup.SmbTransport { *; }
|
||||
-keep class com.example.androidbackupgui.backup.WebdavTransport { *; }
|
||||
|
||||
# --- WifiManager (called from UI, kept for safety) ---
|
||||
-keep class com.example.androidbackupgui.backup.WifiManager { *; }
|
||||
# --- Keep data models used by kotlinx.serialization ---
|
||||
## Keep all model classes that may be referenced via @Serializable
|
||||
-keep class com.example.androidbackupgui.model.** { *; }
|
||||
|
||||
# --- Keep R classes (referenced by code) ---
|
||||
-keep class com.example.androidbackupgui.R { *; }
|
||||
|
||||
|
||||
|
||||
# --- 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 { *; }
|
||||
-keep class jcifs.smb.NtlmContext { *; }
|
||||
|
||||
BIN
app/release/AndroidBackupGUI-release.apk
Normal file
BIN
app/release/AndroidBackupGUI-release.apk
Normal file
Binary file not shown.
@@ -5,9 +5,12 @@
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
<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"
|
||||
@@ -22,6 +25,11 @@
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<service
|
||||
android:name=".backup.BackupService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
||||
@@ -1,83 +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 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.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.BackupFragment
|
||||
import com.example.androidbackupgui.ui.ConfigFragment
|
||||
import com.example.androidbackupgui.ui.RestoreFragment
|
||||
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
|
||||
|
||||
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)
|
||||
RootShell.configure()
|
||||
|
||||
binding = ActivityMainBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
// Initialize restic binary path
|
||||
ResticBinary.prepare(this)?.let { defaultResticWrapper.binaryPath = it }
|
||||
|
||||
// Request root access on startup
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
RootShell.ensureSession()
|
||||
}
|
||||
// Initialize file-based logging and secure credential storage
|
||||
LogUtil.init(filesDir)
|
||||
PasswordManager.init(this)
|
||||
// 启动时初始化 SMB 加密库(MD4/AESCMAC),避免首次 SMB 操作时延迟失败
|
||||
MissingAlgoProvider.register()
|
||||
|
||||
// Edge-to-edge: pad toolbar below status bar
|
||||
ViewCompat.setOnApplyWindowInsetsListener(binding.topAppBar) { view, insets ->
|
||||
val statusBars = insets.getInsets(WindowInsetsCompat.Type.statusBars())
|
||||
view.setPadding(view.paddingLeft, statusBars.top, view.paddingRight, view.paddingBottom)
|
||||
insets
|
||||
}
|
||||
|
||||
val fragments = listOf(
|
||||
BackupFragment(),
|
||||
RestoreFragment(),
|
||||
ConfigFragment()
|
||||
)
|
||||
|
||||
binding.viewPager.adapter = TabAdapter(this, fragments)
|
||||
binding.viewPager.isUserInputEnabled = true
|
||||
|
||||
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
|
||||
setContent {
|
||||
AppTheme {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background,
|
||||
) {
|
||||
AppScaffold()
|
||||
}
|
||||
}
|
||||
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]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
package com.example.androidbackupgui.backup
|
||||
|
||||
/**
|
||||
* 类型化应用错误层次。所有业务层错误统一为此 sealed interface。
|
||||
*
|
||||
* 使用方式:
|
||||
* ```
|
||||
* // 失败返回
|
||||
* return err(AppError.Remote("连接超时", "download", cause = e, retryable = true))
|
||||
*
|
||||
* // 模式匹配
|
||||
* when (error) {
|
||||
* is AppError.Network -> showRetry()
|
||||
* is AppError.Remote -> handleRemote(error)
|
||||
* is AppError.Cancelled -> ignore()
|
||||
* else -> showError(error.message)
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
sealed interface AppError {
|
||||
|
||||
/** 人类可读的错误描述 */
|
||||
val message: String
|
||||
|
||||
/**
|
||||
* 网络/IO 类错误。
|
||||
* 用于 HTTP 请求超时、DNS 解析失败、连接被拒绝等可重试的网络异常。
|
||||
*
|
||||
* @property retryable 默认为 true,表示此错误可安全重试
|
||||
*/
|
||||
data class Network(
|
||||
override val message: String,
|
||||
val cause: Throwable? = null,
|
||||
val retryable: Boolean = true
|
||||
) : AppError
|
||||
|
||||
/**
|
||||
* Root shell 命令执行错误。
|
||||
* 用于 cp、tar、pm path、dumpsys 等 root 命令的非零退出。
|
||||
*/
|
||||
data class Shell(
|
||||
override val message: String,
|
||||
val command: String,
|
||||
val exitCode: Int,
|
||||
val stderr: String
|
||||
) : AppError
|
||||
|
||||
/**
|
||||
* 远端文件操作错误(WebDAV/SMB)。
|
||||
* 用于上传、下载、列出、删除远端文件时的协议层错误。
|
||||
*
|
||||
* @property phase 错误发生时所在的阶段,可取 "connecting"、"transferring"、"list"、"delete" 等
|
||||
* @property isNotFound 远端路径是否存在(区分 404 和其他错误)
|
||||
* @property retryable 默认为 false,明确标记为可重试需业务层判断
|
||||
*/
|
||||
data class Remote(
|
||||
override val message: String,
|
||||
val phase: String,
|
||||
val cause: Throwable? = null,
|
||||
val isNotFound: Boolean = false,
|
||||
val retryable: Boolean = false
|
||||
) : AppError
|
||||
|
||||
/**
|
||||
* 本地文件/IO 错误。
|
||||
* 用于文件读写失败、磁盘空间不足、文件不存在等本地文件系统错误。
|
||||
*/
|
||||
data class LocalIO(
|
||||
override val message: String,
|
||||
val path: String,
|
||||
val cause: Throwable? = null
|
||||
) : AppError
|
||||
|
||||
/**
|
||||
* restic 命令执行错误。
|
||||
* 用于 restic backup / restore / snapshots / forget 等子命令返回非零退出码。
|
||||
*/
|
||||
data class Restic(
|
||||
override val message: String,
|
||||
val exitCode: Int,
|
||||
val stderr: String
|
||||
) : AppError
|
||||
|
||||
/**
|
||||
* 解析/配置错误。
|
||||
* 用于 JSON 解析失败、配置文件格式错误、参数校验失败等场景。
|
||||
*/
|
||||
data class Parse(
|
||||
override val message: String,
|
||||
val detail: String = ""
|
||||
) : AppError
|
||||
|
||||
/** 操作被取消(用户中止或协程取消)。不应重试。 */
|
||||
data object Cancelled : AppError {
|
||||
override val message: String = "操作被取消"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 与 [AppError] 配套的类型化返回类型。
|
||||
*
|
||||
* 使用方式:
|
||||
* ```
|
||||
* fun load(): AppResult<List<Item>> {
|
||||
* return AppResult.Success(items)
|
||||
* // 或
|
||||
* return err(AppError.Network("连接失败"))
|
||||
* }
|
||||
*
|
||||
* // 消费
|
||||
* when (val result = load()) {
|
||||
* is AppResult.Success -> showItems(result.data)
|
||||
* is AppResult.Failure -> showError(result.error.message)
|
||||
* }
|
||||
*
|
||||
* // 或使用 fold / map
|
||||
* result.fold(
|
||||
* onSuccess = { items -> showItems(items) },
|
||||
* onFailure = { error -> showError(error.message) }
|
||||
* )
|
||||
* ```
|
||||
*/
|
||||
sealed class AppResult<out T> {
|
||||
data class Success<T>(val data: T) : AppResult<T>()
|
||||
data class Failure(val error: AppError) : AppResult<Nothing>()
|
||||
|
||||
/** Returns `true` if this is a [Success]. */
|
||||
val isSuccess: Boolean get() = this is Success
|
||||
|
||||
/** Returns `true` if this is a [Failure]. */
|
||||
val isFailure: Boolean get() = this is Failure
|
||||
|
||||
/** Returns the success value, or `null` if this is a [Failure]. */
|
||||
fun getOrNull(): T? = (this as? Success)?.data
|
||||
|
||||
/** Returns the success value, or [default] if this is a [Failure]. */
|
||||
fun getOrDefault(default: @UnsafeVariance T): T =
|
||||
(this as? Success)?.data ?: default
|
||||
|
||||
/**
|
||||
* Returns the success value, or throws a [RuntimeException]
|
||||
* wrapping the error message if this is a [Failure].
|
||||
*/
|
||||
fun getOrThrow(): T =
|
||||
(this as? Success)?.data
|
||||
?: throw RuntimeException((this as Failure).error.message)
|
||||
|
||||
/**
|
||||
* Returns a [RuntimeException] representing the error, or `null` if this is a [Success].
|
||||
* Callers can access `.message` on the result.
|
||||
*/
|
||||
fun exceptionOrNull(): Throwable? =
|
||||
(this as? Failure)?.let { RuntimeException(it.error.message) }
|
||||
|
||||
/** Returns the [AppError], or `null` if this is a [Success]. */
|
||||
fun errorOrNull(): AppError? = (this as? Failure)?.error
|
||||
|
||||
/**
|
||||
* Fold: convert either branch into a single value [R].
|
||||
* [onSuccess] receives the success value; [onFailure] receives the typed [AppError].
|
||||
*/
|
||||
inline fun <R> fold(
|
||||
crossinline onSuccess: (T) -> R,
|
||||
crossinline onFailure: (AppError) -> R,
|
||||
): R = when (this) {
|
||||
is Success -> onSuccess(data)
|
||||
is Failure -> onFailure(error)
|
||||
}
|
||||
|
||||
inline fun <R> map(crossinline transform: (T) -> R): AppResult<R> = when (this) {
|
||||
is Success -> Success(transform(data))
|
||||
is Failure -> this
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Transform the error using [transform], or pass through the success unchanged.
|
||||
*/
|
||||
fun mapError(transform: (AppError) -> AppError): AppResult<T> = when (this) {
|
||||
is Success -> this
|
||||
is Failure -> Failure(transform(error))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a failed [AppResult] wrapping the given [AppError].
|
||||
*/
|
||||
internal fun <T> err(error: AppError): AppResult<T> = AppResult.Failure(error)
|
||||
@@ -10,33 +10,36 @@ import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class AppInfo(
|
||||
val packageName: String,
|
||||
var label: String = "",
|
||||
val packageName: PackageName,
|
||||
val label: String = "",
|
||||
val isSystem: Boolean = false,
|
||||
val apkPaths: List<String> = emptyList(),
|
||||
val hasObb: Boolean = false,
|
||||
val isRunning: Boolean = false,
|
||||
val backupSize: Long = 0 // estimated from last backup
|
||||
val backupSize: Long = 0, // estimated from last backup
|
||||
// Enhanced fields (multi-user, keystore, icon)
|
||||
val userId: UserId = UserId(0),
|
||||
val hasKeystore: Boolean = false,
|
||||
val iconPath: String? = null,
|
||||
)
|
||||
|
||||
object AppScanner {
|
||||
|
||||
/** Scan all third-party installed packages. */
|
||||
suspend fun scanThirdParty(context: Context): List<AppInfo> = withContext(Dispatchers.IO) {
|
||||
val result = RootShell.exec("pm list packages -3")
|
||||
suspend fun scanThirdParty(context: Context, userId: Int = 0): List<AppInfo> = withContext(Dispatchers.IO) {
|
||||
val result = RootShell.exec("pm list packages -3 --user $userId")
|
||||
if (!result.isSuccess) return@withContext emptyList()
|
||||
|
||||
val packages = result.output.lines()
|
||||
.filter { it.startsWith("package:") }
|
||||
.map { it.removePrefix("package:").trim() }
|
||||
.filter { it.isNotEmpty() }
|
||||
.map { AppInfo(packageName = it) }
|
||||
.map { AppInfo(packageName = PackageName(it), userId = UserId(userId)) }
|
||||
resolveLabels(context, packages)
|
||||
}
|
||||
|
||||
/** Scan all system packages. */
|
||||
suspend fun scanSystem(context: Context, config: BackupConfig): List<AppInfo> = withContext(Dispatchers.IO) {
|
||||
val result = RootShell.exec("pm list packages -s")
|
||||
suspend fun scanSystem(context: Context, config: BackupConfig, userId: Int = 0): List<AppInfo> = withContext(Dispatchers.IO) {
|
||||
val result = RootShell.exec("pm list packages -s --user $userId")
|
||||
if (!result.isSuccess) return@withContext emptyList()
|
||||
|
||||
val systemWhitelist = config.system.toSet()
|
||||
@@ -48,14 +51,12 @@ object AppScanner {
|
||||
.map { it.removePrefix("package:").trim() }
|
||||
.filter { it.isNotEmpty() }
|
||||
.filter { pkg ->
|
||||
// Allow if in system whitelist or data whitelist
|
||||
pkg in systemWhitelist || pkg in dataWhitelist
|
||||
}
|
||||
.filter { pkg ->
|
||||
// Exclude if in blacklist (when blacklistMode=1, full ignore)
|
||||
if (config.blacklistMode == 1) pkg !in blacklist else true
|
||||
}
|
||||
.map { AppInfo(packageName = it, isSystem = true) }
|
||||
.map { AppInfo(packageName = PackageName(it), isSystem = true, userId = UserId(userId)) }
|
||||
resolveLabels(context, packages)
|
||||
}
|
||||
|
||||
@@ -68,15 +69,15 @@ object AppScanner {
|
||||
fun resolveLabels(context: Context, packages: List<AppInfo>): List<AppInfo> {
|
||||
if (packages.isEmpty()) return packages
|
||||
val pm = context.packageManager
|
||||
for (app in packages) {
|
||||
app.label = try {
|
||||
val ai = pm.getApplicationInfo(app.packageName, 0)
|
||||
return packages.map { app ->
|
||||
val resolvedLabel = try {
|
||||
val ai = pm.getApplicationInfo(app.packageName.value, 0)
|
||||
pm.getApplicationLabel(ai).toString()
|
||||
} catch (_: PackageManager.NameNotFoundException) {
|
||||
app.packageName
|
||||
app.packageName.value
|
||||
}
|
||||
app.copy(label = resolvedLabel)
|
||||
}
|
||||
return packages
|
||||
}
|
||||
|
||||
/** Get APK paths for a package. */
|
||||
@@ -90,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) {
|
||||
@@ -112,7 +103,69 @@ object AppScanner {
|
||||
val result = RootShell.exec("pidof '${packageName.shellEscape()}'")
|
||||
result.output.isNotBlank()
|
||||
}
|
||||
/** Check if an app has keystore entries (critical — keystore keys can be lost on backup). */
|
||||
suspend fun hasKeystore(packageName: String): Boolean = withContext(Dispatchers.IO) {
|
||||
// Resolve the app's UID first
|
||||
val uidResult = RootShell.exec("dumpsys package '${packageName.shellEscape()}' | grep 'userId=' | head -1")
|
||||
val uid = uidResult.output
|
||||
.substringAfter("userId=", "")
|
||||
.substringBefore(" ")
|
||||
.substringBefore(",")
|
||||
.trim()
|
||||
.toIntOrNull() ?: return@withContext false
|
||||
// keystore_cli_v2 list as app UID — more than 1 line means has keystore entries
|
||||
val ksResult = RootShell.exec("su $uid -c 'keystore_cli_v2 list' 2>/dev/null")
|
||||
ksResult.output.lines().count { it.isNotBlank() } > 1
|
||||
}
|
||||
/** Enumerate all user profiles on the device for multi-user support. */
|
||||
suspend fun enumerateUsers(): List<Pair<Int, String>> = withContext(Dispatchers.IO) {
|
||||
val result = RootShell.exec("pm list users")
|
||||
if (!result.isSuccess) return@withContext listOf(0 to "Owner")
|
||||
|
||||
result.output.lines()
|
||||
.filter { it.contains("UserInfo") }
|
||||
.mapNotNull { line ->
|
||||
val id = line.substringBefore(":").trim().toIntOrNull()
|
||||
val name = line.substringAfter(":").substringBefore(":").trim()
|
||||
if (id != null) id to name else null
|
||||
}
|
||||
}
|
||||
|
||||
/** Extract and save an app's icon to the given directory. */
|
||||
suspend fun extractIcon(packageName: String, destDir: java.io.File, userId: Int = 0): String? = withContext(Dispatchers.IO) {
|
||||
// Try snapshot cache first
|
||||
val snapshotDir = "/data/system_ce/$userId/snapshots/$packageName"
|
||||
val snapshotResult = RootShell.exec("ls '${snapshotDir.shellEscape()}/' 2>/dev/null | head -1")
|
||||
if (snapshotResult.isSuccess && snapshotResult.output.isNotBlank()) {
|
||||
val iconName = snapshotResult.output.trim()
|
||||
val iconFile = java.io.File(destDir, "app_icon.png")
|
||||
val copyResult = RootShell.exec("cp '${snapshotDir.shellEscape()}/${iconName.shellEscape()}' '${iconFile.absolutePath.shellEscape()}' 2>/dev/null")
|
||||
if (copyResult.isSuccess && iconFile.exists()) {
|
||||
return@withContext iconFile.absolutePath
|
||||
}
|
||||
}
|
||||
// Fallback: extract from APK using aapt
|
||||
val apkPaths = getApkPaths(packageName)
|
||||
if (apkPaths.isNotEmpty()) {
|
||||
val primaryApk = apkPaths.first()
|
||||
val badgeResult = RootShell.exec("aapt d badging '$primaryApk' 2>/dev/null | grep '^application:.*icon=' | head -1")
|
||||
if (badgeResult.isSuccess) {
|
||||
val iconPath = badgeResult.output
|
||||
.substringAfter("icon='")
|
||||
.substringBefore("'")
|
||||
.takeIf { it.isNotBlank() }
|
||||
if (iconPath != null) {
|
||||
// The icon path is relative inside the APK, extract using aapt
|
||||
val iconFile = java.io.File(destDir, "app_icon.png")
|
||||
RootShell.exec("aapt d raw '$primaryApk' '$iconPath' > '${iconFile.absolutePath.shellEscape()}' 2>/dev/null")
|
||||
if (iconFile.exists()) {
|
||||
return@withContext iconFile.absolutePath
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
null
|
||||
}
|
||||
/** Apply appList.txt-style filters. Lines starting with # are ignored, ! means apk-only. */
|
||||
fun parseAppList(content: String): List<Pair<String, Boolean>> {
|
||||
return content.lines()
|
||||
|
||||
@@ -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,81 +1,123 @@
|
||||
package com.example.androidbackupgui.backup
|
||||
|
||||
import java.io.File
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Mirrors backup_settings.conf from backup_script.
|
||||
* All keys correspond 1:1 with the original shell config.
|
||||
*
|
||||
* This is an immutable data class. Use [copy] to create modified instances.
|
||||
*/
|
||||
@Serializable
|
||||
data class BackupConfig(
|
||||
// Operation mode
|
||||
var lo: Int = 0, // 0=volume key, 1=volume force, 2=keyboard
|
||||
var backgroundExecution: Int = 0, // 0=foreground, 1=background
|
||||
var setDisplayPowerMode: Int = 0, // 1=keep screen on during backup
|
||||
var 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
|
||||
var outputPath: String = "", // Custom output dir
|
||||
var listLocation: String = "", // Custom appList.txt location
|
||||
|
||||
val outputPath: String = "", // Custom output dir
|
||||
val listLocation: String = "", // Custom appList.txt location
|
||||
// Update
|
||||
var update: Int = 1, // 1=auto update
|
||||
var cdn: Int = 1, // CDN node
|
||||
|
||||
val update: Int = 1, // 1=auto update
|
||||
val cdn: Int = 1, // CDN node
|
||||
// Filters
|
||||
var mountPoint: String = "rannki|0000-1",
|
||||
var user: String = "",
|
||||
|
||||
val mountPoint: String = "rannki|0000-1",
|
||||
val user: String = "",
|
||||
// Backup mode
|
||||
var backupMode: Int = 1, // 1=data+apk, 0=apk only
|
||||
var backupUserData: Int = 1,
|
||||
var backupObbData: Int = 1,
|
||||
var backupMedia: Int = 0,
|
||||
var backgroundAppsIgnore: Int = 0,
|
||||
|
||||
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
|
||||
var 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
|
||||
var blacklistMode: Int = 0, // 1=full ignore, 0=apk only
|
||||
var blacklist: List<String> = emptyList(),
|
||||
|
||||
val blacklistMode: Int = 0, // 1=full ignore, 0=apk only
|
||||
val blacklist: List<String> = emptyList(),
|
||||
// Whitelists
|
||||
var whitelist: List<String> = emptyList(),
|
||||
var system: List<String> = emptyList(),
|
||||
|
||||
val whitelist: List<String> = emptyList(),
|
||||
val system: List<String> = emptyList(),
|
||||
// Compression
|
||||
var compressionMethod: String = "zstd", // zstd or tar
|
||||
|
||||
val compressionMethod: String = "zstd", // zstd or tar
|
||||
// Terminal colors
|
||||
var rgbA: Int = 226,
|
||||
var rgbB: Int = 123,
|
||||
var rgbC: Int = 177,
|
||||
|
||||
var backupWifi: Int = 1,
|
||||
|
||||
val rgbA: Int = 226,
|
||||
val rgbB: Int = 123,
|
||||
val rgbC: Int = 177,
|
||||
val backupWifi: Int = 1,
|
||||
// Restic deduplicated backup with rclone backend
|
||||
var resticEnabled: Int = 0,
|
||||
var resticRepo: String = "",
|
||||
var resticPassword: String = "",
|
||||
var resticBackend: String = "local", // local / webdav / smb
|
||||
var resticBackendUrl: String = "",
|
||||
var resticBackendUser: String = "",
|
||||
var resticBackendPass: String = "",
|
||||
var resticBackendShare: String = "", // SMB share name
|
||||
var resticBackendDomain: String = "" // SMB domain (optional, for NTLM)
|
||||
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 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)
|
||||
// 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 {
|
||||
val config = BackupConfig()
|
||||
if (!file.exists()) return config
|
||||
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 ->
|
||||
@@ -84,103 +126,137 @@ 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", " ") }
|
||||
}
|
||||
|
||||
config.lo = int("Lo")
|
||||
config.backgroundExecution = int("background_execution")
|
||||
config.setDisplayPowerMode = int("setDisplayPowerMode")
|
||||
config.shellLang = str("Shell_LANG")
|
||||
config.outputPath = str("Output_path")
|
||||
config.listLocation = str("list_location")
|
||||
config.update = int("update", default = 1)
|
||||
config.cdn = int("cdn", default = 1)
|
||||
config.mountPoint = str("mount_point")
|
||||
config.user = str("user")
|
||||
config.backupMode = int("Backup_Mode", default = 1)
|
||||
config.backupUserData = int("Backup_user_data", default = 1)
|
||||
config.backupObbData = int("Backup_obb_data", default = 1)
|
||||
config.backupMedia = int("backup_media")
|
||||
config.backgroundAppsIgnore = int("Background_apps_ignore")
|
||||
config.customPath = lines("Custom_path")
|
||||
config.blacklistMode = int("blacklist_mode")
|
||||
config.blacklist = lines("blacklist")
|
||||
config.whitelist = lines("whitelist")
|
||||
config.system = lines("system")
|
||||
config.compressionMethod = str("Compression_method").ifEmpty { "zstd" }
|
||||
config.rgbA = int("rgb_a").let { if (it == 0) 226 else it }
|
||||
config.rgbB = int("rgb_b").let { if (it == 0) 123 else it }
|
||||
config.rgbC = int("rgb_c").let { if (it == 0) 177 else it }
|
||||
config.backupWifi = int("backup_wifi", default = 1)
|
||||
config.resticEnabled = int("restic_enabled")
|
||||
config.resticRepo = str("restic_repo")
|
||||
config.resticPassword = str("restic_password")
|
||||
config.resticBackend = str("restic_backend").ifEmpty { "local" }
|
||||
config.resticBackendUrl = str("restic_backend_url")
|
||||
config.resticBackendUser = str("restic_backend_user")
|
||||
config.resticBackendPass = str("restic_backend_pass")
|
||||
config.resticBackendShare = str("restic_backend_share")
|
||||
config.resticBackendDomain = str("restic_backend_domain")
|
||||
return config
|
||||
return BackupConfig(
|
||||
lo = int("Lo"),
|
||||
backgroundExecution = int("background_execution"),
|
||||
setDisplayPowerMode = int("setDisplayPowerMode"),
|
||||
shellLang = str("Shell_LANG"),
|
||||
outputPath = str("Output_path"),
|
||||
listLocation = str("list_location"),
|
||||
update = int("update", default = 1),
|
||||
cdn = int("cdn", default = 1),
|
||||
mountPoint = str("mount_point"),
|
||||
user = str("user"),
|
||||
backupMode = int("Backup_Mode", default = 1),
|
||||
backupUserData = int("Backup_user_data", default = 1),
|
||||
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"),
|
||||
whitelist = lines("whitelist"),
|
||||
system = lines("system"),
|
||||
compressionMethod = str("Compression_method").ifEmpty { "zstd" },
|
||||
rgbA = int("rgb_a").let { if (it == 0) 226 else it },
|
||||
rgbB = int("rgb_b").let { if (it == 0) 123 else it },
|
||||
rgbC = int("rgb_c").let { if (it == 0) 177 else it },
|
||||
backupWifi = int("backup_wifi", default = 1),
|
||||
resticEnabled = int("restic_enabled"),
|
||||
resticRepo = str("restic_repo"),
|
||||
resticPassword = "", // 不用配置文件中的值,见下方迁移逻辑
|
||||
resticBackend = str("restic_backend").ifEmpty { "local" },
|
||||
resticBackendUrl = str("restic_backend_url"),
|
||||
resticBackendUser = str("restic_backend_user"),
|
||||
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,19 +1,22 @@
|
||||
package com.example.androidbackupgui.backup
|
||||
|
||||
import com.example.androidbackupgui.root.RootShell
|
||||
import android.util.Log
|
||||
import com.example.androidbackupgui.backup.ResticWrapper.SnapshotAppInfo
|
||||
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 org.json.JSONObject
|
||||
import kotlin.coroutines.coroutineContext
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
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
|
||||
|
||||
/**
|
||||
@@ -21,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
|
||||
@@ -29,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
|
||||
@@ -39,7 +41,7 @@ object BackupOperation {
|
||||
val failCount: Int,
|
||||
val skippedCount: Int,
|
||||
val outputDir: String,
|
||||
val elapsedMs: Long
|
||||
val elapsedMs: Long,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -48,200 +50,766 @@ object BackupOperation {
|
||||
* @param config backup configuration
|
||||
* @param outputDir root output directory
|
||||
* @param userId Android user ID (0, 999, etc.)
|
||||
* @param onProgress callback for UI updates
|
||||
* @param includePkgs if non-empty, only backup apps whose package name is in this set;
|
||||
* metadata (app_details.json, appList.txt) is still generated for all [apps].
|
||||
* @param legacyApps metadata from a previous snapshot used to populate app_details.json
|
||||
* for apps not in [apps] (keeps them in the cumulative snapshot record
|
||||
* without requiring re-scans of possibly-uninstalled apps).
|
||||
*/
|
||||
suspend fun backupApps(
|
||||
context: android.content.Context,
|
||||
apps: List<AppInfo>,
|
||||
config: BackupConfig,
|
||||
outputDir: File,
|
||||
userId: String = "0",
|
||||
onProgress: suspend (BackupProgress) -> Unit = {}
|
||||
): BackupResult = withContext(Dispatchers.IO) {
|
||||
val emit: suspend (BackupProgress) -> Unit = { p -> withContext(Dispatchers.Main) { onProgress(p) } }
|
||||
val startTime = System.currentTimeMillis()
|
||||
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()
|
||||
|
||||
// Create backup structure
|
||||
val backupRoot = File(outputDir, "Backup_${config.compressionMethod}_$userId")
|
||||
backupRoot.mkdirs()
|
||||
|
||||
// Write app list
|
||||
val appListFile = File(backupRoot, "appList.txt")
|
||||
appListFile.writeText(apps.joinToString("\n") { it.packageName })
|
||||
|
||||
// Write metadata JSON
|
||||
val metaFile = File(backupRoot, "app_details.json")
|
||||
metaFile.writeText(buildAppDetailsJson(apps))
|
||||
|
||||
val semaphore = Semaphore(3)
|
||||
val successAtomic = AtomicInteger(0)
|
||||
val failAtomic = AtomicInteger(0)
|
||||
val skippedAtomic = AtomicInteger(0)
|
||||
|
||||
coroutineScope {
|
||||
apps.forEachIndexed { index, app ->
|
||||
launch {
|
||||
if (!coroutineContext.isActive) return@launch
|
||||
semaphore.withPermit {
|
||||
val appDir = File(backupRoot, app.packageName)
|
||||
appDir.mkdirs()
|
||||
|
||||
emit(BackupProgress(index + 1, apps.size, app.packageName, "apk", "正在备份 APK…"))
|
||||
|
||||
// 1. Backup APK
|
||||
val paths = AppScanner.getApkPaths(app.packageName)
|
||||
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, apps.size, app.packageName, "done", "APK 备份失败"))
|
||||
return@withPermit
|
||||
}
|
||||
|
||||
// 2. Backup user data (if configured)
|
||||
if (config.backupMode == 1 && config.backupUserData == 1) {
|
||||
emit(BackupProgress(index + 1, apps.size, app.packageName, "data", "正在备份数据…"))
|
||||
if (!backupUserData(app.packageName, appDir, userId, config.compressionMethod)) {
|
||||
failAtomic.incrementAndGet()
|
||||
emit(BackupProgress(index + 1, apps.size, app.packageName, "done", "数据备份失败"))
|
||||
return@withPermit
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Backup OBB (if configured and exists)
|
||||
if (config.backupMode == 1 && config.backupObbData == 1) {
|
||||
val hasObb = AppScanner.hasObbData(app.packageName)
|
||||
if (hasObb) {
|
||||
emit(BackupProgress(index + 1, apps.size, app.packageName, "obb", "正在备份 OBB…"))
|
||||
if (!backupObb(app.packageName, appDir, config.compressionMethod)) {
|
||||
failAtomic.incrementAndGet()
|
||||
emit(BackupProgress(index + 1, apps.size, app.packageName, "done", "OBB 备份失败"))
|
||||
return@withPermit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Backup SSAID
|
||||
emit(BackupProgress(index + 1, apps.size, app.packageName, "ssaid", "正在备份 SSAID…"))
|
||||
backupSsaid(app.packageName, appDir, userId)
|
||||
|
||||
// 5. Backup runtime permissions
|
||||
backupPermissions(app.packageName, appDir)
|
||||
|
||||
successAtomic.incrementAndGet()
|
||||
emit(BackupProgress(index + 1, apps.size, app.packageName, "done", "完成"))
|
||||
}
|
||||
}
|
||||
// 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)
|
||||
}
|
||||
|
||||
// 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}")
|
||||
|
||||
// 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()
|
||||
}
|
||||
|
||||
// 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}'")
|
||||
|
||||
BackupResult(
|
||||
successCount = successAtomic.get(),
|
||||
failCount = failAtomic.get(),
|
||||
skippedCount = skippedAtomic.get(),
|
||||
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 dataDir = "/data/data/$pkgEsc"
|
||||
val userDeDir = "/data/user_de/${userId.shellEscape()}/$pkgEsc"
|
||||
val outputFile = "${appDir.absolutePath.shellEscape()}/${pkgEsc}_data.tar"
|
||||
// Build a list of dirs that exist
|
||||
val dirs = mutableListOf<String>()
|
||||
if (RootShell.exec("test -d $dataDir").isSuccess) dirs.add(dataDir)
|
||||
if (RootShell.exec("test -d $userDeDir").isSuccess) dirs.add(userDeDir)
|
||||
if (dirs.isEmpty()) return true // no data to backup is not an error
|
||||
// Exclude cache, code_cache, lib
|
||||
val excludeArgs = "--exclude='cache' --exclude='code_cache' --exclude='lib' --exclude='no_backup'"
|
||||
val result = when (compression) {
|
||||
"zstd" -> {
|
||||
val dirList = dirs.joinToString(" ")
|
||||
RootShell.exec(
|
||||
"tar $excludeArgs -cf - $dirList 2>/dev/null | zstd -T0 -o '$outputFile.zst'"
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
val dirList = dirs.joinToString(" ")
|
||||
RootShell.exec(
|
||||
"tar $excludeArgs -czf '$outputFile.gz' $dirList 2>/dev/null"
|
||||
)
|
||||
|
||||
// Resolve bundled binary paths (fall back to system PATH if not bundled)
|
||||
val bundledTar = BinaryResolver.tarPath(context)
|
||||
val tarCmd = bundledTar ?: "tar"
|
||||
|
||||
var isZstd = compression == "zstd"
|
||||
val bundledZstd = if (isZstd) BinaryResolver.zstdPath(context) else null
|
||||
val zstdCmd = bundledZstd ?: "zstd"
|
||||
if (isZstd && bundledZstd == null) {
|
||||
val zstdCheck = RootShell.exec("$zstdCmd --version 2>/dev/null")
|
||||
if (!zstdCheck.isSuccess) {
|
||||
Log.w(TAG, "backupUserData: zstd not available, falling back to gzip")
|
||||
isZstd = false
|
||||
}
|
||||
}
|
||||
if (!result.isSuccess) {
|
||||
Log.e(TAG, "Failed to backup data for $packageName: exit=${result.exitCode} err=${result.error}")
|
||||
return false
|
||||
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
|
||||
val dataPaths = listOf("/data/data/$rawPkg", "/data/user_de/$userId/$rawPkg")
|
||||
val dataExcludes = listOf(".ota", "cache", "lib", "code_cache", "no_backup")
|
||||
|
||||
// 1. Try direct paths after nsenter namespace switch
|
||||
var archiveCreated = false
|
||||
var result: RootShell.ShellResult? = null
|
||||
|
||||
val dirs = dataPaths.filter { RootShell.exec("test -d '${it.shellEscape()}'").isSuccess }.toMutableList()
|
||||
if (dirs.isNotEmpty()) {
|
||||
Log.d(TAG, "backupUserData: $packageName test -d found dirs=$dirs")
|
||||
result = runTar(dirs, outputFile, isZstd, tarCmd, zstdCmd, excludes = dataExcludes)
|
||||
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 = archiveHasData()
|
||||
Log.d(TAG, "backupUserData: $packageName step2 exit=${result?.exitCode} err='${result?.error?.take(100)}'")
|
||||
}
|
||||
// Verify the compressed archive integrity
|
||||
val verificationOk = when (compression) {
|
||||
"zstd" -> RootShell.exec("zstd -t '$outputFile.zst' 2>/dev/null").isSuccess
|
||||
else -> RootShell.exec("gzip -t '$outputFile.gz' 2>/dev/null").isSuccess
|
||||
|
||||
// 3. Fallback via /proc/1/root (global mount namespace)
|
||||
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"
|
||||
}
|
||||
result = RootShell.exec(globalCmd)
|
||||
archiveCreated = archiveHasData()
|
||||
Log.d(TAG, "backupUserData: $packageName step3 exit=${result?.exitCode} err='${result?.error?.take(100)}'")
|
||||
}
|
||||
if (!verificationOk) {
|
||||
Log.e(TAG, "Data archive integrity check FAILED for $packageName")
|
||||
|
||||
if (!archiveCreated) {
|
||||
LogUtil.w(TAG, "backupUserData: $packageName all methods failed — no data dirs (or inaccessible)")
|
||||
return null to null
|
||||
}
|
||||
return verificationOk
|
||||
|
||||
// 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
|
||||
}
|
||||
if (!verifyOk) {
|
||||
Log.e(TAG, "backupUserData: $packageName integrity check FAILED")
|
||||
return null to null
|
||||
}
|
||||
|
||||
// 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 null to null
|
||||
}
|
||||
return archiveRaw.length() to 0L // Return (userSize, userDeSize) — combined in one file
|
||||
}
|
||||
|
||||
private suspend fun backupObb(packageName: String, appDir: File, compression: String): Boolean {
|
||||
/**
|
||||
* 运行 tar 命令,自动选择 zstd 或 gzip 压缩。
|
||||
*/
|
||||
internal suspend fun runTar(
|
||||
dirs: List<String>,
|
||||
outputFile: String,
|
||||
isZstd: Boolean,
|
||||
tarCmd: String = "tar",
|
||||
zstdCmd: String = "zstd",
|
||||
excludes: List<String> = emptyList(),
|
||||
): RootShell.ShellResult {
|
||||
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'",
|
||||
)
|
||||
} else {
|
||||
RootShell.exec("$tarCmd -czf $excludeArgs '$outputFile.gz' ${dirs.joinToString(" ") { "'${it.shellEscape()}'" }} 2>/dev/null")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 备份单个应用的 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()
|
||||
val result = when (compression) {
|
||||
"zstd" -> RootShell.exec("tar -cf - '$obbDir' 2>/dev/null | zstd -T0 -o '$escapedAppDir/${escapedPkg}_obb.tar.zst'")
|
||||
else -> RootShell.exec("tar -czf '$escapedAppDir/${escapedPkg}_obb.tar.gz' '$obbDir' 2>/dev/null")
|
||||
}
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
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")
|
||||
}
|
||||
return verificationOk
|
||||
// Validate OBB tar structure
|
||||
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 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"
|
||||
val result = RootShell.exec("grep '${packageName.shellEscape()}' '$ssaidFile' 2>/dev/null")
|
||||
if (result.output.isNotBlank()) {
|
||||
File(appDir, "ssaid.txt").writeText(result.output)
|
||||
// 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() }
|
||||
if (value != null) {
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildAppDetailsJson(apps: List<AppInfo>): String {
|
||||
internal suspend fun buildAppDetailsJson(
|
||||
apps: List<AppInfo>,
|
||||
legacyApps: Map<String, SnapshotAppInfo>? = null,
|
||||
perAppExtra: Map<String, PerAppExtra>? = null,
|
||||
): String {
|
||||
val root = JSONObject()
|
||||
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)
|
||||
root.put(app.packageName, entry)
|
||||
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
|
||||
}
|
||||
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)
|
||||
}
|
||||
// Legacy apps from previous snapshot
|
||||
val legacyMap = legacyApps ?: emptyMap()
|
||||
for ((pkg, legacy) in legacyMap) {
|
||||
if (!root.has(pkg)) {
|
||||
val entry = JSONObject()
|
||||
entry.put("label", legacy.label)
|
||||
entry.put("isSystem", legacy.isSystem)
|
||||
entry.put("apkSizes", JSONArray(legacy.apkSizes))
|
||||
root.put(pkg, entry)
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
package com.example.androidbackupgui.backup
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.Service
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import androidx.core.app.NotificationCompat
|
||||
|
||||
/**
|
||||
* Foreground service to keep the process alive during long backup/restore operations.
|
||||
* Prevents Android from killing the app during extended operations.
|
||||
*/
|
||||
class BackupService : Service() {
|
||||
|
||||
companion object {
|
||||
const val CHANNEL_ID = "backup_service_channel"
|
||||
const val NOTIFICATION_ID = 1001
|
||||
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"
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
createNotificationChannel()
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
when (intent?.action) {
|
||||
ACTION_START_BACKUP -> {
|
||||
val statusText = intent.getStringExtra(EXTRA_STATUS_TEXT) ?: "正在备份…"
|
||||
val notification = createNotification(statusText)
|
||||
startForeground(NOTIFICATION_ID, notification)
|
||||
}
|
||||
ACTION_STOP_BACKUP -> {
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
stopSelf()
|
||||
}
|
||||
}
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
private fun createNotificationChannel() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
"备份服务",
|
||||
NotificationManager.IMPORTANCE_LOW
|
||||
).apply {
|
||||
description = "后台备份任务持续运行通知"
|
||||
setShowBadge(false)
|
||||
}
|
||||
val manager = getSystemService(NotificationManager::class.java)
|
||||
manager.createNotificationChannel(channel)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createNotification(text: String): Notification {
|
||||
return NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setContentTitle("Android Backup")
|
||||
.setContentText(text)
|
||||
.setSmallIcon(android.R.drawable.ic_menu_upload)
|
||||
.setOngoing(true)
|
||||
.setSilent(true)
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.example.androidbackupgui.backup
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Resolves paths to binaries bundled in jniLibs.
|
||||
* Android's PackageManager extracts lib*.so from jniLibs to nativeLibraryDir.
|
||||
* We copy them to app-private dir (writable, executable) for ProcessBuilder use.
|
||||
*/
|
||||
object BinaryResolver {
|
||||
private const val TAG = "BinaryResolver"
|
||||
|
||||
private var tarPath: String? = null
|
||||
private var zstdPath: String? = null
|
||||
|
||||
fun tarPath(context: Context): String? = cacheOrResolve(context, "libtar_bin.so", "tar_bin", ::tarPath) { tarPath = it }
|
||||
fun zstdPath(context: Context): String? = cacheOrResolve(context, "libzstd_bin.so", "zstd_bin", ::zstdPath) { zstdPath = it }
|
||||
|
||||
private fun cacheOrResolve(
|
||||
context: Context, libName: String, destName: String,
|
||||
cache: () -> String?, setCache: (String?) -> Unit
|
||||
): String? {
|
||||
val cached = cache()
|
||||
if (cached != null) return cached
|
||||
val resolved = resolve(context, libName, destName)
|
||||
setCache(resolved)
|
||||
return resolved
|
||||
}
|
||||
|
||||
private fun resolve(context: Context, libName: String, destName: String): String? {
|
||||
val nativeLibDir = context.applicationInfo.nativeLibraryDir
|
||||
val source = File(nativeLibDir, libName)
|
||||
if (!source.isFile) {
|
||||
Log.e(TAG, "$libName NOT FOUND at ${source.absolutePath}")
|
||||
return null
|
||||
}
|
||||
val dest = File(context.filesDir, "bin/$destName")
|
||||
if (!dest.exists() || dest.length() != source.length() || !dest.canExecute()) {
|
||||
dest.parentFile?.mkdirs()
|
||||
if (dest.exists()) dest.delete()
|
||||
source.inputStream().use { src -> dest.outputStream().use { out -> src.copyTo(out) } }
|
||||
dest.setExecutable(true)
|
||||
}
|
||||
Log.i(TAG, "ready: $libName -> ${dest.absolutePath} (${dest.length()} bytes) canExec=${dest.canExecute()}")
|
||||
return dest.absolutePath
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package com.example.androidbackupgui.backup
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* 类型安全的包名包装。
|
||||
*
|
||||
* 使用 [value] 获取原始字符串,用于 Android API 调用和 shell 命令。
|
||||
*
|
||||
* 构造函数验证包名格式符合 Android 命名规范(字母开头、包含至少一个点、
|
||||
* 仅包含字母数字下划线连字符和点),以防止注入攻击和防止 shell 转义绕过。
|
||||
*
|
||||
* 如果包名来源不可信,请使用 [PackageName.safe] 安全创建。
|
||||
*/
|
||||
@JvmInline
|
||||
@Serializable
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 类型安全的用户 ID 包装。
|
||||
*
|
||||
* 使用 [value] 获取原始整数值。默认值 0 表示主用户 (Owner)。
|
||||
*/
|
||||
@JvmInline
|
||||
@Serializable
|
||||
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 {
|
||||
val Owner = UserId(0)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.example.androidbackupgui.backup
|
||||
|
||||
import java.util.Locale
|
||||
|
||||
/** Format byte count to human-readable string (e.g. "1.5 MB"). */
|
||||
fun formatSize(bytes: Long): String {
|
||||
if (bytes < 1024) return "$bytes B"
|
||||
val units = arrayOf("KB", "MB", "GB", "TB")
|
||||
val exp = (63 - bytes.countLeadingZeroBits()) / 10
|
||||
val value = bytes.toDouble() / (1L shl (exp * 10))
|
||||
return "%.1f %s".format(Locale.US, value, units[exp - 1].coerceAtMost(units.last()))
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package com.example.androidbackupgui.backup
|
||||
|
||||
import android.util.Log
|
||||
import java.io.File
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
/**
|
||||
* File-based logger with rotation support.
|
||||
* Writes logs to [baseDir]/logs/YYYY-MM-dd.log, keeping up to [maxDays] days.
|
||||
* Also dispatches to Android Logcat for real-time visibility.
|
||||
*/
|
||||
object LogUtil {
|
||||
|
||||
private const val TAG = "LogUtil"
|
||||
private const val MAX_DAYS = 7
|
||||
|
||||
private var baseDir: File? = null
|
||||
private val executor = Executors.newSingleThreadExecutor()
|
||||
private val dateFormat = SimpleDateFormat("yyyy-MM-dd", Locale.US)
|
||||
private val timestampFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.US)
|
||||
|
||||
fun init(baseDir: File) {
|
||||
this.baseDir = baseDir
|
||||
executor.execute { rotateLogs() }
|
||||
}
|
||||
|
||||
fun i(tag: String, message: String) {
|
||||
Log.i(tag, message)
|
||||
writeLog("I", tag, message)
|
||||
}
|
||||
|
||||
fun w(tag: String, message: String) {
|
||||
Log.w(tag, message)
|
||||
writeLog("W", tag, message)
|
||||
}
|
||||
|
||||
fun e(tag: String, message: String) {
|
||||
Log.e(tag, message)
|
||||
writeLog("E", tag, message)
|
||||
}
|
||||
|
||||
private fun writeLog(level: String, tag: String, message: String) {
|
||||
val dir = baseDir ?: return
|
||||
executor.execute {
|
||||
try {
|
||||
val today = dateFormat.format(Date())
|
||||
val logFile = File(File(dir, "logs"), "$today.log")
|
||||
logFile.parentFile?.mkdirs()
|
||||
val timestamp = timestampFormat.format(Date())
|
||||
val line = "$timestamp $level/$tag: $message\n"
|
||||
logFile.appendText(line)
|
||||
} catch (_: Exception) {
|
||||
// Silently fail — logging should never crash the app
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun rotateLogs() {
|
||||
val dir = baseDir ?: return
|
||||
val logDir = File(dir, "logs")
|
||||
if (!logDir.exists()) return
|
||||
|
||||
val cutoff = System.currentTimeMillis() - MAX_DAYS * 24L * 60 * 60 * 1000
|
||||
logDir.listFiles()
|
||||
?.filter { it.name.endsWith(".log") }
|
||||
?.forEach { file ->
|
||||
if (file.lastModified() < cutoff) {
|
||||
file.delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Get all log files sorted by name (date ascending). */
|
||||
fun getLogFiles(): List<File> {
|
||||
val dir = baseDir ?: return emptyList()
|
||||
val logDir = File(dir, "logs")
|
||||
return logDir.listFiles()
|
||||
?.filter { it.name.endsWith(".log") }
|
||||
?.sortedBy { it.name }
|
||||
?: emptyList()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
package com.example.androidbackupgui.backup
|
||||
|
||||
import android.util.Log
|
||||
import org.bouncycastle.crypto.digests.MD4Digest
|
||||
import org.bouncycastle.crypto.engines.AESEngine
|
||||
import org.bouncycastle.crypto.macs.CMac
|
||||
import org.bouncycastle.crypto.params.KeyParameter
|
||||
import java.security.MessageDigest
|
||||
import java.security.MessageDigestSpi
|
||||
import java.security.Provider
|
||||
import java.security.Security
|
||||
import java.security.spec.AlgorithmParameterSpec
|
||||
import javax.crypto.MacSpi
|
||||
|
||||
/**
|
||||
* Injects missing algorithms (MD4, AESCMAC) into Android's BC provider
|
||||
* for jcifs-ng SMB support.
|
||||
*
|
||||
* jcifs-ng instantiates [BouncyCastleProvider] and requests algorithms
|
||||
* ([MessageDigest]"MD4", [Mac]"AESCMAC") that Android's built-in BC
|
||||
* has removed. The BouncyCastleProvider class is shadowed by the boot
|
||||
* classloader, so we patch `jcifs.util.Crypto.provider` via reflection.
|
||||
*/
|
||||
object MissingAlgoProvider {
|
||||
|
||||
private const val TAG = "MissingAlgoProvider"
|
||||
private val registered = java.util.concurrent.atomic.AtomicBoolean(false)
|
||||
|
||||
private val patchProvider: Provider by lazy {
|
||||
val bc = Security.getProvider("BC")
|
||||
DelegatingBcProvider(bc)
|
||||
}
|
||||
|
||||
fun register() {
|
||||
if (!registered.compareAndSet(false, true)) return
|
||||
try {
|
||||
// 1. Replace cached provider in jcifs-ng classes
|
||||
for (cn in listOf(
|
||||
"jcifs.util.Crypto",
|
||||
"jcifs.smb.NtlmUtil",
|
||||
"jcifs.smb.NtlmPasswordAuthenticator",
|
||||
"jcifs.ntlmssp.Type3Message",
|
||||
"jcifs.smb.NtlmContext"
|
||||
)) setProviderField(cn)
|
||||
|
||||
// 2. Verify
|
||||
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}, " +
|
||||
"hasAESCMAC=${actual.getService("Mac", "AESCMAC") != null})")
|
||||
} 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)
|
||||
}
|
||||
}
|
||||
|
||||
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, patchProvider)
|
||||
Log.i(TAG, "Set $clsName.${f.name} = DelegatingBcProvider")
|
||||
return
|
||||
}
|
||||
}
|
||||
Log.i(TAG, "No static Provider field in $clsName")
|
||||
} catch (_: ClassNotFoundException) {
|
||||
Log.i(TAG, "Class not found: $clsName")
|
||||
}
|
||||
}
|
||||
|
||||
// ── MD4 MessageDigestSpi ────────────────────────────────────
|
||||
|
||||
class Md4Spi : 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() }
|
||||
}
|
||||
|
||||
// ── AESCMAC MacSpi ─────────────────────────────────────────
|
||||
class AesCmacSpi : MacSpi() {
|
||||
private val mac = CMac(AESEngine.newInstance())
|
||||
override fun engineInit(key: java.security.Key, params: AlgorithmParameterSpec?) {
|
||||
val raw = key.encoded ?: throw java.security.InvalidKeyException("AESCMAC key has no encoded form")
|
||||
mac.init(KeyParameter(raw))
|
||||
}
|
||||
override fun engineUpdate(inp: Byte) { mac.update(inp) }
|
||||
override fun engineUpdate(inp: ByteArray, o: Int, l: Int) { mac.update(inp, o, l) }
|
||||
override fun engineDoFinal(): ByteArray {
|
||||
val r = ByteArray(mac.macSize); mac.doFinal(r, 0); return r
|
||||
}
|
||||
override fun engineGetMacLength() = mac.macSize
|
||||
override fun engineReset() { mac.reset() }
|
||||
}
|
||||
|
||||
// ── Delegating provider ─────────────────────────────────────
|
||||
|
||||
/** A "BC"-named provider that delegates to [bc] except for patched algorithms. */
|
||||
private class DelegatingBcProvider(
|
||||
private val bc: Provider?
|
||||
) : Provider("BC", bc?.version ?: 1.0, "BC + patches") {
|
||||
|
||||
init {
|
||||
putService(Service(this, "MessageDigest", "MD4",
|
||||
Md4Spi::class.java.name, null, null))
|
||||
putService(Service(this, "Mac", "AESCMAC",
|
||||
AesCmacSpi::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)
|
||||
if (type == "Mac" && algorithm.equals("AESCMAC", 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 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()
|
||||
}
|
||||
}
|
||||
@@ -1,196 +0,0 @@
|
||||
package com.example.androidbackupgui.backup
|
||||
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Manages remote transport lifecycle (SMB/WebDAV) and local temp repo sync.
|
||||
*
|
||||
* For SMB/WebDAV backends, restic runs against a local temp directory;
|
||||
* [RemoteTransport] syncs files to/from the remote backend.
|
||||
*
|
||||
* All sync operations are serialized via [repoSyncMutex] so concurrent
|
||||
* operations don't corrupt the local temp repo.
|
||||
*/
|
||||
class RemoteSyncManager {
|
||||
|
||||
private val TAG = "ResticWrapper"
|
||||
|
||||
/** Local temp directory used as restic repo for SMB/WebDAV backends. */
|
||||
@Volatile
|
||||
var tempRepoDir: String = ""
|
||||
|
||||
/** Domain for SMB NTLM authentication. */
|
||||
@Volatile
|
||||
var backendDomain: String = ""
|
||||
|
||||
// ── Transport cache ──────────────────────────────────
|
||||
@Volatile private var transport: RemoteTransport? = null
|
||||
private var transportConfigKey: String = ""
|
||||
private val transportLock = Any()
|
||||
|
||||
/** Serializes access to tempRepoDir so concurrent operations don't corrupt each other. */
|
||||
private val repoSyncMutex = Mutex()
|
||||
|
||||
// ── Transport lifecycle ──────────────────────────────
|
||||
|
||||
private fun ensureTransport(
|
||||
backend: String, url: String, user: String, pass: String, share: String, repoPath: String
|
||||
): RemoteTransport? = synchronized(transportLock) {
|
||||
val key = "$backend|$url|$user|$pass|$share|$backendDomain|$repoPath"
|
||||
if (key != transportConfigKey || transport == null) {
|
||||
transport?.let { Log.i(TAG, "transport config changed ($transportConfigKey -> $key), recreating") }
|
||||
// Clear local temp repo when backend config changes so
|
||||
// syncFromRemote downloads fresh data from the new backend
|
||||
if (transportConfigKey.isNotEmpty() && tempRepoDir.isNotEmpty()) {
|
||||
val dir = File(tempRepoDir)
|
||||
val deleted = dir.deleteRecursively()
|
||||
Log.i(TAG, "cleared local temp repo: $tempRepoDir (deleted=$deleted)")
|
||||
dir.mkdirs()
|
||||
}
|
||||
transport = RemoteTransport.create(backend, url, user, pass, share, backendDomain)
|
||||
if (transport != null) {
|
||||
transportConfigKey = key
|
||||
Log.i(TAG, "transport created: $backend @ $url repo=$repoPath domain=$backendDomain")
|
||||
} else {
|
||||
Log.e(TAG, "transport creation failed for backend=$backend url=$url")
|
||||
}
|
||||
}
|
||||
return transport
|
||||
}
|
||||
|
||||
// ── Temp dir lifecycle ───────────────────────────────
|
||||
|
||||
/** Clean up local temp repo and cache directories. */
|
||||
private fun cleanupTempDirs() {
|
||||
if (tempRepoDir.isEmpty()) return
|
||||
try {
|
||||
val repoDir = File(tempRepoDir)
|
||||
if (repoDir.exists()) {
|
||||
val deleted = repoDir.deleteRecursively()
|
||||
Log.i(TAG, "cleanupTempDirs: deleted $tempRepoDir ($deleted)")
|
||||
}
|
||||
val cacheDir = File(tempRepoDir.substringBeforeLast("/") + "/restic_cache")
|
||||
if (cacheDir.exists()) {
|
||||
val deleted = cacheDir.deleteRecursively()
|
||||
Log.i(TAG, "cleanupTempDirs: deleted cache $cacheDir ($deleted)")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "cleanupTempDirs failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/** True if [tempRepoDir] already contains an initialized restic repository (has a config file). */
|
||||
private fun isLocalRepoPopulated(): Boolean {
|
||||
if (tempRepoDir.isEmpty()) return false
|
||||
return File(tempRepoDir, "config").isFile
|
||||
}
|
||||
|
||||
// ── Sync engine ──────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Execute [action] with remote repo synced before/after as needed.
|
||||
* For local/rest-server backends, executes [action] directly without sync.
|
||||
* Protected by [repoSyncMutex] so concurrent operations don't corrupt tempRepoDir.
|
||||
*
|
||||
* Cleanup strategy:
|
||||
* - Write ops (needsUpload=true): cleanup only on successful sync to remote.
|
||||
* On syncToRemote failure the local repo is preserved so the next
|
||||
* operation can retry — destroying it would lose the just-created snapshot.
|
||||
* - Read-only ops (needsUpload=false): keep local cache for subsequent operations.
|
||||
* - Read-only ops skip download entirely if local repo is already populated.
|
||||
*/
|
||||
suspend fun <T> withRemoteSync(
|
||||
backend: String,
|
||||
backendUrl: String,
|
||||
backendUser: String,
|
||||
backendPass: String,
|
||||
backendShare: String,
|
||||
repoPath: String,
|
||||
needsDownload: Boolean,
|
||||
needsUpload: Boolean,
|
||||
onProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
|
||||
onByteProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
|
||||
action: suspend () -> Result<T>
|
||||
): Result<T> {
|
||||
if (backend != "smb" && backend != "webdav") return action()
|
||||
|
||||
return repoSyncMutex.withLock {
|
||||
var shouldCleanup = false
|
||||
try {
|
||||
val t = ensureTransport(backend, backendUrl, backendUser, backendPass, backendShare, repoPath)
|
||||
?: return@withLock Result.failure(Exception("Failed to create transport for backend: $backend"))
|
||||
|
||||
val localDir = File(tempRepoDir)
|
||||
|
||||
val emitProgress: suspend (RemoteTransport.TransferProgress) -> Unit = { p ->
|
||||
withContext(Dispatchers.Main) { onProgress(p) }
|
||||
}
|
||||
|
||||
// Write ops always download to avoid overwriting remote changes.
|
||||
// Read-only ops skip download if local repo is already present.
|
||||
val actualDownload = needsDownload && (needsUpload || !isLocalRepoPopulated())
|
||||
if (actualDownload) {
|
||||
Log.i(TAG, "syncFromRemote start: $repoPath -> $tempRepoDir")
|
||||
val syncResult = RemoteTransport.syncFromRemote(t, localDir, repoPath, emitProgress, onByteProgress)
|
||||
if (syncResult.isFailure) {
|
||||
shouldCleanup = true
|
||||
Log.e(TAG, "syncFromRemote FAILED: ${syncResult.exceptionOrNull()?.message}")
|
||||
return@withLock Result.failure(
|
||||
Exception("syncFromRemote failed: ${syncResult.exceptionOrNull()?.message}")
|
||||
)
|
||||
}
|
||||
Log.i(TAG, "syncFromRemote complete")
|
||||
} else if (needsDownload) {
|
||||
Log.i(TAG, "syncFromRemote skipped: local repo already populated")
|
||||
}
|
||||
|
||||
val result = action()
|
||||
|
||||
if (needsUpload && result.isSuccess) {
|
||||
Log.i(TAG, "syncToRemote start: $tempRepoDir -> $repoPath")
|
||||
val uploadResult = RemoteTransport.syncToRemote(t, localDir, repoPath, emitProgress, onByteProgress)
|
||||
if (uploadResult.isFailure) {
|
||||
shouldCleanup = false // PRESERVE local repo — snapshot would be lost
|
||||
Log.e(TAG, "syncToRemote FAILED: ${uploadResult.exceptionOrNull()?.message} — local repo preserved for retry")
|
||||
return@withLock Result.failure(
|
||||
Exception("syncToRemote failed: ${uploadResult.exceptionOrNull()?.message}")
|
||||
)
|
||||
}
|
||||
Log.i(TAG, "syncToRemote complete")
|
||||
shouldCleanup = true
|
||||
} else if (result.isFailure) {
|
||||
shouldCleanup = true
|
||||
}
|
||||
|
||||
result
|
||||
} catch (e: CancellationException) {
|
||||
shouldCleanup = true
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
shouldCleanup = true
|
||||
Result.failure(e)
|
||||
} finally {
|
||||
if (shouldCleanup) {
|
||||
Log.i(TAG, "withRemoteSync: cleaning up temp dirs")
|
||||
cleanupTempDirs()
|
||||
} else {
|
||||
Log.d(TAG, "withRemoteSync: keeping local repo for subsequent ops")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Public safety-net cleanup called by fragment lifecycle.
|
||||
* Waits for any in-progress operation to finish, then deletes temp dirs.
|
||||
*/
|
||||
suspend fun cleanup() {
|
||||
repoSyncMutex.withLock { cleanupTempDirs() }
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,7 @@
|
||||
package com.example.androidbackupgui.backup
|
||||
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/** Thrown by transports when a remote directory genuinely does not exist (HTTP 404). */
|
||||
class FileNotFoundException(path: String) : Exception("Directory not found: $path")
|
||||
|
||||
/**
|
||||
* Unified abstraction for remote file transport (SMB / WebDAV).
|
||||
@@ -38,67 +31,21 @@ interface RemoteTransport {
|
||||
val currentFile: String
|
||||
)
|
||||
|
||||
suspend fun upload(localPath: String, remotePath: String, onProgress: suspend (TransferProgress) -> Unit = {}, onByteProgress: suspend (ByteProgress) -> Unit = {}): Result<Unit>
|
||||
suspend fun download(remotePath: String, localPath: String, onProgress: suspend (TransferProgress) -> Unit = {}, onByteProgress: suspend (ByteProgress) -> Unit = {}): Result<Unit>
|
||||
suspend fun upload(localPath: String, remotePath: String, onProgress: suspend (TransferProgress) -> Unit = {}, onByteProgress: suspend (ByteProgress) -> Unit = {}): AppResult<Unit>
|
||||
suspend fun download(remotePath: String, localPath: String, onProgress: suspend (TransferProgress) -> Unit = {}, onByteProgress: suspend (ByteProgress) -> Unit = {}): AppResult<Unit>
|
||||
|
||||
/** List entries in a remote directory (files and subdirectories). */
|
||||
suspend fun listFiles(remoteDir: String): Result<List<RemoteFileInfo>>
|
||||
suspend fun listFiles(remoteDir: String): AppResult<List<RemoteFileInfo>>
|
||||
|
||||
/** Create a directory and any missing parents on the remote. */
|
||||
suspend fun mkdirs(remotePath: String): Result<Unit>
|
||||
suspend fun mkdirs(remotePath: String): AppResult<Unit>
|
||||
|
||||
suspend fun delete(remotePath: String): Result<Unit>
|
||||
suspend fun exists(remotePath: String): Result<Boolean>
|
||||
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 {
|
||||
private const val TAG = "RemoteTransport"
|
||||
private const val MAX_RETRIES = 3
|
||||
|
||||
/**
|
||||
* Returns true if the exception indicates a transient error worth retrying
|
||||
* (network blip, DNS hiccup, server 5xx), false for permanent errors (4xx).
|
||||
*/
|
||||
private fun isTransientError(e: Exception): Boolean {
|
||||
val msg = (e.message ?: "") + (e.cause?.message ?: "")
|
||||
// DNS / network-layer failures
|
||||
if (msg.contains("Unable to resolve host", ignoreCase = true)) return true
|
||||
if (msg.contains("No address associated", ignoreCase = true)) return true
|
||||
if (msg.contains("ConnectException", ignoreCase = true)) return true
|
||||
if (msg.contains("SocketTimeoutException", ignoreCase = true)) return true
|
||||
if (msg.contains("timeout", ignoreCase = true)) return true
|
||||
if (msg.contains("Connection refused", ignoreCase = true)) return true
|
||||
if (msg.contains("Network is unreachable", ignoreCase = true)) return true
|
||||
// 5xx server errors (502 Bad Gateway, 503 Service Unavailable, 504 Gateway Timeout)
|
||||
if (Regex("\\b5\\d{2}\\b").containsMatchIn(msg)) return true
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute [block] with retries on transient failures.
|
||||
* Uses exponential backoff: 1s, 2s, 4s.
|
||||
*/
|
||||
private suspend fun <T> withRetry(
|
||||
tag: String,
|
||||
block: suspend () -> Result<T>
|
||||
): Result<T> {
|
||||
var lastError: Result<T>? = null
|
||||
for (attempt in 0..MAX_RETRIES) {
|
||||
if (attempt > 0) {
|
||||
val waitMs = 1000L * (1 shl (attempt - 1)) // 1s, 2s, 4s
|
||||
Log.w(TAG, "$tag retry $attempt/$MAX_RETRIES after ${waitMs}ms")
|
||||
delay(waitMs)
|
||||
}
|
||||
val result = block()
|
||||
if (result.isSuccess) return result
|
||||
val err = result.exceptionOrNull()
|
||||
if (err != null && err is Exception && isTransientError(err)) {
|
||||
lastError = result
|
||||
continue
|
||||
}
|
||||
return result // permanent error — don't retry
|
||||
}
|
||||
return lastError ?: Result.failure(Exception("$tag: max retries exceeded"))
|
||||
}
|
||||
|
||||
fun create(
|
||||
backend: String,
|
||||
@@ -120,244 +67,6 @@ interface RemoteTransport {
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Download all files from remote [remoteDir] into [localDir] recursively,
|
||||
* skipping files that already exist locally with the same size.
|
||||
* Deletes local files no longer present on the remote.
|
||||
* Returns failure if any download fails.
|
||||
*/
|
||||
suspend fun syncFromRemote(
|
||||
transport: RemoteTransport,
|
||||
localDir: File,
|
||||
remoteDir: String,
|
||||
onProgress: suspend (TransferProgress) -> Unit = {},
|
||||
onByteProgress: suspend (ByteProgress) -> Unit = {}
|
||||
): Result<Unit> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
localDir.mkdirs()
|
||||
val remoteFiles = listRemoteRecursive(transport, remoteDir)
|
||||
// Root dir not found (404): treat as empty remote — nothing to download.
|
||||
// This is normal for first-time init where the repo doesn't exist yet.
|
||||
if (remoteFiles == null) {
|
||||
Log.w(TAG, "syncFromRemote: remote dir '$remoteDir' not accessible, treating as empty")
|
||||
return@withContext Result.success(Unit)
|
||||
}
|
||||
onProgress(TransferProgress("list", 0, remoteFiles.size))
|
||||
val remoteByPath = remoteFiles.associateBy { it.path }
|
||||
val errors = mutableListOf<String>()
|
||||
|
||||
// Download remote files that are new or have different size
|
||||
var downloaded = 0
|
||||
val syncTotal = remoteFiles.size
|
||||
for ((relPath, info) in remoteByPath) {
|
||||
downloaded++
|
||||
onProgress(TransferProgress("download", downloaded, syncTotal, relPath))
|
||||
val localFile = File(localDir, relPath)
|
||||
if (localFile.isFile && localFile.length() == info.size) {
|
||||
Log.d(TAG, "syncFromRemote skip (same size): $relPath")
|
||||
continue
|
||||
}
|
||||
localFile.parentFile?.mkdirs()
|
||||
val fullRemotePath = "$remoteDir/$relPath"
|
||||
Log.i(TAG, "syncFromRemote downloading: $fullRemotePath (${info.size} bytes)")
|
||||
val result = withRetry("download($fullRemotePath)") {
|
||||
transport.download(fullRemotePath, localFile.absolutePath, onProgress, onByteProgress)
|
||||
}
|
||||
if (result.isFailure) {
|
||||
errors.add("$fullRemotePath: ${result.exceptionOrNull()?.message}")
|
||||
}
|
||||
}
|
||||
|
||||
// If any download failed, abort before deleting local files —
|
||||
// deleting would destroy valid data for an incomplete sync.
|
||||
if (errors.isNotEmpty()) {
|
||||
return@withContext Result.failure(
|
||||
Exception("syncFromRemote: ${errors.size} file(s) failed: ${errors.joinToString("; ")}")
|
||||
)
|
||||
}
|
||||
|
||||
// Delete local files not on remote (e.g. after prune on another client)
|
||||
val localFiles = walkLocalFiles(localDir)
|
||||
val staleLocalPaths = localFiles.keys.filter { it !in remoteByPath }
|
||||
val staleCount = staleLocalPaths.size
|
||||
for ((staleIdx, relPath) in staleLocalPaths.withIndex()) {
|
||||
onProgress(TransferProgress("delete_stale", staleIdx + 1, staleCount))
|
||||
val localFile = localFiles[relPath]!!
|
||||
Log.i(TAG, "syncFromRemote deleting stale local: $relPath")
|
||||
try { localFile.delete() } catch (_: Exception) {}
|
||||
}
|
||||
onProgress(TransferProgress("complete", syncTotal, syncTotal))
|
||||
Result.success(Unit)
|
||||
} catch (e: Exception) {
|
||||
Result.failure(Exception("syncFromRemote failed: ${e.message}", e))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload all files from [localDir] into [remoteDir] recursively,
|
||||
* skipping files that already exist remotely with the same size.
|
||||
* Deletes remote files that no longer exist locally.
|
||||
* Returns failure if any upload fails.
|
||||
*/
|
||||
suspend fun syncToRemote(
|
||||
transport: RemoteTransport,
|
||||
localDir: File,
|
||||
remoteDir: String,
|
||||
onProgress: suspend (TransferProgress) -> Unit = {},
|
||||
onByteProgress: suspend (ByteProgress) -> Unit = {}
|
||||
): Result<Unit> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val localFiles = walkLocalFiles(localDir)
|
||||
onProgress(TransferProgress("list", 0, localFiles.size))
|
||||
val remoteResult = listRemoteRecursive(transport, remoteDir)
|
||||
// If the remote dir is not accessible (404 or network error), treat as empty.
|
||||
// Any real upload errors will surface during the actual file uploads below.
|
||||
if (remoteResult == null) {
|
||||
Log.w(TAG, "syncToRemote: remote dir '$remoteDir' not accessible, treating as empty")
|
||||
}
|
||||
val remoteByPath = (remoteResult ?: emptyList()).associateBy { it.path }
|
||||
val errors = mutableListOf<String>()
|
||||
|
||||
// Collect unique parent directories that need to exist on remote
|
||||
val remoteDirs = mutableSetOf<String>()
|
||||
for (relPath in localFiles.keys) {
|
||||
val parent = relPath.substringBeforeLast("/", "")
|
||||
if (parent.isNotEmpty()) remoteDirs.add(parent)
|
||||
}
|
||||
|
||||
// Ensure all remote directories exist
|
||||
for (dir in remoteDirs) {
|
||||
transport.mkdirs("$remoteDir/$dir")
|
||||
}
|
||||
|
||||
// Upload new or changed local files
|
||||
var uploaded = 0
|
||||
val syncTotal = localFiles.size
|
||||
for ((relPath, localFile) in localFiles) {
|
||||
uploaded++
|
||||
onProgress(TransferProgress("upload", uploaded, syncTotal, relPath))
|
||||
val remoteInfo = remoteByPath[relPath]
|
||||
if (remoteInfo != null && remoteInfo.size == localFile.length()) {
|
||||
Log.d(TAG, "syncToRemote skip (same size): $relPath")
|
||||
continue
|
||||
}
|
||||
val fullRemotePath = "$remoteDir/$relPath"
|
||||
Log.i(TAG, "syncToRemote uploading: $fullRemotePath (${localFile.length()} bytes)")
|
||||
val result = withRetry("upload($fullRemotePath)") {
|
||||
transport.upload(localFile.absolutePath, fullRemotePath, onProgress, onByteProgress)
|
||||
}
|
||||
if (result.isFailure) {
|
||||
errors.add("$fullRemotePath: ${result.exceptionOrNull()?.message}")
|
||||
}
|
||||
}
|
||||
|
||||
// If any upload failed, abort before deleting remote files —
|
||||
// deleting during failed sync could lose the only copy on remote.
|
||||
if (errors.isNotEmpty()) {
|
||||
return@withContext Result.failure(
|
||||
Exception("syncToRemote: ${errors.size} file(s) failed: ${errors.joinToString("; ")}")
|
||||
)
|
||||
}
|
||||
|
||||
// Delete remote files no longer present locally
|
||||
val staleRemotePaths = remoteByPath.keys.filter { it !in localFiles }
|
||||
val staleCount = staleRemotePaths.size
|
||||
for ((staleIdx, relPath) in staleRemotePaths.withIndex()) {
|
||||
onProgress(TransferProgress("delete_stale", staleIdx + 1, staleCount))
|
||||
Log.i(TAG, "syncToRemote deleting stale: $relPath")
|
||||
transport.delete("$remoteDir/$relPath")
|
||||
}
|
||||
onProgress(TransferProgress("complete", localFiles.size, localFiles.size))
|
||||
Result.success(Unit)
|
||||
} catch (e: Exception) {
|
||||
Result.failure(Exception("syncToRemote failed: ${e.message}", e))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
private data class FlatFileInfo(val path: String, val size: Long)
|
||||
|
||||
/** Recursively list all files on the remote. Returns null on failure.
|
||||
* Depth-limited to avoid redundant requests on servers that report
|
||||
* files as directories or return self-referencing PROPFIND entries. */
|
||||
private const val MAX_RECURSE_DEPTH = 3
|
||||
|
||||
private suspend fun listRemoteRecursive(
|
||||
transport: RemoteTransport,
|
||||
remoteDir: String
|
||||
): List<FlatFileInfo>? {
|
||||
val result = mutableListOf<FlatFileInfo>()
|
||||
// Pair of (relativePath, depth)
|
||||
val dirsToVisit = mutableListOf("" to 0)
|
||||
|
||||
while (dirsToVisit.isNotEmpty()) {
|
||||
val (subDir, depth) = dirsToVisit.removeLast()
|
||||
if (depth >= MAX_RECURSE_DEPTH) {
|
||||
Log.w(TAG, "listRemoteRecursive: max depth $MAX_RECURSE_DEPTH reached at $remoteDir/$subDir")
|
||||
continue
|
||||
}
|
||||
val fullDir = if (subDir.isEmpty()) remoteDir else "$remoteDir/$subDir"
|
||||
val listResult = withRetry("listFiles($fullDir)") {
|
||||
transport.listFiles(fullDir)
|
||||
}
|
||||
if (listResult.isFailure) {
|
||||
val err = listResult.exceptionOrNull()
|
||||
// 404 on a subdirectory: directory doesn't exist, skip it silently.
|
||||
// 404 on the root directory: fatal — the remote repo path may be wrong.
|
||||
if (err is FileNotFoundException) {
|
||||
if (subDir.isEmpty()) {
|
||||
Log.e(TAG, "listRemoteRecursive: root dir '$fullDir' returned 404 — repo may not exist or is rate-limited")
|
||||
return null
|
||||
}
|
||||
Log.d(TAG, "listRemoteRecursive: $fullDir -> 404, skipping")
|
||||
continue
|
||||
}
|
||||
Log.e(TAG, "listRemoteRecursive: listFiles FAILED for '$fullDir': ${err?.message}")
|
||||
return null
|
||||
}
|
||||
val entries = listResult.getOrThrow()
|
||||
val parentName = subDir.substringAfterLast("/", subDir)
|
||||
|
||||
for (entry in entries) {
|
||||
val relPath = if (subDir.isEmpty()) entry.name else "$subDir/${entry.name}"
|
||||
if (entry.isDirectory) {
|
||||
// Skip self-referencing entries where the server returns
|
||||
// the directory itself as a child (e.g. data/f9/ contains "f9")
|
||||
if (entry.name == parentName) {
|
||||
Log.d(TAG, "listRemoteRecursive skip self-ref: $relPath")
|
||||
continue
|
||||
}
|
||||
dirsToVisit.add(relPath to depth + 1)
|
||||
} else {
|
||||
result.add(FlatFileInfo(relPath, entry.size))
|
||||
}
|
||||
}
|
||||
}
|
||||
Log.i(TAG, "listRemoteRecursive: $remoteDir → ${result.size} files in ${result.map { it.path }.toSet().size} paths")
|
||||
return result
|
||||
}
|
||||
|
||||
/** Walk the local directory tree, returning relative-path → File mapping for all files. */
|
||||
private fun walkLocalFiles(localDir: File): Map<String, File> {
|
||||
val result = mutableMapOf<String, File>()
|
||||
val dirsToVisit = mutableListOf(localDir)
|
||||
val basePath = localDir.absolutePath
|
||||
|
||||
while (dirsToVisit.isNotEmpty()) {
|
||||
val dir = dirsToVisit.removeLast()
|
||||
for (file in dir.listFiles() ?: emptyArray()) {
|
||||
if (file.isDirectory) {
|
||||
dirsToVisit.add(file)
|
||||
} else {
|
||||
val relPath = file.absolutePath.removePrefix("$basePath/")
|
||||
result[relPath] = file
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
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:
|
||||
* 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
|
||||
* ```
|
||||
*/
|
||||
class RestBridgeRunner {
|
||||
|
||||
private val TAG = "RestBridgeRunner"
|
||||
|
||||
/** Cached transport to reuse SMB sessions across bridge instances. */
|
||||
private var cachedTransport: RemoteTransport? = null
|
||||
private var cachedTransportKey: String? = null
|
||||
|
||||
/**
|
||||
* Start a REST bridge for the given [backend], execute [block] with the
|
||||
* bridge URL, then stop and clean up.
|
||||
*
|
||||
* For [backend] == "local", the bridge is not started and [block] receives
|
||||
* `null`.
|
||||
*/
|
||||
suspend fun <T> withBridge(
|
||||
backend: String,
|
||||
backendUrl: String,
|
||||
backendUser: String,
|
||||
backendPass: String,
|
||||
backendShare: String,
|
||||
backendDomain: String,
|
||||
repoPath: String,
|
||||
cacheDir: File,
|
||||
transportFactory: (
|
||||
backend: String,
|
||||
url: String,
|
||||
user: String,
|
||||
pass: String,
|
||||
share: String,
|
||||
domain: String
|
||||
) -> RemoteTransport? = ::createTransport,
|
||||
block: suspend (bridgeUrl: String, authToken: String) -> T
|
||||
): T {
|
||||
if (backend == "local") {
|
||||
return block(repoPath, "")
|
||||
}
|
||||
|
||||
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, "")
|
||||
cachedTransport = t
|
||||
cachedTransportKey = key
|
||||
}
|
||||
val transport = cachedTransport!!
|
||||
|
||||
val remoteBase = buildRemoteBase(backend, backendUrl, backendShare, repoPath)
|
||||
val bridge = ResticRestBridge(transport, remoteBase, repoPath, cacheDir, authToken)
|
||||
|
||||
try {
|
||||
bridge.start(0)
|
||||
val port = bridge.listeningPort
|
||||
if (port < 0) {
|
||||
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 (auth=${authToken.take(8)}…)")
|
||||
return block(bridgeUrl, authToken)
|
||||
} finally {
|
||||
try {
|
||||
bridge.stop()
|
||||
} catch (_: Exception) {}
|
||||
Log.d(TAG, "REST bridge stopped")
|
||||
val blobs = cacheDir.listFiles { f -> f.name.startsWith("restic_blob_") }
|
||||
if (blobs != null) {
|
||||
for (f in blobs) f.delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Build the remote base path for the REST bridge. */
|
||||
private fun buildRemoteBase(
|
||||
backend: String,
|
||||
backendUrl: String,
|
||||
backendShare: String,
|
||||
repoPath: String
|
||||
): String {
|
||||
return when (backend) {
|
||||
"smb" -> "smb://${backendUrl.trimEnd('/')}/$backendShare/$repoPath"
|
||||
"webdav" -> "${backendUrl.trimEnd('/')}/${repoPath.trimStart('/')}"
|
||||
else -> repoPath
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** Default transport factory: delegates to [RemoteTransport.create]. */
|
||||
fun createTransport(
|
||||
backend: String,
|
||||
url: String,
|
||||
user: String,
|
||||
pass: String,
|
||||
share: String,
|
||||
domain: String
|
||||
): RemoteTransport? {
|
||||
return RemoteTransport.create(backend, url, user, pass, share, domain)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,27 +1,30 @@
|
||||
package com.example.androidbackupgui.backup
|
||||
|
||||
import android.util.Log
|
||||
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 kotlinx.serialization.json.Json
|
||||
import java.io.File
|
||||
import kotlin.coroutines.coroutineContext
|
||||
|
||||
/** Shared Json instance configured for restic's snake_case output via @SerialName. */
|
||||
private val resticJson = Json { ignoreUnknownKeys = true }
|
||||
|
||||
/**
|
||||
* Backup operations: running restic backup and parsing its summary output.
|
||||
*
|
||||
* Delegates execution to [ResticCommandRunner], [ResticEnvResolver], and
|
||||
* [RemoteSyncManager] which are shared across sub-modules.
|
||||
* 使用 [BackendExecutor] 统一处理 local/remote 后端。
|
||||
*/
|
||||
class ResticBackup(
|
||||
private val runner: ResticCommandRunner,
|
||||
private val envResolver: ResticEnvResolver,
|
||||
private val syncManager: RemoteSyncManager
|
||||
private val bridgeRunner: RestBridgeRunner,
|
||||
private val executor: BackendExecutor = BackendExecutor(),
|
||||
) {
|
||||
private val TAG = "ResticWrapper"
|
||||
private val TAG = "ResticBackup"
|
||||
var cacheDir: String = ""
|
||||
var backendDomain: String = ""
|
||||
|
||||
// ── Backup ─────────────────────────────────────────
|
||||
|
||||
@@ -36,51 +39,69 @@ class ResticBackup(
|
||||
backendUser: String = "",
|
||||
backendPass: String = "",
|
||||
backendShare: String = "",
|
||||
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
|
||||
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
|
||||
onProgress: suspend (ResticWrapper.ResticProgress) -> Unit = {}
|
||||
): Result<ResticWrapper.BackupSummary> = withContext(Dispatchers.IO) {
|
||||
val emit: suspend (ResticWrapper.ResticProgress) -> Unit = { p -> withContext(Dispatchers.Main) { onProgress(p) } }
|
||||
syncManager.withRemoteSync(backend, backendUrl, backendUser, backendPass, backendShare, repoPath,
|
||||
needsDownload = true, needsUpload = true,
|
||||
onProgress = onSyncProgress,
|
||||
onByteProgress = onByteSyncProgress,
|
||||
) {
|
||||
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")
|
||||
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.buildFullEnv(repoPath, password, backend, backendUrl, backendUser, backendPass, backendShare, syncManager.tempRepoDir)
|
||||
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) { /* ignore non-JSON lines */ }
|
||||
for (tag in tags) {
|
||||
args.add("--tag")
|
||||
args.add(tag)
|
||||
}
|
||||
if (hostname != null) {
|
||||
args.add("--host")
|
||||
args.add(hostname)
|
||||
}
|
||||
|
||||
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@withRemoteSync Result.failure(Exception("restic backup failed: ${result.stderr}"))
|
||||
return@withContext err(AppError.Restic("restic backup 失败", result.exitCode, result.stderr))
|
||||
}
|
||||
|
||||
parseBackupSummary(result.stdout)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Internal helpers ───────────────────────────────
|
||||
|
||||
/** Parse the JSON summary from the end of restic backup output. */
|
||||
private fun parseBackupSummary(stdout: String): Result<ResticWrapper.BackupSummary> {
|
||||
private fun parseBackupSummary(stdout: String): AppResult<ResticWrapper.BackupSummary> {
|
||||
val lines = stdout.lines()
|
||||
for (i in lines.indices.reversed()) {
|
||||
val line = lines[i].trim()
|
||||
if (!line.startsWith("{")) continue
|
||||
try {
|
||||
val summary = resticJson.decodeFromString<ResticWrapper.BackupSummary>(line)
|
||||
if (summary.snapshotId.isNotEmpty()) return Result.success(summary)
|
||||
} catch (_: Exception) { /* keep looking */ }
|
||||
if (summary.messageType == "summary" && summary.snapshotId.isNotEmpty()) return AppResult.Success(summary)
|
||||
} catch (_: Exception) {
|
||||
// keep looking
|
||||
}
|
||||
}
|
||||
return Result.failure(Exception("No summary found in restic output"))
|
||||
return err(AppError.Parse("restic 备份输出未找到摘要信息", "stdout=" + stdout.length))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,10 +18,8 @@ object ResticBinary {
|
||||
synchronized(this) {
|
||||
if (cacheInit) return cachedBinaryPath
|
||||
val nativeLibDir = context.applicationInfo.nativeLibraryDir
|
||||
Log.d(TAG, "nativeLibraryDir = $nativeLibDir")
|
||||
|
||||
val path = File(nativeLibDir, BINARY_NAME)
|
||||
Log.d(TAG, "restic: exists=${path.isFile} len=${path.length()} canExec=${path.canExecute()}")
|
||||
Log.d(TAG, "nativeLibraryDir=$nativeLibDir exists=${path.isFile} len=${path.length()} canExec=${path.canExecute()}")
|
||||
|
||||
cachedBinaryPath = if (path.isFile) {
|
||||
Log.i(TAG, "librestic.so ready at ${path.absolutePath} (${path.length()} bytes)")
|
||||
@@ -35,13 +33,6 @@ object ResticBinary {
|
||||
}
|
||||
}
|
||||
|
||||
/** Get the temp directory used as local restic repo for remote backends. */
|
||||
fun getTempRepoDir(context: Context): String {
|
||||
val dir = File(context.cacheDir, "restic_remote_repo")
|
||||
dir.mkdirs()
|
||||
Log.d(TAG, "tempRepoDir = ${dir.absolutePath}")
|
||||
return dir.absolutePath
|
||||
}
|
||||
|
||||
fun isReady(): Boolean = false // call prepare() instead
|
||||
fun isReady(): Boolean = cachedBinaryPath != null
|
||||
}
|
||||
|
||||
@@ -3,9 +3,13 @@ package com.example.androidbackupgui.backup
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.isActive
|
||||
import com.example.androidbackupgui.backup.AppError
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.BufferedReader
|
||||
import java.io.File
|
||||
import kotlin.coroutines.coroutineContext
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.InputStream
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
@@ -27,10 +31,25 @@ class ResticCommandRunner {
|
||||
)
|
||||
|
||||
/** Build the full command list to run restic. */
|
||||
fun buildCommandArgs(args: List<String>): List<String> {
|
||||
val cmd = listOf(binaryPath) + args
|
||||
Log.d(TAG, "buildCommandArgs: binaryPath=$binaryPath args=$args → cmd=$cmd")
|
||||
return cmd
|
||||
fun buildCommandArgs(args: List<String>): List<String> =
|
||||
(listOf(binaryPath) + args).also { cmd ->
|
||||
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). */
|
||||
@@ -38,32 +57,38 @@ class ResticCommandRunner {
|
||||
val cmdArgs = buildCommandArgs(args)
|
||||
Log.i(TAG, "runRestic cmd=${cmdArgs.joinToString(" ")}")
|
||||
Log.d(TAG, "runRestic REPOSITORY=${env["RESTIC_REPOSITORY"]}")
|
||||
|
||||
// NOTE: Do NOT log RESTIC_PASSWORD or any value derived from it.
|
||||
// RESTIC_REPOSITORY is safe to log (does not contain secrets).
|
||||
env["TMPDIR"]?.let { File(it).mkdirs() }
|
||||
return try {
|
||||
val pb = ProcessBuilder(cmdArgs)
|
||||
pb.environment().putAll(env)
|
||||
pb.redirectErrorStream(false)
|
||||
val process = pb.start()
|
||||
|
||||
val stderrText = StringBuilder()
|
||||
val stderrThread = Thread({
|
||||
// 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 {
|
||||
process.errorStream.bufferedReader().use { reader ->
|
||||
var line: String?
|
||||
while (reader.readLine().also { line = it } != null) {
|
||||
Log.d(TAG, "restic stderr: $line")
|
||||
stderrText.appendLine(line)
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {}
|
||||
}, "restic-stderr").apply { isDaemon = true; start() }
|
||||
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 exitCode = process.waitFor()
|
||||
stderrThread.join(5000)
|
||||
val exitCode = try {
|
||||
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}")
|
||||
CommandResult(stdout.trim(), stderrText.toString().trim(), exitCode)
|
||||
if (stderrText.isNotEmpty()) Log.w(TAG, "runRestic stderr: ${stderrText.trim()}")
|
||||
CommandResult(stdout.trim(), stderrText.trim(), exitCode)
|
||||
} catch (e: kotlinx.coroutines.CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "runRestic exception", e)
|
||||
CommandResult("", e.message ?: "Unknown error", -1)
|
||||
@@ -84,6 +109,7 @@ class ResticCommandRunner {
|
||||
val cmdArgs = buildCommandArgs(args)
|
||||
Log.i(TAG, "runResticStreaming cmd=${cmdArgs.joinToString(" ")}")
|
||||
Log.d(TAG, "runResticStreaming REPOSITORY=${env["RESTIC_REPOSITORY"]}")
|
||||
env["TMPDIR"]?.let { File(it).mkdirs() }
|
||||
|
||||
var process: Process? = null
|
||||
try {
|
||||
@@ -92,59 +118,66 @@ 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()
|
||||
val stderrReader = process.errorStream.bufferedReader()
|
||||
|
||||
val stderrText = StringBuilder()
|
||||
val stderrThread = Thread({
|
||||
try { stderrReader.use { stderrText.append(it.readText()) } } catch (_: Exception) {}
|
||||
}, "restic-stderr").apply { isDaemon = true; start() }
|
||||
|
||||
try {
|
||||
var line: String?
|
||||
while (reader.readLine().also { line = it } != null) {
|
||||
var line = reader.readLine()
|
||||
while (line != null) {
|
||||
if (!coroutineContext.isActive) {
|
||||
process.destroy()
|
||||
break
|
||||
}
|
||||
val l = line!!
|
||||
stdoutText.appendLine(l)
|
||||
onLine(l)
|
||||
stdoutText.appendLine(line)
|
||||
onLine(line)
|
||||
line = reader.readLine()
|
||||
}
|
||||
} finally {
|
||||
try { reader.close() } catch (_: Exception) {}
|
||||
}
|
||||
stderrThread.join(5000)
|
||||
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}")
|
||||
if (stderrText.isNotEmpty()) Log.w(TAG, "runResticStreaming stderr: ${stderrText}")
|
||||
CommandResult(stdoutText.toString().trim(), stderrText.toString().trim(), exitCode)
|
||||
CommandResult(stdoutText.toString().trim(), stderrText.trim(), exitCode)
|
||||
} catch (e: kotlinx.coroutines.CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "runResticStreaming 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.
|
||||
*/
|
||||
internal fun InputStream.readAllBytesCompat(): ByteArray {
|
||||
val buffer = ByteArrayOutputStream()
|
||||
val data = ByteArray(4096)
|
||||
while (true) {
|
||||
val n = read(data)
|
||||
if (n == -1) break
|
||||
buffer.write(data, 0, n)
|
||||
}
|
||||
return buffer.toByteArray()
|
||||
}
|
||||
|
||||
@@ -4,43 +4,60 @@ package com.example.androidbackupgui.backup
|
||||
* Stateless helper for constructing restic environment variables and repo URLs.
|
||||
*/
|
||||
class ResticEnvResolver {
|
||||
|
||||
/** Build environment for restic. For SMB/WebDAV backends, uses local temp dir as repo. */
|
||||
fun buildFullEnv(
|
||||
repoPath: String,
|
||||
/** Build environment for non-local backends using the REST bridge URL. */
|
||||
fun buildBridgeEnv(
|
||||
password: String,
|
||||
backend: String = "local",
|
||||
backendUrl: String = "",
|
||||
backendUser: String = "",
|
||||
backendPass: String = "",
|
||||
backendShare: String = "",
|
||||
tempRepoDir: String = ""
|
||||
bridgeUrl: String,
|
||||
cacheDir: String,
|
||||
authToken: String = "",
|
||||
): Map<String, String> {
|
||||
val env = HashMap(System.getenv() ?: emptyMap())
|
||||
env["RESTIC_REPOSITORY"] = if (backend == "smb" || backend == "webdav") {
|
||||
tempRepoDir
|
||||
} else {
|
||||
buildRepoUrl(backend, repoPath, backendUrl)
|
||||
}
|
||||
// 从空白环境开始,不继承系统环境变量(防止敏感信息泄露到子进程)
|
||||
val env = HashMap<String, String>()
|
||||
env["RESTIC_REPOSITORY"] = bridgeUrl
|
||||
env["RESTIC_PASSWORD"] = password
|
||||
// Restic needs HOME for its cache on Android (no $HOME by default).
|
||||
// Both local and remote backends use the same cache dir (sibling of tempRepoDir).
|
||||
if (tempRepoDir.isNotEmpty()) {
|
||||
val cacheDir = tempRepoDir.substringBeforeLast("/") + "/restic_cache"
|
||||
if (authToken.isNotEmpty()) {
|
||||
env["RESTIC_REST_USERNAME"] = authToken
|
||||
env["RESTIC_REST_PASSWORD"] = authToken
|
||||
}
|
||||
if (cacheDir.isNotEmpty()) {
|
||||
env["HOME"] = cacheDir
|
||||
env["XDG_CACHE_HOME"] = cacheDir
|
||||
val tmpDir = "$cacheDir/restic_tmp"
|
||||
env["TMPDIR"] = tmpDir
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
/** Build environment for local repository. */
|
||||
fun buildLocalEnv(
|
||||
repoPath: String,
|
||||
password: String,
|
||||
cacheDir: String,
|
||||
): Map<String, String> {
|
||||
// 从空白环境开始,不继承系统环境变量
|
||||
val env = HashMap<String, String>()
|
||||
env["RESTIC_REPOSITORY"] = repoPath
|
||||
env["RESTIC_PASSWORD"] = password
|
||||
if (cacheDir.isNotEmpty()) {
|
||||
env["HOME"] = cacheDir
|
||||
env["XDG_CACHE_HOME"] = cacheDir
|
||||
val tmpDir = "$cacheDir/restic_tmp"
|
||||
env["TMPDIR"] = tmpDir
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
/** 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.example.androidbackupgui.backup
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
/** Shared Json instance configured for restic's snake_case output via @SerialName. */
|
||||
internal val resticJson = Json { ignoreUnknownKeys = true }
|
||||
@@ -1,22 +1,69 @@
|
||||
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.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.
|
||||
*
|
||||
* 使用 [BackendExecutor] 统一处理 local/remote 后端。
|
||||
*
|
||||
* Delegates execution to [ResticCommandRunner], [ResticEnvResolver], and
|
||||
* [RemoteSyncManager] which are shared across sub-modules.
|
||||
* [RestBridgeRunner] which are shared across sub-modules.
|
||||
*/
|
||||
class ResticMaintenance(
|
||||
private val runner: ResticCommandRunner,
|
||||
private val envResolver: ResticEnvResolver,
|
||||
private val syncManager: RemoteSyncManager
|
||||
private val bridgeRunner: RestBridgeRunner,
|
||||
private val executor: BackendExecutor = BackendExecutor(),
|
||||
) {
|
||||
// ── Prune ──────────────────────────────────────────
|
||||
/** Cache directory for restic env and bridge temp files. Set by [ResticWrapper]. */
|
||||
var cacheDir: String = ""
|
||||
|
||||
/** SMB NTLM domain for remote backend. Set by [ResticWrapper]. */
|
||||
var backendDomain: String = ""
|
||||
|
||||
/** 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,
|
||||
@@ -26,23 +73,39 @@ class ResticMaintenance(
|
||||
backendUser: String = "",
|
||||
backendPass: String = "",
|
||||
backendShare: String = "",
|
||||
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
|
||||
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
|
||||
): Result<String> =
|
||||
withContext(Dispatchers.IO) {
|
||||
syncManager.withRemoteSync(backend, backendUrl, backendUser, backendPass, backendShare, repoPath,
|
||||
needsDownload = true, needsUpload = true,
|
||||
onProgress = onSyncProgress,
|
||||
onByteProgress = onByteSyncProgress,
|
||||
) {
|
||||
val env = envResolver.buildFullEnv(repoPath, password, backend, backendUrl, backendUser, backendPass, backendShare, syncManager.tempRepoDir)
|
||||
val result = runner.runRestic(env, "prune")
|
||||
if (result.exitCode == 0) Result.success(result.stdout)
|
||||
else Result.failure(Exception("restic prune failed: ${result.stderr}"))
|
||||
}
|
||||
}
|
||||
): AppResult<String> =
|
||||
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,
|
||||
@@ -52,23 +115,18 @@ class ResticMaintenance(
|
||||
backendUser: String = "",
|
||||
backendPass: String = "",
|
||||
backendShare: String = "",
|
||||
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
|
||||
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
|
||||
): Result<String> =
|
||||
withContext(Dispatchers.IO) {
|
||||
syncManager.withRemoteSync(backend, backendUrl, backendUser, backendPass, backendShare, repoPath,
|
||||
needsDownload = true, needsUpload = false,
|
||||
onProgress = onSyncProgress,
|
||||
onByteProgress = onByteSyncProgress,
|
||||
) {
|
||||
val env = envResolver.buildFullEnv(repoPath, password, backend, backendUrl, backendUser, backendPass, backendShare, syncManager.tempRepoDir)
|
||||
val result = runner.runRestic(env, "check")
|
||||
if (result.exitCode == 0) Result.success(result.stdout)
|
||||
else Result.failure(Exception("restic check failed: ${result.stderr}"))
|
||||
}
|
||||
}
|
||||
|
||||
// ── Stats ──────────────────────────────────────────
|
||||
): AppResult<String> =
|
||||
runCommand(
|
||||
"check",
|
||||
"restic check 失败",
|
||||
repoPath,
|
||||
password,
|
||||
backend,
|
||||
backendUrl,
|
||||
backendUser,
|
||||
backendPass,
|
||||
backendShare,
|
||||
)
|
||||
|
||||
suspend fun stats(
|
||||
repoPath: String,
|
||||
@@ -78,19 +136,16 @@ class ResticMaintenance(
|
||||
backendUser: String = "",
|
||||
backendPass: String = "",
|
||||
backendShare: String = "",
|
||||
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
|
||||
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
|
||||
): Result<String> =
|
||||
withContext(Dispatchers.IO) {
|
||||
syncManager.withRemoteSync(backend, backendUrl, backendUser, backendPass, backendShare, repoPath,
|
||||
needsDownload = true, needsUpload = false,
|
||||
onProgress = onSyncProgress,
|
||||
onByteProgress = onByteSyncProgress,
|
||||
) {
|
||||
val env = envResolver.buildFullEnv(repoPath, password, backend, backendUrl, backendUser, backendPass, backendShare, syncManager.tempRepoDir)
|
||||
val result = runner.runRestic(env, "stats")
|
||||
if (result.exitCode == 0) Result.success(result.stdout)
|
||||
else Result.failure(Exception("restic stats failed: ${result.stderr}"))
|
||||
}
|
||||
}
|
||||
): AppResult<String> =
|
||||
runCommand(
|
||||
"stats",
|
||||
"restic stats 失败",
|
||||
repoPath,
|
||||
password,
|
||||
backend,
|
||||
backendUrl,
|
||||
backendUser,
|
||||
backendPass,
|
||||
backendShare,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,22 +1,37 @@
|
||||
package com.example.androidbackupgui.backup
|
||||
|
||||
import android.util.Log
|
||||
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
|
||||
|
||||
/**
|
||||
* Repository lifecycle operations: init and repo URL construction.
|
||||
*
|
||||
* Delegates execution to [ResticCommandRunner], [ResticEnvResolver], and
|
||||
* [RemoteSyncManager] which are shared across sub-modules.
|
||||
* [RestBridgeRunner] which are shared across sub-modules.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
class ResticRepoInit(
|
||||
private val runner: ResticCommandRunner,
|
||||
private val envResolver: ResticEnvResolver,
|
||||
private val syncManager: RemoteSyncManager
|
||||
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 = ""
|
||||
|
||||
// ── Repository initialization ──────────────────────
|
||||
|
||||
suspend fun init(
|
||||
@@ -27,42 +42,114 @@ class ResticRepoInit(
|
||||
backendUser: String = "",
|
||||
backendPass: String = "",
|
||||
backendShare: String = "",
|
||||
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
|
||||
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
|
||||
): Result<Unit> =
|
||||
): AppResult<Unit> =
|
||||
withContext(Dispatchers.IO) {
|
||||
syncManager.withRemoteSync(backend, backendUrl, backendUser, backendPass, backendShare, repoPath,
|
||||
needsDownload = true, needsUpload = true,
|
||||
onProgress = onSyncProgress,
|
||||
onByteProgress = onByteSyncProgress,
|
||||
) {
|
||||
val env = envResolver.buildFullEnv(repoPath, password, backend, backendUrl, backendUser, backendPass, backendShare, syncManager.tempRepoDir)
|
||||
val result = runner.runRestic(env, "init")
|
||||
// exitCode 0 = brand new repo created, needs upload
|
||||
if (result.exitCode == 0) {
|
||||
return@withRemoteSync Result.success(Unit)
|
||||
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. */
|
||||
private suspend fun runInit(env: Map<String, String>): AppResult<Unit> {
|
||||
val result = runner.runRestic(env, "init")
|
||||
// exitCode 0 = brand new repo created
|
||||
if (result.exitCode == 0) {
|
||||
return AppResult.Success(Unit)
|
||||
}
|
||||
// exitCode 1: check if it's "config already exists" or a real error
|
||||
if (result.exitCode == 1) {
|
||||
if (!isConfigExistsError(result.stderr)) {
|
||||
// Exit code 1 from restic can also mean connection/backend errors (500, timeout, etc.)
|
||||
return err(AppError.Restic("restic init 失败: ${result.stderr.take(300).trim()}", result.exitCode, result.stderr))
|
||||
}
|
||||
var verify = runner.runRestic(env, "snapshots", "--json")
|
||||
if (verify.exitCode == 0) {
|
||||
// Repo is healthy — already initialized with matching password
|
||||
Log.i(TAG, "init: repo already initialized and verified")
|
||||
return AppResult.Success(Unit)
|
||||
}
|
||||
// Lock-related failure → try unlock then retry
|
||||
if (isLockError(verify.stderr)) {
|
||||
Log.w(TAG, "init: stale lock detected, running unlock")
|
||||
runner.runRestic(env, "unlock")
|
||||
verify = runner.runRestic(env, "snapshots", "--json")
|
||||
if (verify.exitCode == 0) {
|
||||
Log.i(TAG, "init: repo verified after unlock")
|
||||
return AppResult.Success(Unit)
|
||||
}
|
||||
// exitCode 1 = config already exists; verify the repo is actually usable
|
||||
if (result.exitCode == 1) {
|
||||
val verify = runner.runRestic(env, "snapshots", "--json")
|
||||
if (verify.exitCode == 0) {
|
||||
// Repo is healthy — already initialized with matching password
|
||||
Log.i(TAG, "init: repo already initialized and verified")
|
||||
return@withRemoteSync Result.success(Unit)
|
||||
}
|
||||
// Config exists but repo is corrupted (wrong password, missing keys, etc.)
|
||||
return@withRemoteSync Result.failure(
|
||||
Exception("仓库已存在但无法验证: ${verify.stderr.ifEmpty { "密码错误或密钥缺失" }}。请删除远端仓库后重试。")
|
||||
)
|
||||
}
|
||||
Result.failure(Exception("restic init failed: ${result.stderr}"))
|
||||
}
|
||||
// Config exists but verification failed — diagnose the cause
|
||||
val detail = diagnoseInitFailure(verify.stderr)
|
||||
return err(
|
||||
AppError.Restic("仓库已存在但无法验证: $detail", verify.exitCode, verify.stderr),
|
||||
)
|
||||
}
|
||||
return err(AppError.Restic("restic init 失败", result.exitCode, result.stderr))
|
||||
}
|
||||
|
||||
/** Check if [restic init]'s stderr indicates config already exists (vs a real error). */
|
||||
private fun isConfigExistsError(stderr: String): Boolean {
|
||||
val lower = stderr.lowercase()
|
||||
return lower.contains("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")
|
||||
}
|
||||
|
||||
/** Parse restic stderr to produce a user-facing diagnosis string. */
|
||||
private fun diagnoseInitFailure(stderr: String): String {
|
||||
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("permission") || lower.contains("access denied") -> {
|
||||
"权限不足,请检查目录权限"
|
||||
}
|
||||
|
||||
lower.contains("not a directory") || lower.contains("no such file") -> {
|
||||
"仓库路径无效或不可访问"
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,534 @@
|
||||
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
|
||||
|
||||
/**
|
||||
* NanoHTTPD-based REST bridge implementing the restic REST backend API.
|
||||
*
|
||||
* Translates restic HTTP requests into [RemoteTransport] calls so that restic
|
||||
* 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 repoPath: String,
|
||||
private val cacheDir: File,
|
||||
private val authToken: String = "",
|
||||
) : NanoHTTPD("127.0.0.1", 0) {
|
||||
private val TAG = "ResticRestBridge"
|
||||
|
||||
init {
|
||||
cacheDir.mkdirs()
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
override fun serve(session: IHTTPSession): Response {
|
||||
val uri = session.uri
|
||||
val method = session.method
|
||||
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 {
|
||||
handleRequest(method, uri, headers, params, session)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "request failed: $method $uri", e)
|
||||
newFixedLengthResponse(
|
||||
Response.Status.INTERNAL_ERROR,
|
||||
"text/plain",
|
||||
e.message ?: "Internal error",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleRequest(
|
||||
method: NanoHTTPD.Method,
|
||||
uri: String,
|
||||
headers: Map<String, String>,
|
||||
params: Map<String, String>,
|
||||
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",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val segments = strippedPath.split("/").filter { it.isNotEmpty() }
|
||||
|
||||
if (segments.isEmpty()) {
|
||||
return newFixedLengthResponse(Response.Status.NOT_FOUND, "text/plain", "Invalid path")
|
||||
}
|
||||
|
||||
val firstSegment = segments.first()
|
||||
|
||||
// /config endpoints
|
||||
if (firstSegment == "config" && segments.size == 1) {
|
||||
return handleConfig(method, headers, session)
|
||||
}
|
||||
|
||||
// /{type}/ or /{type}/{name}
|
||||
val type = firstSegment
|
||||
val name = if (segments.size >= 2) segments.drop(1).joinToString("/") else null
|
||||
|
||||
if (name == null) {
|
||||
if (method == NanoHTTPD.Method.GET) {
|
||||
return handleListBlobs(type)
|
||||
}
|
||||
return newFixedLengthResponse(Response.Status.METHOD_NOT_ALLOWED, "text/plain", "")
|
||||
}
|
||||
|
||||
return when (method) {
|
||||
NanoHTTPD.Method.HEAD -> handleHeadBlob(type, name)
|
||||
NanoHTTPD.Method.GET -> handleGetBlob(type, name, headers)
|
||||
NanoHTTPD.Method.POST -> handlePostBlob(type, name, session)
|
||||
NanoHTTPD.Method.DELETE -> handleDeleteBlob(type, name)
|
||||
else -> newFixedLengthResponse(Response.Status.METHOD_NOT_ALLOWED, "text/plain", "")
|
||||
}
|
||||
}
|
||||
|
||||
// -- 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> {
|
||||
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 (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")
|
||||
Result.success(tmpFile)
|
||||
} catch (e: Exception) {
|
||||
val elapsed = System.currentTimeMillis() - started
|
||||
Log.w(TAG, "streamBodyToFile failed after ${elapsed}ms", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("UNUSED_PARAMETER")
|
||||
private fun handleConfig(
|
||||
method: NanoHTTPD.Method,
|
||||
headers: Map<String, String>,
|
||||
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 -> {
|
||||
if (exists.data) {
|
||||
configExists = true
|
||||
val sizeResult = transport.fileSize(remotePath)
|
||||
if (sizeResult is AppResult.Success) configSize = sizeResult.data
|
||||
}
|
||||
}
|
||||
|
||||
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"}",
|
||||
)
|
||||
}
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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 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", "")
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
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
|
||||
}
|
||||
// Full file — read into memory (blobs are typically small)
|
||||
val data = tempFile.readBytes()
|
||||
val response =
|
||||
newChunkedResponse(
|
||||
Response.Status.OK,
|
||||
"application/octet-stream",
|
||||
data.inputStream(),
|
||||
)
|
||||
response.addHeader("Content-Length", data.size.toString())
|
||||
response
|
||||
}
|
||||
|
||||
is AppResult.Failure -> {
|
||||
newFixedLengthResponse(
|
||||
Response.Status.NOT_FOUND,
|
||||
"text/plain",
|
||||
"",
|
||||
)
|
||||
}
|
||||
}
|
||||
} 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",
|
||||
)
|
||||
}
|
||||
}
|
||||
} 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",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,27 +1,29 @@
|
||||
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 kotlinx.serialization.json.Json
|
||||
import kotlin.coroutines.coroutineContext
|
||||
|
||||
/** Shared Json instance configured for restic's snake_case output via @SerialName. */
|
||||
private val resticJson = Json { ignoreUnknownKeys = true }
|
||||
|
||||
/**
|
||||
* 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
|
||||
* [RemoteSyncManager] which are shared across sub-modules.
|
||||
* 使用 [BackendExecutor] 统一处理 local/remote 后端。
|
||||
*/
|
||||
class ResticRestore(
|
||||
private val runner: ResticCommandRunner,
|
||||
private val envResolver: ResticEnvResolver,
|
||||
private val syncManager: RemoteSyncManager
|
||||
private val bridgeRunner: RestBridgeRunner,
|
||||
private val executor: BackendExecutor = BackendExecutor(),
|
||||
) {
|
||||
var cacheDir: String = ""
|
||||
var backendDomain: String = ""
|
||||
|
||||
// ── Restore ────────────────────────────────────────
|
||||
|
||||
suspend fun restore(
|
||||
@@ -35,42 +37,60 @@ class ResticRestore(
|
||||
backendUser: String = "",
|
||||
backendPass: String = "",
|
||||
backendShare: String = "",
|
||||
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
|
||||
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
|
||||
onProgress: suspend (String) -> Unit = {}
|
||||
): Result<Unit> = withContext(Dispatchers.IO) {
|
||||
val emit: suspend (String) -> Unit = { s -> withContext(Dispatchers.Main) { onProgress(s) } }
|
||||
syncManager.withRemoteSync(backend, backendUrl, backendUser, backendPass, backendShare, repoPath,
|
||||
needsDownload = true, needsUpload = false,
|
||||
onProgress = onSyncProgress,
|
||||
onByteProgress = onByteSyncProgress,
|
||||
) {
|
||||
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) }
|
||||
|
||||
val env = envResolver.buildFullEnv(repoPath, password, backend, backendUrl, backendUser, backendPass, backendShare, syncManager.tempRepoDir)
|
||||
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 (include != null) {
|
||||
args.add("--include")
|
||||
args.add(include)
|
||||
}
|
||||
|
||||
if (result.exitCode == 0) Result.success(Unit)
|
||||
else Result.failure(Exception("restic restore failed: ${result.stderr}"))
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (result.exitCode == 0) {
|
||||
AppResult.Success(Unit)
|
||||
} else {
|
||||
err(AppError.Restic("restic restore 失败", result.exitCode, result.stderr))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── File dump ──────────────────────────────────────
|
||||
|
||||
@@ -84,18 +104,28 @@ class ResticRestore(
|
||||
backendUser: String = "",
|
||||
backendPass: String = "",
|
||||
backendShare: String = "",
|
||||
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
|
||||
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
|
||||
): Result<String> = withContext(Dispatchers.IO) {
|
||||
syncManager.withRemoteSync(backend, backendUrl, backendUser, backendPass, backendShare, repoPath,
|
||||
needsDownload = true, needsUpload = false,
|
||||
onProgress = onSyncProgress,
|
||||
onByteProgress = onByteSyncProgress,
|
||||
) {
|
||||
val env = envResolver.buildFullEnv(repoPath, password, backend, backendUrl, backendUser, backendPass, backendShare, syncManager.tempRepoDir)
|
||||
val result = runner.runRestic(env, "dump", snapshotId, filePath)
|
||||
if (result.exitCode == 0) Result.success(result.stdout)
|
||||
else Result.failure(Exception(result.stderr.ifEmpty { "restic dump failed with exit code ${result.exitCode}" }))
|
||||
): 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,25 +1,27 @@
|
||||
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.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
|
||||
/** Shared Json instance configured for restic's snake_case output via @SerialName. */
|
||||
private val resticJson = Json { ignoreUnknownKeys = true }
|
||||
/**
|
||||
* Snapshot listing and retention policy operations.
|
||||
*
|
||||
* [listSnapshots] is download-only; [forget] requires both download and upload
|
||||
* (forget removes snapshots from the remote).
|
||||
* Delegates execution to [ResticCommandRunner], [ResticEnvResolver], and
|
||||
* [RemoteSyncManager] which are shared across sub-modules.
|
||||
* [listSnapshots] is download-only; [forget] removes snapshots from the remote.
|
||||
*
|
||||
* 使用 [BackendExecutor] 统一处理 local/remote 后端。
|
||||
*/
|
||||
class ResticSnapshotOps(
|
||||
private val runner: ResticCommandRunner,
|
||||
private val envResolver: ResticEnvResolver,
|
||||
private val syncManager: RemoteSyncManager
|
||||
private val bridgeRunner: RestBridgeRunner,
|
||||
private val executor: BackendExecutor = BackendExecutor(),
|
||||
) {
|
||||
var cacheDir: String = ""
|
||||
var backendDomain: String = ""
|
||||
|
||||
// ── List snapshots ─────────────────────────────────
|
||||
|
||||
suspend fun listSnapshots(
|
||||
@@ -31,34 +33,44 @@ class ResticSnapshotOps(
|
||||
backendUser: String = "",
|
||||
backendPass: String = "",
|
||||
backendShare: String = "",
|
||||
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
|
||||
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
|
||||
): Result<List<ResticWrapper.ResticSnapshot>> = withContext(Dispatchers.IO) {
|
||||
syncManager.withRemoteSync(backend, backendUrl, backendUser, backendPass, backendShare, repoPath,
|
||||
needsDownload = true, needsUpload = false,
|
||||
onProgress = onSyncProgress,
|
||||
onByteProgress = onByteSyncProgress,
|
||||
) {
|
||||
): 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.buildFullEnv(repoPath, password, backend, backendUrl, backendUser, backendPass, backendShare, syncManager.tempRepoDir)
|
||||
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@withRemoteSync Result.failure(Exception("restic snapshots failed: ${result.stderr}"))
|
||||
return@withContext err(AppError.Restic("restic snapshots 失败", result.exitCode, result.stderr))
|
||||
}
|
||||
|
||||
try {
|
||||
val snapshots = resticJson.decodeFromString<List<ResticWrapper.ResticSnapshot>>(
|
||||
result.stdout.ifEmpty { "[]" }
|
||||
)
|
||||
Result.success(snapshots.sortedByDescending { it.time })
|
||||
val snapshots =
|
||||
resticJson.decodeFromString<List<ResticWrapper.ResticSnapshot>>(
|
||||
result.stdout.ifEmpty { "[]" },
|
||||
)
|
||||
AppResult.Success(snapshots.sortedByDescending { it.time })
|
||||
} catch (e: Exception) {
|
||||
Result.failure(Exception("Failed to parse snapshot JSON: ${e.message}"))
|
||||
err(AppError.Parse("解析快照 JSON 失败", e.message ?: ""))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Forget (retention policy) ──────────────────────
|
||||
|
||||
@@ -74,27 +86,40 @@ class ResticSnapshotOps(
|
||||
backendUser: String = "",
|
||||
backendPass: String = "",
|
||||
backendShare: String = "",
|
||||
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
|
||||
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
|
||||
): Result<String> = withContext(Dispatchers.IO) {
|
||||
syncManager.withRemoteSync(backend, backendUrl, backendUser, backendPass, backendShare, repoPath,
|
||||
needsDownload = true, needsUpload = true,
|
||||
onProgress = onSyncProgress,
|
||||
onByteProgress = onByteSyncProgress,
|
||||
) {
|
||||
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.buildFullEnv(repoPath, password, backend, backendUrl, backendUser, backendPass, backendShare, syncManager.tempRepoDir)
|
||||
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) Result.success(result.stdout)
|
||||
else Result.failure(Exception("restic forget failed: ${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,13 +1,17 @@
|
||||
package com.example.androidbackupgui.backup
|
||||
|
||||
import android.util.Log
|
||||
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 java.io.File
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlin.coroutines.coroutineContext
|
||||
import kotlinx.serialization.Serializable
|
||||
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.
|
||||
@@ -15,60 +19,86 @@ import kotlinx.serialization.SerialName
|
||||
* Uses environment variables (RESTIC_REPOSITORY, RESTIC_PASSWORD) rather than
|
||||
* command-line flags to avoid leaking secrets in the process list.
|
||||
*
|
||||
* For SMB/WebDAV backends, restic runs against a local temp directory;
|
||||
* RemoteTransport syncs files to/from the remote backend.
|
||||
* For SMB/WebDAV backends, restic connects via a local REST bridge
|
||||
* ([ResticRestBridge]) that translates HTTP requests to [RemoteTransport] calls,
|
||||
* eliminating the need for a local staging repo and full-directory sync.
|
||||
*
|
||||
* All public methods are suspend and run on Dispatchers.IO.
|
||||
*
|
||||
* This object is a facade that delegates to [ResticCommandRunner],
|
||||
* [ResticEnvResolver], [RemoteSyncManager], and sub-module classes
|
||||
* [ResticEnvResolver], [RestBridgeRunner], and sub-module classes
|
||||
* ([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 syncManager = RemoteSyncManager()
|
||||
/**
|
||||
* 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, syncManager)
|
||||
private val backupOp = ResticBackup(runner, envResolver, syncManager)
|
||||
private val restoreOp = ResticRestore(runner, envResolver, syncManager)
|
||||
private val snapshotOps = ResticSnapshotOps(runner, envResolver, syncManager)
|
||||
private val maintenance = ResticMaintenance(runner, envResolver, syncManager)
|
||||
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
|
||||
}
|
||||
|
||||
/** Local temp directory used as restic repo for SMB/WebDAV backends. */
|
||||
var tempRepoDir: String
|
||||
get() = syncManager.tempRepoDir
|
||||
set(v) { syncManager.tempRepoDir = v }
|
||||
|
||||
/** Domain for SMB NTLM authentication. */
|
||||
var backendDomain: String
|
||||
get() = syncManager.backendDomain
|
||||
set(v) { syncManager.backendDomain = v }
|
||||
/** Cache directory for restic (XDG_CACHE_HOME) and bridge tmp blobs. */
|
||||
var cacheDir: String = ""
|
||||
set(v) {
|
||||
field = v
|
||||
repoInit.cacheDir = v
|
||||
backupOp.cacheDir = v
|
||||
restoreOp.cacheDir = v
|
||||
snapshotOps.cacheDir = v
|
||||
maintenance.cacheDir = v
|
||||
}
|
||||
|
||||
/** Domain for SMB NTLM authentication. Propagated to sub-modules. */
|
||||
var backendDomain: String = ""
|
||||
set(v) {
|
||||
field = v
|
||||
repoInit.backendDomain = v
|
||||
backupOp.backendDomain = v
|
||||
restoreOp.backendDomain = v
|
||||
snapshotOps.backendDomain = v
|
||||
maintenance.backendDomain = v
|
||||
}
|
||||
// ── Progress data ─────────────────────────────────
|
||||
|
||||
@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
|
||||
@@ -78,7 +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(),
|
||||
)
|
||||
|
||||
// ── Repository lifecycle ─────────────────────────
|
||||
@@ -91,30 +128,35 @@ object ResticWrapper {
|
||||
backendUser: String = "",
|
||||
backendPass: String = "",
|
||||
backendShare: String = "",
|
||||
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
|
||||
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
|
||||
): Result<Unit> = repoInit.init(
|
||||
repoPath, password, backend, backendUrl, backendUser, backendPass, backendShare,
|
||||
onSyncProgress, onByteSyncProgress
|
||||
)
|
||||
): AppResult<Unit> =
|
||||
repoInit.init(
|
||||
repoPath,
|
||||
password,
|
||||
backend,
|
||||
backendUrl,
|
||||
backendUser,
|
||||
backendPass,
|
||||
backendShare,
|
||||
)
|
||||
|
||||
// ── Backup ─────────────────────────────────────────
|
||||
|
||||
@Serializable
|
||||
data class BackupSummary(
|
||||
@SerialName("message_type") val messageType: String = "",
|
||||
@SerialName("snapshot_id") val snapshotId: String,
|
||||
@SerialName("files_new") val filesNew: Int,
|
||||
@SerialName("files_changed") val filesChanged: Int,
|
||||
@SerialName("files_unmodified") val filesUnmodified: Int,
|
||||
@SerialName("dirs_new") val dirsNew: Int,
|
||||
@SerialName("dirs_changed") val dirsChanged: Int,
|
||||
@SerialName("dirs_unmodified") val dirsUnmodified: Int,
|
||||
@SerialName("data_blobs") val dataBlobs: Int,
|
||||
@SerialName("tree_blobs") val treeBlobs: Int,
|
||||
@SerialName("data_added") val dataAdded: Long,
|
||||
@SerialName("total_files_processed") val totalFilesProcessed: Int,
|
||||
@SerialName("total_bytes_processed") val totalBytesProcessed: Long,
|
||||
@SerialName("total_duration") val totalDuration: Double
|
||||
@SerialName("files_new") val filesNew: Int = 0,
|
||||
@SerialName("files_changed") val filesChanged: Int = 0,
|
||||
@SerialName("files_unmodified") val filesUnmodified: Int = 0,
|
||||
@SerialName("dirs_new") val dirsNew: Int = 0,
|
||||
@SerialName("dirs_changed") val dirsChanged: Int = 0,
|
||||
@SerialName("dirs_unmodified") val dirsUnmodified: Int = 0,
|
||||
@SerialName("data_blobs") val dataBlobs: Int = 0,
|
||||
@SerialName("tree_blobs") val treeBlobs: Int = 0,
|
||||
@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,
|
||||
)
|
||||
|
||||
suspend fun backup(
|
||||
@@ -128,14 +170,62 @@ object ResticWrapper {
|
||||
backendUser: String = "",
|
||||
backendPass: String = "",
|
||||
backendShare: String = "",
|
||||
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
|
||||
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
|
||||
onProgress: suspend (ResticProgress) -> Unit = {}
|
||||
): Result<BackupSummary> = backupOp.backup(
|
||||
repoPath, password, paths, tags, hostname,
|
||||
backend, backendUrl, backendUser, backendPass, backendShare,
|
||||
onSyncProgress, onByteSyncProgress, onProgress
|
||||
)
|
||||
onProgress: suspend (ResticProgress) -> Unit = {},
|
||||
): AppResult<BackupSummary> =
|
||||
backupOp.backup(
|
||||
repoPath,
|
||||
password,
|
||||
paths,
|
||||
tags,
|
||||
hostname,
|
||||
backend,
|
||||
backendUrl,
|
||||
backendUser,
|
||||
backendPass,
|
||||
backendShare,
|
||||
onProgress,
|
||||
)
|
||||
|
||||
/**
|
||||
* 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,
|
||||
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 ────────────────────────────────────────
|
||||
|
||||
@@ -150,14 +240,21 @@ object ResticWrapper {
|
||||
backendUser: String = "",
|
||||
backendPass: String = "",
|
||||
backendShare: String = "",
|
||||
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
|
||||
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
|
||||
onProgress: suspend (String) -> Unit = {}
|
||||
): Result<Unit> = restoreOp.restore(
|
||||
repoPath, password, snapshotId, targetPath, include,
|
||||
backend, backendUrl, backendUser, backendPass, backendShare,
|
||||
onSyncProgress, onByteSyncProgress, onProgress
|
||||
)
|
||||
onProgress: suspend (String) -> Unit = {},
|
||||
): AppResult<Unit> =
|
||||
restoreOp.restore(
|
||||
repoPath,
|
||||
password,
|
||||
snapshotId,
|
||||
targetPath,
|
||||
include,
|
||||
backend,
|
||||
backendUrl,
|
||||
backendUser,
|
||||
backendPass,
|
||||
backendShare,
|
||||
onProgress,
|
||||
)
|
||||
|
||||
// ── File dump ──────────────────────────────────────
|
||||
|
||||
@@ -171,13 +268,18 @@ object ResticWrapper {
|
||||
backendUser: String = "",
|
||||
backendPass: String = "",
|
||||
backendShare: String = "",
|
||||
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
|
||||
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
|
||||
): Result<String> = restoreOp.dump(
|
||||
repoPath, password, snapshotId, filePath,
|
||||
backend, backendUrl, backendUser, backendPass, backendShare,
|
||||
onSyncProgress, onByteSyncProgress
|
||||
)
|
||||
): AppResult<String> =
|
||||
restoreOp.dump(
|
||||
repoPath,
|
||||
password,
|
||||
snapshotId,
|
||||
filePath,
|
||||
backend,
|
||||
backendUrl,
|
||||
backendUser,
|
||||
backendPass,
|
||||
backendShare,
|
||||
)
|
||||
|
||||
// ── Snapshot management ────────────────────────────
|
||||
|
||||
@@ -190,13 +292,17 @@ object ResticWrapper {
|
||||
backendUser: String = "",
|
||||
backendPass: String = "",
|
||||
backendShare: String = "",
|
||||
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
|
||||
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
|
||||
): Result<List<ResticSnapshot>> = snapshotOps.listSnapshots(
|
||||
repoPath, password, tag,
|
||||
backend, backendUrl, backendUser, backendPass, backendShare,
|
||||
onSyncProgress, onByteSyncProgress
|
||||
)
|
||||
): AppResult<List<ResticSnapshot>> =
|
||||
snapshotOps.listSnapshots(
|
||||
repoPath,
|
||||
password,
|
||||
tag,
|
||||
backend,
|
||||
backendUrl,
|
||||
backendUser,
|
||||
backendPass,
|
||||
backendShare,
|
||||
)
|
||||
|
||||
suspend fun forget(
|
||||
repoPath: String,
|
||||
@@ -210,13 +316,117 @@ object ResticWrapper {
|
||||
backendUser: String = "",
|
||||
backendPass: String = "",
|
||||
backendShare: String = "",
|
||||
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
|
||||
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
|
||||
): Result<String> = snapshotOps.forget(
|
||||
repoPath, password, keepDaily, keepWeekly, keepMonthly, dryRun,
|
||||
backend, backendUrl, backendUser, backendPass, backendShare,
|
||||
onSyncProgress, onByteSyncProgress
|
||||
)
|
||||
): 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
|
||||
* of package-name → [SnapshotAppInfo]. Returns `null` when no snapshots
|
||||
* exist or the file cannot be read (e.g. first backup, legacy format).
|
||||
*/
|
||||
suspend fun getLatestSnapshotAppDetails(
|
||||
repoPath: String,
|
||||
password: String,
|
||||
backend: String = "local",
|
||||
backendUrl: String = "",
|
||||
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
|
||||
|
||||
if (snaps.isEmpty()) return@withContext null
|
||||
|
||||
val latestId = snaps.first().shortId
|
||||
val basePath =
|
||||
snaps
|
||||
.first()
|
||||
.paths
|
||||
.firstOrNull()
|
||||
?.trimEnd('/') ?: return@withContext null
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
/** Parse [app_details.json] content into a package-name → [SnapshotAppInfo] map. */
|
||||
internal fun parseAppDetailsJson(jsonStr: String): Map<String, SnapshotAppInfo> {
|
||||
val map = mutableMapOf<String, SnapshotAppInfo>()
|
||||
try {
|
||||
val root = JSONObject(jsonStr)
|
||||
for (key in root.keys()) {
|
||||
val entry = root.optJSONObject(key) ?: continue
|
||||
val sizes = mutableListOf<Long>()
|
||||
val sizesArr = entry.optJSONArray("apkSizes")
|
||||
if (sizesArr != null) {
|
||||
for (i in 0 until sizesArr.length()) {
|
||||
sizes.add(sizesArr.optLong(i, 0L))
|
||||
}
|
||||
}
|
||||
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")
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
// ── Maintenance ────────────────────────────────────
|
||||
|
||||
@@ -228,13 +438,16 @@ object ResticWrapper {
|
||||
backendUser: String = "",
|
||||
backendPass: String = "",
|
||||
backendShare: String = "",
|
||||
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
|
||||
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
|
||||
): Result<String> = maintenance.prune(
|
||||
repoPath, password,
|
||||
backend, backendUrl, backendUser, backendPass, backendShare,
|
||||
onSyncProgress, onByteSyncProgress
|
||||
)
|
||||
): AppResult<String> =
|
||||
maintenance.prune(
|
||||
repoPath,
|
||||
password,
|
||||
backend,
|
||||
backendUrl,
|
||||
backendUser,
|
||||
backendPass,
|
||||
backendShare,
|
||||
)
|
||||
|
||||
suspend fun check(
|
||||
repoPath: String,
|
||||
@@ -244,13 +457,16 @@ object ResticWrapper {
|
||||
backendUser: String = "",
|
||||
backendPass: String = "",
|
||||
backendShare: String = "",
|
||||
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
|
||||
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
|
||||
): Result<String> = maintenance.check(
|
||||
repoPath, password,
|
||||
backend, backendUrl, backendUser, backendPass, backendShare,
|
||||
onSyncProgress, onByteSyncProgress
|
||||
)
|
||||
): AppResult<String> =
|
||||
maintenance.check(
|
||||
repoPath,
|
||||
password,
|
||||
backend,
|
||||
backendUrl,
|
||||
backendUser,
|
||||
backendPass,
|
||||
backendShare,
|
||||
)
|
||||
|
||||
suspend fun stats(
|
||||
repoPath: String,
|
||||
@@ -260,28 +476,42 @@ object ResticWrapper {
|
||||
backendUser: String = "",
|
||||
backendPass: String = "",
|
||||
backendShare: String = "",
|
||||
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
|
||||
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
|
||||
): Result<String> = maintenance.stats(
|
||||
repoPath, password,
|
||||
backend, backendUrl, backendUser, backendPass, backendShare,
|
||||
onSyncProgress, onByteSyncProgress
|
||||
)
|
||||
): 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)
|
||||
}
|
||||
|
||||
// ── Lifecycle ──────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Public safety-net cleanup called by fragment lifecycle.
|
||||
* Waits for any in-progress operation to finish, then deletes temp dirs.
|
||||
*/
|
||||
suspend fun cleanup() {
|
||||
syncManager.cleanup()
|
||||
}
|
||||
fun buildRepoUrl(
|
||||
backend: String,
|
||||
repoPath: String,
|
||||
backendUrl: String,
|
||||
): String = repoInit.buildRepoUrl(backend, repoPath, backendUrl)
|
||||
}
|
||||
|
||||
@@ -1,37 +1,41 @@
|
||||
package com.example.androidbackupgui.backup
|
||||
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
|
||||
data class RestoreProgress(
|
||||
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,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -39,150 +43,342 @@ object RestoreOperation {
|
||||
* @param filterPkgs if non-null, only restore packages in this set
|
||||
*/
|
||||
suspend fun restoreApps(
|
||||
context: Context,
|
||||
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()
|
||||
|
||||
// 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()
|
||||
}
|
||||
// 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"
|
||||
|
||||
val packages = if (filterPkgs != null) {
|
||||
allPackages.filter { it in filterPkgs }
|
||||
} else {
|
||||
allPackages
|
||||
}
|
||||
// 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 successAtomic = AtomicInteger(0)
|
||||
val failAtomic = AtomicInteger(0)
|
||||
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 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 successAtomic = AtomicInteger(0)
|
||||
val failAtomic = AtomicInteger(0)
|
||||
|
||||
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(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(appBackupDir)
|
||||
|
||||
// 4. Restore OBB
|
||||
emit(RestoreProgress(index + 1, packages.size, pkg, "obb", "正在恢复 OBB…"))
|
||||
restoreObb(pkg, appBackupDir)
|
||||
|
||||
// 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
|
||||
RestoreResult(successAtomic.get(), failAtomic.get(), 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(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
|
||||
|
||||
// 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 result = RootShell.exec("pm install-create -r -t 2>/dev/null")
|
||||
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"
|
||||
RootShell.exec("pm install-write '${sessionId.shellEscape()}' '$sessionName' '${apk.absolutePath.shellEscape()}'")
|
||||
}
|
||||
val commit = RootShell.exec("pm install-commit '${sessionId.shellEscape()}'")
|
||||
return commit.isSuccess
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
|
||||
// Single APK install
|
||||
val result = RootShell.exec("pm install -r -t $apkPaths")
|
||||
return result.isSuccess
|
||||
suspend fun doInstall(): Boolean {
|
||||
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("]")
|
||||
if (sessionId != null) {
|
||||
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
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
suspend fun isInstalled(): Boolean {
|
||||
val verifyResult = RootShell.exec("pm list packages '${packageName.shellEscape()}' 2>/dev/null")
|
||||
return verifyResult.output.contains(packageName)
|
||||
}
|
||||
|
||||
// First install attempt
|
||||
val firstOk = doInstall()
|
||||
if (!firstOk) {
|
||||
LogUtil.e(TAG, "installApk: $packageName — first install attempt failed")
|
||||
return false
|
||||
}
|
||||
|
||||
// Verify installation succeeded
|
||||
if (isInstalled()) {
|
||||
Log.i(TAG, "installApk: $packageName installed and verified")
|
||||
return true
|
||||
}
|
||||
|
||||
// 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")
|
||||
return false
|
||||
}
|
||||
|
||||
if (isInstalled()) {
|
||||
Log.i(TAG, "installApk: $packageName installed and verified (after retry)")
|
||||
return true
|
||||
}
|
||||
|
||||
Log.e(TAG, "installApk: $packageName — install reported success but package not found after retry")
|
||||
return false
|
||||
}
|
||||
|
||||
private suspend fun restoreData(appDir: File) {
|
||||
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 = fileNames.map { File(appDir, it) }
|
||||
|
||||
// Find data archive
|
||||
val dataFiles = appDir.listFiles()
|
||||
?.filter { it.name.contains("_data.tar") }
|
||||
?: return
|
||||
// 安全预检:验证目标数据目录路径合法,防止 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
|
||||
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(" ")
|
||||
|
||||
for (archive in dataFiles) {
|
||||
val archivePath = archive.absolutePath.shellEscape()
|
||||
// Verify archive doesn't contain path traversal before extracting
|
||||
if (!isArchiveSafe(archive)) continue
|
||||
when {
|
||||
archive.name.endsWith(".zst") -> {
|
||||
RootShell.exec("zstd -d -c '$archivePath' | tar -xf - -C / 2>/dev/null")
|
||||
}
|
||||
archive.name.endsWith(".gz") -> {
|
||||
RootShell.exec("tar -xzf '$archivePath' -C / 2>/dev/null")
|
||||
}
|
||||
archive.name.endsWith(".tar") -> {
|
||||
RootShell.exec("tar -xf '$archivePath' -C / 2>/dev/null")
|
||||
Log.d(TAG, "restoreData: found archive ${archive.name}")
|
||||
if (!isArchiveSafe(archive, zstdCmd)) {
|
||||
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 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}")
|
||||
}
|
||||
}
|
||||
|
||||
// Restore SELinux context on extracted data directories
|
||||
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")
|
||||
}
|
||||
|
||||
if (context != null) {
|
||||
Log.d(TAG, "restoreData: restoring SELinux context on $dataPath: $context")
|
||||
SELinuxUtil.chcon(context, dataPath)
|
||||
} else {
|
||||
Log.w(TAG, "restoreData: could not determine SELinux context for $dataPath")
|
||||
}
|
||||
}
|
||||
|
||||
return anyExtracted
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -190,107 +386,402 @@ object RestoreOperation {
|
||||
* or symbolic links pointing outside the tree.
|
||||
* Accepts both absolute and relative paths — tar implementations vary.
|
||||
*/
|
||||
private suspend fun isArchiveSafe(archive: File): Boolean {
|
||||
val listCmd = if (archive.name.endsWith(".zst")) {
|
||||
"zstd -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")) {
|
||||
val fallbackCmd = "$zstdCmd -d -c '${archive.absolutePath.shellEscape()}' 2>/dev/null | tar tf - 2>/dev/null"
|
||||
result = RootShell.exec(fallbackCmd)
|
||||
}
|
||||
val result = RootShell.exec(listCmd)
|
||||
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) {
|
||||
val obbFiles = appDir.listFiles()
|
||||
?.filter { it.name.contains("_obb.tar") }
|
||||
?: 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/*'" }
|
||||
|
||||
var anyExtracted = false
|
||||
for (archive in obbFiles) {
|
||||
if (!isArchiveSafe(archive)) continue
|
||||
if (!isArchiveSafe(archive, zstdCmd)) continue
|
||||
val archivePath = archive.absolutePath.shellEscape()
|
||||
when {
|
||||
archive.name.endsWith(".zst") -> {
|
||||
RootShell.exec("zstd -d -c '$archivePath' | tar -xf - -C / 2>/dev/null")
|
||||
}
|
||||
archive.name.endsWith(".gz") -> {
|
||||
RootShell.exec("tar -xzf '$archivePath' -C / 2>/dev/null")
|
||||
}
|
||||
archive.name.endsWith(".tar") -> {
|
||||
RootShell.exec("tar -xf '$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}")
|
||||
}
|
||||
}
|
||||
|
||||
// Fix OBB permissions
|
||||
RootShell.exec("chown -R 1023:1023 /storage/emulated/0/Android/obb/${packageName.shellEscape()}/ 2>/dev/null")
|
||||
// Fix OBB permissions: resolve GID from parent directory instead of hardcoding 1023
|
||||
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) {
|
||||
/**
|
||||
* 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
|
||||
|
||||
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")
|
||||
if (!ssaidFile.exists()) return
|
||||
val ssaidValue = BackupOperation.readTextFile(ssaidFile)?.trim() ?: return
|
||||
|
||||
val ssaidLine = ssaidFile.readText().trim()
|
||||
if (ssaidLine.isBlank()) 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()
|
||||
|
||||
if (uid == null) {
|
||||
Log.w(TAG, "restoreSsaid: could not resolve UID for $packageName")
|
||||
return
|
||||
}
|
||||
|
||||
// Try XML-based approach first (more reliable across Android versions)
|
||||
val targetFile = "/data/system/users/${userId.shellEscape()}/settings_ssaid.xml"
|
||||
val pkgEsc = packageName.shellEscape()
|
||||
val ssaidEsc = ssaidLine.shellEscape()
|
||||
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
|
||||
}
|
||||
|
||||
// Remove existing entry for this package, insert new one before </settings>
|
||||
RootShell.exec(
|
||||
"grep -v '${pkgEsc}' '$targetFile' > '$targetFile.tmp' && " +
|
||||
"sed -i '\$ i ${ssaidEsc}' '$targetFile.tmp' && " +
|
||||
"mv '$targetFile.tmp' '$targetFile'"
|
||||
)
|
||||
}
|
||||
// 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
|
||||
}
|
||||
|
||||
private suspend fun restorePermissions(packageName: String, appDir: File) {
|
||||
val permFile = File(appDir, "permissions.txt")
|
||||
if (!permFile.exists()) return
|
||||
// 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
|
||||
}
|
||||
|
||||
val perms = permFile.readLines()
|
||||
.filter { it.contains("granted=true") }
|
||||
.mapNotNull { line ->
|
||||
// Extract permission name from dumpsys output
|
||||
// Format: "permission.name: granted=true" or similar
|
||||
line.substringBefore(":")
|
||||
.trim()
|
||||
.takeIf { it.isNotEmpty() && it.contains(".") }
|
||||
// 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) {
|
||||
val result = RootShell.exec("settings put secure ssaid_$uid '${ssaidValue.shellEscape()}'")
|
||||
if (result.isSuccess) {
|
||||
Log.i(TAG, "restoreSsaid: restored SSAID for $packageName via settings (uid=$uid)")
|
||||
} else {
|
||||
Log.e(TAG, "restoreSsaid: failed to set SSAID for $packageName: ${result.error}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun restorePermissions(
|
||||
packageName: String,
|
||||
appDir: File,
|
||||
) {
|
||||
val permFile = File(appDir, "permissions.txt")
|
||||
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)
|
||||
}
|
||||
|
||||
if (parsedPerms.isEmpty()) return
|
||||
|
||||
val pkgEsc = packageName.shellEscape()
|
||||
for (perm in perms) {
|
||||
|
||||
// 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 }
|
||||
|
||||
// Grant runtime permissions that were previously granted
|
||||
for (perm in grantedPerms) {
|
||||
val result = RootShell.exec("pm grant '$pkgEsc' '${perm.shellEscape()}' 2>&1")
|
||||
if (!result.isSuccess) {
|
||||
android.util.Log.w("RestoreOperation", "pm grant failed for $packageName: $perm — ${result.output}")
|
||||
Log.w(TAG, "restorePermissions: pm grant failed for $packageName: $perm — ${result.output}")
|
||||
}
|
||||
}
|
||||
|
||||
// Revoke runtime permissions that were explicitly denied
|
||||
for (perm in deniedPerms) {
|
||||
val result = RootShell.exec("pm revoke '$pkgEsc' '${perm.shellEscape()}' 2>&1")
|
||||
if (!result.isSuccess) {
|
||||
// Revoking a permission that isn't granted is not an error — just log at debug level
|
||||
Log.d(TAG, "restorePermissions: pm revoke for $packageName: $perm — ${result.output}")
|
||||
}
|
||||
}
|
||||
|
||||
Log.i(TAG, "restorePermissions: ${grantedPerms.size} granted, ${deniedPerms.size} revoked for $packageName")
|
||||
}
|
||||
|
||||
private suspend fun fixDataOwnership(packageName: String, userId: String) {
|
||||
/** Resolve app UID using multiple methods for robustness across Android versions. */
|
||||
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()
|
||||
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()
|
||||
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()
|
||||
return ds2Uid
|
||||
}
|
||||
|
||||
private suspend fun fixDataOwnership(
|
||||
packageName: String,
|
||||
userId: String,
|
||||
) {
|
||||
val pkgEsc = packageName.shellEscape()
|
||||
val uidEsc = userId.shellEscape()
|
||||
val uidResult = RootShell.exec("dumpsys package '$pkgEsc' | grep 'userId=' | head -1")
|
||||
val uid = uidResult.output
|
||||
.substringAfter("userId=", "")
|
||||
.substringBefore(" ")
|
||||
.substringBefore(",")
|
||||
.trim()
|
||||
.toIntOrNull()
|
||||
|
||||
if (uid != null) {
|
||||
RootShell.exec("chown -R $uid:$uid /data/data/$pkgEsc/ 2>/dev/null")
|
||||
RootShell.exec("chown -R $uid:$uid /data/user_de/$uidEsc/$pkgEsc/ 2>/dev/null")
|
||||
RootShell.exec("restorecon -R /data/data/$pkgEsc/ 2>/dev/null")
|
||||
RootShell.exec("restorecon -R /data/user_de/$uidEsc/$pkgEsc/ 2>/dev/null")
|
||||
val uid = resolveAppUid(packageName)
|
||||
if (uid == null) {
|
||||
Log.w(TAG, "fixDataOwnership: could not resolve UID for $packageName — data will be inaccessible")
|
||||
return
|
||||
}
|
||||
|
||||
// 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")
|
||||
}
|
||||
if (context != null) {
|
||||
SELinuxUtil.chcon(context, dataPath)
|
||||
Log.d(TAG, "fixDataOwnership: restored SELinux context on $dataPath: $context")
|
||||
} else {
|
||||
Log.w(TAG, "fixDataOwnership: could not determine SELinux context for $dataPath")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.example.androidbackupgui.backup
|
||||
|
||||
import com.example.androidbackupgui.root.RootShell
|
||||
import com.example.androidbackupgui.root.shellEscape
|
||||
import android.util.Log
|
||||
|
||||
/**
|
||||
* SELinux context utilities for restoring file security labels.
|
||||
* Mirrors the approach from Android-DataBackup (Xayah) SELinuxUtil.kt.
|
||||
*/
|
||||
object SELinuxUtil {
|
||||
|
||||
private const val TAG = "SELinuxUtil"
|
||||
|
||||
/**
|
||||
* Query the SELinux context of a path.
|
||||
* Returns the full SELinux label (e.g., "u:object_r:app_data_file:s0:c512,c768")
|
||||
* or null if the path doesn't exist or the query fails.
|
||||
*/
|
||||
suspend fun getContext(path: String): String? {
|
||||
val escaped = path.shellEscape()
|
||||
val result = RootShell.exec("ls -Zd '$escaped' 2>/dev/null | awk 'NF>1{print \$1}'")
|
||||
if (!result.isSuccess) return null
|
||||
val context = result.output.trim()
|
||||
return context.ifBlank { null }
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore a SELinux context on a path recursively.
|
||||
* Equivalent to: chcon -hR [context] [path]/
|
||||
*/
|
||||
suspend fun chcon(context: String, path: String): Boolean {
|
||||
val ctxEsc = context.shellEscape()
|
||||
val pathEsc = path.shellEscape()
|
||||
val result = RootShell.exec("chcon -hR '$ctxEsc' '$pathEsc/' 2>/dev/null")
|
||||
if (result.isSuccess) return true
|
||||
val fallback = RootShell.exec("chcon -R '$ctxEsc' '$pathEsc/' 2>/dev/null")
|
||||
if (!fallback.isSuccess) {
|
||||
Log.w(TAG, "chcon failed (both primary and fallback): $path")
|
||||
}
|
||||
return fallback.isSuccess
|
||||
}
|
||||
}
|
||||
@@ -11,8 +11,10 @@ import jcifs.smb.SmbFileInputStream
|
||||
import jcifs.smb.SmbFileOutputStream
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import java.io.File
|
||||
import java.util.Properties
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
class SmbTransport(
|
||||
private val host: String,
|
||||
@@ -21,10 +23,21 @@ class SmbTransport(
|
||||
private val password: String,
|
||||
private val domain: String = "",
|
||||
private val bufferSize: Int = 8192,
|
||||
private val smbSigning: Boolean = true
|
||||
private val smbSigning: Boolean = false
|
||||
): RemoteTransport {
|
||||
companion object { private const val TAG = "SmbTransport" }
|
||||
companion object {
|
||||
private const val TAG = "SmbTransport"
|
||||
|
||||
/** Register missing JCA algorithms for jcifs-ng (MD4, AESCMAC, etc.). */
|
||||
private val patchesRegistered = AtomicBoolean(false)
|
||||
fun registerMissingAlgorithms() {
|
||||
if (patchesRegistered.compareAndSet(false, true)) {
|
||||
MissingAlgoProvider.register()
|
||||
}
|
||||
}
|
||||
}
|
||||
private val context: CIFSContext by lazy {
|
||||
registerMissingAlgorithms()
|
||||
val props = Properties().apply {
|
||||
// Force SMB 2.0.2 minimum — SMB1 is disabled on modern Windows
|
||||
setProperty("jcifs.smb.client.minVersion", "SMB202")
|
||||
@@ -32,7 +45,7 @@ class SmbTransport(
|
||||
// Shorter timeouts for Android
|
||||
setProperty("jcifs.smb.client.responseTimeout", "15000")
|
||||
setProperty("jcifs.smb.client.connTimeout", "10000")
|
||||
// Enable SMB signing for security (prevents tampering) — disable for legacy servers
|
||||
// SMB signing (disabled by default — most home servers don't support it)
|
||||
if (smbSigning) {
|
||||
setProperty("jcifs.smb.client.signingEnabled", "true")
|
||||
setProperty("jcifs.smb.client.encryptionEnabled", "true")
|
||||
@@ -46,7 +59,9 @@ class SmbTransport(
|
||||
}
|
||||
}
|
||||
|
||||
/** Build a full SMB URL. If [path] is already a full URL, pass through. */
|
||||
private fun buildUrl(path: String): String {
|
||||
if (path.startsWith("smb://")) return path
|
||||
val cleanPath = path.trimStart('/')
|
||||
val sharePath = if (share.isNotEmpty()) "$share/$cleanPath" else cleanPath
|
||||
return "smb://$host/$sharePath"
|
||||
@@ -54,44 +69,54 @@ 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): Result<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)
|
||||
override suspend fun upload(localPath: String, remotePath: String, onProgress: suspend (RemoteTransport.TransferProgress) -> Unit, onByteProgress: suspend (RemoteTransport.ByteProgress) -> Unit): AppResult<Unit> =
|
||||
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))
|
||||
}
|
||||
onProgress(RemoteTransport.TransferProgress("completed", 1, 1, remotePath))
|
||||
Log.i(TAG, "upload $localPath -> ${buildUrl(remotePath)} ($fileSize bytes)")
|
||||
Result.success(Unit)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "upload failed: ${buildUrl(remotePath)}", e)
|
||||
Result.failure(Exception("SMB upload failed: ${e.message}", e))
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun download(remotePath: String, localPath: String, onProgress: suspend (RemoteTransport.TransferProgress) -> Unit, onByteProgress: suspend (RemoteTransport.ByteProgress) -> Unit): Result<Unit> =
|
||||
withContext(Dispatchers.IO) {
|
||||
override suspend fun download(remotePath: String, localPath: String, onProgress: suspend (RemoteTransport.TransferProgress) -> Unit, onByteProgress: suspend (RemoteTransport.ByteProgress) -> Unit): AppResult<Unit> =
|
||||
retryWithBackoff(TAG, "SMB 下载") {
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val localFile = File(localPath)
|
||||
localFile.parentFile?.mkdirs()
|
||||
@@ -114,19 +139,22 @@ class SmbTransport(
|
||||
}
|
||||
onProgress(RemoteTransport.TransferProgress("completed", 1, 1, remotePath))
|
||||
Log.d(TAG, "download ${buildUrl(remotePath)} -> $localPath (${localFile.length()} bytes)")
|
||||
Result.success(Unit)
|
||||
AppResult.Success(Unit)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "download failed: $remotePath", e)
|
||||
Result.failure(Exception("SMB download failed: ${e.message}", e))
|
||||
err(AppError.Remote("SMB 下载失败", "download", cause = e))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun listFiles(remoteDir: String): Result<List<RemoteTransport.RemoteFileInfo>> =
|
||||
override suspend fun listFiles(remoteDir: String): AppResult<List<RemoteTransport.RemoteFileInfo>> =
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val dir = smbFile(remoteDir)
|
||||
if (!dir.exists() || !dir.isDirectory) {
|
||||
return@withContext Result.failure(FileNotFoundException(remoteDir))
|
||||
return@withContext err(AppError.Remote("远端路径不存在", "list", isNotFound = true))
|
||||
}
|
||||
// SmbFile.getName() in jcifs-ng 2.1.x is broken — it concatenates
|
||||
// parent-dir + filename without separator. Use the URL to extract
|
||||
@@ -154,66 +182,87 @@ class SmbTransport(
|
||||
}
|
||||
?: emptyList()
|
||||
Log.d(TAG, "listFiles $remoteDir -> ${entries.size} entries: ${entries.joinToString { "${it.name}(${if (it.isDirectory) "d" else "f"},${it.size})" }}")
|
||||
Result.success(entries)
|
||||
AppResult.Success(entries)
|
||||
} catch (e: SmbException) {
|
||||
if (e.ntStatus == 0xC0000034.toInt()) {
|
||||
return@withContext Result.failure(FileNotFoundException(remoteDir))
|
||||
return@withContext err(AppError.Remote("远端路径不存在", "list", isNotFound = true))
|
||||
}
|
||||
Log.e(TAG, "listFiles failed: $remoteDir", e)
|
||||
Result.failure(Exception("SMB list failed: ${e.message}", e))
|
||||
err(AppError.Remote("SMB 列表失败", "list", cause = e))
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "listFiles failed: $remoteDir", e)
|
||||
Result.failure(Exception("SMB list failed: ${e.message}", e))
|
||||
err(AppError.Remote("SMB 列表失败", "list", cause = e))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
override suspend fun mkdirs(remotePath: String): Result<Unit> =
|
||||
override suspend fun mkdirs(remotePath: String): AppResult<Unit> =
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val dir = smbFile(remotePath)
|
||||
if (!dir.exists()) dir.mkdirs()
|
||||
Result.success(Unit)
|
||||
AppResult.Success(Unit)
|
||||
} catch (e: SmbException) {
|
||||
// STATUS_OBJECT_NAME_COLLISION (0xC0000035): directory already exists — not an error
|
||||
if (e.ntStatus == 0xC0000035.toInt()) {
|
||||
Result.success(Unit)
|
||||
AppResult.Success(Unit)
|
||||
} else {
|
||||
Log.e(TAG, "mkdirs failed: $remotePath — ${e.message}")
|
||||
Result.failure(Exception("SMB mkdirs failed: ${e.message}", e))
|
||||
Log.e(TAG, "mkdirs failed: $remotePath — ntStatus=0x${e.ntStatus.toString(16)} msg=${e.message} cause=${e.cause}")
|
||||
err(AppError.Remote("SMB 创建目录失败", "mkdirs", cause = e))
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "mkdirs failed: $remotePath — ${e.message}")
|
||||
Result.failure(Exception("SMB mkdirs failed: ${e.message}", e))
|
||||
Log.e(TAG, "mkdirs failed: $remotePath — ${e::class.java.name}: ${e.message} cause=${e.cause?.message}")
|
||||
err(AppError.Remote("SMB 创建目录失败", "mkdirs", cause = e))
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun delete(remotePath: String): Result<Unit> =
|
||||
override suspend fun delete(remotePath: String): AppResult<Unit> =
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val file = smbFile(remotePath)
|
||||
if (file.exists()) file.delete()
|
||||
Result.success(Unit)
|
||||
AppResult.Success(Unit)
|
||||
} catch (e: SmbException) {
|
||||
// STATUS_OBJECT_NAME_NOT_FOUND (0xC0000034): file already gone — not an error
|
||||
if (e.ntStatus == 0xC0000034.toInt()) {
|
||||
Result.success(Unit)
|
||||
AppResult.Success(Unit)
|
||||
} else {
|
||||
Log.w(TAG, "delete failed: $remotePath — ${e.message}")
|
||||
Result.failure(Exception("SMB delete failed: ${e.message}", e))
|
||||
err(AppError.Remote("SMB 删除失败", "delete", cause = e))
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "delete failed: $remotePath — ${e.message}")
|
||||
Result.failure(Exception("SMB delete failed: ${e.message}", e))
|
||||
err(AppError.Remote("SMB 删除失败", "delete", cause = e))
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun exists(remotePath: String): Result<Boolean> =
|
||||
override suspend fun exists(remotePath: String): AppResult<Boolean> =
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
Result.success(smbFile(remotePath).exists())
|
||||
AppResult.Success(smbFile(remotePath).exists())
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Result.failure(Exception("SMB exists check failed: ${e.message}", e))
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,20 +6,31 @@ import com.thegrizzlylabs.sardineandroid.impl.OkHttpSardine
|
||||
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)
|
||||
}
|
||||
@@ -31,73 +42,140 @@ class WebdavTransport(
|
||||
return "$baseUrl/$cleanPath"
|
||||
}
|
||||
|
||||
override suspend fun upload(localPath: String, remotePath: String, onProgress: suspend (RemoteTransport.TransferProgress) -> Unit, onByteProgress: suspend (RemoteTransport.ByteProgress) -> Unit): Result<Unit> =
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val url = buildUrl(remotePath)
|
||||
val file = File(localPath)
|
||||
val fileSize = file.length()
|
||||
if (fileSize > 50 * 1024 * 1024L) {
|
||||
return@withContext Result.failure(
|
||||
Exception("WebDAV upload: file too large (${fileSize / 1024 / 1024}MB), max 50MB")
|
||||
)
|
||||
}
|
||||
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)
|
||||
override suspend fun upload(localPath: String, remotePath: 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 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))
|
||||
Result.success(Unit)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "upload failed: $remotePath", e)
|
||||
Result.failure(Exception("WebDAV upload failed: ${e.message}", e))
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun download(remotePath: String, localPath: String, onProgress: suspend (RemoteTransport.TransferProgress) -> Unit, onByteProgress: suspend (RemoteTransport.ByteProgress) -> Unit): Result<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)")
|
||||
Result.success(Unit)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "download failed: $remotePath", e)
|
||||
Result.failure(Exception("WebDAV download failed: ${e.message}", e))
|
||||
}
|
||||
}
|
||||
override suspend fun listFiles(remoteDir: String): Result<List<RemoteTransport.RemoteFileInfo>> =
|
||||
|
||||
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 {
|
||||
val url = buildUrl(remoteDir)
|
||||
@@ -116,7 +194,9 @@ class WebdavTransport(
|
||||
isDirectory = it.isDirectory
|
||||
) }
|
||||
Log.d(TAG, "listFiles $remoteDir -> ${entries.size} entries")
|
||||
Result.success(entries)
|
||||
AppResult.Success(entries)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
// Only treat 404 as empty for non-root paths; the caller (listRemoteRecursive)
|
||||
// handles the distinction. We propagate the error so the caller can decide.
|
||||
@@ -124,14 +204,14 @@ class WebdavTransport(
|
||||
if (is404) {
|
||||
// Return a failure with a distinguishable marker so callers can check
|
||||
Log.d(TAG, "listFiles $remoteDir -> 404 (not found)")
|
||||
return@withContext Result.failure(FileNotFoundException(remoteDir))
|
||||
return@withContext err(AppError.Remote("远端路径不存在", "list", isNotFound = true))
|
||||
}
|
||||
Log.e(TAG, "listFiles failed: $remoteDir", e)
|
||||
Result.failure(Exception("WebDAV list failed: ${e.message}", e))
|
||||
err(AppError.Remote("WebDAV 列表失败", "list", cause = e))
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun mkdirs(remotePath: String): Result<Unit> =
|
||||
override suspend fun mkdirs(remotePath: String): AppResult<Unit> =
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val parts = remotePath.trimStart('/').split("/")
|
||||
@@ -139,34 +219,55 @@ class WebdavTransport(
|
||||
for (part in parts) {
|
||||
current = if (current.isEmpty()) part else "$current/$part"
|
||||
try { sardine.createDirectory(buildUrl(current)) }
|
||||
catch (_: Exception) { /* already exists or parent missing, continue */ }
|
||||
catch (_: Exception) { Log.w(TAG, "mkdirs: failed to create $current"); continue }
|
||||
}
|
||||
Result.success(Unit)
|
||||
AppResult.Success(Unit)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "mkdirs failed: $remotePath — ${e.message}")
|
||||
Result.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))
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun delete(remotePath: String): Result<Unit> =
|
||||
override suspend fun delete(remotePath: String): AppResult<Unit> =
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val url = buildUrl(remotePath)
|
||||
sardine.delete(url)
|
||||
Result.success(Unit)
|
||||
AppResult.Success(Unit)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "delete failed (ignoring): $remotePath — ${e.message}")
|
||||
Result.success(Unit)
|
||||
err(AppError.Remote("WebDAV 删除失败", "delete", cause = e))
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun exists(remotePath: String): Result<Boolean> =
|
||||
override suspend fun exists(remotePath: String): AppResult<Boolean> =
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val result = sardine.exists(buildUrl(remotePath))
|
||||
Result.success(result)
|
||||
AppResult.Success(result)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Result.failure(Exception("WebDAV exists check failed: ${e.message}", e))
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,12 +5,15 @@ import com.example.androidbackupgui.root.shellEscape
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import android.util.Log
|
||||
|
||||
/**
|
||||
* Backup and restore WiFi configuration.
|
||||
* Mirrors backup_script WiFi backup/restore logic.
|
||||
*/
|
||||
object WifiManager {
|
||||
private const val TAG = "WifiManager"
|
||||
|
||||
|
||||
// Possible WiFi config paths on different Android versions
|
||||
private val WIFI_PATHS = listOf(
|
||||
@@ -57,21 +60,27 @@ object WifiManager {
|
||||
// Try the most common path
|
||||
val fallback = "/data/misc/apexdata/com.android.wifi/WifiConfigStore.xml"
|
||||
val parent = File(fallback).parentFile?.absolutePath?.shellEscape() ?: return@withContext false
|
||||
RootShell.exec("mkdir -p '$parent'")
|
||||
val mkdirResult = RootShell.exec("mkdir -p '$parent'")
|
||||
if (!mkdirResult.isSuccess) return@withContext false
|
||||
val result = RootShell.exec("cp '$backupPath' '$fallback'")
|
||||
if (!result.isSuccess) return@withContext false
|
||||
RootShell.exec("chown system:wifi '$fallback'")
|
||||
RootShell.exec("chmod 0660 '$fallback'")
|
||||
val chownResult = RootShell.exec("chown system:wifi '$fallback'")
|
||||
if (!chownResult.isSuccess) Log.w(TAG, "chown failed: ${chownResult.error}")
|
||||
val chmodResult = RootShell.exec("chmod 0660 '$fallback'")
|
||||
if (!chmodResult.isSuccess) Log.w(TAG, "chmod failed: ${chmodResult.error}")
|
||||
} else {
|
||||
val result = RootShell.exec("cp '$backupPath' '$wifiTarget'")
|
||||
if (!result.isSuccess) return@withContext false
|
||||
RootShell.exec("chown system:wifi '$wifiTarget'")
|
||||
RootShell.exec("chmod 0660 '$wifiTarget'")
|
||||
val chownResult = RootShell.exec("chown system:wifi '$wifiTarget'")
|
||||
if (!chownResult.isSuccess) Log.w(TAG, "chown failed: ${chownResult.error}")
|
||||
val chmodResult = RootShell.exec("chmod 0660 '$wifiTarget'")
|
||||
if (!chmodResult.isSuccess) Log.w(TAG, "chmod failed: ${chmodResult.error}")
|
||||
}
|
||||
|
||||
// WiFi backup only takes effect after reboot, but we can try reloading
|
||||
RootShell.exec("svc wifi disable 2>/dev/null")
|
||||
RootShell.exec("svc wifi enable 2>/dev/null")
|
||||
// These are best-effort since reloading WiFi only takes full effect on reboot
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
package com.example.androidbackupgui.root
|
||||
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.sync.*
|
||||
import java.io.BufferedReader
|
||||
import java.io.InputStreamReader
|
||||
import java.io.OutputStreamWriter
|
||||
import java.io.InputStream
|
||||
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
|
||||
|
||||
/**
|
||||
* Escape a string for safe use inside single-quoted shell strings.
|
||||
@@ -15,23 +16,16 @@ import android.util.Log
|
||||
fun String.shellEscape(): String = this.replace("'", "'\\''")
|
||||
|
||||
/**
|
||||
* Persistent root shell session via `su`.
|
||||
* Manages a single su process and executes commands sequentially.
|
||||
* Thread-safe via Mutex — all session state is guarded by the mutex.
|
||||
* Root shell access via libsu.
|
||||
* Shell.cmd internally manages su sessions, compatible with Magisk/KernelSU/APatch.
|
||||
* All shell operations are thread-safe through coroutine dispatchers.
|
||||
*/
|
||||
object RootShell {
|
||||
|
||||
private var process: Process? = null
|
||||
private var writer: OutputStreamWriter? = null
|
||||
private var reader: BufferedReader? = null
|
||||
private var errReader: BufferedReader? = null
|
||||
|
||||
private const val TAG = "RootShell"
|
||||
/** Default command timeout in milliseconds. */
|
||||
private const val COMMAND_TIMEOUT_MS = 120_000L
|
||||
|
||||
private val mutex = Mutex()
|
||||
|
||||
/** Result of a shell command execution. */
|
||||
data class ShellResult(
|
||||
val output: String,
|
||||
@@ -41,134 +35,82 @@ object RootShell {
|
||||
val isSuccess get() = exitCode == 0
|
||||
}
|
||||
|
||||
/** Quick process-alive check. Caller MUST hold the mutex. */
|
||||
private fun isAliveUnsafe(): Boolean {
|
||||
val p = process ?: return false
|
||||
return try { p.exitValue(); false } catch (_: IllegalThreadStateException) { true }
|
||||
}
|
||||
|
||||
/**
|
||||
* Open (or re-open) the su session and verify root access.
|
||||
* Caller MUST hold the mutex.
|
||||
* libsu shell initializer: enter global mount namespace via nsenter.
|
||||
* Preserves the original PATH so that tar/zstd (from Termux etc.) remain accessible.
|
||||
* Ref: DataBackup (XayahSuSuSu) uses the same nsenter pattern.
|
||||
*/
|
||||
private fun ensureSessionUnsafe(): Boolean {
|
||||
if (isAliveUnsafe()) return true
|
||||
return try {
|
||||
val p = Runtime.getRuntime().exec(arrayOf("su"))
|
||||
writer = OutputStreamWriter(p.outputStream)
|
||||
reader = BufferedReader(InputStreamReader(p.inputStream))
|
||||
errReader = BufferedReader(InputStreamReader(p.errorStream))
|
||||
process = p
|
||||
// Drain stderr in background to prevent pipe-buffer deadlock
|
||||
Thread({
|
||||
try { while (errReader?.readLine() != null) {} } catch (_: Exception) {}
|
||||
}, "su-stderr-drain").apply { isDaemon = true; start() }
|
||||
// Inline verification — cannot call exec() which would deadlock on mutex
|
||||
val sentinel = "ROOT_OK_${System.nanoTime()}"
|
||||
writer?.write("echo $sentinel\n"); writer?.flush()
|
||||
var line: String?
|
||||
while (reader?.readLine().also { line = it } != null) {
|
||||
if (line!!.contains(sentinel)) return true
|
||||
}
|
||||
false
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
private class GlobalNamespaceInitializer : Shell.Initializer() {
|
||||
override fun onInit(context: android.content.Context, shell: Shell): Boolean {
|
||||
shell.newJob()
|
||||
.add("nsenter --mount=/proc/1/ns/mnt sh")
|
||||
.add("set -o pipefail")
|
||||
.exec()
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/** Ensure a root shell is open. Returns true if root is available. */
|
||||
suspend fun ensureSession(): Boolean = mutex.withLock {
|
||||
ensureSessionUnsafe()
|
||||
}
|
||||
|
||||
/** Cleanup all session state. Caller MUST hold the mutex. */
|
||||
private fun closeUnsafe() {
|
||||
try { writer?.close() } catch (_: Exception) {}
|
||||
try { reader?.close() } catch (_: Exception) {}
|
||||
try { errReader?.close() } catch (_: Exception) {}
|
||||
try { process?.destroy() } catch (_: Exception) {}
|
||||
process = null
|
||||
writer = null
|
||||
reader = null
|
||||
errReader = null
|
||||
}
|
||||
|
||||
/** Close the root shell session. */
|
||||
suspend fun close() = mutex.withLock {
|
||||
closeUnsafe()
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a command and return the output.
|
||||
* Uses a sentinel delimiter to identify end of output.
|
||||
* Timeout is enforced via structured coroutine cancellation:
|
||||
* `withTimeout(timeoutMs)` cancels the coroutine, interrupting the
|
||||
* blocking readLine() on Dispatchers.IO. If the process cannot be
|
||||
* interrupted, closeUnsafe() destroys it in the catch handler.
|
||||
*/
|
||||
suspend fun exec(command: String, timeoutMs: Long = COMMAND_TIMEOUT_MS): ShellResult = mutex.withLock {
|
||||
if (!isAliveUnsafe() && !ensureSessionUnsafe()) {
|
||||
return@exec ShellResult("", "No root access", -1)
|
||||
}
|
||||
|
||||
val sentinel = "EXIT_${System.nanoTime()}"
|
||||
writer?.write("$command; echo $sentinel \$?\n")
|
||||
writer?.flush()
|
||||
|
||||
/** Call once at app startup to configure libsu. Safe to call multiple times. */
|
||||
fun configure() {
|
||||
Shell.enableVerboseLogging = true
|
||||
try {
|
||||
withTimeout(timeoutMs) {
|
||||
withContext(Dispatchers.IO) {
|
||||
val output = StringBuilder()
|
||||
var line: String?
|
||||
while (reader?.readLine().also { line = it } != null) {
|
||||
val l = line!!
|
||||
if (l.startsWith(sentinel)) {
|
||||
val code = l.removePrefix("$sentinel ").trim().toIntOrNull() ?: -1
|
||||
return@withContext ShellResult(output.toString().trimEnd(), "", code)
|
||||
}
|
||||
output.appendLine(l)
|
||||
}
|
||||
// Process destroyed or readLine returned null naturally
|
||||
ShellResult(output.toString().trimEnd(), "", -1)
|
||||
}
|
||||
}
|
||||
} catch (e: TimeoutCancellationException) {
|
||||
Log.w(TAG, "exec timeout (${timeoutMs}ms) destroying process: $command")
|
||||
closeUnsafe()
|
||||
ShellResult("", "Command timed out after ${timeoutMs}ms", -1)
|
||||
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 (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) { false }
|
||||
}
|
||||
|
||||
suspend fun exec(command: String, timeoutMs: Long = COMMAND_TIMEOUT_MS): ShellResult =
|
||||
withContext(Dispatchers.IO) {
|
||||
ensureActive()
|
||||
try {
|
||||
val result = withTimeout(timeoutMs) {
|
||||
Shell.cmd(command).exec()
|
||||
}
|
||||
ShellResult(
|
||||
output = result.out.joinToString("\n"),
|
||||
error = result.err.joinToString("\n"),
|
||||
exitCode = result.code,
|
||||
)
|
||||
} 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)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a command via `su` and return the stdout as an InputStream
|
||||
* for binary-safe streaming. Caller MUST close the stream and call
|
||||
* waitForStreamResult() or destroy the returned process.
|
||||
* 安全执行 root shell 命令,自动 shellEscape 每个参数。
|
||||
* @param parts 命令和参数列表,第一个元素是命令本身
|
||||
* @param timeoutMs 超时毫秒
|
||||
*/
|
||||
class StreamProcess(
|
||||
val process: Process,
|
||||
val inputStream: InputStream,
|
||||
private val command: String
|
||||
) {
|
||||
fun waitFor(): Int {
|
||||
try { process.waitFor() } catch (_: Exception) {}
|
||||
return process.exitValue()
|
||||
}
|
||||
fun destroy() {
|
||||
try { process.destroy() } catch (_: Exception) {}
|
||||
try { inputStream.close() } catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
|
||||
fun execBinary(command: String): StreamProcess? {
|
||||
return try {
|
||||
val p = Runtime.getRuntime().exec(arrayOf("su", "-c", command))
|
||||
// Drain stderr to prevent pipe deadlock
|
||||
Thread({
|
||||
try { p.errorStream.use { it.readBytes() } } catch (_: Exception) {}
|
||||
}, "su-binary-stderr").apply { isDaemon = true }.start()
|
||||
StreamProcess(p, p.inputStream, command)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
suspend fun execSafe(
|
||||
parts: List<String>,
|
||||
timeoutMs: Long = COMMAND_TIMEOUT_MS
|
||||
): ShellResult = exec(
|
||||
command = parts.joinToString(" ") { "'${it.shellEscape()}'" },
|
||||
timeoutMs = timeoutMs
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,207 +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.fragment.app.Fragment
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import com.example.androidbackupgui.backup.AppInfo
|
||||
import com.example.androidbackupgui.backup.AppScanner
|
||||
import com.example.androidbackupgui.backup.BackupConfig
|
||||
import com.example.androidbackupgui.backup.BackupOperation
|
||||
import com.example.androidbackupgui.backup.ResticBinary
|
||||
import com.example.androidbackupgui.backup.ResticWrapper
|
||||
import com.example.androidbackupgui.backup.WifiManager
|
||||
import com.example.androidbackupgui.backup.RemoteTransport
|
||||
import com.example.androidbackupgui.databinding.FragmentBackupBinding
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
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 lateinit var config: BackupConfig
|
||||
|
||||
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)
|
||||
|
||||
binding.appList.layoutManager = LinearLayoutManager(requireContext())
|
||||
|
||||
binding.scanButton.setOnClickListener { scanApps() }
|
||||
binding.backupButton.setOnClickListener { startBackup() }
|
||||
}
|
||||
|
||||
private fun scanApps() {
|
||||
binding.backupButton.isEnabled = false
|
||||
setRunning(true)
|
||||
binding.statusText.text = "正在扫描应用…"
|
||||
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
val ctx = requireContext()
|
||||
val thirdParty = AppScanner.scanThirdParty(ctx)
|
||||
val system = AppScanner.scanSystem(ctx, config)
|
||||
apps = thirdParty + system
|
||||
selectedApps.clear()
|
||||
selectedApps.addAll(apps.map { it.packageName })
|
||||
|
||||
binding.statusText.text = "共找到 ${apps.size} 个应用,全部已选中"
|
||||
binding.backupButton.isEnabled = apps.isNotEmpty()
|
||||
setRunning(false)
|
||||
|
||||
setupAppList()
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupAppList() {
|
||||
binding.appList.adapter = PackageListAdapter(apps, selectedApps) { pkg, checked ->
|
||||
if (checked) selectedApps.add(pkg) else selectedApps.remove(pkg)
|
||||
binding.statusText.text = "已选择 ${selectedApps.size}/${apps.size} 个应用"
|
||||
}
|
||||
}
|
||||
|
||||
private fun startBackup() {
|
||||
val toBackup = apps.filter { it.packageName in selectedApps }
|
||||
if (toBackup.isEmpty()) return
|
||||
|
||||
setRunning(true)
|
||||
binding.backupButton.isEnabled = false
|
||||
binding.scanButton.isEnabled = false
|
||||
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
val outputDir = File(config.outputPath.ifEmpty {
|
||||
requireContext().filesDir.absolutePath
|
||||
})
|
||||
WifiManager.backup(outputDir)
|
||||
val result = BackupOperation.backupApps(
|
||||
apps = toBackup,
|
||||
config = config,
|
||||
outputDir = outputDir,
|
||||
onProgress = { progress ->
|
||||
val label = toBackup.find { it.packageName == progress.packageName }?.label
|
||||
val name = label?.ifEmpty { progress.packageName } ?: progress.packageName
|
||||
binding.statusText.text =
|
||||
"[${progress.current}/${progress.total}] $name: ${progress.message}"
|
||||
}
|
||||
)
|
||||
|
||||
// If restic is enabled, snapshot the backup to a restic repository
|
||||
var resticSummary: ResticWrapper.BackupSummary? = null
|
||||
if (config.resticEnabled == 1 && config.resticRepo.isNotBlank()) {
|
||||
val binaryPath = ResticBinary.prepare(requireContext())
|
||||
if (binaryPath != null) {
|
||||
ResticWrapper.binaryPath = binaryPath
|
||||
ResticWrapper.tempRepoDir = ResticBinary.getTempRepoDir(requireContext())
|
||||
ResticWrapper.backendDomain = config.resticBackendDomain
|
||||
|
||||
// For local repos, verify init before attempting backup
|
||||
if (config.resticBackend == "local") {
|
||||
if (!File(config.resticRepo, "config").exists()) {
|
||||
binding.statusText.text = "restic 本地仓库未初始化,请先在设置中初始化"
|
||||
setRunning(false)
|
||||
binding.scanButton.isEnabled = true
|
||||
return@launch
|
||||
}
|
||||
}
|
||||
binding.statusText.text = "正在写入 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,
|
||||
onSyncProgress = { progress: RemoteTransport.TransferProgress ->
|
||||
withContext(Dispatchers.Main) {
|
||||
when (progress.phase) {
|
||||
"list", "download", "upload", "delete_stale" ->
|
||||
binding.statusText.text = "同步中: ${progress.current}/${progress.total} 个文件"
|
||||
}
|
||||
}
|
||||
},
|
||||
onByteSyncProgress = { progress ->
|
||||
withContext(Dispatchers.Main) {
|
||||
binding.progressBar.max = progress.totalBytes.toInt().coerceAtLeast(1)
|
||||
binding.progressBar.progress = progress.bytesTransferred.toInt()
|
||||
binding.statusText.text = "同步中: ${progress.currentFile}\n" +
|
||||
"${formatSize(progress.bytesTransferred)} / ${formatSize(progress.totalBytes)}"
|
||||
}
|
||||
},
|
||||
onProgress = { progress ->
|
||||
if (progress.messageType == "status") {
|
||||
binding.statusText.text = "去重仓库: %.0f%% (%d/%d 个文件)".format(
|
||||
progress.percentDone * 100,
|
||||
progress.filesDone,
|
||||
progress.totalFiles
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
resticResult.fold(
|
||||
onSuccess = { resticSummary = it },
|
||||
onFailure = { e ->
|
||||
binding.statusText.text = "restic 快照失败: ${e.message}"
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
binding.statusText.text = buildString {
|
||||
appendLine("备份完成!")
|
||||
appendLine("成功: ${result.successCount} 失败: ${result.failCount}")
|
||||
appendLine("耗时: ${result.elapsedMs / 1000}秒")
|
||||
appendLine("输出: ${result.outputDir}")
|
||||
if (resticSummary != null) {
|
||||
appendLine()
|
||||
appendLine("── Restic 快照 ──")
|
||||
appendLine("ID: ${resticSummary!!.snapshotId.take(8)}…")
|
||||
appendLine("新增: ${resticSummary!!.dataAdded / 1024 / 1024} MB")
|
||||
appendLine("文件: ${resticSummary!!.totalFilesProcessed}")
|
||||
}
|
||||
}
|
||||
setRunning(false)
|
||||
binding.scanButton.isEnabled = true
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatSize(bytes: Long): String {
|
||||
if (bytes < 1024) return "$bytes B"
|
||||
val units = arrayOf("KB", "MB", "GB", "TB")
|
||||
val exp = (63 - bytes.countLeadingZeroBits()) / 10
|
||||
val value = bytes.toDouble() / (1L shl (exp * 10))
|
||||
return "%.1f %s".format(Locale.US, value, units[exp - 1].coerceAtMost(units.last()))
|
||||
}
|
||||
|
||||
private fun setRunning(running: Boolean) {
|
||||
binding.progressBar.visibility = if (running) View.VISIBLE else View.GONE
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) {
|
||||
ResticWrapper.cleanup()
|
||||
}
|
||||
super.onDestroyView()
|
||||
_binding = null
|
||||
}
|
||||
}
|
||||
@@ -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,215 +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 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
|
||||
|
||||
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 for derived UI updates
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
vm.uiState.collect { state -> applyState(state) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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().also { config ->
|
||||
config.backupMode = if (binding.backupModeSwitch.isChecked) 1 else 0
|
||||
config.backupUserData = if (binding.backupUserDataSwitch.isChecked) 1 else 0
|
||||
config.backupObbData = if (binding.backupObbSwitch.isChecked) 1 else 0
|
||||
config.backgroundAppsIgnore = if (binding.ignoreRunningSwitch.isChecked) 1 else 0
|
||||
config.outputPath = binding.outputPathEdit.text?.toString() ?: ""
|
||||
config.compressionMethod = binding.compressionEdit.text?.toString()?.ifEmpty { "zstd" } ?: "zstd"
|
||||
config.backupWifi = if (binding.backupWifiSwitch.isChecked) 1 else 0
|
||||
|
||||
config.resticEnabled = if (binding.resticEnabledSwitch.isChecked) 1 else 0
|
||||
config.resticRepo = binding.resticRepoEdit.text?.toString()?.trim() ?: ""
|
||||
config.resticPassword = binding.resticPasswordEdit.text?.toString() ?: ""
|
||||
config.resticBackend = readBackend()
|
||||
config.resticBackendUrl = binding.resticBackendUrlEdit.text?.toString()?.trim() ?: ""
|
||||
config.resticBackendUser = binding.resticBackendUserEdit.text?.toString()?.trim() ?: ""
|
||||
config.resticBackendPass = binding.resticBackendPassEdit.text?.toString() ?: ""
|
||||
config.resticBackendShare = binding.resticBackendShareEdit.text?.toString()?.trim() ?: ""
|
||||
config.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())
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
_binding = null
|
||||
// cleanup is handled by ViewModel.onCleared()
|
||||
}
|
||||
}
|
||||
@@ -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,28 +1,34 @@
|
||||
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.PasswordManager
|
||||
import com.example.androidbackupgui.backup.ResticBinary
|
||||
import com.example.androidbackupgui.backup.ResticWrapper
|
||||
import com.example.androidbackupgui.backup.RemoteTransport
|
||||
import com.example.androidbackupgui.backup.defaultResticWrapper
|
||||
import com.example.androidbackupgui.backup.formatSize
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
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
|
||||
import java.io.File
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
/** UI-visible state driven by [ConfigViewModel]. */
|
||||
data class ConfigUiState(
|
||||
val config: BackupConfig = BackupConfig(),
|
||||
val backendDisplay: BackendDisplay = BackendDisplay(),
|
||||
val resticStatus: ResticStatus = ResticStatus()
|
||||
val resticStatus: ResticStatus = ResticStatus(),
|
||||
)
|
||||
|
||||
data class BackendDisplay(
|
||||
@@ -30,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(
|
||||
@@ -41,56 +47,107 @@ 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,
|
||||
)
|
||||
|
||||
class ConfigViewModel(application: Application) : AndroidViewModel(application) {
|
||||
/**
|
||||
* 类型安全的一键操作生命周期事件。
|
||||
* [ConfigFragment] 应对此进行收集以触发一次性 UI 效果。
|
||||
*/
|
||||
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) {
|
||||
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,
|
||||
)
|
||||
}
|
||||
|
||||
fun formatSize(bytes: Long): String {
|
||||
if (bytes < 1024) return "$bytes B"
|
||||
val units = arrayOf("KB", "MB", "GB", "TB")
|
||||
val exp = (63 - bytes.countLeadingZeroBits()) / 10
|
||||
val value = bytes.toDouble() / (1L shl (exp * 10))
|
||||
return "%.1f %s".format(Locale.US, value, units[exp - 1].coerceAtMost(units.last()))
|
||||
}
|
||||
}
|
||||
|
||||
private val configFile: File by lazy {
|
||||
File(getApplication<Application>().filesDir, CONFIG_FILE_NAME)
|
||||
}
|
||||
|
||||
/** One-shot operation lifecycle events (e.g. "operation started", "operation completed"). */
|
||||
private val _operationEvents = MutableSharedFlow<OperationEvent>(extraBufferCapacity = 16)
|
||||
val operationEvents: SharedFlow<OperationEvent> = _operationEvents.asSharedFlow()
|
||||
|
||||
private val _uiState = MutableStateFlow(ConfigUiState())
|
||||
val uiState: StateFlow<ConfigUiState> = _uiState.asStateFlow()
|
||||
|
||||
/** 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)
|
||||
@@ -101,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) }
|
||||
}
|
||||
@@ -120,12 +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) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
BackupConfig.toFile(formConfig, configFile)
|
||||
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 = "配置导入失败")) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -135,23 +353,32 @@ class ConfigViewModel(application: Application) : AndroidViewModel(application)
|
||||
val ctx = getApplication<Application>()
|
||||
val binaryPath = ResticBinary.prepare(ctx)
|
||||
if (binaryPath == null) return false
|
||||
ResticWrapper.binaryPath = binaryPath
|
||||
ResticWrapper.tempRepoDir = ResticBinary.getTempRepoDir(ctx)
|
||||
defaultResticWrapper.binaryPath = binaryPath
|
||||
defaultResticWrapper.cacheDir = ctx.cacheDir.absolutePath
|
||||
return true
|
||||
}
|
||||
|
||||
// ── Async restic operations ──────────────────────────────────────
|
||||
|
||||
fun initResticRepo(form: ResticForm) {
|
||||
if (!initGuard.compareAndSet(false, true)) {
|
||||
Log.w(TAG, "initResticRepo: already in progress, ignoring")
|
||||
return
|
||||
}
|
||||
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()) {
|
||||
@@ -159,181 +386,379 @@ 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 {
|
||||
val result = ResticWrapper.init(form.repo, form.password,
|
||||
backend = form.backend, backendUrl = form.backendUrl,
|
||||
backendUser = form.backendUser, backendPass = form.backendPass,
|
||||
backendShare = form.backendShare,
|
||||
onSyncProgress = { p -> onSyncProgress(p) },
|
||||
onByteSyncProgress = { p -> onByteProgress(p) },
|
||||
)
|
||||
result.fold(
|
||||
onSuccess = {
|
||||
_uiState.update { it.copy(resticStatus = it.resticStatus.copy(
|
||||
message = "仓库初始化成功: ${form.repo}", initButtonEnabled = true
|
||||
))}
|
||||
try {
|
||||
_operationEvents.emit(OperationEvent.InitStarted)
|
||||
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}",
|
||||
),
|
||||
)
|
||||
}
|
||||
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}",
|
||||
),
|
||||
)
|
||||
}
|
||||
refreshResticStatus(form)
|
||||
},
|
||||
onFailure = { e ->
|
||||
Log.e(TAG, "initResticRepo failed", e)
|
||||
_uiState.update { it.copy(resticStatus = it.resticStatus.copy(
|
||||
message = "初始化失败: ${e.message}", initButtonEnabled = true
|
||||
))}
|
||||
}
|
||||
)
|
||||
} finally {
|
||||
initGuard.set(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
onSyncProgress = { p -> onSyncProgress(p) },
|
||||
onByteSyncProgress = { p -> onByteProgress(p) },
|
||||
)
|
||||
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 {
|
||||
val statsResult = ResticWrapper.stats(form.repo, form.password,
|
||||
backend = form.backend, backendUrl = form.backendUrl,
|
||||
backendUser = form.backendUser, backendPass = form.backendPass,
|
||||
backendShare = form.backendShare,
|
||||
onSyncProgress = { p -> onSyncProgress(p) },
|
||||
onByteSyncProgress = { p -> onByteProgress(p) },
|
||||
)
|
||||
val snapshotsResult = ResticWrapper.listSnapshots(form.repo, form.password,
|
||||
backend = form.backend, backendUrl = form.backendUrl,
|
||||
backendUser = form.backendUser, backendPass = form.backendPass,
|
||||
backendShare = form.backendShare,
|
||||
onSyncProgress = { p -> onSyncProgress(p) },
|
||||
onByteSyncProgress = { p -> onByteProgress(p) },
|
||||
)
|
||||
try {
|
||||
_operationEvents.emit(OperationEvent.StatsStarted)
|
||||
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.exceptionOrNull()?.message}")
|
||||
}
|
||||
},
|
||||
snapshotCount = snapshotCount,
|
||||
statsButtonEnabled = true
|
||||
))}
|
||||
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,
|
||||
),
|
||||
)
|
||||
}
|
||||
_operationEvents.emit(OperationEvent.StatsCompleted)
|
||||
} finally {
|
||||
_uiState.update { it.copy(resticStatus = it.resticStatus.copy(statsButtonEnabled = true)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
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,
|
||||
backendShare = form.backendShare,
|
||||
onSyncProgress = { p -> onSyncProgress(p) },
|
||||
onByteSyncProgress = { p -> onByteProgress(p) },
|
||||
)
|
||||
if (forgetResult.isFailure) {
|
||||
_uiState.update { it.copy(resticStatus = it.resticStatus.copy(
|
||||
message = "forget 失败: ${forgetResult.exceptionOrNull()?.message}",
|
||||
pruneButtonEnabled = true
|
||||
))}
|
||||
return@launch
|
||||
try {
|
||||
_operationEvents.emit(OperationEvent.PruneStarted)
|
||||
|
||||
// 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,
|
||||
),
|
||||
)
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
|
||||
_uiState.update { it.copy(resticStatus = it.resticStatus.copy(message = "正在回收空间…")) }
|
||||
|
||||
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 {
|
||||
_operationEvents.emit(OperationEvent.PruneFailed)
|
||||
}
|
||||
} finally {
|
||||
_uiState.update { it.copy(resticStatus = it.resticStatus.copy(pruneButtonEnabled = true)) }
|
||||
}
|
||||
|
||||
_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,
|
||||
onSyncProgress = { p -> onSyncProgress(p) },
|
||||
onByteSyncProgress = { p -> onByteProgress(p) },
|
||||
)
|
||||
_uiState.update { it.copy(resticStatus = it.resticStatus.copy(
|
||||
message = if (pruneResult.isSuccess)
|
||||
"清理完成!\n${pruneResult.getOrDefault("")}"
|
||||
else
|
||||
"prune 失败: ${pruneResult.exceptionOrNull()?.message}",
|
||||
pruneButtonEnabled = true
|
||||
))}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Internal progress helpers ─────────────────────────────────────
|
||||
|
||||
private fun onSyncProgress(p: RemoteTransport.TransferProgress) {
|
||||
_uiState.update {
|
||||
it.copy(resticStatus = it.resticStatus.copy(
|
||||
message = "同步中: ${p.current}/${p.total} 个文件"
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
private fun onByteProgress(p: RemoteTransport.ByteProgress) {
|
||||
_uiState.update {
|
||||
it.copy(resticStatus = it.resticStatus.copy(
|
||||
message = "同步中: ${p.currentFile}\n${formatSize(p.bytesTransferred)} / ${formatSize(p.totalBytes)}"
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/** Cleanup ResticWrapper resources when ViewModel is cleared. */
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
ResticWrapper.cleanup()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,75 +0,0 @@
|
||||
package com.example.androidbackupgui.ui
|
||||
|
||||
import android.view.View
|
||||
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
|
||||
) : 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)
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
|
||||
val ctx = parent.context
|
||||
val card = MaterialCardView(ctx).apply {
|
||||
layoutParams = ViewGroup.MarginLayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
).apply { setMargins(0, 0, 0, 8) }
|
||||
radius = 12f
|
||||
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(16, 12, 16, 12)
|
||||
}
|
||||
val cb = CheckBox(ctx).apply { id = R.id.checkbox }
|
||||
val tv = TextView(ctx).apply {
|
||||
id = R.id.appName
|
||||
setPadding(16, 0, 0, 0)
|
||||
textSize = 15f
|
||||
setTextColor(
|
||||
MaterialColors.getColor(ctx, com.google.android.material.R.attr.colorOnSurface, 0)
|
||||
)
|
||||
}
|
||||
layout.addView(cb)
|
||||
layout.addView(tv)
|
||||
card.addView(layout)
|
||||
return ViewHolder(card)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
|
||||
val app = apps[position]
|
||||
// Prefer app name (label), fall back to package name
|
||||
holder.textView.text = app.label.ifEmpty { app.packageName }
|
||||
// Avoid re-triggering listener during bind
|
||||
holder.checkbox.setOnCheckedChangeListener(null)
|
||||
holder.checkbox.isChecked = app.packageName in selected
|
||||
holder.checkbox.setOnCheckedChangeListener { _, checked ->
|
||||
onToggle(app.packageName, checked)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getItemCount() = apps.size
|
||||
}
|
||||
@@ -1,319 +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.fragment.app.Fragment
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import com.example.androidbackupgui.backup.AppInfo
|
||||
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.backup.RemoteTransport
|
||||
import com.example.androidbackupgui.databinding.FragmentRestoreBinding
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import java.util.Locale
|
||||
|
||||
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
|
||||
|
||||
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.tempRepoDir = ResticBinary.getTempRepoDir(requireContext())
|
||||
ResticWrapper.backendDomain = config.resticBackendDomain
|
||||
binding.selectResticButton.visibility = View.VISIBLE
|
||||
}
|
||||
}
|
||||
|
||||
binding.selectDirButton.setOnClickListener { selectBackupDir() }
|
||||
binding.selectResticButton.setOnClickListener { selectResticSnapshot() }
|
||||
binding.restoreButton.setOnClickListener { startRestore() }
|
||||
}
|
||||
|
||||
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 = it) })
|
||||
setupAppList()
|
||||
}
|
||||
|
||||
private fun selectResticSnapshot() {
|
||||
val config = resticConfig ?: return
|
||||
setRunning(true)
|
||||
binding.statusText.text = "正在读取 restic 快照列表…"
|
||||
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
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) {
|
||||
binding.statusText.text = "读取快照失败: ${snapshotsResult.exceptionOrNull()?.message}"
|
||||
setRunning(false)
|
||||
return@launch
|
||||
}
|
||||
|
||||
val snapshots = snapshotsResult.getOrThrow()
|
||||
if (snapshots.isEmpty()) {
|
||||
binding.statusText.text = "没有可用的 restic 快照"
|
||||
setRunning(false)
|
||||
return@launch
|
||||
}
|
||||
|
||||
// Switch to restic source
|
||||
backupDir = null
|
||||
selectedSnapshot = snapshots.first()
|
||||
val backupPath = selectedSnapshot!!.paths.firstOrNull() ?: run {
|
||||
binding.statusText.text = "快照中找不到备份路径"
|
||||
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()) {
|
||||
binding.statusText.text = "无法从快照读取应用列表"
|
||||
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 = it) })
|
||||
|
||||
binding.statusText.text = "restic 快照共 ${packages.size} 个应用,点击恢复开始"
|
||||
binding.restoreButton.isEnabled = true
|
||||
setRunning(false)
|
||||
setupAppList()
|
||||
}
|
||||
}
|
||||
|
||||
/** 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) { 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 {
|
||||
val result = if (selectedSnapshot != null && resticConfig != null) {
|
||||
// Restic restore
|
||||
val snapshot = selectedSnapshot!!
|
||||
val config = resticConfig!!
|
||||
val backupPath = snapshot.paths.firstOrNull() ?: return@launch
|
||||
|
||||
val staging = File(requireContext().cacheDir, "restic_restore_${snapshot.shortId}")
|
||||
staging.mkdirs()
|
||||
|
||||
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,
|
||||
onSyncProgress = { progress: RemoteTransport.TransferProgress ->
|
||||
withContext(Dispatchers.Main) {
|
||||
when (progress.phase) {
|
||||
"list", "download", "upload", "delete_stale" ->
|
||||
binding.statusText.text = "同步中: ${progress.current}/${progress.total} 个文件"
|
||||
}
|
||||
}
|
||||
},
|
||||
onByteSyncProgress = { progress ->
|
||||
withContext(Dispatchers.Main) {
|
||||
binding.progressBar.max = progress.totalBytes.toInt().coerceAtLeast(1)
|
||||
binding.progressBar.progress = progress.bytesTransferred.toInt()
|
||||
binding.statusText.text = "同步中: ${progress.currentFile}\n" +
|
||||
"${formatSize(progress.bytesTransferred)} / ${formatSize(progress.totalBytes)}"
|
||||
}
|
||||
},
|
||||
onProgress = { msg -> binding.statusText.text = msg }
|
||||
)
|
||||
|
||||
if (restoreResult.isFailure) {
|
||||
binding.statusText.text = "restic 恢复失败: ${restoreResult.exceptionOrNull()?.message}"
|
||||
setRunning(false)
|
||||
binding.selectDirButton.isEnabled = true
|
||||
return@launch
|
||||
}
|
||||
|
||||
// The restored backup directory: <staging>/<original_absolute_path>
|
||||
val restoredBackupDir = File(staging, backupPath.removePrefix("/"))
|
||||
binding.statusText.text = "正在从恢复的备份安装应用…"
|
||||
|
||||
val r = RestoreOperation.restoreApps(
|
||||
backupDir = restoredBackupDir,
|
||||
filterPkgs = selectedPackages,
|
||||
onProgress = { progress ->
|
||||
val label = appInfos.find { it.packageName == 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)
|
||||
// Cleanup staging
|
||||
try { staging.deleteRecursively() } catch (_: Exception) {}
|
||||
r
|
||||
} else {
|
||||
// Local restore
|
||||
val dir = backupDir ?: return@launch
|
||||
val r = RestoreOperation.restoreApps(
|
||||
backupDir = dir,
|
||||
filterPkgs = selectedPackages,
|
||||
onProgress = { progress ->
|
||||
val label = appInfos.find { it.packageName == 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,请立即重启设备后再开启应用")
|
||||
}
|
||||
setRunning(false)
|
||||
binding.selectDirButton.isEnabled = true
|
||||
}
|
||||
}
|
||||
|
||||
private fun setRunning(running: Boolean) {
|
||||
binding.progressBar.visibility = if (running) View.VISIBLE else View.GONE
|
||||
}
|
||||
|
||||
private fun formatSize(bytes: Long): String {
|
||||
if (bytes < 1024) return "$bytes B"
|
||||
val units = arrayOf("KB", "MB", "GB", "TB")
|
||||
val exp = (63 - bytes.countLeadingZeroBits()) / 10
|
||||
val value = bytes.toDouble() / (1L shl (exp * 10))
|
||||
return "%.1f %s".format(Locale.US, value, units[exp - 1].coerceAtMost(units.last()))
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) {
|
||||
ResticWrapper.cleanup()
|
||||
}
|
||||
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
|
||||
)
|
||||
)
|
||||
BIN
app/src/main/jniLibs/arm64-v8a/libtar_bin.so
Normal file
BIN
app/src/main/jniLibs/arm64-v8a/libtar_bin.so
Normal file
Binary file not shown.
BIN
app/src/main/jniLibs/arm64-v8a/libzstd_bin.so
Normal file
BIN
app/src/main/jniLibs/arm64-v8a/libzstd_bin.so
Normal file
Binary file not shown.
@@ -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,69 +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="16dp"
|
||||
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>
|
||||
|
||||
<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: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,387 +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="16dp">
|
||||
|
||||
<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" />
|
||||
|
||||
<com.google.android.material.button.MaterialButtonToggleGroup
|
||||
android:id="@+id/resticBackendGroup"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
app:singleSelection="true"
|
||||
app:selectionRequired="true">
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/resticBackendLocal"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="本机"
|
||||
style="@style/Widget.Material3.Button.TonalButton" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/resticBackendWebdav"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="WebDAV"
|
||||
style="@style/Widget.Material3.Button.TonalButton" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/resticBackendSmb"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="SMB"
|
||||
style="@style/Widget.Material3.Button.TonalButton" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/resticBackendRestServer"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="REST"
|
||||
style="@style/Widget.Material3.Button.TonalButton" />
|
||||
|
||||
</com.google.android.material.button.MaterialButtonToggleGroup>
|
||||
|
||||
<!-- 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,89 +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="16dp"
|
||||
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>
|
||||
|
||||
<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: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,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,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,5 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<item name="checkbox" type="id" />
|
||||
<item name="appName" 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"
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -4,8 +4,10 @@ buildscript {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
dependencies {
|
||||
classpath "org.jetbrains.kotlinx:kover-gradle-plugin:0.9.8"
|
||||
classpath 'com.android.tools.build:gradle:8.2.0'
|
||||
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
||||
classpath "org.jetbrains.kotlin:kotlin-serialization:$kotlin_version"
|
||||
|
||||
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 测试(持续集成未运行) |
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user