Compare commits

..
39 Commits
Author SHA1 Message Date
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
641 changed files with 22698 additions and 12505 deletions

No files matched your search

+11 -5
View File
@@ -43,9 +43,15 @@ If applicable, add screenshots to help explain your problem.
**Additional context**
Add any other context about the problem here.
<!--
*Logs*
if applicable, upload logs to help detect problem
**Configure**
Paste configure file which **removed server info**
```yaml
# paste here
```
`Open App` -> `Support` -> `Feedback` -> `Upload Logcat`
-->
**Logs**
Paste logs to help detect problem
```
<paste here>
```
+11 -5
View File
@@ -41,9 +41,15 @@ assignees: ''
**附加信息**
其他的可能与改错误相关的信息
<!--
*日志*
如果适用, 上传日志以帮助侦测错误
**配置文件**
在此粘贴 **去除服务器信息的** 的 **配置文件**
```yaml
# 在此粘贴
```
**日志**
粘贴日志以帮助侦测错误
```
<在此粘贴>
```
`打开应用` -> `支持` -> `反馈` -> `上传日志`
-->
+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 -2
View File
@@ -1,6 +1,7 @@
.gradle
build/
/app/release/
/app/foss/release
/app/premium/release
/captures
# Ignore Gradle GUI config
@@ -16,8 +17,11 @@ 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
@@ -45,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)
+4 -5
View File
@@ -12,7 +12,8 @@ Fully feature of [clash](https://github.com/Dreamacro/clash) ~~(Exclude `externa
### Requirement
* Android 7.0+
* Android 5.0+ (minimum)
* Android 7.0+ (recommend)
* `armeabi-v7a` , `arm64-v8a`, `x86` or `x86_64` Architecture
### License
@@ -35,14 +36,12 @@ See also [PRIVACY_POLICY.md](./PRIVACY_POLICY.md)
git submodule update --init --recursive
```
2. Install `JDK 1.8`, `Android SDK` ,`Android NDK` and `Golang`
2. Install **OpenJDK 11**, **Android SDK**, **CMake** and **Golang**
3. Create `local.properties` in project root with
```properties
sdk.dir=/path/to/android-sdk
ndk.dir=/path/to/android-ndk
appcenter.key=<AppCenter Key> # Optional, from "appcenter.ms"
```
4. Create `keystore.properties` in project root with
@@ -57,7 +56,7 @@ See also [PRIVACY_POLICY.md](./PRIVACY_POLICY.md)
5. Build
```bash
./gradlew app:assembleRelease
./gradlew app:assembleFossRelease
```
6. Pick `app-release-<arch>.apk` in `app/build/outputs/apks`
-1
View File
@@ -1 +0,0 @@
/build
+96 -95
View File
@@ -1,55 +1,97 @@
import java.util.*
import java.security.*
plugins {
id("com.android.application")
id("kotlin-android")
id("kotlin-android-extensions")
kotlin("android")
kotlin("kapt")
}
val rootExtra = rootProject.extra
val gCompileSdkVersion: Int by rootExtra
val gBuildToolsVersion: String by rootExtra
val gMinSdkVersion: Int by rootExtra
val gTargetSdkVersion: Int by rootExtra
val gVersionCode: Int by rootExtra
val gVersionName: String by rootExtra
val gKotlinVersion: String by rootExtra
val gKotlinCoroutineVersion: String by rootExtra
val gAppCenterVersion: String by rootExtra
val gAndroidKtxVersion: String by rootExtra
val gRecyclerviewVersion: String by rootExtra
val gAppCompatVersion: String by rootExtra
val gMaterialDesignVersion: String by rootExtra
val gShizukuPreferenceVersion: String by rootExtra
val gMultiprocessPreferenceVersion: String by rootExtra
android {
compileSdkVersion(gCompileSdkVersion)
buildToolsVersion(gBuildToolsVersion)
compileSdk = buildTargetSdkVersion
flavorDimensions(buildFlavor)
defaultConfig {
applicationId = "com.github.kr328.clash"
minSdkVersion(gMinSdkVersion)
targetSdkVersion(gTargetSdkVersion)
minSdk = buildMinSdkVersion
targetSdk = buildTargetSdkVersion
versionCode = gVersionCode
versionName = gVersionName
versionCode = buildVersionCode
versionName = buildVersionName
resConfigs("zh-rCN", "zh-rHK", "zh-rTW")
resValue("string", "release_name", "v$buildVersionName")
resValue("integer", "release_code", "$buildVersionCode")
}
buildTypes {
maybeCreate("release").apply {
named("release") {
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
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
@@ -65,75 +107,34 @@ android {
isUniversalApk = true
}
}
val signingFile = rootProject.file("keystore.properties")
if ( signingFile.exists() ) {
val properties = Properties().apply {
signingFile.inputStream().use {
load(it)
}
}
signingConfigs {
maybeCreate("release").apply {
storeFile = rootProject.file(Objects.requireNonNull(properties.getProperty("storeFile")))
storePassword = Objects.requireNonNull(properties.getProperty("storePassword"))
keyAlias = Objects.requireNonNull(properties.getProperty("keyAlias"))
keyPassword = Objects.requireNonNull(properties.getProperty("keyPassword"))
}
}
buildTypes {
maybeCreate("release").apply {
this.signingConfig = signingConfigs.findByName("release")
}
}
}
}
dependencies {
implementation(project(":core"))
implementation(project(":service"))
implementation(project(":design"))
implementation(project(":common"))
implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8:$gKotlinVersion")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:$gKotlinCoroutineVersion")
implementation("androidx.recyclerview:recyclerview:$gRecyclerviewVersion")
implementation("androidx.core:core-ktx:$gAndroidKtxVersion")
implementation("androidx.appcompat:appcompat:$gAppCompatVersion")
implementation("com.google.android.material:material:$gMaterialDesignVersion")
implementation("moe.shizuku.preference:preference-appcompat:$gShizukuPreferenceVersion")
implementation("moe.shizuku.preference:preference-simplemenu-appcompat:$gShizukuPreferenceVersion")
implementation("com.microsoft.appcenter:appcenter-analytics:$gAppCenterVersion")
implementation("com.microsoft.appcenter:appcenter-crashes:$gAppCenterVersion")
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("injectAppCenterKey") {
doFirst {
val properties = Properties().apply {
rootProject.file("local.properties").inputStream().use {
load(it)
}
}
val key = properties.getProperty("appcenter.key", "")
android.buildTypes.forEach {
it.buildConfigField("String", "APP_CENTER_KEY", "\"$key\"")
}
}
}
task("injectPackageNameBase64") {
doFirst {
val packageName = android.defaultConfig.applicationId ?: return@doFirst
val base64 = Base64.getEncoder().encodeToString(packageName.toByteArray(Charsets.UTF_8))
android.buildTypes.forEach {
it.buildConfigField("String", "PACKAGE_NAME_BASE64", "\"$base64\"")
}
}
task("cleanRelease", type = Delete::class) {
delete(file("release"))
}
afterEvaluate {
tasks["preBuild"].dependsOn(tasks["injectAppCenterKey"], tasks["injectPackageNameBase64"])
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
}
}
+109 -43
View File
@@ -3,111 +3,177 @@
xmlns:tools="http://schemas.android.com/tools"
package="com.github.kr328.clash">
<uses-feature
android:name="android.software.leanback"
android:required="false" />
<uses-feature
android:name="android.hardware.touchscreen"
android:required="false" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission
android:name="android.permission.QUERY_ALL_PACKAGES"
tools:ignore="QueryAllPackagesPermission" />
<application
android:name=".MainApplication"
android:allowBackup="true"
android:banner="@mipmap/ic_banner"
android:fullBackupContent="@xml/full_backup_content"
android:icon="@mipmap/ic_launcher"
android:label="@string/application_name"
android:networkSecurityConfig="@xml/network_security_config"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme"
tools:ignore="GoogleAppIndexingWarning">
android:theme="@style/BootstrapTheme"
tools:ignore="GoogleAppIndexingWarning"
tools:targetApi="N">
<meta-data
android:name="releaseName"
android:value="@string/release_name" />
<meta-data
android:name="releaseCode"
android:value="@integer/release_code" />
<activity
android:name=".MainActivity"
android:label="@string/launch_name"
android:configChanges="uiMode"
android:launchMode="singleTop">
android:exported="true"
android:label="@string/launch_name">
<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=".RestartReceiver"
android:enabled="false"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
</intent-filter>
</receiver>
</application>
</manifest>
@@ -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,67 +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
}
}
@@ -0,0 +1,35 @@
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()
}
Tracker.uploadLogcat(logs)
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,155 +1,165 @@
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.common.utils.createLanguageConfigurationContext
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.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()
title = resolveActivityTitle()
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())
if (queryDayNight(newConfig) != dayNight) {
ApplicationObserver.createdActivities.forEach {
it.recreate()
}
}
}
@@ -163,59 +173,77 @@ abstract class BaseActivity : AppCompatActivity(), CoroutineScope by MainScope()
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 showSnackbarException(title: String, detail: String?) {
Snackbar.make(rootView, title, Snackbar.LENGTH_LONG).setAction(R.string.detail) {
AlertDialog.Builder(this).setTitle(R.string.detail).setMessage(detail ?: "Unknown")
.show()
}.show()
override fun 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
}
private fun resolveActivityTitle(): CharSequence {
val info = packageManager.getActivityInfo(componentName, 0)
if (info.labelRes <= 0)
return title
return resources.getText(info.labelRes)
}
}
}
@@ -1,11 +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_INTENT_ACTION = "com.github.kr328.clash.action.PROVIDE_URL"
const val URL_PROVIDER_INTENT_EXTRA_NAME = "name"
}
@@ -1,162 +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.common.utils.intent
import com.github.kr328.clash.remote.withProfile
import com.github.kr328.clash.service.model.Profile.Type
import kotlinx.android.synthetic.main.activity_create_profile.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class CreateProfileActivity : BaseActivity() {
companion object {
const val REQUEST_CODE = 20000
}
private val self = this
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_create_profile)
setSupportActionBar(toolbar)
launch {
val providers = queryUrlProviders()
mainList.adapter = Adapter(this@CreateProfileActivity, providers)
mainList.divider = null
mainList.dividerHeight = 0
mainList.setOnItemClickListener { _, _, position, _ ->
val item = providers[position]
self.launch {
val id = withProfile {
acquireUnused(item.type, item.intent?.toUri(0))
}
startActivityForResult(
ProfileEditActivity::class.intent.setData(
Uri.fromParts(
"id",
id.toString(),
null
)
),
REQUEST_CODE
)
}
}
mainList.setOnItemLongClickListener { _, _, position, _ ->
val item = providers[position]
val packageName = item.intent?.component?.packageName
if (packageName != null) {
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
.setData(Uri.fromParts("package", packageName, null))
startActivity(intent)
true
} else {
false
}
}
}
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
if (requestCode == REQUEST_CODE && resultCode == Activity.RESULT_OK)
return finish()
super.onActivityResult(requestCode, resultCode, data)
}
private suspend fun queryUrlProviders(): List<UrlProvider> =
withContext(Dispatchers.IO) {
val common = listOf(
UrlProvider(
getText(R.string.file),
getText(R.string.import_from_file),
getDrawable(R.drawable.ic_file)!!,
Type.FILE,
null
),
UrlProvider(
getText(R.string.url),
getText(R.string.import_from_url),
getDrawable(R.drawable.ic_download)!!,
Type.URL,
null
)
)
val providers = packageManager.queryIntentActivities(
Intent(Constants.URL_PROVIDER_INTENT_ACTION),
0
).map {
val activity = it.activityInfo
val name = activity.applicationInfo.loadLabel(packageManager)
val summary = activity.loadLabel(packageManager)
val icon = activity.loadIcon(packageManager)
val type = Type.EXTERNAL
val intent = Intent(Constants.URL_PROVIDER_INTENT_ACTION)
.setComponent(
ComponentName.createRelative(
activity.packageName,
activity.name
)
)
UrlProvider(name, summary, icon, type, intent)
}
common + providers
}
private data class UrlProvider(
val name: CharSequence,
val summary: CharSequence,
val icon: Drawable,
val type: Type,
val intent: Intent?
)
private class Adapter(private val context: Context, private val providers: List<UrlProvider>) :
BaseAdapter() {
override fun getView(position: Int, convertView: View?, parent: ViewGroup?): View {
val provider = providers[position]
val view = convertView ?: LayoutInflater.from(context).inflate(
R.layout.adapter_url_provider,
parent,
false
)
view.findViewById<TextView>(android.R.id.title).text = provider.name
view.findViewById<TextView>(android.R.id.summary).text = provider.summary
view.findViewById<View>(android.R.id.icon).background = provider.icon
return view
}
override fun getItem(position: Int): Any = providers[position]
override fun getItemId(position: Int): Long = providers[position].hashCode().toLong()
override fun getCount(): Int = providers.size
}
}
@@ -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,134 +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.common.utils.intent
import com.github.kr328.clash.core.event.LogEvent
import kotlinx.android.synthetic.main.activity_log_viewer.*
import kotlinx.coroutines.*
import kotlinx.coroutines.sync.Mutex
import java.io.File
class LogViewerActivity : BaseActivity() {
private val pauseMutex = Mutex()
private 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 onStop() {
super.onStop()
launch {
pauseMutex.lock()
}
}
override fun onStart() {
super.onStart()
launch {
if (pauseMutex.isLocked)
pauseMutex.unlock()
}
}
private fun startLiveMode() {
mainList.layoutManager = LinearLayoutManager(this)
mainList.adapter = LiveLogAdapter(this)
mainList.itemAnimator?.addDuration = 100
mainList.itemAnimator?.removeDuration = 100
stop.setOnClickListener {
unbindService(connection)
stopService(LogcatService::class.intent)
finish()
}
bindService(LogcatService::class.intent, connection, Context.BIND_AUTO_CREATE)
}
private fun startFileMode(file: File) {
stop.visibility = View.GONE
launch {
val items = withContext(Dispatchers.IO) {
try {
file.bufferedReader().useLines { lines ->
lines
.map { it.trim() }
.filter { it.isNotEmpty() && !it.startsWith("#") }
.map { it.split(" ", limit = 3) }
.filter { it.size == 3 }
.map { LogEvent(LogEvent.Level.valueOf(it[1]), it[2], it[0].toLong()) }
.toList()
}
} catch (e: Exception) {
showSnackbarException(getString(R.string.open_log_failure), e.message)
throw CancellationException()
}
}
mainList.layoutManager = LinearLayoutManager(this@LogViewerActivity)
mainList.adapter = LogAdapter(this@LogViewerActivity, items)
mainList.adapter!!.notifyItemRangeInserted(0, items.size)
}
}
private fun startLogcatPoll(service: LogcatService) {
launch {
var offset = 0L
while (isActive) {
pauseMutex.lock()
val response = service.pollLogEvent(offset).await()
(mainList.adapter as LiveLogAdapter).insertItems(response.logs)
mainList.apply {
if (computeVerticalScrollOffset() < 30)
scrollToPosition(0)
}
offset = response.offset + response.logs.size
pauseMutex.unlock()
delay(200)
}
}
}
}
@@ -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,217 +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.common.utils.createLanguageConfigurationContext
import com.github.kr328.clash.common.utils.intent
import com.github.kr328.clash.common.utils.Log
import com.github.kr328.clash.core.event.LogEvent
import com.github.kr328.clash.model.LogFile
import com.github.kr328.clash.preference.UiSettings
import com.github.kr328.clash.service.ClashManagerService
import com.github.kr328.clash.service.IClashManager
import com.github.kr328.clash.service.transact.IStreamCallback
import com.github.kr328.clash.service.transact.ParcelableContainer
import com.github.kr328.clash.utils.format
import com.github.kr328.clash.utils.logsDir
import 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.ClashManager
import com.github.kr328.clash.service.remote.IClashManager
import com.github.kr328.clash.service.remote.ILogObserver
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(ClashManager::class.intent, connection, Context.BIND_AUTO_CREATE)
}
override fun onDestroy() {
logChannel.close()
cancel()
connection.onServiceDisconnected(null)
unbindService(connection)
stopForeground(true)
isServiceRunning = false
running = 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(IClashManager::class)
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()
}
}
}
@@ -235,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
)
@@ -244,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,294 +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.common.utils.intent
import com.github.kr328.clash.common.utils.startForegroundServiceCompat
import com.github.kr328.clash.core.event.LogEvent
import com.github.kr328.clash.design.common.Category
import com.github.kr328.clash.design.view.CommonUiLayout
import com.github.kr328.clash.model.LogFile
import com.github.kr328.clash.utils.format
import com.github.kr328.clash.utils.logsDir
import com.google.android.material.bottomsheet.BottomSheetDialog
import com.google.android.material.snackbar.Snackbar
import kotlinx.android.synthetic.main.activity_logs.*
import 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.text.SimpleDateFormat
import java.util.*
class LogsActivity : BaseActivity() {
companion object {
const val REQUEST_CODE = 50000
private val LOG_EXPORT_DATE_FORMAT = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.ENGLISH)
private val LOG_EXPORT_TIME_FORMAT = SimpleDateFormat("HH:mm:ss", Locale.ENGLISH)
}
private var lastWriteFile: LogFile? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_logs)
setSupportActionBar(toolbar)
if (LogcatService.isServiceRunning) {
startActivity(LogViewerActivity::class.intent)
finish()
return
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 fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
if (requestCode == REQUEST_CODE) {
if (resultCode == Activity.RESULT_OK) {
val url = data?.data ?: return
val file = lastWriteFile ?: return
lastWriteFile = null
launch {
withContext(Dispatchers.IO) {
contentResolver.openOutputStream(url)?.bufferedWriter()?.use { output ->
output.write("# Logcat on " + LOG_EXPORT_DATE_FORMAT.format(Date(file.date)) + "\n")
logsDir.resolve(file.fileName).bufferedReader().useLines { lines ->
lines.map { it.trim() }
.filter { it.isNotEmpty() && !it.startsWith("#") }
.map { it.split(" ", limit = 3) }
.filter { it.size == 3 }
.map {
LogEvent(
LogEvent.Level.valueOf(it[1]),
it[2],
it[0].toLong()
)
}
.forEach {
output.write(
String.format(
"%s |%s| %s\n",
LOG_EXPORT_TIME_FORMAT.format(Date(it.time)),
it.level.toString(),
it.message
)
)
}
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,202 +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.common.utils.intent
import com.github.kr328.clash.common.utils.asBytesString
import com.github.kr328.clash.core.model.General
import com.github.kr328.clash.remote.withClash
import com.github.kr328.clash.remote.withProfile
import com.github.kr328.clash.service.util.startClashService
import com.github.kr328.clash.service.util.stopClashService
import kotlinx.android.synthetic.main.activity_main.*
import kotlinx.coroutines.*
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)
launch(Dispatchers.IO) {
showUpdatedTips(design)
}
setContentView(R.layout.activity_main)
design.fetch()
status.setOnClickListener {
if (clashRunning) {
stopClashService()
} else {
val vpnRequest = startClashService()
if (vpnRequest != null) {
val resolved = packageManager.resolveActivity(vpnRequest, 0)
if (resolved != null) {
startActivityForResult(vpnRequest, REQUEST_CODE)
} else {
showSnackbarException(getString(R.string.missing_vpn_component), null)
val ticker = ticker(TimeUnit.SECONDS.toMillis(1))
while (isActive) {
select<Unit> {
events.onReceive {
when (it) {
Event.ActivityStart,
Event.ServiceRecreated,
Event.ClashStop, Event.ClashStart,
Event.ProfileLoaded, Event.ProfileChanged -> design.fetch()
else -> Unit
}
}
}
}
proxies.setOnClickListener {
startActivity(ProxiesActivity::class.intent)
}
profiles.setOnClickListener {
startActivity(ProfilesActivity::class.intent)
}
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 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)
showSnackbarException(getString(R.string.clash_start_failure), reason)
}
override suspend fun onClashProfileLoaded() {
updateClashStatus()
}
private fun startBandwidthPolling() {
if (bandwidthJob != null)
return
bandwidthJob = launch {
withClash {
try {
while (clashRunning && isActive) {
val bandwidth = queryBandwidth()
status.summary = getString(
R.string.format_traffic_forwarded,
bandwidth.asBytesString()
)
delay(1000)
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 {
queryActive()
}
val modeResId = when (general.mode) {
General.Mode.DIRECT -> R.string.direct_mode
General.Mode.GLOBAL -> R.string.global_mode
General.Mode.RULE -> R.string.rule_mode
}
val profileString =
if (active == null)
getText(R.string.not_selected)
else
getString(R.string.format_profile_activated, active.name)
proxies.summary = getText(modeResId)
profiles.summary = profileString
}
}
private fun showAboutDialog() {
launch {
val content = withContext(Dispatchers.Default) {
val packageInfo = packageManager.getPackageInfo(packageName, 0)
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,19 +2,11 @@ package com.github.kr328.clash
import android.app.Application
import android.content.Context
import android.content.Intent
import android.net.Uri
import com.github.kr328.clash.common.Global
import com.github.kr328.clash.common.utils.componentName
import com.github.kr328.clash.dump.LogcatDumper
import com.github.kr328.clash.remote.Broadcasts
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() {
@@ -28,44 +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.dumpCrash()
return mutableListOf(
ErrorAttachmentLog.attachmentWithText(logcat, "logcat.txt")
)
}
})
if (processName == packageName) {
Remote.launch()
} else {
sendServiceRecreated()
}
}
Global.openMainIntent = {
Intent(Intent.ACTION_MAIN).apply {
component = MainActivity::class.componentName
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
}
}
Global.openProfileIntent = {
Intent(Intent.ACTION_MAIN).apply {
component = ProfileEditActivity::class.componentName
data = Uri.fromParts("id", it.toString(), null)
flags = Intent.FLAG_ACTIVITY_NEW_TASK
}
}
Remote.init(this)
Broadcasts.init(this)
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
}
}
}
@@ -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,167 +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() {
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,135 +0,0 @@
package com.github.kr328.clash
import android.app.Activity
import android.os.Bundle
import android.view.View
import androidx.appcompat.app.AlertDialog
import com.github.kr328.clash.fragment.ProfileEditFragment
import com.github.kr328.clash.remote.withProfile
import com.github.kr328.clash.service.model.Profile
import com.google.android.material.snackbar.Snackbar
import kotlinx.android.synthetic.main.activity_profile_edit.*
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
class ProfileEditActivity : BaseActivity() {
private var editor: ProfileEditFragment? = null
private var processing = false
set(value) {
field = value
if (value) {
saving.visibility = View.VISIBLE
save.visibility = View.INVISIBLE
} else {
saving.visibility = View.INVISIBLE
save.visibility = View.VISIBLE
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_profile_edit)
setSupportActionBar(toolbar)
toolbar.setTitle(R.string.loading)
launch {
val id = intent.data?.schemeSpecificPart?.toLongOrNull() ?: return@launch finish()
val metadata = withProfile {
queryById(id)
} ?: return@launch finish()
when {
metadata.lastModified > 0 ->
toolbar.setTitle(R.string.edit_profile)
metadata.name.isBlank() ->
toolbar.setTitle(R.string.new_profile)
else ->
toolbar.setTitle(R.string.clone_profile)
}
val fragment = ProfileEditFragment(
metadata.id,
metadata.name, metadata.uri, metadata.interval,
metadata.type, metadata.source
)
editor = fragment
supportFragmentManager.beginTransaction()
.replace(R.id.fragment, fragment)
.commit()
save.setOnClickListener {
val name = fragment.name
val uri = fragment.uri
val interval = fragment.interval
if (name.isBlank()) {
Snackbar.make(rootView, R.string.empty_name, Snackbar.LENGTH_LONG).show()
return@setOnClickListener
}
val newMetadata = metadata.copy(
name = name,
uri = uri,
interval = interval
)
processing = true
commit(newMetadata)
}
}
}
override fun onBackPressed() {
if (processing) {
Snackbar.make(rootView, R.string.processing, Snackbar.LENGTH_LONG).show()
return
}
if ( editor?.isModified != true)
return finish()
AlertDialog.Builder(this)
.setTitle(R.string.exit_without_save)
.setMessage(R.string.exit_without_save_warning)
.setNegativeButton(R.string.cancel) { _, _ -> }
.setPositiveButton(R.string.ok) { _, _ -> finish() }
.show()
}
override fun onDestroy() {
runBlocking {
withProfile {
val id = editor?.id ?: return@withProfile
release(id)
}
}
super.onDestroy()
}
private fun commit(metadata: Profile) {
launch {
try {
withProfile {
update(metadata.id, metadata)
commitAsync(metadata.id).await()
}
setResult(Activity.RESULT_OK)
finish()
} catch (e: Exception) {
showSnackbarException(getString(R.string.download_failure), e.message)
}
processing = false
}
}
}
@@ -1,180 +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.common.utils.intent
import com.github.kr328.clash.remote.withProfile
import com.github.kr328.clash.service.ProfileReceiver
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.service.util.sendBroadcastSelf
import com.github.kr328.clash.weight.ProfilesMenu
import com.google.android.material.snackbar.Snackbar
import kotlinx.android.synthetic.main.activity_profiles.*
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import com.github.kr328.clash.util.withProfile
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import java.io.FileNotFoundException
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<Profile>()
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 fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
if (requestCode == EDITOR_REQUEST_CODE) {
launch {
val profile = editorStack.pop()
withProfile {
update(profile.id, profile)
startUpdate(profile.id)
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 {
queryAll()
}
(mainList.adapter as ProfileAdapter).setEntitiesAsync(profiles.toList())
reloadMutex.unlock()
}
override fun onProfileClicked(entity: Profile) {
launch {
withProfile {
setActive(entity.id)
}
}
}
override fun onMenuClicked(entity: Profile) {
ProfilesMenu(this, entity, this).show()
}
override fun onNewProfile() {
startActivity(CreateProfileActivity::class.intent)
}
private fun openProperties(id: Long) {
startActivity(
ProfileEditActivity::class.intent
.setData(Uri.fromParts("id", id.toString(), null))
)
}
private fun openEditor(profile: Profile) = launch {
try {
val uri = withProfile {
acquireTempUri(profile.id)
} ?: throw FileNotFoundException()
editorStack.push(profile.copy(uri = Uri.parse(uri)))
startActivityForResult(
Intent(Intent.ACTION_VIEW)
.setDataAndType(Uri.parse(uri), "text/plain")
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION),
EDITOR_REQUEST_CODE
)
} catch (e: Exception) {
Snackbar.make(rootView, getText(R.string.profile_not_found), Snackbar.LENGTH_LONG)
.show()
}
}
private fun startUpdate(id: Long) {
sendBroadcastSelf(ProfileReceiver.buildUpdateIntentForId(id))
}
override fun onOpenEditor(entity: Profile) {
openEditor(entity)
}
override fun onUpdate(entity: Profile) {
startUpdate(entity.id)
}
override fun onOpenProperties(entity: Profile) {
openProperties(entity.id)
}
override fun onDuplicate(entity: Profile) {
launch {
withProfile {
openProperties(acquireCloned(entity.id))
}
}
}
override fun onResetProvider(entity: Profile) {
launch {
withProfile {
clear(entity.id)
}
}
}
override fun onDelete(entity: Profile) {
launch {
withProfile {
delete(entity.id)
}
private suspend fun ProfilesDesign.fetch() {
withProfile {
patchProfiles(queryAll())
}
}
}
@@ -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,295 +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 suspend fun onClashStopped(reason: String?) {
finish()
}
private fun setupMenu() {
launch {
val general = withClash {
queryGeneral()
}
menu?.apply {
when (general.mode) {
General.Mode.DIRECT ->
findItem(R.id.modeDirect).isChecked = true
General.Mode.GLOBAL ->
findItem(R.id.modeGlobal).isChecked = true
General.Mode.RULE ->
findItem(R.id.modeRule).isChecked = true
}
when (uiSettings.get(UiSettings.PROXY_GROUP_SORT)) {
UiSettings.PROXY_SORT_DEFAULT ->
findItem(R.id.groupDefault).isChecked = true
UiSettings.PROXY_SORT_NAME ->
findItem(R.id.groupName).isChecked = true
UiSettings.PROXY_SORT_DELAY ->
findItem(R.id.proxyDelay).isChecked = true
}
when (uiSettings.get(UiSettings.PROXY_PROXY_SORT)) {
UiSettings.PROXY_SORT_DEFAULT ->
findItem(R.id.proxyDefault).isChecked = true
UiSettings.PROXY_SORT_NAME ->
findItem(R.id.proxyName).isChecked = true
UiSettings.PROXY_SORT_DELAY ->
findItem(R.id.proxyDelay).isChecked = true
}
findItem(R.id.utilsMergePrefix).isChecked =
uiSettings.get(UiSettings.PROXY_MERGE_PREFIX)
}
}
}
private fun setGroupSelected(group: String, select: String) {
launch {
withClash {
setSelectProxy(group, select)
}
}
}
private fun startUrlTesting(group: String) {
launch {
urlTesting.add(group)
withClash {
startHealthCheck(group)
}
urlTesting.remove(group)
refreshList()
}
}
private fun chipClicked(name: String) {
launch {
scrollBinding.scrollMaster(name)
}
}
private fun refreshList(scrollTop: Boolean = false) {
launch {
if (!refreshMutex.tryLock())
return@launch
val general = withClash {
queryGeneral()
}
val proxies = withClash {
queryAllProxyGroups()
}
val merged = Pipeline(proxies, uiSettings).mergePrefix()
val sorted = Pipeline(proxies, uiSettings).sort()
val newList = sorted.toAdapterElement(merged.input, general)
mainListAdapter.applyChange(newList, urlTesting)
(chipList.adapter!! as ProxyChipAdapter).apply {
chips = newList.map { it.name }
notifyDataSetChanged()
}
if (scrollTop)
mainList.smoothScrollToPosition(0)
else if (scrollToLast) {
scrollToLast = false
val selected = uiSettings.get(UiSettings.PROXY_LAST_SELECT_GROUP)
scrollBinding.scrollMaster(selected)
}
delay(500)
refreshMutex.unlock()
}
}
override fun getCurrentMasterToken(): String {
return mainListAdapter.getCurrentGroup()
}
override fun onMasterTokenChanged(token: String) {
chipListAdapter.selected = token
val position = chipListAdapter.chips.indexOf(token)
if (position < 0)
return
chipList.smoothScrollToPosition(position)
}
override fun getMasterTokenPosition(token: String): Int {
return mainListAdapter.getGroupPosition(token)
}
override fun doMasterScroll(scroller: LinearSmoothScroller, target: Int) {
mainListAdapter.layoutManager.startSmoothScroll(scroller)
}
}
@@ -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,44 +1,30 @@
package com.github.kr328.clash
import android.os.Bundle
import com.github.kr328.clash.common.utils.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)
}
}
}
}
@@ -1,17 +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()
}
}
@@ -1,17 +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()
}
}
@@ -1,29 +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 suspend fun onClashStopped(reason: String?) {
recreate()
}
override suspend fun onClashStarted() {
recreate()
}
}
@@ -1,119 +0,0 @@
package com.github.kr328.clash
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.text.Html
import androidx.appcompat.app.AlertDialog
import com.github.kr328.clash.dump.LogcatDumper
import com.google.android.material.snackbar.Snackbar
import com.microsoft.appcenter.crashes.Crashes
import com.microsoft.appcenter.crashes.ingestion.models.ErrorAttachmentLog
import kotlinx.android.synthetic.main.activity_support.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class SupportActivity : BaseActivity() {
class UserRequestTrackException: Exception()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_support)
setSupportActionBar(toolbar)
commonUi.build {
tips {
icon = getDrawable(R.drawable.ic_info)
title = Html.fromHtml(getString(R.string.tips_support), Html.FROM_HTML_MODE_LEGACY)
}
category(text = getString(R.string.sources))
option(
title = getString(R.string.clash),
summary = getString(R.string.clash_url)
) {
onClick {
startActivity(
Intent(Intent.ACTION_VIEW)
.setData(Uri.parse(getString(R.string.clash_url)))
)
}
}
option(
title = getString(R.string.clash_for_android),
summary = getString(R.string.clash_for_android_url)
) {
onClick {
startActivity(
Intent(Intent.ACTION_VIEW)
.setData(Uri.parse(getString(R.string.clash_for_android_url)))
)
}
}
category(text = getString(R.string.feedback))
option(
title = getString(R.string.upload_logcat),
summary = getString(R.string.upload_logcat_summary)
) {
onClick {
AlertDialog.Builder(this@SupportActivity)
.setTitle(R.string.upload_logcat)
.setMessage(R.string.upload_logcat_warn)
.setNegativeButton(R.string.cancel) {_, _ -> }
.setPositiveButton(R.string.ok) {_, _ -> upload() }
.show()
}
}
option(
title = getString(R.string.github_issues),
summary = getString(R.string.github_issues_url)
) {
onClick {
startActivity(
Intent(Intent.ACTION_VIEW)
.setData(Uri.parse(getString(R.string.github_issues_url)))
)
}
}
val firstLanguage = resources.configuration.locales.get(0).language
if (firstLanguage.equals("zh", true)) {
category(getString(R.string.donate))
option(
title = getString(R.string.telegram_channel),
summary = getString(R.string.telegram_channel_url)
) {
onClick {
startActivity(
Intent(Intent.ACTION_VIEW)
.setData(Uri.parse(getString(R.string.telegram_channel_url)))
)
}
}
}
}
}
private fun upload() {
launch {
withContext(Dispatchers.IO) {
val attachment = ErrorAttachmentLog
.attachmentWithText(LogcatDumper.dumpAll(), "logcat.txt")
Crashes.trackError(UserRequestTrackException(), null, listOf(attachment))
}
withContext(Dispatchers.Main) {
Snackbar.make(rootView, R.string.uploaded, Snackbar.LENGTH_LONG).show()
}
}
}
}
@@ -5,20 +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.common.Permissions
import com.github.kr328.clash.common.ids.Intents
import com.github.kr328.clash.remote.RemoteUtils
import com.github.kr328.clash.service.util.startClashService
import com.github.kr328.clash.service.util.stopClashService
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 -> {
@@ -31,74 +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)
},
Permissions.PERMISSION_RECEIVE_BROADCASTS,
null
)
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.model.Profile
import com.github.kr328.clash.utils.IntervalUtils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class ProfileAdapter(private val context: Context, private val callback: Callback) :
RecyclerView.Adapter<RecyclerView.ViewHolder>() {
interface Callback {
fun onProfileClicked(entity: Profile)
fun onMenuClicked(entity: Profile)
fun onNewProfile()
}
private var entities: List<Profile> = emptyList()
class EntityHolder(view: View) : RecyclerView.ViewHolder(view) {
val root: View = view.findViewById(R.id.root)
val menu: View = view.findViewById(R.id.menu)
val radio: RadioButton = view.findViewById(R.id.radio)
val name: TextView = view.findViewById(R.id.name)
val type: TextView = view.findViewById(R.id.type)
val interval: TextView = view.findViewById(R.id.interval)
}
class FooterHolder(view: View) : RecyclerView.ViewHolder(view) {
val root: View = view.findViewById(R.id.root)
}
suspend fun setEntitiesAsync(new: List<Profile>) {
val old = withContext(Dispatchers.Main) {
entities
}
val result = withContext(Dispatchers.Default) {
DiffUtil.calculateDiff(object : DiffUtil.Callback() {
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean =
old[oldItemPosition].id == new[newItemPosition].id
override fun areContentsTheSame(
oldItemPosition: Int,
newItemPosition: Int
): Boolean = old[oldItemPosition] == new[newItemPosition]
override fun getOldListSize(): Int = old.size
override fun getNewListSize(): Int = new.size
}, false)
}
withContext(Dispatchers.Main) {
entities = new
result.dispatchUpdatesTo(this@ProfileAdapter)
}
}
override fun getItemViewType(position: Int): Int {
return if (position == entities.size)
Int.MAX_VALUE
else
super.getItemViewType(position)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
if (viewType == Int.MAX_VALUE) {
return FooterHolder(
LayoutInflater.from(context).inflate(
R.layout.adapter_profile_footer,
parent,
false
)
)
}
return EntityHolder(
LayoutInflater.from(context).inflate(
R.layout.adapter_profile_entity,
parent,
false
)
)
}
override fun getItemCount(): Int {
return entities.size + 1
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
when (holder) {
is EntityHolder -> {
val current = entities[position]
holder.radio.isChecked = current.active
holder.name.text = current.name
holder.type.text = getTypeName(current.type)
holder.interval.text = offsetDate(current.lastModified)
holder.root.setOnClickListener {
callback.onProfileClicked(current)
}
holder.menu.setOnClickListener {
callback.onMenuClicked(current)
}
}
is FooterHolder -> {
holder.root.setOnClickListener {
callback.onNewProfile()
}
}
}
}
private fun getTypeName(type: Profile.Type): CharSequence {
return when (type) {
Profile.Type.FILE ->
context.getText(R.string.file)
Profile.Type.URL ->
context.getText(R.string.url)
Profile.Type.EXTERNAL ->
context.getText(R.string.external)
else ->
context.getText(R.string.unknown)
}
}
private fun offsetDate(date: Long): CharSequence {
return IntervalUtils.intervalString(context, System.currentTimeMillis() - date)
}
}
@@ -1,282 +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,37 +0,0 @@
package com.github.kr328.clash.dump
object LogcatDumper {
fun dumpCrash(): String {
return try {
val process =
Runtime.getRuntime().exec(arrayOf("logcat", "-d", "-s", "Go", "AndroidRuntime", "DEBUG"))
val result = process.inputStream.use {
it.reader().readText()
}
process.waitFor()
result
} catch (e: Exception) {
""
}
}
fun dumpAll(): String {
return try {
val process =
Runtime.getRuntime().exec(arrayOf("logcat", "-d"))
val result = process.inputStream.use {
it.reader().readText()
}
process.waitFor()
result
} catch (e: Exception) {
""
}
}
}
@@ -1,215 +0,0 @@
package com.github.kr328.clash.fragment
import android.app.Activity
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.text.Html
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.ViewGroup.LayoutParams
import android.webkit.MimeTypeMap
import android.webkit.URLUtil
import androidx.fragment.app.Fragment
import com.github.kr328.clash.Constants
import com.github.kr328.clash.R
import com.github.kr328.clash.design.common.TextInput
import com.github.kr328.clash.design.view.CommonUiLayout
import com.github.kr328.clash.service.model.Profile.Type
import com.google.android.material.snackbar.Snackbar
class ProfileEditFragment(
val id: Long,
var name: String,
var uri: Uri,
var interval: Long,
private val type: Type,
private val source: String?
) : Fragment() {
private var root: CommonUiLayout? = null
var isModified = false
companion object {
private const val REQUEST_CODE = 10000
private const val KEY_NAME = "name"
private const val KEY_URL = "url"
private const val KEY_AUTO_UPDATE = "auto_update"
private val TYPE_YAML = MimeTypeMap.getSingleton()
.getMimeTypeFromExtension("yaml") ?: "*/*"
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
return CommonUiLayout(requireContext()).apply {
root = this
layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
build {
tips(icon = requireContext().getDrawable(R.drawable.ic_info)) {
title =
Html.fromHtml(getString(R.string.tips_profile), Html.FROM_HTML_MODE_LEGACY)
}
textInput(
title = getString(R.string.name),
icon = requireContext().getDrawable(R.drawable.ic_label_outline),
hint = getString(R.string.profile_name),
id = KEY_NAME,
content = name
) {
onTextChanged {
name = content.toString()
isModified = true
}
}
textInput(
title = getString(R.string.url),
icon = requireContext().getDrawable(R.drawable.ic_content),
hint = getString(R.string.profile_url),
id = KEY_URL,
content = uri.toString()
) {
onOpenInput {
if (!openUrlProvider())
openDialogInput()
}
onDisplayContent {
it.split("/").last()
}
onTextChanged {
if (!URLUtil.isValidUrl(content.toString())) {
content = ""
Snackbar.make(view, R.string.invalid_url, Snackbar.LENGTH_LONG).show()
return@onTextChanged
}
uri = Uri.parse(content.toString())
isModified = true
}
}
textInput(
title = getString(R.string.auto_update),
icon = requireContext().getDrawable(R.drawable.ic_update),
hint = getString(R.string.more_than_15_minutes),
id = KEY_AUTO_UPDATE,
content = (interval / 1000 / 60).toStringIfNonZero()
) {
onDisplayContent {
val interval = it.toString().toIntOrNull() ?: 0
if (interval <= 0)
getString(R.string.disabled)
else
getString(R.string.format_minutes, interval)
}
onTextChanged {
val s = it.toString()
if (s.isBlank()) {
content = ""
interval = 0
return@onTextChanged
}
val value = s.toIntOrNull()
if (value == null || value < 15) {
content = ""
interval = 0
Snackbar.make(view, R.string.invalid_interval, Snackbar.LENGTH_LONG)
.show()
return@onTextChanged
}
interval = content.toString().toLong() * 1000 * 60
isModified = true
}
if ( type == Type.FILE )
isHidden = true
}
screen.restoreState(savedInstanceState)
if (type == Type.EXTERNAL)
openUrlProvider()
}
}
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
if (requestCode == REQUEST_CODE) {
if (resultCode != Activity.RESULT_OK || data == null)
return
root?.apply {
data.data?.apply {
screen.requireElement<TextInput>(KEY_URL).content = this.toString()
}
data.getStringExtra(Constants.URL_PROVIDER_INTENT_EXTRA_NAME)?.also {
screen.requireElement<TextInput>(KEY_NAME).apply {
if (content.isBlank())
content = it
}
}
}
}
super.onActivityResult(requestCode, resultCode, data)
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
root?.apply {
screen.saveState(outState)
}
}
private fun openUrlProvider(): Boolean {
try {
when (type) {
Type.FILE ->
startActivityForResult(
Intent(Intent.ACTION_GET_CONTENT).setType(TYPE_YAML),
REQUEST_CODE
)
Type.EXTERNAL ->
startActivityForResult(
source?.toIntent() ?: return false,
REQUEST_CODE
)
else -> return false
}
} catch (e: Exception) {
root?.apply {
Snackbar.make(
this,
R.string.start_url_provider_failure,
Snackbar.LENGTH_LONG
).show()
}
}
return true
}
private fun Long.toStringIfNonZero(): String {
return if ( this == 0L ) "" else this.toString()
}
private fun String.toIntent(): Intent? {
return try {
Intent.parseUri(this, 0)
} catch (e: Exception) {
null
}
}
}
@@ -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,31 @@
package com.github.kr328.clash.log
object SystemLogcat {
private val command = arrayOf(
"logcat",
"-d",
"-s",
"Go",
"DEBUG",
"AndroidRuntime",
"ClashForAndroid"
)
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.common.settings.BaseSettings
data class Pipeline<T>(val input: T, val settings: BaseSettings)
@@ -1,100 +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,26 +0,0 @@
package com.github.kr328.clash.preference
import android.content.Context
import com.github.kr328.clash.common.settings.BaseSettings
class UiSettings(context: Context) :
BaseSettings(context.getSharedPreferences(FILE_NAME, Context.MODE_PRIVATE)) {
companion object {
private const val FILE_NAME = "ui"
const val PROXY_SORT_DEFAULT = "default"
const val PROXY_SORT_NAME = "name"
const val PROXY_SORT_DELAY = "delay"
const val DARK_MODE_AUTO = "auto"
const val DARK_MODE_DARK = "dark"
const val DARK_MODE_LIGHT = "light"
val PROXY_GROUP_SORT = StringEntry("proxy_group_sort", PROXY_SORT_DEFAULT)
val PROXY_PROXY_SORT = StringEntry("proxy_proxy_sort", PROXY_SORT_DEFAULT)
val PROXY_LAST_SELECT_GROUP = StringEntry("proxy_last_select_group", "")
val PROXY_MERGE_PREFIX = BooleanEntry("proxy_merge_prefix", false)
val LANGUAGE = StringEntry("language", "")
val DARK_MODE = StringEntry("dark_mode", DARK_MODE_AUTO)
}
}
@@ -5,14 +5,12 @@ import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import com.github.kr328.clash.common.Global
import com.github.kr328.clash.common.Permissions
import com.github.kr328.clash.common.ids.Intents
import com.github.kr328.clash.common.utils.Log
import com.github.kr328.clash.utils.ApplicationObserver
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()
@@ -21,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()
}
@@ -55,45 +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)
}
private val observer = ApplicationObserver {
Log.d("Global Broadcast Receiver State = $it")
fun register() {
if (registered)
return
if ( it ) {
Global.application.registerReceiver(broadcastReceiver, IntentFilter().apply {
addAction(Intents.INTENT_ACTION_PROFILE_CHANGED)
addAction(Intents.INTENT_ACTION_CLASH_STOPPED)
addAction(Intents.INTENT_ACTION_CLASH_STARTED)
addAction(Intents.INTENT_ACTION_PROFILE_LOADED)
}, Permissions.PERMISSION_RECEIVE_BROADCASTS, null)
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(Global.application)
if (current != clashRunning) {
clashRunning = current
if (current) {
receivers.forEach { receiver ->
receiver.onStarted()
}
} else {
receivers.forEach { receiver ->
receiver.onStopped(null)
}
}
}
} else {
Global.application.unregisterReceiver(broadcastReceiver)
clashRunning = StatusClient(context).currentProfile() != null
} catch (e: Exception) {
Log.w("Register global receiver: $e", e)
}
}
fun init(application: Application) {
observer.register(application)
fun unregister() {
if (!registered)
return
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.transact.IStreamCallback
import com.github.kr328.clash.service.transact.ParcelableContainer
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class ClashClient(val service: IClashManager) {
suspend fun setSelectProxy(name: String, proxy: String): Boolean = withContext(Dispatchers.IO) {
service.setSelectProxy(name, proxy)
}
suspend fun startHealthCheck(group: String) = withContext(Dispatchers.IO) {
CompletableDeferred<Unit>().apply {
service.startHealthCheck(group, object : IStreamCallback.Stub() {
override fun complete() {
this@apply.complete(Unit)
}
override fun completeExceptionally(reason: String?) {
this@apply.completeExceptionally(RemoteException(reason))
}
override fun send(data: ParcelableContainer?) {}
})
}
}.await()
suspend fun queryAllProxyGroups(): List<ProxyGroup> = withContext(Dispatchers.IO) {
service.queryAllProxies().list
}
suspend fun queryGeneral(): General = withContext(Dispatchers.IO) {
service.queryGeneral()
}
suspend fun queryBandwidth(): Long = withContext(Dispatchers.IO) {
service.queryBandwidth()
}
suspend fun setProxyMode(mode: General.Mode) = withContext(Dispatchers.IO) {
service.setProxyMode(mode.toString())
}
}
@@ -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,72 +0,0 @@
package com.github.kr328.clash.remote
import android.os.RemoteException
import com.github.kr328.clash.service.IProfileService
import com.github.kr328.clash.service.transact.IStreamCallback
import com.github.kr328.clash.service.transact.ParcelableContainer
import com.github.kr328.clash.service.model.Profile
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class ProfileClient(private val service: IProfileService) {
suspend fun acquireUnused(type: Profile.Type, source: String?) = withContext(Dispatchers.IO) {
service.acquireUnused(type.name, source)
}
suspend fun acquireCloned(id: Long) = withContext(Dispatchers.IO) {
service.acquireCloned(id)
}
suspend fun acquireTempUri(id: Long): String? = withContext(Dispatchers.IO) {
service.acquireTempUri(id)
}
suspend fun update(id: Long, metadata: Profile) = withContext(Dispatchers.IO) {
service.update(id, metadata)
}
suspend fun commitAsync(id: Long) = withContext(Dispatchers.IO) {
CompletableDeferred<Unit>().apply {
service.commit(id, object : IStreamCallback.Stub() {
override fun complete() {
complete(Unit)
}
override fun completeExceptionally(reason: String?) {
completeExceptionally(RemoteException(reason))
}
override fun send(data: ParcelableContainer?) {}
})
}
}
suspend fun release(id: Long) = withContext(Dispatchers.IO) {
service.release(id)
}
suspend fun delete(id: Long) = withContext(Dispatchers.IO) {
service.delete(id)
}
suspend fun clear(id: Long) = withContext(Dispatchers.IO) {
service.clear(id)
}
suspend fun queryAll(): Array<Profile> = withContext(Dispatchers.IO) {
service.queryAll()
}
suspend fun queryActive(): Profile? = withContext(Dispatchers.IO) {
service.queryActive()
}
suspend fun queryById(id: Long): Profile? = withContext(Dispatchers.IO) {
service.queryById(id)
}
suspend fun setActive(id: Long) = withContext(Dispatchers.IO) {
service.setActive(id)
}
}
@@ -1,219 +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.Build
import android.os.Handler
import android.os.IBinder
import android.util.Base64
import androidx.core.content.edit
import com.github.kr328.clash.ApkBrokenActivity
import com.github.kr328.clash.BuildConfig
import com.github.kr328.clash.Constants
import com.github.kr328.clash.AppCrashedActivity
import com.github.kr328.clash.common.Global
import com.github.kr328.clash.common.utils.intent
import com.github.kr328.clash.common.utils.Log
import com.github.kr328.clash.service.ClashManagerService
import com.github.kr328.clash.service.IClashManager
import com.github.kr328.clash.service.IProfileService
import com.github.kr328.clash.service.ProfileService
import com.github.kr328.clash.utils.ApplicationObserver
import com.microsoft.appcenter.crashes.Crashes
import kotlinx.coroutines.*
import 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.io.File
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 services: Services = Services(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)
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
if (service != null)
instance = ClashClient(IClashManager.Stub.asInterface(service))
fun launch() {
ApplicationObserver.attach(Global.application)
service?.linkToDeath({ onServiceDisconnected(null) }, 0)
ApplicationObserver.onVisibleChanged { visible.trySend(it) }
sender = GlobalScope.launch {
while (isActive) {
val client = instance ?: return@launch
clash.send(client)
}
}
Global.launch(Dispatchers.IO) {
run()
}
}
class ProfileConnection : ServiceConnection {
private var instance: ProfileClient? = null
private var sender: Job? = null
private suspend fun run() {
val context = Global.application
val store = AppStore(context)
val updatedAt = getLastUpdated(context)
override fun onServiceDisconnected(name: ComponentName?) {
sender?.cancel()
instance = null
sender = null
}
if (store.updatedAt != updatedAt) {
if (!context.verifyApk()) {
ApplicationObserver.createdActivities.forEach { it.finish() }
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
if (service != null)
instance = ProfileClient(IProfileService.Stub.asInterface(service))
val intent = ApkBrokenActivity::class.intent
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
service?.linkToDeath({ onServiceDisconnected(null) }, 0)
sender = GlobalScope.launch {
while (isActive) {
val client = instance ?: return@launch
profile.send(client)
}
}
}
}
private val handler = Handler()
private val observer = ApplicationObserver {
Log.d("Remote Connection State = $it")
val application = Global.application
if (it) {
handler.removeMessages(0)
GlobalScope.launch {
val valid = withContext(Dispatchers.IO) {
verifyApk(application)
}
if (!valid) {
application.startActivity(
ApkBrokenActivity::class.intent
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
)
return@launch
}
clashConnection = ClashConnection().apply {
application.bindService(
ClashManagerService::class.intent,
this,
Context.BIND_AUTO_CREATE
)
}
profileConnection = ProfileConnection().apply {
application.bindService(
ProfileService::class.intent,
this,
Context.BIND_AUTO_CREATE
)
}
}
} else {
handler.postDelayed({
clashConnection?.also {
application.unbindService(it)
it.onServiceDisconnected(null)
}
profileConnection?.also {
application.unbindService(it)
it.onServiceDisconnected(null)
}
clashConnection = null
profileConnection = null
}, 5000)
}
}
fun init(application: Application) {
observer.register(application)
}
private fun verifyApk(application: Application): Boolean {
return try {
val sp = application.getSharedPreferences(
Constants.PREFERENCE_NAME_APP,
Context.MODE_PRIVATE
)
val pkg = application.packageManager.getPackageInfo(application.packageName, 0)
if (sp.getLong(Constants.PREFERENCE_KEY_LAST_INSTALL, 0) == pkg.lastUpdateTime)
return true
val pkgName: String = try {
application::class.java.getMethod(
String(
charArrayOf(
'g',
'e',
't',
'P',
'a',
'c',
'k',
'a',
'g',
'e',
'N',
'a',
'm',
'e'
)
)
).invoke(application)?.toString()
} catch (e: Exception) {
Log.w("getPackageName failure", e)
null
} ?: application.packageName
val packageNameBase64 = Base64
.encodeToString(pkgName.toByteArray(Charsets.UTF_8), Base64.NO_WRAP)
if (packageNameBase64 != BuildConfig.PACKAGE_NAME_BASE64)
return false
val info = application.applicationInfo
val sources =
info.splitSourceDirs ?: arrayOf(info.sourceDir) ?: return false
val regexNativeLibrary = Regex("lib/(\\S+)/libgojni.so")
val availableAbi = Build.SUPPORTED_ABIS.toSet()
val apkAbi =
sources
.asSequence()
.filter { File(it).exists() }
.flatMap { ZipFile(it).entries().asSequence() }
.mapNotNull { regexNativeLibrary.matchEntire(it.name) }
.mapNotNull { it.groups[1]?.value }
.toSet()
if (availableAbi.intersect(apkAbi).isNotEmpty()) {
sp.edit {
putLong(Constants.PREFERENCE_KEY_LAST_INSTALL, pkg.lastUpdateTime)
}
true
return context.startActivity(intent)
} else {
false
store.updatedAt = updatedAt
}
} catch (e: Exception) {
Crashes.trackError(e)
false
}
while (true) {
if (visible.receive()) {
services.bind()
broadcasts.register()
} else {
services.unbind()
broadcasts.unregister()
}
}
}
private fun getLastUpdated(context: Context): Long {
return context.packageManager.getPackageInfo(context.packageName, 0).lastUpdateTime
}
}
@@ -1,56 +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.common.utils.intent
import com.github.kr328.clash.service.Constants
import com.github.kr328.clash.service.ServiceStatusProvider
import java.lang.Exception
object RemoteUtils {
fun detectClashRunning(context: Context): Boolean {
try {
val authority = Uri.Builder()
.scheme("content")
.authority("${context.packageName}${Constants.STATUS_PROVIDER_SUFFIX}")
.build()
val pong = context.contentResolver.call(
authority,
ServiceStatusProvider.METHOD_PING_CLASH_SERVICE,
null,
null
)
return pong != null
} catch (e: Exception) {
context.startActivity(ApkBrokenActivity::class.intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
return false
}
}
fun getCurrentClashProfileName(context: Context): String? {
try {
val authority = Uri.Builder()
.scheme("content")
.authority("${context.packageName}${Constants.STATUS_PROVIDER_SUFFIX}")
.build()
val pong = context.contentResolver.call(
authority,
ServiceStatusProvider.METHOD_PING_CLASH_SERVICE,
null,
null
)
return pong?.getString("name")
} catch (e: Exception) {
context.startActivity(ApkBrokenActivity::class.intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
return null
}
}
}
@@ -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,88 @@
package com.github.kr328.clash.remote
import android.app.Application
import android.content.ComponentName
import android.content.Context
import android.content.ServiceConnection
import android.os.IBinder
import com.github.kr328.clash.common.log.Log
import com.github.kr328.clash.common.util.intent
import com.github.kr328.clash.service.ClashManager
import com.github.kr328.clash.service.ProfileService
import com.github.kr328.clash.service.remote.IClashManager
import com.github.kr328.clash.service.remote.IProfileManager
import com.github.kr328.clash.service.remote.unwrap
import com.github.kr328.clash.util.unbindServiceSilent
import java.util.concurrent.TimeUnit
class Services(private val context: Application, val crashed: () -> Unit) {
val clash = Resource<IClashManager>()
val profile = Resource<IProfileManager>()
private val clashConnection = object : ServiceConnection {
private var lastCrashed: Long = -1
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
clash.set(service?.unwrap(IClashManager::class))
}
override fun onServiceDisconnected(name: ComponentName?) {
clash.set(null)
if (System.currentTimeMillis() - lastCrashed < TOGGLE_CRASHED_INTERVAL) {
unbind()
crashed()
}
lastCrashed = System.currentTimeMillis()
Log.w("ClashManager crashed")
}
}
private val profileConnection = object : ServiceConnection {
private var lastCrashed: Long = -1
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
profile.set(service?.unwrap(IProfileManager::class))
}
override fun onServiceDisconnected(name: ComponentName?) {
profile.set(null)
if (System.currentTimeMillis() - lastCrashed < TOGGLE_CRASHED_INTERVAL) {
unbind()
crashed()
}
lastCrashed = System.currentTimeMillis()
Log.w("ProfileService crashed")
}
}
fun bind() {
try {
context.bindService(ClashManager::class.intent, clashConnection, Context.BIND_AUTO_CREATE)
context.bindService(ProfileService::class.intent, profileConnection, Context.BIND_AUTO_CREATE)
} catch (e: Exception) {
unbind()
crashed()
}
}
fun unbind() {
context.unbindServiceSilent(clashConnection)
context.unbindServiceSilent(profileConnection)
clash.set(null)
profile.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.R
import com.github.kr328.clash.common.utils.componentName
import com.github.kr328.clash.remote.Broadcasts
import com.github.kr328.clash.service.RestartReceiver
import com.github.kr328.clash.service.settings.ServiceSettings
class BehaviorFragment : BaseSettingFragment() {
companion object {
private const val KEY_START_ON_BOOT = "start_on_boot"
private const val KEY_SHOW_TRAFFIC = "show_traffic"
}
override fun onCreateDataStore(): SettingsDataStore {
return SettingsDataStore().apply {
on(KEY_START_ON_BOOT, StartOnBootSource())
on(KEY_SHOW_TRAFFIC, ServiceSettings.NOTIFICATION_REFRESH.asSource(service))
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
findPreference(KEY_SHOW_TRAFFIC).isEnabled = !Broadcasts.clashRunning
}
override val xmlResourceId: Int
get() = R.xml.settings_behavior
private inner class StartOnBootSource : SettingsDataStore.Source {
override fun set(value: Any?) {
val v = value as Boolean? ?: return
val status = if (v)
PackageManager.COMPONENT_ENABLED_STATE_ENABLED
else
PackageManager.COMPONENT_ENABLED_STATE_DISABLED
requireActivity().packageManager.setComponentEnabledSetting(
RestartReceiver::class.componentName,
status,
PackageManager.DONT_KILL_APP
)
}
override fun get(): Any? {
val status = requireActivity().packageManager
.getComponentEnabledSetting(RestartReceiver::class.componentName)
return status == PackageManager.COMPONENT_ENABLED_STATE_ENABLED
}
}
}
@@ -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,46 +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.common.utils.intent
import com.github.kr328.clash.remote.Broadcasts
import com.github.kr328.clash.service.settings.ServiceSettings
class NetworkFragment : BaseSettingFragment() {
companion object {
private const val KEY_ENABLE_VPN_SERVICE = "enable_vpn_service"
private const val BYPASS_PRIVATE_NETWORK = "bypass_private_network"
private const val KEY_DNS_HIJACKING = "dns_hijacking"
private const val KEY_DNS_OVERRIDE = "dns_override"
private const val KEY_APPEND_SYS_DNS = "append_system_dns"
private const val KEY_ACCESS_CONTROL_MODE = "access_control_mode"
private const val KEY_ACCESS_CONTROL_PACKAGES = "access_control_packages"
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
preferenceScreen.isEnabled = !Broadcasts.clashRunning
findPreference(KEY_ACCESS_CONTROL_PACKAGES).setOnPreferenceClickListener {
startActivity(PackagesActivity::class.intent)
true
}
}
override fun onCreateDataStore(): SettingsDataStore {
return SettingsDataStore().apply {
on(KEY_ENABLE_VPN_SERVICE, ServiceSettings.ENABLE_VPN.asSource(service))
on(BYPASS_PRIVATE_NETWORK, ServiceSettings.BYPASS_PRIVATE_NETWORK.asSource(service))
on(KEY_DNS_HIJACKING, ServiceSettings.DNS_HIJACKING.asSource(service))
on(KEY_DNS_OVERRIDE, ServiceSettings.OVERRIDE_DNS.asSource(service))
on(KEY_APPEND_SYS_DNS, ServiceSettings.AUTO_ADD_SYSTEM_DNS.asSource(service))
on(KEY_ACCESS_CONTROL_MODE, ServiceSettings.ACCESS_CONTROL_MODE.asSource(service))
}
}
override val xmlResourceId: Int
get() = R.xml.settings_network
}
@@ -1,100 +0,0 @@
package com.github.kr328.clash.settings
import com.github.kr328.clash.common.settings.BaseSettings
import moe.shizuku.preference.PreferenceDataStore
class SettingsDataStore : PreferenceDataStore() {
interface Source {
fun set(value: Any?)
fun get(): Any?
}
private val sources: MutableMap<String, Source> = mutableMapOf()
var applyListener: () -> Unit = {}
fun on(key: String, source: Source) {
sources[key] = source
}
fun onApply(block: () -> Unit) {
this.applyListener = block
}
inline fun <reified T> BaseSettings.Entry<T>.asSource(settings: BaseSettings): Source {
return object : Source {
override fun set(value: Any?) {
val v = value ?: throw NullPointerException()
settings.commit {
put(this@asSource, v as T)
}
applyListener()
}
override fun get(): Any? {
return settings.get(this@asSource)
}
}
}
override fun getBoolean(key: String?, defValue: Boolean): Boolean {
val source = sources[key] ?: return defValue
return (source.get() as Boolean?) ?: defValue
}
override fun putLong(key: String?, value: Long) {
val source = sources[key] ?: throw NullPointerException()
source.set(value)
}
override fun putInt(key: String?, value: Int) {
val source = sources[key] ?: throw NullPointerException()
source.set(value)
}
override fun getInt(key: String?, defValue: Int): Int {
val source = sources[key] ?: return defValue
return (source.get() as Int?) ?: defValue
}
override fun putBoolean(key: String?, value: Boolean) {
val source = sources[key] ?: throw NullPointerException()
source.set(value)
}
override fun getLong(key: String?, defValue: Long): Long {
val source = sources[key] ?: return defValue
return (source.get() as Long?) ?: defValue
}
override fun getFloat(key: String?, defValue: Float): Float {
val source = sources[key] ?: return defValue
return (source.get() as Float?) ?: defValue
}
override fun putFloat(key: String?, value: Float) {
val source = sources[key] ?: throw NullPointerException()
source.set(value)
}
override fun getString(key: String?, defValue: String?): String? {
val source = sources[key] ?: return defValue
return (source.get() as String?) ?: defValue
}
override fun putString(key: String?, value: String?) {
val source = sources[key] ?: throw NullPointerException()
source.set(value)
}
}
@@ -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,17 +1,18 @@
package com.github.kr328.clash.service.util
package com.github.kr328.clash.util
import android.content.Context
import android.content.Intent
import android.net.VpnService
import com.github.kr328.clash.common.ids.Intents
import com.github.kr328.clash.common.utils.intent
import com.github.kr328.clash.common.utils.startForegroundServiceCompat
import com.github.kr328.clash.common.compat.startForegroundServiceCompat
import com.github.kr328.clash.common.constants.Intents
import com.github.kr328.clash.common.util.intent
import com.github.kr328.clash.design.store.UiStore
import com.github.kr328.clash.service.ClashService
import com.github.kr328.clash.service.TunService
import com.github.kr328.clash.service.settings.ServiceSettings
import com.github.kr328.clash.service.util.sendBroadcastSelf
fun Context.startClashService(): Intent? {
val startTun = ServiceSettings(this).get(ServiceSettings.ENABLE_VPN)
val startTun = UiStore(this).enableVpn
if (startTun) {
val vpnRequest = VpnService.prepare(this)
@@ -27,5 +28,5 @@ fun Context.startClashService(): Intent? {
}
fun Context.stopClashService() {
sendBroadcastSelf(Intent(Intents.INTENT_ACTION_CLASH_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,44 @@
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 client = Remote.services.clash.get()
try {
return withContext(context) { client.block() }
} catch (e: DeadObjectException) {
Log.w("Remote services panic")
Remote.services.clash.reset(client)
}
}
}
suspend fun <T> withProfile(
context: CoroutineContext = Dispatchers.IO,
block: suspend IProfileManager.() -> T
): T {
while (true) {
val client = Remote.services.profile.get()
try {
return withContext(context) { client.block() }
} catch (e: DeadObjectException) {
Log.w("Remote services panic")
Remote.services.profile.reset(client)
}
}
}
@@ -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,40 +0,0 @@
package com.github.kr328.clash.utils
import android.app.Activity
import android.app.Application
import android.os.Bundle
class ApplicationObserver(val stateChanged: (Boolean) -> Unit) {
private var applicationRunning = false
private set(value) {
if ( field != value )
stateChanged(value)
field = value
}
private var activityCount: Int = 0
private val activityObserver = object: Application.ActivityLifecycleCallbacks {
override fun onActivityPaused(activity: Activity) {}
override fun onActivityStarted(activity: Activity) {}
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {}
override fun onActivityStopped(activity: Activity) {}
override fun onActivityResumed(activity: Activity) {}
override fun onActivityDestroyed(activity: Activity) {
synchronized(this) {
activityCount--
applicationRunning = activityCount > 0
}
}
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {
synchronized(this) {
activityCount++
applicationRunning = activityCount > 0
}
}
}
fun register(application: Application) {
application.registerActivityLifecycleCallbacks(activityObserver)
}
}
@@ -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)
}
}
@@ -1,54 +0,0 @@
package com.github.kr328.clash.utils
import android.content.Context
import androidx.recyclerview.widget.LinearSmoothScroller
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
class ScrollBinding(
private val context: Context,
private val callback: Callback
) {
interface Callback {
fun getCurrentMasterToken(): String
fun onMasterTokenChanged(token: String)
fun getMasterTokenPosition(token: String): Int
fun doMasterScroll(scroller: LinearSmoothScroller, target: Int)
}
private val updateChannel = Channel<Unit>(Channel.CONFLATED)
private var preventSlaveScroll = false
fun sendMasterScrolled() {
updateChannel.offer(Unit)
}
fun scrollMaster(token: String) {
val position = callback.getMasterTokenPosition(token)
if (position < 0)
return
val scroller = QuickSmoothScroller(context, position)
callback.doMasterScroll(scroller, position)
}
suspend fun exec() {
var lastToken: String? = null
while (true) {
updateChannel.receive()
val currentToken = callback.getCurrentMasterToken()
if (preventSlaveScroll || lastToken == currentToken)
continue
lastToken = currentToken
callback.onMasterTokenChanged(currentToken)
delay(200)
}
}
}
@@ -1,25 +0,0 @@
package com.github.kr328.clash.utils
fun String.toCodePointList(): List<Int> {
var offset = 0
val result = mutableListOf<Int>()
while (offset < length) {
val codePoint = codePointAt(offset)
result.add(codePoint)
offset += Character.charCount(codePoint)
}
return result.toList()
}
fun List<Int>.asCodePointString(): String {
val sb = StringBuilder()
forEach {
sb.appendCodePoint(it)
}
return sb.toString()
}
@@ -1,111 +0,0 @@
package com.github.kr328.clash.weight
import android.content.Context
import android.util.TypedValue
import android.view.ViewGroup
import androidx.annotation.ColorInt
import com.github.kr328.clash.R
import com.github.kr328.clash.design.view.CommonUiLayout
import com.github.kr328.clash.service.model.Profile
import com.google.android.material.bottomsheet.BottomSheetDialog
class ProfilesMenu(
context: Context,
private val entity: Profile,
private val callback: Callback
) : BottomSheetDialog(context) {
interface Callback {
fun onOpenEditor(entity: Profile)
fun onUpdate(entity: Profile)
fun onOpenProperties(entity: Profile)
fun onDuplicate(entity: Profile)
fun onResetProvider(entity: Profile)
fun onDelete(entity: Profile)
}
init {
val menu = CommonUiLayout(context).apply {
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
)
}
@ColorInt
val errorColor = TypedValue().run {
context.theme.resolveAttribute(R.attr.colorError, this, true)
data
}
menu.build {
if (entity.type != Profile.Type.FILE) {
option(
title = context.getString(R.string.update),
icon = context.getDrawable(R.drawable.ic_update)
) {
onClick {
callback.onUpdate(entity)
dismiss()
}
}
} else {
option(
title = context.getString(R.string.edit),
icon = context.getDrawable(R.drawable.ic_edit)
) {
onClick {
callback.onOpenEditor(entity)
dismiss()
}
}
}
option(
title = context.getString(R.string.properties),
icon = context.getDrawable(R.drawable.ic_properties)
) {
onClick {
callback.onOpenProperties(entity)
dismiss()
}
}
option(
title = context.getString(R.string.duplicate),
icon = context.getDrawable(R.drawable.ic_copy)
) {
onClick {
callback.onDuplicate(entity)
dismiss()
}
}
option(
title = context.getString(R.string.reset_provider),
icon = context.getDrawable(R.drawable.ic_clear)
) {
onClick {
callback.onResetProvider(entity)
dismiss()
}
}
option(
title = context.getString(R.string.delete),
icon = context.getDrawable(R.drawable.ic_delete_colorful)
) {
textColor = errorColor
onClick {
callback.onDelete(entity)
dismiss()
}
}
}
dismissWithAnimation = true
setContentView(menu)
}
}
Loaded 100 of 641 files, more files were not shown because too many files have changed in this diff. Show more