Initial: initial commit
136
app/build.gradle.kts
Normal file
@@ -0,0 +1,136 @@
|
||||
import java.util.*
|
||||
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
kotlin("android")
|
||||
kotlin("kapt")
|
||||
}
|
||||
|
||||
android {
|
||||
compileSdk = buildTargetSdkVersion
|
||||
|
||||
flavorDimensions(buildFlavor)
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.github.kr328.clash"
|
||||
|
||||
minSdk = buildMinSdkVersion
|
||||
targetSdk = buildTargetSdkVersion
|
||||
|
||||
versionCode = buildVersionCode
|
||||
versionName = buildVersionName
|
||||
|
||||
resConfigs("zh-rCN", "zh-rHK", "zh-rTW")
|
||||
|
||||
resValue("string", "release_name", "v$buildVersionName")
|
||||
resValue("integer", "release_code", "$buildVersionCode")
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
named("release") {
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
||||
}
|
||||
}
|
||||
|
||||
productFlavors {
|
||||
create("open") {
|
||||
dimension = "open"
|
||||
versionNameSuffix = ".open-source"
|
||||
}
|
||||
create("premium") {
|
||||
dimension = "premium"
|
||||
versionNameSuffix = ".premium"
|
||||
}
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
dataBinding = true
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_1_8
|
||||
targetCompatibility = JavaVersion.VERSION_1_8
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "1.8"
|
||||
}
|
||||
|
||||
splits {
|
||||
abi {
|
||||
isEnable = true
|
||||
isUniversalApk = true
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes.apply {
|
||||
val properties = Properties().apply {
|
||||
rootProject.file("local.properties").inputStream().use {
|
||||
load(it)
|
||||
}
|
||||
}
|
||||
|
||||
val key = properties.getProperty("appcenter.key", null)
|
||||
|
||||
forEach {
|
||||
if (it.name == "debug" || key == null) {
|
||||
it.buildConfigField("String", "APP_CENTER_KEY", "null")
|
||||
} else {
|
||||
it.buildConfigField("String", "APP_CENTER_KEY", "\"$key\"")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
signingConfigs.apply {
|
||||
val signingFile = rootProject.file("keystore.properties")
|
||||
if ( signingFile.exists() ) {
|
||||
val properties = Properties().apply {
|
||||
signingFile.inputStream().use {
|
||||
load(it)
|
||||
}
|
||||
}
|
||||
signingConfigs {
|
||||
named("release") {
|
||||
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 {
|
||||
named("release") {
|
||||
this.signingConfig = signingConfigs.findByName("release")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
api(project(":core"))
|
||||
api(project(":service"))
|
||||
api(project(":design"))
|
||||
api(project(":common"))
|
||||
|
||||
implementation(kotlin("stdlib-jdk7"))
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutineVersion")
|
||||
implementation("androidx.core:core-ktx:$ktxVersion")
|
||||
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.microsoft.appcenter:appcenter-analytics:$appcenterVersion")
|
||||
implementation("com.microsoft.appcenter:appcenter-crashes:$appcenterVersion")
|
||||
implementation("com.google.android.material:material:$materialVersion")
|
||||
}
|
||||
|
||||
task("cleanRelease", type = Delete::class) {
|
||||
delete(file("release"))
|
||||
}
|
||||
|
||||
afterEvaluate {
|
||||
tasks["clean"].dependsOn(tasks["cleanRelease"])
|
||||
}
|
||||
33
app/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1,33 @@
|
||||
# Add project specific ProGuard rules here.
|
||||
# You can control the set of applied configuration files using the
|
||||
# proguardFiles setting in build.gradle.
|
||||
#
|
||||
# For more details, see
|
||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||
|
||||
# If your project uses WebView with JS, uncomment the following
|
||||
# and specify the fully qualified class name to the JavaScript interface
|
||||
# class:
|
||||
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||
# public *;
|
||||
#}
|
||||
|
||||
# Uncomment this to preserve the line number information for
|
||||
# debugging stack traces.
|
||||
#-keepattributes SourceFile,LineNumberTable
|
||||
|
||||
# If you keep the line number information, uncomment this to
|
||||
# hide the original source file name.
|
||||
#-renamesourcefileattribute SourceFile
|
||||
|
||||
-dontobfuscate
|
||||
|
||||
-assumenosideeffects class kotlin.jvm.internal.Intrinsics {
|
||||
public static void checkNotNull(...);
|
||||
public static void checkExpressionValueIsNotNull(...);
|
||||
public static void checkNotNullExpressionValue(...);
|
||||
public static void checkReturnedValueIsNotNull(...);
|
||||
public static void checkFieldIsNotNull(...);
|
||||
public static void checkParameterIsNotNull(...);
|
||||
public static void checkNotNullParameter(...);
|
||||
}
|
||||
179
app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,179 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
package="com.github.kr328.clash">
|
||||
|
||||
<uses-feature
|
||||
android:name="android.software.leanback"
|
||||
android:required="false" />
|
||||
<uses-feature
|
||||
android:name="android.hardware.touchscreen"
|
||||
android:required="false" />
|
||||
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||
<uses-permission
|
||||
android:name="android.permission.QUERY_ALL_PACKAGES"
|
||||
tools:ignore="QueryAllPackagesPermission" />
|
||||
|
||||
<application
|
||||
android:name=".MainApplication"
|
||||
android:allowBackup="true"
|
||||
android:banner="@mipmap/ic_banner"
|
||||
android:fullBackupContent="@xml/full_backup_content"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/application_name"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/BootstrapTheme"
|
||||
tools:ignore="GoogleAppIndexingWarning"
|
||||
tools:targetApi="N">
|
||||
<meta-data
|
||||
android:name="releaseName"
|
||||
android:value="@string/release_name" />
|
||||
<meta-data
|
||||
android:name="releaseCode"
|
||||
android:value="@integer/release_code" />
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:configChanges="uiMode"
|
||||
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:configChanges="uiMode"
|
||||
android:exported="false"
|
||||
android:label="@string/application_broken" />
|
||||
<activity
|
||||
android:name=".AppCrashedActivity"
|
||||
android:configChanges="uiMode"
|
||||
android:exported="false"
|
||||
android:label="@string/application_crashed"
|
||||
android:launchMode="singleTask" />
|
||||
<activity
|
||||
android:name=".ProfilesActivity"
|
||||
android:configChanges="uiMode"
|
||||
android:exported="false"
|
||||
android:label="@string/profiles" />
|
||||
<activity
|
||||
android:name=".NewProfileActivity"
|
||||
android:configChanges="uiMode"
|
||||
android:exported="false"
|
||||
android:label="@string/create_profile" />
|
||||
<activity
|
||||
android:name=".PropertiesActivity"
|
||||
android:configChanges="uiMode"
|
||||
android:exported="false"
|
||||
android:label="@string/profile" />
|
||||
<activity
|
||||
android:name=".ProxyActivity"
|
||||
android:configChanges="uiMode"
|
||||
android:exported="false"
|
||||
android:label="@string/proxy" />
|
||||
<activity
|
||||
android:name=".ProvidersActivity"
|
||||
android:configChanges="uiMode"
|
||||
android:exported="false"
|
||||
android:label="@string/providers" />
|
||||
<activity
|
||||
android:name=".LogsActivity"
|
||||
android:configChanges="uiMode"
|
||||
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:configChanges="uiMode"
|
||||
android:exported="false"
|
||||
android:label="@string/settings" />
|
||||
<activity
|
||||
android:name=".NetworkSettingsActivity"
|
||||
android:configChanges="uiMode"
|
||||
android:exported="false"
|
||||
android:label="@string/network" />
|
||||
<activity
|
||||
android:name=".AppSettingsActivity"
|
||||
android:configChanges="uiMode"
|
||||
android:exported="false"
|
||||
android:label="@string/app" />
|
||||
<activity
|
||||
android:name=".OverrideSettingsActivity"
|
||||
android:configChanges="uiMode"
|
||||
android:exported="false"
|
||||
android:label="@string/override" />
|
||||
<activity
|
||||
android:name=".AccessControlActivity"
|
||||
android:configChanges="uiMode"
|
||||
android:exported="false"
|
||||
android:label="@string/access_control_packages" />
|
||||
<activity
|
||||
android:name=".HelpActivity"
|
||||
android:configChanges="uiMode"
|
||||
android:exported="false"
|
||||
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: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>
|
||||
BIN
app/src/main/ic_launcher-web.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.github.kr328.clash
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import com.github.kr328.clash.design.ApkBrokenDesign
|
||||
import kotlinx.coroutines.isActive
|
||||
|
||||
class ApkBrokenActivity : BaseActivity<ApkBrokenDesign>() {
|
||||
override suspend fun main() {
|
||||
val design = ApkBrokenDesign(this)
|
||||
|
||||
setContentDesign(design)
|
||||
|
||||
while (isActive) {
|
||||
val req = design.requests.receive()
|
||||
|
||||
startActivity(Intent(Intent.ACTION_VIEW).setData(Uri.parse(req.url)))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.github.kr328.clash
|
||||
|
||||
import android.os.DeadObjectException
|
||||
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 com.microsoft.appcenter.crashes.Crashes
|
||||
import com.microsoft.appcenter.crashes.ingestion.models.ErrorAttachmentLog
|
||||
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()
|
||||
}
|
||||
|
||||
if (BuildConfig.APP_CENTER_KEY != null && !BuildConfig.DEBUG) {
|
||||
if (logs.isNotBlank()) {
|
||||
Crashes.trackError(
|
||||
DeadObjectException(),
|
||||
mapOf("type" to "app_crashed"),
|
||||
listOf(ErrorAttachmentLog.attachmentWithText(logs, "logcat.txt"))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
249
app/src/main/java/com/github/kr328/clash/BaseActivity.kt
Normal file
@@ -0,0 +1,249 @@
|
||||
package com.github.kr328.clash
|
||||
|
||||
import android.content.res.Configuration
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.activity.result.contract.ActivityResultContract
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import com.github.kr328.clash.common.compat.isAllowForceDarkCompat
|
||||
import com.github.kr328.clash.common.compat.isLightNavigationBarCompat
|
||||
import com.github.kr328.clash.common.compat.isLightStatusBarsCompat
|
||||
import com.github.kr328.clash.common.compat.isSystemBarsTranslucentCompat
|
||||
import com.github.kr328.clash.core.bridge.ClashException
|
||||
import com.github.kr328.clash.design.Design
|
||||
import com.github.kr328.clash.design.model.DarkMode
|
||||
import com.github.kr328.clash.design.store.UiStore
|
||||
import com.github.kr328.clash.design.ui.DayNight
|
||||
import com.github.kr328.clash.design.util.resolveThemedBoolean
|
||||
import com.github.kr328.clash.design.util.resolveThemedColor
|
||||
import com.github.kr328.clash.design.util.showExceptionToast
|
||||
import com.github.kr328.clash.remote.Broadcasts
|
||||
import com.github.kr328.clash.remote.Remote
|
||||
import com.github.kr328.clash.util.ActivityResultLifecycle
|
||||
import com.github.kr328.clash.util.ApplicationObserver
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.suspendCoroutine
|
||||
|
||||
abstract class BaseActivity<D : Design<*>> :
|
||||
AppCompatActivity(),
|
||||
CoroutineScope by MainScope(),
|
||||
Broadcasts.Observer {
|
||||
enum class Event {
|
||||
ServiceRecreated,
|
||||
ActivityStart,
|
||||
ActivityStop,
|
||||
ClashStop,
|
||||
ClashStart,
|
||||
ProfileLoaded,
|
||||
ProfileChanged
|
||||
}
|
||||
|
||||
|
||||
protected val uiStore by lazy { UiStore(this) }
|
||||
protected val events = Channel<Event>(Channel.UNLIMITED)
|
||||
protected var activityStarted: Boolean = false
|
||||
protected val clashRunning: Boolean
|
||||
get() = Remote.broadcasts.clashRunning
|
||||
protected var design: D? = null
|
||||
private set(value) {
|
||||
field = value
|
||||
|
||||
if (value != null) {
|
||||
setContentView(value.root)
|
||||
} else {
|
||||
setContentView(View(this))
|
||||
}
|
||||
}
|
||||
|
||||
private var defer: suspend () -> Unit = {}
|
||||
private var deferRunning = false
|
||||
private val nextRequestKey = AtomicInteger(0)
|
||||
private var dayNight: DayNight = DayNight.Day
|
||||
|
||||
protected abstract suspend fun main()
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setContentDesign(design: D) {
|
||||
suspendCoroutine<Unit> {
|
||||
window.decorView.post {
|
||||
this.design = design
|
||||
|
||||
it.resume(Unit)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
applyDayNight()
|
||||
|
||||
launch {
|
||||
main()
|
||||
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
|
||||
activityStarted = true
|
||||
|
||||
Remote.broadcasts.addObserver(this)
|
||||
|
||||
events.offer(Event.ActivityStart)
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
super.onStop()
|
||||
|
||||
activityStarted = false
|
||||
|
||||
Remote.broadcasts.removeObserver(this)
|
||||
|
||||
events.offer(Event.ActivityStop)
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
design?.cancel()
|
||||
|
||||
cancel()
|
||||
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun finish() {
|
||||
if (deferRunning) {
|
||||
return
|
||||
}
|
||||
|
||||
deferRunning = true
|
||||
|
||||
launch {
|
||||
try {
|
||||
defer()
|
||||
} finally {
|
||||
withContext(NonCancellable) {
|
||||
super.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onConfigurationChanged(newConfig: Configuration) {
|
||||
super.onConfigurationChanged(newConfig)
|
||||
|
||||
if (queryDayNight(newConfig) != dayNight) {
|
||||
ApplicationObserver.createdActivities.forEach {
|
||||
it.recreate()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
open fun shouldDisplayHomeAsUpEnabled(): Boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onSupportNavigateUp(): Boolean {
|
||||
this.onBackPressed()
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onProfileChanged() {
|
||||
events.offer(Event.ProfileChanged)
|
||||
}
|
||||
|
||||
override fun onProfileLoaded() {
|
||||
events.offer(Event.ProfileLoaded)
|
||||
}
|
||||
|
||||
override fun onServiceRecreated() {
|
||||
events.offer(Event.ServiceRecreated)
|
||||
}
|
||||
|
||||
override fun onStarted() {
|
||||
events.offer(Event.ClashStart)
|
||||
}
|
||||
|
||||
override fun onStopped(cause: String?) {
|
||||
events.offer(Event.ClashStop)
|
||||
|
||||
if (cause != null && activityStarted) {
|
||||
launch {
|
||||
design?.showExceptionToast(ClashException(cause))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun queryDayNight(config: Configuration = resources.configuration): DayNight {
|
||||
return when (uiStore.darkMode) {
|
||||
DarkMode.Auto -> {
|
||||
if (config.uiMode and Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES)
|
||||
DayNight.Night
|
||||
else
|
||||
DayNight.Day
|
||||
}
|
||||
DarkMode.ForceLight -> {
|
||||
DayNight.Day
|
||||
}
|
||||
DarkMode.ForceDark -> {
|
||||
DayNight.Night
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyDayNight(config: Configuration = resources.configuration) {
|
||||
val dayNight = queryDayNight(config)
|
||||
|
||||
when (dayNight) {
|
||||
DayNight.Night -> {
|
||||
theme.applyStyle(R.style.AppThemeDark, true)
|
||||
}
|
||||
DayNight.Day -> {
|
||||
theme.applyStyle(R.style.AppThemeLight, true)
|
||||
}
|
||||
}
|
||||
|
||||
window.isAllowForceDarkCompat = false
|
||||
window.isSystemBarsTranslucentCompat = true
|
||||
|
||||
window.statusBarColor = resolveThemedColor(android.R.attr.statusBarColor)
|
||||
window.navigationBarColor = resolveThemedColor(android.R.attr.navigationBarColor)
|
||||
|
||||
if (Build.VERSION.SDK_INT >= 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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
160
app/src/main/java/com/github/kr328/clash/FilesActivity.kt
Normal file
@@ -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?.offer(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())
|
||||
}
|
||||
}
|
||||
19
app/src/main/java/com/github/kr328/clash/HelpActivity.kt
Normal file
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
185
app/src/main/java/com/github/kr328/clash/LogcatActivity.kt
Normal file
@@ -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()
|
||||
}
|
||||
}
|
||||
169
app/src/main/java/com/github/kr328/clash/LogcatService.kt
Normal file
@@ -0,0 +1,169 @@
|
||||
package com.github.kr328.clash
|
||||
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.ServiceConnection
|
||||
import android.os.Binder
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import android.os.IInterface
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
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 java.io.IOException
|
||||
import java.util.*
|
||||
|
||||
class LogcatService : Service(), CoroutineScope by CoroutineScope(Dispatchers.Default), IInterface {
|
||||
private val cache = LogcatCache()
|
||||
|
||||
private val connection = object : ServiceConnection {
|
||||
override fun onServiceDisconnected(name: ComponentName?) {
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
|
||||
startObserver(service ?: return stopSelf())
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
running = true
|
||||
|
||||
createNotificationChannel()
|
||||
|
||||
showNotification()
|
||||
|
||||
bindService(ClashManager::class.intent, connection, Context.BIND_AUTO_CREATE)
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
cancel()
|
||||
|
||||
unbindService(connection)
|
||||
|
||||
stopForeground(true)
|
||||
|
||||
running = false
|
||||
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder {
|
||||
return this.asBinder()
|
||||
}
|
||||
|
||||
override fun asBinder(): IBinder {
|
||||
return object : Binder() {
|
||||
override fun queryLocalInterface(descriptor: String): IInterface {
|
||||
return this@LogcatService
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun snapshot(full: Boolean): LogcatCache.Snapshot? {
|
||||
return cache.snapshot(full)
|
||||
}
|
||||
|
||||
private fun startObserver(binder: IBinder) {
|
||||
if (!binder.isBinderAlive)
|
||||
return stopSelf()
|
||||
|
||||
launch(Dispatchers.IO) {
|
||||
val service = binder.unwrap(IClashManager::class)
|
||||
val channel = Channel<LogMessage>(CACHE_CAPACITY)
|
||||
|
||||
try {
|
||||
logsDir.mkdirs()
|
||||
|
||||
LogcatWriter(this@LogcatService).use {
|
||||
val observer = object : ILogObserver {
|
||||
override fun newItem(log: LogMessage) {
|
||||
channel.offer(log)
|
||||
}
|
||||
}
|
||||
|
||||
service.setLogObserver(observer)
|
||||
|
||||
while (isActive) {
|
||||
val msg = channel.receive()
|
||||
|
||||
it.appendMessage(msg)
|
||||
|
||||
cache.append(msg)
|
||||
}
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Log.e("Write log file: $e", e)
|
||||
} finally {
|
||||
withContext(NonCancellable) {
|
||||
if (binder.isBinderAlive) {
|
||||
service.setLogObserver(null)
|
||||
}
|
||||
|
||||
stopSelf()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun createNotificationChannel() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O)
|
||||
return
|
||||
|
||||
NotificationManagerCompat.from(this)
|
||||
.createNotificationChannel(
|
||||
NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
getString(R.string.clash_logcat),
|
||||
NotificationManager.IMPORTANCE_DEFAULT
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun showNotification() {
|
||||
val notification = NotificationCompat
|
||||
.Builder(this, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_logo_service)
|
||||
.setColor(getColorCompat(R.color.color_clash_light))
|
||||
.setContentTitle(getString(R.string.clash_logcat))
|
||||
.setContentText(getString(R.string.running))
|
||||
.setContentIntent(
|
||||
PendingIntent.getActivity(
|
||||
this,
|
||||
R.id.nf_logcat_status,
|
||||
LogcatActivity::class.intent
|
||||
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT
|
||||
)
|
||||
)
|
||||
.build()
|
||||
|
||||
startForeground(R.id.nf_logcat_status, notification)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val CHANNEL_ID = "clash_logcat_channel"
|
||||
private const val CACHE_CAPACITY = 128
|
||||
|
||||
var running: Boolean = false
|
||||
}
|
||||
}
|
||||
71
app/src/main/java/com/github/kr328/clash/LogsActivity.kt
Normal file
@@ -0,0 +1,71 @@
|
||||
package com.github.kr328.clash
|
||||
|
||||
import com.github.kr328.clash.common.util.intent
|
||||
import com.github.kr328.clash.common.util.setFileName
|
||||
import com.github.kr328.clash.design.LogsDesign
|
||||
import com.github.kr328.clash.design.model.LogFile
|
||||
import com.github.kr328.clash.util.logsDir
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.selects.select
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class LogsActivity : BaseActivity<LogsDesign>() {
|
||||
override suspend fun main() {
|
||||
if (LogcatService.running) {
|
||||
return startActivity(LogcatActivity::class.intent)
|
||||
}
|
||||
|
||||
val design = LogsDesign(this)
|
||||
|
||||
setContentDesign(design)
|
||||
|
||||
while (isActive) {
|
||||
select<Unit> {
|
||||
events.onReceive {
|
||||
when (it) {
|
||||
Event.ActivityStart -> {
|
||||
val files = withContext(Dispatchers.IO) {
|
||||
loadFiles()
|
||||
}
|
||||
|
||||
design.patchLogs(files)
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
design.requests.onReceive {
|
||||
when (it) {
|
||||
LogsDesign.Request.StartLogcat -> {
|
||||
startActivity(LogcatActivity::class.intent)
|
||||
|
||||
finish()
|
||||
}
|
||||
LogsDesign.Request.DeleteAll -> {
|
||||
if (design.requestDeleteAll()) {
|
||||
withContext(Dispatchers.IO) {
|
||||
deleteAllLogs()
|
||||
}
|
||||
|
||||
events.offer(Event.ActivityStart)
|
||||
}
|
||||
}
|
||||
is LogsDesign.Request.OpenFile -> {
|
||||
startActivity(LogcatActivity::class.intent.setFileName(it.file.fileName))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadFiles(): List<LogFile> {
|
||||
val list = cacheDir.resolve("logs").listFiles()?.toList() ?: emptyList()
|
||||
|
||||
return list.mapNotNull { LogFile.parseFromFileName(it.name) }
|
||||
}
|
||||
|
||||
private fun deleteAllLogs() {
|
||||
logsDir.deleteRecursively()
|
||||
}
|
||||
}
|
||||
151
app/src/main/java/com/github/kr328/clash/MainActivity.kt
Normal file
@@ -0,0 +1,151 @@
|
||||
package com.github.kr328.clash
|
||||
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import com.github.kr328.clash.common.util.intent
|
||||
import com.github.kr328.clash.common.util.ticker
|
||||
import com.github.kr328.clash.design.MainDesign
|
||||
import com.github.kr328.clash.design.ui.ToastDuration
|
||||
import com.github.kr328.clash.store.TipsStore
|
||||
import com.github.kr328.clash.util.startClashService
|
||||
import com.github.kr328.clash.util.stopClashService
|
||||
import com.github.kr328.clash.util.withClash
|
||||
import com.github.kr328.clash.util.withProfile
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.selects.select
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class MainActivity : BaseActivity<MainDesign>() {
|
||||
override suspend fun main() {
|
||||
val design = MainDesign(this)
|
||||
|
||||
setContentDesign(design)
|
||||
|
||||
launch(Dispatchers.IO) {
|
||||
showUpdatedTips(design)
|
||||
}
|
||||
|
||||
design.fetch()
|
||||
|
||||
val ticker = ticker(TimeUnit.SECONDS.toMillis(1))
|
||||
|
||||
while (isActive) {
|
||||
select<Unit> {
|
||||
events.onReceive {
|
||||
when (it) {
|
||||
Event.ActivityStart,
|
||||
Event.ServiceRecreated,
|
||||
Event.ClashStop, Event.ClashStart,
|
||||
Event.ProfileLoaded, Event.ProfileChanged -> design.fetch()
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
design.requests.onReceive {
|
||||
when (it) {
|
||||
MainDesign.Request.ToggleStatus -> {
|
||||
if (clashRunning)
|
||||
stopClashService()
|
||||
else
|
||||
design.startClash()
|
||||
}
|
||||
MainDesign.Request.OpenProxy ->
|
||||
startActivity(ProxyActivity::class.intent)
|
||||
MainDesign.Request.OpenProfiles ->
|
||||
startActivity(ProfilesActivity::class.intent)
|
||||
MainDesign.Request.OpenProviders ->
|
||||
startActivity(ProvidersActivity::class.intent)
|
||||
MainDesign.Request.OpenLogs ->
|
||||
startActivity(LogsActivity::class.intent)
|
||||
MainDesign.Request.OpenSettings ->
|
||||
startActivity(SettingsActivity::class.intent)
|
||||
MainDesign.Request.OpenHelp ->
|
||||
startActivity(HelpActivity::class.intent)
|
||||
MainDesign.Request.OpenAbout ->
|
||||
design.showAbout(queryAppVersionName())
|
||||
}
|
||||
}
|
||||
if (clashRunning) {
|
||||
ticker.onReceive {
|
||||
design.fetchTraffic()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun showUpdatedTips(design: MainDesign) {
|
||||
val tips = TipsStore(this)
|
||||
|
||||
if (tips.primaryVersion != TipsStore.CURRENT_PRIMARY_VERSION) {
|
||||
tips.primaryVersion = TipsStore.CURRENT_PRIMARY_VERSION
|
||||
|
||||
val pkg = packageManager.getPackageInfo(packageName, 0)
|
||||
|
||||
if (pkg.firstInstallTime != pkg.lastUpdateTime) {
|
||||
design.showUpdatedTips()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun MainDesign.fetch() {
|
||||
setClashRunning(clashRunning)
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
44
app/src/main/java/com/github/kr328/clash/MainApplication.kt
Normal file
@@ -0,0 +1,44 @@
|
||||
package com.github.kr328.clash
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import com.github.kr328.clash.common.Global
|
||||
import com.github.kr328.clash.common.compat.currentProcessName
|
||||
import com.github.kr328.clash.common.log.Log
|
||||
import com.github.kr328.clash.remote.Remote
|
||||
import com.github.kr328.clash.service.util.sendServiceRecreated
|
||||
import com.microsoft.appcenter.AppCenter
|
||||
import com.microsoft.appcenter.analytics.Analytics
|
||||
import com.microsoft.appcenter.crashes.Crashes
|
||||
|
||||
@Suppress("unused")
|
||||
class MainApplication : Application() {
|
||||
override fun attachBaseContext(base: Context?) {
|
||||
super.attachBaseContext(base)
|
||||
|
||||
Global.init(this)
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
// Initialize AppCenter
|
||||
if (BuildConfig.APP_CENTER_KEY != null && !BuildConfig.DEBUG) {
|
||||
AppCenter.start(
|
||||
this,
|
||||
BuildConfig.APP_CENTER_KEY,
|
||||
Analytics::class.java, Crashes::class.java
|
||||
)
|
||||
}
|
||||
|
||||
val processName = currentProcessName
|
||||
|
||||
Log.d("Process $processName started")
|
||||
|
||||
if (processName == packageName) {
|
||||
Remote.launch()
|
||||
} else {
|
||||
sendServiceRecreated()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
143
app/src/main/java/com/github/kr328/clash/NewProfileActivity.kt
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
77
app/src/main/java/com/github/kr328/clash/ProfilesActivity.kt
Normal file
@@ -0,0 +1,77 @@
|
||||
package com.github.kr328.clash
|
||||
|
||||
import com.github.kr328.clash.common.util.intent
|
||||
import com.github.kr328.clash.common.util.setUUID
|
||||
import com.github.kr328.clash.common.util.ticker
|
||||
import com.github.kr328.clash.design.ProfilesDesign
|
||||
import com.github.kr328.clash.service.model.Profile
|
||||
import com.github.kr328.clash.util.withProfile
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.selects.select
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class ProfilesActivity : BaseActivity<ProfilesDesign>() {
|
||||
override suspend fun main() {
|
||||
val design = ProfilesDesign(this)
|
||||
|
||||
setContentDesign(design)
|
||||
|
||||
val ticker = ticker(TimeUnit.MINUTES.toMillis(1))
|
||||
|
||||
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) }
|
||||
|
||||
startActivity(PropertiesActivity::class.intent.setUUID(uuid))
|
||||
}
|
||||
}
|
||||
}
|
||||
if (activityStarted) {
|
||||
ticker.onReceive {
|
||||
design.updateElapsed()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun ProfilesDesign.fetch() {
|
||||
withProfile {
|
||||
patchProfiles(queryAll())
|
||||
}
|
||||
}
|
||||
}
|
||||
114
app/src/main/java/com/github/kr328/clash/PropertiesActivity.kt
Normal file
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
133
app/src/main/java/com/github/kr328/clash/ProxyActivity.kt
Normal file
@@ -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.offer(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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
18
app/src/main/java/com/github/kr328/clash/RestartReceiver.kt
Normal file
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
32
app/src/main/java/com/github/kr328/clash/SettingsActivity.kt
Normal file
@@ -0,0 +1,32 @@
|
||||
package com.github.kr328.clash
|
||||
|
||||
import com.github.kr328.clash.common.util.intent
|
||||
import com.github.kr328.clash.design.SettingsDesign
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.selects.select
|
||||
|
||||
class SettingsActivity : BaseActivity<SettingsDesign>() {
|
||||
override suspend fun main() {
|
||||
val design = SettingsDesign(this)
|
||||
|
||||
setContentDesign(design)
|
||||
|
||||
while (isActive) {
|
||||
select<Unit> {
|
||||
events.onReceive {
|
||||
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
104
app/src/main/java/com/github/kr328/clash/TileService.kt
Normal file
@@ -0,0 +1,104 @@
|
||||
package com.github.kr328.clash
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.graphics.drawable.Icon
|
||||
import android.os.Build
|
||||
import android.service.quicksettings.Tile
|
||||
import android.service.quicksettings.TileService
|
||||
import androidx.annotation.RequiresApi
|
||||
import com.github.kr328.clash.common.constants.Intents
|
||||
import com.github.kr328.clash.common.constants.Permissions
|
||||
import com.github.kr328.clash.remote.StatusClient
|
||||
import com.github.kr328.clash.util.startClashService
|
||||
import com.github.kr328.clash.util.stopClashService
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.N)
|
||||
class TileService : TileService() {
|
||||
private var currentProfile = ""
|
||||
private var clashRunning = false
|
||||
|
||||
override fun onClick() {
|
||||
val tile = qsTile ?: return
|
||||
|
||||
when (tile.state) {
|
||||
Tile.STATE_INACTIVE -> {
|
||||
startClashService()
|
||||
}
|
||||
Tile.STATE_ACTIVE -> {
|
||||
stopClashService()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStartListening() {
|
||||
super.onStartListening()
|
||||
|
||||
registerReceiver(
|
||||
receiver,
|
||||
IntentFilter().apply {
|
||||
addAction(Intents.ACTION_CLASH_STARTED)
|
||||
addAction(Intents.ACTION_CLASH_STOPPED)
|
||||
addAction(Intents.ACTION_PROFILE_LOADED)
|
||||
addAction(Intents.ACTION_SERVICE_RECREATED)
|
||||
},
|
||||
Permissions.RECEIVE_SELF_BROADCASTS,
|
||||
null
|
||||
)
|
||||
|
||||
val name = StatusClient(this).currentProfile()
|
||||
|
||||
clashRunning = name != null
|
||||
currentProfile = name ?: ""
|
||||
|
||||
updateTile()
|
||||
}
|
||||
|
||||
override fun onStopListening() {
|
||||
super.onStopListening()
|
||||
|
||||
unregisterReceiver(receiver)
|
||||
}
|
||||
|
||||
private fun updateTile() {
|
||||
val tile = qsTile ?: return
|
||||
|
||||
tile.state = if (clashRunning)
|
||||
Tile.STATE_ACTIVE
|
||||
else
|
||||
Tile.STATE_INACTIVE
|
||||
|
||||
tile.label = if (currentProfile.isEmpty())
|
||||
getText(R.string.launch_name)
|
||||
else
|
||||
currentProfile
|
||||
|
||||
tile.icon = Icon.createWithResource(this, R.drawable.ic_logo_service)
|
||||
|
||||
tile.updateTile()
|
||||
}
|
||||
|
||||
private val receiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
when (intent?.action) {
|
||||
Intents.ACTION_CLASH_STARTED -> {
|
||||
clashRunning = true
|
||||
|
||||
currentProfile = ""
|
||||
}
|
||||
Intents.ACTION_CLASH_STOPPED, Intents.ACTION_SERVICE_RECREATED -> {
|
||||
clashRunning = false
|
||||
|
||||
currentProfile = ""
|
||||
}
|
||||
Intents.ACTION_PROFILE_LOADED -> {
|
||||
currentProfile = StatusClient(this@TileService).currentProfile() ?: ""
|
||||
}
|
||||
}
|
||||
|
||||
updateTile()
|
||||
}
|
||||
}
|
||||
}
|
||||
52
app/src/main/java/com/github/kr328/clash/log/LogcatCache.kt
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
25
app/src/main/java/com/github/kr328/clash/log/LogcatFilter.kt
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
32
app/src/main/java/com/github/kr328/clash/log/LogcatReader.kt
Normal file
@@ -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()
|
||||
}
|
||||
}
|
||||
25
app/src/main/java/com/github/kr328/clash/log/LogcatWriter.kt
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
31
app/src/main/java/com/github/kr328/clash/log/SystemLogcat.kt
Normal file
@@ -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) {
|
||||
""
|
||||
}
|
||||
}
|
||||
}
|
||||
103
app/src/main/java/com/github/kr328/clash/remote/Broadcasts.kt
Normal file
@@ -0,0 +1,103 @@
|
||||
package com.github.kr328.clash.remote
|
||||
|
||||
import android.app.Application
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import com.github.kr328.clash.common.constants.Intents
|
||||
import com.github.kr328.clash.common.log.Log
|
||||
|
||||
class Broadcasts(private val context: Application) {
|
||||
interface Observer {
|
||||
fun onServiceRecreated()
|
||||
fun onStarted()
|
||||
fun onStopped(cause: String?)
|
||||
fun onProfileChanged()
|
||||
fun onProfileLoaded()
|
||||
}
|
||||
|
||||
var clashRunning: Boolean = false
|
||||
|
||||
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.ACTION_SERVICE_RECREATED -> {
|
||||
clashRunning = false
|
||||
|
||||
receivers.forEach {
|
||||
it.onServiceRecreated()
|
||||
}
|
||||
}
|
||||
Intents.ACTION_CLASH_STARTED -> {
|
||||
clashRunning = true
|
||||
|
||||
receivers.forEach {
|
||||
it.onStarted()
|
||||
}
|
||||
}
|
||||
Intents.ACTION_CLASH_STOPPED -> {
|
||||
clashRunning = false
|
||||
|
||||
receivers.forEach {
|
||||
it.onStopped(intent.getStringExtra(Intents.EXTRA_STOP_REASON))
|
||||
}
|
||||
}
|
||||
Intents.ACTION_PROFILE_CHANGED ->
|
||||
receivers.forEach {
|
||||
it.onProfileChanged()
|
||||
}
|
||||
Intents.ACTION_PROFILE_LOADED -> {
|
||||
receivers.forEach {
|
||||
it.onProfileLoaded()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun addObserver(observer: Observer) {
|
||||
receivers.add(observer)
|
||||
}
|
||||
|
||||
fun removeObserver(observer: Observer) {
|
||||
receivers.remove(observer)
|
||||
}
|
||||
|
||||
fun register() {
|
||||
if (registered)
|
||||
return
|
||||
|
||||
try {
|
||||
context.registerReceiver(broadcastReceiver, IntentFilter().apply {
|
||||
addAction(Intents.ACTION_SERVICE_RECREATED)
|
||||
addAction(Intents.ACTION_CLASH_STARTED)
|
||||
addAction(Intents.ACTION_CLASH_STOPPED)
|
||||
addAction(Intents.ACTION_PROFILE_CHANGED)
|
||||
addAction(Intents.ACTION_PROFILE_LOADED)
|
||||
})
|
||||
|
||||
clashRunning = StatusClient(context).currentProfile() != null
|
||||
} catch (e: Exception) {
|
||||
Log.w("Register global receiver: $e", e)
|
||||
}
|
||||
}
|
||||
|
||||
fun unregister() {
|
||||
if (!registered)
|
||||
return
|
||||
|
||||
try {
|
||||
context.unregisterReceiver(broadcastReceiver)
|
||||
|
||||
clashRunning = false
|
||||
} catch (e: Exception) {
|
||||
Log.w("Unregister global receiver: $e", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
72
app/src/main/java/com/github/kr328/clash/remote/Remote.kt
Normal file
@@ -0,0 +1,72 @@
|
||||
package com.github.kr328.clash.remote
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import com.github.kr328.clash.ApkBrokenActivity
|
||||
import com.github.kr328.clash.AppCrashedActivity
|
||||
import com.github.kr328.clash.common.Global
|
||||
import com.github.kr328.clash.common.util.intent
|
||||
import com.github.kr328.clash.store.AppStore
|
||||
import com.github.kr328.clash.util.ApplicationObserver
|
||||
import com.github.kr328.clash.util.verifyApk
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
object Remote {
|
||||
val broadcasts: Broadcasts = Broadcasts(Global.application)
|
||||
val services: Services = Services(Global.application) {
|
||||
ApplicationObserver.createdActivities.forEach { it.finish() }
|
||||
|
||||
val intent = AppCrashedActivity::class.intent
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
|
||||
Global.application.startActivity(intent)
|
||||
}
|
||||
|
||||
private val visible = Channel<Boolean>(Channel.CONFLATED)
|
||||
|
||||
fun launch() {
|
||||
ApplicationObserver.attach(Global.application)
|
||||
|
||||
ApplicationObserver.onVisibleChanged(visible::offer)
|
||||
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
run()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun run() {
|
||||
val context = Global.application
|
||||
val store = AppStore(context)
|
||||
val updatedAt = getLastUpdated(context)
|
||||
|
||||
if (store.updatedAt != updatedAt) {
|
||||
if (!context.verifyApk()) {
|
||||
ApplicationObserver.createdActivities.forEach { it.finish() }
|
||||
|
||||
val intent = ApkBrokenActivity::class.intent
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
|
||||
return context.startActivity(intent)
|
||||
} else {
|
||||
store.updatedAt = updatedAt
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
74
app/src/main/java/com/github/kr328/clash/remote/Resource.kt
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
88
app/src/main/java/com/github/kr328/clash/remote/Services.kt
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
22
app/src/main/java/com/github/kr328/clash/store/AppStore.kt
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
29
app/src/main/java/com/github/kr328/clash/store/TipsStore.kt
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
44
app/src/main/java/com/github/kr328/clash/util/Activity.kt
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
78
app/src/main/java/com/github/kr328/clash/util/Application.kt
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
32
app/src/main/java/com/github/kr328/clash/util/Clash.kt
Normal file
@@ -0,0 +1,32 @@
|
||||
package com.github.kr328.clash.util
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.VpnService
|
||||
import com.github.kr328.clash.common.compat.startForegroundServiceCompat
|
||||
import com.github.kr328.clash.common.constants.Intents
|
||||
import com.github.kr328.clash.common.util.intent
|
||||
import com.github.kr328.clash.design.store.UiStore
|
||||
import com.github.kr328.clash.service.ClashService
|
||||
import com.github.kr328.clash.service.TunService
|
||||
import com.github.kr328.clash.service.util.sendBroadcastSelf
|
||||
|
||||
fun Context.startClashService(): Intent? {
|
||||
val startTun = UiStore(this).enableVpn
|
||||
|
||||
if (startTun) {
|
||||
val vpnRequest = VpnService.prepare(this)
|
||||
if (vpnRequest != null)
|
||||
return vpnRequest
|
||||
|
||||
startForegroundServiceCompat(TunService::class.intent)
|
||||
} else {
|
||||
startForegroundServiceCompat(ClashService::class.intent)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
fun Context.stopClashService() {
|
||||
sendBroadcastSelf(Intent(Intents.ACTION_CLASH_REQUEST_STOP))
|
||||
}
|
||||
25
app/src/main/java/com/github/kr328/clash/util/Content.kt
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
7
app/src/main/java/com/github/kr328/clash/util/Files.kt
Normal file
@@ -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")
|
||||
44
app/src/main/java/com/github/kr328/clash/util/Remote.kt
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
12
app/src/main/java/com/github/kr328/clash/util/Service.kt
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
6
app/src/main/java/com/github/kr328/clash/util/Uri.kt
Normal file
@@ -0,0 +1,6 @@
|
||||
package com.github.kr328.clash.util
|
||||
|
||||
import android.net.Uri
|
||||
|
||||
val Uri.fileName: String?
|
||||
get() = schemeSpecificPart.split("/").lastOrNull()
|
||||
15
app/src/main/res/drawable/ic_launcher_foreground.xml
Normal file
@@ -0,0 +1,15 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="406.92642"
|
||||
android:viewportHeight="406.92642">
|
||||
<group
|
||||
android:translateX="103.4632"
|
||||
android:translateY="103.4632">
|
||||
<path
|
||||
android:fillColor="#1E4376"
|
||||
android:pathData="M47.211,168.128C70.531,-34.962 67.471,13.788 94.071,43.818c13.45,-1.52 27.24,-3.47 40.82,-0.67c2.64,0.13 5.42,1.86 7.71,0.18c4.12,-6.27 7.35,-13.54 11.35,-20c12.19,-24.44 12.85,19.54 15.48,26.52c5.23,32.99 10.89,64.46 14.67,97.59c0.31,10.72 5.74,32.92 1.08,33.56c-49.36,5.23 -147.71,3.91 -160.84,-6.3c-15.85,-10.5 -15.18,-35.33 2.03,-43.72c3.63,-2.03 10.68,-3.72 11.94,0.7c-2.41,4.99 -8.79,5.77 -12.12,11.17C16.621,158.948 33.111,168.888 47.211,168.128zM87.841,74.008c-10.42,0.52 -9.59,14.89 -0.07,15.18C98.191,88.668 97.361,74.298 87.841,74.008zM149.121,89.188c10.46,-0.34 9.85,-14.71 0.38,-15.18C139.031,74.348 139.651,88.718 149.121,89.188zM107.871,99.228c2.16,3.48 5.28,3.29 9.79,0.16c3.81,3.17 8.06,3.28 9.18,-0.19c-3.78,1.17 -7.04,0.79 -9.4,-3.49C115.371,100.108 112.071,100.428 107.871,99.228z"
|
||||
tools:ignore="VectorPath" />
|
||||
</group>
|
||||
</vector>
|
||||
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/color_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/color_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
BIN
app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
app/src/main/res/mipmap-hdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
BIN
app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
app/src/main/res/mipmap-mdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
app/src/main/res/mipmap-xhdpi/ic_banner.png
Normal file
|
After Width: | Height: | Size: 6.2 KiB |
BIN
app/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 5.0 KiB |
BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 3.6 KiB |
BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 7.8 KiB |
BIN
app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 5.0 KiB |
BIN
app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
4
app/src/main/res/values-night/themes.xml
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="BootstrapTheme" parent="AppThemeDark" />
|
||||
</resources>
|
||||
4
app/src/main/res/values/colors.xml
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="color_launcher_background">#FFFFFF</color>
|
||||
</resources>
|
||||
4
app/src/main/res/values/ids.xml
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<item name="nf_logcat_status" type="id" />
|
||||
</resources>
|
||||
4
app/src/main/res/values/themes.xml
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="BootstrapTheme" parent="AppThemeLight" />
|
||||
</resources>
|
||||
18
app/src/main/res/xml/full_backup_content.xml
Normal file
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<full-backup-content>
|
||||
<include
|
||||
domain="sharedpref"
|
||||
path="." />
|
||||
<include
|
||||
domain="database"
|
||||
path="." />
|
||||
<include
|
||||
domain="file"
|
||||
path="imported" />
|
||||
<include
|
||||
domain="file"
|
||||
path="pending" />
|
||||
<include
|
||||
domain="file"
|
||||
path="clash/override.json" />
|
||||
</full-backup-content>
|
||||
10
app/src/main/res/xml/network_security_config.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<network-security-config xmlns:tools="http://schemas.android.com/tools"
|
||||
tools:ignore="AcceptsUserCertificates">
|
||||
<base-config>
|
||||
<trust-anchors>
|
||||
<certificates src="system" />
|
||||
<certificates src="user" />
|
||||
</trust-anchors>
|
||||
</base-config>
|
||||
</network-security-config>
|
||||