Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
03785f427a | ||
|
|
5cc1f43a03 | ||
|
|
a557255099 | ||
|
|
932778b153 | ||
|
|
1e8cadb878 | ||
|
|
eba2869c1b | ||
|
|
121108c416 | ||
|
|
780dfda610 | ||
|
|
3dd3431401 | ||
|
|
ba8a6247f5 | ||
|
|
5f1ee513f2 | ||
|
|
17fd5954a0 | ||
|
|
87e4061879 | ||
|
|
5c1ce96239 | ||
|
|
ef058f223d | ||
|
|
ab03e551da | ||
|
|
168a097111 | ||
|
|
232572114c | ||
|
|
54c67f83ab | ||
|
|
e63f210cc8 | ||
|
|
48169224c0 | ||
|
|
f2d99a50a7 | ||
|
|
bfa3c8375b | ||
|
|
34697da10c | ||
|
|
3e7d7f5d60 | ||
|
|
8a4149c800 | ||
|
|
0d7650cbc0 | ||
|
|
72932a255d | ||
|
|
4848a0f6d9 | ||
|
|
a5064fdd4f | ||
|
|
5d4b6b4fb5 | ||
|
|
d5a0299d91 | ||
|
|
718388db51 | ||
|
|
01d9d231b5 | ||
|
|
88e0425736 | ||
|
|
05f2a12baf | ||
|
|
c9d3b8e836 | ||
|
|
33fbd378c0 | ||
|
|
f76bed2f8b | ||
|
|
23027b8813 | ||
|
|
896c57aa03 | ||
|
|
ee6458a60e | ||
|
|
114456a1f2 | ||
|
|
cca7ec81e1 | ||
|
|
e9e59f6026 | ||
|
|
a29c95ceef | ||
|
|
c60d8cfb53 | ||
|
|
4ee8679d29 | ||
|
|
419c6301dd | ||
|
|
e979bec321 | ||
|
|
fa6ac580d0 | ||
|
|
a44bb05345 | ||
|
|
19ddb86430 | ||
|
|
1eb6f59167 | ||
|
|
2ba82ebd97 | ||
|
|
00163fbfd4 | ||
|
|
0ef8734856 | ||
|
|
a4151fb198 | ||
|
|
292ba5af95 | ||
|
|
90456263ae | ||
|
|
bc3f7d0549 | ||
|
|
382f083fd1 | ||
|
|
a287e287b9 | ||
|
|
166558433e | ||
|
|
1915b005a9 | ||
|
|
ed63036329 | ||
|
|
a49899add3 | ||
|
|
85072e4520 | ||
|
|
a31f864ab8 | ||
|
|
5bcbc4563e | ||
|
|
62910f5e7e | ||
|
|
b3ff9abc0d | ||
|
|
eccc9bd886 | ||
|
|
0bd1fcf51b | ||
|
|
51acbc75f5 | ||
|
|
c3cd2351a5 | ||
|
|
dde7f77c29 | ||
|
|
dafba83b8d | ||
|
|
ba472b5768 | ||
|
|
c4f4c157bd | ||
|
|
5ddeaf2783 | ||
|
|
1b87bc0417 | ||
|
|
a154230bca | ||
|
|
4b498d7dc7 | ||
|
|
41b263a7ee | ||
|
|
ddeff030e3 | ||
|
|
d48beadcbc | ||
|
|
7238e919c5 | ||
|
|
7cd52a84db | ||
|
|
e97a2d58c8 | ||
|
|
340fa28c6a | ||
|
|
6c58f552b8 | ||
|
|
7cc53bbf32 | ||
|
|
88ef4d2f10 | ||
|
|
968ef77896 | ||
|
|
64803bef1e | ||
|
|
d7df27b91d | ||
|
|
32b6608f11 | ||
|
|
a665acaf63 | ||
|
|
e2073b5f7c | ||
|
|
475dd96ac2 | ||
|
|
9fe2ec39ae | ||
|
|
9cca3c958e | ||
|
|
903c201f2d | ||
|
|
2b47776554 | ||
|
|
41a2e7effc | ||
|
|
6947118d2e | ||
|
|
3b7a0b0101 | ||
|
|
19dcc4a1fa | ||
|
|
8ec689a893 | ||
|
|
a1d623c42b | ||
|
|
9a893fc0a0 | ||
|
|
f13abcbe49 | ||
|
|
24e1732091 | ||
|
|
8a2826c242 | ||
|
|
b36f19f4c4 | ||
|
|
9967601e3e | ||
|
|
c330bf8238 | ||
|
|
b38d9ec95e | ||
|
|
1069ba1991 | ||
|
|
ea29017687 | ||
|
|
b41b9267bd | ||
|
|
5ffa1aeb1d | ||
|
|
d2b91341f0 | ||
|
|
5e69959cf5 | ||
|
|
845f1b37ca | ||
|
|
21fb12ef71 | ||
|
|
53fa597556 | ||
|
|
adffebadbc | ||
|
|
ea93c0b3ab | ||
|
|
ea3b63fd80 | ||
|
|
bda542d2e5 | ||
|
|
a72c2d8abb | ||
|
|
8ad202cc07 | ||
|
|
bc8cea7bd8 | ||
|
|
8878070efc | ||
|
|
4e2dceef48 | ||
|
|
c9973393ff | ||
|
|
f0aba3758d | ||
|
|
d8e5e74444 | ||
|
|
0a05d76850 | ||
|
|
996d82dfbf | ||
|
|
961f9c7633 | ||
|
|
e7d59ec633 | ||
|
|
32828a6ee9 | ||
|
|
e8a48c0f0d | ||
|
|
afbebb4c8b | ||
|
|
a911a0c4dd | ||
|
|
fe9047ee40 | ||
|
|
bd159ecb6e | ||
|
|
1e8a57e949 | ||
|
|
f418a3649e | ||
|
|
fa99b3f981 | ||
|
|
2661f01c19 | ||
|
|
8633ab2de6 | ||
|
|
70c8bb19a7 | ||
|
|
cdf7107d5a | ||
|
|
c11a1bb2a3 | ||
|
|
8466662226 | ||
|
|
7765d14588 | ||
|
|
8968469310 | ||
|
|
d7d36f32a3 | ||
|
|
aa4b6c23bb | ||
|
|
ceae371826 | ||
|
|
7fba8b09cc | ||
|
|
527f99ea00 | ||
|
|
db1efcb85d | ||
|
|
456c853923 | ||
|
|
e5abd0cba8 | ||
|
|
f61013d99d | ||
|
|
4768bd92dc | ||
|
|
9023809c89 | ||
|
|
e14e91a2a4 | ||
|
|
0e6cb05796 | ||
|
|
da532df009 | ||
|
|
9aa20c6715 | ||
|
|
c7107e9d9b | ||
|
|
f882375cfa | ||
|
|
a6abb51046 | ||
|
|
438392ddf6 | ||
|
|
6646593c5b | ||
|
|
ab4b8b5057 | ||
|
|
3b03624f32 | ||
|
|
7270f94930 | ||
|
|
838d492334 | ||
|
|
600537af9c | ||
|
|
774d83ed9f | ||
|
|
e3c5fb1a8c | ||
|
|
f237879921 | ||
|
|
765892e768 | ||
|
|
5caa778a71 | ||
|
|
f2a0e4877a | ||
|
|
7d04980e30 | ||
|
|
565415d8d1 | ||
|
|
3f91c10c10 | ||
|
|
96b8fe110b | ||
|
|
d0aa037b4c | ||
|
|
792190b5f4 | ||
|
|
d332b6941a | ||
|
|
cd1a8bf5e4 | ||
|
|
5c7e00ce59 | ||
|
|
2b1c32faf7 | ||
|
|
b98d5a5ce0 | ||
|
|
4c84616797 | ||
|
|
b1d22f3c3c | ||
|
|
e8a3335e21 | ||
|
|
6cecb23dd8 | ||
|
|
9987a83334 | ||
|
|
fe194eac76 | ||
|
|
21b9d0fa27 | ||
|
|
cf4e1c5b95 | ||
|
|
e624618be6 | ||
|
|
fa6e5841bf | ||
|
|
c3ebd9d6e3 |
No files matched your search
@@ -1,4 +0,0 @@
|
||||
* text=auto eol=lf
|
||||
|
||||
*.bat text eol=crlf
|
||||
*.jar binary
|
||||
@@ -1,104 +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:
|
||||
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:
|
||||
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."
|
||||
@@ -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.
|
||||
@@ -1,105 +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:
|
||||
label: "配置文件"
|
||||
description: |
|
||||
请在此粘贴和上传配置文件。
|
||||
|
||||
提示:如果您仅有一个订阅链接,请使用浏览器打开此链接以下载配置文件。
|
||||
|
||||
**注意: 请在上传配置文件前,移除其中的代理服务器信息。**
|
||||
**注意: 请在上传配置文件前,移除其中的代理服务器信息。**
|
||||
**注意: 请在上传配置文件前,移除其中的代理服务器信息。**
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
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: "可选"
|
||||
@@ -1,26 +0,0 @@
|
||||
name: "[简体中文] 功能请求"
|
||||
description: "您希望的能够在应用中增加功能"
|
||||
title: "[Feature Request] "
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
感谢您在百忙之中填写此功能请求报告。
|
||||
|
||||
注意: 请务必在上方文本框的 `[Feature Request]` **之后**填写清晰明了的标题。
|
||||
|
||||
<!-- template -->
|
||||
- type: textarea
|
||||
id: "description"
|
||||
attributes:
|
||||
label: "功能描述"
|
||||
description: |
|
||||
简介明了的描述此功能。
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: "additional"
|
||||
attributes:
|
||||
label: "附加信息"
|
||||
description: |
|
||||
与此功能相关的其他附加信息。
|
||||
@@ -1 +0,0 @@
|
||||
blank_issues_enabled: false
|
||||
@@ -1,54 +0,0 @@
|
||||
name: Build Unsigned
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths-ignore:
|
||||
- '.github/**'
|
||||
- '.idea/**'
|
||||
- '.gitattributes'
|
||||
- '.gitignore'
|
||||
- '.gitmodules'
|
||||
- '**.md'
|
||||
- 'LICENSE'
|
||||
- 'NOTICE'
|
||||
pull_request:
|
||||
paths-ignore:
|
||||
- '.github/**'
|
||||
- '.idea/**'
|
||||
- '.gitattributes'
|
||||
- '.gitignore'
|
||||
- '.gitmodules'
|
||||
- '**.md'
|
||||
- 'LICENSE'
|
||||
- 'NOTICE'
|
||||
|
||||
jobs:
|
||||
BuildUnsigned:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
submodules: recursive
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@v3
|
||||
with:
|
||||
distribution: 'zulu'
|
||||
java-version: 17
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: 1.18
|
||||
- uses: actions/cache@v3
|
||||
with:
|
||||
path: |
|
||||
~/.cache/go-build
|
||||
~/go/pkg/mod
|
||||
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-go-
|
||||
- name: Build
|
||||
uses: gradle/gradle-build-action@v2
|
||||
with:
|
||||
arguments: --no-daemon app:assembleFossRelease
|
||||
+7
-20
@@ -1,7 +1,6 @@
|
||||
.gradle
|
||||
build/
|
||||
/app/foss/release
|
||||
/app/premium/release
|
||||
/app/release/
|
||||
/captures
|
||||
|
||||
# Ignore Gradle GUI config
|
||||
@@ -17,30 +16,24 @@ 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
|
||||
|
||||
# gradle
|
||||
.gradle
|
||||
|
||||
# clion cmake build
|
||||
cmake-build-*
|
||||
|
||||
# local.properties
|
||||
local.properties
|
||||
|
||||
|
||||
# tracker
|
||||
tracker.properties
|
||||
# keystore
|
||||
keystore.properties
|
||||
|
||||
# vscode
|
||||
.vscode
|
||||
@@ -55,9 +48,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)
|
||||
|
||||
+1
-1
@@ -17,7 +17,7 @@ The app does use third party services that may collect information used to ident
|
||||
Link to privacy policy of third party service providers used by the app
|
||||
|
||||
* [Google Play Services](https://www.google.com/policies/privacy/)
|
||||
* [AppCenter](https://docs.microsoft.com/en-us/appcenter/gdpr/)
|
||||
* [Firebase Analytics](https://firebase.google.com/policies/analytics)
|
||||
|
||||
**Log Data**
|
||||
|
||||
|
||||
@@ -1,27 +1,36 @@
|
||||
## Clash for Android
|
||||
|
||||
A Graphical user interface of [clash](https://github.com/Dreamacro/clash) for Android
|
||||
A GUI for [clash](https://github.com/Dreamacro/clash) on Android
|
||||
|
||||
> NOTICE: Early testing currently
|
||||
|
||||
|
||||
<a href="https://play.google.com/store/apps/details?id=com.github.kr328.clash"><img width="200px" alt="Get it on Google Play" src="https://play.google.com/intl/en_us/badges/static/images/badges/en_badge_web_generic.png"/></a> or [Releases](https://github.com/Kr328/ClashForAndroid/releases)
|
||||
|
||||
### Feature
|
||||
|
||||
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+
|
||||
* `arm64` 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 +39,28 @@ See also [PRIVACY_POLICY.md](./PRIVACY_POLICY.md)
|
||||
git submodule update --init --recursive
|
||||
```
|
||||
|
||||
2. Install **OpenJDK 11**, **Android SDK**, **CMake** and **Golang**
|
||||
2. Install `Android SDK` ,`Android NDK` and `Golang`
|
||||
|
||||
3. Create `local.properties` in project root with
|
||||
3. Configure `local.properties`
|
||||
|
||||
```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. Build
|
||||
|
||||
```properties
|
||||
keystore.path=/path/to/keystore/file
|
||||
keystore.password=<key store password>
|
||||
key.alias=<key alias>
|
||||
key.password=<key password>
|
||||
```
|
||||
|
||||
5. Build
|
||||
on Linux
|
||||
|
||||
```bash
|
||||
./gradlew app:assembleFossRelease
|
||||
./gradlew build
|
||||
```
|
||||
|
||||
6. Pick `app-foss-<arch>-release.apk` in `app/build/outputs/apk/foss/release/`
|
||||
on Windows
|
||||
|
||||
```bash
|
||||
.\gradlew.bat build
|
||||
```
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
/build
|
||||
@@ -0,0 +1,69 @@
|
||||
apply plugin: 'com.android.application'
|
||||
apply plugin: 'kotlinx-serialization'
|
||||
apply plugin: 'kotlin-android'
|
||||
apply plugin: 'kotlin-android-extensions'
|
||||
apply plugin: 'kotlin-kapt'
|
||||
|
||||
android {
|
||||
compileSdkVersion gCompileSdkVersion
|
||||
buildToolsVersion gBuildToolsVersion
|
||||
defaultConfig {
|
||||
applicationId "com.github.kr328.clash"
|
||||
minSdkVersion gMinSdkVersion
|
||||
targetSdkVersion gTargetSdkVersion
|
||||
versionCode gVersionCode
|
||||
versionName gVersionName
|
||||
}
|
||||
buildTypes {
|
||||
release {
|
||||
minifyEnabled true
|
||||
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||
}
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility = 1.8
|
||||
targetCompatibility = 1.8
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = "1.8"
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
kapt "androidx.room:room-compiler:$gRoomVersion"
|
||||
|
||||
implementation project(":core")
|
||||
implementation project(":service")
|
||||
implementation project(":design")
|
||||
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$gKotlinVersion"
|
||||
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$gKotlinCoroutineVersion"
|
||||
implementation "androidx.lifecycle:lifecycle-extensions:$gLifecycleVersion"
|
||||
implementation "androidx.lifecycle:lifecycle-common-java8:$gLifecycleVersion"
|
||||
implementation "androidx.recyclerview:recyclerview:$gRecyclerviewVersion"
|
||||
implementation "androidx.core:core-ktx:$gAndroidKtxVersion"
|
||||
implementation "androidx.appcompat:appcompat:$gAppCompatVersion"
|
||||
implementation "androidx.room:room-runtime:$gRoomVersion"
|
||||
implementation "org.jetbrains.kotlinx:kotlinx-serialization-runtime:$gKotlinSerializationVersion"
|
||||
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"
|
||||
}
|
||||
|
||||
task injectAppCenterKey() {
|
||||
doFirst {
|
||||
Properties properties = new Properties()
|
||||
properties.load(rootProject.file('local.properties').newDataInputStream())
|
||||
|
||||
def key = properties.getProperty("appcenter.key", "")
|
||||
|
||||
android.buildTypes.each {
|
||||
it.buildConfigField 'String', 'APP_CENTER_KEY', "\"$key\""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
afterEvaluate {
|
||||
preBuild.dependsOn(injectAppCenterKey)
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
plugins {
|
||||
kotlin("android")
|
||||
kotlin("kapt")
|
||||
id("com.android.application")
|
||||
}
|
||||
|
||||
dependencies {
|
||||
compileOnly(project(":hideapi"))
|
||||
|
||||
implementation(project(":core"))
|
||||
implementation(project(":service"))
|
||||
implementation(project(":design"))
|
||||
implementation(project(":common"))
|
||||
|
||||
implementation(libs.kotlin.coroutine)
|
||||
implementation(libs.androidx.core)
|
||||
implementation(libs.androidx.activity)
|
||||
implementation(libs.androidx.fragment)
|
||||
implementation(libs.androidx.appcompat)
|
||||
implementation(libs.androidx.coordinator)
|
||||
implementation(libs.androidx.recyclerview)
|
||||
implementation(libs.google.material)
|
||||
|
||||
val premiumImplementation by configurations
|
||||
|
||||
premiumImplementation(libs.appcenter.analytics)
|
||||
premiumImplementation(libs.appcenter.crashes)
|
||||
}
|
||||
|
||||
tasks.getByName("clean", type = Delete::class) {
|
||||
delete(file("release"))
|
||||
}
|
||||
Vendored
+1
-37
@@ -20,40 +20,4 @@
|
||||
# hide the original source file name.
|
||||
#-renamesourcefileattribute SourceFile
|
||||
|
||||
-dontobfuscate
|
||||
|
||||
-assumenosideeffects class kotlin.jvm.internal.Intrinsics {
|
||||
public static void checkNotNull(...);
|
||||
public static void checkExpressionValueIsNotNull(...);
|
||||
public static void checkNotNullExpressionValue(...);
|
||||
public static void checkReturnedValueIsNotNull(...);
|
||||
public static void checkFieldIsNotNull(...);
|
||||
public static void checkParameterIsNotNull(...);
|
||||
public static void checkNotNullParameter(...);
|
||||
}
|
||||
|
||||
# Kotlin Coroutine
|
||||
# Allow R8 to optimize away the FastServiceLoader.
|
||||
# Together with ServiceLoader optimization in R8
|
||||
# this results in direct instantiation when loading Dispatchers.Main
|
||||
-assumenosideeffects class kotlinx.coroutines.internal.MainDispatcherLoader {
|
||||
boolean FAST_SERVICE_LOADER_ENABLED return false;
|
||||
}
|
||||
|
||||
-assumenosideeffects class kotlinx.coroutines.internal.FastServiceLoaderKt {
|
||||
boolean ANDROID_DETECTED return true;
|
||||
}
|
||||
|
||||
-keep class kotlinx.coroutines.android.AndroidDispatcherFactory {*;}
|
||||
|
||||
# Disable support for "Missing Main Dispatcher", since we always have Android main dispatcher
|
||||
-assumenosideeffects class kotlinx.coroutines.internal.MainDispatchersKt {
|
||||
boolean SUPPORT_MISSING return false;
|
||||
}
|
||||
|
||||
# Statically turn off all debugging facilities and assertions
|
||||
-assumenosideeffects class kotlinx.coroutines.DebugKt {
|
||||
boolean getASSERTIONS_ENABLED() return false;
|
||||
boolean getDEBUG() return false;
|
||||
boolean getRECOVER_STACK_TRACES() return false;
|
||||
}
|
||||
-dontobfuscate
|
||||
@@ -1,14 +0,0 @@
|
||||
package com.github.kr328.clash
|
||||
|
||||
import android.app.Application
|
||||
|
||||
@Suppress("UNUSED_PARAMETER")
|
||||
object Tracker {
|
||||
fun initialize(application: Application) {
|
||||
// do nothing
|
||||
}
|
||||
|
||||
fun uploadLogcat(logcat: String) {
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
@@ -3,177 +3,117 @@
|
||||
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:launchMode="singleTop">
|
||||
android:configChanges="uiMode"
|
||||
android:launchMode="singleTask">
|
||||
<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="singleTask"/>
|
||||
<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">
|
||||
android:name=".OnBootReceiver"
|
||||
android:enabled="false">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
</application>
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 17 KiB |
@@ -1,142 +0,0 @@
|
||||
package com.github.kr328.clash
|
||||
|
||||
import android.Manifest.permission.INTERNET
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.pm.ApplicationInfo
|
||||
import android.content.pm.PackageInfo
|
||||
import android.content.pm.PackageManager
|
||||
import androidx.core.content.getSystemService
|
||||
import com.github.kr328.clash.design.AccessControlDesign
|
||||
import com.github.kr328.clash.design.model.AppInfo
|
||||
import com.github.kr328.clash.design.util.toAppInfo
|
||||
import com.github.kr328.clash.service.store.ServiceStore
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.selects.select
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class AccessControlActivity : BaseActivity<AccessControlDesign>() {
|
||||
override suspend fun main() {
|
||||
val service = ServiceStore(this)
|
||||
|
||||
val selected = withContext(Dispatchers.IO) {
|
||||
service.accessControlPackages.toMutableSet()
|
||||
}
|
||||
|
||||
defer {
|
||||
withContext(Dispatchers.IO) {
|
||||
service.accessControlPackages = selected
|
||||
}
|
||||
}
|
||||
|
||||
val design = AccessControlDesign(this, uiStore, selected)
|
||||
|
||||
setContentDesign(design)
|
||||
|
||||
design.requests.send(AccessControlDesign.Request.ReloadApps)
|
||||
|
||||
while (isActive) {
|
||||
select<Unit> {
|
||||
events.onReceive {
|
||||
|
||||
}
|
||||
design.requests.onReceive {
|
||||
when (it) {
|
||||
AccessControlDesign.Request.ReloadApps -> {
|
||||
design.patchApps(loadApps(selected))
|
||||
}
|
||||
AccessControlDesign.Request.SelectAll -> {
|
||||
val all = withContext(Dispatchers.Default) {
|
||||
design.apps.map(AppInfo::packageName)
|
||||
}
|
||||
|
||||
selected.clear()
|
||||
selected.addAll(all)
|
||||
|
||||
design.rebindAll()
|
||||
}
|
||||
AccessControlDesign.Request.SelectNone -> {
|
||||
selected.clear()
|
||||
|
||||
design.rebindAll()
|
||||
}
|
||||
AccessControlDesign.Request.SelectInvert -> {
|
||||
val all = withContext(Dispatchers.Default) {
|
||||
design.apps.map(AppInfo::packageName).toSet() - selected
|
||||
}
|
||||
|
||||
selected.clear()
|
||||
selected.addAll(all)
|
||||
|
||||
design.rebindAll()
|
||||
}
|
||||
AccessControlDesign.Request.Import -> {
|
||||
val clipboard = getSystemService<ClipboardManager>()
|
||||
val data = clipboard?.primaryClip
|
||||
|
||||
if (data != null && data.itemCount > 0) {
|
||||
val all = withContext(Dispatchers.IO) {
|
||||
val packages = data.getItemAt(0).text.split("\n").toSet()
|
||||
|
||||
design.apps.map(AppInfo::packageName).intersect(packages)
|
||||
}
|
||||
|
||||
selected.clear()
|
||||
selected.addAll(all)
|
||||
}
|
||||
|
||||
design.rebindAll()
|
||||
}
|
||||
AccessControlDesign.Request.Export -> {
|
||||
val clipboard = getSystemService<ClipboardManager>()
|
||||
|
||||
withContext(Dispatchers.IO) {
|
||||
val data = ClipData.newPlainText(
|
||||
"packages",
|
||||
selected.joinToString("\n")
|
||||
)
|
||||
|
||||
clipboard?.setPrimaryClip(data)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadApps(selected: Set<String>): List<AppInfo> =
|
||||
withContext(Dispatchers.IO) {
|
||||
val reverse = uiStore.accessControlReverse
|
||||
val sort = uiStore.accessControlSort
|
||||
val systemApp = uiStore.accessControlSystemApp
|
||||
|
||||
val base = compareByDescending<AppInfo> { it.packageName in selected }
|
||||
val comparator = if (reverse) base.thenDescending(sort) else base.then(sort)
|
||||
|
||||
val pm = packageManager
|
||||
val packages = pm.getInstalledPackages(PackageManager.GET_PERMISSIONS)
|
||||
|
||||
packages.asSequence()
|
||||
.filter {
|
||||
it.packageName != packageName
|
||||
}
|
||||
.filter {
|
||||
it.packageName == "android" || it.requestedPermissions?.contains(INTERNET) == true
|
||||
}
|
||||
.filter {
|
||||
systemApp || !it.isSystemApp
|
||||
}
|
||||
.map {
|
||||
it.toAppInfo(pm)
|
||||
}
|
||||
.sortedWith(comparator)
|
||||
.toList()
|
||||
}
|
||||
|
||||
private val PackageInfo.isSystemApp: Boolean
|
||||
get() {
|
||||
return applicationInfo.flags and ApplicationInfo.FLAG_SYSTEM != 0
|
||||
}
|
||||
}
|
||||
@@ -2,19 +2,62 @@ 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
|
||||
}
|
||||
|
||||
override val activityLabel: CharSequence
|
||||
get() = getText(R.string.application_broken)
|
||||
}
|
||||
@@ -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,164 +1,173 @@
|
||||
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.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.github.kr328.clash.service.data.ClashProfileEntity
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.MainScope
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.*
|
||||
|
||||
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(profileEntity: ClashProfileEntity) {
|
||||
launch {
|
||||
onClashProfileLoaded(profileEntity)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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(profile: ClashProfileEntity) {}
|
||||
|
||||
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)
|
||||
|
||||
val languageOverride = language.split("-")
|
||||
if (language.isEmpty())
|
||||
return super.attachBaseContext(base)
|
||||
|
||||
val configuration = Configuration()
|
||||
val localeOverride = if (languageOverride.size == 2)
|
||||
Locale(languageOverride[0], languageOverride[1])
|
||||
else
|
||||
Locale(languageOverride[0])
|
||||
|
||||
configuration.setLocale(localeOverride)
|
||||
|
||||
super.attachBaseContext(base.createConfigurationContext(configuration))
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
applyDayNight()
|
||||
language = uiSettings.get(UiSettings.LANGUAGE)
|
||||
|
||||
launch {
|
||||
main()
|
||||
resetDarkMode()
|
||||
|
||||
finish()
|
||||
}
|
||||
resetLightNavigationBar()
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
|
||||
activityStarted = true
|
||||
if (language != uiSettings.get(UiSettings.LANGUAGE))
|
||||
recreate()
|
||||
if (darkMode != uiSettings.get(UiSettings.DARK_MODE)) {
|
||||
resetDarkMode()
|
||||
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())
|
||||
|
||||
activityLabel?.let {
|
||||
title = it
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -167,83 +176,58 @@ abstract class BaseActivity<D : Design<*>> :
|
||||
return true
|
||||
}
|
||||
|
||||
abstract val activityLabel: CharSequence?
|
||||
|
||||
override fun onSupportNavigateUp(): Boolean {
|
||||
this.onBackPressed()
|
||||
|
||||
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 makeSnackbarException(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)
|
||||
|
||||
when (dayNight) {
|
||||
DayNight.Night -> {
|
||||
theme.applyStyle(R.style.AppThemeDark, true)
|
||||
}
|
||||
DayNight.Day -> {
|
||||
theme.applyStyle(R.style.AppThemeLight, true)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
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_TYPE_FILE = "file"
|
||||
const val URL_PROVIDER_TYPE_URL = "url"
|
||||
const val URL_PROVIDER_TYPE_EXTERNAL = "external"
|
||||
|
||||
const val URL_PROVIDER_INTENT_ACTION = "com.github.kr328.clash.action.PROVIDE_URL"
|
||||
|
||||
const val URL_PROVIDER_INTENT_EXTRA_NAME = "name"
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
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.service.util.intent
|
||||
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
|
||||
}
|
||||
|
||||
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]
|
||||
|
||||
startActivityForResult(
|
||||
ProfileEditActivity::class.intent
|
||||
.putExtra("type", item.type)
|
||||
.putExtra("intent", item.intent),
|
||||
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 val activityLabel: CharSequence
|
||||
get() = getText(R.string.create_profile)
|
||||
|
||||
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)!!,
|
||||
Constants.URL_PROVIDER_TYPE_FILE,
|
||||
null
|
||||
),
|
||||
UrlProvider(
|
||||
getText(R.string.url),
|
||||
getText(R.string.import_from_url),
|
||||
getDrawable(R.drawable.ic_download)!!,
|
||||
Constants.URL_PROVIDER_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 = Constants.URL_PROVIDER_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: String,
|
||||
val intent: Intent?
|
||||
)
|
||||
|
||||
private class Adapter(private val context: Context, private val providers: List<UrlProvider>) :
|
||||
BaseAdapter() {
|
||||
override fun getView(position: Int, convertView: View?, parent: ViewGroup?): View {
|
||||
val provider = providers[position]
|
||||
val view = convertView ?: LayoutInflater.from(context).inflate(
|
||||
R.layout.adapter_url_provider,
|
||||
parent,
|
||||
false
|
||||
)
|
||||
|
||||
view.findViewById<TextView>(android.R.id.title).text = provider.name
|
||||
view.findViewById<TextView>(android.R.id.summary).text = provider.summary
|
||||
view.findViewById<View>(android.R.id.icon).background = provider.icon
|
||||
|
||||
return view
|
||||
}
|
||||
|
||||
override fun getItem(position: Int): Any = providers[position]
|
||||
override fun getItemId(position: Int): Long = providers[position].hashCode().toLong()
|
||||
override fun getCount(): Int = providers.size
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
package com.github.kr328.clash
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import com.github.kr328.clash.common.util.intent
|
||||
import com.github.kr328.clash.common.util.setUUID
|
||||
import com.github.kr328.clash.service.model.Profile
|
||||
import com.github.kr328.clash.util.withProfile
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.MainScope
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.*
|
||||
|
||||
class ExternalImportActivity : Activity(), CoroutineScope by MainScope() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
if (intent.action != Intent.ACTION_VIEW)
|
||||
return finish()
|
||||
|
||||
val uri = intent.data ?: return finish()
|
||||
val url = uri.getQueryParameter("url") ?: return finish()
|
||||
|
||||
launch {
|
||||
val uuid = withProfile {
|
||||
val type = when (uri.getQueryParameter("type")?.lowercase(Locale.getDefault())) {
|
||||
"url" -> Profile.Type.Url
|
||||
"file" -> Profile.Type.File
|
||||
else -> Profile.Type.Url
|
||||
}
|
||||
val name = uri.getQueryParameter("name") ?: getString(R.string.new_profile)
|
||||
|
||||
create(type, name).also {
|
||||
patch(it, name, url, 0)
|
||||
}
|
||||
}
|
||||
|
||||
startActivity(PropertiesActivity::class.intent.setUUID(uuid))
|
||||
|
||||
finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,160 +0,0 @@
|
||||
@file:Suppress("BlockingMethodInNonBlockingContext")
|
||||
|
||||
package com.github.kr328.clash
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.github.kr328.clash.common.util.grantPermissions
|
||||
import com.github.kr328.clash.common.util.ticker
|
||||
import com.github.kr328.clash.common.util.uuid
|
||||
import com.github.kr328.clash.design.FilesDesign
|
||||
import com.github.kr328.clash.design.util.showExceptionToast
|
||||
import com.github.kr328.clash.remote.FilesClient
|
||||
import com.github.kr328.clash.service.model.Profile
|
||||
import com.github.kr328.clash.util.fileName
|
||||
import com.github.kr328.clash.util.withProfile
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.selects.select
|
||||
import java.util.*
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class FilesActivity : BaseActivity<FilesDesign>() {
|
||||
override suspend fun main() {
|
||||
val uuid = intent.uuid ?: return finish()
|
||||
val profile = withProfile { queryByUUID(uuid) } ?: return finish()
|
||||
val root = uuid.toString()
|
||||
|
||||
val design = FilesDesign(this)
|
||||
val client = FilesClient(this)
|
||||
val stack = Stack<String>()
|
||||
|
||||
design.configurationEditable = profile.type != Profile.Type.Url
|
||||
design.fetch(client, stack, root)
|
||||
|
||||
setContentDesign(design)
|
||||
|
||||
val ticker = ticker(TimeUnit.MINUTES.toMillis(1))
|
||||
|
||||
while (isActive) {
|
||||
select<Unit> {
|
||||
events.onReceive {
|
||||
when (it) {
|
||||
Event.ActivityStart, Event.ActivityStop -> {
|
||||
design.fetch(client, stack, root)
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
design.requests.onReceive {
|
||||
try {
|
||||
when (it) {
|
||||
FilesDesign.Request.PopStack -> {
|
||||
if (stack.empty()) {
|
||||
finish()
|
||||
} else {
|
||||
stack.pop()
|
||||
}
|
||||
}
|
||||
is FilesDesign.Request.OpenDirectory -> {
|
||||
stack.push(it.file.id)
|
||||
}
|
||||
is FilesDesign.Request.OpenFile -> {
|
||||
startActivityForResult(
|
||||
ActivityResultContracts.StartActivityForResult(),
|
||||
Intent(Intent.ACTION_VIEW).setDataAndType(
|
||||
client.buildDocumentUri(it.file.id),
|
||||
"text/plain"
|
||||
).grantPermissions()
|
||||
)
|
||||
}
|
||||
is FilesDesign.Request.DeleteFile -> {
|
||||
client.deleteDocument(it.file.id)
|
||||
}
|
||||
is FilesDesign.Request.RenameFile -> {
|
||||
val newName = design.requestFileName(it.file.name)
|
||||
|
||||
client.renameDocument(it.file.id, newName)
|
||||
}
|
||||
is FilesDesign.Request.ImportFile -> {
|
||||
if (Build.VERSION.SDK_INT >= 23) {
|
||||
val hasPermission = ContextCompat.checkSelfPermission(
|
||||
this@FilesActivity,
|
||||
Manifest.permission.READ_EXTERNAL_STORAGE
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
|
||||
if (!hasPermission) {
|
||||
val granted = startActivityForResult(
|
||||
ActivityResultContracts.RequestPermission(),
|
||||
Manifest.permission.READ_EXTERNAL_STORAGE,
|
||||
)
|
||||
|
||||
if (!granted) {
|
||||
return@onReceive
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val uri: Uri? = startActivityForResult(
|
||||
ActivityResultContracts.GetContent(),
|
||||
"*/*"
|
||||
)
|
||||
|
||||
if (uri != null) {
|
||||
if (it.file == null) {
|
||||
val name = design.requestFileName(uri.fileName ?: "File")
|
||||
|
||||
client.importDocument(stack.last(), uri, name)
|
||||
} else {
|
||||
client.copyDocument(it.file!!.id, uri)
|
||||
}
|
||||
}
|
||||
}
|
||||
is FilesDesign.Request.ExportFile -> {
|
||||
val uri: Uri? = startActivityForResult(
|
||||
ActivityResultContracts.CreateDocument(),
|
||||
it.file.name
|
||||
)
|
||||
|
||||
if (uri != null) {
|
||||
client.copyDocument(uri, it.file.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
design.showExceptionToast(e)
|
||||
}
|
||||
|
||||
design.fetch(client, stack, root)
|
||||
}
|
||||
if (activityStarted) {
|
||||
ticker.onReceive {
|
||||
design.updateElapsed()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onBackPressed() {
|
||||
design?.requests?.trySend(FilesDesign.Request.PopStack)
|
||||
}
|
||||
|
||||
private suspend fun FilesDesign.fetch(client: FilesClient, stack: Stack<String>, root: String) {
|
||||
val documentId = stack.lastOrNull() ?: root
|
||||
val files = if (stack.empty()) {
|
||||
val list = client.list(documentId)
|
||||
val config = list.firstOrNull { it.id.endsWith("config.yaml") }
|
||||
|
||||
if (config == null || config.size > 0) list else listOf(config)
|
||||
} else {
|
||||
client.list(documentId)
|
||||
}
|
||||
|
||||
swapFiles(files, stack.empty())
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
package com.github.kr328.clash
|
||||
|
||||
import android.content.Intent
|
||||
import com.github.kr328.clash.design.HelpDesign
|
||||
import kotlinx.coroutines.isActive
|
||||
|
||||
class HelpActivity : BaseActivity<HelpDesign>() {
|
||||
override suspend fun main() {
|
||||
val design = HelpDesign(this) {
|
||||
startActivity(Intent(Intent.ACTION_VIEW).setData(it))
|
||||
}
|
||||
|
||||
setContentDesign(design)
|
||||
|
||||
while (isActive) {
|
||||
events.receive()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
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.core.event.LogEvent
|
||||
import com.github.kr328.clash.service.util.intent
|
||||
import kotlinx.android.synthetic.main.activity_log_viewer.*
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import java.io.File
|
||||
import kotlin.streams.toList
|
||||
|
||||
class LogViewerActivity : BaseActivity() {
|
||||
private val pauseMutex = Mutex()
|
||||
private var pollingThread: Thread? = null
|
||||
private val connection = object : ServiceConnection {
|
||||
override fun onServiceDisconnected(name: ComponentName?) {
|
||||
finish()
|
||||
}
|
||||
|
||||
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
|
||||
val logcat =
|
||||
requireNotNull(service?.queryLocalInterface(LogcatService::class.java.name)) as LogcatService
|
||||
|
||||
startLogcatPoll(logcat)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
setContentView(R.layout.activity_log_viewer)
|
||||
|
||||
setSupportActionBar(toolbar)
|
||||
|
||||
val file = intent?.data
|
||||
|
||||
if (file == null)
|
||||
startLiveMode()
|
||||
else
|
||||
startFileMode(file.toFile())
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
|
||||
pollingThread?.interrupt()
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
super.onStop()
|
||||
|
||||
launch {
|
||||
pauseMutex.lock()
|
||||
}
|
||||
}
|
||||
|
||||
override val activityLabel: CharSequence
|
||||
get() = getText(R.string.log_viewer)
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
|
||||
launch {
|
||||
if ( pauseMutex.isLocked )
|
||||
pauseMutex.unlock()
|
||||
}
|
||||
}
|
||||
|
||||
private fun startLiveMode() {
|
||||
mainList.layoutManager = LinearLayoutManager(this)
|
||||
mainList.adapter = LiveLogAdapter(this)
|
||||
mainList.itemAnimator?.addDuration = 100
|
||||
mainList.itemAnimator?.removeDuration = 100
|
||||
|
||||
stop.setOnClickListener {
|
||||
stopService(LogcatService::class.intent)
|
||||
finish()
|
||||
}
|
||||
|
||||
bindService(LogcatService::class.intent, connection, Context.BIND_AUTO_CREATE)
|
||||
}
|
||||
|
||||
private fun startFileMode(file: File) {
|
||||
stop.visibility = View.GONE
|
||||
|
||||
launch {
|
||||
val items = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
file.readText()
|
||||
.split("\n")
|
||||
.parallelStream()
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotEmpty() && !it.startsWith("#")}
|
||||
.map { it.split(" ", limit = 3) }
|
||||
.filter { it.size == 3 }
|
||||
.map { LogEvent(LogEvent.Level.valueOf(it[1]), it[2], it[0].toLong()) }
|
||||
.toList()
|
||||
}
|
||||
catch (e: Exception) {
|
||||
makeSnackbarException(getString(R.string.open_log_failure), e.message)
|
||||
|
||||
throw CancellationException()
|
||||
}
|
||||
}
|
||||
|
||||
mainList.layoutManager = LinearLayoutManager(this@LogViewerActivity)
|
||||
mainList.adapter = LogAdapter(this@LogViewerActivity, items)
|
||||
mainList.adapter!!.notifyItemRangeInserted(0, items.size)
|
||||
}
|
||||
}
|
||||
|
||||
private fun startLogcatPoll(service: LogcatService) {
|
||||
launch {
|
||||
var offset = 0L
|
||||
|
||||
while (isActive) {
|
||||
pauseMutex.lock()
|
||||
|
||||
val response = service.pollLogEvent(offset).await()
|
||||
|
||||
(mainList.adapter as LiveLogAdapter).insertItems(response.logs)
|
||||
|
||||
mainList.apply {
|
||||
if ( computeVerticalScrollOffset() < 30 )
|
||||
scrollToPosition(0)
|
||||
}
|
||||
|
||||
offset = response.offset + response.logs.size
|
||||
|
||||
pauseMutex.unlock()
|
||||
|
||||
delay(200)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,185 +0,0 @@
|
||||
package com.github.kr328.clash
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.ServiceConnection
|
||||
import android.net.Uri
|
||||
import android.os.IBinder
|
||||
import android.widget.Toast
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import com.github.kr328.clash.common.compat.startForegroundServiceCompat
|
||||
import com.github.kr328.clash.common.util.fileName
|
||||
import com.github.kr328.clash.common.util.intent
|
||||
import com.github.kr328.clash.common.util.ticker
|
||||
import com.github.kr328.clash.core.model.LogMessage
|
||||
import com.github.kr328.clash.design.LogcatDesign
|
||||
import com.github.kr328.clash.design.dialog.withModelProgressBar
|
||||
import com.github.kr328.clash.design.model.LogFile
|
||||
import com.github.kr328.clash.design.ui.ToastDuration
|
||||
import com.github.kr328.clash.design.util.showExceptionToast
|
||||
import com.github.kr328.clash.log.LogcatFilter
|
||||
import com.github.kr328.clash.log.LogcatReader
|
||||
import com.github.kr328.clash.util.logsDir
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.selects.select
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.OutputStreamWriter
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.suspendCoroutine
|
||||
|
||||
class LogcatActivity : BaseActivity<LogcatDesign>() {
|
||||
private var conn: ServiceConnection? = null
|
||||
|
||||
override suspend fun main() {
|
||||
val fileName = intent?.fileName
|
||||
|
||||
if (fileName != null) {
|
||||
val file = LogFile.parseFromFileName(fileName) ?: return showInvalid()
|
||||
|
||||
return mainLocalFile(file)
|
||||
}
|
||||
|
||||
return mainStreaming()
|
||||
}
|
||||
|
||||
private suspend fun mainLocalFile(file: LogFile) {
|
||||
val messages = try {
|
||||
LogcatReader(this, file).readAll()
|
||||
} catch (e: Exception) {
|
||||
return showInvalid()
|
||||
}
|
||||
|
||||
val design = LogcatDesign(this, false)
|
||||
|
||||
setContentDesign(design)
|
||||
|
||||
design.patchMessages(messages, 0, messages.size)
|
||||
|
||||
while (isActive) {
|
||||
when (design.requests.receive()) {
|
||||
LogcatDesign.Request.Delete -> {
|
||||
withContext(Dispatchers.IO) {
|
||||
logsDir.resolve(file.fileName).delete()
|
||||
}
|
||||
|
||||
finish()
|
||||
}
|
||||
LogcatDesign.Request.Export -> {
|
||||
val output = startActivityForResult(
|
||||
ActivityResultContracts.CreateDocument(),
|
||||
file.fileName
|
||||
)
|
||||
|
||||
if (output != null) {
|
||||
try {
|
||||
withContext(Dispatchers.IO) {
|
||||
writeLogTo(messages, file, output)
|
||||
}
|
||||
|
||||
design.showToast(R.string.file_exported, ToastDuration.Long)
|
||||
} catch (e: Exception) {
|
||||
design.showExceptionToast(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun mainStreaming() {
|
||||
val design = LogcatDesign(this, true)
|
||||
|
||||
setContentDesign(design)
|
||||
|
||||
startForegroundServiceCompat(LogcatService::class.intent)
|
||||
|
||||
val logcat = bindLogcatService()
|
||||
val ticker = ticker(500)
|
||||
|
||||
var initial = true
|
||||
|
||||
while (isActive) {
|
||||
select<Unit> {
|
||||
events.onReceive {
|
||||
|
||||
}
|
||||
design.requests.onReceive {
|
||||
when (it) {
|
||||
LogcatDesign.Request.Close -> {
|
||||
stopService(LogcatService::class.intent)
|
||||
|
||||
finish()
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
if (activityStarted) {
|
||||
ticker.onReceive {
|
||||
val snapshot = logcat.snapshot(initial) ?: return@onReceive
|
||||
|
||||
design.patchMessages(snapshot.messages, snapshot.removed, snapshot.appended)
|
||||
|
||||
initial = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
conn?.apply(this::unbindService)
|
||||
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
private suspend fun bindLogcatService(): LogcatService {
|
||||
return suspendCoroutine { ctx ->
|
||||
bindService(LogcatService::class.intent, object : ServiceConnection {
|
||||
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
|
||||
val srv = service!!.queryLocalInterface("") as LogcatService
|
||||
|
||||
ctx.resume(srv)
|
||||
|
||||
conn = this
|
||||
}
|
||||
|
||||
override fun onServiceDisconnected(name: ComponentName?) {
|
||||
conn = null
|
||||
}
|
||||
}, Context.BIND_AUTO_CREATE)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("BlockingMethodInNonBlockingContext")
|
||||
private suspend fun writeLogTo(messages: List<LogMessage>, file: LogFile, uri: Uri) {
|
||||
LogcatFilter(OutputStreamWriter(contentResolver.openOutputStream(uri)), this).use {
|
||||
withContext(Dispatchers.Main) {
|
||||
withModelProgressBar {
|
||||
configure {
|
||||
isIndeterminate = true
|
||||
max = messages.size
|
||||
}
|
||||
|
||||
withContext(Dispatchers.IO) {
|
||||
it.writeHeader(file.date)
|
||||
|
||||
messages.forEachIndexed { idx, msg ->
|
||||
configure {
|
||||
isIndeterminate = false
|
||||
progress = idx
|
||||
}
|
||||
|
||||
it.writeMessage(msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun showInvalid() {
|
||||
Toast.makeText(this, R.string.invalid_log_file, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.github.kr328.clash
|
||||
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.ComponentName
|
||||
@@ -7,30 +9,49 @@ 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.core.event.LogEvent
|
||||
import com.github.kr328.clash.core.utils.Log
|
||||
import com.github.kr328.clash.model.LogFile
|
||||
import com.github.kr328.clash.service.ClashManagerService
|
||||
import com.github.kr328.clash.service.IClashManager
|
||||
import com.github.kr328.clash.service.ipc.IStreamCallback
|
||||
import com.github.kr328.clash.service.ipc.ParcelableContainer
|
||||
import com.github.kr328.clash.service.util.intent
|
||||
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_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 {
|
||||
override fun onServiceDisconnected(name: ComponentName?) {
|
||||
@@ -38,127 +59,197 @@ class LogcatService : Service(), CoroutineScope by CoroutineScope(Dispatchers.De
|
||||
}
|
||||
|
||||
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
|
||||
startObserver(service ?: return stopSelf())
|
||||
val manager = IClashManager.Stub.asInterface(service) ?: return stopSelf()
|
||||
|
||||
manager.openLogEvent(object : IStreamCallback.Stub() {
|
||||
override fun complete() {
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
override fun completeExceptionally(reason: String?) {
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
override fun send(data: ParcelableContainer?) {
|
||||
val logEvent = (data?.data as LogEvent?) ?: return
|
||||
|
||||
if (!logChannel.offer(logEvent))
|
||||
Log.w("Drop log $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() {
|
||||
cancel()
|
||||
logChannel.close()
|
||||
|
||||
unbindService(connection)
|
||||
cancel()
|
||||
|
||||
stopForeground(true)
|
||||
|
||||
running = false
|
||||
|
||||
super.onDestroy()
|
||||
|
||||
isServiceRunning = false
|
||||
}
|
||||
|
||||
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)
|
||||
// Export to UI
|
||||
suspend fun pollLogEvent(offset: Long): CompletableDeferred<Response> {
|
||||
val request = Request(offset, CompletableDeferred())
|
||||
|
||||
requestChannel.send(request)
|
||||
|
||||
return request.response
|
||||
}
|
||||
|
||||
private fun startObserver(binder: IBinder) {
|
||||
if (!binder.isBinderAlive)
|
||||
return stopSelf()
|
||||
|
||||
launch(Dispatchers.IO) {
|
||||
val service = binder.unwrap(IRemoteService::class).clash()
|
||||
val channel = Channel<LogMessage>(CACHE_CAPACITY)
|
||||
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))
|
||||
.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,261 @@
|
||||
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.design.common.Category
|
||||
import com.github.kr328.clash.design.view.CommonUiLayout
|
||||
import com.github.kr328.clash.model.LogFile
|
||||
import com.github.kr328.clash.service.util.intent
|
||||
import com.github.kr328.clash.service.util.startForegroundServiceCompat
|
||||
import com.github.kr328.clash.utils.format
|
||||
import com.github.kr328.clash.utils.logsDir
|
||||
import com.google.android.material.bottomsheet.BottomSheetDialog
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import kotlinx.android.synthetic.main.activity_logs.*
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.selects.select
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.FileInputStream
|
||||
import java.util.*
|
||||
|
||||
class LogsActivity : BaseActivity<LogsDesign>() {
|
||||
override suspend fun main() {
|
||||
if (LogcatService.running) {
|
||||
return startActivity(LogcatActivity::class.intent)
|
||||
class LogsActivity : BaseActivity() {
|
||||
companion object {
|
||||
const val REQUEST_CODE = 50000
|
||||
}
|
||||
|
||||
private var lastWriteFile: LogFile? = null
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_logs)
|
||||
|
||||
setSupportActionBar(toolbar)
|
||||
|
||||
if ( LogcatService.isServiceRunning ) {
|
||||
startActivity(LogViewerActivity::class.intent)
|
||||
finish()
|
||||
return
|
||||
}
|
||||
|
||||
val design = LogsDesign(this)
|
||||
commonUi.build {
|
||||
option(
|
||||
title = getString(R.string.clash_logcat),
|
||||
summary = getString(R.string.tap_to_start),
|
||||
icon = getDrawable(R.drawable.ic_adb)
|
||||
) {
|
||||
onClick {
|
||||
startForegroundServiceCompat(LogcatService::class.intent)
|
||||
|
||||
setContentDesign(design)
|
||||
startActivity(LogViewerActivity::class.intent)
|
||||
|
||||
while (isActive) {
|
||||
select<Unit> {
|
||||
events.onReceive {
|
||||
when (it) {
|
||||
Event.ActivityStart -> {
|
||||
val files = withContext(Dispatchers.IO) {
|
||||
loadFiles()
|
||||
}
|
||||
|
||||
design.patchLogs(files)
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
finish()
|
||||
}
|
||||
design.requests.onReceive {
|
||||
when (it) {
|
||||
LogsDesign.Request.StartLogcat -> {
|
||||
startActivity(LogcatActivity::class.intent)
|
||||
}
|
||||
category(text = getString(R.string.history), id = "history", showTopSeparator = true)
|
||||
}
|
||||
|
||||
finish()
|
||||
}
|
||||
LogsDesign.Request.DeleteAll -> {
|
||||
if (design.requestDeleteAll()) {
|
||||
withContext(Dispatchers.IO) {
|
||||
deleteAllLogs()
|
||||
}
|
||||
clearAll.setOnClickListener {
|
||||
showClearAllDialog()
|
||||
}
|
||||
|
||||
events.trySend(Event.ActivityStart)
|
||||
val adapter = LogFileAdapter(this@LogsActivity,
|
||||
onItemClicked = {
|
||||
startActivity(LogViewerActivity::class.intent
|
||||
.setData(Uri.fromFile(logsDir.resolve(it.fileName))))
|
||||
},
|
||||
onMenuClicked = this::showMenu)
|
||||
val layoutManager = LinearLayoutManager(this@LogsActivity)
|
||||
|
||||
mainList.layoutManager = layoutManager
|
||||
mainList.adapter = adapter
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
|
||||
if ( LogcatService.isServiceRunning )
|
||||
return
|
||||
|
||||
refreshList()
|
||||
}
|
||||
|
||||
override val activityLabel: CharSequence
|
||||
get() = getText(R.string.logs)
|
||||
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||
if ( requestCode == REQUEST_CODE ) {
|
||||
if ( resultCode == Activity.RESULT_OK ) {
|
||||
val url = data?.data ?: return
|
||||
val file = lastWriteFile ?: return
|
||||
|
||||
lastWriteFile = null
|
||||
|
||||
launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
contentResolver.openOutputStream(url)?.use { output ->
|
||||
FileInputStream(logsDir.resolve(file.fileName)).use { input ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
}
|
||||
is LogsDesign.Request.OpenFile -> {
|
||||
startActivity(LogcatActivity::class.intent.setFileName(it.file.fileName))
|
||||
}
|
||||
}
|
||||
|
||||
Snackbar.make(rootView, R.string.file_exported, Snackbar.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
super.onActivityResult(requestCode, resultCode, data)
|
||||
}
|
||||
|
||||
private fun refreshList() {
|
||||
launch {
|
||||
val files = withContext(Dispatchers.IO) {
|
||||
(logsDir.listFiles() ?: emptyArray())
|
||||
.asSequence()
|
||||
.filter { it.name.endsWith(".log") }
|
||||
.map { LogFile.parseFromFileName(it.name) }
|
||||
.filterNotNull()
|
||||
.toList()
|
||||
}
|
||||
|
||||
if (files.isEmpty())
|
||||
commonUi.screen.requireElement<Category>("history").isHidden = true
|
||||
|
||||
val adapter = mainList.adapter as LogFileAdapter
|
||||
val old = adapter.fileList
|
||||
|
||||
val result = withContext(Dispatchers.Default) {
|
||||
DiffUtil.calculateDiff(object: DiffUtil.Callback() {
|
||||
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
|
||||
return old[oldItemPosition].fileName == files[newItemPosition].fileName
|
||||
}
|
||||
|
||||
override fun getOldListSize(): Int {
|
||||
return old.size
|
||||
}
|
||||
|
||||
override fun getNewListSize(): Int {
|
||||
return files.size
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(
|
||||
oldItemPosition: Int,
|
||||
newItemPosition: Int
|
||||
): Boolean {
|
||||
return old[oldItemPosition] == files[newItemPosition]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
adapter.fileList = files
|
||||
result.dispatchUpdatesTo(adapter)
|
||||
}
|
||||
}
|
||||
|
||||
private fun showClearAllDialog() {
|
||||
AlertDialog.Builder(this)
|
||||
.setTitle(R.string.delete_all_logs)
|
||||
.setMessage(R.string.delete_all_logs_warn)
|
||||
.setPositiveButton(R.string.ok) { _, _ -> deleteAllLogs() }
|
||||
.setNegativeButton(R.string.cancel) { _, _ -> }
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun showMenu(logFile: LogFile) {
|
||||
val dialog = BottomSheetDialog(this)
|
||||
val menu = CommonUiLayout(this).apply {
|
||||
layoutParams = ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
)
|
||||
}
|
||||
@ColorInt
|
||||
val errorColor = TypedValue().run {
|
||||
theme.resolveAttribute(R.attr.colorError, this, true)
|
||||
data
|
||||
}
|
||||
|
||||
menu.build {
|
||||
option(
|
||||
icon = getDrawable(R.drawable.ic_save),
|
||||
title = getString(R.string.export)) {
|
||||
onClick {
|
||||
export(logFile)
|
||||
|
||||
dialog.dismiss()
|
||||
}
|
||||
}
|
||||
option(
|
||||
icon = getDrawable(R.drawable.ic_delete_colorful),
|
||||
title = getString(R.string.delete)) {
|
||||
textColor = errorColor
|
||||
|
||||
onClick {
|
||||
delete(logFile)
|
||||
|
||||
dialog.dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadFiles(): List<LogFile> {
|
||||
val list = cacheDir.resolve("logs").listFiles()?.toList() ?: emptyList()
|
||||
|
||||
return list.mapNotNull { LogFile.parseFromFileName(it.name) }
|
||||
dialog.dismissWithAnimation = true
|
||||
dialog.setContentView(menu)
|
||||
dialog.show()
|
||||
}
|
||||
|
||||
private fun deleteAllLogs() {
|
||||
logsDir.deleteRecursively()
|
||||
launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
logsDir.deleteRecursively()
|
||||
}
|
||||
|
||||
refreshList()
|
||||
}
|
||||
}
|
||||
|
||||
private fun export(file: LogFile) {
|
||||
if ( lastWriteFile != null )
|
||||
return
|
||||
|
||||
val d = Date(file.date)
|
||||
|
||||
val exportName = getString(R.string.format_export_log_name, d.format(this))
|
||||
|
||||
val intent = Intent(Intent.ACTION_CREATE_DOCUMENT)
|
||||
.setType("text/plain")
|
||||
.putExtra(Intent.EXTRA_TITLE, exportName)
|
||||
|
||||
lastWriteFile = file
|
||||
|
||||
startActivityForResult(intent, REQUEST_CODE)
|
||||
}
|
||||
|
||||
private fun delete(file: LogFile) {
|
||||
val d = {
|
||||
launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
logsDir.resolve(file.fileName).delete()
|
||||
}
|
||||
|
||||
refreshList()
|
||||
}
|
||||
}
|
||||
|
||||
AlertDialog.Builder(this)
|
||||
.setTitle(R.string.delete_log)
|
||||
.setMessage(getString(R.string.delete_log_warn, file.fileName))
|
||||
.setPositiveButton(R.string.ok) { _, _ -> d() }
|
||||
.setNegativeButton(R.string.cancel) { _, _ -> }
|
||||
.show()
|
||||
}
|
||||
}
|
||||
@@ -1,151 +1,200 @@
|
||||
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.core.model.General
|
||||
import com.github.kr328.clash.core.utils.asBytesString
|
||||
import com.github.kr328.clash.remote.withClash
|
||||
import com.github.kr328.clash.remote.withProfile
|
||||
import com.github.kr328.clash.service.ClashService
|
||||
import com.github.kr328.clash.service.data.ClashProfileEntity
|
||||
import com.github.kr328.clash.service.util.intent
|
||||
import com.github.kr328.clash.service.util.startForegroundServiceCompat
|
||||
import com.github.kr328.clash.utils.startClashService
|
||||
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) {
|
||||
stopService(ClashService::class.intent)
|
||||
} else {
|
||||
val vpnRequest = startClashService()
|
||||
if (vpnRequest != null)
|
||||
startActivityForResult(vpnRequest, REQUEST_CODE)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 val activityLabel: CharSequence? = null
|
||||
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||
if (requestCode == REQUEST_CODE) {
|
||||
if (resultCode == Activity.RESULT_OK)
|
||||
startForegroundServiceCompat(ClashService::class.intent)
|
||||
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)
|
||||
makeSnackbarException(getString(R.string.clash_start_failure), reason)
|
||||
}
|
||||
|
||||
override suspend fun onClashProfileLoaded(profile: ClashProfileEntity) {
|
||||
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 {
|
||||
queryActiveProfile()
|
||||
}
|
||||
|
||||
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,12 @@ package com.github.kr328.clash
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
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.core.Global
|
||||
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.Crashes
|
||||
|
||||
@Suppress("unused")
|
||||
class MainApplication : Application() {
|
||||
@@ -20,20 +21,15 @@ class MainApplication : Application() {
|
||||
super.onCreate()
|
||||
|
||||
// Initialize AppCenter
|
||||
Tracker.initialize(this)
|
||||
|
||||
val processName = currentProcessName
|
||||
|
||||
Log.d("Process $processName started")
|
||||
|
||||
if (processName == packageName) {
|
||||
Remote.launch()
|
||||
} else {
|
||||
sendServiceRecreated()
|
||||
if (BuildConfig.APP_CENTER_KEY.isNotEmpty() && !BuildConfig.DEBUG) {
|
||||
AppCenter.start(
|
||||
this,
|
||||
BuildConfig.APP_CENTER_KEY,
|
||||
Analytics::class.java, Crashes::class.java
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun finalize() {
|
||||
Global.destroy()
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.github.kr328.clash
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import com.github.kr328.clash.service.Intents
|
||||
import com.github.kr328.clash.service.ProfileBackgroundService
|
||||
import com.github.kr328.clash.service.util.componentName
|
||||
import com.github.kr328.clash.service.util.startForegroundServiceCompat
|
||||
import com.github.kr328.clash.utils.startClashService
|
||||
|
||||
class OnBootReceiver: BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
if ( intent?.action != Intent.ACTION_BOOT_COMPLETED || context == null )
|
||||
return
|
||||
|
||||
context.startClashService()
|
||||
context.startForegroundServiceCompat(Intent(Intents.INTENT_ACTION_PROFILE_SETUP)
|
||||
.setComponent(ProfileBackgroundService::class.componentName))
|
||||
}
|
||||
}
|
||||
@@ -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,169 @@
|
||||
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() {
|
||||
override val activityLabel: CharSequence?
|
||||
get() = getText(R.string.access_control_packages)
|
||||
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,278 @@
|
||||
package com.github.kr328.clash
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.text.Html
|
||||
import android.view.View
|
||||
import android.webkit.MimeTypeMap
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import com.github.kr328.clash.core.utils.Log
|
||||
import com.github.kr328.clash.design.common.TextInput
|
||||
import com.github.kr328.clash.remote.withProfile
|
||||
import com.github.kr328.clash.service.data.ClashProfileEntity
|
||||
import com.github.kr328.clash.service.ipc.IStreamCallback
|
||||
import com.github.kr328.clash.service.ipc.ParcelableContainer
|
||||
import com.github.kr328.clash.service.transact.ProfileRequest
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import kotlinx.android.synthetic.main.activity_profile_edit.*
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class ProfileEditActivity : BaseActivity() {
|
||||
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") ?: "*/*"
|
||||
}
|
||||
|
||||
private var modified = false
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
private val requestCallback = object : IStreamCallback.Stub() {
|
||||
override fun complete() {
|
||||
launch {
|
||||
setResult(Activity.RESULT_OK)
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
override fun completeExceptionally(reason: String?) {
|
||||
launch {
|
||||
makeSnackbarException(getString(R.string.download_failure), reason ?: "Unknown")
|
||||
processing = false
|
||||
}
|
||||
}
|
||||
|
||||
override fun send(data: ParcelableContainer?) {}
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_profile_edit)
|
||||
setSupportActionBar(toolbar)
|
||||
|
||||
settings.build {
|
||||
tips(icon = 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 = getDrawable(R.drawable.ic_label_outline),
|
||||
hint = getString(R.string.profile_name),
|
||||
content = intent.getStringExtra("name") ?: "",
|
||||
id = KEY_NAME
|
||||
) {
|
||||
onTextChanged {
|
||||
modified = true
|
||||
}
|
||||
}
|
||||
textInput(
|
||||
title = getString(R.string.url),
|
||||
icon = getDrawable(R.drawable.ic_content),
|
||||
hint = getString(R.string.profile_url),
|
||||
content = intent.getStringExtra("url") ?: "",
|
||||
id = KEY_URL
|
||||
) {
|
||||
onOpenInput {
|
||||
if (!openUrlProvider())
|
||||
openDialogInput()
|
||||
}
|
||||
onDisplayContent {
|
||||
it.split("/").last()
|
||||
}
|
||||
onTextChanged {
|
||||
modified = true
|
||||
}
|
||||
}
|
||||
textInput(
|
||||
title = getString(R.string.auto_update),
|
||||
icon = getDrawable(R.drawable.ic_update),
|
||||
hint = getString(R.string.seconds),
|
||||
id = KEY_AUTO_UPDATE,
|
||||
content = intent.getStringExtra("interval") ?: ""
|
||||
) {
|
||||
onDisplayContent {
|
||||
val interval = it.toString().toIntOrNull() ?: 0
|
||||
|
||||
if (interval <= 0)
|
||||
getString(R.string.disabled)
|
||||
else
|
||||
getString(R.string.format_seconds, interval)
|
||||
}
|
||||
onTextChanged {
|
||||
val s = it.toString()
|
||||
|
||||
if (s.isNotEmpty() && s.toIntOrNull() == null) {
|
||||
content = ""
|
||||
Snackbar.make(rootView, R.string.invalid_interval, Snackbar.LENGTH_LONG)
|
||||
.show()
|
||||
} else {
|
||||
modified = true
|
||||
}
|
||||
}
|
||||
|
||||
if (intent.getStringExtra("type") == Constants.URL_PROVIDER_TYPE_FILE)
|
||||
isHidden = true
|
||||
}
|
||||
}
|
||||
|
||||
settings.screen.restoreState(savedInstanceState)
|
||||
|
||||
save.setOnClickListener {
|
||||
with(settings.screen) {
|
||||
val name = requireElement<TextInput>(KEY_NAME).content.toString()
|
||||
val url = Uri.parse(requireElement<TextInput>(KEY_URL).content.toString())
|
||||
val interval = requireElement<TextInput>(KEY_AUTO_UPDATE).content.toString()
|
||||
.toLongOrNull()?: 0
|
||||
|
||||
if (name.isBlank()) {
|
||||
Snackbar.make(rootView, R.string.empty_name, Snackbar.LENGTH_LONG).show()
|
||||
return@setOnClickListener
|
||||
}
|
||||
|
||||
if (url == null || url == Uri.EMPTY ||
|
||||
(url.scheme != "http" && url.scheme != "https" && url.scheme != "content")
|
||||
) {
|
||||
Snackbar.make(rootView, R.string.invalid_url, Snackbar.LENGTH_LONG).show()
|
||||
return@setOnClickListener
|
||||
}
|
||||
|
||||
processing = true
|
||||
|
||||
sendProfileRequest(name, url, interval)
|
||||
}
|
||||
}
|
||||
|
||||
when (intent.extras?.getLong("id", Long.MIN_VALUE)) {
|
||||
Long.MIN_VALUE -> {
|
||||
openUrlProvider()
|
||||
setTitle(R.string.new_profile)
|
||||
}
|
||||
-1L -> {
|
||||
setTitle(R.string.new_profile)
|
||||
}
|
||||
else -> {
|
||||
setTitle(R.string.edit_profile)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override val activityLabel: CharSequence? = null
|
||||
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||
if (requestCode == REQUEST_CODE) {
|
||||
if (resultCode != Activity.RESULT_OK || data == null)
|
||||
return
|
||||
|
||||
data.data?.apply {
|
||||
settings.screen.requireElement<TextInput>(KEY_URL).content = this.toString()
|
||||
}
|
||||
|
||||
data.getStringExtra(Constants.URL_PROVIDER_INTENT_EXTRA_NAME)?.also {
|
||||
settings.screen.requireElement<TextInput>(KEY_NAME).apply {
|
||||
if (content.isBlank())
|
||||
content = it
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
super.onActivityResult(requestCode, resultCode, data)
|
||||
}
|
||||
|
||||
override fun onBackPressed() {
|
||||
if (!modified)
|
||||
return super.onBackPressed()
|
||||
|
||||
if (processing) {
|
||||
Snackbar.make(rootView, R.string.processing, Snackbar.LENGTH_LONG).show()
|
||||
return
|
||||
}
|
||||
|
||||
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 onSaveInstanceState(outState: Bundle) {
|
||||
super.onSaveInstanceState(outState)
|
||||
|
||||
settings.screen.saveState(outState)
|
||||
}
|
||||
|
||||
private fun openUrlProvider(): Boolean {
|
||||
val type = intent.getStringExtra("type")
|
||||
val externalIntent = intent.getParcelableExtra<Intent>("intent")
|
||||
|
||||
try {
|
||||
when (type) {
|
||||
Constants.URL_PROVIDER_TYPE_FILE ->
|
||||
startActivityForResult(
|
||||
Intent(Intent.ACTION_GET_CONTENT).setType(TYPE_YAML),
|
||||
REQUEST_CODE
|
||||
)
|
||||
Constants.URL_PROVIDER_TYPE_EXTERNAL ->
|
||||
startActivityForResult(
|
||||
externalIntent ?: throw NullPointerException(),
|
||||
REQUEST_CODE
|
||||
)
|
||||
else -> return false
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
makeSnackbarException(getString(R.string.start_url_provider_failure), e.message)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
private fun sendProfileRequest(name: String, url: Uri, interval: Long) {
|
||||
launch {
|
||||
val source = intent?.getParcelableExtra<Intent>("intent")?.toUri(0)?.run(Uri::parse)
|
||||
val type = when (intent?.getStringExtra("type")) {
|
||||
Constants.URL_PROVIDER_TYPE_FILE ->
|
||||
ClashProfileEntity.TYPE_FILE
|
||||
Constants.URL_PROVIDER_TYPE_URL ->
|
||||
ClashProfileEntity.TYPE_URL
|
||||
Constants.URL_PROVIDER_TYPE_EXTERNAL ->
|
||||
ClashProfileEntity.TYPE_EXTERNAL
|
||||
else -> throw IllegalArgumentException()
|
||||
}
|
||||
|
||||
Log.d(interval.toString())
|
||||
|
||||
val request = ProfileRequest()
|
||||
.action(ProfileRequest.Action.UPDATE_OR_CREATE)
|
||||
.withId(intent.getLongExtra("id", -1L))
|
||||
.withName(name)
|
||||
.withURL(url)
|
||||
.withUpdateInterval(interval)
|
||||
.withCallback(requestCallback)
|
||||
.withType(type)
|
||||
.withSource(source)
|
||||
|
||||
withProfile {
|
||||
enqueueRequest(request)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,77 +1,210 @@
|
||||
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 com.github.kr328.clash.service.model.Profile
|
||||
import com.github.kr328.clash.util.withProfile
|
||||
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.remote.withProfile
|
||||
import com.github.kr328.clash.service.Intents
|
||||
import com.github.kr328.clash.service.ProfileBackgroundService
|
||||
import com.github.kr328.clash.service.data.ClashProfileEntity
|
||||
import com.github.kr328.clash.service.transact.ProfileRequest
|
||||
import com.github.kr328.clash.service.util.componentName
|
||||
import com.github.kr328.clash.service.util.intent
|
||||
import com.github.kr328.clash.service.util.startForegroundServiceCompat
|
||||
import com.github.kr328.clash.weight.ProfilesMenu
|
||||
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.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<String>()
|
||||
|
||||
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 val activityLabel: CharSequence?
|
||||
get() = getText(R.string.profiles)
|
||||
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||
if (requestCode == EDITOR_REQUEST_CODE) {
|
||||
launch {
|
||||
val uri = editorStack.pop()
|
||||
|
||||
withProfile {
|
||||
commitProfileEditUri(uri)
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
super.onActivityResult(requestCode, resultCode, data)
|
||||
}
|
||||
|
||||
override suspend fun onClashProfileChanged() {
|
||||
reloadProfiles()
|
||||
}
|
||||
|
||||
private suspend fun reloadProfiles() {
|
||||
if (!reloadMutex.tryLock())
|
||||
return
|
||||
|
||||
val profiles = withProfile {
|
||||
queryProfiles()
|
||||
}
|
||||
|
||||
(mainList.adapter as ProfileAdapter)
|
||||
.setEntitiesAsync(profiles.toList())
|
||||
|
||||
reloadMutex.unlock()
|
||||
}
|
||||
|
||||
override fun onProfileClicked(entity: ClashProfileEntity) {
|
||||
launch {
|
||||
withProfile {
|
||||
setActiveProfile(entity.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMenuClicked(entity: ClashProfileEntity) {
|
||||
ProfilesMenu(this, entity, this).show()
|
||||
}
|
||||
|
||||
override fun onNewProfile() {
|
||||
startActivity(CreateProfileActivity::class.intent)
|
||||
}
|
||||
|
||||
private fun deleteProfile(entity: ClashProfileEntity) = launch {
|
||||
val request = ProfileRequest().action(ProfileRequest.Action.REMOVE).withId(entity.id)
|
||||
|
||||
withProfile {
|
||||
enqueueRequest(request)
|
||||
}
|
||||
}
|
||||
|
||||
private fun resetProviders(entity: ClashProfileEntity) = launch {
|
||||
val request = ProfileRequest().action(ProfileRequest.Action.CLEAR).withId(entity.id)
|
||||
|
||||
withProfile {
|
||||
enqueueRequest(request)
|
||||
}
|
||||
}
|
||||
|
||||
private fun openPropertiesEditor(entity: ClashProfileEntity, duplicate: Boolean) {
|
||||
val type = when (entity.type) {
|
||||
ClashProfileEntity.TYPE_FILE ->
|
||||
Constants.URL_PROVIDER_TYPE_FILE
|
||||
ClashProfileEntity.TYPE_URL ->
|
||||
Constants.URL_PROVIDER_TYPE_URL
|
||||
ClashProfileEntity.TYPE_EXTERNAL ->
|
||||
Constants.URL_PROVIDER_TYPE_EXTERNAL
|
||||
else -> throw IllegalArgumentException("Invalid type ${entity.type}")
|
||||
}
|
||||
val intent = entity.source?.run { Intent.parseUri(this, 0) }
|
||||
val name = entity.name
|
||||
val uri = entity.uri
|
||||
val interval = entity.updateInterval.toString()
|
||||
|
||||
val editor = ProfileEditActivity::class.intent
|
||||
.putExtra("id", if (duplicate) -1L else entity.id)
|
||||
.putExtra("type", type)
|
||||
.putExtra("intent", intent)
|
||||
.putExtra("name", name)
|
||||
.putExtra("url", uri)
|
||||
.putExtra("interval", interval)
|
||||
|
||||
startActivity(editor)
|
||||
}
|
||||
|
||||
private fun openEditor(entity: ClashProfileEntity) = launch {
|
||||
val uri = withProfile {
|
||||
requestProfileEditUri(entity.id)
|
||||
} ?: return@launch
|
||||
|
||||
editorStack.push(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
|
||||
)
|
||||
}
|
||||
|
||||
private fun startUpdate(entity: ClashProfileEntity) {
|
||||
val request = ProfileRequest()
|
||||
.action(ProfileRequest.Action.UPDATE_OR_CREATE)
|
||||
.withId(entity.id)
|
||||
|
||||
val intent = Intent(Intents.INTENT_ACTION_PROFILE_ENQUEUE_REQUEST)
|
||||
.setComponent(ProfileBackgroundService::class.componentName)
|
||||
.putExtra(Intents.INTENT_EXTRA_PROFILE_REQUEST, request)
|
||||
|
||||
startForegroundServiceCompat(intent)
|
||||
}
|
||||
|
||||
override fun onOpenEditor(entity: ClashProfileEntity) {
|
||||
openEditor(entity)
|
||||
}
|
||||
|
||||
override fun onUpdate(entity: ClashProfileEntity) {
|
||||
startUpdate(entity)
|
||||
}
|
||||
|
||||
override fun onOpenProperties(entity: ClashProfileEntity) {
|
||||
openPropertiesEditor(entity, false)
|
||||
}
|
||||
|
||||
override fun onDuplicate(entity: ClashProfileEntity) {
|
||||
openPropertiesEditor(entity, true)
|
||||
}
|
||||
|
||||
override fun onResetProvider(entity: ClashProfileEntity) {
|
||||
resetProviders(entity)
|
||||
}
|
||||
|
||||
override fun onDelete(entity: ClashProfileEntity) {
|
||||
deleteProfile(entity)
|
||||
}
|
||||
}
|
||||
@@ -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,350 @@
|
||||
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.core.model.Proxy
|
||||
import com.github.kr328.clash.preference.UiSettings
|
||||
import com.github.kr328.clash.remote.withClash
|
||||
import com.github.kr328.clash.utils.PrefixMerger
|
||||
import com.github.kr328.clash.utils.ProxySorter
|
||||
import com.github.kr328.clash.utils.ScrollBinding
|
||||
import kotlinx.android.synthetic.main.activity_proxies.*
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class ProxiesActivity : BaseActivity(), ScrollBinding.Callback {
|
||||
private val scrollBinding = ScrollBinding(this, this)
|
||||
private val doScrollToLastProxy by lazy {
|
||||
val selected = uiSettings.get(UiSettings.PROXY_LAST_SELECT_GROUP)
|
||||
|
||||
launch {
|
||||
scrollBinding.scrollMaster(selected)
|
||||
}
|
||||
}
|
||||
|
||||
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, { group, proxy ->
|
||||
launch {
|
||||
withClash {
|
||||
setSelectProxy(group, proxy)
|
||||
}
|
||||
}
|
||||
}, {
|
||||
launch {
|
||||
urlTesting.add(it)
|
||||
|
||||
withClash {
|
||||
urlTesting.add(it)
|
||||
|
||||
startHealthCheck(it)
|
||||
|
||||
urlTesting.remove(it)
|
||||
|
||||
refreshList()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
mainList.layoutManager = mainListAdapter.layoutManager
|
||||
|
||||
chipList.adapter = ProxyChipAdapter(this) {
|
||||
launch {
|
||||
scrollBinding.scrollMaster(it)
|
||||
}
|
||||
}
|
||||
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 {
|
||||
when (item.itemId) {
|
||||
R.id.modeDirect -> {
|
||||
withClash {
|
||||
setProxyMode(General.Mode.DIRECT)
|
||||
}
|
||||
}
|
||||
R.id.modeGlobal -> {
|
||||
withClash {
|
||||
setProxyMode(General.Mode.GLOBAL)
|
||||
}
|
||||
}
|
||||
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()
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override val activityLabel: CharSequence?
|
||||
get() = getText(R.string.proxy)
|
||||
|
||||
override suspend fun onClashStarted() {
|
||||
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 refreshList() {
|
||||
launch {
|
||||
val general = withClash {
|
||||
queryGeneral()
|
||||
}
|
||||
val proxies = withClash {
|
||||
queryAllProxyGroups()
|
||||
}
|
||||
|
||||
val prefixDeferred = async {
|
||||
if (uiSettings.get(UiSettings.PROXY_MERGE_PREFIX)) {
|
||||
proxies.map {
|
||||
async { PrefixMerger.merge(it.proxies.map { p -> it.name to p.name }) { it.second } }
|
||||
}.flatMap {
|
||||
it.await()
|
||||
}.map {
|
||||
it.value to it
|
||||
}.toMap()
|
||||
} else emptyMap()
|
||||
}
|
||||
|
||||
val sortDeferred = async {
|
||||
val groupSort = when (uiSettings.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 (uiSettings.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)
|
||||
|
||||
sorter.sort(proxies).run {
|
||||
when (general.mode) {
|
||||
General.Mode.GLOBAL -> this
|
||||
General.Mode.DIRECT -> emptyList()
|
||||
General.Mode.RULE -> this.filter { it.name != "GLOBAL" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val prefix = prefixDeferred.await()
|
||||
val sorted = sortDeferred.await()
|
||||
|
||||
val newList = withContext(Dispatchers.Default) {
|
||||
sorted.map {
|
||||
ProxyAdapter.ProxyGroupInfo(it.name,
|
||||
it.proxies.map { p ->
|
||||
val r = prefix.getOrElse(it.name to p.name) {
|
||||
PrefixMerger.Result(p.name, "", p)
|
||||
}
|
||||
|
||||
val data = if ( r.content.isEmpty() ) {
|
||||
r.prefix to p.type.toString()
|
||||
}
|
||||
else {
|
||||
r.content to r.prefix
|
||||
}
|
||||
|
||||
ProxyAdapter.ProxyInfo(
|
||||
p.name,
|
||||
it.name,
|
||||
data.first,
|
||||
data.second,
|
||||
p.delay.toShort(),
|
||||
it.type == Proxy.Type.SELECT,
|
||||
p.name == it.current
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
mainListAdapter.applyChange(newList, urlTesting)
|
||||
|
||||
(chipList.adapter!! as ProxyChipAdapter).apply {
|
||||
chips = sorted.map { it.name }
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
doScrollToLastProxy
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
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,32 +1,46 @@
|
||||
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.service.util.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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override val activityLabel: CharSequence?
|
||||
get() = getText(R.string.settings)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
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()
|
||||
}
|
||||
|
||||
override val activityLabel: CharSequence?
|
||||
get() = getText(R.string.behavior)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
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()
|
||||
}
|
||||
|
||||
override val activityLabel: CharSequence?
|
||||
get() = getText(R.string.interface_)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
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 val activityLabel: CharSequence?
|
||||
get() = getText(R.string.network)
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package com.github.kr328.clash
|
||||
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import androidx.core.content.getSystemService
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import kotlinx.android.synthetic.main.activity_support.*
|
||||
|
||||
class SupportActivity: BaseActivity() {
|
||||
override val activityLabel: CharSequence?
|
||||
get() = getText(R.string.support)
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
setContentView(R.layout.activity_support)
|
||||
setSupportActionBar(toolbar)
|
||||
|
||||
commonUi.build {
|
||||
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.contacts))
|
||||
|
||||
option(
|
||||
title = getString(R.string.email),
|
||||
summary = getString(R.string.email_url)
|
||||
) {
|
||||
onClick {
|
||||
val data =
|
||||
ClipData.newPlainText("email", getText(R.string.email_url))
|
||||
getSystemService<ClipboardManager>()?.setPrimaryClip(data)
|
||||
|
||||
Snackbar.make(rootView, getText(R.string.copied), Snackbar.LENGTH_SHORT).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))))
|
||||
}
|
||||
}
|
||||
|
||||
if ( resources.configuration.locales.get(0)
|
||||
.language.equals("zh", true) ) {
|
||||
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))))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,101 +4,96 @@ import android.content.BroadcastReceiver
|
||||
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.remote.RemoteUtils
|
||||
import com.github.kr328.clash.service.ClashService
|
||||
import com.github.kr328.clash.service.Intents
|
||||
import com.github.kr328.clash.service.data.ClashProfileEntity
|
||||
import com.github.kr328.clash.service.util.intent
|
||||
import com.github.kr328.clash.utils.startClashService
|
||||
|
||||
@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 -> {
|
||||
startClashService()
|
||||
}
|
||||
Tile.STATE_ACTIVE -> {
|
||||
stopClashService()
|
||||
stopService(ClashService::class.intent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
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 -> {
|
||||
when ( intent?.action ) {
|
||||
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 -> {
|
||||
val entity = intent.
|
||||
getParcelableExtra<ClashProfileEntity>(Intents.INTENT_EXTRA_PROFILE)
|
||||
|
||||
currentProfile = entity?.name ?: ""
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
)
|
||||
|
||||
clashRunning = RemoteUtils.detectClashRunning(this)
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
|
||||
unregisterReceiver(clashStatusReceiver)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
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,128 @@
|
||||
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.data.ClashProfileEntity
|
||||
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: ClashProfileEntity)
|
||||
fun onMenuClicked(entity: ClashProfileEntity)
|
||||
fun onNewProfile()
|
||||
}
|
||||
|
||||
private var entities: List<ClashProfileEntity> = 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<ClashProfileEntity>) {
|
||||
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.lastUpdate)
|
||||
|
||||
holder.root.setOnClickListener {
|
||||
callback.onProfileClicked(current)
|
||||
}
|
||||
holder.menu.setOnClickListener {
|
||||
callback.onMenuClicked(current)
|
||||
}
|
||||
}
|
||||
is FooterHolder -> {
|
||||
holder.root.setOnClickListener {
|
||||
callback.onNewProfile()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getTypeName(type: Int): CharSequence {
|
||||
return when (type) {
|
||||
ClashProfileEntity.TYPE_FILE ->
|
||||
context.getText(R.string.file)
|
||||
ClashProfileEntity.TYPE_URL ->
|
||||
context.getText(R.string.url)
|
||||
ClashProfileEntity.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,272 @@
|
||||
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.sync.Mutex
|
||||
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 proxies: List<ProxyInfo>
|
||||
)
|
||||
|
||||
data class ProxyInfo(
|
||||
val name: String,
|
||||
val group: String,
|
||||
val prefix: String,
|
||||
val content: 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 rootMutex = Mutex()
|
||||
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 {
|
||||
return when (renderList[position]) {
|
||||
is ProxyGroupRenderInfo -> spanCount
|
||||
is ProxyRenderInfo -> 1
|
||||
else -> throw IllegalArgumentException()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var root = listOf<ProxyGroupInfo>()
|
||||
|
||||
private class ProxyGroupHeader(view: View) : RecyclerView.ViewHolder(view) {
|
||||
val name: 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) {
|
||||
rootMutex.lock()
|
||||
|
||||
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.name] = index
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
root = newList
|
||||
renderList = newRenderList.toMutableList()
|
||||
urlTesting = testing
|
||||
groupPosition = groupCache
|
||||
activeList = activeCache
|
||||
result.dispatchUpdatesTo(this@ProxyAdapter)
|
||||
}
|
||||
|
||||
rootMutex.unlock()
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
groupPosition[current.name] = position
|
||||
|
||||
holder.name.text = current.info.name
|
||||
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.prefix
|
||||
holder.content.text = current.info.content
|
||||
|
||||
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) {
|
||||
activeList[current.group] = position
|
||||
|
||||
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 old = renderList[oldPosition] as ProxyRenderInfo
|
||||
val new = renderList[position] as ProxyRenderInfo
|
||||
|
||||
renderList[oldPosition] = old.copy(info = old.info.copy(active = false))
|
||||
renderList[position] = new.copy(info = new.info.copy(active = true))
|
||||
|
||||
notifyItemChanged(oldPosition)
|
||||
notifyItemChanged(position)
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,27 @@
|
||||
package com.github.kr328.clash.preference
|
||||
|
||||
import android.content.Context
|
||||
import com.github.kr328.clash.service.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 ENABLE_VPN = BooleanEntry("enable_vpn", true)
|
||||
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,99 +5,97 @@ 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 androidx.lifecycle.DefaultLifecycleObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.ProcessLifecycleOwner
|
||||
import com.github.kr328.clash.service.Intents
|
||||
import com.github.kr328.clash.service.data.ClashProfileEntity
|
||||
|
||||
class Broadcasts(private val context: Application) {
|
||||
interface Observer {
|
||||
fun onServiceRecreated()
|
||||
object Broadcasts {
|
||||
interface Receiver {
|
||||
fun onStarted()
|
||||
fun onStopped(cause: String?)
|
||||
fun onProfileChanged()
|
||||
fun onProfileLoaded()
|
||||
fun onProfileLoaded(profileEntity: ClashProfileEntity)
|
||||
}
|
||||
|
||||
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 -> {
|
||||
val profile = intent
|
||||
.getParcelableExtra<ClashProfileEntity>(Intents.INTENT_EXTRA_PROFILE) ?: return
|
||||
|
||||
receivers.forEach {
|
||||
it.onProfileLoaded()
|
||||
it.onProfileLoaded(profile)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
fun init(application: Application) {
|
||||
ProcessLifecycleOwner.get().lifecycle.addObserver(object : DefaultLifecycleObserver {
|
||||
override fun onStart(owner: LifecycleOwner) {
|
||||
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)
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
|
||||
clashRunning = StatusClient(context).currentProfile() != null
|
||||
} catch (e: Exception) {
|
||||
Log.w("Register global receiver: $e", e)
|
||||
}
|
||||
}
|
||||
val current = RemoteUtils.detectClashRunning(application)
|
||||
if (current != clashRunning) {
|
||||
clashRunning = current
|
||||
|
||||
fun unregister() {
|
||||
if (!registered)
|
||||
return
|
||||
if (current) {
|
||||
receivers.forEach {
|
||||
it.onStarted()
|
||||
}
|
||||
} else {
|
||||
receivers.forEach {
|
||||
it.onStopped(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
context.unregisterReceiver(broadcastReceiver)
|
||||
|
||||
clashRunning = false
|
||||
} catch (e: Exception) {
|
||||
Log.w("Unregister global receiver: $e", e)
|
||||
}
|
||||
override fun onStop(owner: LifecycleOwner) {
|
||||
application.unregisterReceiver(broadcastReceiver)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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,50 @@
|
||||
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.ipc.IStreamCallback
|
||||
import com.github.kr328.clash.service.ipc.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) {
|
||||
return@withContext 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,33 @@
|
||||
package com.github.kr328.clash.remote
|
||||
|
||||
import com.github.kr328.clash.service.IProfileService
|
||||
import com.github.kr328.clash.service.data.ClashProfileEntity
|
||||
import com.github.kr328.clash.service.transact.ProfileRequest
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class ProfileClient(private val service: IProfileService) {
|
||||
suspend fun queryProfiles(): Array<ClashProfileEntity> = withContext(Dispatchers.IO) {
|
||||
service.queryProfiles()
|
||||
}
|
||||
|
||||
suspend fun queryActiveProfile(): ClashProfileEntity? = withContext(Dispatchers.IO) {
|
||||
service.queryActiveProfile()
|
||||
}
|
||||
|
||||
suspend fun enqueueRequest(request: ProfileRequest) = withContext(Dispatchers.IO) {
|
||||
service.enqueueRequest(request)
|
||||
}
|
||||
|
||||
suspend fun setActiveProfile(id: Long) = withContext(Dispatchers.IO) {
|
||||
service.setActiveProfile(id)
|
||||
}
|
||||
|
||||
suspend fun requestProfileEditUri(id: Long): String? = withContext(Dispatchers.IO) {
|
||||
service.requestProfileEditUri(id)
|
||||
}
|
||||
|
||||
suspend fun commitProfileEditUri(uri: String) = withContext(Dispatchers.IO) {
|
||||
service.commitProfileEditUri(uri)
|
||||
}
|
||||
}
|
||||
@@ -1,71 +1,162 @@
|
||||
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.Handler
|
||||
import android.os.IBinder
|
||||
import androidx.core.content.edit
|
||||
import androidx.lifecycle.DefaultLifecycleObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.ProcessLifecycleOwner
|
||||
import com.github.kr328.clash.ApkBrokenActivity
|
||||
import com.github.kr328.clash.AppCrashedActivity
|
||||
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.Constants
|
||||
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.service.util.intent
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.launch
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun init(application: Application) {
|
||||
val handler = Handler()
|
||||
|
||||
ProcessLifecycleOwner.get().lifecycle.addObserver(object : DefaultLifecycleObserver {
|
||||
override fun onStart(owner: LifecycleOwner) {
|
||||
handler.removeMessages(0)
|
||||
|
||||
GlobalScope.launch {
|
||||
if (!verifyApk(application)) {
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStop(owner: LifecycleOwner) {
|
||||
handler.postDelayed({
|
||||
clashConnection?.also {
|
||||
application.unbindService(it)
|
||||
it.onServiceDisconnected(null)
|
||||
}
|
||||
profileConnection?.also {
|
||||
application.unbindService(it)
|
||||
it.onServiceDisconnected(null)
|
||||
}
|
||||
|
||||
clashConnection = null
|
||||
profileConnection = null
|
||||
}, 5000)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private suspend fun verifyApk(application: Application): Boolean {
|
||||
return withContext(Dispatchers.IO) {
|
||||
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@withContext true
|
||||
|
||||
val info = application.applicationInfo
|
||||
val sources =
|
||||
info.splitSourceDirs ?: arrayOf(info.sourceDir) ?: return@withContext false
|
||||
|
||||
for (apk in sources) {
|
||||
if (ZipFile(apk).entries().asSequence().any { it.name.endsWith("libgojni.so") }) {
|
||||
sp.edit {
|
||||
putLong(Constants.PREFERENCE_KEY_LAST_INSTALL, pkg.lastUpdateTime)
|
||||
}
|
||||
return@withContext true
|
||||
}
|
||||
}
|
||||
return@withContext false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.github.kr328.clash.remote
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import com.github.kr328.clash.service.Constants
|
||||
import com.github.kr328.clash.service.ServiceStatusProvider
|
||||
|
||||
object RemoteUtils {
|
||||
fun detectClashRunning(context: Context): Boolean {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
package com.github.kr328.clash.remote
|
||||
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
class Resource<T> {
|
||||
private interface Callback<T> {
|
||||
fun accept(value: T)
|
||||
}
|
||||
|
||||
private val pending: MutableSet<Callback<T>> = mutableSetOf()
|
||||
|
||||
private var value: T? = null
|
||||
|
||||
suspend fun get(): T {
|
||||
return suspendCancellableCoroutine { ctx ->
|
||||
val callback = object : Callback<T> {
|
||||
override fun accept(value: T) {
|
||||
ctx.resume(value)
|
||||
}
|
||||
}
|
||||
|
||||
ctx.invokeOnCancellation {
|
||||
cancel(callback)
|
||||
}
|
||||
|
||||
get(callback)
|
||||
}
|
||||
}
|
||||
|
||||
fun set(v: T?) {
|
||||
setAndNotify(v)
|
||||
}
|
||||
|
||||
fun reset(v: T) {
|
||||
resetIfMatched(v)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun get(callback: Callback<T>) {
|
||||
val v = value
|
||||
|
||||
if (v == null) {
|
||||
pending.add(callback)
|
||||
} else {
|
||||
callback.accept(v)
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun setAndNotify(value: T?) {
|
||||
this.value = value
|
||||
|
||||
if (value != null) {
|
||||
pending.forEach {
|
||||
it.accept(value)
|
||||
}
|
||||
|
||||
pending.clear()
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun resetIfMatched(value: T) {
|
||||
if (this.value === value) {
|
||||
this.value = null
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun cancel(callback: Callback<T>) {
|
||||
pending.remove(callback)
|
||||
}
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
package com.github.kr328.clash.remote
|
||||
|
||||
import android.app.Application
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.ServiceConnection
|
||||
import android.os.IBinder
|
||||
import com.github.kr328.clash.Tracker
|
||||
import com.github.kr328.clash.common.log.Log
|
||||
import com.github.kr328.clash.common.util.intent
|
||||
import com.github.kr328.clash.log.SystemLogcat
|
||||
import com.github.kr328.clash.service.RemoteService
|
||||
import com.github.kr328.clash.service.remote.IRemoteService
|
||||
import com.github.kr328.clash.service.remote.unwrap
|
||||
import com.github.kr328.clash.util.unbindServiceSilent
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class Service(private val context: Application, val crashed: () -> Unit) {
|
||||
val remote = Resource<IRemoteService>()
|
||||
|
||||
private val connection = object : ServiceConnection {
|
||||
private var lastCrashed: Long = -1
|
||||
|
||||
override fun onServiceConnected(name: ComponentName?, service: IBinder) {
|
||||
remote.set(service.unwrap(IRemoteService::class))
|
||||
}
|
||||
|
||||
override fun onServiceDisconnected(name: ComponentName?) {
|
||||
remote.set(null)
|
||||
|
||||
Tracker.uploadLogcat(SystemLogcat.dumpCrash())
|
||||
|
||||
if (System.currentTimeMillis() - lastCrashed < TOGGLE_CRASHED_INTERVAL) {
|
||||
unbind()
|
||||
|
||||
crashed()
|
||||
}
|
||||
|
||||
lastCrashed = System.currentTimeMillis()
|
||||
|
||||
Log.w("RemoteManager crashed")
|
||||
}
|
||||
}
|
||||
|
||||
fun bind() {
|
||||
try {
|
||||
context.bindService(RemoteService::class.intent, connection, Context.BIND_AUTO_CREATE)
|
||||
} catch (e: Exception) {
|
||||
unbind()
|
||||
|
||||
crashed()
|
||||
}
|
||||
}
|
||||
|
||||
fun unbind() {
|
||||
context.unbindServiceSilent(connection)
|
||||
|
||||
remote.set(null)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TOGGLE_CRASHED_INTERVAL = TimeUnit.SECONDS.toMillis(10)
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
package com.github.kr328.clash.remote
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import com.github.kr328.clash.common.constants.Authorities
|
||||
import com.github.kr328.clash.common.log.Log
|
||||
import com.github.kr328.clash.service.StatusProvider
|
||||
|
||||
class StatusClient(private val context: Context) {
|
||||
private val uri: Uri
|
||||
get() {
|
||||
return Uri.Builder()
|
||||
.scheme("content")
|
||||
.authority(Authorities.STATUS_PROVIDER)
|
||||
.build()
|
||||
}
|
||||
|
||||
fun currentProfile(): String? {
|
||||
return try {
|
||||
val result = context.contentResolver.call(
|
||||
uri,
|
||||
StatusProvider.METHOD_CURRENT_PROFILE,
|
||||
null,
|
||||
null
|
||||
)
|
||||
|
||||
result?.getString("name")
|
||||
} catch (e: Exception) {
|
||||
Log.w("Query current profile: $e", e)
|
||||
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.github.kr328.clash.settings
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import com.github.kr328.clash.preference.UiSettings
|
||||
import com.github.kr328.clash.service.settings.ServiceSettings
|
||||
import moe.shizuku.preference.PreferenceFragment
|
||||
|
||||
abstract class BaseSettingFragment: PreferenceFragment() {
|
||||
abstract fun onCreateDataStore(): SettingsDataStore
|
||||
abstract val xmlResourceId: Int
|
||||
|
||||
protected val service: ServiceSettings by lazy { ServiceSettings(requireActivity()) }
|
||||
protected val ui: UiSettings by lazy { UiSettings(requireActivity()) }
|
||||
|
||||
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||
preferenceManager.preferenceDataStore = onCreateDataStore()
|
||||
|
||||
setPreferencesFromResource(xmlResourceId, rootKey)
|
||||
}
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View? {
|
||||
val result = super.onCreateView(inflater, container, savedInstanceState)
|
||||
|
||||
setDivider(null)
|
||||
setDividerHeight(0)
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.github.kr328.clash.settings
|
||||
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Bundle
|
||||
import com.github.kr328.clash.OnBootReceiver
|
||||
import com.github.kr328.clash.R
|
||||
import com.github.kr328.clash.remote.Broadcasts
|
||||
import com.github.kr328.clash.service.settings.ServiceSettings
|
||||
import com.github.kr328.clash.service.util.componentName
|
||||
|
||||
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(
|
||||
OnBootReceiver::class.componentName,
|
||||
status,
|
||||
PackageManager.DONT_KILL_APP)
|
||||
}
|
||||
|
||||
override fun get(): Any? {
|
||||
val status = requireActivity().packageManager
|
||||
.getComponentEnabledSetting(OnBootReceiver::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,49 @@
|
||||
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.preference.UiSettings
|
||||
import com.github.kr328.clash.remote.Broadcasts
|
||||
import com.github.kr328.clash.service.settings.ServiceSettings
|
||||
import com.github.kr328.clash.service.util.intent
|
||||
|
||||
class NetworkFragment: BaseSettingFragment() {
|
||||
companion object {
|
||||
private const val KEY_ENABLE_VPN_SERVICE = "enable_vpn_service"
|
||||
private const val KEY_IPV6 = "ipv6"
|
||||
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, UiSettings.ENABLE_VPN.asSource(ui))
|
||||
on(KEY_IPV6, ServiceSettings.IPV6_SUPPORT.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.service.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,32 +0,0 @@
|
||||
package com.github.kr328.clash.util
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.VpnService
|
||||
import com.github.kr328.clash.common.compat.startForegroundServiceCompat
|
||||
import com.github.kr328.clash.common.constants.Intents
|
||||
import com.github.kr328.clash.common.util.intent
|
||||
import com.github.kr328.clash.design.store.UiStore
|
||||
import com.github.kr328.clash.service.ClashService
|
||||
import com.github.kr328.clash.service.TunService
|
||||
import com.github.kr328.clash.service.util.sendBroadcastSelf
|
||||
|
||||
fun Context.startClashService(): Intent? {
|
||||
val startTun = UiStore(this).enableVpn
|
||||
|
||||
if (startTun) {
|
||||
val vpnRequest = VpnService.prepare(this)
|
||||
if (vpnRequest != null)
|
||||
return vpnRequest
|
||||
|
||||
startForegroundServiceCompat(TunService::class.intent)
|
||||
} else {
|
||||
startForegroundServiceCompat(ClashService::class.intent)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
fun Context.stopClashService() {
|
||||
sendBroadcastSelf(Intent(Intents.ACTION_CLASH_REQUEST_STOP))
|
||||
}
|
||||
@@ -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,25 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package com.github.kr328.clash.utils
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
object PrefixMerger {
|
||||
private val REGEX_PREFIX_TRIM = Regex("[-]*$")
|
||||
|
||||
data class Result<T>(val prefix: String, val content: String, val value: T)
|
||||
|
||||
suspend fun <T> merge(values: List<T>, transform: (T) -> String): List<Result<T>> =
|
||||
withContext(Dispatchers.Default) {
|
||||
val pairs = values.map {
|
||||
transform(it).trim() to it
|
||||
}
|
||||
|
||||
val groups = mutableListOf<List<Pair<String, T>>>()
|
||||
var mergingGroup = mutableListOf<Pair<String, T>>()
|
||||
var currentChar: Char = 0.toChar()
|
||||
val result = mutableListOf<Result<T>>()
|
||||
|
||||
for (pair in pairs) {
|
||||
if (pair.first[0] == currentChar) {
|
||||
mergingGroup.add(pair)
|
||||
} else {
|
||||
if (mergingGroup.isNotEmpty()) {
|
||||
groups.add(mergingGroup)
|
||||
mergingGroup = mutableListOf()
|
||||
}
|
||||
|
||||
currentChar = pair.first[0]
|
||||
mergingGroup.add(pair)
|
||||
}
|
||||
}
|
||||
|
||||
if (mergingGroup.isNotEmpty())
|
||||
groups.add(mergingGroup)
|
||||
|
||||
for (group in groups) {
|
||||
var diffIndex = 0
|
||||
val size = group.map { it.first.length }.min() ?: 0
|
||||
|
||||
diff@ for (charIndex in 0 until size) {
|
||||
for (stringIndex in 0 until (group.size - 1)) {
|
||||
if (group[stringIndex].first[charIndex] != group[stringIndex + 1].first[charIndex])
|
||||
break@diff
|
||||
}
|
||||
|
||||
diffIndex++
|
||||
}
|
||||
|
||||
group.forEach {
|
||||
result.add(
|
||||
Result(
|
||||
it.first.substring(0, diffIndex)
|
||||
.replace(REGEX_PREFIX_TRIM, ""),
|
||||
it.first.substring(diffIndex),
|
||||
it.second
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package com.github.kr328.clash.utils
|
||||
|
||||
import com.github.kr328.clash.core.model.Proxy
|
||||
import com.github.kr328.clash.core.model.ProxyGroup
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class ProxySorter(private val groupOrder: Order, private val proxyOrder: Order) {
|
||||
enum class Order {
|
||||
DEFAULT, DELAY_INCREASE, DELAY_DECREASE, NAME_INCREASE, NAME_DECREASE
|
||||
}
|
||||
|
||||
suspend fun sort(proxyGroup: List<ProxyGroup>): List<ProxyGroup> =
|
||||
withContext(Dispatchers.Default) {
|
||||
val global = proxyGroup.singleOrNull {
|
||||
it.name == "GLOBAL"
|
||||
}
|
||||
|
||||
val sortedGroup = when (groupOrder) {
|
||||
Order.DEFAULT -> groupSortWithDefault(global, proxyGroup)
|
||||
Order.DELAY_INCREASE -> groupSortWithDelay(true, proxyGroup)
|
||||
Order.DELAY_DECREASE -> groupSortWithDelay(false, proxyGroup)
|
||||
Order.NAME_INCREASE -> groupSortWithName(true, proxyGroup)
|
||||
Order.NAME_DECREASE -> groupSortWithName(false, proxyGroup)
|
||||
}
|
||||
|
||||
sortedGroup.map {
|
||||
val sortedProxy = when (proxyOrder) {
|
||||
Order.DEFAULT -> it.proxies
|
||||
Order.DELAY_INCREASE -> proxySortWithDelay(true, it.proxies)
|
||||
Order.DELAY_DECREASE -> proxySortWithDelay(false, it.proxies)
|
||||
Order.NAME_INCREASE -> proxySortWithName(true, it.proxies)
|
||||
Order.NAME_DECREASE -> proxySortWithName(false, it.proxies)
|
||||
}
|
||||
|
||||
it.copy(proxies = sortedProxy)
|
||||
}
|
||||
}
|
||||
|
||||
private fun groupSortWithDefault(
|
||||
global: ProxyGroup?,
|
||||
proxyGroup: List<ProxyGroup>
|
||||
): List<ProxyGroup> {
|
||||
if (global == null) return proxyGroup
|
||||
|
||||
val orderMap = global.proxies.mapIndexed { index, proxy ->
|
||||
proxy.name to index
|
||||
}.toMap()
|
||||
|
||||
return proxyGroup.sortedBy {
|
||||
orderMap[it.name] ?: Int.MAX_VALUE
|
||||
}
|
||||
}
|
||||
|
||||
private fun groupSortWithName(
|
||||
increase: Boolean,
|
||||
proxyGroup: List<ProxyGroup>
|
||||
): List<ProxyGroup> {
|
||||
return if (increase)
|
||||
proxyGroup.sortedBy { it.name }
|
||||
else
|
||||
proxyGroup.sortedByDescending { it.name }
|
||||
}
|
||||
|
||||
private fun groupSortWithDelay(
|
||||
increase: Boolean,
|
||||
proxyGroup: List<ProxyGroup>
|
||||
): List<ProxyGroup> {
|
||||
return if (increase)
|
||||
proxyGroup.sortedBy { it.delay }
|
||||
else
|
||||
proxyGroup.sortedByDescending { it.delay }
|
||||
}
|
||||
|
||||
private fun proxySortWithName(
|
||||
increase: Boolean,
|
||||
proxies: List<Proxy>
|
||||
): List<Proxy> {
|
||||
return if (increase)
|
||||
proxies.sortedBy { it.name }
|
||||
else
|
||||
proxies.sortedByDescending { it.name }
|
||||
}
|
||||
|
||||
private fun proxySortWithDelay(
|
||||
increase: Boolean,
|
||||
proxies: List<Proxy>
|
||||
): List<Proxy> {
|
||||
return if (increase)
|
||||
proxies.sortedBy { it.delay }
|
||||
else
|
||||
proxies.sortedByDescending { it.delay }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package com.github.kr328.clash.utils
|
||||
|
||||
import android.content.Context
|
||||
import android.util.DisplayMetrics
|
||||
import androidx.recyclerview.widget.LinearSmoothScroller
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
class ScrollBinding(
|
||||
private val context: Context,
|
||||
private val callback: Callback
|
||||
) {
|
||||
interface Callback {
|
||||
fun getCurrentMasterToken(): String
|
||||
fun onMasterTokenChanged(token: String)
|
||||
fun getMasterTokenPosition(token: String): Int
|
||||
fun doMasterScroll(scroller: LinearSmoothScroller)
|
||||
}
|
||||
|
||||
private val updateChannel = Channel<Unit>(Channel.CONFLATED)
|
||||
private var preventSlaveScroll = false
|
||||
|
||||
fun sendMasterScrolled() {
|
||||
updateChannel.offer(Unit)
|
||||
}
|
||||
|
||||
fun scrollMaster(token: String) {
|
||||
val position = callback.getMasterTokenPosition(token)
|
||||
|
||||
if (position < 0)
|
||||
return
|
||||
|
||||
val scroller = (object : LinearSmoothScroller(context) {
|
||||
override fun getVerticalSnapPreference(): Int {
|
||||
return SNAP_TO_START
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
super.onStop()
|
||||
|
||||
preventSlaveScroll = false
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
|
||||
preventSlaveScroll = true
|
||||
}
|
||||
|
||||
override fun calculateSpeedPerPixel(displayMetrics: DisplayMetrics?): Float {
|
||||
return super.calculateSpeedPerPixel(displayMetrics) * 0.8f
|
||||
}
|
||||
|
||||
init {
|
||||
targetPosition = position
|
||||
}
|
||||
})
|
||||
|
||||
callback.doMasterScroll(scroller)
|
||||
}
|
||||
|
||||
suspend fun exec() {
|
||||
var lastToken: String? = null
|
||||
|
||||
while (true) {
|
||||
updateChannel.receive()
|
||||
|
||||
val currentToken = callback.getCurrentMasterToken()
|
||||
if (preventSlaveScroll || lastToken == currentToken)
|
||||
continue
|
||||
|
||||
lastToken = currentToken
|
||||
|
||||
callback.onMasterTokenChanged(currentToken)
|
||||
|
||||
delay(200)
|
||||
}
|
||||
}
|
||||
}
|
||||
Loaded 100 of 664 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user