Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8506303693 | ||
|
|
8758c561d2 | ||
|
|
04efed17af | ||
|
|
769b80b079 | ||
|
|
628eb2809e | ||
|
|
9f76a377ab | ||
|
|
26d3decd06 | ||
|
|
7b7ee554e3 | ||
|
|
eb7cd8923f | ||
|
|
c2c09f52cc | ||
|
|
a56b372a39 | ||
|
|
c3bab32b83 | ||
|
|
c18d78db85 | ||
|
|
178e6dcb07 | ||
|
|
a405372f6a | ||
|
|
b472580ca6 | ||
|
|
5a587167ae | ||
|
|
5149caf2b6 | ||
|
|
ffa559a571 | ||
|
|
5c3ab5470a | ||
|
|
e6859ff56d | ||
|
|
f16b0048f3 | ||
|
|
17b3a0d0ed | ||
|
|
1e9f38f2bb | ||
|
|
ef8f9e5738 | ||
|
|
bf59ead3b2 | ||
|
|
5dbd94e42c | ||
|
|
b92fc860eb | ||
|
|
20bdf171ee | ||
|
|
5a6ca2ce12 | ||
|
|
636941da70 | ||
|
|
5bc3975591 | ||
|
|
5e54e4561c | ||
|
|
a7b560b576 | ||
|
|
5f9d4c1572 | ||
|
|
a83ecb8b22 | ||
|
|
1cc88cc04f | ||
|
|
92bce6c2c6 | ||
|
|
ab96790c73 | ||
|
|
d5618321e9 | ||
|
|
4b8fc6ae9a | ||
|
|
152e24748b | ||
|
|
2efdf21130 | ||
|
|
e99c2b64b0 | ||
|
|
3915d9549e | ||
|
|
23077537f9 | ||
|
|
de0cbd6b29 | ||
|
|
d0efa0690a | ||
|
|
5b6d04d558 | ||
|
|
791794f59d | ||
|
|
6a6465ae5b | ||
|
|
056a78c7c4 | ||
|
|
11b0025faf | ||
|
|
3009b29e0c | ||
|
|
5d52d970a3 | ||
|
|
aef0b84275 | ||
|
|
12c704d89c | ||
|
|
38e070a565 | ||
|
|
c7d33c684b | ||
|
|
b2661d31fd | ||
|
|
53aa718674 | ||
|
|
c173114368 | ||
|
|
7006cb516c | ||
|
|
7809c30052 | ||
|
|
7e46d75159 | ||
|
|
87f96b9ec7 | ||
|
|
3920f338f6 | ||
|
|
2565231867 | ||
|
|
b7af3f7341 | ||
|
|
d93a7af534 | ||
|
|
1dcc8307ee | ||
|
|
9936a9469d | ||
|
|
0e245b8936 | ||
|
|
226c11dda3 | ||
|
|
8330054a81 | ||
|
|
c7cb1dab38 | ||
|
|
a3686c16d4 | ||
|
|
b2a6a6a1ac | ||
|
|
e3e9a8ccc3 | ||
|
|
7058c7a4cd | ||
|
|
2591de1c1d | ||
|
|
d20f19c08f | ||
|
|
a5ba4b4d23 | ||
|
|
094815a098 | ||
|
|
5224fa656c | ||
|
|
73550164a8 | ||
|
|
48a9ab313e | ||
|
|
25936c8361 | ||
|
|
3397cdd95f | ||
|
|
69f8ff2688 | ||
|
|
41ab062103 | ||
|
|
c78431a0fb | ||
|
|
3b33216961 | ||
|
|
6373636d57 | ||
|
|
04a1f9987e | ||
|
|
90d286cd5a | ||
|
|
1826d108a5 | ||
|
|
a5619bb0ee | ||
|
|
5c1fadaba2 | ||
|
|
7928f4321a | ||
|
|
3b62367003 | ||
|
|
c8994d8488 | ||
|
|
4033994111 | ||
|
|
2050fb56d0 | ||
|
|
eeea9d0fce | ||
|
|
4b1f7a76c7 | ||
|
|
db2a7feb34 | ||
|
|
8b91b2eeb1 | ||
|
|
899803739b | ||
|
|
1ea3a5b682 | ||
|
|
f21cf86cc4 | ||
|
|
bb1052bbc7 | ||
|
|
7dad08346c | ||
|
|
b7c56e58ae | ||
|
|
a6f6da5bc5 | ||
|
|
f271ba3d97 | ||
|
|
66d02c7e2d | ||
|
|
0406573317 | ||
|
|
4a8b6b2704 | ||
|
|
603cec113c | ||
|
|
a579c7bb86 | ||
|
|
654c488ed8 | ||
|
|
ecfb339680 | ||
|
|
ecbe222a50 | ||
|
|
aacf62c259 | ||
|
|
3973fb2706 | ||
|
|
07ff81f4db | ||
|
|
c0e1e45b72 | ||
|
|
b07f70ab0b | ||
|
|
e1a35f8b2d | ||
|
|
e364af393a | ||
|
|
11c4e19e0c | ||
|
|
fa23be76ed | ||
|
|
8e50d35eba | ||
|
|
1112534be6 | ||
|
|
784f249d54 | ||
|
|
d6ad21b402 | ||
|
|
bd03cac80d | ||
|
|
da3ee71dfd | ||
|
|
62fb089e27 | ||
|
|
51dab21f6f | ||
|
|
9e89b3a201 | ||
|
|
1c1e2b9f3d | ||
|
|
e2e0238dcd | ||
|
|
9745e11da4 | ||
|
|
8ae7ccbfc9 | ||
|
|
5a2229596a | ||
|
|
a463d94480 | ||
|
|
750abc8c71 | ||
|
|
8375fbd8b3 | ||
|
|
394e406a36 | ||
|
|
2645af0d4c | ||
|
|
48222c22c8 | ||
|
|
d6a71267c6 | ||
|
|
0f4a46188c | ||
|
|
5917b90837 | ||
|
|
a222e90d1f | ||
|
|
3f60d713f8 | ||
|
|
9cb8433f3b | ||
|
|
428ca53532 | ||
|
|
6c4d7e537b | ||
|
|
58ab89736a | ||
|
|
5e34221a09 | ||
|
|
ac35f2a5f4 | ||
|
|
73992dca54 | ||
|
|
53dc20109d | ||
|
|
e7fef0a767 | ||
|
|
c73beabf7e | ||
|
|
c7409d7ac6 | ||
|
|
5e238ab5d3 | ||
|
|
a8f502ef4f | ||
|
|
c48ce82640 | ||
|
|
5594485bec | ||
|
|
f967bd299a | ||
|
|
a75c8f5dfc | ||
|
|
271d56c01c | ||
|
|
12220789a3 | ||
|
|
ca85a688cc | ||
|
|
0e3ed548c9 | ||
|
|
27dc5395e0 | ||
|
|
4f05ba1ac6 | ||
|
|
1a36218c80 | ||
|
|
3125b90efe | ||
|
|
2a7425700a | ||
|
|
f8a3308b46 | ||
|
|
93aed001af | ||
|
|
44461b67ee | ||
|
|
8a55dc1945 | ||
|
|
3af16bcf34 | ||
|
|
9adf0f00ae | ||
|
|
a2f3330455 | ||
|
|
7cfefbca19 | ||
|
|
e8eddb3917 | ||
|
|
634c25894c | ||
|
|
42f5331a0c | ||
|
|
e2383df06b | ||
|
|
7381b787f3 | ||
|
|
eff310ac40 | ||
|
|
c245fc6dfa | ||
|
|
b8f0c32ad5 | ||
|
|
4d580eda0e | ||
|
|
2db91740a4 | ||
|
|
cc304de7a7 | ||
|
|
d48b15afee | ||
|
|
0338058495 | ||
|
|
0b69bf735e | ||
|
|
45ab647c67 | ||
|
|
b470dcc252 | ||
|
|
2f69985fe1 | ||
|
|
44424c5c9e | ||
|
|
e4c27ac233 | ||
|
|
08db7c56fa | ||
|
|
75745d89d7 | ||
|
|
5dbc11a450 | ||
|
|
590183ebc0 | ||
|
|
07e8afa69a |
No files matched your search
@@ -0,0 +1,4 @@
|
||||
* text=auto eol=lf
|
||||
|
||||
*.bat text eol=crlf
|
||||
*.jar binary
|
||||
@@ -0,0 +1,106 @@
|
||||
name: "[English] Bug Report"
|
||||
description: "Create a report to help us debug bugs"
|
||||
title: "[BUG] "
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to fill out this bug report!
|
||||
|
||||
NOTE: Be sure to put a clear and concise title **AFTER** `[BUG]` in the text box above.
|
||||
|
||||
NOTE: We do not provide any services such as proxies, DO NOT feedback any problems not caused by this application here.
|
||||
|
||||
<!-- template -->
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: "Describe the bug"
|
||||
description: "A clear and concise description of what the bug is."
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: reproduce
|
||||
attributes:
|
||||
label: "To Reproduce"
|
||||
description: "Steps to reproduce the behavior:"
|
||||
value: |
|
||||
Step 1: ...
|
||||
Step 2: ...
|
||||
Step 3: ...
|
||||
...
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: device-info
|
||||
attributes:
|
||||
label: "Device Info"
|
||||
description: |
|
||||
Input your device information.
|
||||
|
||||
Example:
|
||||
- Device: Pixel 4
|
||||
- ROM: AOSP
|
||||
- Android Version: 10
|
||||
value: |
|
||||
- Device:
|
||||
- ROM:
|
||||
- Android Version:
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: app-info
|
||||
attributes:
|
||||
label: "Application Info"
|
||||
description: |
|
||||
Input application you are using information.
|
||||
|
||||
Example:
|
||||
```
|
||||
- Version: 2.5.4-premium
|
||||
- APK filename: cfa-2.5.4-premium-arm64-v8a-release.apk
|
||||
- Distribution Channel: Google Play
|
||||
```
|
||||
value: |
|
||||
- Version:
|
||||
- APK filename:
|
||||
- Distribution Channel:
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: configure
|
||||
attributes:
|
||||
render: yml
|
||||
label: "Configure File"
|
||||
description: |
|
||||
Please paste or upload the configuration file here.
|
||||
|
||||
TIPS: If you only have a subscription link, please use your browser to download it.
|
||||
|
||||
**NOTE: Please remove proxies from the configuration file before uploading it.**
|
||||
**NOTE: Please remove proxies from the configuration file before uploading it.**
|
||||
**NOTE: Please remove proxies from the configuration file before uploading it.**
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
render: raw
|
||||
label: "Logs"
|
||||
description: |
|
||||
Please paste or upload the log file here.
|
||||
|
||||
TIPS: Please use the `Logcat` in application or `adb logcat`. `adb logcat` would be better.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: screenshot
|
||||
attributes:
|
||||
label: "Screenshot"
|
||||
description: "If applicable, add screenshots to help explain your problem."
|
||||
placeholder: "Optional"
|
||||
- type: textarea
|
||||
id: additional
|
||||
attributes:
|
||||
label: "Additional"
|
||||
description: "Add any other context about the problem here."
|
||||
@@ -0,0 +1,26 @@
|
||||
name: "[English] Feature Request"
|
||||
description: "Create a report to help us improve"
|
||||
title: "[Feature Request] "
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to fill out this feature request!
|
||||
|
||||
NOTE: Be sure to put a clear and concise title **AFTER** `[Feature Request]` in the text box above.
|
||||
|
||||
<!-- template -->
|
||||
- type: textarea
|
||||
id: "description"
|
||||
attributes:
|
||||
label: "Feature Description"
|
||||
description: |
|
||||
A clear and concise description of the feature.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: "additional"
|
||||
attributes:
|
||||
label: "Additional"
|
||||
description: |
|
||||
Add any other context or screenshots about the feature request here.
|
||||
@@ -0,0 +1,107 @@
|
||||
name: "[简体中文] 错误报告"
|
||||
description: "创建错误报告以帮助我们修正应用"
|
||||
title: "[BUG] "
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
感谢您在百忙之中填写此错误报告。
|
||||
|
||||
注意: 请务必在上方文本框的 `[BUG]` **之后**填写清晰明了的标题。
|
||||
|
||||
注意:这里不提供像是代理服务器之类的服务,请不要反馈非应用自身引起的问题。
|
||||
|
||||
<!-- template -->
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: "描述此错误"
|
||||
description: "请清晰简洁的描述你遇到的错误。"
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: reproduce
|
||||
attributes:
|
||||
label: "如何复现该错误"
|
||||
description: "复现步骤:"
|
||||
value: |
|
||||
步骤 1: ...
|
||||
步骤 2: ...
|
||||
步骤 3: ...
|
||||
...
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: device-info
|
||||
attributes:
|
||||
label: "设备信息"
|
||||
description: |
|
||||
输入您正在使用的设备信息。
|
||||
|
||||
例子:
|
||||
- 机型: Pixel 4
|
||||
- 系统类型: MIUI/AOSP
|
||||
- Android 版本: 10
|
||||
value: |
|
||||
- 机型:
|
||||
- 系统类型:
|
||||
- Android 版本:
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: app-info
|
||||
attributes:
|
||||
label: "应用信息"
|
||||
description: |
|
||||
输入您正在使用的应用信息。
|
||||
|
||||
例子:
|
||||
```
|
||||
- 版本: 2.5.4-premium
|
||||
- 安装包文件名: cfa-2.5.4-premium-arm64-v8a-release.apk
|
||||
- 应用来源: Google Play
|
||||
```
|
||||
value: |
|
||||
- 版本:
|
||||
- 安装包文件名:
|
||||
- 应用来源:
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: configure
|
||||
attributes:
|
||||
render: yml
|
||||
label: "配置文件"
|
||||
description: |
|
||||
请在此粘贴和上传配置文件。
|
||||
|
||||
提示:如果您仅有一个订阅链接,请使用浏览器打开此链接以下载配置文件。
|
||||
|
||||
**注意: 请在上传配置文件前,移除其中的代理服务器信息。**
|
||||
**注意: 请在上传配置文件前,移除其中的代理服务器信息。**
|
||||
**注意: 请在上传配置文件前,移除其中的代理服务器信息。**
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
render: raw
|
||||
label: "日志"
|
||||
description: |
|
||||
请在此粘贴或上传日志。
|
||||
|
||||
提示: 请使用应用内的 `Logcat` 或 `adb logcat` 捕获日志. `adb logcat` 能更好地帮助侦测问题.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: screenshot
|
||||
attributes:
|
||||
label: "屏幕截图"
|
||||
description: "如果适用,请在此粘贴或上传屏幕截图。"
|
||||
placeholder: "可选"
|
||||
- type: textarea
|
||||
id: additional
|
||||
attributes:
|
||||
label: "附加信息"
|
||||
description: "其他的可能与改错误相关的信息。"
|
||||
placeholder: "可选"
|
||||
@@ -0,0 +1,26 @@
|
||||
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: |
|
||||
与此功能相关的其他附加信息。
|
||||
@@ -0,0 +1 @@
|
||||
blank_issues_enabled: false
|
||||
@@ -0,0 +1,54 @@
|
||||
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
|
||||
+20
-7
@@ -1,6 +1,7 @@
|
||||
.gradle
|
||||
build/
|
||||
/app/release/
|
||||
/app/foss/release
|
||||
/app/premium/release
|
||||
/captures
|
||||
|
||||
# Ignore Gradle GUI config
|
||||
@@ -16,24 +17,30 @@ 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
|
||||
|
||||
# keystore
|
||||
keystore.properties
|
||||
|
||||
# tracker
|
||||
tracker.properties
|
||||
|
||||
# vscode
|
||||
.vscode
|
||||
@@ -48,3 +55,9 @@ google-services.json
|
||||
|
||||
# Dolphin
|
||||
.directory
|
||||
|
||||
# logs
|
||||
*.log
|
||||
|
||||
# MacOS
|
||||
.DS_Store
|
||||
+6
-3
@@ -1,3 +1,6 @@
|
||||
[submodule "core/src/main/golang/clash"]
|
||||
path = core/src/main/golang/clash
|
||||
url = https://github.com/Kr328/Clash
|
||||
[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
|
||||
Generated
+151
@@ -0,0 +1,151 @@
|
||||
<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
@@ -0,0 +1,5 @@
|
||||
<component name="ProjectCodeStyleConfiguration">
|
||||
<state>
|
||||
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
|
||||
</state>
|
||||
</component>
|
||||
@@ -0,0 +1,14 @@
|
||||
## 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)
|
||||
|
||||
+5
-14
@@ -4,24 +4,19 @@ The Clash for Android is built as an Open Source software. This app is provided
|
||||
|
||||
This page is used to inform visitors regarding our policies with the collection, use, and disclosure of Personal Information if anyone decided to use our app.
|
||||
|
||||
If you choose to use our app, then you agree to the collection and use of information in relation to this policy. The Personal Information that we collect is used for providing and improving the app. We will not use or share your information with anyone except as described in this Privacy Policy.
|
||||
|
||||
The terms used in this Privacy Policy have the same meanings as in our Terms and Conditions, which is accessible at Clash for Android unless otherwise defined in this Privacy Policy.
|
||||
|
||||
**Information Collection and Use**
|
||||
|
||||
For a better experience, while using our app, we may require you to provide us with certain personally identifiable information. The information that we request will be retained by us and used as described in this privacy policy.
|
||||
We will not upload any of your personally information and that will be stored in the internal storage or memory.
|
||||
|
||||
The app does use third party services that may collect information used to identify you.
|
||||
We collect the following information and store it in memory, and such information will be destroyed when the application is fully exited.
|
||||
|
||||
Link to privacy policy of third party service providers used by the app
|
||||
- Installed Applications
|
||||
|
||||
* [Google Play Services](https://www.google.com/policies/privacy/)
|
||||
* [Firebase Analytics](https://firebase.google.com/policies/analytics)
|
||||
This data is used for the PROCESS-NAME rule.
|
||||
|
||||
**Log Data**
|
||||
|
||||
We want to inform you that whenever you use our app, in a case of an error in the app we collect data and information (through third party products) on your phone called Log Data. This Log Data may include information such as your device Internet Protocol (“IP”) address, device name, operating system version, the configuration of the app when utilizing our App, the time and date of your use of the app, and other statistics.
|
||||
We do not collect log data unless you use log collector.
|
||||
|
||||
**Cookies**
|
||||
|
||||
@@ -37,10 +32,6 @@ We value your trust in providing us your Personal Information, thus we are striv
|
||||
|
||||
This app may contain links to other sites. If you click on a third-party link, you will be directed to that site. Note that these external sites are not operated by us. Therefore, we strongly advise you to review the Privacy Policy of these websites. We have no control over and assume no responsibility for the content, privacy policies, or practices of any third-party sites or services.
|
||||
|
||||
**Children’s Privacy**
|
||||
|
||||
These Services do not address anyone under the age of 13\. We do not knowingly collect personally identifiable information from children under 13\. In the case we discover that a child under 13 has provided us with personal information, we immediately delete this from our servers. If you are a parent or guardian and you are aware that your child has provided us with personal information, please contact us so that we will be able to do necessary actions.
|
||||
|
||||
**Changes to This Privacy Policy**
|
||||
|
||||
We may update our Privacy Policy from time to time. Thus, you are advised to review this page periodically for any changes. We will notify you of any changes by posting the new Privacy Policy on this page. These changes are effective immediately after they are posted on this page.
|
||||
|
||||
@@ -1,36 +1,27 @@
|
||||
## Clash for Android
|
||||
|
||||
A GUI for [clash](https://github.com/Dreamacro/clash) on Android
|
||||
|
||||
> NOTICE: Early testing currently
|
||||
|
||||
A Graphical user interface of [clash](https://github.com/Dreamacro/clash) for Android
|
||||
|
||||
<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 7.0+
|
||||
* `arm64` or `x86_64` architecture
|
||||
|
||||
|
||||
- Android 5.0+ (minimum)
|
||||
- Android 7.0+ (recommend)
|
||||
- `armeabi-v7a` , `arm64-v8a`, `x86` or `x86_64` Architecture
|
||||
|
||||
### License
|
||||
|
||||
See also [LICENSE](./LICENSE) and [NOTICE](./NOTICE)
|
||||
|
||||
|
||||
|
||||
### Privacy Policy
|
||||
### Privacy Policy
|
||||
|
||||
See also [PRIVACY_POLICY.md](./PRIVACY_POLICY.md)
|
||||
|
||||
|
||||
|
||||
### Build
|
||||
|
||||
1. Update submodules
|
||||
@@ -39,28 +30,27 @@ See also [PRIVACY_POLICY.md](./PRIVACY_POLICY.md)
|
||||
git submodule update --init --recursive
|
||||
```
|
||||
|
||||
2. Install `Android SDK` ,`Android NDK` and `Golang`
|
||||
2. Install **OpenJDK 11**, **Android SDK**, **CMake** and **Golang**
|
||||
|
||||
3. Configure `local.properties`
|
||||
3. Create `local.properties` in project root with
|
||||
|
||||
```properties
|
||||
sdk.dir=/path/to/android-sdk
|
||||
ndk.dir=/path/to/android-ndk
|
||||
appcenter.key=<AppCenter Key> # Optional, from "appcenter.ms"
|
||||
```
|
||||
|
||||
4. Build
|
||||
4. Create `signing.properties` in project root with
|
||||
|
||||
on Linux
|
||||
```properties
|
||||
keystore.path=/path/to/keystore/file
|
||||
keystore.password=<key store password>
|
||||
key.alias=<key alias>
|
||||
key.password=<key password>
|
||||
```
|
||||
|
||||
5. Build
|
||||
|
||||
```bash
|
||||
./gradlew build
|
||||
./gradlew app:assembleFossRelease
|
||||
```
|
||||
|
||||
on Windows
|
||||
|
||||
```bash
|
||||
.\gradlew.bat build
|
||||
```
|
||||
|
||||
|
||||
6. Pick `app-<version>-foss-<arch>-release.apk` in `app/build/outputs/apk/foss/release/`
|
||||
@@ -1 +0,0 @@
|
||||
/build
|
||||
@@ -1,69 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
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)
|
||||
}
|
||||
|
||||
tasks.getByName("clean", type = Delete::class) {
|
||||
delete(file("release"))
|
||||
}
|
||||
Vendored
+37
-1
@@ -20,4 +20,40 @@
|
||||
# hide the original source file name.
|
||||
#-renamesourcefileattribute SourceFile
|
||||
|
||||
-dontobfuscate
|
||||
-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;
|
||||
}
|
||||
@@ -3,117 +3,177 @@
|
||||
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/AppTheme"
|
||||
tools:ignore="GoogleAppIndexingWarning">
|
||||
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" />
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:label="@string/launch_name"
|
||||
android:configChanges="uiMode"
|
||||
android:launchMode="singleTask">
|
||||
android:exported="true"
|
||||
android:label="@string/launch_name"
|
||||
android:launchMode="singleTop">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.service.quicksettings.action.QS_TILE_PREFERENCES" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<activity
|
||||
android:name=".ExternalImportActivity"
|
||||
android:exported="true"
|
||||
android:label="@string/import_from_file"
|
||||
android:theme="@android:style/Theme.Translucent.NoTitleBar.Fullscreen">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
|
||||
<data
|
||||
android:host="install-config"
|
||||
android:scheme="clash" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<activity
|
||||
android:name=".ApkBrokenActivity"
|
||||
android:label="@string/application_broken"
|
||||
android:configChanges="uiMode"
|
||||
android:exported="false"
|
||||
android:configChanges="uiMode"/>
|
||||
android:label="@string/application_broken" />
|
||||
<activity
|
||||
android:name=".AppCrashedActivity"
|
||||
android:configChanges="uiMode"
|
||||
android:exported="false"
|
||||
android:label="@string/application_crashed"
|
||||
android:launchMode="singleTask" />
|
||||
<activity
|
||||
android:name=".ProfilesActivity"
|
||||
android:label="@string/profiles"
|
||||
android:configChanges="uiMode"
|
||||
android:exported="false"
|
||||
android:configChanges="uiMode"/>
|
||||
android:label="@string/profiles" />
|
||||
<activity
|
||||
android:name=".CreateProfileActivity"
|
||||
android:label="@string/create_profile"
|
||||
android:name=".NewProfileActivity"
|
||||
android:configChanges="uiMode"
|
||||
android:exported="false"
|
||||
android:configChanges="uiMode" />
|
||||
android:label="@string/create_profile" />
|
||||
<activity
|
||||
android:name=".ProfileEditActivity"
|
||||
android:label="@string/profile"
|
||||
android:name=".PropertiesActivity"
|
||||
android:configChanges="uiMode"
|
||||
android:exported="false"
|
||||
android:configChanges="uiMode" />
|
||||
android:label="@string/profile" />
|
||||
<activity
|
||||
android:name=".ProxiesActivity"
|
||||
android:label="@string/proxy"
|
||||
android:name=".ProxyActivity"
|
||||
android:configChanges="uiMode"
|
||||
android:exported="false"
|
||||
android:configChanges="uiMode" />
|
||||
android:label="@string/proxy" />
|
||||
<activity
|
||||
android:name=".ProvidersActivity"
|
||||
android:configChanges="uiMode"
|
||||
android:exported="false"
|
||||
android:label="@string/providers" />
|
||||
<activity
|
||||
android:name=".LogsActivity"
|
||||
android:label="@string/logs"
|
||||
android:exported="false"
|
||||
android:configChanges="uiMode" />
|
||||
<activity
|
||||
android:name=".LogViewerActivity"
|
||||
android:label="@string/log_viewer"
|
||||
android:exported="false"
|
||||
android:configChanges="uiMode"
|
||||
android:launchMode="singleTask"/>
|
||||
android:exported="false"
|
||||
android:label="@string/logs" />
|
||||
<activity
|
||||
android:name=".LogcatActivity"
|
||||
android:configChanges="uiMode"
|
||||
android:exported="false"
|
||||
android:label="@string/logcat" />
|
||||
<activity
|
||||
android:name=".SettingsActivity"
|
||||
android:label="@string/settings"
|
||||
android:configChanges="uiMode"
|
||||
android:exported="false"
|
||||
android:configChanges="uiMode" />
|
||||
android:label="@string/settings" />
|
||||
<activity
|
||||
android:name=".SettingsNetworkActivity"
|
||||
android:label="@string/network"
|
||||
android:name=".NetworkSettingsActivity"
|
||||
android:configChanges="uiMode"
|
||||
android:exported="false"
|
||||
android:configChanges="uiMode" />
|
||||
android:label="@string/network" />
|
||||
<activity
|
||||
android:name=".SettingsBehaviorActivity"
|
||||
android:label="@string/behavior"
|
||||
android:name=".AppSettingsActivity"
|
||||
android:configChanges="uiMode"
|
||||
android:exported="false"
|
||||
android:configChanges="uiMode" />
|
||||
android:label="@string/app" />
|
||||
<activity
|
||||
android:name=".SettingsInterfaceActivity"
|
||||
android:label="@string/interface_"
|
||||
android:name=".OverrideSettingsActivity"
|
||||
android:configChanges="uiMode"
|
||||
android:exported="false"
|
||||
android:configChanges="uiMode" />
|
||||
android:label="@string/override" />
|
||||
<activity
|
||||
android:name=".PackagesActivity"
|
||||
android:label="@string/access_control_packages"
|
||||
android:name=".AccessControlActivity"
|
||||
android:configChanges="uiMode"
|
||||
android:exported="false"
|
||||
android:configChanges="uiMode" />
|
||||
android:label="@string/access_control_packages" />
|
||||
<activity
|
||||
android:name=".SupportActivity"
|
||||
android:label="@string/support"
|
||||
android:name=".HelpActivity"
|
||||
android:configChanges="uiMode"
|
||||
android:exported="false"
|
||||
android:configChanges="uiMode" />
|
||||
android:label="@string/help" />
|
||||
<activity
|
||||
android:name=".FilesActivity"
|
||||
android:configChanges="uiMode"
|
||||
android:exported="false"
|
||||
android:label="@string/files" />
|
||||
|
||||
<service
|
||||
android:name=".LogcatService"
|
||||
android:exported="false"
|
||||
android:label="@string/clash_logcat" />
|
||||
<service
|
||||
android:name=".TileService"
|
||||
android:icon="@drawable/ic_notification"
|
||||
android:exported="true"
|
||||
android:icon="@drawable/ic_logo_service"
|
||||
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=".OnBootReceiver"
|
||||
android:enabled="false">
|
||||
android:name=".RestartReceiver"
|
||||
android:enabled="false"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
</application>
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 16 KiB |
@@ -0,0 +1,137 @@
|
||||
package com.github.kr328.clash
|
||||
|
||||
import android.Manifest.permission.INTERNET
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.pm.ApplicationInfo
|
||||
import android.content.pm.PackageInfo
|
||||
import android.content.pm.PackageManager
|
||||
import androidx.core.content.getSystemService
|
||||
import com.github.kr328.clash.design.AccessControlDesign
|
||||
import com.github.kr328.clash.design.model.AppInfo
|
||||
import com.github.kr328.clash.design.util.toAppInfo
|
||||
import com.github.kr328.clash.service.store.ServiceStore
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.selects.select
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class AccessControlActivity : BaseActivity<AccessControlDesign>() {
|
||||
override suspend fun main() {
|
||||
val service = ServiceStore(this)
|
||||
|
||||
val selected = withContext(Dispatchers.IO) {
|
||||
service.accessControlPackages.toMutableSet()
|
||||
}
|
||||
|
||||
defer {
|
||||
withContext(Dispatchers.IO) {
|
||||
service.accessControlPackages = selected
|
||||
}
|
||||
}
|
||||
|
||||
val design = AccessControlDesign(this, uiStore, selected)
|
||||
|
||||
setContentDesign(design)
|
||||
|
||||
design.requests.send(AccessControlDesign.Request.ReloadApps)
|
||||
|
||||
while (isActive) {
|
||||
select<Unit> {
|
||||
events.onReceive {
|
||||
|
||||
}
|
||||
design.requests.onReceive {
|
||||
when (it) {
|
||||
AccessControlDesign.Request.ReloadApps -> {
|
||||
design.patchApps(loadApps(selected))
|
||||
}
|
||||
AccessControlDesign.Request.SelectAll -> {
|
||||
val all = withContext(Dispatchers.Default) {
|
||||
design.apps.map(AppInfo::packageName)
|
||||
}
|
||||
|
||||
selected.clear()
|
||||
selected.addAll(all)
|
||||
|
||||
design.rebindAll()
|
||||
}
|
||||
AccessControlDesign.Request.SelectNone -> {
|
||||
selected.clear()
|
||||
|
||||
design.rebindAll()
|
||||
}
|
||||
AccessControlDesign.Request.SelectInvert -> {
|
||||
val all = withContext(Dispatchers.Default) {
|
||||
design.apps.map(AppInfo::packageName).toSet() - selected
|
||||
}
|
||||
|
||||
selected.clear()
|
||||
selected.addAll(all)
|
||||
|
||||
design.rebindAll()
|
||||
}
|
||||
AccessControlDesign.Request.Import -> {
|
||||
val clipboard = getSystemService<ClipboardManager>()
|
||||
val data = clipboard?.primaryClip
|
||||
|
||||
if (data != null && data.itemCount > 0) {
|
||||
val packages = data.getItemAt(0).text.split("\n").toSet()
|
||||
val all = design.apps.map(AppInfo::packageName).intersect(packages)
|
||||
|
||||
selected.clear()
|
||||
selected.addAll(all)
|
||||
}
|
||||
|
||||
design.rebindAll()
|
||||
}
|
||||
AccessControlDesign.Request.Export -> {
|
||||
val clipboard = getSystemService<ClipboardManager>()
|
||||
|
||||
val data = ClipData.newPlainText(
|
||||
"packages",
|
||||
selected.joinToString("\n")
|
||||
)
|
||||
|
||||
clipboard?.setPrimaryClip(data)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadApps(selected: Set<String>): List<AppInfo> =
|
||||
withContext(Dispatchers.IO) {
|
||||
val reverse = uiStore.accessControlReverse
|
||||
val sort = uiStore.accessControlSort
|
||||
val systemApp = uiStore.accessControlSystemApp
|
||||
|
||||
val base = compareByDescending<AppInfo> { it.packageName in selected }
|
||||
val comparator = if (reverse) base.thenDescending(sort) else base.then(sort)
|
||||
|
||||
val pm = packageManager
|
||||
val packages = pm.getInstalledPackages(PackageManager.GET_PERMISSIONS)
|
||||
|
||||
packages.asSequence()
|
||||
.filter {
|
||||
it.packageName != packageName
|
||||
}
|
||||
.filter {
|
||||
it.packageName == "android" || it.requestedPermissions?.contains(INTERNET) == true
|
||||
}
|
||||
.filter {
|
||||
systemApp || !it.isSystemApp
|
||||
}
|
||||
.map {
|
||||
it.toAppInfo(pm)
|
||||
}
|
||||
.sortedWith(comparator)
|
||||
.toList()
|
||||
}
|
||||
|
||||
private val PackageInfo.isSystemApp: Boolean
|
||||
get() {
|
||||
return applicationInfo.flags and ApplicationInfo.FLAG_SYSTEM != 0
|
||||
}
|
||||
}
|
||||
@@ -2,62 +2,19 @@ package com.github.kr328.clash
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.text.Html
|
||||
import kotlinx.android.synthetic.main.activity_application_broken.*
|
||||
import com.github.kr328.clash.design.ApkBrokenDesign
|
||||
import kotlinx.coroutines.isActive
|
||||
|
||||
class ApkBrokenActivity : BaseActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
class ApkBrokenActivity : BaseActivity<ApkBrokenDesign>() {
|
||||
override suspend fun main() {
|
||||
val design = ApkBrokenDesign(this)
|
||||
|
||||
setContentView(R.layout.activity_application_broken)
|
||||
setSupportActionBar(toolbar)
|
||||
setContentDesign(design)
|
||||
|
||||
text.text = Html.fromHtml(getString(R.string.application_broken_description),
|
||||
Html.FROM_HTML_MODE_COMPACT)
|
||||
while (isActive) {
|
||||
val req = design.requests.receive()
|
||||
|
||||
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))))
|
||||
}
|
||||
}
|
||||
startActivity(Intent(Intent.ACTION_VIEW).setData(Uri.parse(req.url)))
|
||||
}
|
||||
}
|
||||
|
||||
override fun onBackPressed() {
|
||||
super.onBackPressed()
|
||||
|
||||
finishAffinity()
|
||||
finish()
|
||||
}
|
||||
|
||||
override fun shouldDisplayHomeAsUpEnabled(): Boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
override val activityLabel: CharSequence
|
||||
get() = getText(R.string.application_broken)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
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,173 +1,164 @@
|
||||
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.*
|
||||
import android.view.View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import android.view.View
|
||||
import androidx.activity.result.contract.ActivityResultContract
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
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.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 com.github.kr328.clash.remote.Broadcasts
|
||||
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.*
|
||||
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
|
||||
|
||||
abstract class BaseActivity : AppCompatActivity(), CoroutineScope by MainScope() {
|
||||
class EmptyBroadcastReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {}
|
||||
abstract class BaseActivity<D : Design<*>> :
|
||||
AppCompatActivity(),
|
||||
CoroutineScope by MainScope(),
|
||||
Broadcasts.Observer {
|
||||
enum class Event {
|
||||
ServiceRecreated,
|
||||
ActivityStart,
|
||||
ActivityStop,
|
||||
ClashStop,
|
||||
ClashStart,
|
||||
ProfileLoaded,
|
||||
ProfileChanged
|
||||
}
|
||||
|
||||
private val receiver = object : Broadcasts.Receiver {
|
||||
override fun onStarted() {
|
||||
launch {
|
||||
onClashStarted()
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStopped(cause: String?) {
|
||||
launch {
|
||||
onClashStopped(cause)
|
||||
}
|
||||
}
|
||||
private var defer: suspend () -> Unit = {}
|
||||
private var deferRunning = false
|
||||
private val nextRequestKey = AtomicInteger(0)
|
||||
private var dayNight: DayNight = DayNight.Day
|
||||
|
||||
override fun onProfileChanged() {
|
||||
launch {
|
||||
onClashProfileChanged()
|
||||
}
|
||||
}
|
||||
protected abstract suspend fun main()
|
||||
|
||||
override fun onProfileLoaded(profileEntity: ClashProfileEntity) {
|
||||
launch {
|
||||
onClashProfileLoaded(profileEntity)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var overrideRootView: View? = null
|
||||
suspend fun setContentDesign(design: D) {
|
||||
suspendCoroutine<Unit> {
|
||||
window.decorView.post {
|
||||
this.design = design
|
||||
|
||||
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)
|
||||
it.resume(Unit)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
language = uiSettings.get(UiSettings.LANGUAGE)
|
||||
applyDayNight()
|
||||
|
||||
resetDarkMode()
|
||||
launch {
|
||||
main()
|
||||
|
||||
resetLightNavigationBar()
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
|
||||
if (language != uiSettings.get(UiSettings.LANGUAGE))
|
||||
recreate()
|
||||
if (darkMode != uiSettings.get(UiSettings.DARK_MODE)) {
|
||||
resetDarkMode()
|
||||
recreate()
|
||||
}
|
||||
activityStarted = true
|
||||
|
||||
Broadcasts.register(receiver)
|
||||
Remote.broadcasts.addObserver(this)
|
||||
|
||||
events.trySend(Event.ActivityStart)
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
super.onStop()
|
||||
|
||||
Broadcasts.unregister(receiver)
|
||||
activityStarted = false
|
||||
|
||||
Remote.broadcasts.removeObserver(this)
|
||||
|
||||
events.trySend(Event.ActivityStop)
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu?): Boolean {
|
||||
this.menu = menu
|
||||
return super.onCreateOptionsMenu(menu)
|
||||
override fun onDestroy() {
|
||||
design?.cancel()
|
||||
|
||||
cancel()
|
||||
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||
if (super.onOptionsItemSelected(item))
|
||||
return true
|
||||
|
||||
if (item.itemId == android.R.id.home) {
|
||||
onSupportNavigateUp()
|
||||
return true
|
||||
override fun finish() {
|
||||
if (deferRunning) {
|
||||
return
|
||||
}
|
||||
|
||||
return false
|
||||
deferRunning = true
|
||||
|
||||
launch {
|
||||
try {
|
||||
defer()
|
||||
} finally {
|
||||
withContext(NonCancellable) {
|
||||
super.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun setSupportActionBar(toolbar: Toolbar?) {
|
||||
super.setSupportActionBar(toolbar)
|
||||
override fun onConfigurationChanged(newConfig: Configuration) {
|
||||
super.onConfigurationChanged(newConfig)
|
||||
|
||||
supportActionBar?.apply {
|
||||
setDisplayHomeAsUpEnabled(shouldDisplayHomeAsUpEnabled())
|
||||
|
||||
activityLabel?.let {
|
||||
title = it
|
||||
if (queryDayNight(newConfig) != dayNight) {
|
||||
ApplicationObserver.createdActivities.forEach {
|
||||
it.recreate()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -176,58 +167,83 @@ abstract class BaseActivity : AppCompatActivity(), CoroutineScope by MainScope()
|
||||
return true
|
||||
}
|
||||
|
||||
abstract val activityLabel: CharSequence?
|
||||
|
||||
override fun onSupportNavigateUp(): Boolean {
|
||||
this.onBackPressed()
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
cancel()
|
||||
|
||||
super.onDestroy()
|
||||
override fun onProfileChanged() {
|
||||
events.trySend(Event.ProfileChanged)
|
||||
}
|
||||
|
||||
override fun onConfigurationChanged(newConfig: Configuration) {
|
||||
super.onConfigurationChanged(newConfig)
|
||||
|
||||
recreate()
|
||||
override fun onProfileLoaded() {
|
||||
events.trySend(Event.ProfileLoaded)
|
||||
}
|
||||
|
||||
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 onServiceRecreated() {
|
||||
events.trySend(Event.ServiceRecreated)
|
||||
}
|
||||
|
||||
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
|
||||
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 resetLightNavigationBar() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O)
|
||||
return
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val light = resources.getBoolean(R.bool.lightStatusBar)
|
||||
private fun applyDayNight(config: Configuration = resources.configuration) {
|
||||
val dayNight = queryDayNight(config)
|
||||
|
||||
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()
|
||||
when (dayNight) {
|
||||
DayNight.Night -> {
|
||||
theme.applyStyle(R.style.AppThemeDark, true)
|
||||
}
|
||||
DayNight.Day -> {
|
||||
theme.applyStyle(R.style.AppThemeLight, true)
|
||||
}
|
||||
}
|
||||
|
||||
window.navigationBarColor = getColor(R.color.backgroundColor)
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
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"
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
@file:Suppress("BlockingMethodInNonBlockingContext")
|
||||
|
||||
package com.github.kr328.clash
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.github.kr328.clash.common.util.grantPermissions
|
||||
import com.github.kr328.clash.common.util.ticker
|
||||
import com.github.kr328.clash.common.util.uuid
|
||||
import com.github.kr328.clash.design.FilesDesign
|
||||
import com.github.kr328.clash.design.util.showExceptionToast
|
||||
import com.github.kr328.clash.remote.FilesClient
|
||||
import com.github.kr328.clash.service.model.Profile
|
||||
import com.github.kr328.clash.util.fileName
|
||||
import com.github.kr328.clash.util.withProfile
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.selects.select
|
||||
import java.util.*
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class FilesActivity : BaseActivity<FilesDesign>() {
|
||||
override suspend fun main() {
|
||||
val uuid = intent.uuid ?: return finish()
|
||||
val profile = withProfile { queryByUUID(uuid) } ?: return finish()
|
||||
val root = uuid.toString()
|
||||
|
||||
val design = FilesDesign(this)
|
||||
val client = FilesClient(this)
|
||||
val stack = Stack<String>()
|
||||
|
||||
design.configurationEditable = profile.type != Profile.Type.Url
|
||||
design.fetch(client, stack, root)
|
||||
|
||||
setContentDesign(design)
|
||||
|
||||
val ticker = ticker(TimeUnit.MINUTES.toMillis(1))
|
||||
|
||||
while (isActive) {
|
||||
select<Unit> {
|
||||
events.onReceive {
|
||||
when (it) {
|
||||
Event.ActivityStart, Event.ActivityStop -> {
|
||||
design.fetch(client, stack, root)
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
design.requests.onReceive {
|
||||
try {
|
||||
when (it) {
|
||||
FilesDesign.Request.PopStack -> {
|
||||
if (stack.empty()) {
|
||||
finish()
|
||||
} else {
|
||||
stack.pop()
|
||||
}
|
||||
}
|
||||
is FilesDesign.Request.OpenDirectory -> {
|
||||
stack.push(it.file.id)
|
||||
}
|
||||
is FilesDesign.Request.OpenFile -> {
|
||||
startActivityForResult(
|
||||
ActivityResultContracts.StartActivityForResult(),
|
||||
Intent(Intent.ACTION_VIEW).setDataAndType(
|
||||
client.buildDocumentUri(it.file.id),
|
||||
"text/plain"
|
||||
).grantPermissions()
|
||||
)
|
||||
}
|
||||
is FilesDesign.Request.DeleteFile -> {
|
||||
client.deleteDocument(it.file.id)
|
||||
}
|
||||
is FilesDesign.Request.RenameFile -> {
|
||||
val newName = design.requestFileName(it.file.name)
|
||||
|
||||
client.renameDocument(it.file.id, newName)
|
||||
}
|
||||
is FilesDesign.Request.ImportFile -> {
|
||||
if (Build.VERSION.SDK_INT >= 23) {
|
||||
val hasPermission = ContextCompat.checkSelfPermission(
|
||||
this@FilesActivity,
|
||||
Manifest.permission.READ_EXTERNAL_STORAGE
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
|
||||
if (!hasPermission) {
|
||||
val granted = startActivityForResult(
|
||||
ActivityResultContracts.RequestPermission(),
|
||||
Manifest.permission.READ_EXTERNAL_STORAGE,
|
||||
)
|
||||
|
||||
if (!granted) {
|
||||
return@onReceive
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val uri: Uri? = startActivityForResult(
|
||||
ActivityResultContracts.GetContent(),
|
||||
"*/*"
|
||||
)
|
||||
|
||||
if (uri != null) {
|
||||
if (it.file == null) {
|
||||
val name = design.requestFileName(uri.fileName ?: "File")
|
||||
|
||||
client.importDocument(stack.last(), uri, name)
|
||||
} else {
|
||||
client.copyDocument(it.file!!.id, uri)
|
||||
}
|
||||
}
|
||||
}
|
||||
is FilesDesign.Request.ExportFile -> {
|
||||
val uri: Uri? = startActivityForResult(
|
||||
ActivityResultContracts.CreateDocument("text/plain"),
|
||||
it.file.name
|
||||
)
|
||||
|
||||
if (uri != null) {
|
||||
client.copyDocument(uri, it.file.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
design.showExceptionToast(e)
|
||||
}
|
||||
|
||||
design.fetch(client, stack, root)
|
||||
}
|
||||
if (activityStarted) {
|
||||
ticker.onReceive {
|
||||
design.updateElapsed()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onBackPressed() {
|
||||
design?.requests?.trySend(FilesDesign.Request.PopStack)
|
||||
}
|
||||
|
||||
private suspend fun FilesDesign.fetch(client: FilesClient, stack: Stack<String>, root: String) {
|
||||
val documentId = stack.lastOrNull() ?: root
|
||||
val files = if (stack.empty()) {
|
||||
val list = client.list(documentId)
|
||||
val config = list.firstOrNull { it.id.endsWith("config.yaml") }
|
||||
|
||||
if (config == null || config.size > 0) list else listOf(config)
|
||||
} else {
|
||||
client.list(documentId)
|
||||
}
|
||||
|
||||
swapFiles(files, stack.empty())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
package com.github.kr328.clash
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.ServiceConnection
|
||||
import android.net.Uri
|
||||
import android.os.IBinder
|
||||
import android.widget.Toast
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import com.github.kr328.clash.common.compat.startForegroundServiceCompat
|
||||
import com.github.kr328.clash.common.util.fileName
|
||||
import com.github.kr328.clash.common.util.intent
|
||||
import com.github.kr328.clash.common.util.ticker
|
||||
import com.github.kr328.clash.core.model.LogMessage
|
||||
import com.github.kr328.clash.design.LogcatDesign
|
||||
import com.github.kr328.clash.design.dialog.withModelProgressBar
|
||||
import com.github.kr328.clash.design.model.LogFile
|
||||
import com.github.kr328.clash.design.ui.ToastDuration
|
||||
import com.github.kr328.clash.design.util.showExceptionToast
|
||||
import com.github.kr328.clash.log.LogcatFilter
|
||||
import com.github.kr328.clash.log.LogcatReader
|
||||
import com.github.kr328.clash.util.logsDir
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.selects.select
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.OutputStreamWriter
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.suspendCoroutine
|
||||
|
||||
class LogcatActivity : BaseActivity<LogcatDesign>() {
|
||||
private var conn: ServiceConnection? = null
|
||||
|
||||
override suspend fun main() {
|
||||
val fileName = intent?.fileName
|
||||
|
||||
if (fileName != null) {
|
||||
val file = LogFile.parseFromFileName(fileName) ?: return showInvalid()
|
||||
|
||||
return mainLocalFile(file)
|
||||
}
|
||||
|
||||
return mainStreaming()
|
||||
}
|
||||
|
||||
private suspend fun mainLocalFile(file: LogFile) {
|
||||
val messages = try {
|
||||
LogcatReader(this, file).readAll()
|
||||
} catch (e: Exception) {
|
||||
return showInvalid()
|
||||
}
|
||||
|
||||
val design = LogcatDesign(this, false)
|
||||
|
||||
setContentDesign(design)
|
||||
|
||||
design.patchMessages(messages, 0, messages.size)
|
||||
|
||||
while (isActive) {
|
||||
when (design.requests.receive()) {
|
||||
LogcatDesign.Request.Delete -> {
|
||||
withContext(Dispatchers.IO) {
|
||||
logsDir.resolve(file.fileName).delete()
|
||||
}
|
||||
|
||||
finish()
|
||||
}
|
||||
LogcatDesign.Request.Export -> {
|
||||
val output = startActivityForResult(
|
||||
ActivityResultContracts.CreateDocument("text/plain"),
|
||||
file.fileName
|
||||
)
|
||||
|
||||
if (output != null) {
|
||||
try {
|
||||
withContext(Dispatchers.IO) {
|
||||
writeLogTo(messages, file, output)
|
||||
}
|
||||
|
||||
design.showToast(R.string.file_exported, ToastDuration.Long)
|
||||
} catch (e: Exception) {
|
||||
design.showExceptionToast(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun mainStreaming() {
|
||||
val design = LogcatDesign(this, true)
|
||||
|
||||
setContentDesign(design)
|
||||
|
||||
startForegroundServiceCompat(LogcatService::class.intent)
|
||||
|
||||
val logcat = bindLogcatService()
|
||||
val ticker = ticker(500)
|
||||
|
||||
var initial = true
|
||||
|
||||
while (isActive) {
|
||||
select<Unit> {
|
||||
events.onReceive {
|
||||
|
||||
}
|
||||
design.requests.onReceive {
|
||||
when (it) {
|
||||
LogcatDesign.Request.Close -> {
|
||||
stopService(LogcatService::class.intent)
|
||||
|
||||
finish()
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
if (activityStarted) {
|
||||
ticker.onReceive {
|
||||
val snapshot = logcat.snapshot(initial) ?: return@onReceive
|
||||
|
||||
design.patchMessages(snapshot.messages, snapshot.removed, snapshot.appended)
|
||||
|
||||
initial = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
conn?.apply(this::unbindService)
|
||||
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
private suspend fun bindLogcatService(): LogcatService {
|
||||
return suspendCoroutine { ctx ->
|
||||
bindService(LogcatService::class.intent, object : ServiceConnection {
|
||||
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
|
||||
val srv = service!!.queryLocalInterface("") as LogcatService
|
||||
|
||||
ctx.resume(srv)
|
||||
|
||||
conn = this
|
||||
}
|
||||
|
||||
override fun onServiceDisconnected(name: ComponentName?) {
|
||||
conn = null
|
||||
}
|
||||
}, Context.BIND_AUTO_CREATE)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("BlockingMethodInNonBlockingContext")
|
||||
private suspend fun writeLogTo(messages: List<LogMessage>, file: LogFile, uri: Uri) {
|
||||
LogcatFilter(OutputStreamWriter(contentResolver.openOutputStream(uri)), this).use {
|
||||
withContext(Dispatchers.Main) {
|
||||
withModelProgressBar {
|
||||
configure {
|
||||
isIndeterminate = true
|
||||
max = messages.size
|
||||
}
|
||||
|
||||
withContext(Dispatchers.IO) {
|
||||
it.writeHeader(file.date)
|
||||
|
||||
messages.forEachIndexed { idx, msg ->
|
||||
configure {
|
||||
isIndeterminate = false
|
||||
progress = idx
|
||||
}
|
||||
|
||||
it.writeMessage(msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun showInvalid() {
|
||||
Toast.makeText(this, R.string.invalid_log_file, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
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
|
||||
@@ -9,49 +7,30 @@ 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.collection.CircularArray
|
||||
import androidx.core.app.NotificationChannelCompat
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
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 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 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 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()
|
||||
class LogcatService : Service(), CoroutineScope by CoroutineScope(Dispatchers.Default), IInterface {
|
||||
private val cache = LogcatCache()
|
||||
|
||||
private val connection = object : ServiceConnection {
|
||||
override fun onServiceDisconnected(name: ComponentName?) {
|
||||
@@ -59,197 +38,127 @@ class LogcatService : Service(), CoroutineScope by MainScope(), IInterface {
|
||||
}
|
||||
|
||||
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
|
||||
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")
|
||||
}
|
||||
})
|
||||
startObserver(service ?: return stopSelf())
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
isServiceRunning = true
|
||||
running = true
|
||||
|
||||
createNotificationChannel()
|
||||
|
||||
showNotification()
|
||||
|
||||
bindService(ClashManagerService::class.intent, connection, Context.BIND_AUTO_CREATE)
|
||||
|
||||
launchProcessor()
|
||||
|
||||
launchSaveThread()
|
||||
bindService(RemoteService::class.intent, connection, Context.BIND_AUTO_CREATE)
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
logChannel.close()
|
||||
|
||||
cancel()
|
||||
|
||||
unbindService(connection)
|
||||
|
||||
stopForeground(true)
|
||||
|
||||
super.onDestroy()
|
||||
running = false
|
||||
|
||||
isServiceRunning = false
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? {
|
||||
override fun onBind(intent: Intent?): IBinder {
|
||||
return this.asBinder()
|
||||
}
|
||||
|
||||
override fun asBinder(): IBinder {
|
||||
return object : Binder() {
|
||||
override fun queryLocalInterface(descriptor: String): IInterface? {
|
||||
override fun queryLocalInterface(descriptor: String): IInterface {
|
||||
return this@LogcatService
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Export to UI
|
||||
suspend fun pollLogEvent(offset: Long): CompletableDeferred<Response> {
|
||||
val request = Request(offset, CompletableDeferred())
|
||||
|
||||
requestChannel.send(request)
|
||||
|
||||
return request.response
|
||||
suspend fun snapshot(full: Boolean): LogcatCache.Snapshot? {
|
||||
return cache.snapshot(full)
|
||||
}
|
||||
|
||||
private fun launchProcessor() {
|
||||
launch {
|
||||
val pendingRequest: MutableList<Request> = mutableListOf()
|
||||
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)
|
||||
|
||||
try {
|
||||
withContext(Dispatchers.Default) {
|
||||
logsDir.mkdirs()
|
||||
|
||||
LogcatWriter(this@LogcatService).use {
|
||||
val observer = object : ILogObserver {
|
||||
override fun newItem(log: LogMessage) {
|
||||
channel.trySend(log)
|
||||
}
|
||||
}
|
||||
|
||||
service.setLogObserver(observer)
|
||||
|
||||
while (isActive) {
|
||||
select<Unit> {
|
||||
logChannel.onReceive {
|
||||
cache.addLast(it)
|
||||
val msg = channel.receive()
|
||||
|
||||
if (cache.size() > MAX_CACHE_COUNT) {
|
||||
cache.removeFromStart(1)
|
||||
cacheOffset++
|
||||
}
|
||||
}
|
||||
requestChannel.onReceive {
|
||||
pendingRequest.add(it)
|
||||
}
|
||||
}
|
||||
it.appendMessage(msg)
|
||||
|
||||
// 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()
|
||||
}
|
||||
cache.append(msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (e: Exception) {
|
||||
return@launch
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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.e("Write log file: $e", e)
|
||||
} finally {
|
||||
withContext(NonCancellable) {
|
||||
if (binder.isBinderAlive) {
|
||||
service.setLogObserver(null)
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Log.w("Logcat file write failure", e)
|
||||
|
||||
stopSelf()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun createNotificationChannel() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O)
|
||||
return
|
||||
|
||||
NotificationManagerCompat.from(this)
|
||||
.createNotificationChannel(
|
||||
NotificationChannel(
|
||||
NOTIFICATION_CHANNEL_ID,
|
||||
getString(R.string.clash_logcat),
|
||||
NotificationManager.IMPORTANCE_DEFAULT
|
||||
)
|
||||
NotificationChannelCompat.Builder(
|
||||
CHANNEL_ID,
|
||||
NotificationManagerCompat.IMPORTANCE_DEFAULT
|
||||
).setName(getString(R.string.clash_logcat)).build()
|
||||
)
|
||||
}
|
||||
|
||||
private fun showNotification() {
|
||||
val notification = NotificationCompat
|
||||
.Builder(this, NOTIFICATION_CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setColor(getColor(R.color.colorAccentService))
|
||||
.Builder(this, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_logo_service)
|
||||
.setColor(getColorCompat(R.color.color_clash_light))
|
||||
.setContentTitle(getString(R.string.clash_logcat))
|
||||
.setContentText(getString(R.string.running))
|
||||
.setContentIntent(
|
||||
PendingIntent.getActivity(
|
||||
this,
|
||||
NOTIFICATION_ID,
|
||||
LogsActivity::class.intent
|
||||
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT
|
||||
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)
|
||||
)
|
||||
)
|
||||
.build()
|
||||
|
||||
startForeground(NOTIFICATION_ID, notification)
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,261 +1,71 @@
|
||||
package com.github.kr328.clash
|
||||
|
||||
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 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 kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.selects.select
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.FileInputStream
|
||||
import java.util.*
|
||||
|
||||
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
|
||||
class LogsActivity : BaseActivity<LogsDesign>() {
|
||||
override suspend fun main() {
|
||||
if (LogcatService.running) {
|
||||
return startActivity(LogcatActivity::class.intent)
|
||||
}
|
||||
|
||||
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)
|
||||
val design = LogsDesign(this)
|
||||
|
||||
startActivity(LogViewerActivity::class.intent)
|
||||
setContentDesign(design)
|
||||
|
||||
finish()
|
||||
while (isActive) {
|
||||
select<Unit> {
|
||||
events.onReceive {
|
||||
when (it) {
|
||||
Event.ActivityStart -> {
|
||||
val files = withContext(Dispatchers.IO) {
|
||||
loadFiles()
|
||||
}
|
||||
|
||||
design.patchLogs(files)
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
category(text = getString(R.string.history), id = "history", showTopSeparator = true)
|
||||
}
|
||||
design.requests.onReceive {
|
||||
when (it) {
|
||||
LogsDesign.Request.StartLogcat -> {
|
||||
startActivity(LogcatActivity::class.intent)
|
||||
|
||||
clearAll.setOnClickListener {
|
||||
showClearAllDialog()
|
||||
}
|
||||
finish()
|
||||
}
|
||||
LogsDesign.Request.DeleteAll -> {
|
||||
if (design.requestDeleteAll()) {
|
||||
withContext(Dispatchers.IO) {
|
||||
deleteAllLogs()
|
||||
}
|
||||
|
||||
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)
|
||||
events.trySend(Event.ActivityStart)
|
||||
}
|
||||
}
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dialog.dismissWithAnimation = true
|
||||
dialog.setContentView(menu)
|
||||
dialog.show()
|
||||
private fun loadFiles(): List<LogFile> {
|
||||
val list = cacheDir.resolve("logs").listFiles()?.toList() ?: emptyList()
|
||||
|
||||
return list.mapNotNull { LogFile.parseFromFileName(it.name) }
|
||||
}
|
||||
|
||||
private fun deleteAllLogs() {
|
||||
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()
|
||||
logsDir.deleteRecursively()
|
||||
}
|
||||
}
|
||||
@@ -1,200 +1,151 @@
|
||||
package com.github.kr328.clash
|
||||
|
||||
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.*
|
||||
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
|
||||
|
||||
class MainActivity : BaseActivity() {
|
||||
companion object {
|
||||
private const val REQUEST_CODE = 40000
|
||||
}
|
||||
class MainActivity : BaseActivity<MainDesign>() {
|
||||
override suspend fun main() {
|
||||
val design = MainDesign(this)
|
||||
|
||||
private var bandwidthJob: Job? = null
|
||||
setContentDesign(design)
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
setContentView(R.layout.activity_main)
|
||||
|
||||
status.setOnClickListener {
|
||||
if (clashRunning) {
|
||||
stopService(ClashService::class.intent)
|
||||
} else {
|
||||
val vpnRequest = startClashService()
|
||||
if (vpnRequest != null)
|
||||
startActivityForResult(vpnRequest, REQUEST_CODE)
|
||||
}
|
||||
launch(Dispatchers.IO) {
|
||||
showUpdatedTips(design)
|
||||
}
|
||||
|
||||
proxies.setOnClickListener {
|
||||
startActivity(ProxiesActivity::class.intent)
|
||||
}
|
||||
design.fetch()
|
||||
|
||||
profiles.setOnClickListener {
|
||||
startActivity(ProfilesActivity::class.intent)
|
||||
}
|
||||
val ticker = ticker(TimeUnit.SECONDS.toMillis(1))
|
||||
|
||||
logs.setOnClickListener {
|
||||
startActivity(LogsActivity::class.intent)
|
||||
}
|
||||
|
||||
settings.setOnClickListener {
|
||||
startActivity(SettingsActivity::class.intent)
|
||||
}
|
||||
|
||||
support.setOnClickListener {
|
||||
startActivity(SupportActivity::class.intent)
|
||||
}
|
||||
|
||||
about.setOnClickListener {
|
||||
showAboutDialog()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
|
||||
updateClashStatus()
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
super.onStop()
|
||||
|
||||
stopBandwidthPolling()
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
super.onActivityResult(requestCode, resultCode, data)
|
||||
}
|
||||
|
||||
override suspend fun onClashStarted() {
|
||||
updateClashStatus()
|
||||
}
|
||||
|
||||
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)
|
||||
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()
|
||||
}
|
||||
} finally {
|
||||
bandwidthJob = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopBandwidthPolling() {
|
||||
bandwidthJob?.cancel()
|
||||
}
|
||||
private suspend fun showUpdatedTips(design: MainDesign) {
|
||||
val tips = TipsStore(this)
|
||||
|
||||
private fun updateClashStatus() {
|
||||
if (clashRunning) {
|
||||
startBandwidthPolling()
|
||||
if (tips.primaryVersion != TipsStore.CURRENT_PRIMARY_VERSION) {
|
||||
tips.primaryVersion = TipsStore.CURRENT_PRIMARY_VERSION
|
||||
|
||||
status.setCardBackgroundColor(getColor(R.color.primaryCardColorStarted))
|
||||
status.icon = getDrawable(R.drawable.ic_started)
|
||||
status.title = getText(R.string.running)
|
||||
val pkg = packageManager.getPackageInfo(packageName, 0)
|
||||
|
||||
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()
|
||||
if (pkg.firstInstallTime != pkg.lastUpdateTime) {
|
||||
design.showUpdatedTips()
|
||||
}
|
||||
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)
|
||||
private suspend fun MainDesign.fetch() {
|
||||
setClashRunning(clashRunning)
|
||||
|
||||
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
|
||||
}
|
||||
val state = withClash {
|
||||
queryTunnelState()
|
||||
}
|
||||
val providers = withClash {
|
||||
queryProviders()
|
||||
}
|
||||
|
||||
setMode(state.mode)
|
||||
setHasProviders(providers.isNotEmpty())
|
||||
|
||||
withProfile {
|
||||
setProfileName(queryActive()?.name)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun MainDesign.fetchTraffic() {
|
||||
withClash {
|
||||
setForwarded(queryTrafficTotal())
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
AlertDialog.Builder(this@MainActivity)
|
||||
.setView(content)
|
||||
.show()
|
||||
return
|
||||
}
|
||||
|
||||
val vpnRequest = startClashService()
|
||||
|
||||
try {
|
||||
if (vpnRequest != null) {
|
||||
val result = startActivityForResult(
|
||||
ActivityResultContracts.StartActivityForResult(),
|
||||
vpnRequest
|
||||
)
|
||||
|
||||
if (result.resultCode == RESULT_OK)
|
||||
startClashService()
|
||||
}
|
||||
} 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,12 +2,11 @@ package com.github.kr328.clash
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import com.github.kr328.clash.core.Global
|
||||
import com.github.kr328.clash.remote.Broadcasts
|
||||
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.remote.Remote
|
||||
import com.microsoft.appcenter.AppCenter
|
||||
import com.microsoft.appcenter.analytics.Analytics
|
||||
import com.microsoft.appcenter.crashes.Crashes
|
||||
import com.github.kr328.clash.service.util.sendServiceRecreated
|
||||
|
||||
@Suppress("unused")
|
||||
class MainApplication : Application() {
|
||||
@@ -20,16 +19,18 @@ class MainApplication : Application() {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
// Initialize AppCenter
|
||||
if (BuildConfig.APP_CENTER_KEY.isNotEmpty() && !BuildConfig.DEBUG) {
|
||||
AppCenter.start(
|
||||
this,
|
||||
BuildConfig.APP_CENTER_KEY,
|
||||
Analytics::class.java, Crashes::class.java
|
||||
)
|
||||
}
|
||||
val processName = currentProcessName
|
||||
|
||||
Remote.init(this)
|
||||
Broadcasts.init(this)
|
||||
Log.d("Process $processName started")
|
||||
|
||||
if (processName == packageName) {
|
||||
Remote.launch()
|
||||
} else {
|
||||
sendServiceRecreated()
|
||||
}
|
||||
}
|
||||
|
||||
fun finalize() {
|
||||
Global.destroy()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package com.github.kr328.clash
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.ComponentName
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.provider.Settings
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import com.github.kr328.clash.common.constants.Intents
|
||||
import com.github.kr328.clash.common.util.intent
|
||||
import com.github.kr328.clash.common.util.setUUID
|
||||
import com.github.kr328.clash.design.NewProfileDesign
|
||||
import com.github.kr328.clash.design.model.ProfileProvider
|
||||
import com.github.kr328.clash.service.model.Profile
|
||||
import com.github.kr328.clash.util.withProfile
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.selects.select
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.*
|
||||
|
||||
class NewProfileActivity : BaseActivity<NewProfileDesign>() {
|
||||
private val self: NewProfileActivity
|
||||
get() = this
|
||||
|
||||
override suspend fun main() {
|
||||
val design = NewProfileDesign(this)
|
||||
|
||||
design.patchProviders(queryProfileProviders())
|
||||
|
||||
setContentDesign(design)
|
||||
|
||||
while (isActive) {
|
||||
select<Unit> {
|
||||
events.onReceive {
|
||||
|
||||
}
|
||||
design.requests.onReceive {
|
||||
when (it) {
|
||||
is NewProfileDesign.Request.Create -> {
|
||||
withProfile {
|
||||
val name = getString(R.string.new_profile)
|
||||
|
||||
val uuid: UUID? = when (val p = it.provider) {
|
||||
is ProfileProvider.File ->
|
||||
create(Profile.Type.File, name)
|
||||
is ProfileProvider.Url ->
|
||||
create(Profile.Type.Url, name)
|
||||
is ProfileProvider.External -> {
|
||||
val data = p.get()
|
||||
|
||||
if (data != null) {
|
||||
val (uri, initialName) = data
|
||||
|
||||
create(
|
||||
Profile.Type.External,
|
||||
initialName ?: name,
|
||||
uri.toString()
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (uuid != null)
|
||||
launchProperties(uuid)
|
||||
}
|
||||
}
|
||||
is NewProfileDesign.Request.OpenDetail -> {
|
||||
launchAppDetailed(it.provider)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun launchAppDetailed(provider: ProfileProvider.External) {
|
||||
val data = Uri.fromParts(
|
||||
"package",
|
||||
provider.intent.component?.packageName ?: return,
|
||||
null
|
||||
)
|
||||
|
||||
startActivity(Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).setData(data))
|
||||
}
|
||||
|
||||
private suspend fun launchProperties(uuid: UUID) {
|
||||
val r = startActivityForResult(
|
||||
ActivityResultContracts.StartActivityForResult(),
|
||||
PropertiesActivity::class.intent.setUUID(uuid)
|
||||
)
|
||||
|
||||
if (r.resultCode == Activity.RESULT_OK)
|
||||
finish()
|
||||
}
|
||||
|
||||
private suspend fun ProfileProvider.External.get(): Pair<Uri, String?>? {
|
||||
val result = startActivityForResult(
|
||||
ActivityResultContracts.StartActivityForResult(),
|
||||
intent
|
||||
)
|
||||
|
||||
if (result.resultCode != RESULT_OK)
|
||||
return null
|
||||
|
||||
val uri = result.data?.data
|
||||
val name = result.data?.getStringExtra(Intents.EXTRA_NAME)
|
||||
|
||||
if (uri != null) {
|
||||
return uri to name
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private suspend fun queryProfileProviders(): List<ProfileProvider> {
|
||||
return withContext(Dispatchers.IO) {
|
||||
val providers = packageManager.queryIntentActivities(
|
||||
Intent(Intents.ACTION_PROVIDE_URL),
|
||||
0
|
||||
).map {
|
||||
val activity = it.activityInfo
|
||||
|
||||
val name = activity.applicationInfo.loadLabel(packageManager)
|
||||
val summary = activity.loadLabel(packageManager)
|
||||
val icon = activity.loadIcon(packageManager)
|
||||
val intent = Intent(Intents.ACTION_PROVIDE_URL)
|
||||
.setComponent(
|
||||
ComponentName(
|
||||
activity.packageName,
|
||||
activity.name
|
||||
)
|
||||
)
|
||||
|
||||
ProfileProvider.External(name.toString(), summary.toString(), icon, intent)
|
||||
}
|
||||
|
||||
listOf(ProfileProvider.File(self), ProfileProvider.Url(self)) + providers
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,21 +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.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))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,169 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,278 +0,0 @@
|
||||
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,210 +1,77 @@
|
||||
package com.github.kr328.clash
|
||||
|
||||
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 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 kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import java.util.*
|
||||
import kotlinx.coroutines.selects.select
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class ProfilesActivity : BaseActivity(), ProfileAdapter.Callback, ProfilesMenu.Callback {
|
||||
companion object {
|
||||
private const val EDITOR_REQUEST_CODE = 30000
|
||||
}
|
||||
class ProfilesActivity : BaseActivity<ProfilesDesign>() {
|
||||
override suspend fun main() {
|
||||
val design = ProfilesDesign(this)
|
||||
|
||||
private var backgroundJob: Job? = null
|
||||
private val reloadMutex = Mutex()
|
||||
private val editorStack = Stack<String>()
|
||||
setContentDesign(design)
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_profiles)
|
||||
setSupportActionBar(toolbar)
|
||||
val ticker = ticker(TimeUnit.MINUTES.toMillis(1))
|
||||
|
||||
mainList.layoutManager = LinearLayoutManager(this)
|
||||
mainList.adapter = ProfileAdapter(this, this)
|
||||
}
|
||||
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) }
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
|
||||
backgroundJob = launch {
|
||||
reloadProfiles()
|
||||
|
||||
while (isActive) {
|
||||
delay(1000 * 60)
|
||||
|
||||
// Refresh without animation
|
||||
(mainList.adapter as ProfileAdapter).notifyDataSetChanged()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
startActivity(PropertiesActivity::class.intent.setUUID(uuid))
|
||||
}
|
||||
}
|
||||
}
|
||||
if (activityStarted) {
|
||||
ticker.onReceive {
|
||||
design.updateElapsed()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
private suspend fun ProfilesDesign.fetch() {
|
||||
withProfile {
|
||||
enqueueRequest(request)
|
||||
patchProfiles(queryAll())
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,350 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
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,46 +1,32 @@
|
||||
package com.github.kr328.clash
|
||||
|
||||
import android.os.Bundle
|
||||
import com.github.kr328.clash.service.util.intent
|
||||
import kotlinx.android.synthetic.main.activity_settings.*
|
||||
import com.github.kr328.clash.common.util.intent
|
||||
import com.github.kr328.clash.design.SettingsDesign
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.selects.select
|
||||
|
||||
class SettingsActivity: BaseActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_settings)
|
||||
setSupportActionBar(toolbar)
|
||||
class SettingsActivity : BaseActivity<SettingsDesign>() {
|
||||
override suspend fun main() {
|
||||
val design = SettingsDesign(this)
|
||||
|
||||
commonUi.build {
|
||||
option(
|
||||
icon = getDrawable(R.drawable.ic_settings_applications),
|
||||
title = getString(R.string.behavior)) {
|
||||
paddingHeight = true
|
||||
setContentDesign(design)
|
||||
|
||||
while (isActive) {
|
||||
select<Unit> {
|
||||
events.onReceive {
|
||||
|
||||
onClick {
|
||||
startActivity(SettingsBehaviorActivity::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)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override val activityLabel: CharSequence?
|
||||
get() = getText(R.string.settings)
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
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_)
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
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,96 +4,101 @@ 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 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
|
||||
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
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.N)
|
||||
class TileService : TileService() {
|
||||
private var currentProfile = ""
|
||||
private var clashRunning = false
|
||||
|
||||
override fun onClick() {
|
||||
val tile = qsTile
|
||||
val tile = qsTile ?: return
|
||||
|
||||
when (tile.state) {
|
||||
Tile.STATE_INACTIVE -> {
|
||||
startClashService()
|
||||
}
|
||||
Tile.STATE_ACTIVE -> {
|
||||
stopService(ClashService::class.intent)
|
||||
stopClashService()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStartListening() {
|
||||
refreshStatus()
|
||||
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()
|
||||
}
|
||||
|
||||
private fun refreshStatus() {
|
||||
if (qsTile == null)
|
||||
return
|
||||
override fun onStopListening() {
|
||||
super.onStopListening()
|
||||
|
||||
qsTile.state = if ( clashRunning )
|
||||
unregisterReceiver(receiver)
|
||||
}
|
||||
|
||||
private fun updateTile() {
|
||||
val tile = qsTile ?: return
|
||||
|
||||
tile.state = if (clashRunning)
|
||||
Tile.STATE_ACTIVE
|
||||
else
|
||||
Tile.STATE_INACTIVE
|
||||
|
||||
qsTile.label = if ( currentProfile.isEmpty() )
|
||||
tile.label = if (currentProfile.isEmpty())
|
||||
getText(R.string.launch_name)
|
||||
else
|
||||
currentProfile
|
||||
|
||||
qsTile.updateTile()
|
||||
tile.icon = Icon.createWithResource(this, R.drawable.ic_logo_service)
|
||||
|
||||
tile.updateTile()
|
||||
}
|
||||
|
||||
private val clashStatusReceiver = object : BroadcastReceiver() {
|
||||
private val receiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
when ( intent?.action ) {
|
||||
Intents.INTENT_ACTION_CLASH_STARTED -> {
|
||||
when (intent?.action) {
|
||||
Intents.ACTION_CLASH_STARTED -> {
|
||||
clashRunning = true
|
||||
|
||||
currentProfile = ""
|
||||
}
|
||||
Intents.INTENT_ACTION_CLASH_STOPPED -> {
|
||||
Intents.ACTION_CLASH_STOPPED, Intents.ACTION_SERVICE_RECREATED -> {
|
||||
clashRunning = false
|
||||
|
||||
currentProfile = ""
|
||||
}
|
||||
Intents.INTENT_ACTION_PROFILE_LOADED -> {
|
||||
val entity = intent.
|
||||
getParcelableExtra<ClashProfileEntity>(Intents.INTENT_EXTRA_PROFILE)
|
||||
|
||||
currentProfile = entity?.name ?: ""
|
||||
Intents.ACTION_PROFILE_LOADED -> {
|
||||
currentProfile = StatusClient(this@TileService).currentProfile() ?: ""
|
||||
}
|
||||
}
|
||||
|
||||
refreshStatus()
|
||||
updateTile()
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
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])
|
||||
}
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,272 +0,0 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
package com.github.kr328.clash.adapter
|
||||
|
||||
import android.animation.ValueAnimator
|
||||
import android.content.Context
|
||||
import android.graphics.Color
|
||||
import android.util.TypedValue
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.TextView
|
||||
import androidx.annotation.ColorInt
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.github.kr328.clash.R
|
||||
import com.google.android.material.card.MaterialCardView
|
||||
|
||||
class ProxyChipAdapter(
|
||||
private val context: Context,
|
||||
val onClick: (String) -> Unit
|
||||
) :
|
||||
RecyclerView.Adapter<ProxyChipAdapter.Holder>() {
|
||||
var chips = listOf<String>()
|
||||
var selected: String = ""
|
||||
set(value) {
|
||||
val lastIndex = chips.indexOf(field)
|
||||
val newIndex = chips.indexOf(value)
|
||||
|
||||
field = value
|
||||
|
||||
if (lastIndex >= 0)
|
||||
notifyItemChanged(lastIndex)
|
||||
if (newIndex >= 0)
|
||||
notifyItemChanged(newIndex)
|
||||
}
|
||||
|
||||
@ColorInt
|
||||
private val colorOnSurface: Int
|
||||
|
||||
init {
|
||||
val typedValue = TypedValue()
|
||||
|
||||
context.theme.resolveAttribute(R.attr.colorOnSurface, typedValue, true)
|
||||
colorOnSurface = typedValue.data
|
||||
}
|
||||
|
||||
class Holder(root: View) : RecyclerView.ViewHolder(root) {
|
||||
val card: MaterialCardView = root.findViewById(R.id.root)
|
||||
val title: TextView = root.findViewById(android.R.id.title)
|
||||
|
||||
private var cardAnimator: ValueAnimator? = null
|
||||
private var cardColor: Int = card.cardBackgroundColor.defaultColor
|
||||
private var titleAnimator: ValueAnimator? = null
|
||||
private var titleColor: Int = title.textColors.defaultColor
|
||||
|
||||
fun setCardColorAnimation(color: Int) {
|
||||
if (cardColor == color)
|
||||
return
|
||||
|
||||
cardAnimator?.cancel()
|
||||
|
||||
cardAnimator = ValueAnimator.ofArgb(cardColor, color).apply {
|
||||
addUpdateListener {
|
||||
val v = animatedValue as Int
|
||||
|
||||
card.setCardBackgroundColor(v)
|
||||
|
||||
cardColor = v
|
||||
}
|
||||
|
||||
duration = 200
|
||||
start()
|
||||
}
|
||||
}
|
||||
|
||||
fun setTitleColorAnimation(color: Int) {
|
||||
if (color == titleColor)
|
||||
return
|
||||
|
||||
titleAnimator?.cancel()
|
||||
|
||||
titleAnimator = ValueAnimator.ofArgb(titleColor, color).apply {
|
||||
addUpdateListener {
|
||||
val v = animatedValue as Int
|
||||
|
||||
title.setTextColor(v)
|
||||
|
||||
titleColor = v
|
||||
}
|
||||
|
||||
duration = 200
|
||||
start()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): Holder {
|
||||
val layoutInflater = LayoutInflater.from(context)
|
||||
|
||||
return Holder(layoutInflater.inflate(R.layout.adapter_proxies_chip, parent, false))
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int {
|
||||
return chips.size
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: Holder, position: Int) {
|
||||
val current = chips[position]
|
||||
|
||||
holder.title.text = current
|
||||
holder.card.setOnClickListener {
|
||||
onClick(current)
|
||||
}
|
||||
|
||||
if (selected == current) {
|
||||
holder.setTitleColorAnimation(Color.WHITE)
|
||||
holder.setCardColorAnimation(context.getColor(R.color.primaryCardColorStarted))
|
||||
} else {
|
||||
holder.setTitleColorAnimation(colorOnSurface)
|
||||
holder.setCardColorAnimation(context.getColor(R.color.chipBackgroundColor))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
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) {
|
||||
""
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
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,97 +5,99 @@ import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
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
|
||||
import com.github.kr328.clash.common.constants.Intents
|
||||
import com.github.kr328.clash.common.log.Log
|
||||
|
||||
object Broadcasts {
|
||||
interface Receiver {
|
||||
class Broadcasts(private val context: Application) {
|
||||
interface Observer {
|
||||
fun onServiceRecreated()
|
||||
fun onStarted()
|
||||
fun onStopped(cause: String?)
|
||||
fun onProfileChanged()
|
||||
fun onProfileLoaded(profileEntity: ClashProfileEntity)
|
||||
fun onProfileLoaded()
|
||||
}
|
||||
|
||||
var clashRunning: Boolean = false
|
||||
|
||||
private val receivers = mutableListOf<Receiver>()
|
||||
private var registered = false
|
||||
private val receivers = mutableListOf<Observer>()
|
||||
private val broadcastReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
if (intent?.`package` != context?.packageName)
|
||||
return
|
||||
|
||||
when (intent?.action) {
|
||||
Intents.INTENT_ACTION_CLASH_STARTED -> {
|
||||
Intents.ACTION_SERVICE_RECREATED -> {
|
||||
clashRunning = false
|
||||
|
||||
receivers.forEach {
|
||||
it.onServiceRecreated()
|
||||
}
|
||||
}
|
||||
Intents.ACTION_CLASH_STARTED -> {
|
||||
clashRunning = true
|
||||
|
||||
receivers.forEach {
|
||||
it.onStarted()
|
||||
}
|
||||
}
|
||||
Intents.INTENT_ACTION_CLASH_STOPPED -> {
|
||||
Intents.ACTION_CLASH_STOPPED -> {
|
||||
clashRunning = false
|
||||
|
||||
receivers.forEach {
|
||||
it.onStopped(intent.getStringExtra(Intents.INTENT_EXTRA_CLASH_STOP_REASON))
|
||||
it.onStopped(intent.getStringExtra(Intents.EXTRA_STOP_REASON))
|
||||
}
|
||||
}
|
||||
Intents.INTENT_ACTION_PROFILE_CHANGED ->
|
||||
Intents.ACTION_PROFILE_CHANGED ->
|
||||
receivers.forEach {
|
||||
it.onProfileChanged()
|
||||
}
|
||||
Intents.INTENT_ACTION_PROFILE_LOADED -> {
|
||||
val profile = intent
|
||||
.getParcelableExtra<ClashProfileEntity>(Intents.INTENT_EXTRA_PROFILE) ?: return
|
||||
|
||||
Intents.ACTION_PROFILE_LOADED -> {
|
||||
receivers.forEach {
|
||||
it.onProfileLoaded(profile)
|
||||
it.onProfileLoaded()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun register(receiver: Receiver) {
|
||||
receivers.add(receiver)
|
||||
fun addObserver(observer: Observer) {
|
||||
receivers.add(observer)
|
||||
}
|
||||
|
||||
fun unregister(receiver: Receiver) {
|
||||
receivers.remove(receiver)
|
||||
fun removeObserver(observer: Observer) {
|
||||
receivers.remove(observer)
|
||||
}
|
||||
|
||||
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)
|
||||
})
|
||||
fun register() {
|
||||
if (registered)
|
||||
return
|
||||
|
||||
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)
|
||||
})
|
||||
|
||||
val current = RemoteUtils.detectClashRunning(application)
|
||||
if (current != clashRunning) {
|
||||
clashRunning = current
|
||||
clashRunning = StatusClient(context).currentProfile() != null
|
||||
} catch (e: Exception) {
|
||||
Log.w("Register global receiver: $e", e)
|
||||
}
|
||||
}
|
||||
|
||||
if (current) {
|
||||
receivers.forEach {
|
||||
it.onStarted()
|
||||
}
|
||||
} else {
|
||||
receivers.forEach {
|
||||
it.onStopped(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
fun unregister() {
|
||||
if (!registered)
|
||||
return
|
||||
|
||||
override fun onStop(owner: LifecycleOwner) {
|
||||
application.unregisterReceiver(broadcastReceiver)
|
||||
}
|
||||
})
|
||||
try {
|
||||
context.unregisterReceiver(broadcastReceiver)
|
||||
|
||||
clashRunning = false
|
||||
} catch (e: Exception) {
|
||||
Log.w("Unregister global receiver: $e", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
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()
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
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())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
@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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
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,162 +1,71 @@
|
||||
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.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 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 kotlinx.coroutines.channels.Channel
|
||||
import java.util.zip.ZipFile
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
object Remote {
|
||||
var clash = Channel<ClashClient>()
|
||||
var profile = Channel<ProfileClient>()
|
||||
val broadcasts: Broadcasts = Broadcasts(Global.application)
|
||||
val service: Service = Service(Global.application) {
|
||||
ApplicationObserver.createdActivities.forEach { it.finish() }
|
||||
|
||||
private var clashConnection: ClashConnection? = null
|
||||
private var profileConnection: ProfileConnection? = null
|
||||
val intent = AppCrashedActivity::class.intent
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
|
||||
class ClashConnection : ServiceConnection {
|
||||
private var instance: ClashClient? = null
|
||||
private var sender: Job? = null
|
||||
Global.application.startActivity(intent)
|
||||
}
|
||||
|
||||
override fun onServiceDisconnected(name: ComponentName?) {
|
||||
sender?.cancel()
|
||||
instance = null
|
||||
sender = 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 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)
|
||||
}
|
||||
while (true) {
|
||||
if (visible.receive()) {
|
||||
service.bind()
|
||||
broadcasts.register()
|
||||
} else {
|
||||
service.unbind()
|
||||
broadcasts.unregister()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
private fun getLastUpdated(context: Context): Long {
|
||||
return context.packageManager.getPackageInfo(context.packageName, 0).lastUpdateTime
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package com.github.kr328.clash.remote
|
||||
|
||||
import android.app.Application
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.ServiceConnection
|
||||
import android.os.IBinder
|
||||
import com.github.kr328.clash.common.log.Log
|
||||
import com.github.kr328.clash.common.util.intent
|
||||
import com.github.kr328.clash.service.RemoteService
|
||||
import com.github.kr328.clash.service.remote.IRemoteService
|
||||
import com.github.kr328.clash.service.remote.unwrap
|
||||
import com.github.kr328.clash.util.unbindServiceSilent
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class Service(private val context: Application, val crashed: () -> Unit) {
|
||||
val remote = Resource<IRemoteService>()
|
||||
|
||||
private val connection = object : ServiceConnection {
|
||||
private var lastCrashed: Long = -1
|
||||
|
||||
override fun onServiceConnected(name: ComponentName?, service: IBinder) {
|
||||
remote.set(service.unwrap(IRemoteService::class))
|
||||
}
|
||||
|
||||
override fun onServiceDisconnected(name: ComponentName?) {
|
||||
remote.set(null)
|
||||
|
||||
if (System.currentTimeMillis() - lastCrashed < TOGGLE_CRASHED_INTERVAL) {
|
||||
unbind()
|
||||
|
||||
crashed()
|
||||
}
|
||||
|
||||
lastCrashed = System.currentTimeMillis()
|
||||
|
||||
Log.w("RemoteManager crashed")
|
||||
}
|
||||
}
|
||||
|
||||
fun bind() {
|
||||
try {
|
||||
context.bindService(RemoteService::class.intent, connection, Context.BIND_AUTO_CREATE)
|
||||
} catch (e: Exception) {
|
||||
unbind()
|
||||
|
||||
crashed()
|
||||
}
|
||||
}
|
||||
|
||||
fun unbind() {
|
||||
context.unbindServiceSilent(connection)
|
||||
|
||||
remote.set(null)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TOGGLE_CRASHED_INTERVAL = TimeUnit.SECONDS.toMillis(10)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
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))
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.github.kr328.clash.util
|
||||
|
||||
import android.content.Context
|
||||
import java.io.File
|
||||
|
||||
val Context.logsDir: File
|
||||
get() = cacheDir.resolve("logs")
|
||||
@@ -0,0 +1,46 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.github.kr328.clash.util
|
||||
|
||||
import android.net.Uri
|
||||
|
||||
val Uri.fileName: String?
|
||||
get() = schemeSpecificPart.split("/").lastOrNull()
|
||||
@@ -1,25 +0,0 @@
|
||||
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 -> ""
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
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)
|
||||
@@ -1,32 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
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 }
|
||||
}
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
package com.github.kr328.clash.utils
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.VpnService
|
||||
import com.github.kr328.clash.preference.UiSettings
|
||||
import com.github.kr328.clash.service.ClashService
|
||||
import com.github.kr328.clash.service.Intents
|
||||
import com.github.kr328.clash.service.util.intent
|
||||
import com.github.kr328.clash.service.util.startForegroundServiceCompat
|
||||
|
||||
fun Context.startClashService(): Intent? {
|
||||
val startTun = UiSettings(this).get(UiSettings.ENABLE_VPN)
|
||||
|
||||
if ( startTun ) {
|
||||
val vpnRequest = VpnService.prepare(this)
|
||||
if ( vpnRequest != null )
|
||||
return vpnRequest
|
||||
}
|
||||
|
||||
startForegroundServiceCompat(ClashService::class.intent
|
||||
.putExtra(Intents.INTENT_EXTRA_START_TUN, startTun))
|
||||
|
||||
return null
|
||||
}
|
||||
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