Compare commits

..
69 Commits
Author SHA1 Message Date
kr328 a463d94480 Chore: bump version 2021-06-13 04:22:53 +08:00
kr328 750abc8c71 Improve: enable system proxy by default 2021-06-13 04:22:15 +08:00
kr328 8375fbd8b3 Fix: add http timeout & disable keep-alive 2021-06-13 02:45:20 +08:00
kr328 394e406a36 Chore: update tun2socket 2021-06-13 02:35:35 +08:00
kr328 2645af0d4c Chore: update tun2socket 2021-05-29 19:56:21 +08:00
Kr328 48222c22c8 Fix: should close connection if match blocking list 2021-05-29 00:35:04 +08:00
kr328 d6a71267c6 Chore: bump version 2021-05-28 23:05:09 +08:00
Kr328 0f4a46188c Chore: remove verbose logs 2021-05-28 11:36:29 +08:00
Kr328 5917b90837 Feature: add block loopback connections 2021-05-28 11:32:21 +08:00
Kr328 a222e90d1f Chore: update kaidl 2021-05-28 10:52:41 +08:00
Kr328 3f60d713f8 Chore: update dependencies 2021-05-28 10:52:35 +08:00
Kr328 9cb8433f3b Chore: update tun2socket 2021-05-28 10:43:00 +08:00
Goooler 428ca53532 Chore: use "implementation" & remove "exclude" for buildSrc dependencies (#1034) 2021-05-28 10:37:23 +08:00
kr328 6c4d7e537b Chore: bump version 2021-05-26 19:48:11 +08:00
kr328 58ab89736a Chore: change tun interface address/route 2021-05-26 16:03:43 +08:00
Kr328 5e34221a09 Fix: refactor tun implement 2021-05-26 15:33:59 +08:00
Kr328 ac35f2a5f4 Chore: update tun2socket 2021-05-26 03:17:30 +08:00
kr328 73992dca54 Chore: update tun2socket 2021-05-25 22:50:35 +08:00
kr328 53dc20109d Improve: migrate to latest dependencies 2021-05-25 22:49:03 +08:00
kr328 e7fef0a767 Chore: bump version 2021-05-25 22:30:38 +08:00
kr328 c73beabf7e Fix: launch mode of MainActivity should be singleTop 2021-05-25 20:30:07 +08:00
kr328 c7409d7ac6 Chore: cleanup code 2021-05-25 20:28:57 +08:00
kr328 5e238ab5d3 Chore: update tun2socket 2021-05-25 20:28:51 +08:00
kr328 a8f502ef4f Improve: merge ClashManager and ProfileService 2021-05-25 20:28:40 +08:00
kr328 c48ce82640 Fix: fix generated ksp search path 2021-05-25 19:40:44 +08:00
kr328 5594485bec Improve: enable -O3 for libbridge.so 2021-05-25 19:23:19 +08:00
kr328 f967bd299a Improve: enable CMAKE_POSITION_INDEPENDENT_CODE 2021-05-25 19:14:57 +08:00
kr328 a75c8f5dfc Chore: update tun2socket 2021-05-25 19:10:47 +08:00
kr328 271d56c01c Chore: update tun2socket 2021-05-25 14:11:17 +08:00
Kr328 12220789a3 Improve: clean tun2socket build on tasks["clean"] 2021-05-25 02:25:30 +08:00
kr328 ca85a688cc Chore: bump version 2021-05-24 19:45:09 +08:00
kr328 0e3ed548c9 Chore: update tun2socket 2021-05-24 19:44:41 +08:00
Kr328 27dc5395e0 Improve: migrate to latest dependencies 2021-05-24 16:15:06 +08:00
Kr328 4f05ba1ac6 Feature: Add workflow for push/PR checking (#1030)
* Chore: downgrade ndk version

* Fix: fix local.properties load

* Feature: add workflow for push/PR checking
2021-05-24 16:15:06 +08:00
kr328 1a36218c80 Feature: add nameserver-policy override 2021-05-24 16:15:06 +08:00
Goooler 3125b90efe Chore: update dependencies 2021-05-24 16:14:59 +08:00
Kr328 2a7425700a Chore: update tun2socket 2021-05-24 03:15:58 +08:00
kr328 f8a3308b46 Chore: bump version 2021-05-23 22:42:20 +08:00
kr328 93aed001af Chore: update clash core 2021-05-23 22:20:21 +08:00
kr328 44461b67ee Chore: update tun2socket 2021-05-23 18:45:15 +08:00
kr328 8a55dc1945 Fix: remove tun address from bypass_private_route 2021-05-22 19:59:12 +08:00
kr328 3af16bcf34 Chore: update tun2socket 2021-05-22 19:55:51 +08:00
kr328 9adf0f00ae Chore: update README.md 2021-05-22 19:54:34 +08:00
kr328 a2f3330455 Fix: force use cmake installed in system 2021-05-22 19:53:28 +08:00
kr328 7cfefbca19 Improve: use 'redir-host' instead of 'fake-ip' 2021-05-22 01:38:15 +08:00
kr328 e8eddb3917 Chore: remove unused logger 2021-05-22 01:30:53 +08:00
kr328 634c25894c Fix: should close all endpoint on exit 2021-05-22 01:29:25 +08:00
kr328 42f5331a0c Fix: should close TCP/UDP endpoint on exit 2021-05-22 01:26:02 +08:00
kr328 e2383df06b Improve: improve clash core build 2021-05-22 01:22:09 +08:00
Kr328 7381b787f3 Improve: use lwip stack instead of kernel 2021-05-21 21:02:22 +08:00
Kr328 eff310ac40 Chore: should ignore appcenter.key in foss build 2021-05-20 10:59:54 +08:00
kr328 c245fc6dfa Fix: use hardcoded components package name 2021-05-16 22:56:57 +08:00
kr328 b8f0c32ad5 Chore: remove migration error tracking 2021-05-16 20:00:47 +08:00
kr328 4d580eda0e Fix: .gitignore ignore foss/release 2021-05-16 18:52:22 +08:00
kr328 2db91740a4 Chore: bump version 2021-05-16 18:38:11 +08:00
kr328 cc304de7a7 Improve: add sources code urls for flavor 'foss' 2021-05-16 18:36:28 +08:00
kr328 d48b15afee Improve: "inline" kaidl 2021-05-16 18:26:01 +08:00
kr328 0338058495 Improve: add package suffix for flavor 'foss' 2021-05-16 18:18:28 +08:00
kr328 0b69bf735e Improve: remove tracking for flavor 'foss' 2021-05-16 18:15:36 +08:00
kr328 45ab647c67 Chore: rename flavor 'open' to 'foss' 2021-05-16 17:55:47 +08:00
kr328 b470dcc252 Fix: fix signing config 2021-05-16 17:47:49 +08:00
kr328 2f69985fe1 Chore: update gradle wrapper & append sha256sum 2021-05-15 19:15:43 +08:00
kr328 44424c5c9e Improve: disable proxy group lazy url-test 2021-05-15 10:58:07 +08:00
kr328 e4c27ac233 Improve: auto re-generate go.sum on build 2021-05-15 10:20:58 +08:00
kr328 08db7c56fa Chore: update core 2021-05-15 10:13:35 +08:00
Kr328 75745d89d7 Chore: update CONTRIBUTING.md 2021-05-15 02:01:38 +08:00
kr328 5dbc11a450 Chore: add open-source build outputs to .gitignore 2021-05-15 01:08:46 +08:00
kr328 590183ebc0 Chore: add build outputs to .gitignore 2021-05-15 01:07:09 +08:00
kr328 07e8afa69a Initial: initial commit 2021-05-15 00:51:08 +08:00
657 changed files with 23712 additions and 11716 deletions

No files matched your search

@@ -0,0 +1,57 @@
---
name: "[English] Bug report"
about: Create a report to help us improve
title: "[BUG] "
labels: ''
assignees: ''
---
<!-- Be sure to put a clear title after [BUG] in the text box above -->
<!-- Be sure to put a clear title after [BUG] in the text box above -->
<!-- Be sure to put a clear title after [BUG] in the text box above -->
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Device Info (please complete the following information):**
- Device: [e.g. Pixel 4]
- ROM: [e.g: AOSP]
- ROM Version:
- Android Version [e.g. 10]
**Application Info (please complete the following information):**
- Version: [e.g. 1.1.10]
- Apk File Name: [e.g. app-release-arm64-v8a.apk]
- Distribution Channel: [e.g. Google Play]
**Additional context**
Add any other context about the problem here.
**Configure**
Paste configure file which **removed server info**
```yaml
# paste here
```
**Logs**
Paste logs to help detect problem
```
<paste here>
```
@@ -0,0 +1,24 @@
---
name: "[English] Feature request"
about: Suggest an idea for this app
title: "[Feature Request] "
labels: ''
assignees: ''
---
<!-- Be sure to put a clear title after [Feature Request] in the text box above -->
<!-- Be sure to put a clear title after [Feature Request] in the text box above -->
<!-- Be sure to put a clear title after [Feature Request] in the text box above -->
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.
@@ -0,0 +1,55 @@
---
name: "[简体中文] 创建错误报告"
about: 创建错误报告以帮助我们改进应用
title: "[BUG] "
labels: ''
assignees: ''
---
<!-- 请务必在上方文本框处 [BUG] 后填入清晰明了的标题 -->
<!-- 请务必在上方文本框处 [BUG] 后填入清晰明了的标题 -->
<!-- 请务必在上方文本框处 [BUG] 后填入清晰明了的标题 -->
**描述出现的错误**
请简洁的描述你遇到的错误
**如何复现该错误**
复现步骤:
1. ...
2. ...
3. ...
4. ...
**预期行为**
清晰简单的描述你预期的应用应该表现的行为
**屏幕截图**
如果适用, 上传屏幕截图以帮助描述错误
**设备信息 (请完成以下信息):**
- 机型: [例如: Pixel 4]
- 系统/ROM: [例如: MIUI 11]
- Android 版本 [例如: 10]
- ROM版本 [例如: 20.3.19]
**应用信息**
- 版本: [例如: 1.1.10]
- 安装包文件名: [例如: app-release-arm64-v8a.apk]
- 应用来源: [例如: Google Play]
**附加信息**
其他的可能与改错误相关的信息
**配置文件**
在此粘贴 **去除服务器信息的** 的 **配置文件**
```yaml
# 在此粘贴
```
**日志**
粘贴日志以帮助侦测错误
```
<在此粘贴>
```
@@ -0,0 +1,21 @@
---
name: "[简体中文] 功能请求"
about: 你希望的能够在应用中增加的功能
title: "[Feature Request] "
labels: ''
assignees: ''
---
<!-- 请务必在上方文本框处 [Feature Request] 后填入清晰明了的标题 -->
<!-- 请务必在上方文本框处 [Feature Request] 后填入清晰明了的标题 -->
<!-- 请务必在上方文本框处 [Feature Request] 后填入清晰明了的标题 -->
**功能描述**
请清晰的描述你想要的功能
**描述你希望的实现方式**
清晰的描述应用应该如何实现该功能
**附加信息**
其他的与改功能相关的附加信息
+1
View File
@@ -0,0 +1 @@
blank_issues_enabled: false
+24
View File
@@ -0,0 +1,24 @@
name: Build Unsigned
on: [push, pull_request]
jobs:
BuildUnsigned:
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: actions/checkout@v2
with:
submodules: recursive
- name: Setup Java
uses: actions/setup-java@v1
with:
java-version: 11
- name: Setup Go
uses: actions/setup-go@v2
with:
go-version: 1.16
- name: Setup Android SDK
uses: android-actions/setup-android@v2
- name: Setup Cmake & Ninja
uses: lukka/get-cmake@latest
- name: Build
run: ./gradlew --no-daemon app:assembleRelease
+12 -5
View File
@@ -1,6 +1,7 @@
.gradle
build/
/app/release/
/app/foss/release
/app/premium/release
/captures
# Ignore Gradle GUI config
@@ -16,16 +17,16 @@ gradle-app.setting
# gradle/wrapper/gradle-wrapper.properties
# Ignore IDEA config
.idea
*.iml
/.idea/*
/core/src/main/golang/.idea/*
!/.idea/codeStyles
!/core/src/main/golang/.idea/codeStyles
# KeyStore
*.keystore
*.jks
# gradle
.gradle
# clion cmake build
cmake-build-*
@@ -48,3 +49,9 @@ google-services.json
# Dolphin
.directory
# logs
*.log
# Ignore sum
/core/src/main/golang/go.sum
+7 -1
View File
@@ -1,3 +1,9 @@
[submodule "core/src/main/golang/clash"]
path = core/src/main/golang/clash
url = https://github.com/Kr328/Clash
url = https://github.com/Kr328/clash.git
[submodule "kaidl"]
path = kaidl
url = https://github.com/Kr328/kaidl.git
[submodule "core/src/main/golang/tun2socket"]
path = core/src/main/golang/tun2socket
url = https://github.com/Kr328/tun2socket-lwip.git
+151
View File
@@ -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>
+5
View File
@@ -0,0 +1,5 @@
<component name="ProjectCodeStyleConfiguration">
<state>
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
</state>
</component>
+14
View File
@@ -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)
+18 -22
View File
@@ -1,10 +1,8 @@
## 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
@@ -14,10 +12,9 @@ Fully feature of [clash](https://github.com/Dreamacro/clash) ~~(Exclude `externa
### 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
@@ -39,28 +36,27 @@ See also [PRIVACY_POLICY.md](./PRIVACY_POLICY.md)
git submodule update --init --recursive
```
2. Install `Android SDK (include JDK)` ,`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 `keystore.properties` in project root with
on Linux
```properties
storeFile=/path/to/keystore/file
storePassword=<key store password>
keyAlias=<key alias>
keyPassword=<key password>
```
5. Build
```bash
./gradlew build
./gradlew app:assembleFossRelease
```
on Windows
```bash
.\gradlew.bat build
```
6. Pick `app-release-<arch>.apk` in `app/build/outputs/apks`
-1
View File
@@ -1 +0,0 @@
/build
-76
View File
@@ -1,76 +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
shrinkResources true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
compileOptions {
sourceCompatibility = 1.8
targetCompatibility = 1.8
}
kotlinOptions {
jvmTarget = "1.8"
}
splits {
abi {
enable true
universalApk true
}
}
}
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)
}
+140
View File
@@ -0,0 +1,140 @@
import java.util.*
plugins {
id("com.android.application")
kotlin("android")
kotlin("kapt")
}
android {
compileSdk = buildTargetSdkVersion
flavorDimensions(buildFlavor)
defaultConfig {
applicationId = "com.github.kr328.clash"
minSdk = buildMinSdkVersion
targetSdk = buildTargetSdkVersion
versionCode = buildVersionCode
versionName = buildVersionName
resConfigs("zh-rCN", "zh-rHK", "zh-rTW")
resValue("string", "release_name", "v$buildVersionName")
resValue("integer", "release_code", "$buildVersionCode")
}
buildTypes {
named("release") {
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
productFlavors {
create("foss") {
dimension = "foss"
versionNameSuffix = ".foss"
applicationIdSuffix = ".foss"
}
create("premium") {
dimension = "premium"
versionNameSuffix = ".premium"
if (buildFlavor == "premium") {
val localFile = rootProject.file("local.properties")
if (localFile.exists()) {
val appCenterKey = localFile.inputStream()
.use { Properties().apply { load(it) } }
.getProperty("appcenter.key", null)
if (appCenterKey != null) {
buildConfigField("String", "APP_CENTER_KEY", "\"$appCenterKey\"")
} else {
buildConfigField("String", "APP_CENTER_KEY", "null")
}
} else {
buildConfigField("String", "APP_CENTER_KEY", "null")
}
}
}
}
val signingFile = rootProject.file("keystore.properties")
if (signingFile.exists()) {
val properties = Properties().apply {
signingFile.inputStream().use {
load(it)
}
}
signingConfigs {
create("release") {
storeFile = rootProject.file(properties.getProperty("storeFile")!!)
storePassword = properties.getProperty("storePassword")!!
keyAlias = properties.getProperty("keyAlias")!!
keyPassword = properties.getProperty("keyPassword")!!
}
}
buildTypes {
named("release") {
signingConfig = signingConfigs["release"]
}
}
}
buildFeatures {
dataBinding = true
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
splits {
abi {
isEnable = true
isUniversalApk = true
}
}
}
dependencies {
val premiumImplementation by configurations
api(project(":core"))
api(project(":service"))
api(project(":design"))
api(project(":common"))
premiumImplementation("com.microsoft.appcenter:appcenter-analytics:$appcenterVersion")
premiumImplementation("com.microsoft.appcenter:appcenter-crashes:$appcenterVersion")
implementation(kotlin("stdlib-jdk7"))
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutineVersion")
implementation("androidx.core:core-ktx:$coreVersion")
implementation("androidx.activity:activity:$activityVersion")
implementation("androidx.appcompat:appcompat:$appcompatVersion")
implementation("androidx.coordinatorlayout:coordinatorlayout:$coordinatorlayoutVersion")
implementation("androidx.recyclerview:recyclerview:$recyclerviewVersion")
implementation("androidx.fragment:fragment:$fragmentVersion")
implementation("com.google.android.material:material:$materialVersion")
}
task("cleanRelease", type = Delete::class) {
delete(file("release"))
}
afterEvaluate {
tasks["clean"].dependsOn(tasks["cleanRelease"])
}
+11 -1
View File
@@ -20,4 +20,14 @@
# 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(...);
}
@@ -0,0 +1,14 @@
package com.github.kr328.clash
import android.app.Application
@Suppress("UNUSED_PARAMETER")
object Tracker {
fun initialize(application: Application) {
// do nothing
}
fun uploadLogcat(logcat: String) {
// do nothing
}
}
+104 -44
View File
@@ -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: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="singleTop"/>
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>
@@ -0,0 +1,142 @@
package com.github.kr328.clash
import android.Manifest.permission.INTERNET
import android.content.ClipData
import android.content.ClipboardManager
import android.content.pm.ApplicationInfo
import android.content.pm.PackageInfo
import android.content.pm.PackageManager
import androidx.core.content.getSystemService
import com.github.kr328.clash.design.AccessControlDesign
import com.github.kr328.clash.design.model.AppInfo
import com.github.kr328.clash.design.util.toAppInfo
import com.github.kr328.clash.service.store.ServiceStore
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.isActive
import kotlinx.coroutines.selects.select
import kotlinx.coroutines.withContext
class AccessControlActivity : BaseActivity<AccessControlDesign>() {
override suspend fun main() {
val service = ServiceStore(this)
val selected = withContext(Dispatchers.IO) {
service.accessControlPackages.toMutableSet()
}
defer {
withContext(Dispatchers.IO) {
service.accessControlPackages = selected
}
}
val design = AccessControlDesign(this, uiStore, selected)
setContentDesign(design)
design.requests.send(AccessControlDesign.Request.ReloadApps)
while (isActive) {
select<Unit> {
events.onReceive {
}
design.requests.onReceive {
when (it) {
AccessControlDesign.Request.ReloadApps -> {
design.patchApps(loadApps(selected))
}
AccessControlDesign.Request.SelectAll -> {
val all = withContext(Dispatchers.Default) {
design.apps.map(AppInfo::packageName)
}
selected.clear()
selected.addAll(all)
design.rebindAll()
}
AccessControlDesign.Request.SelectNone -> {
selected.clear()
design.rebindAll()
}
AccessControlDesign.Request.SelectInvert -> {
val all = withContext(Dispatchers.Default) {
design.apps.map(AppInfo::packageName).toSet() - selected
}
selected.clear()
selected.addAll(all)
design.rebindAll()
}
AccessControlDesign.Request.Import -> {
val clipboard = getSystemService<ClipboardManager>()
val data = clipboard?.primaryClip
if (data != null && data.itemCount > 0) {
val all = withContext(Dispatchers.IO) {
val packages = data.getItemAt(0).text.split("\n").toSet()
design.apps.map(AppInfo::packageName).intersect(packages)
}
selected.clear()
selected.addAll(all)
}
design.rebindAll()
}
AccessControlDesign.Request.Export -> {
val clipboard = getSystemService<ClipboardManager>()
withContext(Dispatchers.IO) {
val data = ClipData.newPlainText(
"packages",
selected.joinToString("\n")
)
clipboard?.setPrimaryClip(data)
}
}
}
}
}
}
}
private suspend fun loadApps(selected: Set<String>): List<AppInfo> =
withContext(Dispatchers.IO) {
val reverse = uiStore.accessControlReverse
val sort = uiStore.accessControlSort
val systemApp = uiStore.accessControlSystemApp
val base = compareByDescending<AppInfo> { it.packageName in selected }
val comparator = if (reverse) base.thenDescending(sort) else base.then(sort)
val pm = packageManager
val packages = pm.getInstalledPackages(PackageManager.GET_PERMISSIONS)
packages.asSequence()
.filter {
it.packageName != packageName
}
.filter {
it.packageName == "android" || it.requestedPermissions?.contains(INTERNET) == true
}
.filter {
systemApp || !it.isSystemApp
}
.map {
it.toAppInfo(pm)
}
.sortedWith(comparator)
.toList()
}
private val PackageInfo.isSystemApp: Boolean
get() {
return applicationInfo.flags and ApplicationInfo.FLAG_SYSTEM != 0
}
}
@@ -2,70 +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,156 +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.util.createLanguageConfigurationContext
import com.google.android.material.snackbar.Snackbar
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
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() {
launch {
onClashProfileLoaded()
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() {}
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)
super.attachBaseContext(base.createLanguageConfigurationContext(language))
}
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) || darkMode != uiSettings.get(UiSettings.DARK_MODE))
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()
}
}
}
@@ -159,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 >= Build.VERSION_CODES.M) {
window.isLightStatusBarsCompat =
resolveThemedBoolean(android.R.attr.windowLightStatusBar)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
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(),
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,144 +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(),
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()
}
}
@@ -12,215 +12,114 @@ import android.os.Binder
import android.os.Build
import android.os.IBinder
import android.os.IInterface
import androidx.collection.CircularArray
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.preference.UiSettings
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.createLanguageConfigurationContext
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.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_LISTENER_KEY = "logcat_service"
private const val LOG_CONTENT_FORMAT = "%d %s %s"
var isServiceRunning: Boolean = false
}
data class Request(val offset: Long, val response: CompletableDeferred<Response>)
data class Response(val offset: Long, val logs: List<LogEvent>)
private val logChannel = Channel<LogEvent>(MAX_CACHE_COUNT)
private val requestChannel = Channel<Request>()
private val cache: CircularArray<LogEvent> = CircularArray()
private var cacheOffset = 0L
private val entity = LogFile.generate()
class LogcatService : Service(), CoroutineScope by CoroutineScope(Dispatchers.Default), IInterface {
private val cache = LogcatCache()
private val connection = object : ServiceConnection {
private var manager: IClashManager? = null
override fun onServiceDisconnected(name: ComponentName?) {
manager?.unregisterLogListener(LOG_LISTENER_KEY)
stopSelf()
}
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
manager = IClashManager.Stub.asInterface(service) ?: return stopSelf()
manager?.registerLogListener(LOG_LISTENER_KEY, object : IStreamCallback.Stub() {
override fun complete() {}
override fun completeExceptionally(reason: String?) {}
override fun send(data: ParcelableContainer?) {
data ?: return
data.data ?: return
logChannel.offer(data.data as LogEvent)
}
})
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()
connection.onServiceDisconnected(null)
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
}
}
}
override fun attachBaseContext(base: Context?) {
val b = base ?: return super.attachBaseContext(base)
val language = UiSettings(b).get(UiSettings.LANGUAGE)
super.attachBaseContext(b.createLanguageConfigurationContext(language))
suspend fun snapshot(full: Boolean): LogcatCache.Snapshot? {
return cache.snapshot(full)
}
// Export to UI
suspend fun pollLogEvent(offset: Long): CompletableDeferred<Response> {
val request = Request(offset, CompletableDeferred())
private fun startObserver(binder: IBinder) {
if (!binder.isBinderAlive)
return stopSelf()
requestChannel.send(request)
return request.response
}
private fun launchProcessor() {
launch {
val pendingRequest: MutableList<Request> = mutableListOf()
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()
}
}
}
@@ -233,7 +132,7 @@ class LogcatService : Service(), CoroutineScope by MainScope(), IInterface {
NotificationManagerCompat.from(this)
.createNotificationChannel(
NotificationChannel(
NOTIFICATION_CHANNEL_ID,
CHANNEL_ID,
getString(R.string.clash_logcat),
NotificationManager.IMPORTANCE_DEFAULT
)
@@ -242,23 +141,29 @@ class LogcatService : Service(), CoroutineScope by MainScope(), IInterface {
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))
.setGroup(NOTIFICATION_CHANNEL_ID)
.setContentIntent(
PendingIntent.getActivity(
this,
NOTIFICATION_ID,
LogsActivity::class.intent
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK),
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),
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,270 +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,198 +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.util.intent
import com.github.kr328.clash.utils.startClashService
import com.github.kr328.clash.utils.stopClashService
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) {
stopClashService()
} 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)
startClashService()
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() {
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,16 +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.dump.LogcatDumper
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.AbstractCrashesListener
import com.microsoft.appcenter.crashes.Crashes
import com.microsoft.appcenter.crashes.ingestion.models.ErrorAttachmentLog
import com.microsoft.appcenter.crashes.model.ErrorReport
import com.github.kr328.clash.service.util.sendServiceRecreated
@Suppress("unused")
class MainApplication : Application() {
@@ -25,30 +20,20 @@ class MainApplication : Application() {
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
)
Tracker.initialize(this)
Crashes.setListener(object : AbstractCrashesListener() {
override fun getErrorAttachments(report: ErrorReport?): MutableIterable<ErrorAttachmentLog> {
report ?: return mutableListOf()
val processName = currentProcessName
if (!report.stackTrace.contains("DeadObjectException"))
return mutableListOf()
Log.d("Process $processName started")
val logcat = LogcatDumper.dump().joinToString(separator = "\n")
return mutableListOf(
ErrorAttachmentLog.attachmentWithText(logcat, "logcat.txt")
)
}
})
if (processName == packageName) {
Remote.launch()
} else {
sendServiceRecreated()
}
}
Remote.init(this)
Broadcasts.init(this)
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,23 +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,298 +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.pipeline.Pipeline
import com.github.kr328.clash.pipeline.mergePrefix
import com.github.kr328.clash.pipeline.sort
import com.github.kr328.clash.pipeline.toAdapterElement
import com.github.kr328.clash.preference.UiSettings
import com.github.kr328.clash.remote.withClash
import com.github.kr328.clash.utils.ScrollBinding
import kotlinx.android.synthetic.main.activity_proxies.*
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
class ProxiesActivity : BaseActivity(), ScrollBinding.Callback {
private val refreshMutex = Mutex()
private val scrollBinding = ScrollBinding(this, this)
private var scrollToLast = true
private val mainListAdapter: ProxyAdapter
get() = mainList.adapter as ProxyAdapter
private val chipListAdapter: ProxyChipAdapter
get() = chipList.adapter as ProxyChipAdapter
private val urlTesting: MutableSet<String> = mutableSetOf()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_proxies)
setSupportActionBar(toolbar)
mainList.adapter = ProxyAdapter(this, this::setGroupSelected, this::startUrlTesting)
mainList.layoutManager = mainListAdapter.layoutManager
chipList.adapter = ProxyChipAdapter(this, this::chipClicked)
chipList.layoutManager = LinearLayoutManager(this, LinearLayoutManager.HORIZONTAL, false)
chipList.itemAnimator?.changeDuration = 0
launch {
mainList.addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
scrollBinding.sendMasterScrolled()
}
})
scrollBinding.exec()
}
refreshList()
}
override fun onStop() {
uiSettings.commit {
put(
UiSettings.PROXY_LAST_SELECT_GROUP,
(chipList.adapter!! as ProxyChipAdapter).selected
)
}
super.onStop()
}
override fun onCreateOptionsMenu(menu: Menu?): Boolean {
if (!super.onCreateOptionsMenu(menu))
return false
menuInflater.inflate(R.menu.proxies, menu)
setupMenu()
return true
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
if (super.onOptionsItemSelected(item))
return true
if (item.itemId == R.id.menuRefresh) {
refreshList()
return true
}
launch {
var scrollTop = false
when (item.itemId) {
R.id.modeDirect -> {
withClash {
setProxyMode(General.Mode.DIRECT)
}
}
R.id.modeGlobal -> {
withClash {
setProxyMode(General.Mode.GLOBAL)
scrollTop = true
}
}
R.id.modeRule -> {
withClash {
setProxyMode(General.Mode.RULE)
}
}
R.id.groupDefault -> {
uiSettings.commit {
put(UiSettings.PROXY_GROUP_SORT, UiSettings.PROXY_SORT_DEFAULT)
}
}
R.id.groupName -> {
uiSettings.commit {
put(UiSettings.PROXY_GROUP_SORT, UiSettings.PROXY_SORT_NAME)
}
}
R.id.groupDelay -> {
uiSettings.commit {
put(UiSettings.PROXY_GROUP_SORT, UiSettings.PROXY_SORT_DELAY)
}
}
R.id.proxyDefault -> {
uiSettings.commit {
put(UiSettings.PROXY_PROXY_SORT, UiSettings.PROXY_SORT_DEFAULT)
}
}
R.id.proxyName -> {
uiSettings.commit {
put(UiSettings.PROXY_PROXY_SORT, UiSettings.PROXY_SORT_NAME)
}
}
R.id.proxyDelay -> {
uiSettings.commit {
put(UiSettings.PROXY_PROXY_SORT, UiSettings.PROXY_SORT_DELAY)
}
}
R.id.utilsMergePrefix -> {
item.isChecked = !item.isChecked
uiSettings.commit {
put(UiSettings.PROXY_MERGE_PREFIX, item.isChecked)
}
refreshList()
return@launch
}
else -> return@launch
}
item.isChecked = true
refreshList(scrollTop)
}
return true
}
override val activityLabel: CharSequence?
get() = getText(R.string.proxy)
override suspend fun onClashStopped(reason: String?) {
finish()
}
private fun setupMenu() {
launch {
val general = withClash {
queryGeneral()
}
menu?.apply {
when (general.mode) {
General.Mode.DIRECT ->
findItem(R.id.modeDirect).isChecked = true
General.Mode.GLOBAL ->
findItem(R.id.modeGlobal).isChecked = true
General.Mode.RULE ->
findItem(R.id.modeRule).isChecked = true
}
when (uiSettings.get(UiSettings.PROXY_GROUP_SORT)) {
UiSettings.PROXY_SORT_DEFAULT ->
findItem(R.id.groupDefault).isChecked = true
UiSettings.PROXY_SORT_NAME ->
findItem(R.id.groupName).isChecked = true
UiSettings.PROXY_SORT_DELAY ->
findItem(R.id.proxyDelay).isChecked = true
}
when (uiSettings.get(UiSettings.PROXY_PROXY_SORT)) {
UiSettings.PROXY_SORT_DEFAULT ->
findItem(R.id.proxyDefault).isChecked = true
UiSettings.PROXY_SORT_NAME ->
findItem(R.id.proxyName).isChecked = true
UiSettings.PROXY_SORT_DELAY ->
findItem(R.id.proxyDelay).isChecked = true
}
findItem(R.id.utilsMergePrefix).isChecked =
uiSettings.get(UiSettings.PROXY_MERGE_PREFIX)
}
}
}
private fun setGroupSelected(group: String, select: String) {
launch {
withClash {
setSelectProxy(group, select)
}
}
}
private fun startUrlTesting(group: String) {
launch {
urlTesting.add(group)
withClash {
startHealthCheck(group)
}
urlTesting.remove(group)
refreshList()
}
}
private fun chipClicked(name: String) {
launch {
scrollBinding.scrollMaster(name)
}
}
private fun refreshList(scrollTop: Boolean = false) {
launch {
if (!refreshMutex.tryLock())
return@launch
val general = withClash {
queryGeneral()
}
val proxies = withClash {
queryAllProxyGroups()
}
val merged = Pipeline(proxies, uiSettings).mergePrefix()
val sorted = Pipeline(proxies, uiSettings).sort()
val newList = sorted.toAdapterElement(merged.input, general)
mainListAdapter.applyChange(newList, urlTesting)
(chipList.adapter!! as ProxyChipAdapter).apply {
chips = newList.map { it.name }
notifyDataSetChanged()
}
if (scrollTop)
mainList.smoothScrollToPosition(0)
else if (scrollToLast) {
scrollToLast = false
val selected = uiSettings.get(UiSettings.PROXY_LAST_SELECT_GROUP)
scrollBinding.scrollMaster(selected)
}
delay(200)
refreshMutex.unlock()
}
}
override fun getCurrentMasterToken(): String {
return mainListAdapter.getCurrentGroup()
}
override fun onMasterTokenChanged(token: String) {
chipListAdapter.selected = token
val position = chipListAdapter.chips.indexOf(token)
if (position < 0)
return
chipList.smoothScrollToPosition(position)
}
override fun getMasterTokenPosition(token: String): Int {
return mainListAdapter.getGroupPosition(token)
}
override fun doMasterScroll(scroller: LinearSmoothScroller, target: Int) {
mainListAdapter.layoutManager.startSmoothScroll(scroller)
}
}
@@ -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,49 +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,91 +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)))
)
}
}
}
}
}
}
@@ -5,19 +5,23 @@ 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.Intents
import com.github.kr328.clash.utils.startClashService
import com.github.kr328.clash.utils.stopClashService
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 -> {
@@ -30,72 +34,71 @@ class TileService : TileService() {
}
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.icon = Icon.createWithResource(this, R.drawable.ic_notification)
tile.icon = Icon.createWithResource(this, R.drawable.ic_logo_service)
qsTile.updateTile()
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 -> {
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 -> {
currentProfile = RemoteUtils
.getCurrentClashProfileName(this@TileService) ?: ""
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)
}
)
val name = RemoteUtils.getCurrentClashProfileName(this)
clashRunning = name != null
currentProfile = name ?: ""
}
override fun onDestroy() {
super.onDestroy()
unregisterReceiver(clashStatusReceiver)
}
}
@@ -1,55 +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,131 +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,280 +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.withContext
class ProxyAdapter(
private val context: Context,
val onSelect: (String, String) -> Unit,
val onUrlTest: (String) -> Unit
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
companion object {
const val DEFAULT_SPAN_COUNT = 2
}
data class ProxyGroupInfo(
val name: String,
val current: String,
val proxies: List<ProxyInfo>
)
data class ProxyInfo(
val name: String,
val group: String,
val title: String,
val summary: String,
val delay: Short,
val selectable: Boolean,
val active: Boolean
)
interface RenderInfo {
val name: String
val group: String
}
private data class ProxyGroupRenderInfo(val info: ProxyGroupInfo) :
RenderInfo {
override val name: String
get() = info.name
override val group: String
get() = info.name
}
private data class ProxyRenderInfo(val info: ProxyInfo) : RenderInfo {
override val name: String
get() = info.name
override val group: String
get() = info.group
}
private var urlTesting: Set<String> = emptySet()
private var renderList = mutableListOf<RenderInfo>()
private var activeList: MutableMap<String, Int> = mutableMapOf()
private var groupPosition: MutableMap<String, Int> = mutableMapOf()
@ColorInt
private val colorSurface: Int
@ColorInt
private val colorOnSurface: Int
init {
val typedValue = TypedValue()
context.theme.resolveAttribute(R.attr.colorSurface, typedValue, true)
colorSurface = typedValue.data
context.theme.resolveAttribute(R.attr.colorOnSurface, typedValue, true)
colorOnSurface = typedValue.data
}
val layoutManager = GridLayoutManager(context, DEFAULT_SPAN_COUNT).apply {
spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() {
override fun getSpanSize(position: Int): Int {
val current = renderList.getOrNull(position)
?: renderList.getOrNull(position) ?: return spanCount
return when (current) {
is ProxyGroupRenderInfo -> spanCount
is ProxyRenderInfo -> 1
else -> throw IllegalArgumentException()
}
}
}
}
private var root = listOf<ProxyGroupInfo>()
private class ProxyGroupHeader(view: View) : RecyclerView.ViewHolder(view) {
val title: TextView = view.findViewById(R.id.name)
val urlTest: View = view.findViewById(R.id.urlTest)
val urlTestProgress: View = view.findViewById(R.id.urlTestProgress)
}
private class ProxyItem(view: View) : RecyclerView.ViewHolder(view) {
val root: MaterialCardView = view.findViewById(R.id.root)
val prefix: TextView = view.findViewById(R.id.prefix)
val content: TextView = view.findViewById(R.id.content)
val delay: TextView = view.findViewById(R.id.delay)
}
suspend fun applyChange(newList: List<ProxyGroupInfo>, testing: Set<String>) =
withContext(Dispatchers.Default) {
val newRenderList = newList
.flatMap {
listOf(ProxyGroupRenderInfo(it)) + it.proxies.map { p -> ProxyRenderInfo(p) }
}
val oldRenderList = renderList
val result = DiffUtil.calculateDiff(object : DiffUtil.Callback() {
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int) =
oldRenderList[oldItemPosition]::class == newRenderList[newItemPosition]::class &&
oldRenderList[oldItemPosition].name == newRenderList[newItemPosition].name
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int) =
oldRenderList[oldItemPosition] == newRenderList[newItemPosition]
override fun getOldListSize(): Int = oldRenderList.size
override fun getNewListSize(): Int = newRenderList.size
})
val groupCache: MutableMap<String, Int> = mutableMapOf()
val activeCache: MutableMap<String, Int> = mutableMapOf()
newRenderList.forEachIndexed { index, it ->
when (it) {
is ProxyGroupRenderInfo ->
groupCache[it.name] = index
is ProxyRenderInfo -> {
if (it.info.active)
activeCache[it.group] = index
}
}
}
withContext(Dispatchers.Main) {
root = newList
renderList = newRenderList.toMutableList()
urlTesting = testing
groupPosition = groupCache
activeList = activeCache
result.dispatchUpdatesTo(this@ProxyAdapter)
groupCache.forEach { (_, u) ->
notifyItemChanged(u)
}
}
}
fun getGroupPosition(name: String): Int {
return groupPosition[name] ?: -1
}
fun getCurrentGroup(): String {
val position = layoutManager.findFirstCompletelyVisibleItemPosition()
if (position < 0)
return ""
return renderList[position].group
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
val layoutInflater = LayoutInflater.from(context)
return when (viewType) {
1 -> ProxyGroupHeader(
layoutInflater
.inflate(R.layout.adapter_grid_proxy_group, parent, false)
)
2 -> ProxyItem(
layoutInflater
.inflate(R.layout.adapter_grid_proxy, parent, false)
)
else -> throw IllegalArgumentException()
}
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
when (holder) {
is ProxyGroupHeader -> {
val current = renderList[position] as ProxyGroupRenderInfo
holder.title.text = context.getString(
R.string.format_proxy_group_title,
current.info.name, current.info.current
)
holder.urlTest.setOnClickListener {
holder.urlTest.visibility = View.GONE
holder.urlTestProgress.visibility = View.VISIBLE
onUrlTest(current.name)
}
if (urlTesting.contains(current.name)) {
holder.urlTest.visibility = View.GONE
holder.urlTestProgress.visibility = View.VISIBLE
} else {
holder.urlTest.visibility = View.VISIBLE
holder.urlTestProgress.visibility = View.GONE
}
}
is ProxyItem -> {
val current = renderList[position] as ProxyRenderInfo
holder.prefix.text = current.info.title
holder.content.text = current.info.summary
if (current.info.delay > 0)
holder.delay.text = current.info.delay.toString()
else
holder.delay.text = if (current.info.selectable) "" else "N/A"
if (current.info.active) {
holder.prefix.setTextColor(Color.WHITE)
holder.content.setTextColor(Color.WHITE)
holder.delay.setTextColor(Color.WHITE)
holder.root.setCardBackgroundColor(context.getColor(R.color.primaryCardColorStarted))
} else {
holder.prefix.setTextColor(colorOnSurface)
holder.content.setTextColor(colorOnSurface)
holder.delay.setTextColor(colorOnSurface)
holder.root.setCardBackgroundColor(colorSurface)
}
if (current.info.selectable) {
holder.root.setOnClickListener {
val oldPosition = activeList[current.group] ?: return@setOnClickListener
val groupPosition =
groupPosition[current.group] ?: return@setOnClickListener
val old = renderList[oldPosition] as ProxyRenderInfo
val new = renderList[position] as ProxyRenderInfo
val group = renderList[groupPosition] as ProxyGroupRenderInfo
renderList[oldPosition] = old.copy(info = old.info.copy(active = false))
renderList[position] = new.copy(info = new.info.copy(active = true))
renderList[groupPosition] =
group.copy(info = group.info.copy(current = current.name))
activeList[current.group] = position
notifyItemChanged(oldPosition)
notifyItemChanged(position)
notifyItemChanged(groupPosition)
onSelect(current.group, current.name)
}
holder.root.isClickable = true
holder.root.isFocusable = true
} else {
holder.root.setOnClickListener(null)
holder.root.isClickable = false
holder.root.isFocusable = false
}
}
}
}
override fun getItemCount(): Int = renderList.size
override fun getItemViewType(position: Int): Int {
return when (renderList[position]) {
is ProxyGroupRenderInfo -> 1
is ProxyRenderInfo -> 2
else -> throw IllegalArgumentException()
}
}
}
@@ -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))
}
}
}
@@ -1,34 +0,0 @@
package com.github.kr328.clash.dump
object LogcatDumper {
fun dump(): List<String> {
return try {
val process =
Runtime.getRuntime().exec(arrayOf("logcat", "-d", "-s", "-v", "raw", "Go"))
val result = process.inputStream.bufferedReader().useLines {
var list = mutableListOf<String>()
var capture = false
it.forEach { line ->
if (line.startsWith("panic")) {
capture = true
list = mutableListOf()
}
if (capture)
list.add(line)
}
list
}
process.waitFor()
result
} catch (e: Exception) {
emptyList()
}
}
}
@@ -0,0 +1,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,5 +0,0 @@
package com.github.kr328.clash.pipeline
import com.github.kr328.clash.service.settings.BaseSettings
data class Pipeline<T>(val input: T, val settings: BaseSettings)
@@ -1,101 +0,0 @@
package com.github.kr328.clash.pipeline
import com.github.kr328.clash.adapter.ProxyAdapter
import com.github.kr328.clash.core.model.General
import com.github.kr328.clash.core.model.Proxy
import com.github.kr328.clash.core.model.ProxyGroup
import com.github.kr328.clash.preference.UiSettings
import com.github.kr328.clash.utils.PrefixMerger
import com.github.kr328.clash.utils.ProxySorter
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.withContext
data class ProxyEntry(val group: String, val name: String)
data class ProxyMerged(val prefix: String, val content: String)
suspend fun Pipeline<List<ProxyGroup>>.mergePrefix(): Pipeline<Map<ProxyEntry, ProxyMerged>> {
if (!settings.get(UiSettings.PROXY_MERGE_PREFIX))
return Pipeline(emptyMap(), settings)
val result = coroutineScope {
input
.map {
async {
it.name to PrefixMerger.merge(it.proxies, Proxy::name)
}
}
.map {
it.await()
}
.flatMap {
it.second.map { merged ->
ProxyEntry(it.first, merged.value.name) to ProxyMerged(
merged.prefix,
merged.content
)
}
}
.toMap()
}
return Pipeline(result, settings)
}
suspend fun Pipeline<List<ProxyGroup>>.sort(): Pipeline<List<ProxyGroup>> {
val groupSort = when (settings.get(UiSettings.PROXY_GROUP_SORT)) {
UiSettings.PROXY_SORT_DEFAULT ->
ProxySorter.Order.DEFAULT
UiSettings.PROXY_SORT_NAME ->
ProxySorter.Order.NAME_INCREASE
UiSettings.PROXY_SORT_DELAY ->
ProxySorter.Order.DELAY_INCREASE
else -> throw IllegalArgumentException()
}
val proxySort = when (settings.get(UiSettings.PROXY_PROXY_SORT)) {
UiSettings.PROXY_SORT_DEFAULT ->
ProxySorter.Order.DEFAULT
UiSettings.PROXY_SORT_NAME ->
ProxySorter.Order.NAME_INCREASE
UiSettings.PROXY_SORT_DELAY ->
ProxySorter.Order.DELAY_INCREASE
else -> throw IllegalArgumentException()
}
val sorter = ProxySorter(groupSort, proxySort)
return copy(input = sorter.sort(input))
}
suspend fun Pipeline<List<ProxyGroup>>.toAdapterElement(
prefixMerged: Map<ProxyEntry, ProxyMerged>,
general: General
):
List<ProxyAdapter.ProxyGroupInfo> {
return input.map { group ->
val proxies = group.proxies.map { proxy ->
val merged = prefixMerged[ProxyEntry(group.name, proxy.name)]?.takeIf {
it.prefix.isNotBlank() && it.content.isNotBlank()
} ?: ProxyMerged(proxy.type.toString(), proxy.name)
ProxyAdapter.ProxyInfo(
proxy.name, group.name, merged.content, merged.prefix,
proxy.delay.toShort(), group.type == Proxy.Type.SELECT,
group.current == proxy.name
)
}
ProxyAdapter.ProxyGroupInfo(group.name, group.current, proxies)
}.let {
withContext(Dispatchers.Default) {
when (general.mode) {
General.Mode.DIRECT -> emptyList()
General.Mode.GLOBAL -> it
General.Mode.RULE -> it.filter { it.name != "GLOBAL" }
}
}
}
}
@@ -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,13 +5,12 @@ 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.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()
@@ -20,32 +19,40 @@ object Broadcasts {
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 -> {
Intents.ACTION_PROFILE_LOADED -> {
receivers.forEach {
it.onProfileLoaded()
}
@@ -54,44 +61,43 @@ object Broadcasts {
}
}
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,49 +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) {
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,178 +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 android.os.RemoteException
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.dump.LogcatDumper
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 com.microsoft.appcenter.crashes.Crashes
import com.microsoft.appcenter.crashes.ingestion.models.ErrorAttachmentLog
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({
val log = LogcatDumper.dump().joinToString(separator = "\n")
val attachmentLog = ErrorAttachmentLog
.attachmentWithText(log, "logcat.txt")
Crashes.trackError(
RemoteException("Clash Service Crashed"),
null, listOf(attachmentLog)
)
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,55 +0,0 @@
package com.github.kr328.clash.remote
import android.content.Context
import android.content.Intent
import android.net.Uri
import com.github.kr328.clash.ApkBrokenActivity
import com.github.kr328.clash.service.Constants
import com.github.kr328.clash.service.ServiceStatusProvider
import com.github.kr328.clash.service.util.intent
object RemoteUtils {
fun detectClashRunning(context: Context): Boolean {
try {
val authority = Uri.Builder()
.scheme("content")
.authority("${context.packageName}${Constants.STATUS_PROVIDER_SUFFIX}")
.build()
val pong = context.contentResolver.call(
authority,
ServiceStatusProvider.METHOD_PING_CLASH_SERVICE,
null,
null
)
return pong != null
} catch (e: IllegalArgumentException) {
context.startActivity(ApkBrokenActivity::class.intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
return false
}
}
fun getCurrentClashProfileName(context: Context): String? {
try {
val authority = Uri.Builder()
.scheme("content")
.authority("${context.packageName}${Constants.STATUS_PROVIDER_SUFFIX}")
.build()
val pong = context.contentResolver.call(
authority,
ServiceStatusProvider.METHOD_PING_CLASH_SERVICE,
null,
null
)
return pong?.getString("name")
} catch (e: IllegalArgumentException) {
context.startActivity(ApkBrokenActivity::class.intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
return null
}
}
}
@@ -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,64 @@
package com.github.kr328.clash.remote
import android.app.Application
import android.content.ComponentName
import android.content.Context
import android.content.ServiceConnection
import android.os.IBinder
import com.github.kr328.clash.Tracker
import com.github.kr328.clash.common.log.Log
import com.github.kr328.clash.common.util.intent
import com.github.kr328.clash.log.SystemLogcat
import com.github.kr328.clash.service.RemoteService
import com.github.kr328.clash.service.remote.IRemoteService
import com.github.kr328.clash.service.remote.unwrap
import com.github.kr328.clash.util.unbindServiceSilent
import java.util.concurrent.TimeUnit
class Service(private val context: Application, val crashed: () -> Unit) {
val remote = Resource<IRemoteService>()
private val connection = object : ServiceConnection {
private var lastCrashed: Long = -1
override fun onServiceConnected(name: ComponentName?, service: IBinder) {
remote.set(service.unwrap(IRemoteService::class))
}
override fun onServiceDisconnected(name: ComponentName?) {
remote.set(null)
Tracker.uploadLogcat(SystemLogcat.dumpCrash())
if (System.currentTimeMillis() - lastCrashed < TOGGLE_CRASHED_INTERVAL) {
unbind()
crashed()
}
lastCrashed = System.currentTimeMillis()
Log.w("RemoteManager crashed")
}
}
fun bind() {
try {
context.bindService(RemoteService::class.intent, connection, Context.BIND_AUTO_CREATE)
} catch (e: Exception) {
unbind()
crashed()
}
}
fun unbind() {
context.unbindServiceSilent(connection)
remote.set(null)
}
companion object {
private val TOGGLE_CRASHED_INTERVAL = TimeUnit.SECONDS.toMillis(10)
}
}
@@ -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,56 +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
}
}
@@ -1,18 +1,18 @@
package com.github.kr328.clash.utils
package com.github.kr328.clash.util
import android.content.Context
import android.content.Intent
import android.net.VpnService
import com.github.kr328.clash.preference.UiSettings
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.Intents
import com.github.kr328.clash.service.TunService
import com.github.kr328.clash.service.util.intent
import com.github.kr328.clash.service.util.sendBroadcastSelf
import com.github.kr328.clash.service.util.startForegroundServiceCompat
fun Context.startClashService(): Intent? {
val startTun = UiSettings(this).get(UiSettings.ENABLE_VPN)
val startTun = UiStore(this).enableVpn
if (startTun) {
val vpnRequest = VpnService.prepare(this)
@@ -28,5 +28,5 @@ fun Context.startClashService(): Intent? {
}
fun Context.stopClashService() {
sendBroadcastSelf(Intent(Intents.INTENT_ACTION_REQUEST_STOP))
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,30 +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,70 +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().toCodePointList() to it
}
val groups = mutableListOf<List<Pair<List<Int>, T>>>()
var mergingGroup = mutableListOf<Pair<List<Int>, T>>()
var currentCodePoint = 0
val result = mutableListOf<Result<T>>()
for (pair in pairs) {
if (pair.first.isEmpty())
continue
if (pair.first[0] == currentCodePoint) {
mergingGroup.add(pair)
} else {
if (mergingGroup.isNotEmpty()) {
groups.add(mergingGroup)
mergingGroup = mutableListOf()
}
currentCodePoint = 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.size }.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 {
val prefix = it.first.subList(0, diffIndex)
val content = it.first.subList(diffIndex, it.first.size)
result.add(
Result(
prefix.asCodePointString().replace(REGEX_PREFIX_TRIM, ""),
content.asCodePointString(), it.second
)
)
}
}
result
}
}
@@ -1,105 +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 groups = proxyGroup.groupBy {
if (it.name == "GLOBAL")
"GLOBAL"
else
"OTHER"
}
val global = groups["GLOBAL"]?.singleOrNull()
val other = groups["OTHER"] ?: emptyList()
val sortedGroup = when (groupOrder) {
Order.DEFAULT -> groupSortWithDefault(global, other)
Order.DELAY_INCREASE -> groupSortWithDelay(true, other)
Order.DELAY_DECREASE -> groupSortWithDelay(false, other)
Order.NAME_INCREASE -> groupSortWithName(true, other)
Order.NAME_DECREASE -> groupSortWithName(false, other)
}
val sorted = if (global == null)
sortedGroup
else
listOf(global) + sortedGroup
sorted.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,60 +0,0 @@
package com.github.kr328.clash.utils
import android.content.Context
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.LinearSmoothScroller
import androidx.recyclerview.widget.RecyclerView
class QuickSmoothScroller(context: Context, target: Int) :
LinearSmoothScroller(context) {
companion object {
const val MAX_OFFSET = 2
}
var started = {}
var stopped = {}
init {
targetPosition = target
}
override fun getVerticalSnapPreference(): Int {
return SNAP_TO_START
}
override fun onStop() {
super.onStop()
stopped()
}
override fun onStart() {
super.onStart()
started()
}
override fun onSeekTargetStep(dx: Int, dy: Int, state: RecyclerView.State, action: Action) {
when (val lm = layoutManager) {
is LinearLayoutManager -> {
val current = lm.findFirstCompletelyVisibleItemPosition()
if (targetPosition > current && targetPosition - current > MAX_OFFSET)
action.jumpTo(targetPosition - MAX_OFFSET)
else if (current > targetPosition && current - targetPosition > MAX_OFFSET)
action.jumpTo(targetPosition + MAX_OFFSET)
}
is GridLayoutManager -> {
val current = lm.findFirstCompletelyVisibleItemPosition()
if (targetPosition > current && targetPosition - current > MAX_OFFSET)
action.jumpTo(targetPosition - MAX_OFFSET)
else if (current > targetPosition && current - targetPosition > MAX_OFFSET)
action.jumpTo(targetPosition + MAX_OFFSET)
}
}
super.onSeekTargetStep(dx, dy, state, action)
}
}
Loaded 100 of 657 files, more files were not shown because too many files have changed in this diff. Show more