Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
821bb49914 | ||
|
|
c95993a629 | ||
|
|
e6495f5e1f | ||
|
|
c104942b5f | ||
|
|
59835667aa | ||
|
|
8a98eea8fa | ||
|
|
3dc20b9e70 | ||
|
|
d8ab6fd755 | ||
|
|
cc499a7897 | ||
|
|
76f5261b20 | ||
|
|
6a506b761f | ||
|
|
fe966f9c5d | ||
|
|
bcf7f793ec | ||
|
|
75de6ce041 | ||
|
|
4417a85ef7 | ||
|
|
9e0b67c04a | ||
|
|
8cd203e7b0 | ||
|
|
211160f7f8 | ||
|
|
2afc7262eb | ||
|
|
94a0282c70 | ||
|
|
96b78829ab | ||
|
|
35d5a90b2a | ||
|
|
50d73ad498 | ||
|
|
d1f8bc3062 | ||
|
|
d3353b1aa5 | ||
|
|
cd48fb4e08 | ||
|
|
f70738ecf5 | ||
|
|
2d63466597 | ||
|
|
ecf67443fb | ||
|
|
43987ad111 | ||
|
|
5fbd6c6196 | ||
|
|
1ec7f1f7e9 | ||
|
|
c5104c241a | ||
|
|
021ba3519d | ||
|
|
119f74cd05 | ||
|
|
f0b61c9e1f | ||
|
|
a573631068 | ||
|
|
a12444b258 | ||
|
|
17257228a9 | ||
|
|
53b6a9dfc1 | ||
|
|
c45f8ddfc3 | ||
|
|
e04c9c15ff | ||
|
|
d5cb363579 | ||
|
|
0748021ffa | ||
|
|
1d21368385 | ||
|
|
2a5865397a | ||
|
|
f5f378f3b0 | ||
|
|
861d5fa871 | ||
|
|
4518825aca | ||
|
|
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 | ||
|
|
e958e7f675 | ||
|
|
68113e694e | ||
|
|
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
|
||||
@@ -0,0 +1,51 @@
|
||||
---
|
||||
name: "[English] Bug report"
|
||||
about: Create a report to help us improve
|
||||
title: "[BUG] "
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
<!-- Be sure to put a clear title after [BUG] in the text box above -->
|
||||
<!-- Be sure to put a clear title after [BUG] in the text box above -->
|
||||
<!-- Be sure to put a clear title after [BUG] in the text box above -->
|
||||
|
||||
**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.
|
||||
|
||||
**Device Info (please complete the following information):**
|
||||
|
||||
- Device: [e.g. Pixel 4]
|
||||
- ROM: [e.g: AOSP]
|
||||
- ROM Version:
|
||||
- Android Version [e.g. 10]
|
||||
|
||||
**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.
|
||||
|
||||
<!--
|
||||
*Logs*
|
||||
if applicable, upload logs to help detect problem
|
||||
|
||||
`Open App` -> `Support` -> `Feedback` -> `Upload Logcat`
|
||||
-->
|
||||
@@ -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,24 @@
|
||||
---
|
||||
name: "[English] Feature request"
|
||||
about: Suggest an idea for this app
|
||||
title: "[Feature Request] "
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
<!-- Be sure to put a clear title after [Feature Request] in the text box above -->
|
||||
<!-- Be sure to put a clear title after [Feature Request] in the text box above -->
|
||||
<!-- Be sure to put a clear title after [Feature Request] in the text box above -->
|
||||
|
||||
**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,49 @@
|
||||
---
|
||||
name: "[简体中文] 创建错误报告"
|
||||
about: 创建错误报告以帮助我们改进应用
|
||||
title: "[BUG] "
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
<!-- 请务必在上方文本框处 [BUG] 后填入清晰明了的标题 -->
|
||||
<!-- 请务必在上方文本框处 [BUG] 后填入清晰明了的标题 -->
|
||||
<!-- 请务必在上方文本框处 [BUG] 后填入清晰明了的标题 -->
|
||||
|
||||
**描述出现的错误**
|
||||
请简洁的描述你遇到的错误
|
||||
|
||||
**如何复现该错误**
|
||||
复现步骤:
|
||||
1. ...
|
||||
2. ...
|
||||
3. ...
|
||||
4. ...
|
||||
|
||||
**预期行为**
|
||||
清晰简单的描述你预期的应用应该表现的行为
|
||||
|
||||
**屏幕截图**
|
||||
如果适用, 上传屏幕截图以帮助描述错误
|
||||
|
||||
**设备信息 (请完成以下信息):**
|
||||
- 机型: [例如: Pixel 4]
|
||||
- 系统/ROM: [例如: MIUI 11]
|
||||
- Android 版本 [例如: 10]
|
||||
- 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,21 @@
|
||||
---
|
||||
name: "[简体中文] 功能请求"
|
||||
about: 你希望的能够在应用中增加的功能
|
||||
title: "[Feature Request] "
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
<!-- 请务必在上方文本框处 [Feature Request] 后填入清晰明了的标题 -->
|
||||
<!-- 请务必在上方文本框处 [Feature Request] 后填入清晰明了的标题 -->
|
||||
<!-- 请务必在上方文本框处 [Feature Request] 后填入清晰明了的标题 -->
|
||||
|
||||
**功能描述**
|
||||
请清晰的描述你想要的功能
|
||||
|
||||
**描述你希望的实现方式**
|
||||
清晰的描述应用应该如何实现该功能
|
||||
|
||||
**附加信息**
|
||||
其他的与改功能相关的附加信息
|
||||
@@ -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,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)
|
||||
|
||||
+14
-5
@@ -4,19 +4,24 @@ The Clash for Android is built as an Open Source software. This app is provided
|
||||
|
||||
This page is used to inform visitors regarding our policies with the collection, use, and disclosure of Personal Information if anyone decided to use our app.
|
||||
|
||||
If you choose to use our app, then you agree to the collection and use of information in relation to this policy. The Personal Information that we collect is used for providing and improving the app. We will not use or share your information with anyone except as described in this Privacy Policy.
|
||||
|
||||
The terms used in this Privacy Policy have the same meanings as in our Terms and Conditions, which is accessible at Clash for Android unless otherwise defined in this Privacy Policy.
|
||||
|
||||
**Information Collection and Use**
|
||||
|
||||
We will not upload any of your personally information and that will be stored in the internal storage or memory.
|
||||
For a better experience, while using our app, we may require you to provide us with certain personally identifiable information. The information that we request will be retained by us and used as described in this privacy policy.
|
||||
|
||||
We collect the following information and store it in memory, and such information will be destroyed when the application is fully exited.
|
||||
The app does use third party services that may collect information used to identify you.
|
||||
|
||||
- Installed Applications
|
||||
Link to privacy policy of third party service providers used by the app
|
||||
|
||||
This data is used for the PROCESS-NAME rule.
|
||||
* [Google Play Services](https://www.google.com/policies/privacy/)
|
||||
* [AppCenter](https://docs.microsoft.com/en-us/appcenter/gdpr/)
|
||||
|
||||
**Log Data**
|
||||
|
||||
We do not collect log data unless you use log collector.
|
||||
We want to inform you that whenever you use our app, in a case of an error in the app we collect data and information (through third party products) on your phone called Log Data. This Log Data may include information such as your device Internet Protocol (“IP”) address, device name, operating system version, the configuration of the app when utilizing our App, the time and date of your use of the app, and other statistics.
|
||||
|
||||
**Cookies**
|
||||
|
||||
@@ -32,6 +37,10 @@ We value your trust in providing us your Personal Information, thus we are striv
|
||||
|
||||
This app may contain links to other sites. If you click on a third-party link, you will be directed to that site. Note that these external sites are not operated by us. Therefore, we strongly advise you to review the Privacy Policy of these websites. We have no control over and assume no responsibility for the content, privacy policies, or practices of any third-party sites or services.
|
||||
|
||||
**Children’s Privacy**
|
||||
|
||||
These Services do not address anyone under the age of 13\. We do not knowingly collect personally identifiable information from children under 13\. In the case we discover that a child under 13 has provided us with personal information, we immediately delete this from our servers. If you are a parent or guardian and you are aware that your child has provided us with personal information, please contact us so that we will be able to do necessary actions.
|
||||
|
||||
**Changes to This Privacy Policy**
|
||||
|
||||
We may update our Privacy Policy from time to time. Thus, you are advised to review this page periodically for any changes. We will notify you of any changes by posting the new Privacy Policy on this page. These changes are effective immediately after they are posted on this page.
|
||||
|
||||
@@ -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-<version>-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
|
||||
+127
-15
@@ -1,27 +1,139 @@
|
||||
import java.util.*
|
||||
import java.security.*
|
||||
|
||||
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)
|
||||
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\"")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
task("injectPackageNameBase64") {
|
||||
doFirst {
|
||||
val packageName = android.defaultConfig.applicationId ?: return@doFirst
|
||||
|
||||
val base64 = Base64.getEncoder().encodeToString(packageName.toByteArray(Charsets.UTF_8))
|
||||
|
||||
android.buildTypes.forEach {
|
||||
it.buildConfigField("String", "PACKAGE_NAME_BASE64", "\"$base64\"")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
afterEvaluate {
|
||||
tasks["preBuild"].dependsOn(tasks["injectAppCenterKey"], tasks["injectPackageNameBase64"])
|
||||
}
|
||||
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
|
||||
@@ -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,137 +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 packages = data.getItemAt(0).text.split("\n").toSet()
|
||||
val all = design.apps.map(AppInfo::packageName).intersect(packages)
|
||||
|
||||
selected.clear()
|
||||
selected.addAll(all)
|
||||
}
|
||||
|
||||
design.rebindAll()
|
||||
}
|
||||
AccessControlDesign.Request.Export -> {
|
||||
val clipboard = getSystemService<ClipboardManager>()
|
||||
|
||||
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("text/plain"),
|
||||
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,140 @@
|
||||
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
|
||||
|
||||
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.bufferedReader().useLines { lines ->
|
||||
lines
|
||||
.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("text/plain"),
|
||||
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,294 @@
|
||||
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.core.event.LogEvent
|
||||
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.text.SimpleDateFormat
|
||||
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 val LOG_EXPORT_DATE_FORMAT = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.ENGLISH)
|
||||
private val LOG_EXPORT_TIME_FORMAT = SimpleDateFormat("HH:mm:ss", Locale.ENGLISH)
|
||||
}
|
||||
|
||||
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)?.bufferedWriter()?.use { output ->
|
||||
output.write("# Logcat on " + LOG_EXPORT_DATE_FORMAT.format(Date(file.date)) + "\n")
|
||||
|
||||
logsDir.resolve(file.fileName).bufferedReader().useLines { lines ->
|
||||
lines.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()
|
||||
)
|
||||
}
|
||||
.forEach {
|
||||
output.write(
|
||||
String.format(
|
||||
"%s |%s| %s\n",
|
||||
LOG_EXPORT_TIME_FORMAT.format(Date(it.time)),
|
||||
it.level.toString(),
|
||||
it.message
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
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() {
|
||||
@@ -19,18 +27,45 @@ class MainApplication : Application() {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
val processName = currentProcessName
|
||||
// Initialize AppCenter
|
||||
if (BuildConfig.APP_CENTER_KEY.isNotEmpty() && !BuildConfig.DEBUG) {
|
||||
AppCenter.start(
|
||||
this,
|
||||
BuildConfig.APP_CENTER_KEY,
|
||||
Analytics::class.java, Crashes::class.java
|
||||
)
|
||||
|
||||
Log.d("Process $processName started")
|
||||
Crashes.setListener(object : AbstractCrashesListener() {
|
||||
override fun getErrorAttachments(report: ErrorReport?): MutableIterable<ErrorAttachmentLog> {
|
||||
report ?: return mutableListOf()
|
||||
|
||||
if (processName == packageName) {
|
||||
Remote.launch()
|
||||
} else {
|
||||
sendServiceRecreated()
|
||||
if (!report.stackTrace.contains("DeadObjectException"))
|
||||
return mutableListOf()
|
||||
|
||||
val logcat = LogcatDumper.dumpCrash()
|
||||
|
||||
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,119 @@
|
||||
package com.github.kr328.clash
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.text.Html
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import com.github.kr328.clash.dump.LogcatDumper
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.microsoft.appcenter.crashes.Crashes
|
||||
import com.microsoft.appcenter.crashes.ingestion.models.ErrorAttachmentLog
|
||||
import kotlinx.android.synthetic.main.activity_support.*
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class SupportActivity : BaseActivity() {
|
||||
class UserRequestTrackException: Exception()
|
||||
|
||||
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.upload_logcat),
|
||||
summary = getString(R.string.upload_logcat_summary)
|
||||
) {
|
||||
onClick {
|
||||
AlertDialog.Builder(this@SupportActivity)
|
||||
.setTitle(R.string.upload_logcat)
|
||||
.setMessage(R.string.upload_logcat_warn)
|
||||
.setNegativeButton(R.string.cancel) {_, _ -> }
|
||||
.setPositiveButton(R.string.ok) {_, _ -> upload() }
|
||||
.show()
|
||||
}
|
||||
}
|
||||
|
||||
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)))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun upload() {
|
||||
launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
val attachment = ErrorAttachmentLog
|
||||
.attachmentWithText(LogcatDumper.dumpAll(), "logcat.txt")
|
||||
|
||||
Crashes.trackError(UserRequestTrackException(), null, listOf(attachment))
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
Snackbar.make(rootView, R.string.uploaded, Snackbar.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,37 @@
|
||||
package com.github.kr328.clash.dump
|
||||
|
||||
object LogcatDumper {
|
||||
fun dumpCrash(): String {
|
||||
return try {
|
||||
val process =
|
||||
Runtime.getRuntime().exec(arrayOf("logcat", "-d", "-s", "Go", "AndroidRuntime", "DEBUG"))
|
||||
|
||||
val result = process.inputStream.use {
|
||||
it.reader().readText()
|
||||
}
|
||||
|
||||
process.waitFor()
|
||||
|
||||
result
|
||||
} catch (e: Exception) {
|
||||
""
|
||||
}
|
||||
}
|
||||
|
||||
fun dumpAll(): String {
|
||||
return try {
|
||||
val process =
|
||||
Runtime.getRuntime().exec(arrayOf("logcat", "-d"))
|
||||
|
||||
val result = process.inputStream.use {
|
||||
it.reader().readText()
|
||||
}
|
||||
|
||||
process.waitFor()
|
||||
|
||||
result
|
||||
} catch (e: Exception) {
|
||||
""
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
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() {
|
||||
private var root: CommonUiLayout? = null
|
||||
|
||||
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 {
|
||||
root = this
|
||||
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 = ""
|
||||
interval = 0
|
||||
return@onTextChanged
|
||||
}
|
||||
|
||||
val value = s.toIntOrNull()
|
||||
if (value == null || value < 15) {
|
||||
content = ""
|
||||
interval = 0
|
||||
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
|
||||
|
||||
root?.apply {
|
||||
data.data?.apply {
|
||||
screen.requireElement<TextInput>(KEY_URL).content = this.toString()
|
||||
}
|
||||
|
||||
data.getStringExtra(Constants.URL_PROVIDER_INTENT_EXTRA_NAME)?.also {
|
||||
screen.requireElement<TextInput>(KEY_NAME).apply {
|
||||
if (content.isBlank())
|
||||
content = it
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
super.onActivityResult(requestCode, resultCode, data)
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
super.onSaveInstanceState(outState)
|
||||
|
||||
root?.apply {
|
||||
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) {
|
||||
root?.apply {
|
||||
Snackbar.make(
|
||||
this,
|
||||
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,219 @@
|
||||
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 android.util.Base64
|
||||
import androidx.core.content.edit
|
||||
import com.github.kr328.clash.ApkBrokenActivity
|
||||
import com.github.kr328.clash.AppCrashedActivity
|
||||
import com.github.kr328.clash.BuildConfig
|
||||
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 pkgName: String = try {
|
||||
application::class.java.getMethod(
|
||||
String(
|
||||
charArrayOf(
|
||||
'g',
|
||||
'e',
|
||||
't',
|
||||
'P',
|
||||
'a',
|
||||
'c',
|
||||
'k',
|
||||
'a',
|
||||
'g',
|
||||
'e',
|
||||
'N',
|
||||
'a',
|
||||
'm',
|
||||
'e'
|
||||
)
|
||||
)
|
||||
).invoke(application)?.toString()
|
||||
} catch (e: Exception) {
|
||||
Log.w("getPackageName failure", e)
|
||||
null
|
||||
} ?: application.packageName
|
||||
|
||||
val packageNameBase64 = Base64
|
||||
.encodeToString(pkgName.toByteArray(Charsets.UTF_8), Base64.NO_WRAP)
|
||||
|
||||
if (packageNameBase64 != BuildConfig.PACKAGE_NAME_BASE64)
|
||||
return false
|
||||
|
||||
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,56 @@
|
||||
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
|
||||
import java.lang.Exception
|
||||
|
||||
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: Exception) {
|
||||
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: Exception) {
|
||||
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,60 +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.common.log.Log
|
||||
import com.github.kr328.clash.common.util.intent
|
||||
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)
|
||||
|
||||
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 -> ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.github.kr328.clash.utils
|
||||
|
||||
import android.content.Context
|
||||
import com.github.kr328.clash.Constants
|
||||
import java.io.File
|
||||
|
||||
val Context.logsDir: File
|
||||
get() = (externalCacheDir ?: cacheDir).resolve(Constants.LOG_DIR_NAME)
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.github.kr328.clash.utils
|
||||
|
||||
import android.content.Context
|
||||
import com.github.kr328.clash.R
|
||||
|
||||
object IntervalUtils {
|
||||
private const val MILLIS_SECOND = 1000L
|
||||
private const val MILLIS_MINUTE = MILLIS_SECOND * 60
|
||||
private const val MILLIS_HOUR = MILLIS_MINUTE * 60
|
||||
private const val MILLIS_DAY = MILLIS_HOUR * 24
|
||||
private const val MILLIS_MONTH = MILLIS_DAY * 30
|
||||
private const val MILLIS_YEAR = MILLIS_MONTH * 12
|
||||
|
||||
fun intervalString(context: Context, interval: Long): String {
|
||||
val year = interval / MILLIS_YEAR
|
||||
val month = interval / MILLIS_MONTH
|
||||
val day = interval / MILLIS_DAY
|
||||
val hour = interval / MILLIS_HOUR
|
||||
val minute = interval / MILLIS_MINUTE
|
||||
|
||||
System.currentTimeMillis()
|
||||
|
||||
return when {
|
||||
year > 0 -> context.getString(R.string.format_years, year)
|
||||
month > 0 -> context.getString(R.string.format_months, month)
|
||||
day > 0 -> context.getString(R.string.format_days, day)
|
||||
hour > 0 -> context.getString(R.string.format_hours, hour)
|
||||
minute > 0 -> context.getString(R.string.format_minutes, minute)
|
||||
else -> context.getString(R.string.recently)
|
||||
}
|
||||
}
|
||||
}
|
||||
Loaded 100 of 659 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user