Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
260ebc83ff | ||
|
|
640cf3e75c | ||
|
|
7466990e0c | ||
|
|
b41715686c | ||
|
|
bca2a7a520 | ||
|
|
545c43fe2c | ||
|
|
eaf16318de | ||
|
|
a809f94a02 | ||
|
|
541862cda8 | ||
|
|
7ab2df9afe | ||
|
|
c45c4a1ad8 | ||
|
|
2b71f9a9f7 | ||
|
|
3db2e1c618 | ||
|
|
d24617d842 | ||
|
|
29dc8dc492 | ||
|
|
57b8ce3d7f | ||
|
|
1536c9f056 | ||
|
|
c2280555d3 | ||
|
|
532760e71a | ||
|
|
6da7eff62c | ||
|
|
a75192b6bf | ||
|
|
f4ceebb12c | ||
|
|
151327e9ba | ||
|
|
a343678bad | ||
|
|
1b2c97d381 | ||
|
|
d034eb6f4b | ||
|
|
08dc165450 | ||
|
|
50eef54ab6 | ||
|
|
a75bdd457f | ||
|
|
d5d219789f | ||
|
|
04eed1a768 | ||
|
|
87d17bcb9a | ||
|
|
2d3bd5c5cf | ||
|
|
12d76a2ad1 | ||
|
|
a0026b1bb3 | ||
|
|
761f11fa5f | ||
|
|
a7b28692ea | ||
|
|
daac6e1e5c | ||
|
|
8c9b7e4a01 | ||
|
|
1b0778141c | ||
|
|
96ed63b704 | ||
|
|
bb07bc639d | ||
|
|
c3eb4b2f51 | ||
|
|
9deeb37f21 | ||
|
|
0befdc0d34 | ||
|
|
606a21dff0 | ||
|
|
ef6a37fb7c | ||
|
|
2f7b8c4b59 | ||
|
|
968e82b072 | ||
|
|
0240eea776 | ||
|
|
dc8d94fa31 | ||
|
|
f6ec8cc882 | ||
|
|
b648d21068 | ||
|
|
2b63997b22 | ||
|
|
198daaf720 | ||
|
|
a1847dc6f2 | ||
|
|
8ebb3a5f31 | ||
|
|
7cf3dc2bf2 | ||
|
|
2346095323 | ||
|
|
40ae2d456c | ||
|
|
3152bcaaa6 | ||
|
|
c69c41c57f | ||
|
|
ea1f424b33 | ||
|
|
1ef828497c | ||
|
|
88b28faa48 | ||
|
|
7bfaacf936 | ||
|
|
05d8dae4e0 | ||
|
|
27cd92b4b1 | ||
|
|
4b6b5b5d95 | ||
|
|
6468575a42 | ||
|
|
5ea22cecad | ||
|
|
c8205d3f95 | ||
|
|
639377760c | ||
|
|
a9323f0528 | ||
|
|
2e31e90225 | ||
|
|
9d5b8188eb | ||
|
|
1f9e330f6a | ||
|
|
a69776fe33 | ||
|
|
d42dc46b8c | ||
|
|
c37dc2f874 | ||
|
|
9c20f13f95 | ||
|
|
88aec66ef8 | ||
|
|
f500596621 | ||
|
|
df5bafd0bb | ||
|
|
01fe9deb20 | ||
|
|
4e44298e98 | ||
|
|
a7c3a05c23 | ||
|
|
05fa36497b | ||
|
|
e6f8ae265e | ||
|
|
d107847747 | ||
|
|
ea8e270df6 | ||
|
|
4bc40e0663 | ||
|
|
5e6ccd990a | ||
|
|
1a8d673742 | ||
|
|
a4069aa6f6 | ||
|
|
3cfb90c078 | ||
|
|
594949d3f0 | ||
|
|
8fd34b5258 | ||
|
|
1161482a5b | ||
|
|
8a123e7a0d | ||
|
|
4ef3a20929 | ||
|
|
16c2e9b694 | ||
|
|
c9a9d310ef | ||
|
|
84996a5652 | ||
|
|
115afc5735 | ||
|
|
2ae75e876d | ||
|
|
126291d544 | ||
|
|
37db81f6b1 | ||
|
|
a3cfd10d39 | ||
|
|
0e61f02046 | ||
|
|
d8b1c0b7b8 | ||
|
|
8bf908b731 | ||
|
|
568f1f12e7 | ||
|
|
ca739a5e2f | ||
|
|
7833f747d5 | ||
|
|
977519b383 | ||
|
|
b39b3812d0 | ||
|
|
f7dddb41fa | ||
|
|
053300919c | ||
|
|
93dff86383 | ||
|
|
1b7e9c6b12 | ||
|
|
f072cd0839 | ||
|
|
f626a0fc5c | ||
|
|
63da12f046 | ||
|
|
ce1beafb08 | ||
|
|
f6ecc5c953 | ||
|
|
de87e3fd74 | ||
|
|
e34a7a3081 | ||
|
|
ecb1678e48 | ||
|
|
ca395033a5 | ||
|
|
8095451e14 | ||
|
|
c0435ead01 | ||
|
|
d346344e17 | ||
|
|
68d6af4255 | ||
|
|
253d465d32 | ||
|
|
7ab7c8abee | ||
|
|
ea3aee0883 | ||
|
|
b4a5d7b1be | ||
|
|
028346713e | ||
|
|
d519bc1a23 | ||
|
|
a6d5fbc2ca | ||
|
|
e9a6bc2fbc | ||
|
|
ef42612c4b | ||
|
|
f4b421c953 | ||
|
|
14aa981ca4 | ||
|
|
515942274d | ||
|
|
03785f427a | ||
|
|
5cc1f43a03 | ||
|
|
a557255099 | ||
|
|
932778b153 | ||
|
|
1e8cadb878 | ||
|
|
eba2869c1b | ||
|
|
121108c416 | ||
|
|
780dfda610 | ||
|
|
3dd3431401 | ||
|
|
ba8a6247f5 | ||
|
|
5f1ee513f2 | ||
|
|
17fd5954a0 | ||
|
|
87e4061879 | ||
|
|
5c1ce96239 | ||
|
|
ef058f223d | ||
|
|
ab03e551da | ||
|
|
168a097111 | ||
|
|
232572114c | ||
|
|
54c67f83ab | ||
|
|
e63f210cc8 | ||
|
|
48169224c0 | ||
|
|
f2d99a50a7 | ||
|
|
bfa3c8375b | ||
|
|
34697da10c | ||
|
|
3e7d7f5d60 | ||
|
|
8a4149c800 | ||
|
|
0d7650cbc0 | ||
|
|
72932a255d | ||
|
|
4848a0f6d9 | ||
|
|
a5064fdd4f | ||
|
|
5d4b6b4fb5 | ||
|
|
d5a0299d91 | ||
|
|
718388db51 | ||
|
|
01d9d231b5 | ||
|
|
88e0425736 | ||
|
|
05f2a12baf | ||
|
|
c9d3b8e836 | ||
|
|
33fbd378c0 | ||
|
|
f76bed2f8b | ||
|
|
23027b8813 | ||
|
|
896c57aa03 | ||
|
|
ee6458a60e | ||
|
|
114456a1f2 | ||
|
|
cca7ec81e1 | ||
|
|
e9e59f6026 | ||
|
|
a29c95ceef | ||
|
|
c60d8cfb53 | ||
|
|
4ee8679d29 | ||
|
|
419c6301dd | ||
|
|
e979bec321 | ||
|
|
fa6ac580d0 | ||
|
|
a44bb05345 | ||
|
|
19ddb86430 | ||
|
|
1eb6f59167 | ||
|
|
2ba82ebd97 | ||
|
|
00163fbfd4 | ||
|
|
0ef8734856 | ||
|
|
a4151fb198 | ||
|
|
292ba5af95 | ||
|
|
90456263ae | ||
|
|
bc3f7d0549 | ||
|
|
382f083fd1 | ||
|
|
a287e287b9 | ||
|
|
166558433e | ||
|
|
1915b005a9 | ||
|
|
ed63036329 | ||
|
|
a49899add3 | ||
|
|
85072e4520 | ||
|
|
a31f864ab8 | ||
|
|
5bcbc4563e | ||
|
|
62910f5e7e | ||
|
|
b3ff9abc0d | ||
|
|
eccc9bd886 | ||
|
|
0bd1fcf51b | ||
|
|
51acbc75f5 | ||
|
|
c3cd2351a5 | ||
|
|
dde7f77c29 | ||
|
|
dafba83b8d | ||
|
|
ba472b5768 | ||
|
|
c4f4c157bd | ||
|
|
5ddeaf2783 | ||
|
|
1b87bc0417 | ||
|
|
a154230bca | ||
|
|
4b498d7dc7 | ||
|
|
41b263a7ee | ||
|
|
ddeff030e3 | ||
|
|
d48beadcbc | ||
|
|
7238e919c5 | ||
|
|
7cd52a84db | ||
|
|
e97a2d58c8 | ||
|
|
340fa28c6a | ||
|
|
6c58f552b8 | ||
|
|
7cc53bbf32 | ||
|
|
88ef4d2f10 | ||
|
|
968ef77896 | ||
|
|
64803bef1e | ||
|
|
d7df27b91d | ||
|
|
32b6608f11 | ||
|
|
a665acaf63 | ||
|
|
e2073b5f7c | ||
|
|
475dd96ac2 | ||
|
|
9fe2ec39ae | ||
|
|
9cca3c958e | ||
|
|
903c201f2d | ||
|
|
2b47776554 | ||
|
|
41a2e7effc | ||
|
|
6947118d2e | ||
|
|
3b7a0b0101 | ||
|
|
19dcc4a1fa | ||
|
|
8ec689a893 | ||
|
|
a1d623c42b | ||
|
|
9a893fc0a0 | ||
|
|
f13abcbe49 | ||
|
|
24e1732091 | ||
|
|
8a2826c242 | ||
|
|
b36f19f4c4 | ||
|
|
9967601e3e | ||
|
|
c330bf8238 | ||
|
|
b38d9ec95e | ||
|
|
1069ba1991 | ||
|
|
ea29017687 | ||
|
|
b41b9267bd | ||
|
|
5ffa1aeb1d | ||
|
|
d2b91341f0 | ||
|
|
5e69959cf5 | ||
|
|
845f1b37ca | ||
|
|
21fb12ef71 | ||
|
|
53fa597556 | ||
|
|
adffebadbc | ||
|
|
ea93c0b3ab | ||
|
|
ea3b63fd80 | ||
|
|
bda542d2e5 | ||
|
|
a72c2d8abb | ||
|
|
8ad202cc07 | ||
|
|
bc8cea7bd8 | ||
|
|
8878070efc | ||
|
|
4e2dceef48 | ||
|
|
c9973393ff | ||
|
|
f0aba3758d | ||
|
|
d8e5e74444 | ||
|
|
0a05d76850 | ||
|
|
996d82dfbf | ||
|
|
961f9c7633 | ||
|
|
e7d59ec633 | ||
|
|
32828a6ee9 | ||
|
|
e8a48c0f0d | ||
|
|
afbebb4c8b | ||
|
|
a911a0c4dd | ||
|
|
fe9047ee40 | ||
|
|
bd159ecb6e | ||
|
|
1e8a57e949 | ||
|
|
f418a3649e | ||
|
|
fa99b3f981 | ||
|
|
2661f01c19 | ||
|
|
8633ab2de6 | ||
|
|
70c8bb19a7 | ||
|
|
cdf7107d5a | ||
|
|
c11a1bb2a3 | ||
|
|
8466662226 | ||
|
|
7765d14588 | ||
|
|
8968469310 | ||
|
|
d7d36f32a3 | ||
|
|
aa4b6c23bb | ||
|
|
ceae371826 | ||
|
|
7fba8b09cc | ||
|
|
527f99ea00 | ||
|
|
db1efcb85d | ||
|
|
456c853923 | ||
|
|
e5abd0cba8 | ||
|
|
f61013d99d | ||
|
|
4768bd92dc | ||
|
|
9023809c89 | ||
|
|
e14e91a2a4 | ||
|
|
0e6cb05796 | ||
|
|
da532df009 | ||
|
|
9aa20c6715 | ||
|
|
c7107e9d9b | ||
|
|
f882375cfa | ||
|
|
a6abb51046 | ||
|
|
438392ddf6 | ||
|
|
6646593c5b | ||
|
|
ab4b8b5057 | ||
|
|
3b03624f32 | ||
|
|
7270f94930 | ||
|
|
838d492334 | ||
|
|
600537af9c | ||
|
|
774d83ed9f | ||
|
|
e3c5fb1a8c | ||
|
|
f237879921 | ||
|
|
765892e768 | ||
|
|
5caa778a71 | ||
|
|
f2a0e4877a | ||
|
|
7d04980e30 | ||
|
|
565415d8d1 | ||
|
|
3f91c10c10 | ||
|
|
96b8fe110b | ||
|
|
d0aa037b4c | ||
|
|
792190b5f4 | ||
|
|
d332b6941a | ||
|
|
cd1a8bf5e4 | ||
|
|
5c7e00ce59 | ||
|
|
2b1c32faf7 | ||
|
|
b98d5a5ce0 | ||
|
|
4c84616797 | ||
|
|
b1d22f3c3c | ||
|
|
e8a3335e21 | ||
|
|
6cecb23dd8 | ||
|
|
9987a83334 | ||
|
|
fe194eac76 | ||
|
|
21b9d0fa27 | ||
|
|
cf4e1c5b95 | ||
|
|
e624618be6 | ||
|
|
fa6e5841bf | ||
|
|
c3ebd9d6e3 |
No files matched your search
@@ -1,4 +0,0 @@
|
||||
* text=auto eol=lf
|
||||
|
||||
*.bat text eol=crlf
|
||||
*.jar binary
|
||||
@@ -1,3 +0,0 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: [Kr328]
|
||||
@@ -0,0 +1,43 @@
|
||||
---
|
||||
name: "[English] Bug report"
|
||||
about: Create a report to help us improve
|
||||
title: "[BUG] "
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**Logs**
|
||||
if applicable, add logs to help detect problem
|
||||
|
||||
**Device Info (please complete the following information):**
|
||||
|
||||
- Device: [e.g. Pixel 4]
|
||||
- ROM: [e.g: AOSP]
|
||||
- ROM Version:
|
||||
- Android Version [e.g. Oreo]
|
||||
|
||||
**Application Info (please complete the following information):**
|
||||
|
||||
- Version: [e.g. 1.1.10]
|
||||
- Apk File Name: [e.g. app-release-arm64-v8a.apk]
|
||||
- Distribution Channel: [e.g. Google Play]
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
@@ -1,106 +0,0 @@
|
||||
name: "[English] Bug Report"
|
||||
description: "Create a report to help us debug bugs"
|
||||
title: "[BUG] "
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to fill out this bug report!
|
||||
|
||||
NOTE: Be sure to put a clear and concise title **AFTER** `[BUG]` in the text box above.
|
||||
|
||||
NOTE: We do not provide any services such as proxies, DO NOT feedback any problems not caused by this application here.
|
||||
|
||||
<!-- template -->
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: "Describe the bug"
|
||||
description: "A clear and concise description of what the bug is."
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: reproduce
|
||||
attributes:
|
||||
label: "To Reproduce"
|
||||
description: "Steps to reproduce the behavior:"
|
||||
value: |
|
||||
Step 1: ...
|
||||
Step 2: ...
|
||||
Step 3: ...
|
||||
...
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: device-info
|
||||
attributes:
|
||||
label: "Device Info"
|
||||
description: |
|
||||
Input your device information.
|
||||
|
||||
Example:
|
||||
- Device: Pixel 4
|
||||
- ROM: AOSP
|
||||
- Android Version: 10
|
||||
value: |
|
||||
- Device:
|
||||
- ROM:
|
||||
- Android Version:
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: app-info
|
||||
attributes:
|
||||
label: "Application Info"
|
||||
description: |
|
||||
Input application you are using information.
|
||||
|
||||
Example:
|
||||
```
|
||||
- Version: 2.5.4-premium
|
||||
- APK filename: cfa-2.5.4-premium-arm64-v8a-release.apk
|
||||
- Distribution Channel: Google Play
|
||||
```
|
||||
value: |
|
||||
- Version:
|
||||
- APK filename:
|
||||
- Distribution Channel:
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: configure
|
||||
attributes:
|
||||
render: yml
|
||||
label: "Configure File"
|
||||
description: |
|
||||
Please paste or upload the configuration file here.
|
||||
|
||||
TIPS: If you only have a subscription link, please use your browser to download it.
|
||||
|
||||
**NOTE: Please remove proxies from the configuration file before uploading it.**
|
||||
**NOTE: Please remove proxies from the configuration file before uploading it.**
|
||||
**NOTE: Please remove proxies from the configuration file before uploading it.**
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
render: raw
|
||||
label: "Logs"
|
||||
description: |
|
||||
Please paste or upload the log file here.
|
||||
|
||||
TIPS: Please use the `Logcat` in application or `adb logcat`. `adb logcat` would be better.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: screenshot
|
||||
attributes:
|
||||
label: "Screenshot"
|
||||
description: "If applicable, add screenshots to help explain your problem."
|
||||
placeholder: "Optional"
|
||||
- type: textarea
|
||||
id: additional
|
||||
attributes:
|
||||
label: "Additional"
|
||||
description: "Add any other context about the problem here."
|
||||
@@ -0,0 +1,20 @@
|
||||
---
|
||||
name: "[English] Feature request"
|
||||
about: Suggest an idea for this app
|
||||
title: "[Feature Request] "
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
**Additional context**
|
||||
Add any other context or screenshots about the feature request here.
|
||||
@@ -1,26 +0,0 @@
|
||||
name: "[English] Feature Request"
|
||||
description: "Create a report to help us improve"
|
||||
title: "[Feature Request] "
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to fill out this feature request!
|
||||
|
||||
NOTE: Be sure to put a clear and concise title **AFTER** `[Feature Request]` in the text box above.
|
||||
|
||||
<!-- template -->
|
||||
- type: textarea
|
||||
id: "description"
|
||||
attributes:
|
||||
label: "Feature Description"
|
||||
description: |
|
||||
A clear and concise description of the feature.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: "additional"
|
||||
attributes:
|
||||
label: "Additional"
|
||||
description: |
|
||||
Add any other context or screenshots about the feature request here.
|
||||
@@ -0,0 +1,42 @@
|
||||
---
|
||||
name: "[简体中文] 创建错误报告"
|
||||
about: 创建错误报告以帮助我们改进应用
|
||||
title: "[BUG] "
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**描述出现的错误**
|
||||
请简洁的描述你遇到的错误
|
||||
|
||||
**如何复现该错误**
|
||||
复现步骤:
|
||||
1. ...
|
||||
2. ...
|
||||
3. ...
|
||||
4. ...
|
||||
|
||||
**预期行为**
|
||||
清晰简单的描述你预期的应用应该表现的行为
|
||||
|
||||
**屏幕截图**
|
||||
如果适用, 上传屏幕截图以帮助描述错误
|
||||
|
||||
**日志**
|
||||
|
||||
如果适用, 上传日志以帮助侦测错误
|
||||
|
||||
**设备信息 (请完成一下信息):**
|
||||
- 机型: [例如: Pixel 4]
|
||||
- 系统/ROM: [例如: MIUI 11]
|
||||
- Android 版本 [例如: Oreo]
|
||||
- ROM版本 [例如: 20.3.19]
|
||||
|
||||
**应用信息**
|
||||
- 版本: [例如: 1.1.10]
|
||||
- 安装包文件名: [例如: app-release-arm64-v8a.apk]
|
||||
- 应用来源: [例如: Google Play]
|
||||
|
||||
**附加信息**
|
||||
其他的可能与改错误相关的信息
|
||||
@@ -1,107 +0,0 @@
|
||||
name: "[简体中文] 错误报告"
|
||||
description: "创建错误报告以帮助我们修正应用"
|
||||
title: "[BUG] "
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
感谢您在百忙之中填写此错误报告。
|
||||
|
||||
注意: 请务必在上方文本框的 `[BUG]` **之后**填写清晰明了的标题。
|
||||
|
||||
注意:这里不提供像是代理服务器之类的服务,请不要反馈非应用自身引起的问题。
|
||||
|
||||
<!-- template -->
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: "描述此错误"
|
||||
description: "请清晰简洁的描述你遇到的错误。"
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: reproduce
|
||||
attributes:
|
||||
label: "如何复现该错误"
|
||||
description: "复现步骤:"
|
||||
value: |
|
||||
步骤 1: ...
|
||||
步骤 2: ...
|
||||
步骤 3: ...
|
||||
...
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: device-info
|
||||
attributes:
|
||||
label: "设备信息"
|
||||
description: |
|
||||
输入您正在使用的设备信息。
|
||||
|
||||
例子:
|
||||
- 机型: Pixel 4
|
||||
- 系统类型: MIUI/AOSP
|
||||
- Android 版本: 10
|
||||
value: |
|
||||
- 机型:
|
||||
- 系统类型:
|
||||
- Android 版本:
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: app-info
|
||||
attributes:
|
||||
label: "应用信息"
|
||||
description: |
|
||||
输入您正在使用的应用信息。
|
||||
|
||||
例子:
|
||||
```
|
||||
- 版本: 2.5.4-premium
|
||||
- 安装包文件名: cfa-2.5.4-premium-arm64-v8a-release.apk
|
||||
- 应用来源: Google Play
|
||||
```
|
||||
value: |
|
||||
- 版本:
|
||||
- 安装包文件名:
|
||||
- 应用来源:
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: configure
|
||||
attributes:
|
||||
render: yml
|
||||
label: "配置文件"
|
||||
description: |
|
||||
请在此粘贴和上传配置文件。
|
||||
|
||||
提示:如果您仅有一个订阅链接,请使用浏览器打开此链接以下载配置文件。
|
||||
|
||||
**注意: 请在上传配置文件前,移除其中的代理服务器信息。**
|
||||
**注意: 请在上传配置文件前,移除其中的代理服务器信息。**
|
||||
**注意: 请在上传配置文件前,移除其中的代理服务器信息。**
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
render: raw
|
||||
label: "日志"
|
||||
description: |
|
||||
请在此粘贴或上传日志。
|
||||
|
||||
提示: 请使用应用内的 `Logcat` 或 `adb logcat` 捕获日志. `adb logcat` 能更好地帮助侦测问题.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: screenshot
|
||||
attributes:
|
||||
label: "屏幕截图"
|
||||
description: "如果适用,请在此粘贴或上传屏幕截图。"
|
||||
placeholder: "可选"
|
||||
- type: textarea
|
||||
id: additional
|
||||
attributes:
|
||||
label: "附加信息"
|
||||
description: "其他的可能与改错误相关的信息。"
|
||||
placeholder: "可选"
|
||||
@@ -0,0 +1,17 @@
|
||||
---
|
||||
name: "[简体中文] 功能请求"
|
||||
about: 你希望的能够在应用中增加的功能
|
||||
title: "[Feature Request] "
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**功能描述**
|
||||
请清晰的描述你想要的功能
|
||||
|
||||
**描述你希望的实现方式**
|
||||
清晰的描述应用应该如何实现该功能
|
||||
|
||||
**附加信息**
|
||||
其他的与改功能相关的附加信息
|
||||
@@ -1,26 +0,0 @@
|
||||
name: "[简体中文] 功能请求"
|
||||
description: "您希望的能够在应用中增加功能"
|
||||
title: "[Feature Request] "
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
感谢您在百忙之中填写此功能请求报告。
|
||||
|
||||
注意: 请务必在上方文本框的 `[Feature Request]` **之后**填写清晰明了的标题。
|
||||
|
||||
<!-- template -->
|
||||
- type: textarea
|
||||
id: "description"
|
||||
attributes:
|
||||
label: "功能描述"
|
||||
description: |
|
||||
简介明了的描述此功能。
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: "additional"
|
||||
attributes:
|
||||
label: "附加信息"
|
||||
description: |
|
||||
与此功能相关的其他附加信息。
|
||||
@@ -1 +0,0 @@
|
||||
blank_issues_enabled: false
|
||||
@@ -1,54 +0,0 @@
|
||||
name: Build Unsigned
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths-ignore:
|
||||
- '.github/**'
|
||||
- '.idea/**'
|
||||
- '.gitattributes'
|
||||
- '.gitignore'
|
||||
- '.gitmodules'
|
||||
- '**.md'
|
||||
- 'LICENSE'
|
||||
- 'NOTICE'
|
||||
pull_request:
|
||||
paths-ignore:
|
||||
- '.github/**'
|
||||
- '.idea/**'
|
||||
- '.gitattributes'
|
||||
- '.gitignore'
|
||||
- '.gitmodules'
|
||||
- '**.md'
|
||||
- 'LICENSE'
|
||||
- 'NOTICE'
|
||||
|
||||
jobs:
|
||||
BuildUnsigned:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
submodules: recursive
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@v3
|
||||
with:
|
||||
distribution: 'zulu'
|
||||
java-version: 17
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: 1.18
|
||||
- uses: actions/cache@v3
|
||||
with:
|
||||
path: |
|
||||
~/.cache/go-build
|
||||
~/go/pkg/mod
|
||||
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-go-
|
||||
- name: Build
|
||||
uses: gradle/gradle-build-action@v2
|
||||
with:
|
||||
arguments: --no-daemon app:assembleFossRelease
|
||||
+4
-20
@@ -1,7 +1,6 @@
|
||||
.gradle
|
||||
build/
|
||||
/app/foss/release
|
||||
/app/premium/release
|
||||
/app/release/
|
||||
/captures
|
||||
|
||||
# Ignore Gradle GUI config
|
||||
@@ -17,18 +16,10 @@ gradle-app.setting
|
||||
# gradle/wrapper/gradle-wrapper.properties
|
||||
|
||||
# Ignore IDEA config
|
||||
.idea
|
||||
*.iml
|
||||
/.idea/*
|
||||
!/.idea/codeStyles
|
||||
/core/src/main/golang/.idea/*
|
||||
!/core/src/main/golang/.idea/codeStyles
|
||||
/core/src/foss/golang/.idea/*
|
||||
!/core/src/foss/golang/.idea/codeStyles
|
||||
/core/src/premium/golang/.idea/*
|
||||
!/core/src/premium/golang/.idea/codeStyles
|
||||
|
||||
# KeyStore
|
||||
signing.properties
|
||||
*.keystore
|
||||
*.jks
|
||||
|
||||
@@ -38,9 +29,8 @@ cmake-build-*
|
||||
# local.properties
|
||||
local.properties
|
||||
|
||||
|
||||
# tracker
|
||||
tracker.properties
|
||||
# keystore
|
||||
keystore.properties
|
||||
|
||||
# vscode
|
||||
.vscode
|
||||
@@ -55,9 +45,3 @@ google-services.json
|
||||
|
||||
# Dolphin
|
||||
.directory
|
||||
|
||||
# logs
|
||||
*.log
|
||||
|
||||
# MacOS
|
||||
.DS_Store
|
||||
+3
-6
@@ -1,6 +1,3 @@
|
||||
[submodule "clash-foss"]
|
||||
path = core/src/foss/golang/clash
|
||||
url = https://github.com/Kr328/clash.git
|
||||
[submodule "clash-premium"]
|
||||
path = core/src/premium/golang/clash
|
||||
url = https://github.com/Kr328/clash.git
|
||||
[submodule "core/src/main/golang/clash"]
|
||||
path = core/src/main/golang/clash
|
||||
url = https://github.com/Kr328/Clash
|
||||
Generated
-151
@@ -1,151 +0,0 @@
|
||||
<component name="ProjectCodeStyleConfiguration">
|
||||
<code_scheme name="Project" version="173">
|
||||
<AndroidXmlCodeStyleSettings>
|
||||
<option name="LAYOUT_SETTINGS">
|
||||
<value>
|
||||
<option name="INSERT_BLANK_LINE_BEFORE_TAG" value="false" />
|
||||
</value>
|
||||
</option>
|
||||
<option name="VALUE_RESOURCE_FILE_SETTINGS">
|
||||
<value>
|
||||
<option name="INSERT_LINE_BREAKS_AROUND_STYLE" value="false" />
|
||||
</value>
|
||||
</option>
|
||||
</AndroidXmlCodeStyleSettings>
|
||||
<JetCodeStyleSettings>
|
||||
<option name="PACKAGES_TO_USE_STAR_IMPORTS">
|
||||
<value>
|
||||
<package name="java.util" alias="false" withSubpackages="false" />
|
||||
<package name="kotlinx.android.synthetic" alias="false" withSubpackages="true" />
|
||||
<package name="io.ktor" alias="false" withSubpackages="true" />
|
||||
</value>
|
||||
</option>
|
||||
<option name="PACKAGES_IMPORT_LAYOUT">
|
||||
<value>
|
||||
<package name="" alias="false" withSubpackages="true" />
|
||||
<package name="java" alias="false" withSubpackages="true" />
|
||||
<package name="javax" alias="false" withSubpackages="true" />
|
||||
<package name="kotlin" alias="false" withSubpackages="true" />
|
||||
<package name="" alias="true" withSubpackages="true" />
|
||||
</value>
|
||||
</option>
|
||||
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
|
||||
</JetCodeStyleSettings>
|
||||
<codeStyleSettings language="XML">
|
||||
<option name="FORCE_REARRANGE_MODE" value="1" />
|
||||
<indentOptions>
|
||||
<option name="CONTINUATION_INDENT_SIZE" value="4" />
|
||||
</indentOptions>
|
||||
<arrangement>
|
||||
<rules>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>xmlns:android</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>^$</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>xmlns:.*</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>^$</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
<order>BY_NAME</order>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>.*:id</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>.*:name</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>name</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>^$</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>style</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>^$</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>.*</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>^$</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
<order>BY_NAME</order>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>.*</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
<order>ANDROID_ATTRIBUTE_ORDER</order>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>.*</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>.*</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
<order>BY_NAME</order>
|
||||
</rule>
|
||||
</section>
|
||||
</rules>
|
||||
</arrangement>
|
||||
</codeStyleSettings>
|
||||
<codeStyleSettings language="kotlin">
|
||||
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
|
||||
</codeStyleSettings>
|
||||
</code_scheme>
|
||||
</component>
|
||||
Generated
-5
@@ -1,5 +0,0 @@
|
||||
<component name="ProjectCodeStyleConfiguration">
|
||||
<state>
|
||||
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
|
||||
</state>
|
||||
</component>
|
||||
@@ -1,14 +0,0 @@
|
||||
## Contributing to Clash for Android
|
||||
|
||||
#### Code Style
|
||||
|
||||
Please use `Android Studio` or `Intellij IDEA` to open the project and use the project code style profile.
|
||||
|
||||
`File` -> `Settings` -> `Editor` -> `Code Style` -> `C/C++ and Kotlin` -> `Scheme` -> `Project`
|
||||
|
||||
|
||||
|
||||
#### License
|
||||
|
||||
Contributing to Clash for Android that assumes you allow code to be merged into closed-source branch of Clash for Android. Other terms follow the [GPLv3](https://www.gnu.org/licenses/gpl-3.0.html)
|
||||
|
||||
@@ -8,20 +8,25 @@ A Graphical user interface of [clash](https://github.com/Dreamacro/clash) for An
|
||||
|
||||
Fully feature of [clash](https://github.com/Dreamacro/clash) ~~(Exclude `external-controller`~~
|
||||
|
||||
|
||||
|
||||
### Requirement
|
||||
|
||||
- Android 5.0+ (minimum)
|
||||
- Android 7.0+ (recommend)
|
||||
- `armeabi-v7a` , `arm64-v8a`, `x86` or `x86_64` Architecture
|
||||
* Android 7.0+
|
||||
* `armeabi-v7a` , `arm64-v8a`, `x86` or `x86_64` Architecture
|
||||
|
||||
### License
|
||||
|
||||
See also [LICENSE](./LICENSE) and [NOTICE](./NOTICE)
|
||||
|
||||
### Privacy Policy
|
||||
|
||||
|
||||
### Privacy Policy
|
||||
|
||||
See also [PRIVACY_POLICY.md](./PRIVACY_POLICY.md)
|
||||
|
||||
|
||||
|
||||
### Build
|
||||
|
||||
1. Update submodules
|
||||
@@ -30,27 +35,29 @@ See also [PRIVACY_POLICY.md](./PRIVACY_POLICY.md)
|
||||
git submodule update --init --recursive
|
||||
```
|
||||
|
||||
2. Install **OpenJDK 11**, **Android SDK**, **CMake** and **Golang**
|
||||
2. Install `JDK 1.8`, `Android SDK` ,`Android NDK` and `Golang`
|
||||
|
||||
3. Create `local.properties` in project root with
|
||||
3. Create `local.properties` in project root with
|
||||
|
||||
```properties
|
||||
sdk.dir=/path/to/android-sdk
|
||||
ndk.dir=/path/to/android-ndk
|
||||
appcenter.key=<AppCenter Key> # Optional, from "appcenter.ms"
|
||||
```
|
||||
|
||||
4. Create `signing.properties` in project root with
|
||||
4. Create `keystore.properties` in project root with
|
||||
|
||||
```properties
|
||||
keystore.path=/path/to/keystore/file
|
||||
keystore.password=<key store password>
|
||||
key.alias=<key alias>
|
||||
key.password=<key password>
|
||||
```
|
||||
storeFile=/path/to/keystore/file
|
||||
storePassword=<key store password>
|
||||
keyAlias=<key alias>
|
||||
keyPassword=<key password>
|
||||
```
|
||||
|
||||
5. Build
|
||||
|
||||
```bash
|
||||
./gradlew app:assembleFossRelease
|
||||
./gradlew app:assembleRelease
|
||||
```
|
||||
|
||||
6. Pick `app-foss-<arch>-release.apk` in `app/build/outputs/apk/foss/release/`
|
||||
6. Pick `app-release-<arch>.apk` in `app/build/outputs/apks`
|
||||
@@ -0,0 +1 @@
|
||||
/build
|
||||
+114
-20
@@ -1,32 +1,126 @@
|
||||
import java.util.*
|
||||
|
||||
plugins {
|
||||
kotlin("android")
|
||||
kotlin("kapt")
|
||||
id("com.android.application")
|
||||
id("kotlin-android")
|
||||
id("kotlin-android-extensions")
|
||||
}
|
||||
|
||||
val rootExtra = rootProject.extra
|
||||
|
||||
val gCompileSdkVersion: Int by rootExtra
|
||||
val gBuildToolsVersion: String by rootExtra
|
||||
|
||||
val gMinSdkVersion: Int by rootExtra
|
||||
val gTargetSdkVersion: Int by rootExtra
|
||||
|
||||
val gVersionCode: Int by rootExtra
|
||||
val gVersionName: String by rootExtra
|
||||
|
||||
val gKotlinVersion: String by rootExtra
|
||||
val gKotlinCoroutineVersion: String by rootExtra
|
||||
val gAppCenterVersion: String by rootExtra
|
||||
val gAndroidKtxVersion: String by rootExtra
|
||||
val gRecyclerviewVersion: String by rootExtra
|
||||
val gAppCompatVersion: String by rootExtra
|
||||
val gMaterialDesignVersion: String by rootExtra
|
||||
val gShizukuPreferenceVersion: String by rootExtra
|
||||
val gMultiprocessPreferenceVersion: String by rootExtra
|
||||
|
||||
android {
|
||||
compileSdkVersion(gCompileSdkVersion)
|
||||
buildToolsVersion(gBuildToolsVersion)
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.github.kr328.clash"
|
||||
|
||||
minSdkVersion(gMinSdkVersion)
|
||||
targetSdkVersion(gTargetSdkVersion)
|
||||
|
||||
versionCode = gVersionCode
|
||||
versionName = gVersionName
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
maybeCreate("release").apply {
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_1_8
|
||||
targetCompatibility = JavaVersion.VERSION_1_8
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "1.8"
|
||||
}
|
||||
|
||||
splits {
|
||||
abi {
|
||||
isEnable = true
|
||||
isUniversalApk = true
|
||||
}
|
||||
}
|
||||
|
||||
val signingFile = rootProject.file("keystore.properties")
|
||||
if ( signingFile.exists() ) {
|
||||
val properties = Properties().apply {
|
||||
signingFile.inputStream().use {
|
||||
load(it)
|
||||
}
|
||||
}
|
||||
signingConfigs {
|
||||
maybeCreate("release").apply {
|
||||
storeFile = rootProject.file(Objects.requireNonNull(properties.getProperty("storeFile")))
|
||||
storePassword = Objects.requireNonNull(properties.getProperty("storePassword"))
|
||||
keyAlias = Objects.requireNonNull(properties.getProperty("keyAlias"))
|
||||
keyPassword = Objects.requireNonNull(properties.getProperty("keyPassword"))
|
||||
}
|
||||
}
|
||||
buildTypes {
|
||||
maybeCreate("release").apply {
|
||||
this.signingConfig = signingConfigs.findByName("release")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
compileOnly(project(":hideapi"))
|
||||
|
||||
implementation(project(":core"))
|
||||
implementation(project(":service"))
|
||||
implementation(project(":design"))
|
||||
implementation(project(":common"))
|
||||
|
||||
implementation(libs.kotlin.coroutine)
|
||||
implementation(libs.androidx.core)
|
||||
implementation(libs.androidx.activity)
|
||||
implementation(libs.androidx.fragment)
|
||||
implementation(libs.androidx.appcompat)
|
||||
implementation(libs.androidx.coordinator)
|
||||
implementation(libs.androidx.recyclerview)
|
||||
implementation(libs.google.material)
|
||||
|
||||
val premiumImplementation by configurations
|
||||
|
||||
premiumImplementation(libs.appcenter.analytics)
|
||||
premiumImplementation(libs.appcenter.crashes)
|
||||
implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8:$gKotlinVersion")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:$gKotlinCoroutineVersion")
|
||||
implementation("androidx.recyclerview:recyclerview:$gRecyclerviewVersion")
|
||||
implementation("androidx.core:core-ktx:$gAndroidKtxVersion")
|
||||
implementation("androidx.appcompat:appcompat:$gAppCompatVersion")
|
||||
implementation("com.google.android.material:material:$gMaterialDesignVersion")
|
||||
implementation("moe.shizuku.preference:preference-appcompat:$gShizukuPreferenceVersion")
|
||||
implementation("moe.shizuku.preference:preference-simplemenu-appcompat:$gShizukuPreferenceVersion")
|
||||
implementation("com.microsoft.appcenter:appcenter-analytics:$gAppCenterVersion")
|
||||
implementation("com.microsoft.appcenter:appcenter-crashes:$gAppCenterVersion")
|
||||
}
|
||||
|
||||
tasks.getByName("clean", type = Delete::class) {
|
||||
delete(file("release"))
|
||||
task("injectAppCenterKey") {
|
||||
doFirst {
|
||||
val properties = Properties().apply {
|
||||
rootProject.file("local.properties").inputStream().use {
|
||||
load(it)
|
||||
}
|
||||
}
|
||||
|
||||
val key = properties.getProperty("appcenter.key", "")
|
||||
|
||||
android.buildTypes.forEach {
|
||||
it.buildConfigField("String", "APP_CENTER_KEY", "\"$key\"")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
afterEvaluate {
|
||||
tasks["preBuild"].dependsOn(tasks["injectAppCenterKey"])
|
||||
}
|
||||
Vendored
+1
-37
@@ -20,40 +20,4 @@
|
||||
# hide the original source file name.
|
||||
#-renamesourcefileattribute SourceFile
|
||||
|
||||
-dontobfuscate
|
||||
|
||||
-assumenosideeffects class kotlin.jvm.internal.Intrinsics {
|
||||
public static void checkNotNull(...);
|
||||
public static void checkExpressionValueIsNotNull(...);
|
||||
public static void checkNotNullExpressionValue(...);
|
||||
public static void checkReturnedValueIsNotNull(...);
|
||||
public static void checkFieldIsNotNull(...);
|
||||
public static void checkParameterIsNotNull(...);
|
||||
public static void checkNotNullParameter(...);
|
||||
}
|
||||
|
||||
# Kotlin Coroutine
|
||||
# Allow R8 to optimize away the FastServiceLoader.
|
||||
# Together with ServiceLoader optimization in R8
|
||||
# this results in direct instantiation when loading Dispatchers.Main
|
||||
-assumenosideeffects class kotlinx.coroutines.internal.MainDispatcherLoader {
|
||||
boolean FAST_SERVICE_LOADER_ENABLED return false;
|
||||
}
|
||||
|
||||
-assumenosideeffects class kotlinx.coroutines.internal.FastServiceLoaderKt {
|
||||
boolean ANDROID_DETECTED return true;
|
||||
}
|
||||
|
||||
-keep class kotlinx.coroutines.android.AndroidDispatcherFactory {*;}
|
||||
|
||||
# Disable support for "Missing Main Dispatcher", since we always have Android main dispatcher
|
||||
-assumenosideeffects class kotlinx.coroutines.internal.MainDispatchersKt {
|
||||
boolean SUPPORT_MISSING return false;
|
||||
}
|
||||
|
||||
# Statically turn off all debugging facilities and assertions
|
||||
-assumenosideeffects class kotlinx.coroutines.DebugKt {
|
||||
boolean getASSERTIONS_ENABLED() return false;
|
||||
boolean getDEBUG() return false;
|
||||
boolean getRECOVER_STACK_TRACES() return false;
|
||||
}
|
||||
-dontobfuscate
|
||||
@@ -1,14 +0,0 @@
|
||||
package com.github.kr328.clash
|
||||
|
||||
import android.app.Application
|
||||
|
||||
@Suppress("UNUSED_PARAMETER")
|
||||
object Tracker {
|
||||
fun initialize(application: Application) {
|
||||
// do nothing
|
||||
}
|
||||
|
||||
fun uploadLogcat(logcat: String) {
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
@@ -3,178 +3,111 @@
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
package="com.github.kr328.clash">
|
||||
|
||||
<uses-feature
|
||||
android:name="android.software.leanback"
|
||||
android:required="false" />
|
||||
<uses-feature
|
||||
android:name="android.hardware.touchscreen"
|
||||
android:required="false" />
|
||||
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||
<uses-permission
|
||||
android:name="android.permission.QUERY_ALL_PACKAGES"
|
||||
tools:ignore="QueryAllPackagesPermission" />
|
||||
|
||||
<application
|
||||
android:name=".MainApplication"
|
||||
android:allowBackup="true"
|
||||
android:banner="@mipmap/ic_banner"
|
||||
android:fullBackupContent="@xml/full_backup_content"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/application_name"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/BootstrapTheme"
|
||||
tools:ignore="GoogleAppIndexingWarning"
|
||||
tools:targetApi="N">
|
||||
<meta-data
|
||||
android:name="releaseName"
|
||||
android:value="@string/release_name" />
|
||||
<meta-data
|
||||
android:name="releaseCode"
|
||||
android:value="@integer/release_code" />
|
||||
|
||||
android:theme="@style/AppTheme"
|
||||
tools:ignore="GoogleAppIndexingWarning">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:configChanges="uiMode"
|
||||
android:exported="true"
|
||||
android:label="@string/launch_name"
|
||||
android:configChanges="uiMode"
|
||||
android:launchMode="singleTop">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.service.quicksettings.action.QS_TILE_PREFERENCES" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<activity
|
||||
android:name=".ExternalImportActivity"
|
||||
android:exported="true"
|
||||
android:label="@string/import_from_file"
|
||||
android:theme="@android:style/Theme.Translucent.NoTitleBar.Fullscreen">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
|
||||
<data
|
||||
android:host="install-config"
|
||||
android:scheme="clash" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<activity
|
||||
android:name=".ApkBrokenActivity"
|
||||
android:configChanges="uiMode"
|
||||
android:label="@string/application_broken"
|
||||
android:exported="false"
|
||||
android:label="@string/application_broken" />
|
||||
<activity
|
||||
android:name=".AppCrashedActivity"
|
||||
android:configChanges="uiMode"
|
||||
android:exported="false"
|
||||
android:label="@string/application_crashed"
|
||||
android:launchMode="singleTask" />
|
||||
android:configChanges="uiMode"/>
|
||||
<activity
|
||||
android:name=".ProfilesActivity"
|
||||
android:configChanges="uiMode"
|
||||
android:label="@string/profiles"
|
||||
android:exported="false"
|
||||
android:label="@string/profiles" />
|
||||
android:configChanges="uiMode"/>
|
||||
<activity
|
||||
android:name=".NewProfileActivity"
|
||||
android:configChanges="uiMode"
|
||||
android:name=".CreateProfileActivity"
|
||||
android:label="@string/create_profile"
|
||||
android:exported="false"
|
||||
android:label="@string/create_profile" />
|
||||
android:configChanges="uiMode" />
|
||||
<activity
|
||||
android:name=".PropertiesActivity"
|
||||
android:configChanges="uiMode"
|
||||
android:name=".ProfileEditActivity"
|
||||
android:label="@string/profile"
|
||||
android:exported="false"
|
||||
android:label="@string/profile" />
|
||||
android:configChanges="uiMode" />
|
||||
<activity
|
||||
android:name=".ProxyActivity"
|
||||
android:configChanges="uiMode"
|
||||
android:name=".ProxiesActivity"
|
||||
android:label="@string/proxy"
|
||||
android:exported="false"
|
||||
android:label="@string/proxy" />
|
||||
<activity
|
||||
android:name=".ProvidersActivity"
|
||||
android:configChanges="uiMode"
|
||||
android:exported="false"
|
||||
android:label="@string/providers" />
|
||||
android:configChanges="uiMode" />
|
||||
<activity
|
||||
android:name=".LogsActivity"
|
||||
android:configChanges="uiMode"
|
||||
android:label="@string/logs"
|
||||
android:exported="false"
|
||||
android:label="@string/logs" />
|
||||
android:configChanges="uiMode" />
|
||||
<activity
|
||||
android:name=".LogcatActivity"
|
||||
android:configChanges="uiMode"
|
||||
android:name=".LogViewerActivity"
|
||||
android:label="@string/log_viewer"
|
||||
android:exported="false"
|
||||
android:label="@string/logcat" />
|
||||
android:configChanges="uiMode"
|
||||
android:launchMode="singleTop"/>
|
||||
<activity
|
||||
android:name=".SettingsActivity"
|
||||
android:configChanges="uiMode"
|
||||
android:label="@string/settings"
|
||||
android:exported="false"
|
||||
android:label="@string/settings" />
|
||||
android:configChanges="uiMode" />
|
||||
<activity
|
||||
android:name=".NetworkSettingsActivity"
|
||||
android:configChanges="uiMode"
|
||||
android:name=".SettingsNetworkActivity"
|
||||
android:label="@string/network"
|
||||
android:exported="false"
|
||||
android:label="@string/network" />
|
||||
android:configChanges="uiMode" />
|
||||
<activity
|
||||
android:name=".AppSettingsActivity"
|
||||
android:configChanges="uiMode"
|
||||
android:name=".SettingsBehaviorActivity"
|
||||
android:label="@string/behavior"
|
||||
android:exported="false"
|
||||
android:label="@string/app" />
|
||||
android:configChanges="uiMode" />
|
||||
<activity
|
||||
android:name=".OverrideSettingsActivity"
|
||||
android:configChanges="uiMode"
|
||||
android:name=".SettingsInterfaceActivity"
|
||||
android:label="@string/interface_"
|
||||
android:exported="false"
|
||||
android:label="@string/override" />
|
||||
android:configChanges="uiMode" />
|
||||
<activity
|
||||
android:name=".AccessControlActivity"
|
||||
android:configChanges="uiMode"
|
||||
android:name=".PackagesActivity"
|
||||
android:label="@string/access_control_packages"
|
||||
android:exported="false"
|
||||
android:label="@string/access_control_packages" />
|
||||
android:configChanges="uiMode" />
|
||||
<activity
|
||||
android:name=".HelpActivity"
|
||||
android:configChanges="uiMode"
|
||||
android:name=".SupportActivity"
|
||||
android:label="@string/support"
|
||||
android:exported="false"
|
||||
android:label="@string/help" />
|
||||
<activity
|
||||
android:name=".FilesActivity"
|
||||
android:configChanges="uiMode"
|
||||
android:exported="false"
|
||||
android:label="@string/files" />
|
||||
|
||||
android:configChanges="uiMode" />
|
||||
<service
|
||||
android:name=".LogcatService"
|
||||
android:exported="false"
|
||||
android:label="@string/clash_logcat" />
|
||||
<service
|
||||
android:name=".TileService"
|
||||
android:exported="true"
|
||||
android:icon="@drawable/ic_logo_service"
|
||||
android:icon="@drawable/ic_notification"
|
||||
android:label="@string/launch_name"
|
||||
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
|
||||
<intent-filter>
|
||||
<action android:name="android.service.quicksettings.action.QS_TILE" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<receiver
|
||||
android:name=".RestartReceiver"
|
||||
android:enabled="false"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -1,142 +0,0 @@
|
||||
package com.github.kr328.clash
|
||||
|
||||
import android.Manifest.permission.INTERNET
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.pm.ApplicationInfo
|
||||
import android.content.pm.PackageInfo
|
||||
import android.content.pm.PackageManager
|
||||
import androidx.core.content.getSystemService
|
||||
import com.github.kr328.clash.design.AccessControlDesign
|
||||
import com.github.kr328.clash.design.model.AppInfo
|
||||
import com.github.kr328.clash.design.util.toAppInfo
|
||||
import com.github.kr328.clash.service.store.ServiceStore
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.selects.select
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class AccessControlActivity : BaseActivity<AccessControlDesign>() {
|
||||
override suspend fun main() {
|
||||
val service = ServiceStore(this)
|
||||
|
||||
val selected = withContext(Dispatchers.IO) {
|
||||
service.accessControlPackages.toMutableSet()
|
||||
}
|
||||
|
||||
defer {
|
||||
withContext(Dispatchers.IO) {
|
||||
service.accessControlPackages = selected
|
||||
}
|
||||
}
|
||||
|
||||
val design = AccessControlDesign(this, uiStore, selected)
|
||||
|
||||
setContentDesign(design)
|
||||
|
||||
design.requests.send(AccessControlDesign.Request.ReloadApps)
|
||||
|
||||
while (isActive) {
|
||||
select<Unit> {
|
||||
events.onReceive {
|
||||
|
||||
}
|
||||
design.requests.onReceive {
|
||||
when (it) {
|
||||
AccessControlDesign.Request.ReloadApps -> {
|
||||
design.patchApps(loadApps(selected))
|
||||
}
|
||||
AccessControlDesign.Request.SelectAll -> {
|
||||
val all = withContext(Dispatchers.Default) {
|
||||
design.apps.map(AppInfo::packageName)
|
||||
}
|
||||
|
||||
selected.clear()
|
||||
selected.addAll(all)
|
||||
|
||||
design.rebindAll()
|
||||
}
|
||||
AccessControlDesign.Request.SelectNone -> {
|
||||
selected.clear()
|
||||
|
||||
design.rebindAll()
|
||||
}
|
||||
AccessControlDesign.Request.SelectInvert -> {
|
||||
val all = withContext(Dispatchers.Default) {
|
||||
design.apps.map(AppInfo::packageName).toSet() - selected
|
||||
}
|
||||
|
||||
selected.clear()
|
||||
selected.addAll(all)
|
||||
|
||||
design.rebindAll()
|
||||
}
|
||||
AccessControlDesign.Request.Import -> {
|
||||
val clipboard = getSystemService<ClipboardManager>()
|
||||
val data = clipboard?.primaryClip
|
||||
|
||||
if (data != null && data.itemCount > 0) {
|
||||
val all = withContext(Dispatchers.IO) {
|
||||
val packages = data.getItemAt(0).text.split("\n").toSet()
|
||||
|
||||
design.apps.map(AppInfo::packageName).intersect(packages)
|
||||
}
|
||||
|
||||
selected.clear()
|
||||
selected.addAll(all)
|
||||
}
|
||||
|
||||
design.rebindAll()
|
||||
}
|
||||
AccessControlDesign.Request.Export -> {
|
||||
val clipboard = getSystemService<ClipboardManager>()
|
||||
|
||||
withContext(Dispatchers.IO) {
|
||||
val data = ClipData.newPlainText(
|
||||
"packages",
|
||||
selected.joinToString("\n")
|
||||
)
|
||||
|
||||
clipboard?.setPrimaryClip(data)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadApps(selected: Set<String>): List<AppInfo> =
|
||||
withContext(Dispatchers.IO) {
|
||||
val reverse = uiStore.accessControlReverse
|
||||
val sort = uiStore.accessControlSort
|
||||
val systemApp = uiStore.accessControlSystemApp
|
||||
|
||||
val base = compareByDescending<AppInfo> { it.packageName in selected }
|
||||
val comparator = if (reverse) base.thenDescending(sort) else base.then(sort)
|
||||
|
||||
val pm = packageManager
|
||||
val packages = pm.getInstalledPackages(PackageManager.GET_PERMISSIONS)
|
||||
|
||||
packages.asSequence()
|
||||
.filter {
|
||||
it.packageName != packageName
|
||||
}
|
||||
.filter {
|
||||
it.packageName == "android" || it.requestedPermissions?.contains(INTERNET) == true
|
||||
}
|
||||
.filter {
|
||||
systemApp || !it.isSystemApp
|
||||
}
|
||||
.map {
|
||||
it.toAppInfo(pm)
|
||||
}
|
||||
.sortedWith(comparator)
|
||||
.toList()
|
||||
}
|
||||
|
||||
private val PackageInfo.isSystemApp: Boolean
|
||||
get() {
|
||||
return applicationInfo.flags and ApplicationInfo.FLAG_SYSTEM != 0
|
||||
}
|
||||
}
|
||||
@@ -2,19 +2,67 @@ package com.github.kr328.clash
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import com.github.kr328.clash.design.ApkBrokenDesign
|
||||
import kotlinx.coroutines.isActive
|
||||
import android.os.Bundle
|
||||
import android.text.Html
|
||||
import kotlinx.android.synthetic.main.activity_application_broken.*
|
||||
|
||||
class ApkBrokenActivity : BaseActivity<ApkBrokenDesign>() {
|
||||
override suspend fun main() {
|
||||
val design = ApkBrokenDesign(this)
|
||||
class ApkBrokenActivity : BaseActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
setContentDesign(design)
|
||||
setContentView(R.layout.activity_application_broken)
|
||||
setSupportActionBar(toolbar)
|
||||
|
||||
while (isActive) {
|
||||
val req = design.requests.receive()
|
||||
text.text = Html.fromHtml(
|
||||
getString(R.string.application_broken_description),
|
||||
Html.FROM_HTML_MODE_COMPACT
|
||||
)
|
||||
|
||||
startActivity(Intent(Intent.ACTION_VIEW).setData(Uri.parse(req.url)))
|
||||
commonUi.build {
|
||||
option(
|
||||
icon = getDrawable(R.drawable.ic_info),
|
||||
title = getString(R.string.learn_more_about_split_apks)
|
||||
) {
|
||||
onClick {
|
||||
startActivity(
|
||||
Intent(Intent.ACTION_VIEW)
|
||||
.setData(Uri.parse(getString(R.string.about_split_apks_url)))
|
||||
)
|
||||
}
|
||||
}
|
||||
option(
|
||||
icon = getDrawable(R.drawable.ic_input),
|
||||
title = getString(R.string.reinstall_from_google_play)
|
||||
) {
|
||||
onClick {
|
||||
startActivity(
|
||||
Intent(Intent.ACTION_VIEW)
|
||||
.setData(Uri.parse(getString(R.string.google_play_url)))
|
||||
)
|
||||
}
|
||||
}
|
||||
option(
|
||||
icon = getDrawable(R.drawable.ic_play_for_work),
|
||||
title = getString(R.string.download_from_github_releases)
|
||||
) {
|
||||
onClick {
|
||||
startActivity(
|
||||
Intent(Intent.ACTION_VIEW)
|
||||
.setData(Uri.parse(getString(R.string.github_releases_url)))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onBackPressed() {
|
||||
super.onBackPressed()
|
||||
|
||||
finishAffinity()
|
||||
finish()
|
||||
}
|
||||
|
||||
override fun shouldDisplayHomeAsUpEnabled(): Boolean {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
package com.github.kr328.clash
|
||||
|
||||
import com.github.kr328.clash.common.compat.versionCodeCompat
|
||||
import com.github.kr328.clash.common.log.Log
|
||||
import com.github.kr328.clash.design.AppCrashedDesign
|
||||
import com.github.kr328.clash.log.SystemLogcat
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class AppCrashedActivity : BaseActivity<AppCrashedDesign>() {
|
||||
override suspend fun main() {
|
||||
val design = AppCrashedDesign(this)
|
||||
|
||||
setContentDesign(design)
|
||||
|
||||
val packageInfo = withContext(Dispatchers.IO) {
|
||||
packageManager.getPackageInfo(packageName, 0)
|
||||
}
|
||||
|
||||
Log.i("App version: versionName = ${packageInfo.versionName} versionCode = ${packageInfo.versionCodeCompat}")
|
||||
|
||||
val logs = withContext(Dispatchers.IO) {
|
||||
SystemLogcat.dumpCrash()
|
||||
}
|
||||
|
||||
design.setAppLogs(logs)
|
||||
|
||||
while (isActive) {
|
||||
events.receive()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
package com.github.kr328.clash
|
||||
|
||||
import android.content.pm.PackageManager
|
||||
import com.github.kr328.clash.common.util.componentName
|
||||
import com.github.kr328.clash.design.AppSettingsDesign
|
||||
import com.github.kr328.clash.design.model.Behavior
|
||||
import com.github.kr328.clash.service.store.ServiceStore
|
||||
import com.github.kr328.clash.util.ApplicationObserver
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.selects.select
|
||||
|
||||
class AppSettingsActivity : BaseActivity<AppSettingsDesign>(), Behavior {
|
||||
override suspend fun main() {
|
||||
val design = AppSettingsDesign(
|
||||
this,
|
||||
uiStore,
|
||||
ServiceStore(this),
|
||||
this,
|
||||
clashRunning,
|
||||
)
|
||||
|
||||
setContentDesign(design)
|
||||
|
||||
while (isActive) {
|
||||
select<Unit> {
|
||||
events.onReceive {
|
||||
when (it) {
|
||||
Event.ClashStart, Event.ClashStop, Event.ServiceRecreated ->
|
||||
recreate()
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
design.requests.onReceive {
|
||||
ApplicationObserver.createdActivities.forEach {
|
||||
it.recreate()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override var autoRestart: Boolean
|
||||
get() {
|
||||
val status = packageManager.getComponentEnabledSetting(
|
||||
RestartReceiver::class.componentName
|
||||
)
|
||||
|
||||
return status == PackageManager.COMPONENT_ENABLED_STATE_ENABLED
|
||||
}
|
||||
set(value) {
|
||||
val status = if (value)
|
||||
PackageManager.COMPONENT_ENABLED_STATE_ENABLED
|
||||
else
|
||||
PackageManager.COMPONENT_ENABLED_STATE_DISABLED
|
||||
|
||||
packageManager.setComponentEnabledSetting(
|
||||
RestartReceiver::class.componentName,
|
||||
status,
|
||||
PackageManager.DONT_KILL_APP,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,165 +1,155 @@
|
||||
package com.github.kr328.clash
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.res.Configuration
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.activity.result.contract.ActivityResultContract
|
||||
import android.view.*
|
||||
import android.view.View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import com.github.kr328.clash.common.compat.isAllowForceDarkCompat
|
||||
import com.github.kr328.clash.common.compat.isLightNavigationBarCompat
|
||||
import com.github.kr328.clash.common.compat.isLightStatusBarsCompat
|
||||
import com.github.kr328.clash.common.compat.isSystemBarsTranslucentCompat
|
||||
import com.github.kr328.clash.core.bridge.ClashException
|
||||
import com.github.kr328.clash.design.Design
|
||||
import com.github.kr328.clash.design.model.DarkMode
|
||||
import com.github.kr328.clash.design.store.UiStore
|
||||
import com.github.kr328.clash.design.ui.DayNight
|
||||
import com.github.kr328.clash.design.util.resolveThemedBoolean
|
||||
import com.github.kr328.clash.design.util.resolveThemedColor
|
||||
import com.github.kr328.clash.design.util.showExceptionToast
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.appcompat.widget.Toolbar
|
||||
import androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||
import com.github.kr328.clash.common.utils.createLanguageConfigurationContext
|
||||
import com.github.kr328.clash.preference.UiSettings
|
||||
import com.github.kr328.clash.remote.Broadcasts
|
||||
import com.github.kr328.clash.remote.Remote
|
||||
import com.github.kr328.clash.util.ActivityResultLifecycle
|
||||
import com.github.kr328.clash.util.ApplicationObserver
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.suspendCoroutine
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.MainScope
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
abstract class BaseActivity<D : Design<*>> :
|
||||
AppCompatActivity(),
|
||||
CoroutineScope by MainScope(),
|
||||
Broadcasts.Observer {
|
||||
enum class Event {
|
||||
ServiceRecreated,
|
||||
ActivityStart,
|
||||
ActivityStop,
|
||||
ClashStop,
|
||||
ClashStart,
|
||||
ProfileLoaded,
|
||||
ProfileChanged
|
||||
abstract class BaseActivity : AppCompatActivity(), CoroutineScope by MainScope() {
|
||||
class EmptyBroadcastReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {}
|
||||
}
|
||||
|
||||
|
||||
protected val uiStore by lazy { UiStore(this) }
|
||||
protected val events = Channel<Event>(Channel.UNLIMITED)
|
||||
protected var activityStarted: Boolean = false
|
||||
protected val clashRunning: Boolean
|
||||
get() = Remote.broadcasts.clashRunning
|
||||
protected var design: D? = null
|
||||
private set(value) {
|
||||
field = value
|
||||
|
||||
if (value != null) {
|
||||
setContentView(value.root)
|
||||
} else {
|
||||
setContentView(View(this))
|
||||
private val receiver = object : Broadcasts.Receiver {
|
||||
override fun onStarted() {
|
||||
launch {
|
||||
onClashStarted()
|
||||
}
|
||||
}
|
||||
|
||||
private var defer: suspend () -> Unit = {}
|
||||
private var deferRunning = false
|
||||
private val nextRequestKey = AtomicInteger(0)
|
||||
private var dayNight: DayNight = DayNight.Day
|
||||
override fun onStopped(cause: String?) {
|
||||
launch {
|
||||
onClashStopped(cause)
|
||||
}
|
||||
}
|
||||
|
||||
protected abstract suspend fun main()
|
||||
override fun onProfileChanged() {
|
||||
launch {
|
||||
onClashProfileChanged()
|
||||
}
|
||||
}
|
||||
|
||||
fun defer(operation: suspend () -> Unit) {
|
||||
this.defer = operation
|
||||
}
|
||||
|
||||
suspend fun <I, O> startActivityForResult(
|
||||
contracts: ActivityResultContract<I, O>,
|
||||
input: I
|
||||
): O = withContext(Dispatchers.Main) {
|
||||
val requestKey = nextRequestKey.getAndIncrement().toString()
|
||||
|
||||
ActivityResultLifecycle().use { lifecycle, start ->
|
||||
suspendCoroutine { c ->
|
||||
activityResultRegistry.register(requestKey, lifecycle, contracts) {
|
||||
c.resumeWith(Result.success(it))
|
||||
}.apply { start() }.launch(input)
|
||||
override fun onProfileLoaded() {
|
||||
launch {
|
||||
onClashProfileLoaded()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setContentDesign(design: D) {
|
||||
suspendCoroutine<Unit> {
|
||||
window.decorView.post {
|
||||
this.design = design
|
||||
private var overrideRootView: View? = null
|
||||
|
||||
it.resume(Unit)
|
||||
val clashRunning: Boolean
|
||||
get() = Broadcasts.clashRunning
|
||||
val rootView: View
|
||||
get() = overrideRootView ?: window.decorView
|
||||
var menu: Menu? = null
|
||||
lateinit var uiSettings: UiSettings
|
||||
private set
|
||||
private lateinit var language: String
|
||||
private lateinit var darkMode: String
|
||||
|
||||
open suspend fun onClashStarted() {}
|
||||
open suspend fun onClashStopped(reason: String?) {}
|
||||
open suspend fun onClashProfileChanged() {}
|
||||
open suspend fun onClashProfileLoaded() {}
|
||||
|
||||
override fun setContentView(layoutResID: Int) {
|
||||
val base = CoordinatorLayout(this).apply {
|
||||
layoutParams = ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT
|
||||
)
|
||||
|
||||
val displayMetrics = resources.displayMetrics
|
||||
|
||||
if (displayMetrics.widthPixels > displayMetrics.heightPixels) {
|
||||
val padding = (displayMetrics.widthPixels - displayMetrics.heightPixels) / 2
|
||||
|
||||
setPadding(padding, 0, padding, 0)
|
||||
}
|
||||
}
|
||||
|
||||
overrideRootView = LayoutInflater.from(this).inflate(layoutResID, base, true)
|
||||
|
||||
super.setContentView(base)
|
||||
}
|
||||
|
||||
override fun attachBaseContext(newBase: Context?) {
|
||||
val base = newBase ?: return super.attachBaseContext(newBase)
|
||||
|
||||
uiSettings = UiSettings(base)
|
||||
|
||||
language = uiSettings.get(UiSettings.LANGUAGE)
|
||||
|
||||
super.attachBaseContext(base.createLanguageConfigurationContext(language))
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
applyDayNight()
|
||||
language = uiSettings.get(UiSettings.LANGUAGE)
|
||||
|
||||
launch {
|
||||
main()
|
||||
resetDarkMode()
|
||||
|
||||
finish()
|
||||
}
|
||||
resetLightNavigationBar()
|
||||
|
||||
title = resolveActivityTitle()
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
|
||||
activityStarted = true
|
||||
if (language != uiSettings.get(UiSettings.LANGUAGE) || darkMode != uiSettings.get(UiSettings.DARK_MODE))
|
||||
recreate()
|
||||
|
||||
Remote.broadcasts.addObserver(this)
|
||||
|
||||
events.trySend(Event.ActivityStart)
|
||||
Broadcasts.register(receiver)
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
super.onStop()
|
||||
|
||||
activityStarted = false
|
||||
|
||||
Remote.broadcasts.removeObserver(this)
|
||||
|
||||
events.trySend(Event.ActivityStop)
|
||||
Broadcasts.unregister(receiver)
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
design?.cancel()
|
||||
|
||||
cancel()
|
||||
|
||||
super.onDestroy()
|
||||
override fun onCreateOptionsMenu(menu: Menu?): Boolean {
|
||||
this.menu = menu
|
||||
return super.onCreateOptionsMenu(menu)
|
||||
}
|
||||
|
||||
override fun finish() {
|
||||
if (deferRunning) {
|
||||
return
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||
if (super.onOptionsItemSelected(item))
|
||||
return true
|
||||
|
||||
if (item.itemId == android.R.id.home) {
|
||||
onSupportNavigateUp()
|
||||
return true
|
||||
}
|
||||
|
||||
deferRunning = true
|
||||
|
||||
launch {
|
||||
try {
|
||||
defer()
|
||||
} finally {
|
||||
withContext(NonCancellable) {
|
||||
super.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
override fun onConfigurationChanged(newConfig: Configuration) {
|
||||
super.onConfigurationChanged(newConfig)
|
||||
override fun setSupportActionBar(toolbar: Toolbar?) {
|
||||
super.setSupportActionBar(toolbar)
|
||||
|
||||
if (queryDayNight(newConfig) != dayNight) {
|
||||
ApplicationObserver.createdActivities.forEach {
|
||||
it.recreate()
|
||||
}
|
||||
supportActionBar?.apply {
|
||||
setDisplayHomeAsUpEnabled(shouldDisplayHomeAsUpEnabled())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,77 +163,59 @@ abstract class BaseActivity<D : Design<*>> :
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onProfileChanged() {
|
||||
events.trySend(Event.ProfileChanged)
|
||||
override fun onDestroy() {
|
||||
cancel()
|
||||
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onProfileLoaded() {
|
||||
events.trySend(Event.ProfileLoaded)
|
||||
override fun onConfigurationChanged(newConfig: Configuration) {
|
||||
super.onConfigurationChanged(newConfig)
|
||||
|
||||
recreate()
|
||||
}
|
||||
|
||||
override fun onServiceRecreated() {
|
||||
events.trySend(Event.ServiceRecreated)
|
||||
protected fun showSnackbarException(title: String, detail: String?) {
|
||||
Snackbar.make(rootView, title, Snackbar.LENGTH_LONG).setAction(R.string.detail) {
|
||||
AlertDialog.Builder(this).setTitle(R.string.detail).setMessage(detail ?: "Unknown")
|
||||
.show()
|
||||
}.show()
|
||||
}
|
||||
|
||||
override fun onStarted() {
|
||||
events.trySend(Event.ClashStart)
|
||||
}
|
||||
|
||||
override fun onStopped(cause: String?) {
|
||||
events.trySend(Event.ClashStop)
|
||||
|
||||
if (cause != null && activityStarted) {
|
||||
launch {
|
||||
design?.showExceptionToast(ClashException(cause))
|
||||
}
|
||||
private fun resetDarkMode() {
|
||||
when (uiSettings.get(UiSettings.DARK_MODE).also { darkMode = it }) {
|
||||
UiSettings.DARK_MODE_AUTO ->
|
||||
delegate.localNightMode = AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
|
||||
UiSettings.DARK_MODE_DARK ->
|
||||
delegate.localNightMode = AppCompatDelegate.MODE_NIGHT_YES
|
||||
UiSettings.DARK_MODE_LIGHT ->
|
||||
delegate.localNightMode = AppCompatDelegate.MODE_NIGHT_NO
|
||||
}
|
||||
}
|
||||
|
||||
private fun queryDayNight(config: Configuration = resources.configuration): DayNight {
|
||||
return when (uiStore.darkMode) {
|
||||
DarkMode.Auto -> {
|
||||
if (config.uiMode and Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES)
|
||||
DayNight.Night
|
||||
else
|
||||
DayNight.Day
|
||||
}
|
||||
DarkMode.ForceLight -> {
|
||||
DayNight.Day
|
||||
}
|
||||
DarkMode.ForceDark -> {
|
||||
DayNight.Night
|
||||
}
|
||||
private fun resetLightNavigationBar() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O)
|
||||
return
|
||||
|
||||
val light = resources.getBoolean(R.bool.lightStatusBar)
|
||||
|
||||
if (light) {
|
||||
window.decorView.systemUiVisibility =
|
||||
window.decorView.systemUiVisibility or SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR
|
||||
} else {
|
||||
window.decorView.systemUiVisibility =
|
||||
window.decorView.systemUiVisibility and SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR.inv()
|
||||
}
|
||||
|
||||
window.navigationBarColor = getColor(R.color.backgroundColor)
|
||||
}
|
||||
|
||||
private fun applyDayNight(config: Configuration = resources.configuration) {
|
||||
val dayNight = queryDayNight(config)
|
||||
private fun resolveActivityTitle(): CharSequence {
|
||||
val info = packageManager.getActivityInfo(componentName, 0)
|
||||
|
||||
when (dayNight) {
|
||||
DayNight.Night -> {
|
||||
theme.applyStyle(R.style.AppThemeDark, true)
|
||||
}
|
||||
DayNight.Day -> {
|
||||
theme.applyStyle(R.style.AppThemeLight, true)
|
||||
}
|
||||
}
|
||||
if (info.labelRes <= 0)
|
||||
return title
|
||||
|
||||
window.isAllowForceDarkCompat = false
|
||||
window.isSystemBarsTranslucentCompat = true
|
||||
|
||||
window.statusBarColor = resolveThemedColor(android.R.attr.statusBarColor)
|
||||
window.navigationBarColor = resolveThemedColor(android.R.attr.navigationBarColor)
|
||||
|
||||
if (Build.VERSION.SDK_INT >= 23) {
|
||||
window.isLightStatusBarsCompat =
|
||||
resolveThemedBoolean(android.R.attr.windowLightStatusBar)
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT >= 27) {
|
||||
window.isLightNavigationBarCompat =
|
||||
resolveThemedBoolean(android.R.attr.windowLightNavigationBar)
|
||||
}
|
||||
|
||||
this.dayNight = dayNight
|
||||
return resources.getText(info.labelRes)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.github.kr328.clash
|
||||
|
||||
object Constants {
|
||||
const val PREFERENCE_NAME_APP = "app"
|
||||
const val PREFERENCE_KEY_LAST_INSTALL = "last_install"
|
||||
|
||||
const val LOG_DIR_NAME = "logs"
|
||||
|
||||
const val URL_PROVIDER_INTENT_ACTION = "com.github.kr328.clash.action.PROVIDE_URL"
|
||||
const val URL_PROVIDER_INTENT_EXTRA_NAME = "name"
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package com.github.kr328.clash
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.provider.Settings
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.BaseAdapter
|
||||
import android.widget.TextView
|
||||
import com.github.kr328.clash.common.utils.intent
|
||||
import com.github.kr328.clash.remote.withProfile
|
||||
import com.github.kr328.clash.service.model.Profile.Type
|
||||
import kotlinx.android.synthetic.main.activity_create_profile.*
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class CreateProfileActivity : BaseActivity() {
|
||||
companion object {
|
||||
const val REQUEST_CODE = 20000
|
||||
}
|
||||
|
||||
private val self = this
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
setContentView(R.layout.activity_create_profile)
|
||||
|
||||
setSupportActionBar(toolbar)
|
||||
|
||||
launch {
|
||||
val providers = queryUrlProviders()
|
||||
|
||||
mainList.adapter = Adapter(this@CreateProfileActivity, providers)
|
||||
mainList.divider = null
|
||||
mainList.dividerHeight = 0
|
||||
|
||||
mainList.setOnItemClickListener { _, _, position, _ ->
|
||||
val item = providers[position]
|
||||
|
||||
self.launch {
|
||||
val id = withProfile {
|
||||
acquireUnused(item.type, item.intent?.toUri(0))
|
||||
}
|
||||
|
||||
startActivityForResult(
|
||||
ProfileEditActivity::class.intent.setData(
|
||||
Uri.fromParts(
|
||||
"id",
|
||||
id.toString(),
|
||||
null
|
||||
)
|
||||
),
|
||||
REQUEST_CODE
|
||||
)
|
||||
}
|
||||
}
|
||||
mainList.setOnItemLongClickListener { _, _, position, _ ->
|
||||
val item = providers[position]
|
||||
val packageName = item.intent?.component?.packageName
|
||||
|
||||
if (packageName != null) {
|
||||
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
|
||||
.setData(Uri.fromParts("package", packageName, null))
|
||||
|
||||
startActivity(intent)
|
||||
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||
if (requestCode == REQUEST_CODE && resultCode == Activity.RESULT_OK)
|
||||
return finish()
|
||||
|
||||
super.onActivityResult(requestCode, resultCode, data)
|
||||
}
|
||||
|
||||
private suspend fun queryUrlProviders(): List<UrlProvider> =
|
||||
withContext(Dispatchers.IO) {
|
||||
val common = listOf(
|
||||
UrlProvider(
|
||||
getText(R.string.file),
|
||||
getText(R.string.import_from_file),
|
||||
getDrawable(R.drawable.ic_file)!!,
|
||||
Type.FILE,
|
||||
null
|
||||
),
|
||||
UrlProvider(
|
||||
getText(R.string.url),
|
||||
getText(R.string.import_from_url),
|
||||
getDrawable(R.drawable.ic_download)!!,
|
||||
Type.URL,
|
||||
null
|
||||
)
|
||||
)
|
||||
|
||||
val providers = packageManager.queryIntentActivities(
|
||||
Intent(Constants.URL_PROVIDER_INTENT_ACTION),
|
||||
0
|
||||
).map {
|
||||
val activity = it.activityInfo
|
||||
|
||||
val name = activity.applicationInfo.loadLabel(packageManager)
|
||||
val summary = activity.loadLabel(packageManager)
|
||||
val icon = activity.loadIcon(packageManager)
|
||||
val type = Type.EXTERNAL
|
||||
val intent = Intent(Constants.URL_PROVIDER_INTENT_ACTION)
|
||||
.setComponent(
|
||||
ComponentName.createRelative(
|
||||
activity.packageName,
|
||||
activity.name
|
||||
)
|
||||
)
|
||||
|
||||
UrlProvider(name, summary, icon, type, intent)
|
||||
}
|
||||
|
||||
common + providers
|
||||
}
|
||||
|
||||
private data class UrlProvider(
|
||||
val name: CharSequence,
|
||||
val summary: CharSequence,
|
||||
val icon: Drawable,
|
||||
val type: Type,
|
||||
val intent: Intent?
|
||||
)
|
||||
|
||||
private class Adapter(private val context: Context, private val providers: List<UrlProvider>) :
|
||||
BaseAdapter() {
|
||||
override fun getView(position: Int, convertView: View?, parent: ViewGroup?): View {
|
||||
val provider = providers[position]
|
||||
val view = convertView ?: LayoutInflater.from(context).inflate(
|
||||
R.layout.adapter_url_provider,
|
||||
parent,
|
||||
false
|
||||
)
|
||||
|
||||
view.findViewById<TextView>(android.R.id.title).text = provider.name
|
||||
view.findViewById<TextView>(android.R.id.summary).text = provider.summary
|
||||
view.findViewById<View>(android.R.id.icon).background = provider.icon
|
||||
|
||||
return view
|
||||
}
|
||||
|
||||
override fun getItem(position: Int): Any = providers[position]
|
||||
override fun getItemId(position: Int): Long = providers[position].hashCode().toLong()
|
||||
override fun getCount(): Int = providers.size
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
package com.github.kr328.clash
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import com.github.kr328.clash.common.util.intent
|
||||
import com.github.kr328.clash.common.util.setUUID
|
||||
import com.github.kr328.clash.service.model.Profile
|
||||
import com.github.kr328.clash.util.withProfile
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.MainScope
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.*
|
||||
|
||||
class ExternalImportActivity : Activity(), CoroutineScope by MainScope() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
if (intent.action != Intent.ACTION_VIEW)
|
||||
return finish()
|
||||
|
||||
val uri = intent.data ?: return finish()
|
||||
val url = uri.getQueryParameter("url") ?: return finish()
|
||||
|
||||
launch {
|
||||
val uuid = withProfile {
|
||||
val type = when (uri.getQueryParameter("type")?.lowercase(Locale.getDefault())) {
|
||||
"url" -> Profile.Type.Url
|
||||
"file" -> Profile.Type.File
|
||||
else -> Profile.Type.Url
|
||||
}
|
||||
val name = uri.getQueryParameter("name") ?: getString(R.string.new_profile)
|
||||
|
||||
create(type, name).also {
|
||||
patch(it, name, url, 0)
|
||||
}
|
||||
}
|
||||
|
||||
startActivity(PropertiesActivity::class.intent.setUUID(uuid))
|
||||
|
||||
finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,160 +0,0 @@
|
||||
@file:Suppress("BlockingMethodInNonBlockingContext")
|
||||
|
||||
package com.github.kr328.clash
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.github.kr328.clash.common.util.grantPermissions
|
||||
import com.github.kr328.clash.common.util.ticker
|
||||
import com.github.kr328.clash.common.util.uuid
|
||||
import com.github.kr328.clash.design.FilesDesign
|
||||
import com.github.kr328.clash.design.util.showExceptionToast
|
||||
import com.github.kr328.clash.remote.FilesClient
|
||||
import com.github.kr328.clash.service.model.Profile
|
||||
import com.github.kr328.clash.util.fileName
|
||||
import com.github.kr328.clash.util.withProfile
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.selects.select
|
||||
import java.util.*
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class FilesActivity : BaseActivity<FilesDesign>() {
|
||||
override suspend fun main() {
|
||||
val uuid = intent.uuid ?: return finish()
|
||||
val profile = withProfile { queryByUUID(uuid) } ?: return finish()
|
||||
val root = uuid.toString()
|
||||
|
||||
val design = FilesDesign(this)
|
||||
val client = FilesClient(this)
|
||||
val stack = Stack<String>()
|
||||
|
||||
design.configurationEditable = profile.type != Profile.Type.Url
|
||||
design.fetch(client, stack, root)
|
||||
|
||||
setContentDesign(design)
|
||||
|
||||
val ticker = ticker(TimeUnit.MINUTES.toMillis(1))
|
||||
|
||||
while (isActive) {
|
||||
select<Unit> {
|
||||
events.onReceive {
|
||||
when (it) {
|
||||
Event.ActivityStart, Event.ActivityStop -> {
|
||||
design.fetch(client, stack, root)
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
design.requests.onReceive {
|
||||
try {
|
||||
when (it) {
|
||||
FilesDesign.Request.PopStack -> {
|
||||
if (stack.empty()) {
|
||||
finish()
|
||||
} else {
|
||||
stack.pop()
|
||||
}
|
||||
}
|
||||
is FilesDesign.Request.OpenDirectory -> {
|
||||
stack.push(it.file.id)
|
||||
}
|
||||
is FilesDesign.Request.OpenFile -> {
|
||||
startActivityForResult(
|
||||
ActivityResultContracts.StartActivityForResult(),
|
||||
Intent(Intent.ACTION_VIEW).setDataAndType(
|
||||
client.buildDocumentUri(it.file.id),
|
||||
"text/plain"
|
||||
).grantPermissions()
|
||||
)
|
||||
}
|
||||
is FilesDesign.Request.DeleteFile -> {
|
||||
client.deleteDocument(it.file.id)
|
||||
}
|
||||
is FilesDesign.Request.RenameFile -> {
|
||||
val newName = design.requestFileName(it.file.name)
|
||||
|
||||
client.renameDocument(it.file.id, newName)
|
||||
}
|
||||
is FilesDesign.Request.ImportFile -> {
|
||||
if (Build.VERSION.SDK_INT >= 23) {
|
||||
val hasPermission = ContextCompat.checkSelfPermission(
|
||||
this@FilesActivity,
|
||||
Manifest.permission.READ_EXTERNAL_STORAGE
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
|
||||
if (!hasPermission) {
|
||||
val granted = startActivityForResult(
|
||||
ActivityResultContracts.RequestPermission(),
|
||||
Manifest.permission.READ_EXTERNAL_STORAGE,
|
||||
)
|
||||
|
||||
if (!granted) {
|
||||
return@onReceive
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val uri: Uri? = startActivityForResult(
|
||||
ActivityResultContracts.GetContent(),
|
||||
"*/*"
|
||||
)
|
||||
|
||||
if (uri != null) {
|
||||
if (it.file == null) {
|
||||
val name = design.requestFileName(uri.fileName ?: "File")
|
||||
|
||||
client.importDocument(stack.last(), uri, name)
|
||||
} else {
|
||||
client.copyDocument(it.file!!.id, uri)
|
||||
}
|
||||
}
|
||||
}
|
||||
is FilesDesign.Request.ExportFile -> {
|
||||
val uri: Uri? = startActivityForResult(
|
||||
ActivityResultContracts.CreateDocument(),
|
||||
it.file.name
|
||||
)
|
||||
|
||||
if (uri != null) {
|
||||
client.copyDocument(uri, it.file.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
design.showExceptionToast(e)
|
||||
}
|
||||
|
||||
design.fetch(client, stack, root)
|
||||
}
|
||||
if (activityStarted) {
|
||||
ticker.onReceive {
|
||||
design.updateElapsed()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onBackPressed() {
|
||||
design?.requests?.trySend(FilesDesign.Request.PopStack)
|
||||
}
|
||||
|
||||
private suspend fun FilesDesign.fetch(client: FilesClient, stack: Stack<String>, root: String) {
|
||||
val documentId = stack.lastOrNull() ?: root
|
||||
val files = if (stack.empty()) {
|
||||
val list = client.list(documentId)
|
||||
val config = list.firstOrNull { it.id.endsWith("config.yaml") }
|
||||
|
||||
if (config == null || config.size > 0) list else listOf(config)
|
||||
} else {
|
||||
client.list(documentId)
|
||||
}
|
||||
|
||||
swapFiles(files, stack.empty())
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
package com.github.kr328.clash
|
||||
|
||||
import android.content.Intent
|
||||
import com.github.kr328.clash.design.HelpDesign
|
||||
import kotlinx.coroutines.isActive
|
||||
|
||||
class HelpActivity : BaseActivity<HelpDesign>() {
|
||||
override suspend fun main() {
|
||||
val design = HelpDesign(this) {
|
||||
startActivity(Intent(Intent.ACTION_VIEW).setData(it))
|
||||
}
|
||||
|
||||
setContentDesign(design)
|
||||
|
||||
while (isActive) {
|
||||
events.receive()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package com.github.kr328.clash
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.ServiceConnection
|
||||
import android.os.Bundle
|
||||
import android.os.IBinder
|
||||
import android.view.View
|
||||
import androidx.core.net.toFile
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import com.github.kr328.clash.adapter.LiveLogAdapter
|
||||
import com.github.kr328.clash.adapter.LogAdapter
|
||||
import com.github.kr328.clash.common.utils.intent
|
||||
import com.github.kr328.clash.core.event.LogEvent
|
||||
import kotlinx.android.synthetic.main.activity_log_viewer.*
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import java.io.File
|
||||
import kotlin.streams.toList
|
||||
|
||||
class LogViewerActivity : BaseActivity() {
|
||||
private val pauseMutex = Mutex()
|
||||
private var pollingThread: Thread? = null
|
||||
private val connection = object : ServiceConnection {
|
||||
override fun onServiceDisconnected(name: ComponentName?) {
|
||||
finish()
|
||||
}
|
||||
|
||||
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
|
||||
val logcat =
|
||||
requireNotNull(service?.queryLocalInterface(LogcatService::class.java.name)) as LogcatService
|
||||
|
||||
startLogcatPoll(logcat)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
setContentView(R.layout.activity_log_viewer)
|
||||
|
||||
setSupportActionBar(toolbar)
|
||||
|
||||
val file = intent?.data
|
||||
|
||||
if (file == null)
|
||||
startLiveMode()
|
||||
else
|
||||
startFileMode(file.toFile())
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
|
||||
pollingThread?.interrupt()
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
super.onStop()
|
||||
|
||||
launch {
|
||||
pauseMutex.lock()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
|
||||
launch {
|
||||
if (pauseMutex.isLocked)
|
||||
pauseMutex.unlock()
|
||||
}
|
||||
}
|
||||
|
||||
private fun startLiveMode() {
|
||||
mainList.layoutManager = LinearLayoutManager(this)
|
||||
mainList.adapter = LiveLogAdapter(this)
|
||||
mainList.itemAnimator?.addDuration = 100
|
||||
mainList.itemAnimator?.removeDuration = 100
|
||||
|
||||
stop.setOnClickListener {
|
||||
stopService(LogcatService::class.intent)
|
||||
finish()
|
||||
}
|
||||
|
||||
bindService(LogcatService::class.intent, connection, Context.BIND_AUTO_CREATE)
|
||||
}
|
||||
|
||||
private fun startFileMode(file: File) {
|
||||
stop.visibility = View.GONE
|
||||
|
||||
launch {
|
||||
val items = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
file.readText()
|
||||
.split("\n")
|
||||
.parallelStream()
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotEmpty() && !it.startsWith("#") }
|
||||
.map { it.split(" ", limit = 3) }
|
||||
.filter { it.size == 3 }
|
||||
.map { LogEvent(LogEvent.Level.valueOf(it[1]), it[2], it[0].toLong()) }
|
||||
.toList()
|
||||
} catch (e: Exception) {
|
||||
showSnackbarException(getString(R.string.open_log_failure), e.message)
|
||||
|
||||
throw CancellationException()
|
||||
}
|
||||
}
|
||||
|
||||
mainList.layoutManager = LinearLayoutManager(this@LogViewerActivity)
|
||||
mainList.adapter = LogAdapter(this@LogViewerActivity, items)
|
||||
mainList.adapter!!.notifyItemRangeInserted(0, items.size)
|
||||
}
|
||||
}
|
||||
|
||||
private fun startLogcatPoll(service: LogcatService) {
|
||||
launch {
|
||||
var offset = 0L
|
||||
|
||||
while (isActive) {
|
||||
pauseMutex.lock()
|
||||
|
||||
val response = service.pollLogEvent(offset).await()
|
||||
|
||||
(mainList.adapter as LiveLogAdapter).insertItems(response.logs)
|
||||
|
||||
mainList.apply {
|
||||
if (computeVerticalScrollOffset() < 30)
|
||||
scrollToPosition(0)
|
||||
}
|
||||
|
||||
offset = response.offset + response.logs.size
|
||||
|
||||
pauseMutex.unlock()
|
||||
|
||||
delay(200)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,185 +0,0 @@
|
||||
package com.github.kr328.clash
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.ServiceConnection
|
||||
import android.net.Uri
|
||||
import android.os.IBinder
|
||||
import android.widget.Toast
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import com.github.kr328.clash.common.compat.startForegroundServiceCompat
|
||||
import com.github.kr328.clash.common.util.fileName
|
||||
import com.github.kr328.clash.common.util.intent
|
||||
import com.github.kr328.clash.common.util.ticker
|
||||
import com.github.kr328.clash.core.model.LogMessage
|
||||
import com.github.kr328.clash.design.LogcatDesign
|
||||
import com.github.kr328.clash.design.dialog.withModelProgressBar
|
||||
import com.github.kr328.clash.design.model.LogFile
|
||||
import com.github.kr328.clash.design.ui.ToastDuration
|
||||
import com.github.kr328.clash.design.util.showExceptionToast
|
||||
import com.github.kr328.clash.log.LogcatFilter
|
||||
import com.github.kr328.clash.log.LogcatReader
|
||||
import com.github.kr328.clash.util.logsDir
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.selects.select
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.OutputStreamWriter
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.suspendCoroutine
|
||||
|
||||
class LogcatActivity : BaseActivity<LogcatDesign>() {
|
||||
private var conn: ServiceConnection? = null
|
||||
|
||||
override suspend fun main() {
|
||||
val fileName = intent?.fileName
|
||||
|
||||
if (fileName != null) {
|
||||
val file = LogFile.parseFromFileName(fileName) ?: return showInvalid()
|
||||
|
||||
return mainLocalFile(file)
|
||||
}
|
||||
|
||||
return mainStreaming()
|
||||
}
|
||||
|
||||
private suspend fun mainLocalFile(file: LogFile) {
|
||||
val messages = try {
|
||||
LogcatReader(this, file).readAll()
|
||||
} catch (e: Exception) {
|
||||
return showInvalid()
|
||||
}
|
||||
|
||||
val design = LogcatDesign(this, false)
|
||||
|
||||
setContentDesign(design)
|
||||
|
||||
design.patchMessages(messages, 0, messages.size)
|
||||
|
||||
while (isActive) {
|
||||
when (design.requests.receive()) {
|
||||
LogcatDesign.Request.Delete -> {
|
||||
withContext(Dispatchers.IO) {
|
||||
logsDir.resolve(file.fileName).delete()
|
||||
}
|
||||
|
||||
finish()
|
||||
}
|
||||
LogcatDesign.Request.Export -> {
|
||||
val output = startActivityForResult(
|
||||
ActivityResultContracts.CreateDocument(),
|
||||
file.fileName
|
||||
)
|
||||
|
||||
if (output != null) {
|
||||
try {
|
||||
withContext(Dispatchers.IO) {
|
||||
writeLogTo(messages, file, output)
|
||||
}
|
||||
|
||||
design.showToast(R.string.file_exported, ToastDuration.Long)
|
||||
} catch (e: Exception) {
|
||||
design.showExceptionToast(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun mainStreaming() {
|
||||
val design = LogcatDesign(this, true)
|
||||
|
||||
setContentDesign(design)
|
||||
|
||||
startForegroundServiceCompat(LogcatService::class.intent)
|
||||
|
||||
val logcat = bindLogcatService()
|
||||
val ticker = ticker(500)
|
||||
|
||||
var initial = true
|
||||
|
||||
while (isActive) {
|
||||
select<Unit> {
|
||||
events.onReceive {
|
||||
|
||||
}
|
||||
design.requests.onReceive {
|
||||
when (it) {
|
||||
LogcatDesign.Request.Close -> {
|
||||
stopService(LogcatService::class.intent)
|
||||
|
||||
finish()
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
if (activityStarted) {
|
||||
ticker.onReceive {
|
||||
val snapshot = logcat.snapshot(initial) ?: return@onReceive
|
||||
|
||||
design.patchMessages(snapshot.messages, snapshot.removed, snapshot.appended)
|
||||
|
||||
initial = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
conn?.apply(this::unbindService)
|
||||
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
private suspend fun bindLogcatService(): LogcatService {
|
||||
return suspendCoroutine { ctx ->
|
||||
bindService(LogcatService::class.intent, object : ServiceConnection {
|
||||
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
|
||||
val srv = service!!.queryLocalInterface("") as LogcatService
|
||||
|
||||
ctx.resume(srv)
|
||||
|
||||
conn = this
|
||||
}
|
||||
|
||||
override fun onServiceDisconnected(name: ComponentName?) {
|
||||
conn = null
|
||||
}
|
||||
}, Context.BIND_AUTO_CREATE)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("BlockingMethodInNonBlockingContext")
|
||||
private suspend fun writeLogTo(messages: List<LogMessage>, file: LogFile, uri: Uri) {
|
||||
LogcatFilter(OutputStreamWriter(contentResolver.openOutputStream(uri)), this).use {
|
||||
withContext(Dispatchers.Main) {
|
||||
withModelProgressBar {
|
||||
configure {
|
||||
isIndeterminate = true
|
||||
max = messages.size
|
||||
}
|
||||
|
||||
withContext(Dispatchers.IO) {
|
||||
it.writeHeader(file.date)
|
||||
|
||||
messages.forEachIndexed { idx, msg ->
|
||||
configure {
|
||||
isIndeterminate = false
|
||||
progress = idx
|
||||
}
|
||||
|
||||
it.writeMessage(msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun showInvalid() {
|
||||
Toast.makeText(this, R.string.invalid_log_file, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.github.kr328.clash
|
||||
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.ComponentName
|
||||
@@ -7,158 +9,258 @@ import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.ServiceConnection
|
||||
import android.os.Binder
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import android.os.IInterface
|
||||
import androidx.core.app.NotificationChannelCompat
|
||||
import androidx.collection.CircularArray
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import com.github.kr328.clash.common.compat.getColorCompat
|
||||
import com.github.kr328.clash.common.compat.pendingIntentFlags
|
||||
import com.github.kr328.clash.common.log.Log
|
||||
import com.github.kr328.clash.common.util.intent
|
||||
import com.github.kr328.clash.core.model.LogMessage
|
||||
import com.github.kr328.clash.log.LogcatCache
|
||||
import com.github.kr328.clash.log.LogcatWriter
|
||||
import com.github.kr328.clash.service.RemoteService
|
||||
import com.github.kr328.clash.service.remote.ILogObserver
|
||||
import com.github.kr328.clash.service.remote.IRemoteService
|
||||
import com.github.kr328.clash.service.remote.unwrap
|
||||
import com.github.kr328.clash.util.logsDir
|
||||
import com.github.kr328.clash.common.utils.createLanguageConfigurationContext
|
||||
import com.github.kr328.clash.common.utils.intent
|
||||
import com.github.kr328.clash.common.utils.Log
|
||||
import com.github.kr328.clash.core.event.LogEvent
|
||||
import com.github.kr328.clash.model.LogFile
|
||||
import com.github.kr328.clash.preference.UiSettings
|
||||
import com.github.kr328.clash.service.ClashManagerService
|
||||
import com.github.kr328.clash.service.IClashManager
|
||||
import com.github.kr328.clash.service.transact.IStreamCallback
|
||||
import com.github.kr328.clash.service.transact.ParcelableContainer
|
||||
import com.github.kr328.clash.utils.format
|
||||
import com.github.kr328.clash.utils.logsDir
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.selects.select
|
||||
import java.io.FileWriter
|
||||
import java.io.IOException
|
||||
import java.util.*
|
||||
import kotlin.math.max
|
||||
|
||||
class LogcatService : Service(), CoroutineScope by CoroutineScope(Dispatchers.Default), IInterface {
|
||||
private val cache = LogcatCache()
|
||||
class LogcatService : Service(), CoroutineScope by MainScope(), IInterface {
|
||||
companion object {
|
||||
private const val NOTIFICATION_CHANNEL_ID = "clash_logcat_channel"
|
||||
private const val NOTIFICATION_ID = 256
|
||||
private const val MAX_CACHE_COUNT = 200
|
||||
private const val LOG_LISTENER_KEY = "logcat_service"
|
||||
|
||||
private const val LOG_CONTENT_FORMAT = "%d %s %s"
|
||||
|
||||
var isServiceRunning: Boolean = false
|
||||
}
|
||||
|
||||
data class Request(val offset: Long, val response: CompletableDeferred<Response>)
|
||||
data class Response(val offset: Long, val logs: List<LogEvent>)
|
||||
|
||||
private val logChannel = Channel<LogEvent>(MAX_CACHE_COUNT)
|
||||
private val requestChannel = Channel<Request>()
|
||||
private val cache: CircularArray<LogEvent> = CircularArray()
|
||||
private var cacheOffset = 0L
|
||||
private val entity = LogFile.generate()
|
||||
|
||||
private val connection = object : ServiceConnection {
|
||||
private var manager: IClashManager? = null
|
||||
|
||||
override fun onServiceDisconnected(name: ComponentName?) {
|
||||
stopSelf()
|
||||
manager?.unregisterLogListener(LOG_LISTENER_KEY)
|
||||
}
|
||||
|
||||
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
|
||||
startObserver(service ?: return stopSelf())
|
||||
manager = IClashManager.Stub.asInterface(service) ?: return stopSelf()
|
||||
|
||||
manager?.registerLogListener(LOG_LISTENER_KEY, object : IStreamCallback.Stub() {
|
||||
override fun complete() {}
|
||||
override fun completeExceptionally(reason: String?) {}
|
||||
override fun send(data: ParcelableContainer?) {
|
||||
data ?: return
|
||||
data.data ?: return
|
||||
|
||||
logChannel.offer(data.data as LogEvent)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
running = true
|
||||
isServiceRunning = true
|
||||
|
||||
createNotificationChannel()
|
||||
|
||||
showNotification()
|
||||
|
||||
bindService(RemoteService::class.intent, connection, Context.BIND_AUTO_CREATE)
|
||||
bindService(ClashManagerService::class.intent, connection, Context.BIND_AUTO_CREATE)
|
||||
|
||||
launchProcessor()
|
||||
|
||||
launchSaveThread()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
logChannel.close()
|
||||
|
||||
cancel()
|
||||
|
||||
connection.onServiceDisconnected(null)
|
||||
|
||||
unbindService(connection)
|
||||
|
||||
stopForeground(true)
|
||||
|
||||
running = false
|
||||
isServiceRunning = false
|
||||
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder {
|
||||
override fun onBind(intent: Intent?): IBinder? {
|
||||
return this.asBinder()
|
||||
}
|
||||
|
||||
override fun asBinder(): IBinder {
|
||||
return object : Binder() {
|
||||
override fun queryLocalInterface(descriptor: String): IInterface {
|
||||
override fun queryLocalInterface(descriptor: String): IInterface? {
|
||||
return this@LogcatService
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun snapshot(full: Boolean): LogcatCache.Snapshot? {
|
||||
return cache.snapshot(full)
|
||||
override fun attachBaseContext(base: Context?) {
|
||||
val b = base ?: return super.attachBaseContext(base)
|
||||
|
||||
val language = UiSettings(b).get(UiSettings.LANGUAGE)
|
||||
|
||||
super.attachBaseContext(b.createLanguageConfigurationContext(language))
|
||||
}
|
||||
|
||||
private fun startObserver(binder: IBinder) {
|
||||
if (!binder.isBinderAlive)
|
||||
return stopSelf()
|
||||
// Export to UI
|
||||
suspend fun pollLogEvent(offset: Long): CompletableDeferred<Response> {
|
||||
val request = Request(offset, CompletableDeferred())
|
||||
|
||||
launch(Dispatchers.IO) {
|
||||
val service = binder.unwrap(IRemoteService::class).clash()
|
||||
val channel = Channel<LogMessage>(CACHE_CAPACITY)
|
||||
requestChannel.send(request)
|
||||
|
||||
return request.response
|
||||
}
|
||||
|
||||
private fun launchProcessor() {
|
||||
launch {
|
||||
val pendingRequest: MutableList<Request> = mutableListOf()
|
||||
|
||||
try {
|
||||
logsDir.mkdirs()
|
||||
withContext(Dispatchers.Default) {
|
||||
while (isActive) {
|
||||
select<Unit> {
|
||||
logChannel.onReceive {
|
||||
cache.addLast(it)
|
||||
|
||||
LogcatWriter(this@LogcatService).use {
|
||||
val observer = object : ILogObserver {
|
||||
override fun newItem(log: LogMessage) {
|
||||
channel.trySend(log)
|
||||
if (cache.size() > MAX_CACHE_COUNT) {
|
||||
cache.removeFromStart(1)
|
||||
cacheOffset++
|
||||
}
|
||||
}
|
||||
requestChannel.onReceive {
|
||||
pendingRequest.add(it)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle pending requests
|
||||
val iterator = pendingRequest.iterator()
|
||||
while (iterator.hasNext()) {
|
||||
val request = iterator.next()
|
||||
|
||||
if (request.offset >= cacheOffset + cache.size())
|
||||
continue
|
||||
|
||||
val logs = mutableListOf<LogEvent>()
|
||||
|
||||
val responseOffset = max(cacheOffset, request.offset)
|
||||
val begin = (responseOffset - cacheOffset).toInt()
|
||||
|
||||
for (i in begin until cache.size())
|
||||
logs.add(cache[i])
|
||||
|
||||
request.response.complete(Response(responseOffset, logs))
|
||||
|
||||
iterator.remove()
|
||||
}
|
||||
}
|
||||
|
||||
service.setLogObserver(observer)
|
||||
|
||||
while (isActive) {
|
||||
val msg = channel.receive()
|
||||
|
||||
it.appendMessage(msg)
|
||||
|
||||
cache.append(msg)
|
||||
}
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Log.e("Write log file: $e", e)
|
||||
} finally {
|
||||
withContext(NonCancellable) {
|
||||
if (binder.isBinderAlive) {
|
||||
service.setLogObserver(null)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
return@launch
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stopSelf()
|
||||
private fun launchSaveThread() {
|
||||
launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
logsDir.mkdirs()
|
||||
try {
|
||||
FileWriter(logsDir.resolve(entity.fileName)).buffered().use { output ->
|
||||
var offset = 0L
|
||||
|
||||
output.write("# Logcat on ${Date(entity.date).format(this@LogcatService)}")
|
||||
output.newLine()
|
||||
|
||||
while (isActive) {
|
||||
val response = pollLogEvent(offset).await()
|
||||
|
||||
if (response.offset != offset) {
|
||||
output.write("# Lost ${response.offset - offset} items")
|
||||
output.newLine()
|
||||
}
|
||||
|
||||
response.logs.forEach {
|
||||
output.write(
|
||||
LOG_CONTENT_FORMAT.format(
|
||||
it.time,
|
||||
it.level,
|
||||
it.message
|
||||
)
|
||||
)
|
||||
output.newLine()
|
||||
}
|
||||
|
||||
offset = response.offset + response.logs.size
|
||||
}
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Log.w("Logcat file write failure", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun createNotificationChannel() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O)
|
||||
return
|
||||
|
||||
NotificationManagerCompat.from(this)
|
||||
.createNotificationChannel(
|
||||
NotificationChannelCompat.Builder(
|
||||
CHANNEL_ID,
|
||||
NotificationManagerCompat.IMPORTANCE_DEFAULT
|
||||
).setName(getString(R.string.clash_logcat)).build()
|
||||
NotificationChannel(
|
||||
NOTIFICATION_CHANNEL_ID,
|
||||
getString(R.string.clash_logcat),
|
||||
NotificationManager.IMPORTANCE_DEFAULT
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun showNotification() {
|
||||
val notification = NotificationCompat
|
||||
.Builder(this, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_logo_service)
|
||||
.setColor(getColorCompat(R.color.color_clash_light))
|
||||
.Builder(this, NOTIFICATION_CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setColor(getColor(R.color.colorAccentService))
|
||||
.setContentTitle(getString(R.string.clash_logcat))
|
||||
.setContentText(getString(R.string.running))
|
||||
.setGroup(NOTIFICATION_CHANNEL_ID)
|
||||
.setContentIntent(
|
||||
PendingIntent.getActivity(
|
||||
this,
|
||||
R.id.nf_logcat_status,
|
||||
LogcatActivity::class.intent
|
||||
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP),
|
||||
pendingIntentFlags(PendingIntent.FLAG_UPDATE_CURRENT)
|
||||
NOTIFICATION_ID,
|
||||
LogsActivity::class.intent
|
||||
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT
|
||||
)
|
||||
)
|
||||
.build()
|
||||
|
||||
startForeground(R.id.nf_logcat_status, notification)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val CHANNEL_ID = "clash_logcat_channel"
|
||||
private const val CACHE_CAPACITY = 128
|
||||
|
||||
var running: Boolean = false
|
||||
startForeground(NOTIFICATION_ID, notification)
|
||||
}
|
||||
}
|
||||
@@ -1,71 +1,268 @@
|
||||
package com.github.kr328.clash
|
||||
|
||||
import com.github.kr328.clash.common.util.intent
|
||||
import com.github.kr328.clash.common.util.setFileName
|
||||
import com.github.kr328.clash.design.LogsDesign
|
||||
import com.github.kr328.clash.design.model.LogFile
|
||||
import com.github.kr328.clash.util.logsDir
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.util.TypedValue
|
||||
import android.view.ViewGroup
|
||||
import androidx.annotation.ColorInt
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import com.github.kr328.clash.adapter.LogFileAdapter
|
||||
import com.github.kr328.clash.common.utils.intent
|
||||
import com.github.kr328.clash.common.utils.startForegroundServiceCompat
|
||||
import com.github.kr328.clash.design.common.Category
|
||||
import com.github.kr328.clash.design.view.CommonUiLayout
|
||||
import com.github.kr328.clash.model.LogFile
|
||||
import com.github.kr328.clash.utils.format
|
||||
import com.github.kr328.clash.utils.logsDir
|
||||
import com.google.android.material.bottomsheet.BottomSheetDialog
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import kotlinx.android.synthetic.main.activity_logs.*
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.selects.select
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.FileInputStream
|
||||
import java.util.*
|
||||
|
||||
class LogsActivity : BaseActivity<LogsDesign>() {
|
||||
override suspend fun main() {
|
||||
if (LogcatService.running) {
|
||||
return startActivity(LogcatActivity::class.intent)
|
||||
class LogsActivity : BaseActivity() {
|
||||
companion object {
|
||||
const val REQUEST_CODE = 50000
|
||||
}
|
||||
|
||||
private var lastWriteFile: LogFile? = null
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_logs)
|
||||
|
||||
setSupportActionBar(toolbar)
|
||||
|
||||
if (LogcatService.isServiceRunning) {
|
||||
startActivity(LogViewerActivity::class.intent)
|
||||
finish()
|
||||
return
|
||||
}
|
||||
|
||||
val design = LogsDesign(this)
|
||||
commonUi.build {
|
||||
option(
|
||||
title = getString(R.string.clash_logcat),
|
||||
summary = getString(R.string.tap_to_start),
|
||||
icon = getDrawable(R.drawable.ic_adb)
|
||||
) {
|
||||
onClick {
|
||||
startForegroundServiceCompat(LogcatService::class.intent)
|
||||
|
||||
setContentDesign(design)
|
||||
startActivity(LogViewerActivity::class.intent)
|
||||
|
||||
while (isActive) {
|
||||
select<Unit> {
|
||||
events.onReceive {
|
||||
when (it) {
|
||||
Event.ActivityStart -> {
|
||||
val files = withContext(Dispatchers.IO) {
|
||||
loadFiles()
|
||||
}
|
||||
|
||||
design.patchLogs(files)
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
finish()
|
||||
}
|
||||
design.requests.onReceive {
|
||||
when (it) {
|
||||
LogsDesign.Request.StartLogcat -> {
|
||||
startActivity(LogcatActivity::class.intent)
|
||||
}
|
||||
category(text = getString(R.string.history), id = "history", showTopSeparator = true)
|
||||
}
|
||||
|
||||
finish()
|
||||
}
|
||||
LogsDesign.Request.DeleteAll -> {
|
||||
if (design.requestDeleteAll()) {
|
||||
withContext(Dispatchers.IO) {
|
||||
deleteAllLogs()
|
||||
}
|
||||
clearAll.setOnClickListener {
|
||||
showClearAllDialog()
|
||||
}
|
||||
|
||||
events.trySend(Event.ActivityStart)
|
||||
val adapter = LogFileAdapter(
|
||||
this@LogsActivity,
|
||||
onItemClicked = {
|
||||
startActivity(
|
||||
LogViewerActivity::class.intent
|
||||
.setData(Uri.fromFile(logsDir.resolve(it.fileName)))
|
||||
)
|
||||
},
|
||||
onMenuClicked = this::showMenu
|
||||
)
|
||||
val layoutManager = LinearLayoutManager(this@LogsActivity)
|
||||
|
||||
mainList.layoutManager = layoutManager
|
||||
mainList.adapter = adapter
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
|
||||
if (LogcatService.isServiceRunning)
|
||||
return
|
||||
|
||||
refreshList()
|
||||
}
|
||||
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||
if (requestCode == REQUEST_CODE) {
|
||||
if (resultCode == Activity.RESULT_OK) {
|
||||
val url = data?.data ?: return
|
||||
val file = lastWriteFile ?: return
|
||||
|
||||
lastWriteFile = null
|
||||
|
||||
launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
contentResolver.openOutputStream(url)?.use { output ->
|
||||
FileInputStream(logsDir.resolve(file.fileName)).use { input ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
}
|
||||
is LogsDesign.Request.OpenFile -> {
|
||||
startActivity(LogcatActivity::class.intent.setFileName(it.file.fileName))
|
||||
}
|
||||
}
|
||||
|
||||
Snackbar.make(rootView, R.string.file_exported, Snackbar.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
super.onActivityResult(requestCode, resultCode, data)
|
||||
}
|
||||
|
||||
private fun refreshList() {
|
||||
launch {
|
||||
val files = withContext(Dispatchers.IO) {
|
||||
(logsDir.listFiles() ?: emptyArray())
|
||||
.asSequence()
|
||||
.filter { it.name.endsWith(".log") }
|
||||
.map { LogFile.parseFromFileName(it.name) }
|
||||
.filterNotNull()
|
||||
.toList()
|
||||
}
|
||||
|
||||
if (files.isEmpty())
|
||||
commonUi.screen.requireElement<Category>("history").isHidden = true
|
||||
|
||||
val adapter = mainList.adapter as LogFileAdapter
|
||||
val old = adapter.fileList
|
||||
|
||||
val result = withContext(Dispatchers.Default) {
|
||||
DiffUtil.calculateDiff(object : DiffUtil.Callback() {
|
||||
override fun areItemsTheSame(
|
||||
oldItemPosition: Int,
|
||||
newItemPosition: Int
|
||||
): Boolean {
|
||||
return old[oldItemPosition].fileName == files[newItemPosition].fileName
|
||||
}
|
||||
|
||||
override fun getOldListSize(): Int {
|
||||
return old.size
|
||||
}
|
||||
|
||||
override fun getNewListSize(): Int {
|
||||
return files.size
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(
|
||||
oldItemPosition: Int,
|
||||
newItemPosition: Int
|
||||
): Boolean {
|
||||
return old[oldItemPosition] == files[newItemPosition]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
adapter.fileList = files
|
||||
result.dispatchUpdatesTo(adapter)
|
||||
}
|
||||
}
|
||||
|
||||
private fun showClearAllDialog() {
|
||||
AlertDialog.Builder(this)
|
||||
.setTitle(R.string.delete_all_logs)
|
||||
.setMessage(R.string.delete_all_logs_warn)
|
||||
.setPositiveButton(R.string.ok) { _, _ -> deleteAllLogs() }
|
||||
.setNegativeButton(R.string.cancel) { _, _ -> }
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun showMenu(logFile: LogFile) {
|
||||
val dialog = BottomSheetDialog(this)
|
||||
val menu = CommonUiLayout(this).apply {
|
||||
layoutParams = ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
)
|
||||
}
|
||||
|
||||
@ColorInt
|
||||
val errorColor = TypedValue().run {
|
||||
theme.resolveAttribute(R.attr.colorError, this, true)
|
||||
data
|
||||
}
|
||||
|
||||
menu.build {
|
||||
option(
|
||||
icon = getDrawable(R.drawable.ic_save),
|
||||
title = getString(R.string.export)
|
||||
) {
|
||||
onClick {
|
||||
export(logFile)
|
||||
|
||||
dialog.dismiss()
|
||||
}
|
||||
}
|
||||
option(
|
||||
icon = getDrawable(R.drawable.ic_delete_colorful),
|
||||
title = getString(R.string.delete)
|
||||
) {
|
||||
textColor = errorColor
|
||||
|
||||
onClick {
|
||||
delete(logFile)
|
||||
|
||||
dialog.dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadFiles(): List<LogFile> {
|
||||
val list = cacheDir.resolve("logs").listFiles()?.toList() ?: emptyList()
|
||||
|
||||
return list.mapNotNull { LogFile.parseFromFileName(it.name) }
|
||||
dialog.dismissWithAnimation = true
|
||||
dialog.setContentView(menu)
|
||||
dialog.show()
|
||||
}
|
||||
|
||||
private fun deleteAllLogs() {
|
||||
logsDir.deleteRecursively()
|
||||
launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
logsDir.deleteRecursively()
|
||||
}
|
||||
|
||||
refreshList()
|
||||
}
|
||||
}
|
||||
|
||||
private fun export(file: LogFile) {
|
||||
if (lastWriteFile != null)
|
||||
return
|
||||
|
||||
val d = Date(file.date)
|
||||
|
||||
val exportName = getString(R.string.format_export_log_name, d.format(this))
|
||||
|
||||
val intent = Intent(Intent.ACTION_CREATE_DOCUMENT)
|
||||
.setType("text/plain")
|
||||
.putExtra(Intent.EXTRA_TITLE, exportName)
|
||||
|
||||
lastWriteFile = file
|
||||
|
||||
startActivityForResult(intent, REQUEST_CODE)
|
||||
}
|
||||
|
||||
private fun delete(file: LogFile) {
|
||||
val d = {
|
||||
launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
logsDir.resolve(file.fileName).delete()
|
||||
}
|
||||
|
||||
refreshList()
|
||||
}
|
||||
}
|
||||
|
||||
AlertDialog.Builder(this)
|
||||
.setTitle(R.string.delete_log)
|
||||
.setMessage(getString(R.string.delete_log_warn, file.fileName))
|
||||
.setPositiveButton(R.string.ok) { _, _ -> d() }
|
||||
.setNegativeButton(R.string.cancel) { _, _ -> }
|
||||
.show()
|
||||
}
|
||||
}
|
||||
@@ -1,151 +1,202 @@
|
||||
package com.github.kr328.clash
|
||||
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import com.github.kr328.clash.common.util.intent
|
||||
import com.github.kr328.clash.common.util.ticker
|
||||
import com.github.kr328.clash.design.MainDesign
|
||||
import com.github.kr328.clash.design.ui.ToastDuration
|
||||
import com.github.kr328.clash.store.TipsStore
|
||||
import com.github.kr328.clash.util.startClashService
|
||||
import com.github.kr328.clash.util.stopClashService
|
||||
import com.github.kr328.clash.util.withClash
|
||||
import com.github.kr328.clash.util.withProfile
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.selects.select
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.concurrent.TimeUnit
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.TextView
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import com.github.kr328.clash.common.utils.intent
|
||||
import com.github.kr328.clash.common.utils.asBytesString
|
||||
import com.github.kr328.clash.core.model.General
|
||||
import com.github.kr328.clash.remote.withClash
|
||||
import com.github.kr328.clash.remote.withProfile
|
||||
import com.github.kr328.clash.service.util.startClashService
|
||||
import com.github.kr328.clash.service.util.stopClashService
|
||||
import kotlinx.android.synthetic.main.activity_main.*
|
||||
import kotlinx.coroutines.*
|
||||
|
||||
class MainActivity : BaseActivity<MainDesign>() {
|
||||
override suspend fun main() {
|
||||
val design = MainDesign(this)
|
||||
class MainActivity : BaseActivity() {
|
||||
companion object {
|
||||
private const val REQUEST_CODE = 40000
|
||||
}
|
||||
|
||||
setContentDesign(design)
|
||||
private var bandwidthJob: Job? = null
|
||||
|
||||
launch(Dispatchers.IO) {
|
||||
showUpdatedTips(design)
|
||||
}
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
design.fetch()
|
||||
setContentView(R.layout.activity_main)
|
||||
|
||||
val ticker = ticker(TimeUnit.SECONDS.toMillis(1))
|
||||
|
||||
while (isActive) {
|
||||
select<Unit> {
|
||||
events.onReceive {
|
||||
when (it) {
|
||||
Event.ActivityStart,
|
||||
Event.ServiceRecreated,
|
||||
Event.ClashStop, Event.ClashStart,
|
||||
Event.ProfileLoaded, Event.ProfileChanged -> design.fetch()
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
design.requests.onReceive {
|
||||
when (it) {
|
||||
MainDesign.Request.ToggleStatus -> {
|
||||
if (clashRunning)
|
||||
stopClashService()
|
||||
else
|
||||
design.startClash()
|
||||
}
|
||||
MainDesign.Request.OpenProxy ->
|
||||
startActivity(ProxyActivity::class.intent)
|
||||
MainDesign.Request.OpenProfiles ->
|
||||
startActivity(ProfilesActivity::class.intent)
|
||||
MainDesign.Request.OpenProviders ->
|
||||
startActivity(ProvidersActivity::class.intent)
|
||||
MainDesign.Request.OpenLogs ->
|
||||
startActivity(LogsActivity::class.intent)
|
||||
MainDesign.Request.OpenSettings ->
|
||||
startActivity(SettingsActivity::class.intent)
|
||||
MainDesign.Request.OpenHelp ->
|
||||
startActivity(HelpActivity::class.intent)
|
||||
MainDesign.Request.OpenAbout ->
|
||||
design.showAbout(queryAppVersionName())
|
||||
}
|
||||
}
|
||||
if (clashRunning) {
|
||||
ticker.onReceive {
|
||||
design.fetchTraffic()
|
||||
status.setOnClickListener {
|
||||
if (clashRunning) {
|
||||
stopClashService()
|
||||
} else {
|
||||
val vpnRequest = startClashService()
|
||||
if (vpnRequest != null) {
|
||||
val resolved = packageManager.resolveActivity(vpnRequest, 0)
|
||||
if (resolved != null) {
|
||||
startActivityForResult(vpnRequest, REQUEST_CODE)
|
||||
} else {
|
||||
showSnackbarException(getString(R.string.missing_vpn_component), null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun showUpdatedTips(design: MainDesign) {
|
||||
val tips = TipsStore(this)
|
||||
proxies.setOnClickListener {
|
||||
startActivity(ProxiesActivity::class.intent)
|
||||
}
|
||||
|
||||
if (tips.primaryVersion != TipsStore.CURRENT_PRIMARY_VERSION) {
|
||||
tips.primaryVersion = TipsStore.CURRENT_PRIMARY_VERSION
|
||||
profiles.setOnClickListener {
|
||||
startActivity(ProfilesActivity::class.intent)
|
||||
}
|
||||
|
||||
val pkg = packageManager.getPackageInfo(packageName, 0)
|
||||
logs.setOnClickListener {
|
||||
startActivity(LogsActivity::class.intent)
|
||||
}
|
||||
|
||||
if (pkg.firstInstallTime != pkg.lastUpdateTime) {
|
||||
design.showUpdatedTips()
|
||||
}
|
||||
settings.setOnClickListener {
|
||||
startActivity(SettingsActivity::class.intent)
|
||||
}
|
||||
|
||||
support.setOnClickListener {
|
||||
startActivity(SupportActivity::class.intent)
|
||||
}
|
||||
|
||||
about.setOnClickListener {
|
||||
showAboutDialog()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun MainDesign.fetch() {
|
||||
setClashRunning(clashRunning)
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
|
||||
val state = withClash {
|
||||
queryTunnelState()
|
||||
}
|
||||
val providers = withClash {
|
||||
queryProviders()
|
||||
}
|
||||
|
||||
setMode(state.mode)
|
||||
setHasProviders(providers.isNotEmpty())
|
||||
|
||||
withProfile {
|
||||
setProfileName(queryActive()?.name)
|
||||
}
|
||||
updateClashStatus()
|
||||
}
|
||||
|
||||
private suspend fun MainDesign.fetchTraffic() {
|
||||
withClash {
|
||||
setForwarded(queryTrafficTotal())
|
||||
}
|
||||
override fun onStop() {
|
||||
super.onStop()
|
||||
|
||||
stopBandwidthPolling()
|
||||
}
|
||||
|
||||
private suspend fun MainDesign.startClash() {
|
||||
val active = withProfile { queryActive() }
|
||||
|
||||
if (active == null || !active.imported) {
|
||||
showToast(R.string.no_profile_selected, ToastDuration.Long) {
|
||||
setAction(R.string.profiles) {
|
||||
startActivity(ProfilesActivity::class.intent)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||
if (requestCode == REQUEST_CODE) {
|
||||
if (resultCode == Activity.RESULT_OK)
|
||||
startClashService()
|
||||
return
|
||||
}
|
||||
|
||||
val vpnRequest = startClashService()
|
||||
super.onActivityResult(requestCode, resultCode, data)
|
||||
}
|
||||
|
||||
try {
|
||||
if (vpnRequest != null) {
|
||||
val result = startActivityForResult(
|
||||
ActivityResultContracts.StartActivityForResult(),
|
||||
vpnRequest
|
||||
)
|
||||
override suspend fun onClashStarted() {
|
||||
updateClashStatus()
|
||||
}
|
||||
|
||||
if (result.resultCode == RESULT_OK)
|
||||
startClashService()
|
||||
override suspend fun onClashStopped(reason: String?) {
|
||||
updateClashStatus()
|
||||
|
||||
if (reason != null)
|
||||
showSnackbarException(getString(R.string.clash_start_failure), reason)
|
||||
}
|
||||
|
||||
override suspend fun onClashProfileLoaded() {
|
||||
updateClashStatus()
|
||||
}
|
||||
|
||||
private fun startBandwidthPolling() {
|
||||
if (bandwidthJob != null)
|
||||
return
|
||||
|
||||
bandwidthJob = launch {
|
||||
withClash {
|
||||
try {
|
||||
while (clashRunning && isActive) {
|
||||
val bandwidth = queryBandwidth()
|
||||
status.summary = getString(
|
||||
R.string.format_traffic_forwarded,
|
||||
bandwidth.asBytesString()
|
||||
)
|
||||
delay(1000)
|
||||
}
|
||||
} finally {
|
||||
bandwidthJob = null
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
design?.showToast(R.string.unable_to_start_vpn, ToastDuration.Long)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun queryAppVersionName(): String {
|
||||
return withContext(Dispatchers.IO) {
|
||||
packageManager.getPackageInfo(packageName, 0).versionName
|
||||
private fun stopBandwidthPolling() {
|
||||
bandwidthJob?.cancel()
|
||||
}
|
||||
|
||||
private fun updateClashStatus() {
|
||||
if (clashRunning) {
|
||||
startBandwidthPolling()
|
||||
|
||||
status.setCardBackgroundColor(getColor(R.color.primaryCardColorStarted))
|
||||
status.icon = getDrawable(R.drawable.ic_started)
|
||||
status.title = getText(R.string.running)
|
||||
|
||||
proxies.visibility = View.VISIBLE
|
||||
} else {
|
||||
stopBandwidthPolling()
|
||||
|
||||
status.setCardBackgroundColor(getColor(R.color.primaryCardColorStopped))
|
||||
status.icon = getDrawable(R.drawable.ic_stopped)
|
||||
status.title = getText(R.string.stopped)
|
||||
status.summary = getText(R.string.tap_to_start)
|
||||
|
||||
proxies.visibility = View.GONE
|
||||
}
|
||||
|
||||
launch {
|
||||
val general = withClash {
|
||||
queryGeneral()
|
||||
}
|
||||
val active = withProfile {
|
||||
queryActive()
|
||||
}
|
||||
|
||||
val modeResId = when (general.mode) {
|
||||
General.Mode.DIRECT -> R.string.direct_mode
|
||||
General.Mode.GLOBAL -> R.string.global_mode
|
||||
General.Mode.RULE -> R.string.rule_mode
|
||||
}
|
||||
|
||||
val profileString =
|
||||
if (active == null)
|
||||
getText(R.string.not_selected)
|
||||
else
|
||||
getString(R.string.format_profile_activated, active.name)
|
||||
|
||||
proxies.summary = getText(modeResId)
|
||||
profiles.summary = profileString
|
||||
}
|
||||
}
|
||||
|
||||
private fun showAboutDialog() {
|
||||
launch {
|
||||
val content = withContext(Dispatchers.Default) {
|
||||
val packageInfo = packageManager.getPackageInfo(packageName, 0)
|
||||
|
||||
LayoutInflater.from(this@MainActivity)
|
||||
.inflate(R.layout.dialog_abort, rootView as ViewGroup?, false).apply {
|
||||
findViewById<View>(android.R.id.icon).background =
|
||||
getDrawable(R.drawable.ic_logo)
|
||||
findViewById<TextView>(android.R.id.title).text =
|
||||
getText(R.string.application_name)
|
||||
findViewById<TextView>(android.R.id.summary).text = packageInfo.versionName
|
||||
}
|
||||
}
|
||||
|
||||
AlertDialog.Builder(this@MainActivity)
|
||||
.setView(content)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,19 @@ package com.github.kr328.clash
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import com.github.kr328.clash.common.Global
|
||||
import com.github.kr328.clash.common.compat.currentProcessName
|
||||
import com.github.kr328.clash.common.log.Log
|
||||
import com.github.kr328.clash.common.utils.componentName
|
||||
import com.github.kr328.clash.dump.LogcatDumper
|
||||
import com.github.kr328.clash.remote.Broadcasts
|
||||
import com.github.kr328.clash.remote.Remote
|
||||
import com.github.kr328.clash.service.util.sendServiceRecreated
|
||||
import com.microsoft.appcenter.AppCenter
|
||||
import com.microsoft.appcenter.analytics.Analytics
|
||||
import com.microsoft.appcenter.crashes.AbstractCrashesListener
|
||||
import com.microsoft.appcenter.crashes.Crashes
|
||||
import com.microsoft.appcenter.crashes.ingestion.models.ErrorAttachmentLog
|
||||
import com.microsoft.appcenter.crashes.model.ErrorReport
|
||||
|
||||
@Suppress("unused")
|
||||
class MainApplication : Application() {
|
||||
@@ -20,20 +28,44 @@ class MainApplication : Application() {
|
||||
super.onCreate()
|
||||
|
||||
// Initialize AppCenter
|
||||
Tracker.initialize(this)
|
||||
if (BuildConfig.APP_CENTER_KEY.isNotEmpty() && !BuildConfig.DEBUG) {
|
||||
AppCenter.start(
|
||||
this,
|
||||
BuildConfig.APP_CENTER_KEY,
|
||||
Analytics::class.java, Crashes::class.java
|
||||
)
|
||||
|
||||
val processName = currentProcessName
|
||||
Crashes.setListener(object : AbstractCrashesListener() {
|
||||
override fun getErrorAttachments(report: ErrorReport?): MutableIterable<ErrorAttachmentLog> {
|
||||
report ?: return mutableListOf()
|
||||
|
||||
Log.d("Process $processName started")
|
||||
if (!report.stackTrace.contains("DeadObjectException"))
|
||||
return mutableListOf()
|
||||
|
||||
if (processName == packageName) {
|
||||
Remote.launch()
|
||||
} else {
|
||||
sendServiceRecreated()
|
||||
val logcat = LogcatDumper.dump().joinToString(separator = "\n")
|
||||
|
||||
return mutableListOf(
|
||||
ErrorAttachmentLog.attachmentWithText(logcat, "logcat.txt")
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fun finalize() {
|
||||
Global.destroy()
|
||||
Global.openMainIntent = {
|
||||
Intent(Intent.ACTION_MAIN).apply {
|
||||
component = MainActivity::class.componentName
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
|
||||
}
|
||||
}
|
||||
Global.openProfileIntent = {
|
||||
Intent(Intent.ACTION_MAIN).apply {
|
||||
component = ProfileEditActivity::class.componentName
|
||||
data = Uri.fromParts("id", it.toString(), null)
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
}
|
||||
}
|
||||
|
||||
Remote.init(this)
|
||||
Broadcasts.init(this)
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
package com.github.kr328.clash
|
||||
|
||||
import com.github.kr328.clash.common.util.intent
|
||||
import com.github.kr328.clash.design.NetworkSettingsDesign
|
||||
import com.github.kr328.clash.service.store.ServiceStore
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.selects.select
|
||||
|
||||
class NetworkSettingsActivity : BaseActivity<NetworkSettingsDesign>() {
|
||||
override suspend fun main() {
|
||||
val design = NetworkSettingsDesign(
|
||||
this,
|
||||
uiStore,
|
||||
ServiceStore(this),
|
||||
clashRunning,
|
||||
)
|
||||
|
||||
setContentDesign(design)
|
||||
|
||||
while (isActive) {
|
||||
select<Unit> {
|
||||
events.onReceive {
|
||||
when (it) {
|
||||
Event.ClashStart, Event.ClashStop, Event.ServiceRecreated ->
|
||||
recreate()
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
design.requests.onReceive {
|
||||
when (it) {
|
||||
NetworkSettingsDesign.Request.StartAccessControlList ->
|
||||
startActivity(AccessControlActivity::class.intent)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
package com.github.kr328.clash
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.ComponentName
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.provider.Settings
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import com.github.kr328.clash.common.constants.Intents
|
||||
import com.github.kr328.clash.common.util.intent
|
||||
import com.github.kr328.clash.common.util.setUUID
|
||||
import com.github.kr328.clash.design.NewProfileDesign
|
||||
import com.github.kr328.clash.design.model.ProfileProvider
|
||||
import com.github.kr328.clash.service.model.Profile
|
||||
import com.github.kr328.clash.util.withProfile
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.selects.select
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.*
|
||||
|
||||
class NewProfileActivity : BaseActivity<NewProfileDesign>() {
|
||||
private val self: NewProfileActivity
|
||||
get() = this
|
||||
|
||||
override suspend fun main() {
|
||||
val design = NewProfileDesign(this)
|
||||
|
||||
design.patchProviders(queryProfileProviders())
|
||||
|
||||
setContentDesign(design)
|
||||
|
||||
while (isActive) {
|
||||
select<Unit> {
|
||||
events.onReceive {
|
||||
|
||||
}
|
||||
design.requests.onReceive {
|
||||
when (it) {
|
||||
is NewProfileDesign.Request.Create -> {
|
||||
withProfile {
|
||||
val name = getString(R.string.new_profile)
|
||||
|
||||
val uuid: UUID? = when (val p = it.provider) {
|
||||
is ProfileProvider.File ->
|
||||
create(Profile.Type.File, name)
|
||||
is ProfileProvider.Url ->
|
||||
create(Profile.Type.Url, name)
|
||||
is ProfileProvider.External -> {
|
||||
val data = p.get()
|
||||
|
||||
if (data != null) {
|
||||
val (uri, initialName) = data
|
||||
|
||||
create(
|
||||
Profile.Type.External,
|
||||
initialName ?: name,
|
||||
uri.toString()
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (uuid != null)
|
||||
launchProperties(uuid)
|
||||
}
|
||||
}
|
||||
is NewProfileDesign.Request.OpenDetail -> {
|
||||
launchAppDetailed(it.provider)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun launchAppDetailed(provider: ProfileProvider.External) {
|
||||
val data = Uri.fromParts(
|
||||
"package",
|
||||
provider.intent.component?.packageName ?: return,
|
||||
null
|
||||
)
|
||||
|
||||
startActivity(Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).setData(data))
|
||||
}
|
||||
|
||||
private suspend fun launchProperties(uuid: UUID) {
|
||||
val r = startActivityForResult(
|
||||
ActivityResultContracts.StartActivityForResult(),
|
||||
PropertiesActivity::class.intent.setUUID(uuid)
|
||||
)
|
||||
|
||||
if (r.resultCode == Activity.RESULT_OK)
|
||||
finish()
|
||||
}
|
||||
|
||||
private suspend fun ProfileProvider.External.get(): Pair<Uri, String?>? {
|
||||
val result = startActivityForResult(
|
||||
ActivityResultContracts.StartActivityForResult(),
|
||||
intent
|
||||
)
|
||||
|
||||
if (result.resultCode != RESULT_OK)
|
||||
return null
|
||||
|
||||
val uri = result.data?.data
|
||||
val name = result.data?.getStringExtra(Intents.EXTRA_NAME)
|
||||
|
||||
if (uri != null) {
|
||||
return uri to name
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private suspend fun queryProfileProviders(): List<ProfileProvider> {
|
||||
return withContext(Dispatchers.IO) {
|
||||
val providers = packageManager.queryIntentActivities(
|
||||
Intent(Intents.ACTION_PROVIDE_URL),
|
||||
0
|
||||
).map {
|
||||
val activity = it.activityInfo
|
||||
|
||||
val name = activity.applicationInfo.loadLabel(packageManager)
|
||||
val summary = activity.loadLabel(packageManager)
|
||||
val icon = activity.loadIcon(packageManager)
|
||||
val intent = Intent(Intents.ACTION_PROVIDE_URL)
|
||||
.setComponent(
|
||||
ComponentName(
|
||||
activity.packageName,
|
||||
activity.name
|
||||
)
|
||||
)
|
||||
|
||||
ProfileProvider.External(name.toString(), summary.toString(), icon, intent)
|
||||
}
|
||||
|
||||
listOf(ProfileProvider.File(self), ProfileProvider.Url(self)) + providers
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
package com.github.kr328.clash
|
||||
|
||||
import android.content.pm.PackageManager
|
||||
import com.github.kr328.clash.common.compat.getDrawableCompat
|
||||
import com.github.kr328.clash.common.constants.Metadata
|
||||
import com.github.kr328.clash.core.Clash
|
||||
import com.github.kr328.clash.design.OverrideSettingsDesign
|
||||
import com.github.kr328.clash.design.model.AppInfo
|
||||
import com.github.kr328.clash.design.util.toAppInfo
|
||||
import com.github.kr328.clash.service.store.ServiceStore
|
||||
import com.github.kr328.clash.util.withClash
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.selects.select
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class OverrideSettingsActivity : BaseActivity<OverrideSettingsDesign>() {
|
||||
override suspend fun main() {
|
||||
val configuration = withClash { queryOverride(Clash.OverrideSlot.Persist) }
|
||||
val service = ServiceStore(this)
|
||||
|
||||
defer {
|
||||
withClash {
|
||||
patchOverride(Clash.OverrideSlot.Persist, configuration)
|
||||
}
|
||||
}
|
||||
|
||||
val design = OverrideSettingsDesign(
|
||||
this,
|
||||
configuration
|
||||
)
|
||||
|
||||
setContentDesign(design)
|
||||
|
||||
while (isActive) {
|
||||
select<Unit> {
|
||||
events.onReceive {
|
||||
|
||||
}
|
||||
design.requests.onReceive {
|
||||
when (it) {
|
||||
OverrideSettingsDesign.Request.ResetOverride -> {
|
||||
if (design.requestResetConfirm()) {
|
||||
defer {
|
||||
withClash {
|
||||
clearOverride(Clash.OverrideSlot.Persist)
|
||||
}
|
||||
|
||||
service.sideloadGeoip = ""
|
||||
}
|
||||
|
||||
finish()
|
||||
}
|
||||
}
|
||||
OverrideSettingsDesign.Request.EditSideloadGeoip -> {
|
||||
withContext(Dispatchers.IO) {
|
||||
val list = querySideloadProviders()
|
||||
val initial = service.sideloadGeoip
|
||||
val exist = list.any { info -> info.packageName == initial }
|
||||
|
||||
service.sideloadGeoip =
|
||||
design.requestSelectSideload(if (exist) initial else "", list)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun querySideloadProviders(): List<AppInfo> {
|
||||
val apps = packageManager.getInstalledPackages(PackageManager.GET_META_DATA)
|
||||
.filter {
|
||||
it.applicationInfo.metaData?.containsKey(Metadata.GEOIP_FILE_NAME)
|
||||
?: false
|
||||
}
|
||||
.map { it.toAppInfo(packageManager) }
|
||||
|
||||
return listOf(
|
||||
AppInfo(
|
||||
packageName = "",
|
||||
label = getString(R.string.use_built_in),
|
||||
icon = getDrawableCompat(R.drawable.ic_baseline_work)!!,
|
||||
installTime = 0,
|
||||
updateDate = 0,
|
||||
)
|
||||
) + apps
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package com.github.kr328.clash
|
||||
|
||||
import android.content.pm.ApplicationInfo
|
||||
import android.os.Bundle
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import androidx.appcompat.widget.SearchView
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import com.github.kr328.clash.adapter.PackagesAdapter
|
||||
import com.github.kr328.clash.service.settings.ServiceSettings
|
||||
import kotlinx.android.synthetic.main.activity_access_control_packages.*
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlin.streams.toList
|
||||
|
||||
class PackagesActivity : BaseActivity() {
|
||||
private val activity: PackagesActivity
|
||||
get() = this
|
||||
private val adapter: PackagesAdapter?
|
||||
get() = mainList.adapter as PackagesAdapter?
|
||||
private val refreshChannel = Channel<Unit>(Channel.CONFLATED)
|
||||
|
||||
private var keyword: String = ""
|
||||
private var sort: PackagesAdapter.Sort = PackagesAdapter.Sort.NAME
|
||||
private var decrease: Boolean = false
|
||||
private var systemApp: Boolean = true
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
setContentView(R.layout.activity_access_control_packages)
|
||||
setSupportActionBar(toolbar)
|
||||
|
||||
launch {
|
||||
val appsDeferred = async {
|
||||
withContext(Dispatchers.IO) {
|
||||
val pm = packageManager
|
||||
val packages = pm.getInstalledPackages(0)
|
||||
|
||||
packages.parallelStream()
|
||||
.map {
|
||||
PackagesAdapter.AppInfo(
|
||||
it.packageName,
|
||||
it.applicationInfo.loadLabel(pm).toString(),
|
||||
it.applicationInfo.loadIcon(pm),
|
||||
it.firstInstallTime, it.lastUpdateTime,
|
||||
it.applicationInfo.flags and ApplicationInfo.FLAG_SYSTEM != 0
|
||||
)
|
||||
}
|
||||
.toList()
|
||||
}
|
||||
}
|
||||
|
||||
val selectedDeferred = async {
|
||||
withContext(Dispatchers.IO) {
|
||||
ServiceSettings(activity).get(ServiceSettings.ACCESS_CONTROL_PACKAGES)
|
||||
}
|
||||
}
|
||||
|
||||
val apps = appsDeferred.await()
|
||||
val selected = selectedDeferred.await()
|
||||
|
||||
val adapter = PackagesAdapter(activity, apps)
|
||||
|
||||
mainList.adapter = adapter
|
||||
mainList.layoutManager = LinearLayoutManager(activity)
|
||||
|
||||
adapter.selectedPackages.addAll(selected)
|
||||
|
||||
progress.visibility = View.GONE
|
||||
|
||||
refreshChannel.offer(Unit)
|
||||
|
||||
while (isActive) {
|
||||
refreshChannel.receive()
|
||||
|
||||
adapter.applyFilter(keyword, sort, decrease, systemApp)
|
||||
mainList.scrollToPosition(0)
|
||||
|
||||
delay(200)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu?): Boolean {
|
||||
if (!super.onCreateOptionsMenu(menu))
|
||||
return false
|
||||
|
||||
menuInflater.inflate(R.menu.packages, menu)
|
||||
|
||||
menu?.apply {
|
||||
(findItem(R.id.search).actionView as SearchView).apply {
|
||||
setOnQueryTextListener(object : SearchView.OnQueryTextListener {
|
||||
override fun onQueryTextSubmit(query: String?) = false
|
||||
|
||||
override fun onQueryTextChange(newText: String?): Boolean {
|
||||
keyword = newText ?: ""
|
||||
|
||||
refreshChannel.offer(Unit)
|
||||
|
||||
return true
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||
if (super.onOptionsItemSelected(item))
|
||||
return true
|
||||
|
||||
when (item.itemId) {
|
||||
R.id.systemApps -> {
|
||||
item.isChecked = !item.isChecked
|
||||
|
||||
systemApp = item.isChecked
|
||||
}
|
||||
R.id.sortReverse -> {
|
||||
item.isChecked = !item.isChecked
|
||||
|
||||
decrease = item.isChecked
|
||||
}
|
||||
R.id.sortName -> {
|
||||
item.isChecked = true
|
||||
|
||||
sort = PackagesAdapter.Sort.NAME
|
||||
}
|
||||
R.id.sortPackageName -> {
|
||||
item.isChecked = true
|
||||
|
||||
sort = PackagesAdapter.Sort.PACKAGE
|
||||
}
|
||||
R.id.sortUpdateTime -> {
|
||||
item.isChecked = true
|
||||
|
||||
sort = PackagesAdapter.Sort.UPDATE_TIME
|
||||
}
|
||||
R.id.sortInstallTime -> {
|
||||
item.isChecked = true
|
||||
|
||||
sort = PackagesAdapter.Sort.INSTALL_TIME
|
||||
}
|
||||
else -> return false
|
||||
}
|
||||
|
||||
refreshChannel.offer(Unit)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
super.onStop()
|
||||
|
||||
val packageList = adapter?.selectedPackages ?: return
|
||||
|
||||
GlobalScope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
ServiceSettings(activity).commit {
|
||||
put(ServiceSettings.ACCESS_CONTROL_PACKAGES, packageList)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package com.github.kr328.clash
|
||||
|
||||
import android.app.Activity
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import com.github.kr328.clash.fragment.ProfileEditFragment
|
||||
import com.github.kr328.clash.remote.withProfile
|
||||
import com.github.kr328.clash.service.model.Profile
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import kotlinx.android.synthetic.main.activity_profile_edit.*
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
|
||||
class ProfileEditActivity : BaseActivity() {
|
||||
private var editor: ProfileEditFragment? = null
|
||||
private var processing = false
|
||||
set(value) {
|
||||
field = value
|
||||
|
||||
if (value) {
|
||||
saving.visibility = View.VISIBLE
|
||||
save.visibility = View.INVISIBLE
|
||||
} else {
|
||||
saving.visibility = View.INVISIBLE
|
||||
save.visibility = View.VISIBLE
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_profile_edit)
|
||||
setSupportActionBar(toolbar)
|
||||
|
||||
toolbar.setTitle(R.string.loading)
|
||||
|
||||
launch {
|
||||
val id = intent.data?.schemeSpecificPart?.toLongOrNull() ?: return@launch finish()
|
||||
|
||||
val metadata = withProfile {
|
||||
queryById(id)
|
||||
} ?: return@launch finish()
|
||||
|
||||
when {
|
||||
metadata.lastModified > 0 ->
|
||||
toolbar.setTitle(R.string.edit_profile)
|
||||
metadata.name.isBlank() ->
|
||||
toolbar.setTitle(R.string.new_profile)
|
||||
else ->
|
||||
toolbar.setTitle(R.string.clone_profile)
|
||||
}
|
||||
|
||||
val fragment = ProfileEditFragment(
|
||||
metadata.id,
|
||||
metadata.name, metadata.uri, metadata.interval,
|
||||
metadata.type, metadata.source
|
||||
)
|
||||
|
||||
editor = fragment
|
||||
|
||||
supportFragmentManager.beginTransaction()
|
||||
.replace(R.id.fragment, fragment)
|
||||
.commit()
|
||||
|
||||
save.setOnClickListener {
|
||||
val name = fragment.name
|
||||
val uri = fragment.uri
|
||||
val interval = fragment.interval
|
||||
|
||||
if (name.isBlank()) {
|
||||
Snackbar.make(rootView, R.string.empty_name, Snackbar.LENGTH_LONG).show()
|
||||
return@setOnClickListener
|
||||
}
|
||||
|
||||
val newMetadata = metadata.copy(
|
||||
name = name,
|
||||
uri = uri,
|
||||
interval = interval
|
||||
)
|
||||
|
||||
processing = true
|
||||
|
||||
commit(newMetadata)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onBackPressed() {
|
||||
if (processing) {
|
||||
Snackbar.make(rootView, R.string.processing, Snackbar.LENGTH_LONG).show()
|
||||
return
|
||||
}
|
||||
|
||||
if ( editor?.isModified != true)
|
||||
return finish()
|
||||
|
||||
AlertDialog.Builder(this)
|
||||
.setTitle(R.string.exit_without_save)
|
||||
.setMessage(R.string.exit_without_save_warning)
|
||||
.setNegativeButton(R.string.cancel) { _, _ -> }
|
||||
.setPositiveButton(R.string.ok) { _, _ -> finish() }
|
||||
.show()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
runBlocking {
|
||||
withProfile {
|
||||
val id = editor?.id ?: return@withProfile
|
||||
|
||||
release(id)
|
||||
}
|
||||
}
|
||||
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
private fun commit(metadata: Profile) {
|
||||
launch {
|
||||
try {
|
||||
withProfile {
|
||||
update(metadata.id, metadata)
|
||||
commitAsync(metadata.id).await()
|
||||
}
|
||||
|
||||
setResult(Activity.RESULT_OK)
|
||||
|
||||
finish()
|
||||
} catch (e: Exception) {
|
||||
showSnackbarException(getString(R.string.download_failure), e.message)
|
||||
}
|
||||
|
||||
processing = false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,77 +1,180 @@
|
||||
package com.github.kr328.clash
|
||||
|
||||
import com.github.kr328.clash.common.util.intent
|
||||
import com.github.kr328.clash.common.util.setUUID
|
||||
import com.github.kr328.clash.common.util.ticker
|
||||
import com.github.kr328.clash.design.ProfilesDesign
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import com.github.kr328.clash.adapter.ProfileAdapter
|
||||
import com.github.kr328.clash.common.utils.intent
|
||||
import com.github.kr328.clash.remote.withProfile
|
||||
import com.github.kr328.clash.service.ProfileReceiver
|
||||
import com.github.kr328.clash.service.model.Profile
|
||||
import com.github.kr328.clash.util.withProfile
|
||||
import com.github.kr328.clash.service.util.sendBroadcastSelf
|
||||
import com.github.kr328.clash.weight.ProfilesMenu
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import kotlinx.android.synthetic.main.activity_profiles.*
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.selects.select
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import java.io.FileNotFoundException
|
||||
import java.util.*
|
||||
|
||||
class ProfilesActivity : BaseActivity<ProfilesDesign>() {
|
||||
override suspend fun main() {
|
||||
val design = ProfilesDesign(this)
|
||||
class ProfilesActivity : BaseActivity(), ProfileAdapter.Callback, ProfilesMenu.Callback {
|
||||
companion object {
|
||||
private const val EDITOR_REQUEST_CODE = 30000
|
||||
}
|
||||
|
||||
setContentDesign(design)
|
||||
private var backgroundJob: Job? = null
|
||||
private val reloadMutex = Mutex()
|
||||
private val editorStack = Stack<Profile>()
|
||||
|
||||
val ticker = ticker(TimeUnit.MINUTES.toMillis(1))
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_profiles)
|
||||
setSupportActionBar(toolbar)
|
||||
|
||||
while (isActive) {
|
||||
select<Unit> {
|
||||
events.onReceive {
|
||||
when (it) {
|
||||
Event.ActivityStart, Event.ProfileChanged -> {
|
||||
design.fetch()
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
design.requests.onReceive {
|
||||
when (it) {
|
||||
ProfilesDesign.Request.Create ->
|
||||
startActivity(NewProfileActivity::class.intent)
|
||||
ProfilesDesign.Request.UpdateAll ->
|
||||
withProfile {
|
||||
queryAll().forEach { p ->
|
||||
if (p.imported && p.type != Profile.Type.File)
|
||||
update(p.uuid)
|
||||
}
|
||||
}
|
||||
is ProfilesDesign.Request.Update ->
|
||||
withProfile { update(it.profile.uuid) }
|
||||
is ProfilesDesign.Request.Delete ->
|
||||
withProfile { delete(it.profile.uuid) }
|
||||
is ProfilesDesign.Request.Edit ->
|
||||
startActivity(PropertiesActivity::class.intent.setUUID(it.profile.uuid))
|
||||
is ProfilesDesign.Request.Active -> {
|
||||
withProfile {
|
||||
if (it.profile.imported)
|
||||
setActive(it.profile)
|
||||
else
|
||||
design.requestSave(it.profile)
|
||||
}
|
||||
}
|
||||
is ProfilesDesign.Request.Duplicate -> {
|
||||
val uuid = withProfile { clone(it.profile.uuid) }
|
||||
mainList.layoutManager = LinearLayoutManager(this)
|
||||
mainList.adapter = ProfileAdapter(this, this)
|
||||
}
|
||||
|
||||
startActivity(PropertiesActivity::class.intent.setUUID(uuid))
|
||||
}
|
||||
}
|
||||
}
|
||||
if (activityStarted) {
|
||||
ticker.onReceive {
|
||||
design.updateElapsed()
|
||||
}
|
||||
}
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
|
||||
backgroundJob = launch {
|
||||
reloadProfiles()
|
||||
|
||||
while (isActive) {
|
||||
delay(1000 * 60)
|
||||
|
||||
// Refresh without animation
|
||||
(mainList.adapter as ProfileAdapter).notifyDataSetChanged()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun ProfilesDesign.fetch() {
|
||||
withProfile {
|
||||
patchProfiles(queryAll())
|
||||
override fun onStop() {
|
||||
super.onStop()
|
||||
|
||||
backgroundJob?.cancel()
|
||||
backgroundJob = null
|
||||
}
|
||||
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||
if (requestCode == EDITOR_REQUEST_CODE) {
|
||||
launch {
|
||||
val profile = editorStack.pop()
|
||||
|
||||
withProfile {
|
||||
update(profile.id, profile)
|
||||
startUpdate(profile.id)
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
super.onActivityResult(requestCode, resultCode, data)
|
||||
}
|
||||
|
||||
override suspend fun onClashProfileChanged() {
|
||||
reloadProfiles()
|
||||
}
|
||||
|
||||
private suspend fun reloadProfiles() {
|
||||
if (!reloadMutex.tryLock())
|
||||
return
|
||||
|
||||
val profiles = withProfile {
|
||||
queryAll()
|
||||
}
|
||||
|
||||
(mainList.adapter as ProfileAdapter).setEntitiesAsync(profiles.toList())
|
||||
|
||||
reloadMutex.unlock()
|
||||
}
|
||||
|
||||
override fun onProfileClicked(entity: Profile) {
|
||||
launch {
|
||||
withProfile {
|
||||
setActive(entity.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMenuClicked(entity: Profile) {
|
||||
ProfilesMenu(this, entity, this).show()
|
||||
}
|
||||
|
||||
override fun onNewProfile() {
|
||||
startActivity(CreateProfileActivity::class.intent)
|
||||
}
|
||||
|
||||
private fun openProperties(id: Long) {
|
||||
startActivity(
|
||||
ProfileEditActivity::class.intent
|
||||
.setData(Uri.fromParts("id", id.toString(), null))
|
||||
)
|
||||
}
|
||||
|
||||
private fun openEditor(profile: Profile) = launch {
|
||||
try {
|
||||
val uri = withProfile {
|
||||
acquireTempUri(profile.id)
|
||||
} ?: throw FileNotFoundException()
|
||||
|
||||
editorStack.push(profile.copy(uri = Uri.parse(uri)))
|
||||
|
||||
startActivityForResult(
|
||||
Intent(Intent.ACTION_VIEW)
|
||||
.setDataAndType(Uri.parse(uri), "text/plain")
|
||||
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION),
|
||||
EDITOR_REQUEST_CODE
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Snackbar.make(rootView, getText(R.string.profile_not_found), Snackbar.LENGTH_LONG)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
|
||||
private fun startUpdate(id: Long) {
|
||||
sendBroadcastSelf(ProfileReceiver.buildUpdateIntentForId(id))
|
||||
}
|
||||
|
||||
override fun onOpenEditor(entity: Profile) {
|
||||
openEditor(entity)
|
||||
}
|
||||
|
||||
override fun onUpdate(entity: Profile) {
|
||||
startUpdate(entity.id)
|
||||
}
|
||||
|
||||
override fun onOpenProperties(entity: Profile) {
|
||||
openProperties(entity.id)
|
||||
}
|
||||
|
||||
override fun onDuplicate(entity: Profile) {
|
||||
launch {
|
||||
withProfile {
|
||||
openProperties(acquireCloned(entity.id))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResetProvider(entity: Profile) {
|
||||
launch {
|
||||
withProfile {
|
||||
clear(entity.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDelete(entity: Profile) {
|
||||
launch {
|
||||
withProfile {
|
||||
delete(entity.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
package com.github.kr328.clash
|
||||
|
||||
import com.github.kr328.clash.common.util.intent
|
||||
import com.github.kr328.clash.common.util.setUUID
|
||||
import com.github.kr328.clash.common.util.uuid
|
||||
import com.github.kr328.clash.design.PropertiesDesign
|
||||
import com.github.kr328.clash.design.ui.ToastDuration
|
||||
import com.github.kr328.clash.design.util.showExceptionToast
|
||||
import com.github.kr328.clash.service.model.Profile
|
||||
import com.github.kr328.clash.util.withProfile
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.selects.select
|
||||
|
||||
class PropertiesActivity : BaseActivity<PropertiesDesign>() {
|
||||
private var canceled: Boolean = false
|
||||
|
||||
override suspend fun main() {
|
||||
setResult(RESULT_CANCELED)
|
||||
|
||||
val uuid = intent.uuid ?: return finish()
|
||||
val design = PropertiesDesign(this)
|
||||
|
||||
val original = withProfile { queryByUUID(uuid) } ?: return finish()
|
||||
|
||||
design.profile = original
|
||||
|
||||
setContentDesign(design)
|
||||
|
||||
defer {
|
||||
canceled = true
|
||||
|
||||
withProfile { release(uuid) }
|
||||
}
|
||||
|
||||
while (isActive) {
|
||||
select<Unit> {
|
||||
events.onReceive {
|
||||
when (it) {
|
||||
Event.ActivityStop -> {
|
||||
val profile = design.profile
|
||||
|
||||
if (!canceled && profile != original) {
|
||||
withProfile {
|
||||
patch(profile.uuid, profile.name, profile.source, profile.interval)
|
||||
}
|
||||
}
|
||||
}
|
||||
Event.ServiceRecreated -> {
|
||||
finish()
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
design.requests.onReceive {
|
||||
when (it) {
|
||||
PropertiesDesign.Request.BrowseFiles -> {
|
||||
startActivity(FilesActivity::class.intent.setUUID(uuid))
|
||||
}
|
||||
PropertiesDesign.Request.Commit -> {
|
||||
design.verifyAndCommit()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onBackPressed() {
|
||||
design?.apply {
|
||||
launch {
|
||||
if (!progressing) {
|
||||
if (requestExitWithoutSaving())
|
||||
finish()
|
||||
}
|
||||
}
|
||||
} ?: return super.onBackPressed()
|
||||
}
|
||||
|
||||
private suspend fun PropertiesDesign.verifyAndCommit() {
|
||||
when {
|
||||
profile.name.isBlank() -> {
|
||||
showToast(R.string.empty_name, ToastDuration.Long)
|
||||
}
|
||||
profile.type != Profile.Type.File && profile.source.isBlank() -> {
|
||||
showToast(R.string.invalid_url, ToastDuration.Long)
|
||||
}
|
||||
else -> {
|
||||
try {
|
||||
withProcessing { updateStatus ->
|
||||
withProfile {
|
||||
patch(profile.uuid, profile.name, profile.source, profile.interval)
|
||||
|
||||
coroutineScope {
|
||||
commit(profile.uuid) {
|
||||
launch {
|
||||
updateStatus(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setResult(RESULT_OK)
|
||||
|
||||
finish()
|
||||
} catch (e: Exception) {
|
||||
showExceptionToast(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
package com.github.kr328.clash
|
||||
|
||||
import com.github.kr328.clash.common.util.intent
|
||||
import com.github.kr328.clash.common.util.ticker
|
||||
import com.github.kr328.clash.design.ProvidersDesign
|
||||
import com.github.kr328.clash.design.util.showExceptionToast
|
||||
import com.github.kr328.clash.util.withClash
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.selects.select
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class ProvidersActivity : BaseActivity<ProvidersDesign>() {
|
||||
override suspend fun main() {
|
||||
val providers = withClash { queryProviders().sorted() }
|
||||
val design = ProvidersDesign(this, providers)
|
||||
|
||||
setContentDesign(design)
|
||||
|
||||
val ticker = ticker(TimeUnit.MINUTES.toMillis(1))
|
||||
|
||||
while (isActive) {
|
||||
select<Unit> {
|
||||
events.onReceive {
|
||||
when (it) {
|
||||
Event.ProfileLoaded -> {
|
||||
val newList = withClash { queryProviders().sorted() }
|
||||
|
||||
if (newList != providers) {
|
||||
startActivity(ProvidersActivity::class.intent)
|
||||
|
||||
finish()
|
||||
}
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
design.requests.onReceive {
|
||||
when (it) {
|
||||
is ProvidersDesign.Request.Update -> {
|
||||
launch {
|
||||
try {
|
||||
withClash {
|
||||
updateProvider(it.provider.type, it.provider.name)
|
||||
}
|
||||
|
||||
design.notifyChanged(it.index)
|
||||
} catch (e: Exception) {
|
||||
design.showExceptionToast(
|
||||
getString(
|
||||
R.string.format_update_provider_failure,
|
||||
it.provider.name,
|
||||
e.message
|
||||
)
|
||||
)
|
||||
|
||||
design.notifyUpdated(it.index)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (activityStarted) {
|
||||
ticker.onReceive {
|
||||
design.updateElapsed()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
package com.github.kr328.clash
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.LinearSmoothScroller
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.github.kr328.clash.adapter.ProxyAdapter
|
||||
import com.github.kr328.clash.adapter.ProxyChipAdapter
|
||||
import com.github.kr328.clash.core.model.General
|
||||
import com.github.kr328.clash.pipeline.Pipeline
|
||||
import com.github.kr328.clash.pipeline.mergePrefix
|
||||
import com.github.kr328.clash.pipeline.sort
|
||||
import com.github.kr328.clash.pipeline.toAdapterElement
|
||||
import com.github.kr328.clash.preference.UiSettings
|
||||
import com.github.kr328.clash.remote.withClash
|
||||
import com.github.kr328.clash.utils.ScrollBinding
|
||||
import kotlinx.android.synthetic.main.activity_proxies.*
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
|
||||
class ProxiesActivity : BaseActivity(), ScrollBinding.Callback {
|
||||
private val refreshMutex = Mutex()
|
||||
private val scrollBinding = ScrollBinding(this, this)
|
||||
private var scrollToLast = true
|
||||
|
||||
private val mainListAdapter: ProxyAdapter
|
||||
get() = mainList.adapter as ProxyAdapter
|
||||
private val chipListAdapter: ProxyChipAdapter
|
||||
get() = chipList.adapter as ProxyChipAdapter
|
||||
private val urlTesting: MutableSet<String> = mutableSetOf()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_proxies)
|
||||
setSupportActionBar(toolbar)
|
||||
|
||||
mainList.adapter = ProxyAdapter(this, this::setGroupSelected, this::startUrlTesting)
|
||||
mainList.layoutManager = mainListAdapter.layoutManager
|
||||
|
||||
chipList.adapter = ProxyChipAdapter(this, this::chipClicked)
|
||||
chipList.layoutManager = LinearLayoutManager(this, LinearLayoutManager.HORIZONTAL, false)
|
||||
chipList.itemAnimator?.changeDuration = 0
|
||||
|
||||
launch {
|
||||
mainList.addOnScrollListener(object : RecyclerView.OnScrollListener() {
|
||||
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
|
||||
scrollBinding.sendMasterScrolled()
|
||||
}
|
||||
})
|
||||
|
||||
scrollBinding.exec()
|
||||
}
|
||||
|
||||
refreshList()
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
uiSettings.commit {
|
||||
put(
|
||||
UiSettings.PROXY_LAST_SELECT_GROUP,
|
||||
(chipList.adapter!! as ProxyChipAdapter).selected
|
||||
)
|
||||
}
|
||||
|
||||
super.onStop()
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu?): Boolean {
|
||||
if (!super.onCreateOptionsMenu(menu))
|
||||
return false
|
||||
|
||||
menuInflater.inflate(R.menu.proxies, menu)
|
||||
|
||||
setupMenu()
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||
if (super.onOptionsItemSelected(item))
|
||||
return true
|
||||
|
||||
if (item.itemId == R.id.menuRefresh) {
|
||||
refreshList()
|
||||
return true
|
||||
}
|
||||
|
||||
launch {
|
||||
var scrollTop = false
|
||||
|
||||
when (item.itemId) {
|
||||
R.id.modeDirect -> {
|
||||
withClash {
|
||||
setProxyMode(General.Mode.DIRECT)
|
||||
}
|
||||
}
|
||||
R.id.modeGlobal -> {
|
||||
withClash {
|
||||
setProxyMode(General.Mode.GLOBAL)
|
||||
|
||||
scrollTop = true
|
||||
}
|
||||
}
|
||||
R.id.modeRule -> {
|
||||
withClash {
|
||||
setProxyMode(General.Mode.RULE)
|
||||
}
|
||||
}
|
||||
R.id.groupDefault -> {
|
||||
uiSettings.commit {
|
||||
put(UiSettings.PROXY_GROUP_SORT, UiSettings.PROXY_SORT_DEFAULT)
|
||||
}
|
||||
}
|
||||
R.id.groupName -> {
|
||||
uiSettings.commit {
|
||||
put(UiSettings.PROXY_GROUP_SORT, UiSettings.PROXY_SORT_NAME)
|
||||
}
|
||||
}
|
||||
R.id.groupDelay -> {
|
||||
uiSettings.commit {
|
||||
put(UiSettings.PROXY_GROUP_SORT, UiSettings.PROXY_SORT_DELAY)
|
||||
}
|
||||
}
|
||||
R.id.proxyDefault -> {
|
||||
uiSettings.commit {
|
||||
put(UiSettings.PROXY_PROXY_SORT, UiSettings.PROXY_SORT_DEFAULT)
|
||||
}
|
||||
}
|
||||
R.id.proxyName -> {
|
||||
uiSettings.commit {
|
||||
put(UiSettings.PROXY_PROXY_SORT, UiSettings.PROXY_SORT_NAME)
|
||||
}
|
||||
}
|
||||
R.id.proxyDelay -> {
|
||||
uiSettings.commit {
|
||||
put(UiSettings.PROXY_PROXY_SORT, UiSettings.PROXY_SORT_DELAY)
|
||||
}
|
||||
}
|
||||
R.id.utilsMergePrefix -> {
|
||||
item.isChecked = !item.isChecked
|
||||
|
||||
uiSettings.commit {
|
||||
put(UiSettings.PROXY_MERGE_PREFIX, item.isChecked)
|
||||
}
|
||||
|
||||
refreshList()
|
||||
|
||||
return@launch
|
||||
}
|
||||
else -> return@launch
|
||||
}
|
||||
|
||||
item.isChecked = true
|
||||
|
||||
refreshList(scrollTop)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override suspend fun onClashStopped(reason: String?) {
|
||||
finish()
|
||||
}
|
||||
|
||||
private fun setupMenu() {
|
||||
launch {
|
||||
val general = withClash {
|
||||
queryGeneral()
|
||||
}
|
||||
|
||||
menu?.apply {
|
||||
when (general.mode) {
|
||||
General.Mode.DIRECT ->
|
||||
findItem(R.id.modeDirect).isChecked = true
|
||||
General.Mode.GLOBAL ->
|
||||
findItem(R.id.modeGlobal).isChecked = true
|
||||
General.Mode.RULE ->
|
||||
findItem(R.id.modeRule).isChecked = true
|
||||
}
|
||||
when (uiSettings.get(UiSettings.PROXY_GROUP_SORT)) {
|
||||
UiSettings.PROXY_SORT_DEFAULT ->
|
||||
findItem(R.id.groupDefault).isChecked = true
|
||||
UiSettings.PROXY_SORT_NAME ->
|
||||
findItem(R.id.groupName).isChecked = true
|
||||
UiSettings.PROXY_SORT_DELAY ->
|
||||
findItem(R.id.proxyDelay).isChecked = true
|
||||
}
|
||||
when (uiSettings.get(UiSettings.PROXY_PROXY_SORT)) {
|
||||
UiSettings.PROXY_SORT_DEFAULT ->
|
||||
findItem(R.id.proxyDefault).isChecked = true
|
||||
UiSettings.PROXY_SORT_NAME ->
|
||||
findItem(R.id.proxyName).isChecked = true
|
||||
UiSettings.PROXY_SORT_DELAY ->
|
||||
findItem(R.id.proxyDelay).isChecked = true
|
||||
}
|
||||
|
||||
findItem(R.id.utilsMergePrefix).isChecked =
|
||||
uiSettings.get(UiSettings.PROXY_MERGE_PREFIX)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun setGroupSelected(group: String, select: String) {
|
||||
launch {
|
||||
withClash {
|
||||
setSelectProxy(group, select)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun startUrlTesting(group: String) {
|
||||
launch {
|
||||
urlTesting.add(group)
|
||||
|
||||
withClash {
|
||||
startHealthCheck(group)
|
||||
}
|
||||
|
||||
urlTesting.remove(group)
|
||||
|
||||
refreshList()
|
||||
}
|
||||
}
|
||||
|
||||
private fun chipClicked(name: String) {
|
||||
launch {
|
||||
scrollBinding.scrollMaster(name)
|
||||
}
|
||||
}
|
||||
|
||||
private fun refreshList(scrollTop: Boolean = false) {
|
||||
launch {
|
||||
if (!refreshMutex.tryLock())
|
||||
return@launch
|
||||
|
||||
val general = withClash {
|
||||
queryGeneral()
|
||||
}
|
||||
val proxies = withClash {
|
||||
queryAllProxyGroups()
|
||||
}
|
||||
|
||||
val merged = Pipeline(proxies, uiSettings).mergePrefix()
|
||||
val sorted = Pipeline(proxies, uiSettings).sort()
|
||||
|
||||
val newList = sorted.toAdapterElement(merged.input, general)
|
||||
|
||||
mainListAdapter.applyChange(newList, urlTesting)
|
||||
|
||||
(chipList.adapter!! as ProxyChipAdapter).apply {
|
||||
chips = newList.map { it.name }
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
if (scrollTop)
|
||||
mainList.smoothScrollToPosition(0)
|
||||
else if (scrollToLast) {
|
||||
scrollToLast = false
|
||||
|
||||
val selected = uiSettings.get(UiSettings.PROXY_LAST_SELECT_GROUP)
|
||||
|
||||
scrollBinding.scrollMaster(selected)
|
||||
}
|
||||
|
||||
delay(500)
|
||||
|
||||
refreshMutex.unlock()
|
||||
}
|
||||
}
|
||||
|
||||
override fun getCurrentMasterToken(): String {
|
||||
return mainListAdapter.getCurrentGroup()
|
||||
}
|
||||
|
||||
override fun onMasterTokenChanged(token: String) {
|
||||
chipListAdapter.selected = token
|
||||
val position = chipListAdapter.chips.indexOf(token)
|
||||
|
||||
if (position < 0)
|
||||
return
|
||||
|
||||
chipList.smoothScrollToPosition(position)
|
||||
}
|
||||
|
||||
override fun getMasterTokenPosition(token: String): Int {
|
||||
return mainListAdapter.getGroupPosition(token)
|
||||
}
|
||||
|
||||
override fun doMasterScroll(scroller: LinearSmoothScroller, target: Int) {
|
||||
mainListAdapter.layoutManager.startSmoothScroll(scroller)
|
||||
}
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
package com.github.kr328.clash
|
||||
|
||||
import com.github.kr328.clash.common.util.intent
|
||||
import com.github.kr328.clash.core.Clash
|
||||
import com.github.kr328.clash.core.model.Proxy
|
||||
import com.github.kr328.clash.design.ProxyDesign
|
||||
import com.github.kr328.clash.design.model.ProxyState
|
||||
import com.github.kr328.clash.store.TipsStore
|
||||
import com.github.kr328.clash.util.withClash
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.selects.select
|
||||
import kotlinx.coroutines.sync.Semaphore
|
||||
import kotlinx.coroutines.sync.withPermit
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class ProxyActivity : BaseActivity<ProxyDesign>() {
|
||||
override suspend fun main() {
|
||||
val mode = withClash { queryOverride(Clash.OverrideSlot.Session).mode }
|
||||
val names = withClash { queryProxyGroupNames(uiStore.proxyExcludeNotSelectable) }
|
||||
val states = List(names.size) { ProxyState("?") }
|
||||
val unorderedStates = names.indices.map { names[it] to states[it] }.toMap()
|
||||
val reloadLock = Semaphore(10)
|
||||
val tips = TipsStore(this)
|
||||
|
||||
val design = ProxyDesign(
|
||||
this,
|
||||
mode,
|
||||
names,
|
||||
uiStore
|
||||
)
|
||||
|
||||
setContentDesign(design)
|
||||
|
||||
launch(Dispatchers.IO) {
|
||||
val pkg = packageManager.getPackageInfo(packageName, 0)
|
||||
val validate = System.currentTimeMillis() - pkg.firstInstallTime > TimeUnit.DAYS.toMillis(5)
|
||||
|
||||
if (tips.requestDonate && validate) {
|
||||
tips.requestDonate = false
|
||||
|
||||
design.requestDonate()
|
||||
}
|
||||
}
|
||||
|
||||
design.requests.send(ProxyDesign.Request.ReloadAll)
|
||||
|
||||
while (isActive) {
|
||||
select<Unit> {
|
||||
events.onReceive {
|
||||
when (it) {
|
||||
Event.ProfileLoaded -> {
|
||||
val newNames = withClash {
|
||||
queryProxyGroupNames(uiStore.proxyExcludeNotSelectable)
|
||||
}
|
||||
|
||||
if (newNames != names) {
|
||||
startActivity(ProxyActivity::class.intent)
|
||||
|
||||
finish()
|
||||
}
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
design.requests.onReceive {
|
||||
when (it) {
|
||||
ProxyDesign.Request.ReLaunch -> {
|
||||
startActivity(ProxyActivity::class.intent)
|
||||
|
||||
finish()
|
||||
}
|
||||
ProxyDesign.Request.ReloadAll -> {
|
||||
names.indices.forEach { idx ->
|
||||
design.requests.trySend(ProxyDesign.Request.Reload(idx))
|
||||
}
|
||||
}
|
||||
is ProxyDesign.Request.Reload -> {
|
||||
launch {
|
||||
val group = reloadLock.withPermit {
|
||||
withClash {
|
||||
queryProxyGroup(names[it.index], uiStore.proxySort)
|
||||
}
|
||||
}
|
||||
val state = states[it.index]
|
||||
|
||||
state.now = group.now
|
||||
|
||||
design.updateGroup(
|
||||
it.index,
|
||||
group.proxies,
|
||||
group.type == Proxy.Type.Selector,
|
||||
state,
|
||||
unorderedStates
|
||||
)
|
||||
}
|
||||
}
|
||||
is ProxyDesign.Request.Select -> {
|
||||
withClash {
|
||||
patchSelector(names[it.index], it.name)
|
||||
|
||||
states[it.index].now = it.name
|
||||
}
|
||||
|
||||
design.requestRedrawVisible()
|
||||
}
|
||||
is ProxyDesign.Request.UrlTest -> {
|
||||
launch {
|
||||
withClash {
|
||||
healthCheck(names[it.index])
|
||||
}
|
||||
|
||||
design.requests.send(ProxyDesign.Request.Reload(it.index))
|
||||
}
|
||||
}
|
||||
is ProxyDesign.Request.PatchMode -> {
|
||||
design.showModeSwitchTips()
|
||||
|
||||
withClash {
|
||||
val o = queryOverride(Clash.OverrideSlot.Session)
|
||||
|
||||
o.mode = it.mode
|
||||
|
||||
patchOverride(Clash.OverrideSlot.Session, o)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
package com.github.kr328.clash
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import com.github.kr328.clash.service.StatusProvider
|
||||
import com.github.kr328.clash.util.startClashService
|
||||
|
||||
class RestartReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
when (intent.action) {
|
||||
Intent.ACTION_BOOT_COMPLETED, Intent.ACTION_MY_PACKAGE_REPLACED -> {
|
||||
if (StatusProvider.shouldStartClashOnBoot)
|
||||
context.startClashService()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,30 +1,44 @@
|
||||
package com.github.kr328.clash
|
||||
|
||||
import com.github.kr328.clash.common.util.intent
|
||||
import com.github.kr328.clash.design.SettingsDesign
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.selects.select
|
||||
import android.os.Bundle
|
||||
import com.github.kr328.clash.common.utils.intent
|
||||
import kotlinx.android.synthetic.main.activity_settings.*
|
||||
|
||||
class SettingsActivity : BaseActivity<SettingsDesign>() {
|
||||
override suspend fun main() {
|
||||
val design = SettingsDesign(this)
|
||||
class SettingsActivity : BaseActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_settings)
|
||||
setSupportActionBar(toolbar)
|
||||
|
||||
setContentDesign(design)
|
||||
|
||||
while (isActive) {
|
||||
select<Unit> {
|
||||
events.onReceive {
|
||||
commonUi.build {
|
||||
option(
|
||||
icon = getDrawable(R.drawable.ic_settings_applications),
|
||||
title = getString(R.string.behavior)
|
||||
) {
|
||||
paddingHeight = true
|
||||
|
||||
onClick {
|
||||
startActivity(SettingsBehaviorActivity::class.intent)
|
||||
}
|
||||
design.requests.onReceive {
|
||||
when (it) {
|
||||
SettingsDesign.Request.StartApp ->
|
||||
startActivity(AppSettingsActivity::class.intent)
|
||||
SettingsDesign.Request.StartNetwork ->
|
||||
startActivity(NetworkSettingsActivity::class.intent)
|
||||
SettingsDesign.Request.StartOverride ->
|
||||
startActivity(OverrideSettingsActivity::class.intent)
|
||||
}
|
||||
}
|
||||
option(
|
||||
icon = getDrawable(R.drawable.ic_network),
|
||||
title = getString(R.string.network)
|
||||
) {
|
||||
paddingHeight = true
|
||||
|
||||
onClick {
|
||||
startActivity(SettingsNetworkActivity::class.intent)
|
||||
}
|
||||
}
|
||||
option(
|
||||
icon = getDrawable(R.drawable.ic_interface),
|
||||
title = getString(R.string.interface_)
|
||||
) {
|
||||
paddingHeight = true
|
||||
|
||||
onClick {
|
||||
startActivity(SettingsInterfaceActivity::class.intent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.github.kr328.clash
|
||||
|
||||
import android.os.Bundle
|
||||
import com.github.kr328.clash.settings.BehaviorFragment
|
||||
|
||||
class SettingsBehaviorActivity : BaseActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
setContentView(R.layout.activity_fragment)
|
||||
setSupportActionBar(findViewById(R.id.toolbar))
|
||||
|
||||
supportFragmentManager.beginTransaction()
|
||||
.replace(R.id.fragment, BehaviorFragment())
|
||||
.commit()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.github.kr328.clash
|
||||
|
||||
import android.os.Bundle
|
||||
import com.github.kr328.clash.settings.InterfaceFragment
|
||||
|
||||
class SettingsInterfaceActivity : BaseActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
setContentView(R.layout.activity_fragment)
|
||||
setSupportActionBar(findViewById(R.id.toolbar))
|
||||
|
||||
supportFragmentManager.beginTransaction()
|
||||
.replace(R.id.fragment, InterfaceFragment())
|
||||
.commit()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.github.kr328.clash
|
||||
|
||||
import android.os.Bundle
|
||||
import com.github.kr328.clash.settings.NetworkFragment
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
|
||||
class SettingsNetworkActivity : BaseActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
setContentView(R.layout.activity_fragment)
|
||||
setSupportActionBar(findViewById(R.id.toolbar))
|
||||
|
||||
supportFragmentManager.beginTransaction()
|
||||
.replace(R.id.fragment, NetworkFragment())
|
||||
.commit()
|
||||
|
||||
if (clashRunning)
|
||||
Snackbar.make(rootView, R.string.options_unavailable, Snackbar.LENGTH_INDEFINITE).show()
|
||||
}
|
||||
|
||||
override suspend fun onClashStopped(reason: String?) {
|
||||
recreate()
|
||||
}
|
||||
|
||||
override suspend fun onClashStarted() {
|
||||
recreate()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package com.github.kr328.clash
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.text.Html
|
||||
import kotlinx.android.synthetic.main.activity_support.*
|
||||
|
||||
class SupportActivity : BaseActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
setContentView(R.layout.activity_support)
|
||||
setSupportActionBar(toolbar)
|
||||
|
||||
commonUi.build {
|
||||
tips {
|
||||
icon = getDrawable(R.drawable.ic_info)
|
||||
title = Html.fromHtml(getString(R.string.tips_support), Html.FROM_HTML_MODE_LEGACY)
|
||||
}
|
||||
|
||||
category(text = getString(R.string.sources))
|
||||
|
||||
option(
|
||||
title = getString(R.string.clash),
|
||||
summary = getString(R.string.clash_url)
|
||||
) {
|
||||
onClick {
|
||||
startActivity(
|
||||
Intent(Intent.ACTION_VIEW)
|
||||
.setData(Uri.parse(getString(R.string.clash_url)))
|
||||
)
|
||||
}
|
||||
}
|
||||
option(
|
||||
title = getString(R.string.clash_for_android),
|
||||
summary = getString(R.string.clash_for_android_url)
|
||||
) {
|
||||
onClick {
|
||||
startActivity(
|
||||
Intent(Intent.ACTION_VIEW)
|
||||
.setData(Uri.parse(getString(R.string.clash_for_android_url)))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
category(text = getString(R.string.feedback))
|
||||
|
||||
option(
|
||||
title = getString(R.string.github_issues),
|
||||
summary = getString(R.string.github_issues_url)
|
||||
) {
|
||||
onClick {
|
||||
startActivity(
|
||||
Intent(Intent.ACTION_VIEW)
|
||||
.setData(Uri.parse(getString(R.string.github_issues_url)))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val firstLanguage = resources.configuration.locales.get(0).language
|
||||
|
||||
if (firstLanguage.equals("zh", true)) {
|
||||
category(getString(R.string.donate))
|
||||
|
||||
option(
|
||||
title = getString(R.string.telegram_channel),
|
||||
summary = getString(R.string.telegram_channel_url)
|
||||
) {
|
||||
onClick {
|
||||
startActivity(
|
||||
Intent(Intent.ACTION_VIEW)
|
||||
.setData(Uri.parse(getString(R.string.telegram_channel_url)))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,23 +5,20 @@ import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.graphics.drawable.Icon
|
||||
import android.os.Build
|
||||
import android.service.quicksettings.Tile
|
||||
import android.service.quicksettings.TileService
|
||||
import androidx.annotation.RequiresApi
|
||||
import com.github.kr328.clash.common.constants.Intents
|
||||
import com.github.kr328.clash.common.constants.Permissions
|
||||
import com.github.kr328.clash.remote.StatusClient
|
||||
import com.github.kr328.clash.util.startClashService
|
||||
import com.github.kr328.clash.util.stopClashService
|
||||
import com.github.kr328.clash.common.Permissions
|
||||
import com.github.kr328.clash.common.ids.Intents
|
||||
import com.github.kr328.clash.remote.RemoteUtils
|
||||
import com.github.kr328.clash.service.util.startClashService
|
||||
import com.github.kr328.clash.service.util.stopClashService
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.N)
|
||||
class TileService : TileService() {
|
||||
private var currentProfile = ""
|
||||
private var clashRunning = false
|
||||
|
||||
override fun onClick() {
|
||||
val tile = qsTile ?: return
|
||||
val tile = qsTile
|
||||
|
||||
when (tile.state) {
|
||||
Tile.STATE_INACTIVE -> {
|
||||
@@ -34,71 +31,74 @@ class TileService : TileService() {
|
||||
}
|
||||
|
||||
override fun onStartListening() {
|
||||
super.onStartListening()
|
||||
|
||||
registerReceiver(
|
||||
receiver,
|
||||
IntentFilter().apply {
|
||||
addAction(Intents.ACTION_CLASH_STARTED)
|
||||
addAction(Intents.ACTION_CLASH_STOPPED)
|
||||
addAction(Intents.ACTION_PROFILE_LOADED)
|
||||
addAction(Intents.ACTION_SERVICE_RECREATED)
|
||||
},
|
||||
Permissions.RECEIVE_SELF_BROADCASTS,
|
||||
null
|
||||
)
|
||||
|
||||
val name = StatusClient(this).currentProfile()
|
||||
|
||||
clashRunning = name != null
|
||||
currentProfile = name ?: ""
|
||||
|
||||
updateTile()
|
||||
refreshStatus()
|
||||
}
|
||||
|
||||
override fun onStopListening() {
|
||||
super.onStopListening()
|
||||
private fun refreshStatus() {
|
||||
if (qsTile == null)
|
||||
return
|
||||
|
||||
unregisterReceiver(receiver)
|
||||
}
|
||||
|
||||
private fun updateTile() {
|
||||
val tile = qsTile ?: return
|
||||
|
||||
tile.state = if (clashRunning)
|
||||
qsTile.state = if (clashRunning)
|
||||
Tile.STATE_ACTIVE
|
||||
else
|
||||
Tile.STATE_INACTIVE
|
||||
|
||||
tile.label = if (currentProfile.isEmpty())
|
||||
qsTile.label = if (currentProfile.isEmpty())
|
||||
getText(R.string.launch_name)
|
||||
else
|
||||
currentProfile
|
||||
|
||||
tile.icon = Icon.createWithResource(this, R.drawable.ic_logo_service)
|
||||
qsTile.icon = Icon.createWithResource(this, R.drawable.ic_notification)
|
||||
|
||||
tile.updateTile()
|
||||
qsTile.updateTile()
|
||||
}
|
||||
|
||||
private val receiver = object : BroadcastReceiver() {
|
||||
private val clashStatusReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
when (intent?.action) {
|
||||
Intents.ACTION_CLASH_STARTED -> {
|
||||
Intents.INTENT_ACTION_CLASH_STARTED -> {
|
||||
clashRunning = true
|
||||
|
||||
currentProfile = ""
|
||||
}
|
||||
Intents.ACTION_CLASH_STOPPED, Intents.ACTION_SERVICE_RECREATED -> {
|
||||
Intents.INTENT_ACTION_CLASH_STOPPED -> {
|
||||
clashRunning = false
|
||||
|
||||
currentProfile = ""
|
||||
}
|
||||
Intents.ACTION_PROFILE_LOADED -> {
|
||||
currentProfile = StatusClient(this@TileService).currentProfile() ?: ""
|
||||
Intents.INTENT_ACTION_PROFILE_LOADED -> {
|
||||
currentProfile = RemoteUtils
|
||||
.getCurrentClashProfileName(this@TileService) ?: ""
|
||||
}
|
||||
}
|
||||
|
||||
updateTile()
|
||||
refreshStatus()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
registerReceiver(
|
||||
clashStatusReceiver,
|
||||
IntentFilter().apply {
|
||||
addAction(Intents.INTENT_ACTION_CLASH_STARTED)
|
||||
addAction(Intents.INTENT_ACTION_CLASH_STOPPED)
|
||||
addAction(Intents.INTENT_ACTION_PROFILE_LOADED)
|
||||
},
|
||||
Permissions.PERMISSION_RECEIVE_BROADCASTS,
|
||||
null
|
||||
)
|
||||
|
||||
val name = RemoteUtils.getCurrentClashProfileName(this)
|
||||
|
||||
clashRunning = name != null
|
||||
currentProfile = name ?: ""
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
|
||||
unregisterReceiver(clashStatusReceiver)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.github.kr328.clash.adapter
|
||||
|
||||
import android.content.Context
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import androidx.collection.CircularArray
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.github.kr328.clash.R
|
||||
import com.github.kr328.clash.core.event.LogEvent
|
||||
|
||||
class LiveLogAdapter(private val context: Context) : RecyclerView.Adapter<LogAdapter.Holder>() {
|
||||
companion object {
|
||||
const val MAX_LOG_ITEMS = 100
|
||||
}
|
||||
|
||||
private val circularArray = CircularArray<LogEvent>(MAX_LOG_ITEMS)
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): LogAdapter.Holder {
|
||||
return LogAdapter.Holder(
|
||||
LayoutInflater.from(context).inflate(
|
||||
R.layout.adapter_log,
|
||||
parent,
|
||||
false
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int {
|
||||
return circularArray.size()
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: LogAdapter.Holder, position: Int) {
|
||||
holder.bind(circularArray[position])
|
||||
}
|
||||
|
||||
fun insertItems(i: List<LogEvent>) {
|
||||
val items = if (i.size > MAX_LOG_ITEMS) {
|
||||
i.subList(i.size - MAX_LOG_ITEMS, i.size)
|
||||
} else i
|
||||
|
||||
val predictSize = items.size + circularArray.size()
|
||||
|
||||
if (predictSize > MAX_LOG_ITEMS) {
|
||||
val removeSize = predictSize - MAX_LOG_ITEMS
|
||||
notifyItemRangeRemoved(MAX_LOG_ITEMS - removeSize, removeSize)
|
||||
circularArray.removeFromEnd(removeSize)
|
||||
}
|
||||
|
||||
items.forEach {
|
||||
circularArray.addFirst(it)
|
||||
}
|
||||
|
||||
notifyItemRangeInserted(0, items.size)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.github.kr328.clash.adapter
|
||||
|
||||
import android.content.Context
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.TextView
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.github.kr328.clash.R
|
||||
import com.github.kr328.clash.core.event.LogEvent
|
||||
import com.github.kr328.clash.utils.format
|
||||
import java.util.*
|
||||
|
||||
class LogAdapter(
|
||||
private val context: Context,
|
||||
private val logs: List<LogEvent>
|
||||
) : RecyclerView.Adapter<LogAdapter.Holder>() {
|
||||
class Holder(view: View) : RecyclerView.ViewHolder(view) {
|
||||
private val level: TextView = view.findViewById(R.id.level)
|
||||
private val time: TextView = view.findViewById(R.id.time)
|
||||
private val payload: TextView = view.findViewById(R.id.payload)
|
||||
|
||||
fun bind(logEvent: LogEvent) {
|
||||
level.text = logEvent.level.toString()
|
||||
time.text = Date(logEvent.time).format(itemView.context, includeDate = false)
|
||||
payload.text = logEvent.message
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): Holder {
|
||||
return Holder(LayoutInflater.from(context).inflate(R.layout.adapter_log, parent, false))
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int {
|
||||
return logs.size
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: Holder, position: Int) {
|
||||
holder.bind(logs[position])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.github.kr328.clash.adapter
|
||||
|
||||
import android.content.Context
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.TextView
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.github.kr328.clash.R
|
||||
import com.github.kr328.clash.model.LogFile
|
||||
import com.github.kr328.clash.utils.format
|
||||
import java.util.*
|
||||
|
||||
class LogFileAdapter(
|
||||
private val context: Context,
|
||||
private val onItemClicked: (LogFile) -> Unit,
|
||||
private val onMenuClicked: (LogFile) -> Unit
|
||||
) : RecyclerView.Adapter<LogFileAdapter.Holder>() {
|
||||
var fileList: List<LogFile> = emptyList()
|
||||
|
||||
class Holder(view: View) : RecyclerView.ViewHolder(view) {
|
||||
val root: View = view.findViewById(R.id.root)
|
||||
val fileName: TextView = view.findViewById(R.id.fileName)
|
||||
val date: TextView = view.findViewById(R.id.date)
|
||||
val menu: View = view.findViewById(R.id.menu)
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): Holder {
|
||||
return Holder(
|
||||
LayoutInflater.from(context).inflate(
|
||||
R.layout.adapter_log_file,
|
||||
parent,
|
||||
false
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int {
|
||||
return fileList.size
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: Holder, position: Int) {
|
||||
val current = fileList[position]
|
||||
val date = Date(current.date)
|
||||
|
||||
holder.fileName.text = current.fileName
|
||||
holder.date.text = date.format(context)
|
||||
holder.menu.setOnClickListener {
|
||||
onMenuClicked(current)
|
||||
}
|
||||
holder.root.setOnClickListener {
|
||||
onItemClicked(current)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package com.github.kr328.clash.adapter
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.github.kr328.clash.R
|
||||
import com.google.android.material.checkbox.MaterialCheckBox
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlin.streams.toList
|
||||
|
||||
class PackagesAdapter(
|
||||
private val context: Context,
|
||||
private val apps: List<AppInfo>
|
||||
) :
|
||||
RecyclerView.Adapter<PackagesAdapter.Holder>() {
|
||||
data class AppInfo(
|
||||
val packageName: String, val label: String, val icon: Drawable,
|
||||
val installTime: Long, val updateTime: Long, val isSystem: Boolean
|
||||
)
|
||||
|
||||
enum class Sort {
|
||||
NAME, PACKAGE, INSTALL_TIME, UPDATE_TIME
|
||||
}
|
||||
|
||||
val selectedPackages: MutableSet<String> = mutableSetOf()
|
||||
|
||||
private var filteredCache: List<AppInfo> = apps
|
||||
|
||||
inner class Holder(view: View) : RecyclerView.ViewHolder(view) {
|
||||
val root: View = view.findViewById(R.id.root)
|
||||
val icon: ImageView = view.findViewById(R.id.icon)
|
||||
val label: TextView = view.findViewById(R.id.label)
|
||||
val packageName: TextView = view.findViewById(R.id.packageName)
|
||||
val checkbox: MaterialCheckBox = view.findViewById(R.id.checkbox)
|
||||
}
|
||||
|
||||
suspend fun applyFilter(keyword: String, sort: Sort, decrease: Boolean, systemApp: Boolean) {
|
||||
withContext(Dispatchers.Default) {
|
||||
val newList = apps.parallelStream()
|
||||
.filter {
|
||||
(it.label.contains(keyword, true)
|
||||
|| it.packageName.contains(keyword, true))
|
||||
&& (systemApp || !it.isSystem)
|
||||
}
|
||||
.sorted { a, b ->
|
||||
val sA = selectedPackages.contains(a.packageName)
|
||||
val sB = selectedPackages.contains(b.packageName)
|
||||
|
||||
if (sA != sB) {
|
||||
when {
|
||||
sA -> return@sorted -1
|
||||
sB -> return@sorted 1
|
||||
}
|
||||
}
|
||||
|
||||
val result = when (sort) {
|
||||
Sort.NAME -> a.label.compareTo(b.label, true)
|
||||
Sort.PACKAGE -> a.packageName.compareTo(b.packageName)
|
||||
Sort.INSTALL_TIME -> (a.installTime - b.installTime).toInt()
|
||||
Sort.UPDATE_TIME -> (a.updateTime - b.updateTime).toInt()
|
||||
}
|
||||
|
||||
if (decrease) -result else result
|
||||
}
|
||||
.toList()
|
||||
val oldList = filteredCache
|
||||
|
||||
val result = DiffUtil.calculateDiff(object : DiffUtil.Callback() {
|
||||
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
|
||||
return oldList[oldItemPosition].packageName == newList[newItemPosition].packageName
|
||||
}
|
||||
|
||||
override fun getOldListSize(): Int {
|
||||
return oldList.size
|
||||
}
|
||||
|
||||
override fun getNewListSize(): Int {
|
||||
return newList.size
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(
|
||||
oldItemPosition: Int,
|
||||
newItemPosition: Int
|
||||
): Boolean {
|
||||
return areItemsTheSame(oldItemPosition, newItemPosition) &&
|
||||
(selectedPackages.contains(oldList[oldItemPosition].packageName) ==
|
||||
selectedPackages.contains(newList[newItemPosition].packageName))
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
filteredCache = newList
|
||||
result.dispatchUpdatesTo(this@PackagesAdapter)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): Holder {
|
||||
return Holder(LayoutInflater.from(context).inflate(R.layout.adapter_package, parent, false))
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int {
|
||||
return filteredCache.size
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: Holder, position: Int) {
|
||||
val current = filteredCache[position]
|
||||
|
||||
holder.icon.setImageDrawable(current.icon)
|
||||
holder.label.text = current.label
|
||||
holder.packageName.text = current.packageName
|
||||
holder.checkbox.isChecked = selectedPackages.contains(current.packageName)
|
||||
holder.root.setOnClickListener {
|
||||
if (selectedPackages.contains(current.packageName)) {
|
||||
selectedPackages.remove(current.packageName)
|
||||
} else {
|
||||
selectedPackages.add(current.packageName)
|
||||
}
|
||||
|
||||
notifyItemChanged(filteredCache.indexOf(current))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package com.github.kr328.clash.adapter
|
||||
|
||||
import android.content.Context
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.RadioButton
|
||||
import android.widget.TextView
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.github.kr328.clash.R
|
||||
import com.github.kr328.clash.service.model.Profile
|
||||
import com.github.kr328.clash.utils.IntervalUtils
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class ProfileAdapter(private val context: Context, private val callback: Callback) :
|
||||
RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
||||
interface Callback {
|
||||
fun onProfileClicked(entity: Profile)
|
||||
fun onMenuClicked(entity: Profile)
|
||||
fun onNewProfile()
|
||||
}
|
||||
|
||||
private var entities: List<Profile> = emptyList()
|
||||
|
||||
class EntityHolder(view: View) : RecyclerView.ViewHolder(view) {
|
||||
val root: View = view.findViewById(R.id.root)
|
||||
val menu: View = view.findViewById(R.id.menu)
|
||||
val radio: RadioButton = view.findViewById(R.id.radio)
|
||||
val name: TextView = view.findViewById(R.id.name)
|
||||
val type: TextView = view.findViewById(R.id.type)
|
||||
val interval: TextView = view.findViewById(R.id.interval)
|
||||
}
|
||||
|
||||
class FooterHolder(view: View) : RecyclerView.ViewHolder(view) {
|
||||
val root: View = view.findViewById(R.id.root)
|
||||
}
|
||||
|
||||
suspend fun setEntitiesAsync(new: List<Profile>) {
|
||||
val old = withContext(Dispatchers.Main) {
|
||||
entities
|
||||
}
|
||||
|
||||
val result = withContext(Dispatchers.Default) {
|
||||
DiffUtil.calculateDiff(object : DiffUtil.Callback() {
|
||||
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean =
|
||||
old[oldItemPosition].id == new[newItemPosition].id
|
||||
|
||||
override fun areContentsTheSame(
|
||||
oldItemPosition: Int,
|
||||
newItemPosition: Int
|
||||
): Boolean = old[oldItemPosition] == new[newItemPosition]
|
||||
|
||||
override fun getOldListSize(): Int = old.size
|
||||
override fun getNewListSize(): Int = new.size
|
||||
}, false)
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
entities = new
|
||||
result.dispatchUpdatesTo(this@ProfileAdapter)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getItemViewType(position: Int): Int {
|
||||
return if (position == entities.size)
|
||||
Int.MAX_VALUE
|
||||
else
|
||||
super.getItemViewType(position)
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
|
||||
if (viewType == Int.MAX_VALUE) {
|
||||
return FooterHolder(
|
||||
LayoutInflater.from(context).inflate(
|
||||
R.layout.adapter_profile_footer,
|
||||
parent,
|
||||
false
|
||||
)
|
||||
)
|
||||
}
|
||||
return EntityHolder(
|
||||
LayoutInflater.from(context).inflate(
|
||||
R.layout.adapter_profile_entity,
|
||||
parent,
|
||||
false
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int {
|
||||
return entities.size + 1
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||
when (holder) {
|
||||
is EntityHolder -> {
|
||||
val current = entities[position]
|
||||
|
||||
holder.radio.isChecked = current.active
|
||||
holder.name.text = current.name
|
||||
holder.type.text = getTypeName(current.type)
|
||||
holder.interval.text = offsetDate(current.lastModified)
|
||||
|
||||
holder.root.setOnClickListener {
|
||||
callback.onProfileClicked(current)
|
||||
}
|
||||
holder.menu.setOnClickListener {
|
||||
callback.onMenuClicked(current)
|
||||
}
|
||||
}
|
||||
is FooterHolder -> {
|
||||
holder.root.setOnClickListener {
|
||||
callback.onNewProfile()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getTypeName(type: Profile.Type): CharSequence {
|
||||
return when (type) {
|
||||
Profile.Type.FILE ->
|
||||
context.getText(R.string.file)
|
||||
Profile.Type.URL ->
|
||||
context.getText(R.string.url)
|
||||
Profile.Type.EXTERNAL ->
|
||||
context.getText(R.string.external)
|
||||
else ->
|
||||
context.getText(R.string.unknown)
|
||||
}
|
||||
}
|
||||
|
||||
private fun offsetDate(date: Long): CharSequence {
|
||||
return IntervalUtils.intervalString(context, System.currentTimeMillis() - date)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
package com.github.kr328.clash.adapter
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Color
|
||||
import android.util.TypedValue
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.TextView
|
||||
import androidx.annotation.ColorInt
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.github.kr328.clash.R
|
||||
import com.google.android.material.card.MaterialCardView
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
|
||||
class ProxyAdapter(
|
||||
private val context: Context,
|
||||
val onSelect: (String, String) -> Unit,
|
||||
val onUrlTest: (String) -> Unit
|
||||
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
||||
companion object {
|
||||
const val DEFAULT_SPAN_COUNT = 2
|
||||
}
|
||||
|
||||
data class ProxyGroupInfo(
|
||||
val name: String,
|
||||
val current: String,
|
||||
val proxies: List<ProxyInfo>
|
||||
)
|
||||
|
||||
data class ProxyInfo(
|
||||
val name: String,
|
||||
val group: String,
|
||||
val title: String,
|
||||
val summary: String,
|
||||
val delay: Short,
|
||||
val selectable: Boolean,
|
||||
val active: Boolean
|
||||
)
|
||||
|
||||
interface RenderInfo {
|
||||
val name: String
|
||||
val group: String
|
||||
}
|
||||
|
||||
private data class ProxyGroupRenderInfo(val info: ProxyGroupInfo) :
|
||||
RenderInfo {
|
||||
override val name: String
|
||||
get() = info.name
|
||||
override val group: String
|
||||
get() = info.name
|
||||
}
|
||||
|
||||
private data class ProxyRenderInfo(val info: ProxyInfo) : RenderInfo {
|
||||
override val name: String
|
||||
get() = info.name
|
||||
override val group: String
|
||||
get() = info.group
|
||||
}
|
||||
|
||||
private var urlTesting: Set<String> = emptySet()
|
||||
private var renderList = mutableListOf<RenderInfo>()
|
||||
private var activeList: MutableMap<String, Int> = mutableMapOf()
|
||||
private var groupPosition: MutableMap<String, Int> = mutableMapOf()
|
||||
|
||||
@ColorInt
|
||||
private val colorSurface: Int
|
||||
|
||||
@ColorInt
|
||||
private val colorOnSurface: Int
|
||||
|
||||
init {
|
||||
val typedValue = TypedValue()
|
||||
|
||||
context.theme.resolveAttribute(R.attr.colorSurface, typedValue, true)
|
||||
colorSurface = typedValue.data
|
||||
|
||||
context.theme.resolveAttribute(R.attr.colorOnSurface, typedValue, true)
|
||||
colorOnSurface = typedValue.data
|
||||
}
|
||||
|
||||
val layoutManager = GridLayoutManager(context, DEFAULT_SPAN_COUNT).apply {
|
||||
spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() {
|
||||
override fun getSpanSize(position: Int): Int {
|
||||
val current = renderList.getOrNull(position)
|
||||
?: renderList.getOrNull(position) ?: return spanCount
|
||||
|
||||
return when (current) {
|
||||
is ProxyGroupRenderInfo -> spanCount
|
||||
is ProxyRenderInfo -> 1
|
||||
else -> throw IllegalArgumentException()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var root = listOf<ProxyGroupInfo>()
|
||||
|
||||
private class ProxyGroupHeader(view: View) : RecyclerView.ViewHolder(view) {
|
||||
val title: TextView = view.findViewById(R.id.name)
|
||||
val urlTest: View = view.findViewById(R.id.urlTest)
|
||||
val urlTestProgress: View = view.findViewById(R.id.urlTestProgress)
|
||||
}
|
||||
|
||||
private class ProxyItem(view: View) : RecyclerView.ViewHolder(view) {
|
||||
val root: MaterialCardView = view.findViewById(R.id.root)
|
||||
val prefix: TextView = view.findViewById(R.id.prefix)
|
||||
val content: TextView = view.findViewById(R.id.content)
|
||||
val delay: TextView = view.findViewById(R.id.delay)
|
||||
}
|
||||
|
||||
suspend fun applyChange(newList: List<ProxyGroupInfo>, testing: Set<String>) =
|
||||
withContext(Dispatchers.Default) {
|
||||
val newRenderList = newList
|
||||
.flatMap {
|
||||
listOf(ProxyGroupRenderInfo(it)) + it.proxies.map { p -> ProxyRenderInfo(p) }
|
||||
}
|
||||
|
||||
val oldRenderList = renderList
|
||||
|
||||
val result = DiffUtil.calculateDiff(object : DiffUtil.Callback() {
|
||||
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int) =
|
||||
oldRenderList[oldItemPosition]::class == newRenderList[newItemPosition]::class &&
|
||||
oldRenderList[oldItemPosition].name == newRenderList[newItemPosition].name
|
||||
|
||||
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int) =
|
||||
oldRenderList[oldItemPosition] == newRenderList[newItemPosition]
|
||||
|
||||
override fun getOldListSize(): Int = oldRenderList.size
|
||||
override fun getNewListSize(): Int = newRenderList.size
|
||||
})
|
||||
|
||||
val groupCache: MutableMap<String, Int> = mutableMapOf()
|
||||
val activeCache: MutableMap<String, Int> = mutableMapOf()
|
||||
|
||||
newRenderList.forEachIndexed { index, it ->
|
||||
when (it) {
|
||||
is ProxyGroupRenderInfo ->
|
||||
groupCache[it.name] = index
|
||||
is ProxyRenderInfo -> {
|
||||
if (it.info.active)
|
||||
activeCache[it.group] = index
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
root = newList
|
||||
renderList = newRenderList.toMutableList()
|
||||
urlTesting = testing
|
||||
groupPosition = groupCache
|
||||
activeList = activeCache
|
||||
result.dispatchUpdatesTo(this@ProxyAdapter)
|
||||
|
||||
groupCache.forEach { (_, u) ->
|
||||
notifyItemChanged(u)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getGroupPosition(name: String): Int {
|
||||
return groupPosition[name] ?: -1
|
||||
}
|
||||
|
||||
fun getCurrentGroup(): String {
|
||||
val position = layoutManager.findFirstCompletelyVisibleItemPosition()
|
||||
|
||||
if (position < 0)
|
||||
return ""
|
||||
|
||||
return renderList[position].group
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
|
||||
val layoutInflater = LayoutInflater.from(context)
|
||||
|
||||
return when (viewType) {
|
||||
1 -> ProxyGroupHeader(
|
||||
layoutInflater
|
||||
.inflate(R.layout.adapter_grid_proxy_group, parent, false)
|
||||
)
|
||||
2 -> ProxyItem(
|
||||
layoutInflater
|
||||
.inflate(R.layout.adapter_grid_proxy, parent, false)
|
||||
)
|
||||
else -> throw IllegalArgumentException()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||
when (holder) {
|
||||
is ProxyGroupHeader -> {
|
||||
val current = renderList[position] as ProxyGroupRenderInfo
|
||||
|
||||
holder.title.text = context.getString(
|
||||
R.string.format_proxy_group_title,
|
||||
current.info.name, current.info.current
|
||||
)
|
||||
holder.urlTest.setOnClickListener {
|
||||
holder.urlTest.visibility = View.GONE
|
||||
holder.urlTestProgress.visibility = View.VISIBLE
|
||||
|
||||
onUrlTest(current.name)
|
||||
}
|
||||
|
||||
if (urlTesting.contains(current.name)) {
|
||||
holder.urlTest.visibility = View.GONE
|
||||
holder.urlTestProgress.visibility = View.VISIBLE
|
||||
} else {
|
||||
holder.urlTest.visibility = View.VISIBLE
|
||||
holder.urlTestProgress.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
is ProxyItem -> {
|
||||
val current = renderList[position] as ProxyRenderInfo
|
||||
|
||||
holder.prefix.text = current.info.title
|
||||
holder.content.text = current.info.summary
|
||||
|
||||
if (current.info.delay > 0)
|
||||
holder.delay.text = current.info.delay.toString()
|
||||
else
|
||||
holder.delay.text = if (current.info.selectable) "" else "N/A"
|
||||
|
||||
if (current.info.active) {
|
||||
holder.prefix.setTextColor(Color.WHITE)
|
||||
holder.content.setTextColor(Color.WHITE)
|
||||
holder.delay.setTextColor(Color.WHITE)
|
||||
holder.root.setCardBackgroundColor(context.getColor(R.color.primaryCardColorStarted))
|
||||
} else {
|
||||
holder.prefix.setTextColor(colorOnSurface)
|
||||
holder.content.setTextColor(colorOnSurface)
|
||||
holder.delay.setTextColor(colorOnSurface)
|
||||
holder.root.setCardBackgroundColor(colorSurface)
|
||||
}
|
||||
|
||||
if (current.info.selectable) {
|
||||
holder.root.setOnClickListener {
|
||||
val oldPosition = activeList[current.group] ?: return@setOnClickListener
|
||||
val groupPosition =
|
||||
groupPosition[current.group] ?: return@setOnClickListener
|
||||
val old = renderList[oldPosition] as ProxyRenderInfo
|
||||
val new = renderList[position] as ProxyRenderInfo
|
||||
val group = renderList[groupPosition] as ProxyGroupRenderInfo
|
||||
|
||||
renderList[oldPosition] = old.copy(info = old.info.copy(active = false))
|
||||
renderList[position] = new.copy(info = new.info.copy(active = true))
|
||||
renderList[groupPosition] =
|
||||
group.copy(info = group.info.copy(current = current.name))
|
||||
|
||||
activeList[current.group] = position
|
||||
|
||||
notifyItemChanged(oldPosition)
|
||||
notifyItemChanged(position)
|
||||
notifyItemChanged(groupPosition)
|
||||
|
||||
onSelect(current.group, current.name)
|
||||
}
|
||||
holder.root.isClickable = true
|
||||
holder.root.isFocusable = true
|
||||
} else {
|
||||
holder.root.setOnClickListener(null)
|
||||
holder.root.isClickable = false
|
||||
holder.root.isFocusable = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int = renderList.size
|
||||
override fun getItemViewType(position: Int): Int {
|
||||
return when (renderList[position]) {
|
||||
is ProxyGroupRenderInfo -> 1
|
||||
is ProxyRenderInfo -> 2
|
||||
else -> throw IllegalArgumentException()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package com.github.kr328.clash.adapter
|
||||
|
||||
import android.animation.ValueAnimator
|
||||
import android.content.Context
|
||||
import android.graphics.Color
|
||||
import android.util.TypedValue
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.TextView
|
||||
import androidx.annotation.ColorInt
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.github.kr328.clash.R
|
||||
import com.google.android.material.card.MaterialCardView
|
||||
|
||||
class ProxyChipAdapter(
|
||||
private val context: Context,
|
||||
val onClick: (String) -> Unit
|
||||
) :
|
||||
RecyclerView.Adapter<ProxyChipAdapter.Holder>() {
|
||||
var chips = listOf<String>()
|
||||
var selected: String = ""
|
||||
set(value) {
|
||||
val lastIndex = chips.indexOf(field)
|
||||
val newIndex = chips.indexOf(value)
|
||||
|
||||
field = value
|
||||
|
||||
if (lastIndex >= 0)
|
||||
notifyItemChanged(lastIndex)
|
||||
if (newIndex >= 0)
|
||||
notifyItemChanged(newIndex)
|
||||
}
|
||||
|
||||
@ColorInt
|
||||
private val colorOnSurface: Int
|
||||
|
||||
init {
|
||||
val typedValue = TypedValue()
|
||||
|
||||
context.theme.resolveAttribute(R.attr.colorOnSurface, typedValue, true)
|
||||
colorOnSurface = typedValue.data
|
||||
}
|
||||
|
||||
class Holder(root: View) : RecyclerView.ViewHolder(root) {
|
||||
val card: MaterialCardView = root.findViewById(R.id.root)
|
||||
val title: TextView = root.findViewById(android.R.id.title)
|
||||
|
||||
private var cardAnimator: ValueAnimator? = null
|
||||
private var cardColor: Int = card.cardBackgroundColor.defaultColor
|
||||
private var titleAnimator: ValueAnimator? = null
|
||||
private var titleColor: Int = title.textColors.defaultColor
|
||||
|
||||
fun setCardColorAnimation(color: Int) {
|
||||
if (cardColor == color)
|
||||
return
|
||||
|
||||
cardAnimator?.cancel()
|
||||
|
||||
cardAnimator = ValueAnimator.ofArgb(cardColor, color).apply {
|
||||
addUpdateListener {
|
||||
val v = animatedValue as Int
|
||||
|
||||
card.setCardBackgroundColor(v)
|
||||
|
||||
cardColor = v
|
||||
}
|
||||
|
||||
duration = 200
|
||||
start()
|
||||
}
|
||||
}
|
||||
|
||||
fun setTitleColorAnimation(color: Int) {
|
||||
if (color == titleColor)
|
||||
return
|
||||
|
||||
titleAnimator?.cancel()
|
||||
|
||||
titleAnimator = ValueAnimator.ofArgb(titleColor, color).apply {
|
||||
addUpdateListener {
|
||||
val v = animatedValue as Int
|
||||
|
||||
title.setTextColor(v)
|
||||
|
||||
titleColor = v
|
||||
}
|
||||
|
||||
duration = 200
|
||||
start()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): Holder {
|
||||
val layoutInflater = LayoutInflater.from(context)
|
||||
|
||||
return Holder(layoutInflater.inflate(R.layout.adapter_proxies_chip, parent, false))
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int {
|
||||
return chips.size
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: Holder, position: Int) {
|
||||
val current = chips[position]
|
||||
|
||||
holder.title.text = current
|
||||
holder.card.setOnClickListener {
|
||||
onClick(current)
|
||||
}
|
||||
|
||||
if (selected == current) {
|
||||
holder.setTitleColorAnimation(Color.WHITE)
|
||||
holder.setCardColorAnimation(context.getColor(R.color.primaryCardColorStarted))
|
||||
} else {
|
||||
holder.setTitleColorAnimation(colorOnSurface)
|
||||
holder.setCardColorAnimation(context.getColor(R.color.chipBackgroundColor))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.github.kr328.clash.dump
|
||||
|
||||
object LogcatDumper {
|
||||
fun dump(): List<String> {
|
||||
return try {
|
||||
val process =
|
||||
Runtime.getRuntime().exec(arrayOf("logcat", "-d", "-s", "-v", "raw", "Go"))
|
||||
|
||||
val result = process.inputStream.bufferedReader().useLines {
|
||||
var list = mutableListOf<String>()
|
||||
var capture = false
|
||||
|
||||
it.forEach { line ->
|
||||
if (line.startsWith("panic")) {
|
||||
capture = true
|
||||
|
||||
list = mutableListOf()
|
||||
}
|
||||
|
||||
if (capture)
|
||||
list.add(line)
|
||||
}
|
||||
|
||||
list
|
||||
}
|
||||
|
||||
process.waitFor()
|
||||
|
||||
result
|
||||
} catch (e: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
package com.github.kr328.clash.fragment
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.text.Html
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.ViewGroup.LayoutParams
|
||||
import android.webkit.MimeTypeMap
|
||||
import android.webkit.URLUtil
|
||||
import androidx.fragment.app.Fragment
|
||||
import com.github.kr328.clash.Constants
|
||||
import com.github.kr328.clash.R
|
||||
import com.github.kr328.clash.design.common.TextInput
|
||||
import com.github.kr328.clash.design.view.CommonUiLayout
|
||||
import com.github.kr328.clash.service.model.Profile.Type
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
|
||||
class ProfileEditFragment(
|
||||
val id: Long,
|
||||
var name: String,
|
||||
var uri: Uri,
|
||||
var interval: Long,
|
||||
private val type: Type,
|
||||
private val source: String?
|
||||
) : Fragment() {
|
||||
var isModified = false
|
||||
|
||||
companion object {
|
||||
private const val REQUEST_CODE = 10000
|
||||
|
||||
private const val KEY_NAME = "name"
|
||||
private const val KEY_URL = "url"
|
||||
private const val KEY_AUTO_UPDATE = "auto_update"
|
||||
|
||||
private val TYPE_YAML = MimeTypeMap.getSingleton()
|
||||
.getMimeTypeFromExtension("yaml") ?: "*/*"
|
||||
}
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View? {
|
||||
return CommonUiLayout(requireContext()).apply {
|
||||
layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
|
||||
|
||||
build {
|
||||
tips(icon = requireContext().getDrawable(R.drawable.ic_info)) {
|
||||
title =
|
||||
Html.fromHtml(getString(R.string.tips_profile), Html.FROM_HTML_MODE_LEGACY)
|
||||
}
|
||||
|
||||
textInput(
|
||||
title = getString(R.string.name),
|
||||
icon = requireContext().getDrawable(R.drawable.ic_label_outline),
|
||||
hint = getString(R.string.profile_name),
|
||||
id = KEY_NAME,
|
||||
content = name
|
||||
) {
|
||||
onTextChanged {
|
||||
name = content.toString()
|
||||
isModified = true
|
||||
}
|
||||
}
|
||||
textInput(
|
||||
title = getString(R.string.url),
|
||||
icon = requireContext().getDrawable(R.drawable.ic_content),
|
||||
hint = getString(R.string.profile_url),
|
||||
id = KEY_URL,
|
||||
content = uri.toString()
|
||||
) {
|
||||
onOpenInput {
|
||||
if (!openUrlProvider())
|
||||
openDialogInput()
|
||||
}
|
||||
onDisplayContent {
|
||||
it.split("/").last()
|
||||
}
|
||||
onTextChanged {
|
||||
if (!URLUtil.isValidUrl(content.toString())) {
|
||||
content = ""
|
||||
Snackbar.make(view, R.string.invalid_url, Snackbar.LENGTH_LONG).show()
|
||||
return@onTextChanged
|
||||
}
|
||||
|
||||
uri = Uri.parse(content.toString())
|
||||
isModified = true
|
||||
}
|
||||
}
|
||||
textInput(
|
||||
title = getString(R.string.auto_update),
|
||||
icon = requireContext().getDrawable(R.drawable.ic_update),
|
||||
hint = getString(R.string.more_than_15_minutes),
|
||||
id = KEY_AUTO_UPDATE,
|
||||
content = (interval / 1000 / 60).toStringIfNonZero()
|
||||
) {
|
||||
onDisplayContent {
|
||||
val interval = it.toString().toIntOrNull() ?: 0
|
||||
|
||||
if (interval <= 0)
|
||||
getString(R.string.disabled)
|
||||
else
|
||||
getString(R.string.format_minutes, interval)
|
||||
}
|
||||
onTextChanged {
|
||||
val s = it.toString()
|
||||
|
||||
if (s.isBlank()) {
|
||||
content = ""
|
||||
return@onTextChanged
|
||||
}
|
||||
|
||||
val value = s.toIntOrNull()
|
||||
if (value == null || value < 15) {
|
||||
content = ""
|
||||
Snackbar.make(view, R.string.invalid_interval, Snackbar.LENGTH_LONG)
|
||||
.show()
|
||||
return@onTextChanged
|
||||
}
|
||||
|
||||
interval = content.toString().toLong() * 1000 * 60
|
||||
isModified = true
|
||||
}
|
||||
|
||||
if ( type == Type.FILE )
|
||||
isHidden = true
|
||||
}
|
||||
|
||||
screen.restoreState(savedInstanceState)
|
||||
|
||||
if (type == Type.EXTERNAL)
|
||||
openUrlProvider()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||
if (requestCode == REQUEST_CODE) {
|
||||
if (resultCode != Activity.RESULT_OK || data == null)
|
||||
return
|
||||
|
||||
val layout = view as CommonUiLayout
|
||||
|
||||
data.data?.apply {
|
||||
layout.screen.requireElement<TextInput>(KEY_URL).content = this.toString()
|
||||
}
|
||||
|
||||
data.getStringExtra(Constants.URL_PROVIDER_INTENT_EXTRA_NAME)?.also {
|
||||
layout.screen.requireElement<TextInput>(KEY_NAME).apply {
|
||||
if (content.isBlank())
|
||||
content = it
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
super.onActivityResult(requestCode, resultCode, data)
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
super.onSaveInstanceState(outState)
|
||||
|
||||
(view as CommonUiLayout?)?.screen?.saveState(outState)
|
||||
}
|
||||
|
||||
private fun openUrlProvider(): Boolean {
|
||||
try {
|
||||
when (type) {
|
||||
Type.FILE ->
|
||||
startActivityForResult(
|
||||
Intent(Intent.ACTION_GET_CONTENT).setType(TYPE_YAML),
|
||||
REQUEST_CODE
|
||||
)
|
||||
Type.EXTERNAL ->
|
||||
startActivityForResult(
|
||||
source?.toIntent() ?: return false,
|
||||
REQUEST_CODE
|
||||
)
|
||||
else -> return false
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Snackbar.make(
|
||||
view as ViewGroup,
|
||||
R.string.start_url_provider_failure,
|
||||
Snackbar.LENGTH_LONG
|
||||
).show()
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
private fun Long.toStringIfNonZero(): String {
|
||||
return if ( this == 0L ) "" else this.toString()
|
||||
}
|
||||
|
||||
private fun String.toIntent(): Intent? {
|
||||
return try {
|
||||
Intent.parseUri(this, 0)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
package com.github.kr328.clash.log
|
||||
|
||||
import androidx.collection.CircularArray
|
||||
import com.github.kr328.clash.core.model.LogMessage
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
|
||||
class LogcatCache {
|
||||
data class Snapshot(val messages: List<LogMessage>, val removed: Int, val appended: Int)
|
||||
|
||||
private val array = CircularArray<LogMessage>(CAPACITY)
|
||||
private val lock = Mutex()
|
||||
|
||||
private var removed: Int = 0
|
||||
private var appended: Int = 0
|
||||
|
||||
suspend fun append(msg: LogMessage) {
|
||||
lock.withLock {
|
||||
if (array.size() >= CAPACITY) {
|
||||
array.removeFromStart(1)
|
||||
|
||||
removed++
|
||||
appended--
|
||||
}
|
||||
|
||||
array.addLast(msg)
|
||||
|
||||
appended++
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun snapshot(full: Boolean): Snapshot? {
|
||||
return lock.withLock {
|
||||
if (!full && removed == 0 && appended == 0) {
|
||||
return@withLock null
|
||||
}
|
||||
|
||||
Snapshot(
|
||||
List(array.size()) { array[it] },
|
||||
removed,
|
||||
if (full) array.size() + appended else appended
|
||||
).also {
|
||||
removed = 0
|
||||
appended = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val CAPACITY = 128
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
package com.github.kr328.clash.log
|
||||
|
||||
import android.content.Context
|
||||
import com.github.kr328.clash.core.model.LogMessage
|
||||
import com.github.kr328.clash.design.util.format
|
||||
import java.io.BufferedWriter
|
||||
import java.io.Writer
|
||||
import java.util.*
|
||||
|
||||
class LogcatFilter(output: Writer, private val context: Context) : BufferedWriter(output) {
|
||||
fun writeHeader(time: Date) {
|
||||
appendLine("# Capture on ${time.format(context)}")
|
||||
}
|
||||
|
||||
fun writeMessage(message: LogMessage) {
|
||||
val time = message.time.format(context, includeDate = false)
|
||||
val level = message.level.name
|
||||
|
||||
appendLine(FORMAT.format(time, level, message.message))
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val FORMAT = "%12s %7s: %s"
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
package com.github.kr328.clash.log
|
||||
|
||||
import android.content.Context
|
||||
import com.github.kr328.clash.core.model.LogMessage
|
||||
import com.github.kr328.clash.design.model.LogFile
|
||||
import com.github.kr328.clash.util.logsDir
|
||||
import java.io.BufferedReader
|
||||
import java.io.FileReader
|
||||
import java.util.*
|
||||
|
||||
class LogcatReader(context: Context, file: LogFile) : AutoCloseable {
|
||||
private val reader = BufferedReader(FileReader(context.logsDir.resolve(file.fileName)))
|
||||
|
||||
override fun close() {
|
||||
reader.close()
|
||||
}
|
||||
|
||||
fun readAll(): List<LogMessage> {
|
||||
return reader.lineSequence()
|
||||
.map { it.trim() }
|
||||
.filter { !it.startsWith("#") }
|
||||
.map { it.split(":", limit = 3) }
|
||||
.map {
|
||||
LogMessage(
|
||||
time = Date(it[0].toLong()),
|
||||
level = LogMessage.Level.valueOf(it[1]),
|
||||
message = it[2]
|
||||
)
|
||||
}
|
||||
.toList()
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
package com.github.kr328.clash.log
|
||||
|
||||
import android.content.Context
|
||||
import com.github.kr328.clash.core.model.LogMessage
|
||||
import com.github.kr328.clash.design.model.LogFile
|
||||
import com.github.kr328.clash.util.logsDir
|
||||
import java.io.BufferedWriter
|
||||
import java.io.FileWriter
|
||||
|
||||
class LogcatWriter(context: Context) : AutoCloseable {
|
||||
private val file = LogFile.generate()
|
||||
private val writer = BufferedWriter(FileWriter(context.logsDir.resolve(file.fileName)))
|
||||
|
||||
override fun close() {
|
||||
writer.close()
|
||||
}
|
||||
|
||||
fun appendMessage(message: LogMessage) {
|
||||
writer.appendLine(FORMAT.format(message.time.time, message.level.name, message.message))
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val FORMAT = "%d:%s:%s"
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
package com.github.kr328.clash.log
|
||||
|
||||
object SystemLogcat {
|
||||
private val command = arrayOf(
|
||||
"logcat",
|
||||
"-d",
|
||||
"-s",
|
||||
"Go",
|
||||
"DEBUG",
|
||||
"AndroidRuntime",
|
||||
"ClashForAndroid",
|
||||
"LwIP",
|
||||
)
|
||||
|
||||
fun dumpCrash(): String {
|
||||
return try {
|
||||
val process = Runtime.getRuntime().exec(command)
|
||||
|
||||
val result = process.inputStream.use { stream ->
|
||||
stream.reader().readLines()
|
||||
.filterNot { it.startsWith("------") }
|
||||
.joinToString("\n")
|
||||
}
|
||||
|
||||
process.waitFor()
|
||||
|
||||
result.trim()
|
||||
} catch (e: Exception) {
|
||||
""
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.github.kr328.clash.model
|
||||
|
||||
data class LogFile(val fileName: String, val date: Long) {
|
||||
companion object {
|
||||
private val REGEX_FILE = Regex("clash-(\\d+).log")
|
||||
private const val FORMAT_FILE_NAME = "clash-%d.log"
|
||||
|
||||
fun parseFromFileName(fileName: String): LogFile? {
|
||||
return REGEX_FILE.matchEntire(fileName)?.run {
|
||||
LogFile(fileName, groupValues[1].toLong())
|
||||
}
|
||||
}
|
||||
|
||||
fun generate(date: Long = System.currentTimeMillis()): LogFile {
|
||||
val fileName = FORMAT_FILE_NAME.format(date)
|
||||
|
||||
return LogFile(fileName, date)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.github.kr328.clash.pipeline
|
||||
|
||||
import com.github.kr328.clash.common.settings.BaseSettings
|
||||
|
||||
data class Pipeline<T>(val input: T, val settings: BaseSettings)
|
||||
@@ -0,0 +1,100 @@
|
||||
package com.github.kr328.clash.pipeline
|
||||
|
||||
import com.github.kr328.clash.adapter.ProxyAdapter
|
||||
import com.github.kr328.clash.core.model.General
|
||||
import com.github.kr328.clash.core.model.Proxy
|
||||
import com.github.kr328.clash.core.model.ProxyGroup
|
||||
import com.github.kr328.clash.preference.UiSettings
|
||||
import com.github.kr328.clash.utils.PrefixMerger
|
||||
import com.github.kr328.clash.utils.ProxySorter
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
data class ProxyEntry(val group: String, val name: String)
|
||||
data class ProxyMerged(val prefix: String, val content: String)
|
||||
|
||||
suspend fun Pipeline<List<ProxyGroup>>.mergePrefix(): Pipeline<Map<ProxyEntry, ProxyMerged>> {
|
||||
if (!settings.get(UiSettings.PROXY_MERGE_PREFIX))
|
||||
return Pipeline(emptyMap(), settings)
|
||||
|
||||
val result = coroutineScope {
|
||||
input
|
||||
.map {
|
||||
async {
|
||||
it.name to PrefixMerger.merge(it.proxies, Proxy::name)
|
||||
}
|
||||
}
|
||||
.map {
|
||||
it.await()
|
||||
}
|
||||
.flatMap {
|
||||
it.second.map { merged ->
|
||||
ProxyEntry(it.first, merged.value.name) to ProxyMerged(
|
||||
merged.prefix,
|
||||
merged.content
|
||||
)
|
||||
}
|
||||
}
|
||||
.toMap()
|
||||
}
|
||||
|
||||
return Pipeline(result, settings)
|
||||
}
|
||||
|
||||
suspend fun Pipeline<List<ProxyGroup>>.sort(): Pipeline<List<ProxyGroup>> {
|
||||
val groupSort = when (settings.get(UiSettings.PROXY_GROUP_SORT)) {
|
||||
UiSettings.PROXY_SORT_DEFAULT ->
|
||||
ProxySorter.Order.DEFAULT
|
||||
UiSettings.PROXY_SORT_NAME ->
|
||||
ProxySorter.Order.NAME_INCREASE
|
||||
UiSettings.PROXY_SORT_DELAY ->
|
||||
ProxySorter.Order.DELAY_INCREASE
|
||||
else -> throw IllegalArgumentException()
|
||||
}
|
||||
|
||||
val proxySort = when (settings.get(UiSettings.PROXY_PROXY_SORT)) {
|
||||
UiSettings.PROXY_SORT_DEFAULT ->
|
||||
ProxySorter.Order.DEFAULT
|
||||
UiSettings.PROXY_SORT_NAME ->
|
||||
ProxySorter.Order.NAME_INCREASE
|
||||
UiSettings.PROXY_SORT_DELAY ->
|
||||
ProxySorter.Order.DELAY_INCREASE
|
||||
else -> throw IllegalArgumentException()
|
||||
}
|
||||
|
||||
val sorter = ProxySorter(groupSort, proxySort)
|
||||
|
||||
return copy(input = sorter.sort(input))
|
||||
}
|
||||
|
||||
suspend fun Pipeline<List<ProxyGroup>>.toAdapterElement(
|
||||
prefixMerged: Map<ProxyEntry, ProxyMerged>,
|
||||
general: General
|
||||
): List<ProxyAdapter.ProxyGroupInfo> {
|
||||
return input.map { group ->
|
||||
val proxies = group.proxies.map { proxy ->
|
||||
val merged = prefixMerged[ProxyEntry(group.name, proxy.name)]?.takeIf {
|
||||
it.prefix.isNotBlank() && it.content.isNotBlank()
|
||||
} ?: ProxyMerged(proxy.type.toString(), proxy.name)
|
||||
|
||||
ProxyAdapter.ProxyInfo(
|
||||
proxy.name, group.name, merged.content, merged.prefix,
|
||||
proxy.delay.toShort(), group.type == Proxy.Type.SELECT,
|
||||
group.current == proxy.name
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
ProxyAdapter.ProxyGroupInfo(group.name, group.current, proxies)
|
||||
}.let {
|
||||
withContext(Dispatchers.Default) {
|
||||
when (general.mode) {
|
||||
General.Mode.DIRECT -> emptyList()
|
||||
General.Mode.GLOBAL -> it
|
||||
General.Mode.RULE -> it.filter { it.name != "GLOBAL" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.github.kr328.clash.preference
|
||||
|
||||
import android.content.Context
|
||||
import com.github.kr328.clash.common.settings.BaseSettings
|
||||
|
||||
class UiSettings(context: Context) :
|
||||
BaseSettings(context.getSharedPreferences(FILE_NAME, Context.MODE_PRIVATE)) {
|
||||
companion object {
|
||||
private const val FILE_NAME = "ui"
|
||||
|
||||
const val PROXY_SORT_DEFAULT = "default"
|
||||
const val PROXY_SORT_NAME = "name"
|
||||
const val PROXY_SORT_DELAY = "delay"
|
||||
|
||||
const val DARK_MODE_AUTO = "auto"
|
||||
const val DARK_MODE_DARK = "dark"
|
||||
const val DARK_MODE_LIGHT = "light"
|
||||
|
||||
val PROXY_GROUP_SORT = StringEntry("proxy_group_sort", PROXY_SORT_DEFAULT)
|
||||
val PROXY_PROXY_SORT = StringEntry("proxy_proxy_sort", PROXY_SORT_DEFAULT)
|
||||
val PROXY_LAST_SELECT_GROUP = StringEntry("proxy_last_select_group", "")
|
||||
val PROXY_MERGE_PREFIX = BooleanEntry("proxy_merge_prefix", false)
|
||||
val LANGUAGE = StringEntry("language", "")
|
||||
val DARK_MODE = StringEntry("dark_mode", DARK_MODE_AUTO)
|
||||
}
|
||||
}
|
||||
@@ -5,12 +5,14 @@ import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import com.github.kr328.clash.common.constants.Intents
|
||||
import com.github.kr328.clash.common.log.Log
|
||||
import com.github.kr328.clash.common.Global
|
||||
import com.github.kr328.clash.common.Permissions
|
||||
import com.github.kr328.clash.common.ids.Intents
|
||||
import com.github.kr328.clash.common.utils.Log
|
||||
import com.github.kr328.clash.utils.ApplicationObserver
|
||||
|
||||
class Broadcasts(private val context: Application) {
|
||||
interface Observer {
|
||||
fun onServiceRecreated()
|
||||
object Broadcasts {
|
||||
interface Receiver {
|
||||
fun onStarted()
|
||||
fun onStopped(cause: String?)
|
||||
fun onProfileChanged()
|
||||
@@ -19,40 +21,32 @@ class Broadcasts(private val context: Application) {
|
||||
|
||||
var clashRunning: Boolean = false
|
||||
|
||||
private var registered = false
|
||||
private val receivers = mutableListOf<Observer>()
|
||||
private val receivers = mutableListOf<Receiver>()
|
||||
private val broadcastReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
if (intent?.`package` != context?.packageName)
|
||||
return
|
||||
|
||||
when (intent?.action) {
|
||||
Intents.ACTION_SERVICE_RECREATED -> {
|
||||
clashRunning = false
|
||||
|
||||
receivers.forEach {
|
||||
it.onServiceRecreated()
|
||||
}
|
||||
}
|
||||
Intents.ACTION_CLASH_STARTED -> {
|
||||
Intents.INTENT_ACTION_CLASH_STARTED -> {
|
||||
clashRunning = true
|
||||
|
||||
receivers.forEach {
|
||||
it.onStarted()
|
||||
}
|
||||
}
|
||||
Intents.ACTION_CLASH_STOPPED -> {
|
||||
Intents.INTENT_ACTION_CLASH_STOPPED -> {
|
||||
clashRunning = false
|
||||
|
||||
receivers.forEach {
|
||||
it.onStopped(intent.getStringExtra(Intents.EXTRA_STOP_REASON))
|
||||
it.onStopped(intent.getStringExtra(Intents.INTENT_EXTRA_CLASH_STOP_REASON))
|
||||
}
|
||||
}
|
||||
Intents.ACTION_PROFILE_CHANGED ->
|
||||
Intents.INTENT_ACTION_PROFILE_CHANGED ->
|
||||
receivers.forEach {
|
||||
it.onProfileChanged()
|
||||
}
|
||||
Intents.ACTION_PROFILE_LOADED -> {
|
||||
Intents.INTENT_ACTION_PROFILE_LOADED -> {
|
||||
receivers.forEach {
|
||||
it.onProfileLoaded()
|
||||
}
|
||||
@@ -61,43 +55,45 @@ class Broadcasts(private val context: Application) {
|
||||
}
|
||||
}
|
||||
|
||||
fun addObserver(observer: Observer) {
|
||||
receivers.add(observer)
|
||||
fun register(receiver: Receiver) {
|
||||
receivers.add(receiver)
|
||||
}
|
||||
|
||||
fun removeObserver(observer: Observer) {
|
||||
receivers.remove(observer)
|
||||
fun unregister(receiver: Receiver) {
|
||||
receivers.remove(receiver)
|
||||
}
|
||||
|
||||
fun register() {
|
||||
if (registered)
|
||||
return
|
||||
private val observer = ApplicationObserver {
|
||||
Log.d("Global Broadcast Receiver State = $it")
|
||||
|
||||
try {
|
||||
context.registerReceiver(broadcastReceiver, IntentFilter().apply {
|
||||
addAction(Intents.ACTION_SERVICE_RECREATED)
|
||||
addAction(Intents.ACTION_CLASH_STARTED)
|
||||
addAction(Intents.ACTION_CLASH_STOPPED)
|
||||
addAction(Intents.ACTION_PROFILE_CHANGED)
|
||||
addAction(Intents.ACTION_PROFILE_LOADED)
|
||||
})
|
||||
if ( it ) {
|
||||
Global.application.registerReceiver(broadcastReceiver, IntentFilter().apply {
|
||||
addAction(Intents.INTENT_ACTION_PROFILE_CHANGED)
|
||||
addAction(Intents.INTENT_ACTION_CLASH_STOPPED)
|
||||
addAction(Intents.INTENT_ACTION_CLASH_STARTED)
|
||||
addAction(Intents.INTENT_ACTION_PROFILE_LOADED)
|
||||
}, Permissions.PERMISSION_RECEIVE_BROADCASTS, null)
|
||||
|
||||
clashRunning = StatusClient(context).currentProfile() != null
|
||||
} catch (e: Exception) {
|
||||
Log.w("Register global receiver: $e", e)
|
||||
val current = RemoteUtils.detectClashRunning(Global.application)
|
||||
if (current != clashRunning) {
|
||||
clashRunning = current
|
||||
|
||||
if (current) {
|
||||
receivers.forEach { receiver ->
|
||||
receiver.onStarted()
|
||||
}
|
||||
} else {
|
||||
receivers.forEach { receiver ->
|
||||
receiver.onStopped(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Global.application.unregisterReceiver(broadcastReceiver)
|
||||
}
|
||||
}
|
||||
|
||||
fun unregister() {
|
||||
if (!registered)
|
||||
return
|
||||
|
||||
try {
|
||||
context.unregisterReceiver(broadcastReceiver)
|
||||
|
||||
clashRunning = false
|
||||
} catch (e: Exception) {
|
||||
Log.w("Unregister global receiver: $e", e)
|
||||
}
|
||||
fun init(application: Application) {
|
||||
observer.register(application)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.github.kr328.clash.remote
|
||||
|
||||
suspend fun <T> withClash(block: suspend ClashClient.() -> T): T {
|
||||
val client = Remote.clash.receive()
|
||||
|
||||
return client.block()
|
||||
}
|
||||
|
||||
suspend fun <T> withProfile(block: suspend ProfileClient.() -> T): T {
|
||||
val client = Remote.profile.receive()
|
||||
|
||||
return client.block()
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.github.kr328.clash.remote
|
||||
|
||||
import android.os.RemoteException
|
||||
import com.github.kr328.clash.core.model.General
|
||||
import com.github.kr328.clash.core.model.ProxyGroup
|
||||
import com.github.kr328.clash.service.IClashManager
|
||||
import com.github.kr328.clash.service.transact.IStreamCallback
|
||||
import com.github.kr328.clash.service.transact.ParcelableContainer
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class ClashClient(val service: IClashManager) {
|
||||
suspend fun setSelectProxy(name: String, proxy: String): Boolean = withContext(Dispatchers.IO) {
|
||||
service.setSelectProxy(name, proxy)
|
||||
}
|
||||
|
||||
suspend fun startHealthCheck(group: String) = withContext(Dispatchers.IO) {
|
||||
CompletableDeferred<Unit>().apply {
|
||||
service.startHealthCheck(group, object : IStreamCallback.Stub() {
|
||||
override fun complete() {
|
||||
this@apply.complete(Unit)
|
||||
}
|
||||
|
||||
override fun completeExceptionally(reason: String?) {
|
||||
this@apply.completeExceptionally(RemoteException(reason))
|
||||
}
|
||||
|
||||
override fun send(data: ParcelableContainer?) {}
|
||||
})
|
||||
}
|
||||
}.await()
|
||||
|
||||
suspend fun queryAllProxyGroups(): List<ProxyGroup> = withContext(Dispatchers.IO) {
|
||||
service.queryAllProxies().list
|
||||
}
|
||||
|
||||
suspend fun queryGeneral(): General = withContext(Dispatchers.IO) {
|
||||
service.queryGeneral()
|
||||
}
|
||||
|
||||
suspend fun queryBandwidth(): Long = withContext(Dispatchers.IO) {
|
||||
service.queryBandwidth()
|
||||
}
|
||||
|
||||
suspend fun setProxyMode(mode: General.Mode) = withContext(Dispatchers.IO) {
|
||||
service.setProxyMode(mode.toString())
|
||||
}
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
@file:Suppress("BlockingMethodInNonBlockingContext")
|
||||
|
||||
package com.github.kr328.clash.remote
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import com.github.kr328.clash.common.constants.Authorities
|
||||
import com.github.kr328.clash.design.model.File
|
||||
import com.github.kr328.clash.util.copyContentTo
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import android.provider.DocumentsContract as DC
|
||||
|
||||
class FilesClient(private val context: Context) {
|
||||
suspend fun list(parentDocumentId: String): List<File> = withContext(Dispatchers.IO) {
|
||||
val uri = DC.buildChildDocumentsUri(Authorities.FILES_PROVIDER, parentDocumentId)
|
||||
|
||||
context.contentResolver.query(uri, FilesProjection, null, null, null)?.use { cursor ->
|
||||
val idIndex = cursor.getColumnIndex(DC.Document.COLUMN_DOCUMENT_ID)
|
||||
val nameIndex = cursor.getColumnIndex(DC.Document.COLUMN_DISPLAY_NAME)
|
||||
val sizeIndex = cursor.getColumnIndex(DC.Document.COLUMN_SIZE)
|
||||
val lastModified = cursor.getColumnIndex(DC.Document.COLUMN_LAST_MODIFIED)
|
||||
val mimeTypeIndex = cursor.getColumnIndex(DC.Document.COLUMN_MIME_TYPE)
|
||||
|
||||
cursor.moveToFirst()
|
||||
|
||||
List(cursor.count) {
|
||||
File(
|
||||
id = cursor.getString(idIndex),
|
||||
name = cursor.getString(nameIndex),
|
||||
size = cursor.getLong(sizeIndex),
|
||||
lastModified = cursor.getLong(lastModified),
|
||||
isDirectory = cursor.getString(mimeTypeIndex) == DC.Document.MIME_TYPE_DIR,
|
||||
).also {
|
||||
cursor.moveToNext()
|
||||
}
|
||||
}.sortedWith(compareBy({ !it.isDirectory }, { it.name }))
|
||||
} ?: emptyList()
|
||||
}
|
||||
|
||||
suspend fun renameDocument(documentId: String, name: String) = withContext(Dispatchers.IO) {
|
||||
val uri = buildDocumentUri(documentId)
|
||||
|
||||
DC.renameDocument(context.contentResolver, uri, name)
|
||||
}
|
||||
|
||||
suspend fun deleteDocument(documentId: String) = withContext(Dispatchers.IO) {
|
||||
val uri = buildDocumentUri(documentId)
|
||||
|
||||
DC.deleteDocument(context.contentResolver, uri)
|
||||
}
|
||||
|
||||
suspend fun importDocument(
|
||||
parentDocumentId: String,
|
||||
source: Uri,
|
||||
name: String
|
||||
) = withContext(Dispatchers.IO) {
|
||||
val target = buildDocumentUri("$parentDocumentId/$name")
|
||||
|
||||
context.contentResolver.copyContentTo(source, target)
|
||||
}
|
||||
|
||||
suspend fun copyDocument(
|
||||
documentId: String,
|
||||
source: Uri
|
||||
) {
|
||||
val target = buildDocumentUri(documentId)
|
||||
|
||||
context.contentResolver.copyContentTo(source, target)
|
||||
}
|
||||
|
||||
suspend fun copyDocument(
|
||||
target: Uri,
|
||||
documentId: String
|
||||
) {
|
||||
val source = buildDocumentUri(documentId)
|
||||
|
||||
context.contentResolver.copyContentTo(source, target)
|
||||
}
|
||||
|
||||
fun buildDocumentUri(documentId: String): Uri {
|
||||
return DC.buildDocumentUri(Authorities.FILES_PROVIDER, documentId)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val FilesProjection = arrayOf(
|
||||
DC.Document.COLUMN_DOCUMENT_ID,
|
||||
DC.Document.COLUMN_DISPLAY_NAME,
|
||||
DC.Document.COLUMN_SIZE,
|
||||
DC.Document.COLUMN_LAST_MODIFIED,
|
||||
DC.Document.COLUMN_MIME_TYPE,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.github.kr328.clash.remote
|
||||
|
||||
import android.os.RemoteException
|
||||
import com.github.kr328.clash.service.IProfileService
|
||||
import com.github.kr328.clash.service.transact.IStreamCallback
|
||||
import com.github.kr328.clash.service.transact.ParcelableContainer
|
||||
import com.github.kr328.clash.service.model.Profile
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class ProfileClient(private val service: IProfileService) {
|
||||
suspend fun acquireUnused(type: Profile.Type, source: String?) = withContext(Dispatchers.IO) {
|
||||
service.acquireUnused(type.name, source)
|
||||
}
|
||||
|
||||
suspend fun acquireCloned(id: Long) = withContext(Dispatchers.IO) {
|
||||
service.acquireCloned(id)
|
||||
}
|
||||
|
||||
suspend fun acquireTempUri(id: Long): String? = withContext(Dispatchers.IO) {
|
||||
service.acquireTempUri(id)
|
||||
}
|
||||
|
||||
suspend fun update(id: Long, metadata: Profile) = withContext(Dispatchers.IO) {
|
||||
service.update(id, metadata)
|
||||
}
|
||||
|
||||
suspend fun commitAsync(id: Long) = withContext(Dispatchers.IO) {
|
||||
CompletableDeferred<Unit>().apply {
|
||||
service.commit(id, object : IStreamCallback.Stub() {
|
||||
override fun complete() {
|
||||
complete(Unit)
|
||||
}
|
||||
|
||||
override fun completeExceptionally(reason: String?) {
|
||||
completeExceptionally(RemoteException(reason))
|
||||
}
|
||||
|
||||
override fun send(data: ParcelableContainer?) {}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun release(id: Long) = withContext(Dispatchers.IO) {
|
||||
service.release(id)
|
||||
}
|
||||
|
||||
suspend fun delete(id: Long) = withContext(Dispatchers.IO) {
|
||||
service.delete(id)
|
||||
}
|
||||
|
||||
suspend fun clear(id: Long) = withContext(Dispatchers.IO) {
|
||||
service.clear(id)
|
||||
}
|
||||
|
||||
suspend fun queryAll(): Array<Profile> = withContext(Dispatchers.IO) {
|
||||
service.queryAll()
|
||||
}
|
||||
|
||||
suspend fun queryActive(): Profile? = withContext(Dispatchers.IO) {
|
||||
service.queryActive()
|
||||
}
|
||||
|
||||
suspend fun queryById(id: Long): Profile? = withContext(Dispatchers.IO) {
|
||||
service.queryById(id)
|
||||
}
|
||||
|
||||
suspend fun setActive(id: Long) = withContext(Dispatchers.IO) {
|
||||
service.setActive(id)
|
||||
}
|
||||
}
|
||||
@@ -1,71 +1,185 @@
|
||||
package com.github.kr328.clash.remote
|
||||
|
||||
import android.app.Application
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.ServiceConnection
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.IBinder
|
||||
import androidx.core.content.edit
|
||||
import com.github.kr328.clash.ApkBrokenActivity
|
||||
import com.github.kr328.clash.AppCrashedActivity
|
||||
import com.github.kr328.clash.Constants
|
||||
import com.github.kr328.clash.common.Global
|
||||
import com.github.kr328.clash.common.util.intent
|
||||
import com.github.kr328.clash.store.AppStore
|
||||
import com.github.kr328.clash.util.ApplicationObserver
|
||||
import com.github.kr328.clash.util.verifyApk
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import com.github.kr328.clash.common.utils.intent
|
||||
import com.github.kr328.clash.common.utils.Log
|
||||
import com.github.kr328.clash.service.ClashManagerService
|
||||
import com.github.kr328.clash.service.IClashManager
|
||||
import com.github.kr328.clash.service.IProfileService
|
||||
import com.github.kr328.clash.service.ProfileService
|
||||
import com.github.kr328.clash.utils.ApplicationObserver
|
||||
import com.microsoft.appcenter.crashes.Crashes
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
import java.util.zip.ZipFile
|
||||
|
||||
object Remote {
|
||||
val broadcasts: Broadcasts = Broadcasts(Global.application)
|
||||
val service: Service = Service(Global.application) {
|
||||
ApplicationObserver.createdActivities.forEach { it.finish() }
|
||||
var clash = Channel<ClashClient>()
|
||||
var profile = Channel<ProfileClient>()
|
||||
|
||||
val intent = AppCrashedActivity::class.intent
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
private var clashConnection: ClashConnection? = null
|
||||
private var profileConnection: ProfileConnection? = null
|
||||
|
||||
Global.application.startActivity(intent)
|
||||
}
|
||||
class ClashConnection : ServiceConnection {
|
||||
private var instance: ClashClient? = null
|
||||
private var sender: Job? = null
|
||||
|
||||
private val visible = Channel<Boolean>(Channel.CONFLATED)
|
||||
|
||||
fun launch() {
|
||||
ApplicationObserver.attach(Global.application)
|
||||
|
||||
ApplicationObserver.onVisibleChanged { visible.trySend(it) }
|
||||
|
||||
Global.launch(Dispatchers.IO) {
|
||||
run()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun run() {
|
||||
val context = Global.application
|
||||
val store = AppStore(context)
|
||||
val updatedAt = getLastUpdated(context)
|
||||
|
||||
if (store.updatedAt != updatedAt) {
|
||||
if (!context.verifyApk()) {
|
||||
ApplicationObserver.createdActivities.forEach { it.finish() }
|
||||
|
||||
val intent = ApkBrokenActivity::class.intent
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
|
||||
return context.startActivity(intent)
|
||||
} else {
|
||||
store.updatedAt = updatedAt
|
||||
}
|
||||
override fun onServiceDisconnected(name: ComponentName?) {
|
||||
sender?.cancel()
|
||||
instance = null
|
||||
sender = null
|
||||
}
|
||||
|
||||
while (true) {
|
||||
if (visible.receive()) {
|
||||
service.bind()
|
||||
broadcasts.register()
|
||||
} else {
|
||||
service.unbind()
|
||||
broadcasts.unregister()
|
||||
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
|
||||
if (service != null)
|
||||
instance = ClashClient(IClashManager.Stub.asInterface(service))
|
||||
|
||||
service?.linkToDeath({ onServiceDisconnected(null) }, 0)
|
||||
|
||||
sender = GlobalScope.launch {
|
||||
while (isActive) {
|
||||
val client = instance ?: return@launch
|
||||
clash.send(client)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getLastUpdated(context: Context): Long {
|
||||
return context.packageManager.getPackageInfo(context.packageName, 0).lastUpdateTime
|
||||
class ProfileConnection : ServiceConnection {
|
||||
private var instance: ProfileClient? = null
|
||||
private var sender: Job? = null
|
||||
|
||||
override fun onServiceDisconnected(name: ComponentName?) {
|
||||
sender?.cancel()
|
||||
instance = null
|
||||
sender = null
|
||||
}
|
||||
|
||||
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
|
||||
if (service != null)
|
||||
instance = ProfileClient(IProfileService.Stub.asInterface(service))
|
||||
|
||||
service?.linkToDeath({ onServiceDisconnected(null) }, 0)
|
||||
|
||||
sender = GlobalScope.launch {
|
||||
while (isActive) {
|
||||
val client = instance ?: return@launch
|
||||
profile.send(client)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val handler = Handler()
|
||||
private val observer = ApplicationObserver {
|
||||
Log.d("Remote Connection State = $it")
|
||||
|
||||
val application = Global.application
|
||||
|
||||
if ( it ) {
|
||||
handler.removeMessages(0)
|
||||
|
||||
GlobalScope.launch {
|
||||
val valid = withContext(Dispatchers.IO) {
|
||||
verifyApk(application)
|
||||
}
|
||||
|
||||
if (!valid) {
|
||||
application.startActivity(
|
||||
ApkBrokenActivity::class.intent
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
)
|
||||
return@launch
|
||||
}
|
||||
|
||||
clashConnection = ClashConnection().apply {
|
||||
application.bindService(
|
||||
ClashManagerService::class.intent,
|
||||
this,
|
||||
Context.BIND_AUTO_CREATE
|
||||
)
|
||||
}
|
||||
|
||||
profileConnection = ProfileConnection().apply {
|
||||
application.bindService(
|
||||
ProfileService::class.intent,
|
||||
this,
|
||||
Context.BIND_AUTO_CREATE
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
handler.postDelayed({
|
||||
clashConnection?.also {
|
||||
application.unbindService(it)
|
||||
it.onServiceDisconnected(null)
|
||||
}
|
||||
profileConnection?.also {
|
||||
application.unbindService(it)
|
||||
it.onServiceDisconnected(null)
|
||||
}
|
||||
|
||||
clashConnection = null
|
||||
profileConnection = null
|
||||
}, 5000)
|
||||
}
|
||||
}
|
||||
|
||||
fun init(application: Application) {
|
||||
observer.register(application)
|
||||
}
|
||||
|
||||
private fun verifyApk(application: Application): Boolean {
|
||||
return try {
|
||||
val sp = application.getSharedPreferences(
|
||||
Constants.PREFERENCE_NAME_APP,
|
||||
Context.MODE_PRIVATE
|
||||
)
|
||||
val pkg = application.packageManager.getPackageInfo(application.packageName, 0)
|
||||
|
||||
if (sp.getLong(Constants.PREFERENCE_KEY_LAST_INSTALL, 0) == pkg.lastUpdateTime)
|
||||
return true
|
||||
|
||||
val info = application.applicationInfo
|
||||
val sources =
|
||||
info.splitSourceDirs ?: arrayOf(info.sourceDir) ?: return false
|
||||
|
||||
val regexNativeLibrary = Regex("lib/(\\S+)/libgojni.so")
|
||||
val availableAbi = Build.SUPPORTED_ABIS.toSet()
|
||||
val apkAbi =
|
||||
sources
|
||||
.asSequence()
|
||||
.filter { File(it).exists() }
|
||||
.flatMap { ZipFile(it).entries().asSequence() }
|
||||
.mapNotNull { regexNativeLibrary.matchEntire(it.name) }
|
||||
.mapNotNull { it.groups[1]?.value }
|
||||
.toSet()
|
||||
|
||||
if (availableAbi.intersect(apkAbi).isNotEmpty()) {
|
||||
sp.edit {
|
||||
putLong(Constants.PREFERENCE_KEY_LAST_INSTALL, pkg.lastUpdateTime)
|
||||
}
|
||||
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Crashes.trackError(e)
|
||||
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.github.kr328.clash.remote
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import com.github.kr328.clash.ApkBrokenActivity
|
||||
import com.github.kr328.clash.common.utils.intent
|
||||
import com.github.kr328.clash.service.Constants
|
||||
import com.github.kr328.clash.service.ServiceStatusProvider
|
||||
|
||||
object RemoteUtils {
|
||||
fun detectClashRunning(context: Context): Boolean {
|
||||
try {
|
||||
val authority = Uri.Builder()
|
||||
.scheme("content")
|
||||
.authority("${context.packageName}${Constants.STATUS_PROVIDER_SUFFIX}")
|
||||
.build()
|
||||
|
||||
val pong = context.contentResolver.call(
|
||||
authority,
|
||||
ServiceStatusProvider.METHOD_PING_CLASH_SERVICE,
|
||||
null,
|
||||
null
|
||||
)
|
||||
|
||||
return pong != null
|
||||
} catch (e: IllegalArgumentException) {
|
||||
context.startActivity(ApkBrokenActivity::class.intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
fun getCurrentClashProfileName(context: Context): String? {
|
||||
try {
|
||||
val authority = Uri.Builder()
|
||||
.scheme("content")
|
||||
.authority("${context.packageName}${Constants.STATUS_PROVIDER_SUFFIX}")
|
||||
.build()
|
||||
|
||||
val pong = context.contentResolver.call(
|
||||
authority,
|
||||
ServiceStatusProvider.METHOD_PING_CLASH_SERVICE,
|
||||
null,
|
||||
null
|
||||
)
|
||||
|
||||
return pong?.getString("name")
|
||||
} catch (e: IllegalArgumentException) {
|
||||
context.startActivity(ApkBrokenActivity::class.intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
|
||||
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
package com.github.kr328.clash.remote
|
||||
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
class Resource<T> {
|
||||
private interface Callback<T> {
|
||||
fun accept(value: T)
|
||||
}
|
||||
|
||||
private val pending: MutableSet<Callback<T>> = mutableSetOf()
|
||||
|
||||
private var value: T? = null
|
||||
|
||||
suspend fun get(): T {
|
||||
return suspendCancellableCoroutine { ctx ->
|
||||
val callback = object : Callback<T> {
|
||||
override fun accept(value: T) {
|
||||
ctx.resume(value)
|
||||
}
|
||||
}
|
||||
|
||||
ctx.invokeOnCancellation {
|
||||
cancel(callback)
|
||||
}
|
||||
|
||||
get(callback)
|
||||
}
|
||||
}
|
||||
|
||||
fun set(v: T?) {
|
||||
setAndNotify(v)
|
||||
}
|
||||
|
||||
fun reset(v: T) {
|
||||
resetIfMatched(v)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun get(callback: Callback<T>) {
|
||||
val v = value
|
||||
|
||||
if (v == null) {
|
||||
pending.add(callback)
|
||||
} else {
|
||||
callback.accept(v)
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun setAndNotify(value: T?) {
|
||||
this.value = value
|
||||
|
||||
if (value != null) {
|
||||
pending.forEach {
|
||||
it.accept(value)
|
||||
}
|
||||
|
||||
pending.clear()
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun resetIfMatched(value: T) {
|
||||
if (this.value === value) {
|
||||
this.value = null
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun cancel(callback: Callback<T>) {
|
||||
pending.remove(callback)
|
||||
}
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
package com.github.kr328.clash.remote
|
||||
|
||||
import android.app.Application
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.ServiceConnection
|
||||
import android.os.IBinder
|
||||
import com.github.kr328.clash.Tracker
|
||||
import com.github.kr328.clash.common.log.Log
|
||||
import com.github.kr328.clash.common.util.intent
|
||||
import com.github.kr328.clash.log.SystemLogcat
|
||||
import com.github.kr328.clash.service.RemoteService
|
||||
import com.github.kr328.clash.service.remote.IRemoteService
|
||||
import com.github.kr328.clash.service.remote.unwrap
|
||||
import com.github.kr328.clash.util.unbindServiceSilent
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class Service(private val context: Application, val crashed: () -> Unit) {
|
||||
val remote = Resource<IRemoteService>()
|
||||
|
||||
private val connection = object : ServiceConnection {
|
||||
private var lastCrashed: Long = -1
|
||||
|
||||
override fun onServiceConnected(name: ComponentName?, service: IBinder) {
|
||||
remote.set(service.unwrap(IRemoteService::class))
|
||||
}
|
||||
|
||||
override fun onServiceDisconnected(name: ComponentName?) {
|
||||
remote.set(null)
|
||||
|
||||
Tracker.uploadLogcat(SystemLogcat.dumpCrash())
|
||||
|
||||
if (System.currentTimeMillis() - lastCrashed < TOGGLE_CRASHED_INTERVAL) {
|
||||
unbind()
|
||||
|
||||
crashed()
|
||||
}
|
||||
|
||||
lastCrashed = System.currentTimeMillis()
|
||||
|
||||
Log.w("RemoteManager crashed")
|
||||
}
|
||||
}
|
||||
|
||||
fun bind() {
|
||||
try {
|
||||
context.bindService(RemoteService::class.intent, connection, Context.BIND_AUTO_CREATE)
|
||||
} catch (e: Exception) {
|
||||
unbind()
|
||||
|
||||
crashed()
|
||||
}
|
||||
}
|
||||
|
||||
fun unbind() {
|
||||
context.unbindServiceSilent(connection)
|
||||
|
||||
remote.set(null)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TOGGLE_CRASHED_INTERVAL = TimeUnit.SECONDS.toMillis(10)
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
package com.github.kr328.clash.remote
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import com.github.kr328.clash.common.constants.Authorities
|
||||
import com.github.kr328.clash.common.log.Log
|
||||
import com.github.kr328.clash.service.StatusProvider
|
||||
|
||||
class StatusClient(private val context: Context) {
|
||||
private val uri: Uri
|
||||
get() {
|
||||
return Uri.Builder()
|
||||
.scheme("content")
|
||||
.authority(Authorities.STATUS_PROVIDER)
|
||||
.build()
|
||||
}
|
||||
|
||||
fun currentProfile(): String? {
|
||||
return try {
|
||||
val result = context.contentResolver.call(
|
||||
uri,
|
||||
StatusProvider.METHOD_CURRENT_PROFILE,
|
||||
null,
|
||||
null
|
||||
)
|
||||
|
||||
result?.getString("name")
|
||||
} catch (e: Exception) {
|
||||
Log.w("Query current profile: $e", e)
|
||||
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.github.kr328.clash.settings
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import com.github.kr328.clash.preference.UiSettings
|
||||
import com.github.kr328.clash.service.settings.ServiceSettings
|
||||
import moe.shizuku.preference.PreferenceFragment
|
||||
|
||||
abstract class BaseSettingFragment : PreferenceFragment() {
|
||||
abstract fun onCreateDataStore(): SettingsDataStore
|
||||
abstract val xmlResourceId: Int
|
||||
|
||||
protected val service: ServiceSettings by lazy { ServiceSettings(requireActivity()) }
|
||||
protected val ui: UiSettings by lazy { UiSettings(requireActivity()) }
|
||||
|
||||
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||
preferenceManager.preferenceDataStore = onCreateDataStore()
|
||||
|
||||
setPreferencesFromResource(xmlResourceId, rootKey)
|
||||
}
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View? {
|
||||
val result = super.onCreateView(inflater, container, savedInstanceState)
|
||||
|
||||
setDivider(null)
|
||||
setDividerHeight(0)
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.github.kr328.clash.settings
|
||||
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Bundle
|
||||
import com.github.kr328.clash.R
|
||||
import com.github.kr328.clash.common.utils.componentName
|
||||
import com.github.kr328.clash.remote.Broadcasts
|
||||
import com.github.kr328.clash.service.RestartReceiver
|
||||
import com.github.kr328.clash.service.settings.ServiceSettings
|
||||
|
||||
class BehaviorFragment : BaseSettingFragment() {
|
||||
companion object {
|
||||
private const val KEY_START_ON_BOOT = "start_on_boot"
|
||||
private const val KEY_SHOW_TRAFFIC = "show_traffic"
|
||||
}
|
||||
|
||||
override fun onCreateDataStore(): SettingsDataStore {
|
||||
return SettingsDataStore().apply {
|
||||
on(KEY_START_ON_BOOT, StartOnBootSource())
|
||||
on(KEY_SHOW_TRAFFIC, ServiceSettings.NOTIFICATION_REFRESH.asSource(service))
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
findPreference(KEY_SHOW_TRAFFIC).isEnabled = !Broadcasts.clashRunning
|
||||
}
|
||||
|
||||
override val xmlResourceId: Int
|
||||
get() = R.xml.settings_behavior
|
||||
|
||||
private inner class StartOnBootSource : SettingsDataStore.Source {
|
||||
override fun set(value: Any?) {
|
||||
val v = value as Boolean? ?: return
|
||||
|
||||
val status = if (v)
|
||||
PackageManager.COMPONENT_ENABLED_STATE_ENABLED
|
||||
else
|
||||
PackageManager.COMPONENT_ENABLED_STATE_DISABLED
|
||||
|
||||
requireActivity().packageManager.setComponentEnabledSetting(
|
||||
RestartReceiver::class.componentName,
|
||||
status,
|
||||
PackageManager.DONT_KILL_APP
|
||||
)
|
||||
}
|
||||
|
||||
override fun get(): Any? {
|
||||
val status = requireActivity().packageManager
|
||||
.getComponentEnabledSetting(RestartReceiver::class.componentName)
|
||||
|
||||
return status == PackageManager.COMPONENT_ENABLED_STATE_ENABLED
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.github.kr328.clash.settings
|
||||
|
||||
import com.github.kr328.clash.R
|
||||
import com.github.kr328.clash.preference.UiSettings
|
||||
import com.github.kr328.clash.service.settings.ServiceSettings
|
||||
|
||||
class InterfaceFragment : BaseSettingFragment() {
|
||||
companion object {
|
||||
private const val KEY_DARK_MODE = "dark_mode"
|
||||
private const val KEY_LANGUAGE = "language"
|
||||
}
|
||||
|
||||
override fun onCreateDataStore(): SettingsDataStore {
|
||||
return SettingsDataStore().apply {
|
||||
on(KEY_DARK_MODE, UiSettings.DARK_MODE.asSource(ui))
|
||||
on(KEY_LANGUAGE, UiSettings.LANGUAGE.asSource(ui))
|
||||
|
||||
onApply {
|
||||
service.commit {
|
||||
put(ServiceSettings.LANGUAGE, ui.get(UiSettings.LANGUAGE))
|
||||
}
|
||||
|
||||
requireActivity().recreate()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override val xmlResourceId: Int
|
||||
get() = R.xml.settings_interface
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.github.kr328.clash.settings
|
||||
|
||||
import android.os.Bundle
|
||||
import com.github.kr328.clash.PackagesActivity
|
||||
import com.github.kr328.clash.R
|
||||
import com.github.kr328.clash.common.utils.intent
|
||||
import com.github.kr328.clash.remote.Broadcasts
|
||||
import com.github.kr328.clash.service.settings.ServiceSettings
|
||||
|
||||
class NetworkFragment : BaseSettingFragment() {
|
||||
companion object {
|
||||
private const val KEY_ENABLE_VPN_SERVICE = "enable_vpn_service"
|
||||
private const val BYPASS_PRIVATE_NETWORK = "bypass_private_network"
|
||||
private const val KEY_DNS_HIJACKING = "dns_hijacking"
|
||||
private const val KEY_DNS_OVERRIDE = "dns_override"
|
||||
private const val KEY_APPEND_SYS_DNS = "append_system_dns"
|
||||
private const val KEY_ACCESS_CONTROL_MODE = "access_control_mode"
|
||||
private const val KEY_ACCESS_CONTROL_PACKAGES = "access_control_packages"
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
preferenceScreen.isEnabled = !Broadcasts.clashRunning
|
||||
|
||||
findPreference(KEY_ACCESS_CONTROL_PACKAGES).setOnPreferenceClickListener {
|
||||
startActivity(PackagesActivity::class.intent)
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateDataStore(): SettingsDataStore {
|
||||
return SettingsDataStore().apply {
|
||||
on(KEY_ENABLE_VPN_SERVICE, ServiceSettings.ENABLE_VPN.asSource(service))
|
||||
on(BYPASS_PRIVATE_NETWORK, ServiceSettings.BYPASS_PRIVATE_NETWORK.asSource(service))
|
||||
on(KEY_DNS_HIJACKING, ServiceSettings.DNS_HIJACKING.asSource(service))
|
||||
on(KEY_DNS_OVERRIDE, ServiceSettings.OVERRIDE_DNS.asSource(service))
|
||||
on(KEY_APPEND_SYS_DNS, ServiceSettings.AUTO_ADD_SYSTEM_DNS.asSource(service))
|
||||
on(KEY_ACCESS_CONTROL_MODE, ServiceSettings.ACCESS_CONTROL_MODE.asSource(service))
|
||||
}
|
||||
}
|
||||
|
||||
override val xmlResourceId: Int
|
||||
get() = R.xml.settings_network
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
package com.github.kr328.clash.settings
|
||||
|
||||
import com.github.kr328.clash.common.settings.BaseSettings
|
||||
import moe.shizuku.preference.PreferenceDataStore
|
||||
|
||||
class SettingsDataStore : PreferenceDataStore() {
|
||||
interface Source {
|
||||
fun set(value: Any?)
|
||||
fun get(): Any?
|
||||
}
|
||||
|
||||
private val sources: MutableMap<String, Source> = mutableMapOf()
|
||||
var applyListener: () -> Unit = {}
|
||||
|
||||
fun on(key: String, source: Source) {
|
||||
sources[key] = source
|
||||
}
|
||||
|
||||
fun onApply(block: () -> Unit) {
|
||||
this.applyListener = block
|
||||
}
|
||||
|
||||
inline fun <reified T> BaseSettings.Entry<T>.asSource(settings: BaseSettings): Source {
|
||||
return object : Source {
|
||||
override fun set(value: Any?) {
|
||||
val v = value ?: throw NullPointerException()
|
||||
|
||||
settings.commit {
|
||||
put(this@asSource, v as T)
|
||||
}
|
||||
|
||||
applyListener()
|
||||
}
|
||||
|
||||
override fun get(): Any? {
|
||||
return settings.get(this@asSource)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun getBoolean(key: String?, defValue: Boolean): Boolean {
|
||||
val source = sources[key] ?: return defValue
|
||||
|
||||
return (source.get() as Boolean?) ?: defValue
|
||||
}
|
||||
|
||||
override fun putLong(key: String?, value: Long) {
|
||||
val source = sources[key] ?: throw NullPointerException()
|
||||
|
||||
source.set(value)
|
||||
}
|
||||
|
||||
override fun putInt(key: String?, value: Int) {
|
||||
val source = sources[key] ?: throw NullPointerException()
|
||||
|
||||
source.set(value)
|
||||
}
|
||||
|
||||
override fun getInt(key: String?, defValue: Int): Int {
|
||||
val source = sources[key] ?: return defValue
|
||||
|
||||
return (source.get() as Int?) ?: defValue
|
||||
}
|
||||
|
||||
override fun putBoolean(key: String?, value: Boolean) {
|
||||
val source = sources[key] ?: throw NullPointerException()
|
||||
|
||||
source.set(value)
|
||||
}
|
||||
|
||||
override fun getLong(key: String?, defValue: Long): Long {
|
||||
val source = sources[key] ?: return defValue
|
||||
|
||||
return (source.get() as Long?) ?: defValue
|
||||
}
|
||||
|
||||
override fun getFloat(key: String?, defValue: Float): Float {
|
||||
val source = sources[key] ?: return defValue
|
||||
|
||||
return (source.get() as Float?) ?: defValue
|
||||
}
|
||||
|
||||
override fun putFloat(key: String?, value: Float) {
|
||||
val source = sources[key] ?: throw NullPointerException()
|
||||
|
||||
source.set(value)
|
||||
}
|
||||
|
||||
override fun getString(key: String?, defValue: String?): String? {
|
||||
val source = sources[key] ?: return defValue
|
||||
|
||||
return (source.get() as String?) ?: defValue
|
||||
}
|
||||
|
||||
override fun putString(key: String?, value: String?) {
|
||||
val source = sources[key] ?: throw NullPointerException()
|
||||
|
||||
source.set(value)
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
package com.github.kr328.clash.store
|
||||
|
||||
import android.content.Context
|
||||
import com.github.kr328.clash.common.store.Store
|
||||
import com.github.kr328.clash.common.store.asStoreProvider
|
||||
|
||||
class AppStore(context: Context) {
|
||||
private val store = Store(
|
||||
context
|
||||
.getSharedPreferences(FILE_NAME, Context.MODE_PRIVATE)
|
||||
.asStoreProvider()
|
||||
)
|
||||
|
||||
var updatedAt: Long by store.long(
|
||||
key = "updated_at",
|
||||
defaultValue = -1,
|
||||
)
|
||||
|
||||
companion object {
|
||||
private const val FILE_NAME = "app"
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
package com.github.kr328.clash.store
|
||||
|
||||
import android.content.Context
|
||||
import com.github.kr328.clash.common.store.Store
|
||||
import com.github.kr328.clash.common.store.asStoreProvider
|
||||
|
||||
class TipsStore(context: Context) {
|
||||
private val store = Store(
|
||||
context
|
||||
.getSharedPreferences(FILE_NAME, Context.MODE_PRIVATE)
|
||||
.asStoreProvider()
|
||||
)
|
||||
|
||||
var requestDonate: Boolean by store.boolean(
|
||||
key = "request_donate",
|
||||
defaultValue = true,
|
||||
)
|
||||
|
||||
var primaryVersion: Int by store.int(
|
||||
key = "primary_version",
|
||||
defaultValue = -1,
|
||||
)
|
||||
|
||||
companion object {
|
||||
const val CURRENT_PRIMARY_VERSION = 1
|
||||
|
||||
private const val FILE_NAME = "tips"
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
package com.github.kr328.clash.util
|
||||
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.LifecycleRegistry
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class ActivityResultLifecycle : LifecycleOwner {
|
||||
private val lifecycle = LifecycleRegistry(this)
|
||||
|
||||
init {
|
||||
lifecycle.currentState = Lifecycle.State.INITIALIZED
|
||||
}
|
||||
|
||||
override fun getLifecycle(): Lifecycle {
|
||||
return lifecycle
|
||||
}
|
||||
|
||||
suspend fun <T> use(block: suspend (lifecycle: ActivityResultLifecycle, start: () -> Unit) -> T): T {
|
||||
return try {
|
||||
markCreated()
|
||||
|
||||
block(this, this::markStarted)
|
||||
} finally {
|
||||
withContext(NonCancellable) {
|
||||
markDestroy()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun markCreated() {
|
||||
lifecycle.currentState = Lifecycle.State.CREATED
|
||||
}
|
||||
|
||||
private fun markStarted() {
|
||||
lifecycle.currentState = Lifecycle.State.STARTED
|
||||
lifecycle.currentState = Lifecycle.State.RESUMED
|
||||
}
|
||||
|
||||
private fun markDestroy() {
|
||||
lifecycle.currentState = Lifecycle.State.DESTROYED
|
||||
}
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
package com.github.kr328.clash.util
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import java.io.File
|
||||
import java.util.zip.ZipFile
|
||||
|
||||
object ApplicationObserver {
|
||||
private val activities: MutableSet<Activity> = mutableSetOf()
|
||||
|
||||
private var visibleChanged: (Boolean) -> Unit = {}
|
||||
|
||||
private var appVisible = false
|
||||
private set(value) {
|
||||
if (field != value) {
|
||||
field = value
|
||||
|
||||
visibleChanged(value)
|
||||
}
|
||||
}
|
||||
|
||||
val createdActivities: Set<Activity>
|
||||
get() = activities
|
||||
|
||||
private val activityObserver = object : Application.ActivityLifecycleCallbacks {
|
||||
@Synchronized
|
||||
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {
|
||||
activities.add(activity)
|
||||
|
||||
appVisible = true
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun onActivityDestroyed(activity: Activity) {
|
||||
activities.remove(activity)
|
||||
|
||||
appVisible = activities.isNotEmpty()
|
||||
}
|
||||
|
||||
override fun onActivityStarted(activity: Activity) {}
|
||||
override fun onActivityStopped(activity: Activity) {}
|
||||
override fun onActivityPaused(activity: Activity) {}
|
||||
override fun onActivityResumed(activity: Activity) {}
|
||||
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {}
|
||||
}
|
||||
|
||||
fun onVisibleChanged(visibleChanged: (Boolean) -> Unit) {
|
||||
this.visibleChanged = visibleChanged
|
||||
}
|
||||
|
||||
fun attach(application: Application) {
|
||||
application.registerActivityLifecycleCallbacks(activityObserver)
|
||||
}
|
||||
}
|
||||
|
||||
fun Context.verifyApk(): Boolean {
|
||||
return try {
|
||||
val info = applicationInfo
|
||||
val sources = info.splitSourceDirs ?: arrayOf(info.sourceDir) ?: return false
|
||||
|
||||
val regexNativeLibrary = Regex("lib/(\\S+)/libclash.so")
|
||||
val availableAbi = Build.SUPPORTED_ABIS.toSet()
|
||||
val apkAbi = sources
|
||||
.asSequence()
|
||||
.filter { File(it).exists() }
|
||||
.flatMap { ZipFile(it).entries().asSequence() }
|
||||
.mapNotNull { regexNativeLibrary.matchEntire(it.name) }
|
||||
.mapNotNull { it.groups[1]?.value }
|
||||
.toSet()
|
||||
|
||||
availableAbi.intersect(apkAbi).isNotEmpty()
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
package com.github.kr328.clash.util
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.net.Uri
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.FileNotFoundException
|
||||
|
||||
private fun fileNotFound(file: Uri): FileNotFoundException {
|
||||
return FileNotFoundException("$file not found")
|
||||
}
|
||||
|
||||
@Suppress("BlockingMethodInNonBlockingContext")
|
||||
suspend fun ContentResolver.copyContentTo(
|
||||
source: Uri,
|
||||
target: Uri
|
||||
) {
|
||||
withContext(Dispatchers.IO) {
|
||||
(openInputStream(source) ?: throw fileNotFound(source)).use { input ->
|
||||
(openOutputStream(target, "rwt") ?: throw fileNotFound(target)).use { output ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
package com.github.kr328.clash.util
|
||||
|
||||
import android.content.Context
|
||||
import java.io.File
|
||||
|
||||
val Context.logsDir: File
|
||||
get() = cacheDir.resolve("logs")
|
||||
@@ -1,46 +0,0 @@
|
||||
package com.github.kr328.clash.util
|
||||
|
||||
import android.os.DeadObjectException
|
||||
import com.github.kr328.clash.common.log.Log
|
||||
import com.github.kr328.clash.remote.Remote
|
||||
import com.github.kr328.clash.service.remote.IClashManager
|
||||
import com.github.kr328.clash.service.remote.IProfileManager
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
|
||||
suspend fun <T> withClash(
|
||||
context: CoroutineContext = Dispatchers.IO,
|
||||
block: suspend IClashManager.() -> T
|
||||
): T {
|
||||
while (true) {
|
||||
val remote = Remote.service.remote.get()
|
||||
val client = remote.clash()
|
||||
|
||||
try {
|
||||
return withContext(context) { client.block() }
|
||||
} catch (e: DeadObjectException) {
|
||||
Log.w("Remote services panic")
|
||||
|
||||
Remote.service.remote.reset(remote)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun <T> withProfile(
|
||||
context: CoroutineContext = Dispatchers.IO,
|
||||
block: suspend IProfileManager.() -> T
|
||||
): T {
|
||||
while (true) {
|
||||
val remote = Remote.service.remote.get()
|
||||
val client = remote.profile()
|
||||
|
||||
try {
|
||||
return withContext(context) { client.block() }
|
||||
} catch (e: DeadObjectException) {
|
||||
Log.w("Remote services panic")
|
||||
|
||||
Remote.service.remote.reset(remote)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
package com.github.kr328.clash.util
|
||||
|
||||
import android.content.Context
|
||||
import android.content.ServiceConnection
|
||||
|
||||
fun Context.unbindServiceSilent(connection: ServiceConnection) {
|
||||
try {
|
||||
unbindService(connection)
|
||||
} catch (e: Exception) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
package com.github.kr328.clash.util
|
||||
|
||||
import android.net.Uri
|
||||
|
||||
val Uri.fileName: String?
|
||||
get() = schemeSpecificPart.split("/").lastOrNull()
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.github.kr328.clash.utils
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.Application
|
||||
import android.os.Bundle
|
||||
|
||||
class ApplicationObserver(val stateChanged: (Boolean) -> Unit) {
|
||||
private var applicationRunning = false
|
||||
private set(value) {
|
||||
if ( field != value )
|
||||
stateChanged(value)
|
||||
|
||||
field = value
|
||||
}
|
||||
private var activityCount: Int = 0
|
||||
|
||||
private val activityObserver = object: Application.ActivityLifecycleCallbacks {
|
||||
override fun onActivityPaused(activity: Activity) {}
|
||||
override fun onActivityStarted(activity: Activity) {}
|
||||
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {}
|
||||
override fun onActivityStopped(activity: Activity) {}
|
||||
override fun onActivityResumed(activity: Activity) {}
|
||||
override fun onActivityDestroyed(activity: Activity) {
|
||||
synchronized(this) {
|
||||
activityCount--
|
||||
applicationRunning = activityCount > 0
|
||||
}
|
||||
}
|
||||
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {
|
||||
synchronized(this) {
|
||||
activityCount++
|
||||
applicationRunning = activityCount > 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun register(application: Application) {
|
||||
application.registerActivityLifecycleCallbacks(activityObserver)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.github.kr328.clash.utils
|
||||
|
||||
import android.content.Context
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
const val DATE_DATE_ONLY = "yyyy-MM-dd"
|
||||
const val DATE_TIME_ONLY = "HH:mm:ss"
|
||||
const val DATE_ALL = "$DATE_DATE_ONLY $DATE_TIME_ONLY"
|
||||
|
||||
fun Date.format(
|
||||
context: Context,
|
||||
includeDate: Boolean = true,
|
||||
includeTime: Boolean = true,
|
||||
custom: String = ""
|
||||
): String {
|
||||
val locale = context.resources.configuration.locales[0]
|
||||
|
||||
return when {
|
||||
custom.isNotEmpty() ->
|
||||
SimpleDateFormat(custom, locale).format(this)
|
||||
includeDate && includeTime ->
|
||||
SimpleDateFormat(DATE_ALL, locale).format(this)
|
||||
includeDate ->
|
||||
SimpleDateFormat(DATE_DATE_ONLY, locale).format(this)
|
||||
includeTime ->
|
||||
SimpleDateFormat(DATE_TIME_ONLY, locale).format(this)
|
||||
else -> ""
|
||||
}
|
||||
}
|
||||
Loaded 100 of 658 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user