add android code
add android code
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
plugins {
|
||||
kotlin("android")
|
||||
id("kotlinx-serialization")
|
||||
id("com.android.library")
|
||||
id("com.google.devtools.ksp")
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":core"))
|
||||
implementation(project(":common"))
|
||||
|
||||
ksp(libs.kaidl.compiler)
|
||||
ksp(libs.androidx.room.compiler)
|
||||
|
||||
implementation(libs.kotlin.coroutine)
|
||||
implementation(libs.kotlin.serialization.json)
|
||||
implementation(libs.androidx.core)
|
||||
implementation(libs.androidx.room.runtime)
|
||||
implementation(libs.androidx.room.ktx)
|
||||
implementation(libs.kaidl.runtime)
|
||||
implementation(libs.rikkax.multiprocess)
|
||||
implementation(platform("com.squareup.okhttp3:okhttp-bom:4.10.0"))
|
||||
|
||||
// define any required OkHttp artifacts without version
|
||||
implementation("com.squareup.okhttp3:okhttp")
|
||||
implementation("com.squareup.okhttp3:logging-interceptor")
|
||||
}
|
||||
|
||||
afterEvaluate {
|
||||
android {
|
||||
libraryVariants.forEach {
|
||||
sourceSets[it.name].kotlin.srcDir(buildDir.resolve("generated/ksp/${it.name}/kotlin"))
|
||||
sourceSets[it.name].java.srcDir(buildDir.resolve("generated/ksp/${it.name}/java"))
|
||||
}
|
||||
}
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
# 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
|
||||
@@ -0,0 +1,78 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
package="com.github.kr328.clash.service">
|
||||
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
|
||||
<uses-permission
|
||||
android:name="android.permission.QUERY_ALL_PACKAGES"
|
||||
tools:ignore="QueryAllPackagesPermission" />
|
||||
|
||||
<application>
|
||||
<service
|
||||
android:name=".ClashService"
|
||||
android:exported="false"
|
||||
android:label="@string/clash_meta_for_android"
|
||||
android:process=":background" />
|
||||
<service
|
||||
android:name=".TunService"
|
||||
android:exported="false"
|
||||
android:label="@string/clash_meta_for_android"
|
||||
android:permission="android.permission.BIND_VPN_SERVICE"
|
||||
android:process=":background">
|
||||
<intent-filter>
|
||||
<action android:name="android.net.VpnService" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
<service
|
||||
android:name=".RemoteService"
|
||||
android:exported="false"
|
||||
android:process=":background" />
|
||||
<service
|
||||
android:name=".ProfileWorker"
|
||||
android:exported="false"
|
||||
android:process=":background" />
|
||||
|
||||
<provider
|
||||
android:name=".FilesProvider"
|
||||
android:authorities="${applicationId}.files"
|
||||
android:exported="true"
|
||||
android:grantUriPermissions="true"
|
||||
android:permission="android.permission.MANAGE_DOCUMENTS"
|
||||
android:process=":background">
|
||||
<intent-filter>
|
||||
<action android:name="android.content.action.DOCUMENTS_PROVIDER" />
|
||||
</intent-filter>
|
||||
</provider>
|
||||
<provider
|
||||
android:name=".StatusProvider"
|
||||
android:authorities="${applicationId}.status"
|
||||
android:exported="false"
|
||||
android:process=":background" />
|
||||
<provider
|
||||
android:name=".PreferenceProvider"
|
||||
android:authorities="${applicationId}.settings"
|
||||
android:exported="false"
|
||||
android:process=":background" />
|
||||
|
||||
<receiver
|
||||
android:name=".ProfileReceiver"
|
||||
android:enabled="true"
|
||||
android:exported="true"
|
||||
android:permission="${applicationId}.permission.RECEIVE_BROADCASTS"
|
||||
android:process=":background">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
|
||||
<action android:name="android.intent.action.TIME_SET" />
|
||||
<action android:name="android.intent.action.TIMEZONE_CHANGED" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="{applicationId}.intent.action.PROFILE_REQUEST_UPDATE" />
|
||||
<data android:scheme="uuid" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
</application>
|
||||
</manifest>
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package com.github.kr328.clash.service
|
||||
|
||||
import android.app.Service
|
||||
import com.github.kr328.clash.service.util.cancelAndJoinBlocking
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
|
||||
abstract class BaseService : Service(), CoroutineScope by CoroutineScope(Dispatchers.Default) {
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
|
||||
cancelAndJoinBlocking()
|
||||
}
|
||||
}
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
package com.github.kr328.clash.service
|
||||
|
||||
import android.content.Context
|
||||
import com.github.kr328.clash.common.log.Log
|
||||
import com.github.kr328.clash.core.Clash
|
||||
import com.github.kr328.clash.core.model.*
|
||||
import com.github.kr328.clash.service.data.Selection
|
||||
import com.github.kr328.clash.service.data.SelectionDao
|
||||
import com.github.kr328.clash.service.remote.IClashManager
|
||||
import com.github.kr328.clash.service.remote.ILogObserver
|
||||
import com.github.kr328.clash.service.store.ServiceStore
|
||||
import com.github.kr328.clash.service.util.sendOverrideChanged
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.channels.ReceiveChannel
|
||||
|
||||
class ClashManager(private val context: Context) : IClashManager,
|
||||
CoroutineScope by CoroutineScope(Dispatchers.IO) {
|
||||
private val store = ServiceStore(context)
|
||||
private var logReceiver: ReceiveChannel<LogMessage>? = null
|
||||
|
||||
override fun queryTunnelState(): TunnelState {
|
||||
return Clash.queryTunnelState()
|
||||
}
|
||||
|
||||
override fun queryTrafficTotal(): Long {
|
||||
return Clash.queryTrafficTotal()
|
||||
}
|
||||
|
||||
override fun queryProxyGroupNames(excludeNotSelectable: Boolean): List<String> {
|
||||
return Clash.queryGroupNames(excludeNotSelectable)
|
||||
}
|
||||
|
||||
override fun queryProxyGroup(name: String, proxySort: ProxySort): ProxyGroup {
|
||||
return Clash.queryGroup(name, proxySort)
|
||||
}
|
||||
|
||||
override fun queryConfiguration(): UiConfiguration {
|
||||
return Clash.queryConfiguration()
|
||||
}
|
||||
|
||||
override fun queryProviders(): ProviderList {
|
||||
return ProviderList(Clash.queryProviders())
|
||||
}
|
||||
|
||||
override fun queryOverride(slot: Clash.OverrideSlot): ConfigurationOverride {
|
||||
return Clash.queryOverride(slot)
|
||||
}
|
||||
|
||||
override fun patchSelector(group: String, name: String): Boolean {
|
||||
return Clash.patchSelector(group, name).also {
|
||||
val current = store.activeProfile ?: return@also
|
||||
|
||||
if (it) {
|
||||
SelectionDao().setSelected(Selection(current, group, name))
|
||||
} else {
|
||||
SelectionDao().removeSelected(current, group)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun patchOverride(slot: Clash.OverrideSlot, configuration: ConfigurationOverride) {
|
||||
Clash.patchOverride(slot, configuration)
|
||||
|
||||
context.sendOverrideChanged()
|
||||
}
|
||||
|
||||
override fun clearOverride(slot: Clash.OverrideSlot) {
|
||||
Clash.clearOverride(slot)
|
||||
}
|
||||
|
||||
override suspend fun healthCheck(group: String) {
|
||||
return Clash.healthCheck(group).await()
|
||||
}
|
||||
|
||||
override suspend fun updateProvider(type: Provider.Type, name: String) {
|
||||
return Clash.updateProvider(type, name).await()
|
||||
}
|
||||
|
||||
override fun setLogObserver(observer: ILogObserver?) {
|
||||
synchronized(this) {
|
||||
logReceiver?.apply {
|
||||
cancel()
|
||||
|
||||
Clash.forceGc()
|
||||
}
|
||||
|
||||
if (observer != null) {
|
||||
logReceiver = Clash.subscribeLogcat().also { c ->
|
||||
launch {
|
||||
try {
|
||||
while (isActive) {
|
||||
observer.newItem(c.receive())
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
// intended behavior
|
||||
// ignore
|
||||
} catch (e: Exception) {
|
||||
Log.w("UI crashed", e)
|
||||
} finally {
|
||||
withContext(NonCancellable) {
|
||||
c.cancel()
|
||||
|
||||
Clash.forceGc()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
package com.github.kr328.clash.service
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Binder
|
||||
import android.os.IBinder
|
||||
import com.github.kr328.clash.common.log.Log
|
||||
import com.github.kr328.clash.service.clash.clashRuntime
|
||||
import com.github.kr328.clash.service.clash.module.*
|
||||
import com.github.kr328.clash.service.store.ServiceStore
|
||||
import com.github.kr328.clash.service.util.cancelAndJoinBlocking
|
||||
import com.github.kr328.clash.service.util.sendClashStarted
|
||||
import com.github.kr328.clash.service.util.sendClashStopped
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.selects.select
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class ClashService : BaseService() {
|
||||
private val self: ClashService
|
||||
get() = this
|
||||
|
||||
private var reason: String? = null
|
||||
|
||||
private val runtime = clashRuntime {
|
||||
val store = ServiceStore(self)
|
||||
|
||||
val close = install(CloseModule(self))
|
||||
val config = install(ConfigurationModule(self))
|
||||
val network = install(NetworkObserveModule(self))
|
||||
|
||||
if (store.dynamicNotification)
|
||||
install(DynamicNotificationModule(self))
|
||||
else
|
||||
install(StaticNotificationModule(self))
|
||||
|
||||
install(AppListCacheModule(self))
|
||||
install(TimeZoneModule(self))
|
||||
install(SuspendModule(self))
|
||||
|
||||
try {
|
||||
while (isActive) {
|
||||
val quit = select<Boolean> {
|
||||
close.onEvent {
|
||||
true
|
||||
}
|
||||
config.onEvent {
|
||||
reason = it.message
|
||||
|
||||
true
|
||||
}
|
||||
network.onEvent {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
if (quit) break
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("Create clash runtime: ${e.message}", e)
|
||||
|
||||
reason = e.message
|
||||
} finally {
|
||||
withContext(NonCancellable) {
|
||||
stopSelf()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
if (StatusProvider.serviceRunning)
|
||||
return stopSelf()
|
||||
|
||||
StatusProvider.serviceRunning = true
|
||||
|
||||
StaticNotificationModule.createNotificationChannel(this)
|
||||
StaticNotificationModule.notifyLoadingNotification(this)
|
||||
|
||||
runtime.launch()
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
sendClashStarted()
|
||||
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder {
|
||||
return Binder()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
StatusProvider.serviceRunning = false
|
||||
|
||||
sendClashStopped(reason)
|
||||
|
||||
cancelAndJoinBlocking()
|
||||
|
||||
Log.i("ClashService destroyed: ${reason ?: "successfully"}")
|
||||
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onTrimMemory(level: Int) {
|
||||
super.onTrimMemory(level)
|
||||
|
||||
runtime.requestGc()
|
||||
}
|
||||
}
|
||||
+208
@@ -0,0 +1,208 @@
|
||||
package com.github.kr328.clash.service
|
||||
|
||||
import android.database.Cursor
|
||||
import android.database.MatrixCursor
|
||||
import android.os.Build
|
||||
import android.os.CancellationSignal
|
||||
import android.os.ParcelFileDescriptor
|
||||
import android.provider.DocumentsContract.Root
|
||||
import android.provider.DocumentsProvider
|
||||
import com.github.kr328.clash.common.util.PatternFileName
|
||||
import com.github.kr328.clash.service.document.*
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import java.io.FileNotFoundException
|
||||
import android.provider.DocumentsContract.Document as D
|
||||
|
||||
class FilesProvider : DocumentsProvider() {
|
||||
companion object {
|
||||
private const val DEFAULT_ROOT_ID = "0"
|
||||
|
||||
private val DEFAULT_DOCUMENT_COLUMNS = arrayOf(
|
||||
D.COLUMN_DOCUMENT_ID,
|
||||
D.COLUMN_DISPLAY_NAME,
|
||||
D.COLUMN_MIME_TYPE,
|
||||
D.COLUMN_LAST_MODIFIED,
|
||||
D.COLUMN_SIZE,
|
||||
D.COLUMN_FLAGS
|
||||
)
|
||||
private val DEFAULT_ROOT_COLUMNS = arrayOf(
|
||||
Root.COLUMN_ROOT_ID,
|
||||
Root.COLUMN_FLAGS,
|
||||
Root.COLUMN_ICON,
|
||||
Root.COLUMN_TITLE,
|
||||
Root.COLUMN_SUMMARY,
|
||||
Root.COLUMN_DOCUMENT_ID
|
||||
)
|
||||
|
||||
private val FLAG_VIRTUAL: Int =
|
||||
if (Build.VERSION.SDK_INT >= 24) D.FLAG_VIRTUAL_DOCUMENT else 0
|
||||
}
|
||||
|
||||
private val picker: Picker by lazy {
|
||||
Picker(context!!)
|
||||
}
|
||||
|
||||
override fun openDocument(
|
||||
documentId: String?,
|
||||
mode: String?,
|
||||
signal: CancellationSignal?
|
||||
): ParcelFileDescriptor {
|
||||
val m = ParcelFileDescriptor.parseMode(mode)
|
||||
|
||||
return runBlocking {
|
||||
val path = Paths.resolve(documentId ?: "/")
|
||||
|
||||
val document = picker.pick(path, mode?.requestWrite ?: true)
|
||||
|
||||
require(document is FileDocument) {
|
||||
throw FileNotFoundException("invalid path $documentId")
|
||||
}
|
||||
|
||||
ParcelFileDescriptor.open(document.file, m)
|
||||
}
|
||||
}
|
||||
|
||||
override fun deleteDocument(documentId: String?) {
|
||||
val documentPath = documentId ?: "/"
|
||||
|
||||
runBlocking {
|
||||
val path = Paths.resolve(documentPath)
|
||||
|
||||
if (path.relative == null)
|
||||
throw IllegalArgumentException("invalid path $documentId")
|
||||
|
||||
val document = picker.pick(path, true)
|
||||
|
||||
require(document is FileDocument) {
|
||||
throw FileNotFoundException("invalid path $documentId")
|
||||
}
|
||||
|
||||
document.file.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
override fun renameDocument(documentId: String?, displayName: String?): String {
|
||||
val name = displayName ?: ""
|
||||
|
||||
if (!PatternFileName.matches(name))
|
||||
throw IllegalArgumentException("invalid name $displayName")
|
||||
|
||||
return runBlocking {
|
||||
val path = Paths.resolve(documentId ?: "/")
|
||||
|
||||
if (path.relative == null)
|
||||
throw IllegalArgumentException("unable to rename $documentId")
|
||||
|
||||
val document = picker.pick(path, true)
|
||||
|
||||
require(document is FileDocument) {
|
||||
throw IllegalArgumentException("unable to rename $document")
|
||||
}
|
||||
|
||||
val parent = document.file.parentFile
|
||||
|
||||
require(parent != null) {
|
||||
throw IllegalArgumentException("unable to rename $document")
|
||||
}
|
||||
|
||||
document.file.renameTo(parent.resolve(name))
|
||||
|
||||
path.copy(relative = path.relative.dropLast(1) + name).toString()
|
||||
}
|
||||
}
|
||||
|
||||
override fun queryChildDocuments(
|
||||
parentDocumentId: String?,
|
||||
projection: Array<out String>?,
|
||||
sortOrder: String?
|
||||
): Cursor {
|
||||
return runBlocking {
|
||||
try {
|
||||
val doc = parentDocumentId ?: "/"
|
||||
val path = Paths.resolve(doc)
|
||||
val documents = picker.list(path)
|
||||
|
||||
MatrixCursor(resolveDocumentProjection(projection)).apply {
|
||||
documents.forEach {
|
||||
newRow().applyDocument(it)
|
||||
.add(D.COLUMN_DOCUMENT_ID, "$doc/${it.id}")
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
MatrixCursor(resolveDocumentProjection(projection))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun queryDocument(documentId: String?, projection: Array<out String>?): Cursor {
|
||||
return runBlocking {
|
||||
try {
|
||||
val doc = documentId ?: "/"
|
||||
val path = Paths.resolve(doc)
|
||||
val document = picker.pick(path, false)
|
||||
|
||||
MatrixCursor(resolveDocumentProjection(projection)).apply {
|
||||
newRow().applyDocument(document).add(D.COLUMN_DOCUMENT_ID, doc)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
MatrixCursor(resolveDocumentProjection(projection))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate(): Boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
override fun queryRoots(projection: Array<out String>?): Cursor {
|
||||
val flags = Root.FLAG_LOCAL_ONLY or Root.FLAG_SUPPORTS_IS_CHILD
|
||||
|
||||
return MatrixCursor(projection ?: DEFAULT_ROOT_COLUMNS).apply {
|
||||
newRow().apply {
|
||||
add(Root.COLUMN_ROOT_ID, DEFAULT_ROOT_ID)
|
||||
add(Root.COLUMN_FLAGS, flags)
|
||||
add(Root.COLUMN_ICON, R.drawable.ic_logo_service)
|
||||
add(Root.COLUMN_TITLE, context!!.getString(R.string.clash_meta_for_android))
|
||||
add(Root.COLUMN_SUMMARY, context!!.getString(R.string.profiles_and_providers))
|
||||
add(Root.COLUMN_DOCUMENT_ID, "/")
|
||||
add(Root.COLUMN_MIME_TYPES, D.MIME_TYPE_DIR)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun isChildDocument(parentDocumentId: String?, documentId: String?): Boolean {
|
||||
if (parentDocumentId == null || documentId == null)
|
||||
return false
|
||||
|
||||
return documentId.startsWith(parentDocumentId)
|
||||
}
|
||||
|
||||
private fun MatrixCursor.RowBuilder.applyDocument(document: Document): MatrixCursor.RowBuilder {
|
||||
var flags = 0
|
||||
|
||||
document.flags.forEach {
|
||||
flags = when (it) {
|
||||
Flag.Writable -> flags or D.FLAG_SUPPORTS_WRITE
|
||||
Flag.Deletable -> flags or D.FLAG_SUPPORTS_DELETE
|
||||
Flag.Virtual -> flags or FLAG_VIRTUAL
|
||||
}
|
||||
}
|
||||
|
||||
add(D.COLUMN_DISPLAY_NAME, document.name)
|
||||
add(D.COLUMN_MIME_TYPE, document.mimeType)
|
||||
add(D.COLUMN_LAST_MODIFIED, document.updatedAt)
|
||||
add(D.COLUMN_SIZE, document.size)
|
||||
add(D.COLUMN_FLAGS, flags)
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
private fun resolveDocumentProjection(projection: Array<out String>?): Array<out String> {
|
||||
return projection ?: DEFAULT_DOCUMENT_COLUMNS
|
||||
}
|
||||
|
||||
private val String.requestWrite: Boolean
|
||||
get() {
|
||||
return contains("w", ignoreCase = true)
|
||||
}
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
package com.github.kr328.clash.service
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import com.github.kr328.clash.common.constants.Authorities
|
||||
import rikka.preference.MultiProcessPreference
|
||||
import rikka.preference.PreferenceProvider
|
||||
|
||||
class PreferenceProvider : PreferenceProvider() {
|
||||
override fun onCreatePreference(context: Context): SharedPreferences {
|
||||
return context.getSharedPreferences(FILE_NAME, Context.MODE_PRIVATE)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val FILE_NAME = "service"
|
||||
|
||||
fun createSharedPreferencesFromContext(context: Context): SharedPreferences {
|
||||
return when (context) {
|
||||
is BaseService, is TunService ->
|
||||
context.getSharedPreferences(
|
||||
FILE_NAME,
|
||||
Context.MODE_PRIVATE
|
||||
)
|
||||
else ->
|
||||
MultiProcessPreference(
|
||||
context,
|
||||
Authorities.SETTINGS_PROVIDER
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+311
@@ -0,0 +1,311 @@
|
||||
package com.github.kr328.clash.service
|
||||
|
||||
import android.content.Context
|
||||
import com.github.kr328.clash.service.data.Database
|
||||
import com.github.kr328.clash.service.data.Imported
|
||||
import com.github.kr328.clash.service.data.ImportedDao
|
||||
import com.github.kr328.clash.service.data.Pending
|
||||
import com.github.kr328.clash.service.data.PendingDao
|
||||
import com.github.kr328.clash.service.model.Profile
|
||||
import com.github.kr328.clash.service.remote.IFetchObserver
|
||||
import com.github.kr328.clash.service.remote.IProfileManager
|
||||
import com.github.kr328.clash.service.store.ServiceStore
|
||||
import com.github.kr328.clash.service.util.directoryLastModified
|
||||
import com.github.kr328.clash.service.util.generateProfileUUID
|
||||
import com.github.kr328.clash.service.util.importedDir
|
||||
import com.github.kr328.clash.service.util.pendingDir
|
||||
import com.github.kr328.clash.service.util.sendProfileChanged
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.io.FileNotFoundException
|
||||
import java.math.BigDecimal
|
||||
import java.util.*
|
||||
|
||||
class ProfileManager(private val context: Context) : IProfileManager,
|
||||
CoroutineScope by CoroutineScope(Dispatchers.IO) {
|
||||
private val store = ServiceStore(context)
|
||||
|
||||
init {
|
||||
launch {
|
||||
Database.database //.init
|
||||
|
||||
ProfileReceiver.rescheduleAll(context)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun create(type: Profile.Type, name: String, source: String): UUID {
|
||||
val uuid = generateProfileUUID()
|
||||
val pending = Pending(
|
||||
uuid = uuid,
|
||||
name = name,
|
||||
type = type,
|
||||
source = source,
|
||||
interval = 0,
|
||||
upload = 0,
|
||||
total = 0,
|
||||
download = 0,
|
||||
expire = 0,
|
||||
)
|
||||
|
||||
PendingDao().insert(pending)
|
||||
|
||||
context.pendingDir.resolve(uuid.toString()).apply {
|
||||
deleteRecursively()
|
||||
mkdirs()
|
||||
|
||||
@Suppress("BlockingMethodInNonBlockingContext")
|
||||
resolve("config.yaml").createNewFile()
|
||||
resolve("providers").mkdir()
|
||||
}
|
||||
|
||||
return uuid
|
||||
}
|
||||
|
||||
override suspend fun clone(uuid: UUID): UUID {
|
||||
val newUUID = generateProfileUUID()
|
||||
|
||||
val imported = ImportedDao().queryByUUID(uuid)
|
||||
?: throw FileNotFoundException("profile $uuid not found")
|
||||
|
||||
val pending = Pending(
|
||||
uuid = newUUID,
|
||||
name = imported.name,
|
||||
type = Profile.Type.File,
|
||||
source = imported.source,
|
||||
interval = imported.interval,
|
||||
upload = imported.upload,
|
||||
total = imported.total,
|
||||
download = imported.download,
|
||||
expire = imported.expire,
|
||||
)
|
||||
|
||||
cloneImportedFiles(uuid, newUUID)
|
||||
|
||||
PendingDao().insert(pending)
|
||||
|
||||
return newUUID
|
||||
}
|
||||
|
||||
override suspend fun patch(uuid: UUID, name: String, source: String, interval: Long) {
|
||||
val pending = PendingDao().queryByUUID(uuid)
|
||||
|
||||
if (pending == null) {
|
||||
val imported = ImportedDao().queryByUUID(uuid)
|
||||
?: throw FileNotFoundException("profile $uuid not found")
|
||||
|
||||
cloneImportedFiles(uuid)
|
||||
|
||||
PendingDao().insert(
|
||||
Pending(
|
||||
uuid = imported.uuid,
|
||||
name = name,
|
||||
type = imported.type,
|
||||
source = source,
|
||||
interval = interval,
|
||||
upload = 0,
|
||||
total = 0,
|
||||
download = 0,
|
||||
expire = 0,
|
||||
)
|
||||
)
|
||||
} else {
|
||||
val newPending = pending.copy(
|
||||
name = name,
|
||||
source = source,
|
||||
interval = interval,
|
||||
upload = 0,
|
||||
total = 0,
|
||||
download = 0,
|
||||
expire = 0,
|
||||
)
|
||||
|
||||
PendingDao().update(newPending)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun update(uuid: UUID) {
|
||||
scheduleUpdate(uuid, true)
|
||||
ImportedDao().queryByUUID(uuid)?.let {
|
||||
if (it.type == Profile.Type.Url && it.source.startsWith("https://",true)) {
|
||||
updateFlow(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun updateFlow(old: Imported) {
|
||||
val client = OkHttpClient()
|
||||
try {
|
||||
val request = Request.Builder()
|
||||
.url(old.source)
|
||||
.header("User-Agent", "ClashforWindows/0.19.23")
|
||||
.build()
|
||||
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful || response.headers["subscription-userinfo"] == null) return
|
||||
|
||||
var upload: Long = 0
|
||||
var download: Long = 0
|
||||
var total: Long = 0
|
||||
var expire: Long = 0
|
||||
|
||||
val userinfo = response.headers["subscription-userinfo"]
|
||||
if (response.isSuccessful && userinfo != null) {
|
||||
|
||||
val flags = userinfo.split(";")
|
||||
for (flag in flags) {
|
||||
val info = flag.split("=")
|
||||
when {
|
||||
info[0].contains("upload") && info[1].isNotEmpty() -> upload =
|
||||
BigDecimal(info[1]).longValueExact()
|
||||
|
||||
info[0].contains("download") && info[1].isNotEmpty() -> download =
|
||||
BigDecimal(info[1]).longValueExact()
|
||||
|
||||
info[0].contains("total") && info[1].isNotEmpty() -> total =
|
||||
BigDecimal(info[1]).longValueExact()
|
||||
|
||||
info[0].contains("expire") && info[1].isNotEmpty() -> {
|
||||
if (info[1].isNotEmpty()) {
|
||||
expire = (info[1].toDouble()*1000).toLong()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val new = Imported(
|
||||
old.uuid,
|
||||
old.name,
|
||||
old.type,
|
||||
old.source,
|
||||
old.interval,
|
||||
upload,
|
||||
download,
|
||||
total,
|
||||
expire,
|
||||
old?.createdAt ?: System.currentTimeMillis()
|
||||
)
|
||||
|
||||
if (old != null) {
|
||||
ImportedDao().update(new)
|
||||
} else {
|
||||
ImportedDao().insert(new)
|
||||
}
|
||||
|
||||
PendingDao().remove(new.uuid)
|
||||
context.sendProfileChanged(new.uuid)
|
||||
// println(response.body!!.string())
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
System.out.println(e)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun commit(uuid: UUID, callback: IFetchObserver?) {
|
||||
ProfileProcessor.apply(context, uuid, callback)
|
||||
|
||||
scheduleUpdate(uuid, false)
|
||||
}
|
||||
|
||||
override suspend fun release(uuid: UUID) {
|
||||
ProfileProcessor.release(context, uuid)
|
||||
}
|
||||
|
||||
override suspend fun delete(uuid: UUID) {
|
||||
ImportedDao().queryByUUID(uuid)?.also {
|
||||
ProfileReceiver.cancelNext(context, it)
|
||||
}
|
||||
|
||||
ProfileProcessor.delete(context, uuid)
|
||||
}
|
||||
|
||||
override suspend fun queryByUUID(uuid: UUID): Profile? {
|
||||
return resolveProfile(uuid)
|
||||
}
|
||||
|
||||
override suspend fun queryAll(): List<Profile> {
|
||||
val uuids = withContext(Dispatchers.IO) {
|
||||
(ImportedDao().queryAllUUIDs() + PendingDao().queryAllUUIDs()).distinct()
|
||||
}
|
||||
|
||||
return uuids.mapNotNull { resolveProfile(it) }
|
||||
}
|
||||
|
||||
override suspend fun queryActive(): Profile? {
|
||||
val active = store.activeProfile ?: return null
|
||||
|
||||
return if (ImportedDao().exists(active)) {
|
||||
resolveProfile(active)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun setActive(profile: Profile) {
|
||||
ProfileProcessor.active(context, profile.uuid)
|
||||
}
|
||||
|
||||
private suspend fun resolveProfile(uuid: UUID): Profile? {
|
||||
val imported = ImportedDao().queryByUUID(uuid)
|
||||
val pending = PendingDao().queryByUUID(uuid)
|
||||
|
||||
val active = store.activeProfile
|
||||
val name = pending?.name ?: imported?.name ?: return null
|
||||
val type = pending?.type ?: imported?.type ?: return null
|
||||
val source = pending?.source ?: imported?.source ?: return null
|
||||
val interval = pending?.interval ?: imported?.interval ?: return null
|
||||
val upload = pending?.upload ?: imported?.upload ?: return null
|
||||
val download = pending?.download ?: imported?.download ?: return null
|
||||
val total = pending?.total ?: imported?.total ?: return null
|
||||
val expire = pending?.expire ?: imported?.expire ?: return null
|
||||
|
||||
return Profile(
|
||||
uuid,
|
||||
name,
|
||||
type,
|
||||
source,
|
||||
active != null && imported?.uuid == active,
|
||||
interval,
|
||||
upload,
|
||||
download,
|
||||
total,
|
||||
expire,
|
||||
resolveUpdatedAt(uuid),
|
||||
imported != null,
|
||||
pending != null
|
||||
)
|
||||
}
|
||||
|
||||
private fun resolveUpdatedAt(uuid: UUID): Long {
|
||||
return context.pendingDir.resolve(uuid.toString()).directoryLastModified
|
||||
?: context.importedDir.resolve(uuid.toString()).directoryLastModified
|
||||
?: -1
|
||||
}
|
||||
|
||||
private fun cloneImportedFiles(source: UUID, target: UUID = source) {
|
||||
val s = context.importedDir.resolve(source.toString())
|
||||
val t = context.pendingDir.resolve(target.toString())
|
||||
|
||||
if (!s.exists())
|
||||
throw FileNotFoundException("profile $source not found")
|
||||
|
||||
t.deleteRecursively()
|
||||
|
||||
s.copyRecursively(t)
|
||||
}
|
||||
|
||||
private suspend fun scheduleUpdate(uuid: UUID, startImmediately: Boolean) {
|
||||
val imported = ImportedDao().queryByUUID(uuid) ?: return
|
||||
|
||||
if (startImmediately) {
|
||||
ProfileReceiver.schedule(context, imported)
|
||||
} else {
|
||||
ProfileReceiver.scheduleNext(context, imported)
|
||||
}
|
||||
}
|
||||
}
|
||||
+261
@@ -0,0 +1,261 @@
|
||||
package com.github.kr328.clash.service
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import com.github.kr328.clash.common.log.Log
|
||||
import com.github.kr328.clash.core.Clash
|
||||
import com.github.kr328.clash.service.data.Imported
|
||||
import com.github.kr328.clash.service.data.ImportedDao
|
||||
import com.github.kr328.clash.service.data.Pending
|
||||
import com.github.kr328.clash.service.data.PendingDao
|
||||
import com.github.kr328.clash.service.model.Profile
|
||||
import com.github.kr328.clash.service.remote.IFetchObserver
|
||||
import com.github.kr328.clash.service.store.ServiceStore
|
||||
import com.github.kr328.clash.service.util.importedDir
|
||||
import com.github.kr328.clash.service.util.pendingDir
|
||||
import com.github.kr328.clash.service.util.processingDir
|
||||
import com.github.kr328.clash.service.util.sendProfileChanged
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.net.URL
|
||||
import java.util.*
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
object ProfileProcessor {
|
||||
private val profileLock = Mutex()
|
||||
private val processLock = Mutex()
|
||||
|
||||
suspend fun apply(context: Context, uuid: UUID, callback: IFetchObserver? = null) {
|
||||
withContext(NonCancellable) {
|
||||
processLock.withLock {
|
||||
val snapshot = profileLock.withLock {
|
||||
val pending = PendingDao().queryByUUID(uuid)
|
||||
?: throw IllegalArgumentException("profile $uuid not found")
|
||||
|
||||
pending.enforceFieldValid()
|
||||
|
||||
context.processingDir.deleteRecursively()
|
||||
context.processingDir.mkdirs()
|
||||
|
||||
context.pendingDir.resolve(pending.uuid.toString())
|
||||
.copyRecursively(context.processingDir, overwrite = true)
|
||||
|
||||
pending
|
||||
}
|
||||
|
||||
val force = snapshot.type != Profile.Type.File
|
||||
var cb = callback
|
||||
|
||||
Clash.fetchAndValid(context.processingDir, snapshot.source, force) {
|
||||
try {
|
||||
cb?.updateStatus(it)
|
||||
} catch (e: Exception) {
|
||||
cb = null
|
||||
|
||||
Log.w("Report fetch status: $e", e)
|
||||
}
|
||||
}.await()
|
||||
|
||||
profileLock.withLock {
|
||||
if (PendingDao().queryByUUID(snapshot.uuid) == snapshot) {
|
||||
context.importedDir.resolve(snapshot.uuid.toString())
|
||||
.deleteRecursively()
|
||||
context.processingDir
|
||||
.copyRecursively(context.importedDir.resolve(snapshot.uuid.toString()))
|
||||
|
||||
val old = ImportedDao().queryByUUID(snapshot.uuid)
|
||||
var upload: Long = 0
|
||||
var download: Long = 0
|
||||
var total: Long = 0
|
||||
var expire: Long = 0
|
||||
if (snapshot?.type == Profile.Type.Url) {
|
||||
if (snapshot.source.startsWith("https://", true)) {
|
||||
val client = OkHttpClient()
|
||||
val request = Request.Builder()
|
||||
.url(snapshot.source)
|
||||
.header("User-Agent", "ClashforWindows/0.19.23")
|
||||
.build()
|
||||
|
||||
client.newCall(request).execute().use { response ->
|
||||
val userinfo = response.headers["subscription-userinfo"]
|
||||
if (response.isSuccessful && userinfo != null) {
|
||||
val flags = userinfo.split(";")
|
||||
for (flag in flags) {
|
||||
val info = flag.split("=")
|
||||
when {
|
||||
info[0].contains("upload") && info[1].isNotEmpty() -> upload =
|
||||
info[1].toLong()
|
||||
|
||||
info[0].contains("download") && info[1].isNotEmpty() -> download =
|
||||
info[1].toLong()
|
||||
|
||||
info[0].contains("total") && info[1].isNotEmpty() -> total =
|
||||
info[1].toLong()
|
||||
|
||||
info[0].contains("expire") && info[1].isNotEmpty() -> expire =
|
||||
(info[1].toDouble() * 1000).toLong()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val new = Imported(
|
||||
snapshot.uuid,
|
||||
snapshot.name,
|
||||
snapshot.type,
|
||||
snapshot.source,
|
||||
snapshot.interval,
|
||||
upload,
|
||||
download,
|
||||
total,
|
||||
expire,
|
||||
old?.createdAt ?: System.currentTimeMillis()
|
||||
)
|
||||
if (old != null) {
|
||||
ImportedDao().update(new)
|
||||
} else {
|
||||
ImportedDao().insert(new)
|
||||
}
|
||||
|
||||
PendingDao().remove(snapshot.uuid)
|
||||
|
||||
context.pendingDir.resolve(snapshot.uuid.toString())
|
||||
.deleteRecursively()
|
||||
|
||||
context.sendProfileChanged(snapshot.uuid)
|
||||
} else if (snapshot?.type == Profile.Type.File) {
|
||||
val new = Imported(
|
||||
snapshot.uuid,
|
||||
snapshot.name,
|
||||
snapshot.type,
|
||||
snapshot.source,
|
||||
snapshot.interval,
|
||||
upload,
|
||||
download,
|
||||
total,
|
||||
expire,
|
||||
old?.createdAt ?: System.currentTimeMillis()
|
||||
)
|
||||
if (old != null) {
|
||||
ImportedDao().update(new)
|
||||
} else {
|
||||
ImportedDao().insert(new)
|
||||
}
|
||||
|
||||
PendingDao().remove(snapshot.uuid)
|
||||
|
||||
context.pendingDir.resolve(snapshot.uuid.toString())
|
||||
.deleteRecursively()
|
||||
|
||||
context.sendProfileChanged(snapshot.uuid)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun update(context: Context, uuid: UUID, callback: IFetchObserver?) {
|
||||
withContext(NonCancellable) {
|
||||
processLock.withLock {
|
||||
val snapshot = profileLock.withLock {
|
||||
val imported = ImportedDao().queryByUUID(uuid)
|
||||
?: throw IllegalArgumentException("profile $uuid not found")
|
||||
|
||||
context.processingDir.deleteRecursively()
|
||||
context.processingDir.mkdirs()
|
||||
|
||||
context.importedDir.resolve(imported.uuid.toString())
|
||||
.copyRecursively(context.processingDir, overwrite = true)
|
||||
|
||||
imported
|
||||
}
|
||||
|
||||
var cb = callback
|
||||
|
||||
Clash.fetchAndValid(context.processingDir, snapshot.source, true) {
|
||||
try {
|
||||
cb?.updateStatus(it)
|
||||
} catch (e: Exception) {
|
||||
cb = null
|
||||
|
||||
Log.w("Report fetch status: $e", e)
|
||||
}
|
||||
}.await()
|
||||
|
||||
profileLock.withLock {
|
||||
if (ImportedDao().exists(snapshot.uuid)) {
|
||||
context.importedDir.resolve(snapshot.uuid.toString()).deleteRecursively()
|
||||
context.processingDir
|
||||
.copyRecursively(context.importedDir.resolve(snapshot.uuid.toString()))
|
||||
|
||||
context.sendProfileChanged(snapshot.uuid)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun delete(context: Context, uuid: UUID) {
|
||||
withContext(NonCancellable) {
|
||||
profileLock.withLock {
|
||||
ImportedDao().remove(uuid)
|
||||
PendingDao().remove(uuid)
|
||||
|
||||
val pending = context.pendingDir.resolve(uuid.toString())
|
||||
val imported = context.importedDir.resolve(uuid.toString())
|
||||
|
||||
pending.deleteRecursively()
|
||||
imported.deleteRecursively()
|
||||
|
||||
context.sendProfileChanged(uuid)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun release(context: Context, uuid: UUID): Boolean {
|
||||
return withContext(NonCancellable) {
|
||||
profileLock.withLock {
|
||||
PendingDao().remove(uuid)
|
||||
|
||||
context.pendingDir.resolve(uuid.toString()).deleteRecursively()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun active(context: Context, uuid: UUID) {
|
||||
withContext(NonCancellable) {
|
||||
profileLock.withLock {
|
||||
if (ImportedDao().exists(uuid)) {
|
||||
val store = ServiceStore(context)
|
||||
|
||||
store.activeProfile = uuid
|
||||
|
||||
context.sendProfileChanged(uuid)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Pending.enforceFieldValid() {
|
||||
val scheme = Uri.parse(source)?.scheme?.lowercase(Locale.getDefault())
|
||||
|
||||
when {
|
||||
name.isBlank() ->
|
||||
throw IllegalArgumentException("Empty name")
|
||||
|
||||
source.isEmpty() && type != Profile.Type.File ->
|
||||
throw IllegalArgumentException("Invalid url")
|
||||
|
||||
source.isNotEmpty() && scheme != "https" && scheme != "http" && scheme != "content" ->
|
||||
throw IllegalArgumentException("Unsupported url $source")
|
||||
|
||||
interval != 0L && TimeUnit.MILLISECONDS.toMinutes(interval) < 15 ->
|
||||
throw IllegalArgumentException("Invalid interval")
|
||||
}
|
||||
}
|
||||
}
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
package com.github.kr328.clash.service
|
||||
|
||||
import android.app.AlarmManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.core.content.getSystemService
|
||||
import com.github.kr328.clash.common.Global
|
||||
import com.github.kr328.clash.common.compat.pendingIntentFlags
|
||||
import com.github.kr328.clash.common.compat.startForegroundServiceCompat
|
||||
import com.github.kr328.clash.common.constants.Intents
|
||||
import com.github.kr328.clash.common.log.Log
|
||||
import com.github.kr328.clash.common.util.componentName
|
||||
import com.github.kr328.clash.common.util.setUUID
|
||||
import com.github.kr328.clash.service.data.Imported
|
||||
import com.github.kr328.clash.service.data.ImportedDao
|
||||
import com.github.kr328.clash.service.model.Profile
|
||||
import com.github.kr328.clash.service.util.importedDir
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class ProfileReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
when (intent.action) {
|
||||
Intent.ACTION_BOOT_COMPLETED, Intent.ACTION_MY_PACKAGE_REPLACED,
|
||||
Intent.ACTION_TIMEZONE_CHANGED, Intent.ACTION_TIME_CHANGED -> {
|
||||
Global.launch {
|
||||
reset()
|
||||
|
||||
val service = Intent(Intents.ACTION_PROFILE_SCHEDULE_UPDATES)
|
||||
.setComponent(ProfileWorker::class.componentName)
|
||||
|
||||
context.startForegroundServiceCompat(service)
|
||||
}
|
||||
}
|
||||
Intents.ACTION_PROFILE_REQUEST_UPDATE -> {
|
||||
val redirect = intent.setComponent(ProfileWorker::class.componentName)
|
||||
|
||||
context.startForegroundServiceCompat(redirect)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val lock = Mutex()
|
||||
private var initialized: Boolean = false
|
||||
|
||||
suspend fun rescheduleAll(context: Context) = lock.withLock {
|
||||
if (initialized)
|
||||
return
|
||||
|
||||
initialized = true
|
||||
|
||||
Log.i("Reschedule all profiles update")
|
||||
|
||||
ImportedDao().queryAllUUIDs()
|
||||
.mapNotNull { ImportedDao().queryByUUID(it) }
|
||||
.filter { it.type != Profile.Type.File }
|
||||
.forEach { scheduleNext(context, it) }
|
||||
}
|
||||
|
||||
fun cancelNext(context: Context, imported: Imported) {
|
||||
val intent = pendingIntentOf(context, imported)
|
||||
|
||||
context.getSystemService<AlarmManager>()?.cancel(intent)
|
||||
}
|
||||
|
||||
fun schedule(context: Context, imported: Imported) {
|
||||
val intent = pendingIntentOf(context, imported)
|
||||
|
||||
context.getSystemService<AlarmManager>()?.cancel(intent)
|
||||
|
||||
intent.send(context, 0, null)
|
||||
}
|
||||
|
||||
fun scheduleNext(context: Context, imported: Imported) {
|
||||
val intent = pendingIntentOf(context, imported)
|
||||
|
||||
context.getSystemService<AlarmManager>()?.cancel(intent)
|
||||
|
||||
if (imported.interval < TimeUnit.MINUTES.toMillis(15))
|
||||
return
|
||||
|
||||
val current = System.currentTimeMillis()
|
||||
val last = context.importedDir
|
||||
.resolve(imported.uuid.toString())
|
||||
.resolve("config.yaml")
|
||||
.lastModified()
|
||||
|
||||
// file not existed
|
||||
if (last < 0)
|
||||
return
|
||||
|
||||
val interval = (imported.interval - (current - last)).coerceAtLeast(0)
|
||||
|
||||
context.getSystemService<AlarmManager>()
|
||||
?.set(AlarmManager.RTC, current + interval, intent)
|
||||
}
|
||||
|
||||
private suspend fun reset() = lock.withLock {
|
||||
initialized = false
|
||||
}
|
||||
|
||||
private fun pendingIntentOf(context: Context, imported: Imported): PendingIntent {
|
||||
val intent = Intent(Intents.ACTION_PROFILE_REQUEST_UPDATE)
|
||||
.setComponent(ProfileReceiver::class.componentName)
|
||||
.setUUID(imported.uuid)
|
||||
|
||||
return PendingIntent.getBroadcast(
|
||||
context,
|
||||
0,
|
||||
intent,
|
||||
pendingIntentFlags(PendingIntent.FLAG_UPDATE_CURRENT)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+211
@@ -0,0 +1,211 @@
|
||||
package com.github.kr328.clash.service
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.content.Intent
|
||||
import android.os.Binder
|
||||
import android.os.IBinder
|
||||
import androidx.core.app.NotificationChannelCompat
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import com.github.kr328.clash.common.compat.getColorCompat
|
||||
import com.github.kr328.clash.common.compat.pendingIntentFlags
|
||||
import com.github.kr328.clash.common.constants.Components
|
||||
import com.github.kr328.clash.common.constants.Intents
|
||||
import com.github.kr328.clash.common.id.UndefinedIds
|
||||
import com.github.kr328.clash.common.util.setUUID
|
||||
import com.github.kr328.clash.common.util.uuid
|
||||
import com.github.kr328.clash.service.data.ImportedDao
|
||||
import com.github.kr328.clash.service.util.sendProfileUpdateCompleted
|
||||
import com.github.kr328.clash.service.util.sendProfileUpdateFailed
|
||||
import kotlinx.coroutines.*
|
||||
import java.util.*
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class ProfileWorker : BaseService() {
|
||||
private val service: ProfileWorker
|
||||
get() = this
|
||||
|
||||
private val jobs = mutableListOf<Job>()
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
createChannels()
|
||||
|
||||
foreground()
|
||||
|
||||
launch {
|
||||
delay(TimeUnit.SECONDS.toMillis(10))
|
||||
|
||||
while (true) {
|
||||
jobs.removeFirstOrNull()?.join() ?: break
|
||||
}
|
||||
|
||||
stopSelf()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
stopForeground(true)
|
||||
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
super.onStartCommand(intent, flags, startId)
|
||||
|
||||
when (intent?.action) {
|
||||
Intents.ACTION_PROFILE_REQUEST_UPDATE -> {
|
||||
intent.uuid?.also {
|
||||
val job = launch {
|
||||
run(it)
|
||||
}
|
||||
|
||||
jobs.add(job)
|
||||
}
|
||||
}
|
||||
Intents.ACTION_PROFILE_SCHEDULE_UPDATES -> {
|
||||
val job = launch {
|
||||
ProfileReceiver.rescheduleAll(service)
|
||||
|
||||
delay(TimeUnit.SECONDS.toMillis(30))
|
||||
}
|
||||
|
||||
jobs.add(job)
|
||||
}
|
||||
}
|
||||
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
private suspend fun run(uuid: UUID) {
|
||||
val imported = ImportedDao().queryByUUID(uuid) ?: return
|
||||
|
||||
try {
|
||||
processing(imported.name) {
|
||||
ProfileProcessor.update(this, imported.uuid, null)
|
||||
}
|
||||
|
||||
completed(imported.uuid, imported.name)
|
||||
|
||||
ProfileReceiver.scheduleNext(this, imported)
|
||||
} catch (e: Exception) {
|
||||
failed(imported.uuid, imported.name, e.message ?: "Unknown")
|
||||
}
|
||||
}
|
||||
|
||||
private fun createChannels() {
|
||||
NotificationManagerCompat.from(this).createNotificationChannelsCompat(
|
||||
listOf(
|
||||
NotificationChannelCompat.Builder(
|
||||
SERVICE_CHANNEL,
|
||||
NotificationManagerCompat.IMPORTANCE_LOW
|
||||
).setName(getString(R.string.profile_service_status)).build(),
|
||||
NotificationChannelCompat.Builder(
|
||||
STATUS_CHANNEL,
|
||||
NotificationManagerCompat.IMPORTANCE_LOW
|
||||
).setName(getString(R.string.profile_process_status)).build(),
|
||||
NotificationChannelCompat.Builder(
|
||||
RESULT_CHANNEL,
|
||||
NotificationManagerCompat.IMPORTANCE_DEFAULT
|
||||
).setName(getString(R.string.profile_process_result)).build()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun foreground() {
|
||||
val notification = NotificationCompat.Builder(this, SERVICE_CHANNEL)
|
||||
.setContentTitle(getString(R.string.profile_updater))
|
||||
.setContentText(getString(R.string.running))
|
||||
.setColor(getColorCompat(R.color.color_clash))
|
||||
.setSmallIcon(R.drawable.ic_logo_service)
|
||||
.setOngoing(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.build()
|
||||
|
||||
startForeground(R.id.nf_profile_worker, notification)
|
||||
}
|
||||
|
||||
private suspend inline fun processing(name: String, block: () -> Unit) {
|
||||
val id = UndefinedIds.next()
|
||||
|
||||
val notification = NotificationCompat.Builder(this, STATUS_CHANNEL)
|
||||
.setContentTitle(getString(R.string.profile_updating))
|
||||
.setContentText(name)
|
||||
.setColor(getColorCompat(R.color.color_clash))
|
||||
.setSmallIcon(R.drawable.ic_logo_service)
|
||||
.setOngoing(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setGroup(STATUS_CHANNEL)
|
||||
.build()
|
||||
|
||||
NotificationManagerCompat.from(applicationContext)
|
||||
.notify(id, notification)
|
||||
try {
|
||||
block()
|
||||
} finally {
|
||||
withContext(NonCancellable) {
|
||||
NotificationManagerCompat.from(applicationContext)
|
||||
.cancel(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun resultBuilder(id: Int, uuid: UUID): NotificationCompat.Builder {
|
||||
val intent = PendingIntent.getActivity(
|
||||
this,
|
||||
id,
|
||||
Intent().setComponent(Components.PROPERTIES_ACTIVITY).setUUID(uuid),
|
||||
pendingIntentFlags(PendingIntent.FLAG_UPDATE_CURRENT)
|
||||
)
|
||||
|
||||
return NotificationCompat.Builder(this, RESULT_CHANNEL)
|
||||
.setColor(getColorCompat(R.color.color_clash))
|
||||
.setSmallIcon(R.drawable.ic_logo_service)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setContentIntent(intent)
|
||||
.setAutoCancel(true)
|
||||
.setGroup(RESULT_CHANNEL)
|
||||
}
|
||||
|
||||
private fun completed(uuid: UUID, name: String) {
|
||||
val id = UndefinedIds.next()
|
||||
|
||||
val notification = resultBuilder(id, uuid)
|
||||
.setContentTitle(getString(R.string.update_successfully))
|
||||
.setContentText(getString(R.string.format_update_complete, name))
|
||||
.build()
|
||||
|
||||
NotificationManagerCompat.from(this)
|
||||
.notify(id, notification)
|
||||
|
||||
sendProfileUpdateCompleted(uuid)
|
||||
}
|
||||
|
||||
private fun failed(uuid: UUID, name: String, reason: String) {
|
||||
val id = UndefinedIds.next()
|
||||
|
||||
val content = getString(R.string.format_update_failure, name, reason)
|
||||
|
||||
val notification = resultBuilder(id, uuid)
|
||||
.setContentTitle(getString(R.string.update_failure))
|
||||
.setContentText(content)
|
||||
.setStyle(NotificationCompat.BigTextStyle().bigText(content))
|
||||
.build()
|
||||
|
||||
NotificationManagerCompat.from(this)
|
||||
.notify(id, notification)
|
||||
|
||||
sendProfileUpdateFailed(uuid, reason)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val SERVICE_CHANNEL = "profile_service_channel"
|
||||
private const val STATUS_CHANNEL = "profile_status_channel"
|
||||
private const val RESULT_CHANNEL = "profile_result_channel"
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder {
|
||||
return Binder()
|
||||
}
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
package com.github.kr328.clash.service
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.IBinder
|
||||
import com.github.kr328.clash.service.remote.IClashManager
|
||||
import com.github.kr328.clash.service.remote.IRemoteService
|
||||
import com.github.kr328.clash.service.remote.IProfileManager
|
||||
import com.github.kr328.clash.service.remote.wrap
|
||||
import com.github.kr328.clash.service.util.cancelAndJoinBlocking
|
||||
|
||||
class RemoteService : BaseService(), IRemoteService {
|
||||
private val binder = this.wrap()
|
||||
|
||||
private var clash: ClashManager? = null
|
||||
private var profile: ProfileManager? = null
|
||||
private var clashBinder: IClashManager? = null
|
||||
private var profileBinder: IProfileManager? = null
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
clash = ClashManager(this)
|
||||
profile = ProfileManager(this)
|
||||
clashBinder = clash?.wrap() as IClashManager?
|
||||
profileBinder = profile?.wrap() as IProfileManager?
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
|
||||
clash?.cancelAndJoinBlocking()
|
||||
profile?.cancelAndJoinBlocking()
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder {
|
||||
return binder
|
||||
}
|
||||
|
||||
override fun clash(): IClashManager {
|
||||
return clashBinder!!
|
||||
}
|
||||
|
||||
override fun profile(): IProfileManager {
|
||||
return profileBinder!!
|
||||
}
|
||||
}
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
package com.github.kr328.clash.service
|
||||
|
||||
import android.content.ContentProvider
|
||||
import android.content.ContentValues
|
||||
import android.database.Cursor
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import com.github.kr328.clash.common.Global
|
||||
|
||||
class StatusProvider : ContentProvider() {
|
||||
override fun call(method: String, arg: String?, extras: Bundle?): Bundle? {
|
||||
return when (method) {
|
||||
METHOD_CURRENT_PROFILE -> {
|
||||
return if (serviceRunning)
|
||||
Bundle().apply {
|
||||
putString("name", currentProfile)
|
||||
}
|
||||
else
|
||||
null
|
||||
}
|
||||
else -> super.call(method, arg, extras)
|
||||
}
|
||||
}
|
||||
|
||||
override fun insert(uri: Uri, values: ContentValues?): Uri? {
|
||||
throw IllegalArgumentException("Stub!")
|
||||
}
|
||||
|
||||
override fun query(
|
||||
uri: Uri,
|
||||
projection: Array<out String>?,
|
||||
selection: String?,
|
||||
selectionArgs: Array<out String>?,
|
||||
sortOrder: String?
|
||||
): Cursor? {
|
||||
throw IllegalArgumentException("Stub!")
|
||||
}
|
||||
|
||||
override fun update(
|
||||
uri: Uri,
|
||||
values: ContentValues?,
|
||||
selection: String?,
|
||||
selectionArgs: Array<out String>?
|
||||
): Int {
|
||||
throw IllegalArgumentException("Stub!")
|
||||
}
|
||||
|
||||
override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): Int {
|
||||
throw IllegalArgumentException("Stub!")
|
||||
}
|
||||
|
||||
override fun getType(uri: Uri): String? {
|
||||
throw IllegalArgumentException("Stub!")
|
||||
}
|
||||
|
||||
override fun onCreate(): Boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val METHOD_CURRENT_PROFILE = "currentProfile"
|
||||
|
||||
private const val CLASH_SERVICE_RUNNING_FILE = "service_running.lock"
|
||||
|
||||
var serviceRunning: Boolean = false
|
||||
set(value) {
|
||||
field = value
|
||||
|
||||
shouldStartClashOnBoot = value
|
||||
}
|
||||
var shouldStartClashOnBoot: Boolean
|
||||
get() = Global.application.filesDir.resolve(CLASH_SERVICE_RUNNING_FILE).exists()
|
||||
set(value) {
|
||||
Global.application.filesDir.resolve(CLASH_SERVICE_RUNNING_FILE).apply {
|
||||
if (value)
|
||||
createNewFile()
|
||||
else
|
||||
delete()
|
||||
}
|
||||
}
|
||||
var currentProfile: String? = null
|
||||
}
|
||||
}
|
||||
+266
@@ -0,0 +1,266 @@
|
||||
package com.github.kr328.clash.service
|
||||
|
||||
import android.annotation.TargetApi
|
||||
import android.app.PendingIntent
|
||||
import android.content.Intent
|
||||
import android.net.ProxyInfo
|
||||
import android.net.VpnService
|
||||
import android.os.Build
|
||||
import com.github.kr328.clash.common.compat.pendingIntentFlags
|
||||
import com.github.kr328.clash.common.constants.Components
|
||||
import com.github.kr328.clash.common.log.Log
|
||||
import com.github.kr328.clash.service.clash.clashRuntime
|
||||
import com.github.kr328.clash.service.clash.module.*
|
||||
import com.github.kr328.clash.service.model.AccessControlMode
|
||||
import com.github.kr328.clash.service.store.ServiceStore
|
||||
import com.github.kr328.clash.service.util.cancelAndJoinBlocking
|
||||
import com.github.kr328.clash.service.util.parseCIDR
|
||||
import com.github.kr328.clash.service.util.sendClashStarted
|
||||
import com.github.kr328.clash.service.util.sendClashStopped
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.selects.select
|
||||
|
||||
class TunService : VpnService(), CoroutineScope by CoroutineScope(Dispatchers.Default) {
|
||||
private val self: TunService
|
||||
get() = this
|
||||
|
||||
private var reason: String? = null
|
||||
|
||||
private val runtime = clashRuntime {
|
||||
val store = ServiceStore(self)
|
||||
|
||||
val close = install(CloseModule(self))
|
||||
val tun = install(TunModule(self))
|
||||
val config = install(ConfigurationModule(self))
|
||||
val network = install(NetworkObserveModule(self))
|
||||
|
||||
if (store.dynamicNotification)
|
||||
install(DynamicNotificationModule(self))
|
||||
else
|
||||
install(StaticNotificationModule(self))
|
||||
|
||||
install(AppListCacheModule(self))
|
||||
install(TimeZoneModule(self))
|
||||
install(SuspendModule(self))
|
||||
|
||||
try {
|
||||
tun.open()
|
||||
|
||||
while (isActive) {
|
||||
val quit = select<Boolean> {
|
||||
close.onEvent {
|
||||
true
|
||||
}
|
||||
config.onEvent {
|
||||
reason = it.message
|
||||
|
||||
true
|
||||
}
|
||||
network.onEvent { n ->
|
||||
if (Build.VERSION.SDK_INT in 22..28) @TargetApi(22) {
|
||||
setUnderlyingNetworks(n?.let { arrayOf(it) })
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
if (quit) break
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("Create clash runtime: ${e.message}", e)
|
||||
|
||||
reason = e.message
|
||||
} finally {
|
||||
withContext(NonCancellable) {
|
||||
tun.close()
|
||||
|
||||
stopSelf()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
if (StatusProvider.serviceRunning)
|
||||
return stopSelf()
|
||||
|
||||
StatusProvider.serviceRunning = true
|
||||
|
||||
StaticNotificationModule.createNotificationChannel(this)
|
||||
StaticNotificationModule.notifyLoadingNotification(this)
|
||||
|
||||
runtime.launch()
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
sendClashStarted()
|
||||
|
||||
return super.onStartCommand(intent, flags, startId)
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
TunModule.requestStop()
|
||||
|
||||
StatusProvider.serviceRunning = false
|
||||
|
||||
sendClashStopped(reason)
|
||||
|
||||
cancelAndJoinBlocking()
|
||||
|
||||
Log.i("TunService destroyed: ${reason ?: "successfully"}")
|
||||
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onTrimMemory(level: Int) {
|
||||
super.onTrimMemory(level)
|
||||
|
||||
runtime.requestGc()
|
||||
}
|
||||
|
||||
private fun TunModule.open() {
|
||||
val store = ServiceStore(self)
|
||||
|
||||
val device = with(Builder()) {
|
||||
// Interface address
|
||||
addAddress(TUN_GATEWAY, TUN_SUBNET_PREFIX)
|
||||
if (store.allowIpv6) {
|
||||
addAddress(TUN_GATEWAY6, TUN_SUBNET_PREFIX6)
|
||||
}
|
||||
|
||||
// Route
|
||||
if (store.bypassPrivateNetwork) {
|
||||
resources.getStringArray(R.array.bypass_private_route).map(::parseCIDR).forEach {
|
||||
addRoute(it.ip, it.prefix)
|
||||
}
|
||||
if (store.allowIpv6) {
|
||||
resources.getStringArray(R.array.bypass_private_route6).map(::parseCIDR).forEach {
|
||||
addRoute(it.ip, it.prefix)
|
||||
}
|
||||
}
|
||||
|
||||
// Route of virtual DNS
|
||||
addRoute(TUN_DNS, 32)
|
||||
if (store.allowIpv6) {
|
||||
addRoute(TUN_DNS6, 128)
|
||||
}
|
||||
} else {
|
||||
addRoute(NET_ANY, 0)
|
||||
if (store.allowIpv6) {
|
||||
addRoute(NET_ANY6, 0)
|
||||
}
|
||||
}
|
||||
|
||||
// Access Control
|
||||
when (store.accessControlMode) {
|
||||
AccessControlMode.AcceptAll -> Unit
|
||||
AccessControlMode.AcceptSelected -> {
|
||||
(store.accessControlPackages + packageName).forEach {
|
||||
runCatching { addAllowedApplication(it) }
|
||||
}
|
||||
}
|
||||
AccessControlMode.DenySelected -> {
|
||||
(store.accessControlPackages - packageName).forEach {
|
||||
runCatching { addDisallowedApplication(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Blocking
|
||||
setBlocking(false)
|
||||
|
||||
// Mtu
|
||||
setMtu(TUN_MTU)
|
||||
|
||||
// Session Name
|
||||
setSession("Clash")
|
||||
|
||||
// Virtual Dns Server
|
||||
addDnsServer(TUN_DNS)
|
||||
if (store.allowIpv6) {
|
||||
addDnsServer(TUN_DNS6)
|
||||
}
|
||||
|
||||
// Open MainActivity
|
||||
setConfigureIntent(
|
||||
PendingIntent.getActivity(
|
||||
self,
|
||||
R.id.nf_vpn_status,
|
||||
Intent().setComponent(Components.MAIN_ACTIVITY),
|
||||
pendingIntentFlags(PendingIntent.FLAG_UPDATE_CURRENT)
|
||||
)
|
||||
)
|
||||
|
||||
// Metered
|
||||
if (Build.VERSION.SDK_INT >= 29) {
|
||||
setMetered(false)
|
||||
}
|
||||
|
||||
// System Proxy
|
||||
if (Build.VERSION.SDK_INT >= 29 && store.systemProxy) {
|
||||
listenHttp()?.let {
|
||||
setHttpProxy(
|
||||
ProxyInfo.buildDirectProxy(
|
||||
it.address.hostAddress,
|
||||
it.port,
|
||||
HTTP_PROXY_BLACK_LIST + if (store.bypassPrivateNetwork) HTTP_PROXY_LOCAL_LIST else emptyList()
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (store.allowBypass) {
|
||||
allowBypass()
|
||||
}
|
||||
|
||||
TunModule.TunDevice(
|
||||
fd = establish()?.detachFd()
|
||||
?: throw NullPointerException("Establish VPN rejected by system"),
|
||||
stack = store.tunStackMode,
|
||||
gateway = "$TUN_GATEWAY/$TUN_SUBNET_PREFIX" + if (store.allowIpv6) ",$TUN_GATEWAY6/$TUN_SUBNET_PREFIX6" else "",
|
||||
portal = TUN_PORTAL + if (store.allowIpv6) ",$TUN_PORTAL6" else "",
|
||||
dns = if (store.dnsHijacking) NET_ANY else (TUN_DNS + if (store.allowIpv6) ",$TUN_DNS6" else ""),
|
||||
)
|
||||
}
|
||||
|
||||
attach(device)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TUN_MTU = 9000
|
||||
private const val TUN_SUBNET_PREFIX = 30
|
||||
private const val TUN_GATEWAY = "172.19.0.1"
|
||||
private const val TUN_SUBNET_PREFIX6 = 126
|
||||
private const val TUN_GATEWAY6 = "fdfe:dcba:9876::1"
|
||||
private const val TUN_PORTAL = "172.19.0.2"
|
||||
private const val TUN_PORTAL6 = "fdfe:dcba:9876::2"
|
||||
private const val TUN_DNS = TUN_PORTAL
|
||||
private const val TUN_DNS6 = TUN_PORTAL6
|
||||
private const val NET_ANY = "0.0.0.0"
|
||||
private const val NET_ANY6 = "::"
|
||||
|
||||
private val HTTP_PROXY_LOCAL_LIST: List<String> = listOf(
|
||||
"localhost",
|
||||
"*.local",
|
||||
"127.*",
|
||||
"10.*",
|
||||
"172.16.*",
|
||||
"172.17.*",
|
||||
"172.18.*",
|
||||
"172.19.*",
|
||||
"172.2*",
|
||||
"172.30.*",
|
||||
"172.31.*",
|
||||
"192.168.*"
|
||||
)
|
||||
private val HTTP_PROXY_BLACK_LIST: List<String> = listOf(
|
||||
"*zhihu.com",
|
||||
"*zhimg.com",
|
||||
"*jd.com",
|
||||
"100ime-iat-api.xfyun.cn",
|
||||
"*360buyimg.com",
|
||||
)
|
||||
}
|
||||
}
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
package com.github.kr328.clash.service.clash
|
||||
|
||||
import com.github.kr328.clash.common.log.Log
|
||||
import com.github.kr328.clash.core.Clash
|
||||
import com.github.kr328.clash.service.clash.module.Module
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
|
||||
private val globalLock = Mutex()
|
||||
|
||||
interface ClashRuntimeScope {
|
||||
fun <E, T : Module<E>> install(module: T): T
|
||||
}
|
||||
|
||||
interface ClashRuntime {
|
||||
fun launch()
|
||||
fun requestGc()
|
||||
}
|
||||
|
||||
fun CoroutineScope.clashRuntime(block: suspend ClashRuntimeScope.() -> Unit): ClashRuntime {
|
||||
return object : ClashRuntime {
|
||||
override fun launch() {
|
||||
launch(Dispatchers.IO) {
|
||||
globalLock.withLock {
|
||||
Log.d("ClashRuntime: initialize")
|
||||
|
||||
try {
|
||||
val modules = mutableListOf<Module<*>>()
|
||||
|
||||
Clash.reset()
|
||||
Clash.clearOverride(Clash.OverrideSlot.Session)
|
||||
|
||||
val scope = object : ClashRuntimeScope {
|
||||
override fun <E, T : Module<E>> install(module: T): T {
|
||||
launch {
|
||||
modules.add(module)
|
||||
|
||||
module.execute()
|
||||
}
|
||||
|
||||
return module
|
||||
}
|
||||
}
|
||||
|
||||
scope.block()
|
||||
|
||||
cancel()
|
||||
} finally {
|
||||
withContext(NonCancellable) {
|
||||
Clash.reset()
|
||||
Clash.clearOverride(Clash.OverrideSlot.Session)
|
||||
|
||||
Log.d("ClashRuntime: destroyed")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun requestGc() {
|
||||
Clash.forceGc()
|
||||
}
|
||||
}
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
package com.github.kr328.clash.service.clash.module
|
||||
|
||||
import android.app.Service
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageInfo
|
||||
import com.github.kr328.clash.common.log.Log
|
||||
import com.github.kr328.clash.core.Clash
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.delay
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class AppListCacheModule(service: Service) : Module<Unit>(service) {
|
||||
private fun PackageInfo.uniqueUidName(): String =
|
||||
if (sharedUserId != null && sharedUserId.isNotBlank()) sharedUserId else packageName
|
||||
|
||||
private fun reload() {
|
||||
val packages = service.packageManager.getInstalledPackages(0)
|
||||
.groupBy { it.uniqueUidName() }
|
||||
.map { (_, v) ->
|
||||
val info = v[0]
|
||||
|
||||
if (v.size == 1) {
|
||||
// Force use package name if only one app in a single sharedUid group
|
||||
// Example: firefox
|
||||
|
||||
info.applicationInfo.uid to info.packageName
|
||||
} else {
|
||||
info.applicationInfo.uid to info.uniqueUidName()
|
||||
}
|
||||
}
|
||||
|
||||
Clash.notifyInstalledAppsChanged(packages)
|
||||
|
||||
Log.d("Installed ${packages.size} packages cached")
|
||||
}
|
||||
|
||||
override suspend fun run() {
|
||||
val packageChanged = receiveBroadcast(false, Channel.CONFLATED) {
|
||||
addAction(Intent.ACTION_PACKAGE_ADDED)
|
||||
addAction(Intent.ACTION_PACKAGE_REMOVED)
|
||||
addDataScheme("package")
|
||||
}
|
||||
|
||||
while (true) {
|
||||
reload()
|
||||
|
||||
packageChanged.receive()
|
||||
|
||||
delay(TimeUnit.SECONDS.toMillis(10))
|
||||
}
|
||||
}
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
package com.github.kr328.clash.service.clash.module
|
||||
|
||||
import android.app.Service
|
||||
import com.github.kr328.clash.common.constants.Intents
|
||||
import com.github.kr328.clash.common.log.Log
|
||||
|
||||
class CloseModule(service: Service) : Module<CloseModule.RequestClose>(service) {
|
||||
object RequestClose
|
||||
|
||||
override suspend fun run() {
|
||||
val broadcasts = receiveBroadcast {
|
||||
addAction(Intents.ACTION_CLASH_REQUEST_STOP)
|
||||
}
|
||||
|
||||
broadcasts.receive()
|
||||
|
||||
Log.d("User request close")
|
||||
|
||||
return enqueueEvent(RequestClose)
|
||||
}
|
||||
}
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
package com.github.kr328.clash.service.clash.module
|
||||
|
||||
import android.app.Service
|
||||
import com.github.kr328.clash.common.constants.Intents
|
||||
import com.github.kr328.clash.common.log.Log
|
||||
import com.github.kr328.clash.core.Clash
|
||||
import com.github.kr328.clash.service.StatusProvider
|
||||
import com.github.kr328.clash.service.data.ImportedDao
|
||||
import com.github.kr328.clash.service.data.SelectionDao
|
||||
import com.github.kr328.clash.service.store.ServiceStore
|
||||
import com.github.kr328.clash.service.util.importedDir
|
||||
import com.github.kr328.clash.service.util.sendProfileLoaded
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.selects.select
|
||||
import java.util.*
|
||||
|
||||
class ConfigurationModule(service: Service) : Module<ConfigurationModule.LoadException>(service) {
|
||||
data class LoadException(val message: String)
|
||||
|
||||
private val store = ServiceStore(service)
|
||||
private val reload = Channel<Unit>(Channel.CONFLATED)
|
||||
|
||||
override suspend fun run() {
|
||||
val broadcasts = receiveBroadcast {
|
||||
addAction(Intents.ACTION_PROFILE_CHANGED)
|
||||
addAction(Intents.ACTION_OVERRIDE_CHANGED)
|
||||
}
|
||||
|
||||
var loaded: UUID? = null
|
||||
|
||||
reload.trySend(Unit)
|
||||
|
||||
while (true) {
|
||||
val changed: UUID? = select {
|
||||
broadcasts.onReceive {
|
||||
if (it.action == Intents.ACTION_PROFILE_CHANGED)
|
||||
UUID.fromString(it.getStringExtra(Intents.EXTRA_UUID))
|
||||
else
|
||||
null
|
||||
}
|
||||
reload.onReceive {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
val current = store.activeProfile
|
||||
?: throw NullPointerException("No profile selected")
|
||||
|
||||
if (current == loaded && changed != null && changed != loaded)
|
||||
continue
|
||||
|
||||
loaded = current
|
||||
|
||||
val active = ImportedDao().queryByUUID(current)
|
||||
?: throw NullPointerException("No profile selected")
|
||||
|
||||
Clash.load(service.importedDir.resolve(active.uuid.toString())).await()
|
||||
|
||||
val remove = SelectionDao().querySelections(active.uuid)
|
||||
.filterNot { Clash.patchSelector(it.proxy, it.selected) }
|
||||
.map { it.proxy }
|
||||
|
||||
SelectionDao().removeSelections(active.uuid, remove)
|
||||
|
||||
StatusProvider.currentProfile = active.name
|
||||
|
||||
service.sendProfileLoaded(current)
|
||||
|
||||
Log.d("Profile ${active.name} loaded")
|
||||
} catch (e: Exception) {
|
||||
return enqueueEvent(LoadException(e.message ?: "Unknown"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
package com.github.kr328.clash.service.clash.module
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Intent
|
||||
import android.os.PowerManager
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.content.getSystemService
|
||||
import com.github.kr328.clash.common.compat.getColorCompat
|
||||
import com.github.kr328.clash.common.compat.pendingIntentFlags
|
||||
import com.github.kr328.clash.common.constants.Components
|
||||
import com.github.kr328.clash.common.constants.Intents
|
||||
import com.github.kr328.clash.common.util.ticker
|
||||
import com.github.kr328.clash.core.Clash
|
||||
import com.github.kr328.clash.core.util.trafficDownload
|
||||
import com.github.kr328.clash.core.util.trafficUpload
|
||||
import com.github.kr328.clash.service.R
|
||||
import com.github.kr328.clash.service.StatusProvider
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.selects.select
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class DynamicNotificationModule(service: Service) : Module<Unit>(service) {
|
||||
private val builder = NotificationCompat.Builder(service, StaticNotificationModule.CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_logo_service)
|
||||
.setOngoing(true)
|
||||
.setColor(service.getColorCompat(R.color.color_clash))
|
||||
.setOnlyAlertOnce(true)
|
||||
.setShowWhen(false)
|
||||
.setContentTitle("Not Selected")
|
||||
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
|
||||
.setContentIntent(
|
||||
PendingIntent.getActivity(
|
||||
service,
|
||||
R.id.nf_clash_status,
|
||||
Intent().setComponent(Components.MAIN_ACTIVITY)
|
||||
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP),
|
||||
pendingIntentFlags(PendingIntent.FLAG_UPDATE_CURRENT)
|
||||
)
|
||||
)
|
||||
|
||||
private val notificationManager = NotificationManagerCompat.from(service)
|
||||
|
||||
private fun update() {
|
||||
val now = Clash.queryTrafficNow()
|
||||
val total = Clash.queryTrafficTotal()
|
||||
|
||||
val uploading = now.trafficUpload()
|
||||
val downloading = now.trafficDownload()
|
||||
val uploaded = total.trafficUpload()
|
||||
val downloaded = total.trafficDownload()
|
||||
|
||||
val notification = builder
|
||||
.setContentText(
|
||||
service.getString(
|
||||
R.string.clash_notification_content,
|
||||
"$uploading/s", "$downloading/s"
|
||||
)
|
||||
)
|
||||
.setSubText(
|
||||
service.getString(
|
||||
R.string.clash_notification_content,
|
||||
uploaded, downloaded
|
||||
)
|
||||
)
|
||||
.build()
|
||||
|
||||
notificationManager.notify(R.id.nf_clash_status, notification)
|
||||
}
|
||||
|
||||
override suspend fun run() = coroutineScope {
|
||||
var shouldUpdate = service.getSystemService<PowerManager>()?.isInteractive ?: true
|
||||
|
||||
val screenToggle = receiveBroadcast(false, Channel.CONFLATED) {
|
||||
addAction(Intent.ACTION_SCREEN_ON)
|
||||
addAction(Intent.ACTION_SCREEN_OFF)
|
||||
}
|
||||
|
||||
val profileLoaded = receiveBroadcast(capacity = Channel.CONFLATED) {
|
||||
addAction(Intents.ACTION_PROFILE_LOADED)
|
||||
}
|
||||
|
||||
val ticker = ticker(TimeUnit.SECONDS.toMillis(1))
|
||||
|
||||
while (true) {
|
||||
select<Unit> {
|
||||
screenToggle.onReceive {
|
||||
when (it.action) {
|
||||
Intent.ACTION_SCREEN_ON ->
|
||||
shouldUpdate = true
|
||||
Intent.ACTION_SCREEN_OFF ->
|
||||
shouldUpdate = false
|
||||
}
|
||||
}
|
||||
profileLoaded.onReceive {
|
||||
builder.setContentTitle(StatusProvider.currentProfile ?: "Not selected")
|
||||
}
|
||||
if (shouldUpdate) {
|
||||
ticker.onReceive {
|
||||
update()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
package com.github.kr328.clash.service.clash.module
|
||||
|
||||
import android.app.Service
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import com.github.kr328.clash.common.constants.Permissions
|
||||
import com.github.kr328.clash.common.log.Log
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.channels.ReceiveChannel
|
||||
import kotlinx.coroutines.selects.SelectClause1
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
abstract class Module<E>(val service: Service) {
|
||||
private val events: Channel<E> = Channel(Channel.UNLIMITED)
|
||||
private val receivers: MutableList<BroadcastReceiver> = mutableListOf()
|
||||
|
||||
val onEvent: SelectClause1<E>
|
||||
get() = events.onReceive
|
||||
|
||||
protected suspend fun enqueueEvent(event: E) {
|
||||
events.send(event)
|
||||
}
|
||||
|
||||
protected fun receiveBroadcast(
|
||||
requireSelf: Boolean = true,
|
||||
capacity: Int = Channel.UNLIMITED,
|
||||
configure: IntentFilter.() -> Unit
|
||||
): ReceiveChannel<Intent> {
|
||||
val filter = IntentFilter().apply(configure)
|
||||
val channel = Channel<Intent>(capacity)
|
||||
val receiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
if (context == null || intent == null) {
|
||||
channel.close()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
channel.trySend(intent)
|
||||
}
|
||||
}
|
||||
|
||||
if (requireSelf) {
|
||||
service.registerReceiver(receiver, filter, Permissions.RECEIVE_SELF_BROADCASTS, null)
|
||||
} else {
|
||||
service.registerReceiver(receiver, filter)
|
||||
}
|
||||
|
||||
receivers.add(receiver)
|
||||
|
||||
return channel
|
||||
}
|
||||
|
||||
suspend fun execute() {
|
||||
val moduleName = this.javaClass.simpleName
|
||||
|
||||
try {
|
||||
Log.d("$moduleName: initialize")
|
||||
|
||||
run()
|
||||
} finally {
|
||||
withContext(NonCancellable) {
|
||||
receivers.forEach {
|
||||
it.onReceive(null, null)
|
||||
|
||||
service.unregisterReceiver(it)
|
||||
}
|
||||
|
||||
Log.d("$moduleName: destroyed")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected abstract suspend fun run()
|
||||
}
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
package com.github.kr328.clash.service.clash.module
|
||||
|
||||
import android.app.Service
|
||||
import android.net.*
|
||||
import android.os.Build
|
||||
import androidx.core.content.getSystemService
|
||||
import com.github.kr328.clash.common.log.Log
|
||||
import com.github.kr328.clash.core.Clash
|
||||
import com.github.kr328.clash.service.util.resolvePrimaryDns
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.channels.trySendBlocking
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class NetworkObserveModule(service: Service) : Module<Network?>(service) {
|
||||
private data class Action(val type: Type, val network: Network) {
|
||||
enum class Type { Available, Lost, Changed }
|
||||
}
|
||||
|
||||
private val connectivity = service.getSystemService<ConnectivityManager>()!!
|
||||
private val actions = Channel<Action>(Channel.UNLIMITED)
|
||||
private val request = NetworkRequest.Builder().apply {
|
||||
addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)
|
||||
addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||
addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_RESTRICTED)
|
||||
}.build()
|
||||
private val callback = object : ConnectivityManager.NetworkCallback() {
|
||||
override fun onAvailable(network: Network) {
|
||||
actions.trySendBlocking(Action(Action.Type.Available, network))
|
||||
}
|
||||
|
||||
override fun onLost(network: Network) {
|
||||
actions.trySendBlocking(Action(Action.Type.Lost, network))
|
||||
}
|
||||
|
||||
override fun onLinkPropertiesChanged(network: Network, linkProperties: LinkProperties) {
|
||||
actions.trySendBlocking(Action(Action.Type.Changed, network))
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun run() {
|
||||
try {
|
||||
connectivity.registerNetworkCallback(request, callback)
|
||||
} catch (e: Exception) {
|
||||
Log.w("Observe network failed: $e", e)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
val networks = mutableSetOf<Network>()
|
||||
|
||||
while (true) {
|
||||
val action = actions.receive()
|
||||
|
||||
val resolveDefault = when (action.type) {
|
||||
Action.Type.Available -> {
|
||||
networks.add(action.network)
|
||||
|
||||
true
|
||||
}
|
||||
Action.Type.Lost -> {
|
||||
networks.remove(action.network)
|
||||
|
||||
true
|
||||
}
|
||||
Action.Type.Changed -> {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
val dns = networks.mapNotNull {
|
||||
connectivity.resolvePrimaryDns(it)
|
||||
}
|
||||
|
||||
Clash.notifyDnsChanged(dns)
|
||||
|
||||
Log.d("DNS: $dns")
|
||||
|
||||
if (resolveDefault) {
|
||||
val network = networks.maxByOrNull { net ->
|
||||
connectivity.getNetworkCapabilities(net)?.let { cap ->
|
||||
TRANSPORT_PRIORITY.indexOfFirst { cap.hasTransport(it) }
|
||||
} ?: -1
|
||||
}
|
||||
|
||||
enqueueEvent(network)
|
||||
|
||||
Log.d("Network: $network of $networks")
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
withContext(NonCancellable) {
|
||||
enqueueEvent(null)
|
||||
|
||||
Clash.notifyDnsChanged(emptyList())
|
||||
|
||||
runCatching {
|
||||
connectivity.unregisterNetworkCallback(callback)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TRANSPORT_PRIORITY = sequence {
|
||||
yield(NetworkCapabilities.TRANSPORT_CELLULAR)
|
||||
|
||||
if (Build.VERSION.SDK_INT >= 27) {
|
||||
yield(NetworkCapabilities.TRANSPORT_LOWPAN)
|
||||
}
|
||||
|
||||
yield(NetworkCapabilities.TRANSPORT_BLUETOOTH)
|
||||
|
||||
if (Build.VERSION.SDK_INT >= 26) {
|
||||
yield(NetworkCapabilities.TRANSPORT_WIFI_AWARE)
|
||||
}
|
||||
|
||||
yield(NetworkCapabilities.TRANSPORT_WIFI)
|
||||
|
||||
if (Build.VERSION.SDK_INT >= 31) {
|
||||
yield(NetworkCapabilities.TRANSPORT_USB)
|
||||
}
|
||||
|
||||
yield(NetworkCapabilities.TRANSPORT_ETHERNET)
|
||||
}.toList()
|
||||
}
|
||||
}
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
package com.github.kr328.clash.service.clash.module
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Intent
|
||||
import androidx.core.app.NotificationChannelCompat
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import com.github.kr328.clash.common.compat.getColorCompat
|
||||
import com.github.kr328.clash.common.compat.pendingIntentFlags
|
||||
import com.github.kr328.clash.common.constants.Components
|
||||
import com.github.kr328.clash.common.constants.Intents
|
||||
import com.github.kr328.clash.service.R
|
||||
import com.github.kr328.clash.service.StatusProvider
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
|
||||
class StaticNotificationModule(service: Service) : Module<Unit>(service) {
|
||||
private val builder = NotificationCompat.Builder(service, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_logo_service)
|
||||
.setOngoing(true)
|
||||
.setColor(service.getColorCompat(R.color.color_clash))
|
||||
.setOnlyAlertOnce(true)
|
||||
.setShowWhen(false)
|
||||
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
|
||||
.setContentIntent(
|
||||
PendingIntent.getActivity(
|
||||
service,
|
||||
R.id.nf_clash_status,
|
||||
Intent().setComponent(Components.MAIN_ACTIVITY)
|
||||
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP),
|
||||
pendingIntentFlags(PendingIntent.FLAG_UPDATE_CURRENT)
|
||||
)
|
||||
)
|
||||
|
||||
override suspend fun run() {
|
||||
val loaded = receiveBroadcast(capacity = Channel.CONFLATED) {
|
||||
addAction(Intents.ACTION_PROFILE_LOADED)
|
||||
}
|
||||
|
||||
while (true) {
|
||||
loaded.receive()
|
||||
|
||||
val profileName = StatusProvider.currentProfile ?: "Not selected"
|
||||
|
||||
val notification = builder
|
||||
.setContentTitle(profileName)
|
||||
.setContentText(service.getText(R.string.running))
|
||||
.build()
|
||||
|
||||
service.startForeground(R.id.nf_clash_status, notification)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val CHANNEL_ID = "clash_status_channel"
|
||||
|
||||
fun createNotificationChannel(service: Service) {
|
||||
NotificationManagerCompat.from(service).createNotificationChannel(
|
||||
NotificationChannelCompat.Builder(
|
||||
CHANNEL_ID,
|
||||
NotificationManagerCompat.IMPORTANCE_LOW
|
||||
).setName(service.getText(R.string.clash_service_status_channel)).build()
|
||||
)
|
||||
}
|
||||
|
||||
fun notifyLoadingNotification(service: Service) {
|
||||
val notification =
|
||||
NotificationCompat.Builder(service, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_logo_service)
|
||||
.setOngoing(true)
|
||||
.setColor(service.getColorCompat(R.color.color_clash))
|
||||
.setOnlyAlertOnce(true)
|
||||
.setShowWhen(false)
|
||||
.setContentTitle(service.getText(R.string.loading))
|
||||
.build()
|
||||
|
||||
service.startForeground(R.id.nf_clash_status, notification)
|
||||
}
|
||||
}
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
package com.github.kr328.clash.service.clash.module
|
||||
|
||||
import android.app.Service
|
||||
import android.content.Intent
|
||||
import android.os.PowerManager
|
||||
import androidx.core.content.getSystemService
|
||||
import com.github.kr328.clash.common.log.Log
|
||||
import com.github.kr328.clash.core.Clash
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class SuspendModule(service: Service) : Module<Unit>(service) {
|
||||
override suspend fun run() {
|
||||
val interactive = service.getSystemService<PowerManager>()?.isInteractive ?: true
|
||||
|
||||
Clash.suspendCore(!interactive)
|
||||
|
||||
val screenToggle = receiveBroadcast(false, Channel.CONFLATED) {
|
||||
addAction(Intent.ACTION_SCREEN_ON)
|
||||
addAction(Intent.ACTION_SCREEN_OFF)
|
||||
}
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
when (screenToggle.receive().action) {
|
||||
Intent.ACTION_SCREEN_ON -> {
|
||||
Clash.suspendCore(false)
|
||||
|
||||
Log.d("Clash resumed")
|
||||
}
|
||||
Intent.ACTION_SCREEN_OFF -> {
|
||||
Clash.suspendCore(true)
|
||||
|
||||
Log.d("Clash suspended")
|
||||
}
|
||||
else -> {
|
||||
// unreachable
|
||||
|
||||
Clash.healthCheckAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
withContext(NonCancellable) {
|
||||
Clash.suspendCore(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package com.github.kr328.clash.service.clash.module
|
||||
|
||||
import android.app.Service
|
||||
import android.content.Intent
|
||||
import com.github.kr328.clash.core.Clash
|
||||
import java.util.*
|
||||
|
||||
class TimeZoneModule(service: Service) : Module<Unit>(service) {
|
||||
override suspend fun run() {
|
||||
val timeZones = receiveBroadcast {
|
||||
addAction(Intent.ACTION_TIMEZONE_CHANGED)
|
||||
}
|
||||
|
||||
while (true) {
|
||||
val timeZone = TimeZone.getDefault()
|
||||
|
||||
Clash.notifyTimeZoneChanged(timeZone.id, timeZone.rawOffset / 1000)
|
||||
|
||||
timeZones.receive()
|
||||
}
|
||||
}
|
||||
}
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
package com.github.kr328.clash.service.clash.module
|
||||
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.VpnService
|
||||
import android.os.Build
|
||||
import androidx.core.content.getSystemService
|
||||
import com.github.kr328.clash.core.Clash
|
||||
import com.github.kr328.clash.core.util.parseInetSocketAddress
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.net.InetSocketAddress
|
||||
import java.security.SecureRandom
|
||||
|
||||
class TunModule(private val vpn: VpnService) : Module<Unit>(vpn) {
|
||||
data class TunDevice(
|
||||
val fd: Int,
|
||||
var stack: String,
|
||||
val gateway: String,
|
||||
val portal: String,
|
||||
val dns: String,
|
||||
)
|
||||
|
||||
private val connectivity = service.getSystemService<ConnectivityManager>()!!
|
||||
private val close = Channel<Unit>(Channel.CONFLATED)
|
||||
|
||||
private fun queryUid(
|
||||
protocol: Int,
|
||||
source: InetSocketAddress,
|
||||
target: InetSocketAddress,
|
||||
): Int {
|
||||
if (Build.VERSION.SDK_INT < 29)
|
||||
return -1
|
||||
|
||||
return runCatching { connectivity.getConnectionOwnerUid(protocol, source, target) }
|
||||
.getOrElse { -1 }
|
||||
}
|
||||
|
||||
override suspend fun run() {
|
||||
try {
|
||||
return close.receive()
|
||||
} finally {
|
||||
withContext(NonCancellable) {
|
||||
requestStop()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun listenHttp(): InetSocketAddress? {
|
||||
val r = { 1 + random.nextInt(199) }
|
||||
val listenAt = "127.${r()}.${r()}.${r()}:0"
|
||||
val address = Clash.startHttp(listenAt)
|
||||
|
||||
return address?.let(::parseInetSocketAddress)
|
||||
}
|
||||
|
||||
fun attach(device: TunDevice) {
|
||||
Clash.startTun(
|
||||
fd = device.fd,
|
||||
stack = device.stack,
|
||||
gateway = device.gateway,
|
||||
portal = device.portal,
|
||||
dns = device.dns,
|
||||
markSocket = vpn::protect,
|
||||
querySocketUid = this::queryUid
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun close() {
|
||||
close.send(Unit)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val random = SecureRandom()
|
||||
|
||||
fun requestStop() {
|
||||
Clash.stopHttp()
|
||||
Clash.stopTun()
|
||||
}
|
||||
}
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
package com.github.kr328.clash.service.data
|
||||
|
||||
import androidx.room.TypeConverter
|
||||
import com.github.kr328.clash.service.model.Profile
|
||||
import java.util.*
|
||||
|
||||
class Converters {
|
||||
@TypeConverter
|
||||
fun fromUUID(uuid: UUID): String {
|
||||
return uuid.toString()
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
fun toUUID(uuid: String): UUID {
|
||||
return UUID.fromString(uuid)
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
fun fromProfileType(type: Profile.Type): String {
|
||||
return type.name
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
fun toProfileType(type: String): Profile.Type {
|
||||
return Profile.Type.valueOf(type)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.github.kr328.clash.service.data
|
||||
|
||||
fun ImportedDao(): ImportedDao {
|
||||
return Database.database.openImportedDao()
|
||||
}
|
||||
|
||||
fun PendingDao(): PendingDao {
|
||||
return Database.database.openPendingDao()
|
||||
}
|
||||
|
||||
fun SelectionDao(): SelectionDao {
|
||||
return Database.database.openSelectionProxyDao()
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
package com.github.kr328.clash.service.data
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Room
|
||||
import androidx.room.RoomDatabase
|
||||
import com.github.kr328.clash.common.Global
|
||||
import com.github.kr328.clash.service.data.migrations.LEGACY_MIGRATION
|
||||
import com.github.kr328.clash.service.data.migrations.MIGRATIONS
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import java.lang.ref.SoftReference
|
||||
import androidx.room.Database as DB
|
||||
|
||||
@DB(
|
||||
version = 1,
|
||||
entities = [Imported::class, Pending::class, Selection::class],
|
||||
exportSchema = false,
|
||||
)
|
||||
abstract class Database : RoomDatabase() {
|
||||
abstract fun openImportedDao(): ImportedDao
|
||||
abstract fun openPendingDao(): PendingDao
|
||||
abstract fun openSelectionProxyDao(): SelectionDao
|
||||
|
||||
companion object {
|
||||
val database: Database
|
||||
@Synchronized get() {
|
||||
return softDatabase.get() ?: open(Global.application).apply {
|
||||
softDatabase = SoftReference(this)
|
||||
}
|
||||
}
|
||||
|
||||
private var softDatabase: SoftReference<Database?> = SoftReference(null)
|
||||
|
||||
private fun open(context: Context): Database {
|
||||
return Room.databaseBuilder(
|
||||
context.applicationContext,
|
||||
Database::class.java,
|
||||
"profiles"
|
||||
).addMigrations(*MIGRATIONS).build()
|
||||
}
|
||||
|
||||
init {
|
||||
Global.launch(Dispatchers.IO) {
|
||||
LEGACY_MIGRATION(Global.application)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package com.github.kr328.clash.service.data
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.TypeConverters
|
||||
import com.github.kr328.clash.service.model.Profile
|
||||
import java.util.*
|
||||
|
||||
@Entity(tableName = "imported", primaryKeys = ["uuid"])
|
||||
@TypeConverters(Converters::class)
|
||||
data class Imported(
|
||||
@ColumnInfo(name = "uuid") val uuid: UUID,
|
||||
@ColumnInfo(name = "name") val name: String,
|
||||
@ColumnInfo(name = "type") val type: Profile.Type,
|
||||
@ColumnInfo(name = "source") val source: String,
|
||||
@ColumnInfo(name = "interval") val interval: Long,
|
||||
@ColumnInfo(name = "upload") val upload: Long,
|
||||
@ColumnInfo(name = "download") val download: Long,
|
||||
@ColumnInfo(name = "total") val total: Long,
|
||||
@ColumnInfo(name = "expire") val expire: Long,
|
||||
@ColumnInfo(name = "createdAt") val createdAt: Long,
|
||||
)
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
package com.github.kr328.clash.service.data
|
||||
|
||||
import androidx.room.*
|
||||
import java.util.*
|
||||
|
||||
@Dao
|
||||
@TypeConverters(Converters::class)
|
||||
interface ImportedDao {
|
||||
@Query("SELECT * FROM imported WHERE uuid = :uuid")
|
||||
suspend fun queryByUUID(uuid: UUID): Imported?
|
||||
|
||||
@Query("SELECT uuid FROM imported ORDER BY createdAt")
|
||||
suspend fun queryAllUUIDs(): List<UUID>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.ABORT)
|
||||
suspend fun insert(imported: Imported): Long
|
||||
|
||||
@Update(onConflict = OnConflictStrategy.ABORT)
|
||||
suspend fun update(imported: Imported)
|
||||
|
||||
@Query("DELETE FROM imported WHERE uuid = :uuid")
|
||||
suspend fun remove(uuid: UUID)
|
||||
|
||||
@Query("SELECT EXISTS(SELECT 1 FROM imported WHERE uuid = :uuid)")
|
||||
suspend fun exists(uuid: UUID): Boolean
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package com.github.kr328.clash.service.data
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.TypeConverters
|
||||
import com.github.kr328.clash.service.model.Profile
|
||||
import java.util.*
|
||||
|
||||
@Entity(tableName = "pending", primaryKeys = ["uuid"])
|
||||
@TypeConverters(Converters::class)
|
||||
data class Pending(
|
||||
@ColumnInfo(name = "uuid") val uuid: UUID,
|
||||
@ColumnInfo(name = "name") val name: String,
|
||||
@ColumnInfo(name = "type") val type: Profile.Type,
|
||||
@ColumnInfo(name = "source") val source: String,
|
||||
@ColumnInfo(name = "interval") val interval: Long,
|
||||
@ColumnInfo(name = "upload") val upload: Long,
|
||||
@ColumnInfo(name = "download") val download: Long,
|
||||
@ColumnInfo(name = "total") val total: Long,
|
||||
@ColumnInfo(name = "expire") val expire: Long,
|
||||
@ColumnInfo(name = "createdAt") val createdAt: Long = System.currentTimeMillis(),
|
||||
)
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
package com.github.kr328.clash.service.data
|
||||
|
||||
import androidx.room.*
|
||||
import java.util.*
|
||||
|
||||
@Dao
|
||||
@TypeConverters(Converters::class)
|
||||
interface PendingDao {
|
||||
@Query("SELECT * FROM pending WHERE uuid = :uuid")
|
||||
suspend fun queryByUUID(uuid: UUID): Pending?
|
||||
|
||||
@Query("DELETE FROM pending WHERE uuid = :uuid")
|
||||
suspend fun remove(uuid: UUID)
|
||||
|
||||
@Query("SELECT EXISTS(SELECT 1 FROM pending WHERE uuid = :uuid)")
|
||||
suspend fun exists(uuid: UUID): Boolean
|
||||
|
||||
@Query("SELECT uuid FROM pending ORDER BY createdAt")
|
||||
suspend fun queryAllUUIDs(): List<UUID>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insert(pending: Pending)
|
||||
|
||||
@Update(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun update(pending: Pending)
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package com.github.kr328.clash.service.data
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import androidx.room.TypeConverters
|
||||
import java.util.*
|
||||
|
||||
@Entity(
|
||||
tableName = "selections",
|
||||
foreignKeys = [ForeignKey(
|
||||
entity = Imported::class,
|
||||
childColumns = ["uuid"],
|
||||
parentColumns = ["uuid"],
|
||||
onDelete = ForeignKey.CASCADE,
|
||||
onUpdate = ForeignKey.CASCADE
|
||||
)],
|
||||
primaryKeys = ["uuid", "proxy"]
|
||||
)
|
||||
@TypeConverters(Converters::class)
|
||||
data class Selection(
|
||||
@ColumnInfo(name = "uuid") val uuid: UUID,
|
||||
@ColumnInfo(name = "proxy") val proxy: String,
|
||||
@ColumnInfo(name = "selected") val selected: String,
|
||||
)
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package com.github.kr328.clash.service.data
|
||||
|
||||
import androidx.room.*
|
||||
import java.util.*
|
||||
|
||||
@Dao
|
||||
@TypeConverters(Converters::class)
|
||||
interface SelectionDao {
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
fun setSelected(selection: Selection)
|
||||
|
||||
@Query("DELETE FROM selections WHERE uuid = :uuid AND proxy = :proxy")
|
||||
fun removeSelected(uuid: UUID, proxy: String)
|
||||
|
||||
@Query("SELECT * FROM selections WHERE uuid = :uuid")
|
||||
suspend fun querySelections(uuid: UUID): List<Selection>
|
||||
|
||||
@Query("DELETE FROM selections WHERE uuid = :uuid AND proxy in (:proxies)")
|
||||
suspend fun removeSelections(uuid: UUID, proxies: List<String>)
|
||||
}
|
||||
+198
@@ -0,0 +1,198 @@
|
||||
@file:Suppress("BlockingMethodInNonBlockingContext")
|
||||
|
||||
package com.github.kr328.clash.service.data.migrations
|
||||
|
||||
import android.content.Context
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import androidx.core.text.isDigitsOnly
|
||||
import com.github.kr328.clash.common.log.Log
|
||||
import com.github.kr328.clash.service.data.Pending
|
||||
import com.github.kr328.clash.service.data.PendingDao
|
||||
import com.github.kr328.clash.service.model.Profile
|
||||
import com.github.kr328.clash.service.util.generateProfileUUID
|
||||
import com.github.kr328.clash.service.util.pendingDir
|
||||
import com.github.kr328.clash.service.util.sendProfileChanged
|
||||
import java.io.File
|
||||
|
||||
internal suspend fun migrationFromLegacy(context: Context) {
|
||||
val file = context.getDatabasePath("clash-config")
|
||||
|
||||
if (!file.exists()) {
|
||||
return
|
||||
}
|
||||
|
||||
Log.i("Migration from legacy database")
|
||||
|
||||
try {
|
||||
SQLiteDatabase.openDatabase(file.absolutePath, null, SQLiteDatabase.OPEN_READONLY)
|
||||
.use { db ->
|
||||
val v = db.version
|
||||
|
||||
Log.i("Legacy database version = $v")
|
||||
|
||||
when (v) {
|
||||
1 -> migrationFromLegacy1(context, db)
|
||||
2, 3, 4 -> migrationFromLegacy234(context, db, v)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w("Migration legacy database: $e", e)
|
||||
}
|
||||
|
||||
context.deleteDatabase("clash-config")
|
||||
|
||||
Log.i("Legacy database migrated")
|
||||
}
|
||||
|
||||
private suspend fun migrationFromLegacy234(
|
||||
context: Context,
|
||||
legacy: SQLiteDatabase,
|
||||
version: Int,
|
||||
) {
|
||||
legacy.query(
|
||||
"profiles",
|
||||
arrayOf("id", "name", "type", "uri", if (version == 2) "update_interval" else "interval"),
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
"id"
|
||||
).use { cursor ->
|
||||
val id = cursor.getColumnIndex("id")
|
||||
val name = cursor.getColumnIndex("name")
|
||||
val type = cursor.getColumnIndex("type")
|
||||
val uri = cursor.getColumnIndex("uri")
|
||||
val interval = cursor.getColumnIndex(if (version == 2) "update_interval" else "interval")
|
||||
|
||||
if (!cursor.moveToFirst())
|
||||
return
|
||||
|
||||
do {
|
||||
val newType = when (cursor.getInt(type)) {
|
||||
1 -> { // TYPE_FILE
|
||||
Profile.Type.File
|
||||
}
|
||||
2 -> { // TYPE_URL
|
||||
Profile.Type.Url
|
||||
}
|
||||
3 -> { // TYPE_EXTERNAL
|
||||
Profile.Type.External
|
||||
}
|
||||
else -> { // unknown
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
val idValue = cursor.getInt(id)
|
||||
val intervalValue = cursor.getLong(interval)
|
||||
|
||||
val pending = Pending(
|
||||
uuid = generateProfileUUID(),
|
||||
name = cursor.getString(name),
|
||||
type = newType,
|
||||
source = if (newType != Profile.Type.File) cursor.getString(uri) else "",
|
||||
interval = if (version == 2) intervalValue * 1000 else intervalValue,
|
||||
0,0,0,0
|
||||
)
|
||||
|
||||
val base = context.pendingDir.resolve(pending.uuid.toString())
|
||||
|
||||
base.apply {
|
||||
mkdirs()
|
||||
|
||||
resolve("config.yaml").createNewFile()
|
||||
resolve("providers").mkdir()
|
||||
}
|
||||
|
||||
if (newType == Profile.Type.File) {
|
||||
val legacyFile = context.filesDir.resolve("profiles/$idValue.yaml")
|
||||
|
||||
if (legacyFile.isFile) {
|
||||
legacyFile.copyTo(base.resolve("config.yaml"), overwrite = true)
|
||||
}
|
||||
}
|
||||
|
||||
PendingDao().insert(pending)
|
||||
|
||||
context.sendProfileChanged(pending.uuid)
|
||||
|
||||
Log.i("${pending.name} migrated")
|
||||
} while (cursor.moveToNext())
|
||||
}
|
||||
|
||||
context.filesDir.resolve("profiles").deleteRecursively()
|
||||
context.filesDir.resolve("clash").listFiles()?.forEach {
|
||||
if (it.name.isDigitsOnly()) {
|
||||
it.deleteRecursively()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun migrationFromLegacy1(context: Context, legacy: SQLiteDatabase) {
|
||||
legacy.query(
|
||||
"profiles",
|
||||
arrayOf("name", "token", "id", "file"),
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
"id",
|
||||
).use { cursor ->
|
||||
val name = cursor.getColumnIndex("name")
|
||||
val token = cursor.getColumnIndex("token")
|
||||
val file = cursor.getColumnIndex("file")
|
||||
|
||||
if (!cursor.moveToFirst())
|
||||
return
|
||||
|
||||
do {
|
||||
val legacyToken = cursor.getString(token)
|
||||
|
||||
val newType = when {
|
||||
legacyToken.startsWith("file|") -> Profile.Type.File
|
||||
legacyToken.startsWith("url|") -> Profile.Type.Url
|
||||
else -> continue
|
||||
}
|
||||
|
||||
val source = if (newType == Profile.Type.Url) {
|
||||
legacyToken.removePrefix("url|")
|
||||
} else {
|
||||
""
|
||||
}
|
||||
|
||||
val pending = Pending(
|
||||
uuid = generateProfileUUID(),
|
||||
name = cursor.getString(name),
|
||||
type = newType,
|
||||
source = source,
|
||||
interval = 0,
|
||||
0,0,0,0
|
||||
)
|
||||
|
||||
val base = context.pendingDir.resolve(pending.uuid.toString())
|
||||
|
||||
base.apply {
|
||||
mkdirs()
|
||||
|
||||
resolve("config.yaml").createNewFile()
|
||||
resolve("providers").mkdir()
|
||||
}
|
||||
|
||||
val legacyFile = File(cursor.getString(file))
|
||||
|
||||
if (newType == Profile.Type.File) {
|
||||
if (legacyFile.isFile) {
|
||||
legacyFile.copyTo(base.resolve("config.yaml"), overwrite = true)
|
||||
}
|
||||
}
|
||||
|
||||
legacyFile.delete()
|
||||
|
||||
PendingDao().insert(pending)
|
||||
|
||||
context.sendProfileChanged(pending.uuid)
|
||||
|
||||
Log.i("${pending.name} migrated")
|
||||
} while (cursor.moveToNext())
|
||||
}
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package com.github.kr328.clash.service.data.migrations
|
||||
|
||||
import androidx.room.migration.Migration
|
||||
|
||||
val MIGRATIONS: Array<Migration> = arrayOf()
|
||||
|
||||
val LEGACY_MIGRATION = ::migrationFromLegacy
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
package com.github.kr328.clash.service.document
|
||||
|
||||
interface Document {
|
||||
val id: String
|
||||
val name: String
|
||||
val mimeType: String
|
||||
val size: Long
|
||||
val updatedAt: Long
|
||||
val flags: Set<Flag>
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package com.github.kr328.clash.service.document
|
||||
|
||||
import android.provider.DocumentsContract
|
||||
import java.io.File
|
||||
|
||||
class FileDocument(
|
||||
val file: File,
|
||||
override val flags: Set<Flag>,
|
||||
private val idOverride: String? = null,
|
||||
private val nameOverride: String? = null,
|
||||
) : Document {
|
||||
override val id: String
|
||||
get() = idOverride ?: file.name
|
||||
override val name: String
|
||||
get() = nameOverride ?: file.name
|
||||
override val mimeType: String
|
||||
get() = if (file.isDirectory) DocumentsContract.Document.MIME_TYPE_DIR else "text/plain"
|
||||
override val size: Long
|
||||
get() = file.length()
|
||||
override val updatedAt: Long
|
||||
get() = file.lastModified()
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
package com.github.kr328.clash.service.document
|
||||
|
||||
enum class Flag {
|
||||
Writable, Deletable, Virtual
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
package com.github.kr328.clash.service.document
|
||||
|
||||
import java.util.*
|
||||
|
||||
data class Path(
|
||||
val uuid: UUID?,
|
||||
val scope: Scope?,
|
||||
val relative: List<String>?
|
||||
) {
|
||||
enum class Scope {
|
||||
Configuration, Providers
|
||||
}
|
||||
|
||||
override fun toString(): String {
|
||||
if (uuid == null)
|
||||
return "/"
|
||||
|
||||
if (scope == null)
|
||||
return "/$uuid"
|
||||
|
||||
val sc = when (scope) {
|
||||
Scope.Configuration -> Paths.CONFIGURATION_ID
|
||||
Scope.Providers -> Paths.PROVIDERS_ID
|
||||
}
|
||||
|
||||
if (relative == null)
|
||||
return "/$uuid/$sc"
|
||||
|
||||
return "/$uuid/$sc/${relative.joinToString(separator = "/")}"
|
||||
}
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
package com.github.kr328.clash.service.document
|
||||
|
||||
import java.util.*
|
||||
|
||||
object Paths {
|
||||
const val CONFIGURATION_ID = "config.yaml"
|
||||
const val PROVIDERS_ID = "providers"
|
||||
|
||||
fun resolve(path: String): Path {
|
||||
val segments = path.split("/").filter { it.isNotBlank() && it != "." && it != ".." }
|
||||
|
||||
return when (segments.size) {
|
||||
0 -> Path(
|
||||
uuid = null,
|
||||
scope = null,
|
||||
relative = null,
|
||||
)
|
||||
1 -> Path(
|
||||
uuid = UUID.fromString(segments[0]),
|
||||
scope = null,
|
||||
relative = null,
|
||||
)
|
||||
2 -> Path(
|
||||
uuid = UUID.fromString(segments[0]),
|
||||
scope = when (segments[1]) {
|
||||
CONFIGURATION_ID -> Path.Scope.Configuration
|
||||
PROVIDERS_ID -> Path.Scope.Providers
|
||||
else -> throw IllegalArgumentException("unknown scope ${segments[1]}")
|
||||
},
|
||||
relative = null,
|
||||
)
|
||||
else -> Path(
|
||||
uuid = UUID.fromString(segments[0]),
|
||||
scope = when (segments[1]) {
|
||||
CONFIGURATION_ID -> Path.Scope.Configuration
|
||||
PROVIDERS_ID -> Path.Scope.Providers
|
||||
else -> throw IllegalArgumentException("unknown scope ${segments[1]}")
|
||||
},
|
||||
relative = segments.drop(2),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+147
@@ -0,0 +1,147 @@
|
||||
package com.github.kr328.clash.service.document
|
||||
|
||||
import android.content.Context
|
||||
import android.provider.DocumentsContract
|
||||
import com.github.kr328.clash.service.R
|
||||
import com.github.kr328.clash.service.data.ImportedDao
|
||||
import com.github.kr328.clash.service.data.Pending
|
||||
import com.github.kr328.clash.service.data.PendingDao
|
||||
import com.github.kr328.clash.service.model.Profile
|
||||
import com.github.kr328.clash.service.util.importedDir
|
||||
import com.github.kr328.clash.service.util.pendingDir
|
||||
import java.io.FileNotFoundException
|
||||
import java.util.*
|
||||
|
||||
class Picker(private val context: Context) {
|
||||
suspend fun list(path: Path): List<Document> {
|
||||
if (path.uuid == null) {
|
||||
return ImportedDao().queryAllUUIDs().map {
|
||||
pick(path.copy(uuid = it), false)
|
||||
}
|
||||
}
|
||||
|
||||
if (path.scope == null) {
|
||||
return listOf(Path.Scope.Configuration, Path.Scope.Providers).map {
|
||||
pick(path.copy(scope = it), false)
|
||||
}
|
||||
}
|
||||
|
||||
val parent = pick(path, false)
|
||||
|
||||
if (parent !is FileDocument)
|
||||
return emptyList()
|
||||
|
||||
return (parent.file.list() ?: emptyArray()).map {
|
||||
pick(path.copy(relative = (path.relative ?: emptyList()) + it), false)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun pick(path: Path, writable: Boolean): Document {
|
||||
if (path.uuid == null) {
|
||||
return VirtualDocument(
|
||||
"",
|
||||
context.getString(R.string.clash_meta_for_android),
|
||||
DocumentsContract.Document.MIME_TYPE_DIR,
|
||||
0,
|
||||
0,
|
||||
setOf(Flag.Virtual),
|
||||
)
|
||||
}
|
||||
|
||||
if (writable) {
|
||||
cloneToPending(path.uuid)
|
||||
}
|
||||
|
||||
val imported = ImportedDao().queryByUUID(path.uuid)
|
||||
val pending = PendingDao().queryByUUID(path.uuid)
|
||||
|
||||
if (path.scope == null) {
|
||||
if (writable)
|
||||
throw IllegalArgumentException("invalid open mode")
|
||||
|
||||
return VirtualDocument(
|
||||
id = path.uuid.toString(),
|
||||
name = pending?.name ?: imported?.name
|
||||
?: throw FileNotFoundException("profile not found"),
|
||||
mimeType = DocumentsContract.Document.MIME_TYPE_DIR,
|
||||
size = 0,
|
||||
updatedAt = 0,
|
||||
flags = setOf(Flag.Virtual),
|
||||
)
|
||||
}
|
||||
|
||||
if (path.relative == null) {
|
||||
if (path.scope == Path.Scope.Configuration) {
|
||||
val type = pending?.type ?: imported?.type
|
||||
?: throw FileNotFoundException("profile not found")
|
||||
|
||||
if (writable && type != Profile.Type.File)
|
||||
throw IllegalArgumentException("invalid open mode")
|
||||
|
||||
val flags: Set<Flag> = if (type == Profile.Type.Url)
|
||||
emptySet()
|
||||
else
|
||||
setOf(Flag.Writable)
|
||||
|
||||
return FileDocument(
|
||||
file = when {
|
||||
pending != null -> context.pendingDir.resolve(pending.uuid.toString())
|
||||
imported != null -> context.importedDir.resolve(imported.uuid.toString())
|
||||
else -> throw FileNotFoundException("profile not found")
|
||||
}.resolve("config.yaml"),
|
||||
flags = flags,
|
||||
idOverride = Paths.CONFIGURATION_ID,
|
||||
nameOverride = context.getString(R.string.configuration_yaml)
|
||||
)
|
||||
} else {
|
||||
return FileDocument(
|
||||
file = when {
|
||||
pending != null -> context.pendingDir.resolve(pending.uuid.toString())
|
||||
imported != null -> context.importedDir.resolve(imported.uuid.toString())
|
||||
else -> throw FileNotFoundException("profile not found")
|
||||
}.resolve("providers"),
|
||||
idOverride = Paths.PROVIDERS_ID,
|
||||
nameOverride = context.getString(R.string.provider_files),
|
||||
flags = setOf(Flag.Virtual)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (path.scope != Path.Scope.Providers)
|
||||
throw FileNotFoundException("invalid path")
|
||||
|
||||
return FileDocument(
|
||||
file = when {
|
||||
pending != null -> context.pendingDir.resolve(pending.uuid.toString())
|
||||
imported != null -> context.importedDir.resolve(imported.uuid.toString())
|
||||
else -> throw FileNotFoundException("profile not found")
|
||||
}.resolve("providers").resolve(path.relative.joinToString(separator = "/")),
|
||||
flags = setOf(Flag.Writable, Flag.Deletable)
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun cloneToPending(uuid: UUID) {
|
||||
if (PendingDao().queryByUUID(uuid) != null)
|
||||
return
|
||||
|
||||
val imported =
|
||||
ImportedDao().queryByUUID(uuid) ?: throw FileNotFoundException("profile not found")
|
||||
|
||||
PendingDao().insert(
|
||||
Pending(
|
||||
imported.uuid,
|
||||
imported.name,
|
||||
imported.type,
|
||||
imported.source,
|
||||
imported.interval,
|
||||
0,0,0,0
|
||||
)
|
||||
)
|
||||
|
||||
val source = context.importedDir.resolve(uuid.toString())
|
||||
val target = context.pendingDir.resolve(uuid.toString())
|
||||
|
||||
target.deleteRecursively()
|
||||
source.copyRecursively(target)
|
||||
}
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
package com.github.kr328.clash.service.document
|
||||
|
||||
class VirtualDocument(
|
||||
override val id: String,
|
||||
override val name: String,
|
||||
override val mimeType: String,
|
||||
override val size: Long,
|
||||
override val updatedAt: Long,
|
||||
override val flags: Set<Flag>,
|
||||
) : Document
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
package com.github.kr328.clash.service.model
|
||||
|
||||
enum class AccessControlMode {
|
||||
AcceptAll, AcceptSelected, DenySelected
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
@file:UseSerializers(UUIDSerializer::class)
|
||||
|
||||
package com.github.kr328.clash.service.model
|
||||
|
||||
import android.os.Parcel
|
||||
import android.os.Parcelable
|
||||
import com.github.kr328.clash.core.util.Parcelizer
|
||||
import com.github.kr328.clash.service.util.UUIDSerializer
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.UseSerializers
|
||||
import java.util.*
|
||||
|
||||
@Serializable
|
||||
data class Profile(
|
||||
val uuid: UUID,
|
||||
val name: String,
|
||||
val type: Type,
|
||||
val source: String,
|
||||
val active: Boolean,
|
||||
val interval: Long,
|
||||
val upload: Long,
|
||||
var download: Long,
|
||||
val total: Long,
|
||||
val expire: Long,
|
||||
|
||||
|
||||
val updatedAt: Long,
|
||||
val imported: Boolean,
|
||||
val pending: Boolean,
|
||||
) : Parcelable {
|
||||
enum class Type {
|
||||
File, Url, External
|
||||
}
|
||||
|
||||
override fun writeToParcel(parcel: Parcel, flags: Int) {
|
||||
Parcelizer.encodeToParcel(serializer(), parcel, this)
|
||||
}
|
||||
|
||||
override fun describeContents(): Int {
|
||||
return 0
|
||||
}
|
||||
|
||||
companion object CREATOR : Parcelable.Creator<Profile> {
|
||||
override fun createFromParcel(parcel: Parcel): Profile {
|
||||
return Parcelizer.decodeFromParcel(serializer(), parcel)
|
||||
}
|
||||
|
||||
override fun newArray(size: Int): Array<Profile?> {
|
||||
return arrayOfNulls(size)
|
||||
}
|
||||
}
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
package com.github.kr328.clash.service.remote
|
||||
|
||||
import com.github.kr328.clash.core.Clash
|
||||
import com.github.kr328.clash.core.model.*
|
||||
import com.github.kr328.kaidl.BinderInterface
|
||||
|
||||
@BinderInterface
|
||||
interface IClashManager {
|
||||
fun queryTunnelState(): TunnelState
|
||||
fun queryTrafficTotal(): Long
|
||||
fun queryProxyGroupNames(excludeNotSelectable: Boolean): List<String>
|
||||
fun queryProxyGroup(name: String, proxySort: ProxySort): ProxyGroup
|
||||
fun queryConfiguration(): UiConfiguration
|
||||
fun queryProviders(): ProviderList
|
||||
|
||||
fun patchSelector(group: String, name: String): Boolean
|
||||
|
||||
suspend fun healthCheck(group: String)
|
||||
suspend fun updateProvider(type: Provider.Type, name: String)
|
||||
|
||||
fun queryOverride(slot: Clash.OverrideSlot): ConfigurationOverride
|
||||
fun patchOverride(slot: Clash.OverrideSlot, configuration: ConfigurationOverride)
|
||||
fun clearOverride(slot: Clash.OverrideSlot)
|
||||
|
||||
fun setLogObserver(observer: ILogObserver?)
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package com.github.kr328.clash.service.remote
|
||||
|
||||
import com.github.kr328.clash.core.model.FetchStatus
|
||||
import com.github.kr328.kaidl.BinderInterface
|
||||
|
||||
@BinderInterface
|
||||
fun interface IFetchObserver {
|
||||
fun updateStatus(status: FetchStatus)
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package com.github.kr328.clash.service.remote
|
||||
|
||||
import com.github.kr328.clash.core.model.LogMessage
|
||||
import com.github.kr328.kaidl.BinderInterface
|
||||
|
||||
@BinderInterface
|
||||
interface ILogObserver {
|
||||
fun newItem(log: LogMessage)
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package com.github.kr328.clash.service.remote
|
||||
|
||||
import com.github.kr328.clash.service.model.Profile
|
||||
import com.github.kr328.kaidl.BinderInterface
|
||||
import java.util.*
|
||||
|
||||
@BinderInterface
|
||||
interface IProfileManager {
|
||||
suspend fun create(type: Profile.Type, name: String, source: String = ""): UUID
|
||||
suspend fun clone(uuid: UUID): UUID
|
||||
suspend fun commit(uuid: UUID, callback: IFetchObserver? = null)
|
||||
suspend fun release(uuid: UUID)
|
||||
suspend fun delete(uuid: UUID)
|
||||
suspend fun patch(uuid: UUID, name: String, source: String, interval: Long)
|
||||
suspend fun update(uuid: UUID)
|
||||
suspend fun queryByUUID(uuid: UUID): Profile?
|
||||
suspend fun queryAll(): List<Profile>
|
||||
suspend fun queryActive(): Profile?
|
||||
suspend fun setActive(profile: Profile)
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package com.github.kr328.clash.service.remote
|
||||
|
||||
import com.github.kr328.kaidl.BinderInterface
|
||||
|
||||
@BinderInterface
|
||||
interface IRemoteService {
|
||||
fun clash(): IClashManager
|
||||
fun profile(): IProfileManager
|
||||
}
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
package com.github.kr328.clash.service.store
|
||||
|
||||
import android.content.Context
|
||||
import com.github.kr328.clash.common.store.Store
|
||||
import com.github.kr328.clash.common.store.asStoreProvider
|
||||
import com.github.kr328.clash.service.PreferenceProvider
|
||||
import com.github.kr328.clash.service.model.AccessControlMode
|
||||
import java.util.*
|
||||
|
||||
class ServiceStore(context: Context) {
|
||||
private val store = Store(
|
||||
PreferenceProvider
|
||||
.createSharedPreferencesFromContext(context)
|
||||
.asStoreProvider()
|
||||
)
|
||||
|
||||
var activeProfile: UUID? by store.typedString(
|
||||
key = "active_profile",
|
||||
from = { if (it.isBlank()) null else UUID.fromString(it) },
|
||||
to = { it?.toString() ?: "" }
|
||||
)
|
||||
|
||||
var bypassPrivateNetwork: Boolean by store.boolean(
|
||||
key = "bypass_private_network",
|
||||
defaultValue = true
|
||||
)
|
||||
|
||||
var accessControlMode: AccessControlMode by store.enum(
|
||||
key = "access_control_mode",
|
||||
defaultValue = AccessControlMode.AcceptAll,
|
||||
values = AccessControlMode.values()
|
||||
)
|
||||
|
||||
var accessControlPackages by store.stringSet(
|
||||
key = "access_control_packages",
|
||||
defaultValue = emptySet()
|
||||
)
|
||||
|
||||
var dnsHijacking by store.boolean(
|
||||
key = "dns_hijacking",
|
||||
defaultValue = true
|
||||
)
|
||||
|
||||
var systemProxy by store.boolean(
|
||||
key = "system_proxy",
|
||||
defaultValue = true
|
||||
)
|
||||
|
||||
var allowBypass by store.boolean(
|
||||
key = "allow_bypass",
|
||||
defaultValue = true
|
||||
)
|
||||
|
||||
var allowIpv6 by store.boolean(
|
||||
key = "allow_ipv6",
|
||||
defaultValue = false
|
||||
)
|
||||
|
||||
var tunStackMode by store.string(
|
||||
key = "tun_stack_mode",
|
||||
defaultValue = "system"
|
||||
)
|
||||
|
||||
var dynamicNotification by store.boolean(
|
||||
key = "dynamic_notification",
|
||||
defaultValue = true
|
||||
)
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
package com.github.kr328.clash.service.util
|
||||
|
||||
import java.net.Inet4Address
|
||||
import java.net.Inet6Address
|
||||
import java.net.InetAddress
|
||||
|
||||
fun InetAddress.asSocketAddressText(port: Int): String {
|
||||
return when (this) {
|
||||
is Inet6Address ->
|
||||
"[${numericToTextFormat(this.address)}]:$port"
|
||||
is Inet4Address ->
|
||||
"${this.hostAddress}:$port"
|
||||
else -> throw IllegalArgumentException("Unsupported Inet type ${this.javaClass}")
|
||||
}
|
||||
}
|
||||
|
||||
private const val INT16SZ = 2
|
||||
private const val INADDRSZ = 16
|
||||
private fun numericToTextFormat(src: ByteArray): String {
|
||||
val sb = StringBuilder(39)
|
||||
for (i in 0 until INADDRSZ / INT16SZ) {
|
||||
sb.append(
|
||||
Integer.toHexString(
|
||||
src[i shl 1].toInt() shl 8 and 0xff00
|
||||
or (src[(i shl 1) + 1].toInt() and 0xff)
|
||||
)
|
||||
)
|
||||
if (i < INADDRSZ / INT16SZ - 1) {
|
||||
sb.append(":")
|
||||
}
|
||||
}
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
package com.github.kr328.clash.service.util
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import com.github.kr328.clash.common.constants.Intents
|
||||
import com.github.kr328.clash.common.constants.Permissions
|
||||
import java.util.*
|
||||
|
||||
fun Context.sendBroadcastSelf(intent: Intent) {
|
||||
sendBroadcast(
|
||||
intent.setPackage(this.packageName),
|
||||
Permissions.RECEIVE_SELF_BROADCASTS
|
||||
)
|
||||
}
|
||||
|
||||
fun Context.sendProfileChanged(uuid: UUID) {
|
||||
val intent = Intent(Intents.ACTION_PROFILE_CHANGED)
|
||||
.putExtra(Intents.EXTRA_UUID, uuid.toString())
|
||||
|
||||
sendBroadcastSelf(intent)
|
||||
}
|
||||
|
||||
fun Context.sendProfileLoaded(uuid: UUID) {
|
||||
val intent = Intent(Intents.ACTION_PROFILE_LOADED)
|
||||
.putExtra(Intents.EXTRA_UUID, uuid.toString())
|
||||
|
||||
sendBroadcastSelf(intent)
|
||||
}
|
||||
|
||||
fun Context.sendProfileUpdateCompleted(uuid: UUID) {
|
||||
val intent = Intent(Intents.ACTION_PROFILE_UPDATE_COMPLETED)
|
||||
.putExtra(Intents.EXTRA_UUID, uuid.toString())
|
||||
|
||||
sendBroadcastSelf(intent)
|
||||
}
|
||||
|
||||
fun Context.sendProfileUpdateFailed(uuid: UUID, reason: String) {
|
||||
val intent = Intent(Intents.ACTION_PROFILE_UPDATE_FAILED)
|
||||
.putExtra(Intents.EXTRA_UUID, uuid.toString())
|
||||
.putExtra(Intents.EXTRA_FAIL_REASON, reason)
|
||||
|
||||
sendBroadcastSelf(intent)
|
||||
}
|
||||
|
||||
fun Context.sendOverrideChanged() {
|
||||
val intent = Intent(Intents.ACTION_OVERRIDE_CHANGED)
|
||||
|
||||
sendBroadcastSelf(intent)
|
||||
}
|
||||
|
||||
fun Context.sendServiceRecreated() {
|
||||
sendBroadcastSelf(Intent(Intents.ACTION_SERVICE_RECREATED))
|
||||
}
|
||||
|
||||
fun Context.sendClashStarted() {
|
||||
sendBroadcastSelf(Intent(Intents.ACTION_CLASH_STARTED))
|
||||
}
|
||||
|
||||
fun Context.sendClashStopped(reason: String?) {
|
||||
sendBroadcastSelf(
|
||||
Intent(Intents.ACTION_CLASH_STOPPED).putExtra(
|
||||
Intents.EXTRA_STOP_REASON,
|
||||
reason
|
||||
)
|
||||
)
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
package com.github.kr328.clash.service.util
|
||||
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.Network
|
||||
|
||||
fun ConnectivityManager.resolvePrimaryDns(network: Network?): String? {
|
||||
val properties = getLinkProperties(network) ?: return null
|
||||
|
||||
return properties.dnsServers.firstOrNull()?.asSocketAddressText(53)
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package com.github.kr328.clash.service.util
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.job
|
||||
import kotlinx.coroutines.runBlocking
|
||||
|
||||
fun CoroutineScope.cancelAndJoinBlocking() {
|
||||
val scope = this
|
||||
|
||||
runBlocking {
|
||||
scope.coroutineContext.job.cancel()
|
||||
scope.coroutineContext.job.join()
|
||||
}
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package com.github.kr328.clash.service.util
|
||||
|
||||
import com.github.kr328.clash.service.data.ImportedDao
|
||||
import com.github.kr328.clash.service.data.PendingDao
|
||||
import java.util.*
|
||||
|
||||
suspend fun generateProfileUUID(): UUID {
|
||||
var result = UUID.randomUUID()
|
||||
|
||||
while (ImportedDao().exists(result) || PendingDao().exists(result)) {
|
||||
result = UUID.randomUUID()
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
package com.github.kr328.clash.service.util
|
||||
|
||||
import android.content.Context
|
||||
import java.io.File
|
||||
|
||||
val Context.importedDir: File
|
||||
get() = filesDir.resolve("imported")
|
||||
|
||||
val Context.pendingDir: File
|
||||
get() = filesDir.resolve("pending")
|
||||
|
||||
val Context.processingDir: File
|
||||
get() = filesDir.resolve("processing")
|
||||
|
||||
val File.directoryLastModified: Long?
|
||||
get() {
|
||||
return walk().map { it.lastModified() }.maxOrNull()
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
package com.github.kr328.clash.service.util
|
||||
|
||||
import android.content.Intent
|
||||
|
||||
val Intent.packageName: String?
|
||||
get() {
|
||||
return data?.takeIf { it.scheme == "package" }?.schemeSpecificPart
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.github.kr328.clash.service.util
|
||||
|
||||
data class IPNet(val ip: String, val prefix: Int)
|
||||
|
||||
fun parseCIDR(cidr: String): IPNet {
|
||||
val s = cidr.split("/", limit = 2)
|
||||
|
||||
if (s.size != 2)
|
||||
throw IllegalArgumentException("Invalid address")
|
||||
|
||||
val address = s[0]
|
||||
val prefix = s[1].toInt()
|
||||
|
||||
return IPNet(address, prefix)
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package com.github.kr328.clash.service.util
|
||||
|
||||
import kotlinx.serialization.KSerializer
|
||||
import kotlinx.serialization.descriptors.PrimitiveKind
|
||||
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
|
||||
import kotlinx.serialization.descriptors.SerialDescriptor
|
||||
import kotlinx.serialization.encoding.Decoder
|
||||
import kotlinx.serialization.encoding.Encoder
|
||||
import java.util.*
|
||||
|
||||
class UUIDSerializer : KSerializer<UUID> {
|
||||
override val descriptor: SerialDescriptor =
|
||||
PrimitiveSerialDescriptor("UUID", PrimitiveKind.STRING)
|
||||
|
||||
override fun deserialize(decoder: Decoder): UUID {
|
||||
return UUID.fromString(decoder.decodeString())
|
||||
}
|
||||
|
||||
override fun serialize(encoder: Encoder, value: UUID) {
|
||||
encoder.encodeString(value.toString())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:width="205dp"
|
||||
android:height="205dp"
|
||||
android:viewportWidth="205"
|
||||
android:viewportHeight="200"
|
||||
android:tint="@color/color_clash">
|
||||
<path
|
||||
android:pathData="M 31.55 16.82 C 32.47 16.76 33.46 16.72 34.27 17.21 C 35.55 17.98 36.58 19.09 37.68 20.07 C 54.09 35.08 70.55 50.02 86.96 65.03 C 88.06 66.02 89.1 67.14 90.46 67.79 C 91.53 68.3 92.75 68.05 93.88 67.89 C 96.89 67.38 99.95 67.35 102.99 67.35 C 105.92 67.4 108.87 67.49 111.76 67.97 C 112.92 68.11 114.31 68.48 115.19 67.47 C 133.27 50.95 151.41 34.47 169.52 17.94 C 171.05 16.35 173.4 16.7 175.4 16.94 C 180.44 17.69 185.53 18.04 190.6 18.61 C 191.44 18.77 192.63 18.83 192.92 19.84 C 193.32 21.57 193.1 23.36 193.14 25.12 C 193.12 60.85 193.12 96.58 193.1 132.31 C 193.08 134.12 193.2 135.94 193.02 137.73 C 192.94 138.38 192.86 139.13 192.33 139.58 C 191.21 140.08 189.96 140.25 188.78 140.55 C 183.71 141.73 178.57 142.58 173.52 143.87 C 170.99 144.53 168.37 144.28 165.79 144.32 C 164.47 144.3 163.11 144.41 161.79 144.2 C 161.02 144.14 160.47 143.45 160.41 142.7 C 160.13 140.92 160.29 139.13 160.27 137.34 C 160.25 119.75 160.23 102.16 160.25 84.57 C 160.23 82.83 160.35 81.08 160.11 79.34 C 159.79 77.35 157.71 75.95 155.75 76.19 C 154.67 76.21 153.74 76.88 152.95 77.57 C 144.22 85.61 135.39 93.52 126.67 101.55 C 125.66 102.42 124.52 103.42 123.1 103.28 C 121.23 103.12 119.47 102.45 117.66 102.04 C 110.4 100.22 102.83 100.01 95.42 100.64 C 91.81 101.13 88.16 101.55 84.69 102.69 C 83.57 103 82.44 103.36 81.28 103.3 C 80.1 103.22 79.17 102.43 78.32 101.7 C 69.66 93.74 60.91 85.91 52.25 77.94 C 51.41 77.21 50.5 76.41 49.35 76.23 C 47.92 76.05 46.44 76.64 45.45 77.69 C 44.6 78.65 44.56 79.99 44.52 81.22 C 44.56 101.19 44.5 121.15 44.54 141.12 C 44.5 141.89 44.5 142.68 44.21 143.41 C 43.75 144.24 42.69 144.28 41.86 144.32 C 39.61 144.35 37.38 144.3 35.14 144.32 C 32.61 144.35 30.17 143.62 27.72 143.11 C 23.25 142.13 18.77 141.12 14.28 140.18 C 13.33 139.94 12.01 139.66 11.83 138.5 C 11.55 136.45 11.75 134.38 11.71 132.31 C 11.67 96.72 11.69 61.12 11.69 25.53 C 11.69 23.85 11.59 22.18 11.75 20.52 C 11.81 19.91 11.99 19.2 12.58 18.89 C 13.21 18.61 13.92 18.57 14.59 18.47 C 20.27 18.08 25.91 17.39 31.55 16.82 Z M 98.96 148.02 C 101.51 147.81 104.07 147.89 106.6 148.02 C 108.57 148.01 109.79 150.39 108.88 152.07 C 107.78 154.27 106.63 156.44 105.33 158.55 C 104.51 160.07 102.1 160.37 101.02 158.97 C 99.97 157.69 99.36 156.15 98.53 154.73 C 97.9 153.54 97.09 152.42 96.79 151.08 C 96.5 149.68 97.56 148.22 98.96 148.02 Z M 13.54 152.62 C 14.04 152.56 14.53 152.52 15.04 152.52 C 29.14 152.56 43.24 152.54 57.36 152.54 C 58.62 152.52 60.06 152.83 60.81 153.98 C 61.58 155.26 61.42 157.25 60.08 158.1 C 59.17 158.73 58.01 158.73 56.94 158.77 C 42.98 158.73 29 158.75 15.04 158.77 C 14.26 158.73 13.47 158.73 12.74 158.48 C 11.53 158.02 10.73 156.7 10.9 155.42 C 11 154.04 12.11 152.74 13.54 152.62 Z M 146.68 152.62 C 148.57 152.42 150.49 152.58 152.4 152.54 C 164.96 152.52 177.54 152.58 190.1 152.52 C 190.91 152.56 191.76 152.54 192.51 152.87 C 193.63 153.37 194.26 154.59 194.26 155.79 C 194.28 157.19 193.12 158.51 191.72 158.65 C 189.92 158.87 188.11 158.73 186.31 158.75 L 151.55 158.75 C 149.78 158.73 147.98 158.89 146.23 158.59 C 144.91 158.36 143.82 157.15 143.92 155.77 C 143.74 154.15 145.12 152.73 146.68 152.62 Z M 56.69 167.7 C 57.62 167.45 58.66 167.31 59.55 167.76 C 61.4 168.61 61.84 171.57 60.18 172.85 C 59.19 173.54 58.01 173.81 56.91 174.21 C 52.73 175.51 48.66 177.15 44.48 178.45 C 39.67 179.97 34.96 181.76 30.17 183.38 C 26.68 184.5 23.21 185.68 19.74 186.89 C 18.3 187.38 16.9 188.01 15.42 188.33 C 14.51 188.52 13.5 188.35 12.79 187.74 C 11.61 186.81 11.39 185.02 12.07 183.73 C 12.62 182.81 13.72 182.47 14.67 182.1 C 17.67 181.05 20.62 179.97 23.64 179.02 C 31.19 176.57 38.62 173.74 46.2 171.35 C 49.73 170.21 53.16 168.81 56.69 167.7 Z M 146.25 167.55 C 147.33 167.29 148.4 167.7 149.42 168 C 152.72 169.12 155.95 170.38 159.26 171.45 C 167.05 173.91 174.68 176.85 182.49 179.32 C 185.05 180.14 187.59 181.09 190.16 181.98 C 191.13 182.35 192.19 182.67 192.9 183.48 C 194.02 185.02 193.35 187.52 191.56 188.21 C 190.4 188.66 189.17 188.21 188.05 187.86 C 183.41 186.14 178.72 184.6 174.03 183.06 C 169.46 181.49 164.92 179.81 160.33 178.35 C 156.15 177.03 152.1 175.39 147.92 174.11 C 146.82 173.72 145.64 173.44 144.75 172.65 C 143.15 171.09 144.02 167.94 146.25 167.55 Z"
|
||||
android:fillColor="#FFFFFFFF" />
|
||||
</vector>
|
||||
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="clash_service_status_channel">Clashステータス</string>
|
||||
<string name="profile_service_status">プロファイルサービスのステータス</string>
|
||||
<string name="profile_process_status">プロファイルの処理状況</string>
|
||||
<string name="profile_process_result">プロファイルプロセスの結果</string>
|
||||
<string name="update_successfully">正常に更新されました</string>
|
||||
<string name="update_failure">更新に失敗しました</string>
|
||||
<string name="format_update_complete">%sを更新しました</string>
|
||||
<string name="format_update_failure">更新 %1$s: %2$s </string>
|
||||
<string name="running">実行中</string>
|
||||
<string name="loading">読み込み中</string>
|
||||
<string name="clash_meta_for_android">LiteVPN</string>
|
||||
<string name="profiles_and_providers">プロファイルと外部リソース</string>
|
||||
<string name="configuration_yaml">コンフィグ.yaml</string>
|
||||
<string name="provider_files">外部リソースファイル</string>
|
||||
<string name="profile_updater">プロファイルの更新</string>
|
||||
<string name="profile_updating">プロファイルを更新中</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="clash_service_status_channel">Clash 상태</string>
|
||||
<string name="profile_service_status">구성 파일 서비스 상태</string>
|
||||
<string name="profile_process_status">구성 파일 처리 상태</string>
|
||||
<string name="profile_process_result">구성 파일 처리 결과</string>
|
||||
<string name="update_successfully">업데이트 성공</string>
|
||||
<string name="update_failure">업데이트 실패</string>
|
||||
<string name="format_update_complete">%s 업데이트 성공</string>
|
||||
<string name="format_update_failure">업데이트 %1$s: %2$s</string>
|
||||
<string name="running">연결됨</string>
|
||||
<string name="loading">로딩중</string>
|
||||
<string name="clash_meta_for_android">LiteVPN</string>
|
||||
<string name="profiles_and_providers">구성 파일과 외부 리소스</string>
|
||||
<string name="configuration_yaml">구성 파일.yaml</string>
|
||||
<string name="provider_files">외부 리소스 파일</string>
|
||||
<string name="profile_updater">구성 파일 업데이트</string>
|
||||
<string name="profile_updating">구성 파일 업데이트 중</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,21 @@
|
||||
<resources xmlns:tools="http://schemas.android.com/tools" tools:ignore="PluralsCandidate">
|
||||
<!-- from https://github.com/shadowsocks/shadowsocks-android/blob/master/core/src/main/res/values/strings.xml -->
|
||||
<string name="clash_notification_content" translatable="false">"%1$s↑\t%2$s↓"</string>
|
||||
|
||||
<string name="clash_service_status_channel">Статус Clash</string>
|
||||
<string name="profile_service_status">Статус сервиса профиля</string>
|
||||
<string name="profile_process_status">Статус обработки профиля</string>
|
||||
<string name="profile_process_result">Результат обработки профиля</string>
|
||||
<string name="update_successfully">Успешно обновлено</string>
|
||||
<string name="update_failure">Не удалось обновить</string>
|
||||
<string name="format_update_complete">Обновление %s завершено</string>
|
||||
<string name="format_update_failure">Обновление %1$s: %2$s</string>
|
||||
<string name="running">Работает</string>
|
||||
<string name="loading">Загружается</string>
|
||||
<string name="clash_meta_for_android">Clash Meta для Android</string>
|
||||
<string name="profiles_and_providers">Профили и провайдеры</string>
|
||||
<string name="configuration_yaml">Configuration.yaml</string>
|
||||
<string name="provider_files">Файлы провайдера</string>
|
||||
<string name="profile_updater">Обновление профиля</string>
|
||||
<string name="profile_updating">Профиль обновляется</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="clash_service_status_channel">Clash 狀態</string>
|
||||
<string name="running">正在運行</string>
|
||||
<string name="format_update_complete">更新 %s 成功</string>
|
||||
<string name="format_update_failure">"更新 %1$s: %2$s "</string>
|
||||
<string name="clash_meta_for_android">LiteVPN</string>
|
||||
<string name="profiles_and_providers">配置文件和外部資源</string>
|
||||
<string name="configuration_yaml">配置文件.yaml</string>
|
||||
<string name="provider_files">外部資源文件列表</string>
|
||||
<string name="loading">載入中</string>
|
||||
<string name="profile_process_status">配置文件處理狀態</string>
|
||||
<string name="update_successfully">更新成功</string>
|
||||
<string name="update_failure">更新失敗</string>
|
||||
<string name="profile_updater">配置更新服務</string>
|
||||
<string name="profile_updating">配置更新中</string>
|
||||
<string name="profile_service_status">配置文件服務狀態</string>
|
||||
<string name="profile_process_result">配置文件處理結果</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="clash_service_status_channel">Clash 狀態</string>
|
||||
<string name="running">正在運作</string>
|
||||
<string name="format_update_complete">更新 %s 成功</string>
|
||||
<string name="format_update_failure">"更新 %1$s: %2$s "</string>
|
||||
<string name="clash_meta_for_android">LiteVPN</string>
|
||||
<string name="profiles_and_providers">設定檔和外部資源</string>
|
||||
<string name="configuration_yaml">設定檔.yaml</string>
|
||||
<string name="provider_files">外部資源文件列表</string>
|
||||
<string name="loading">載入中</string>
|
||||
<string name="profile_process_status">設定檔處理狀態</string>
|
||||
<string name="update_successfully">更新成功</string>
|
||||
<string name="update_failure">更新失敗</string>
|
||||
<string name="profile_updater">設定檔更新服務</string>
|
||||
<string name="profile_updating">設定檔更新中</string>
|
||||
<string name="profile_service_status">設定檔服務狀態</string>
|
||||
<string name="profile_process_result">設定檔處理結果</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="clash_service_status_channel">Clash 状态</string>
|
||||
<string name="running">正在运行</string>
|
||||
<string name="format_update_complete">更新 %s 成功</string>
|
||||
<string name="format_update_failure">"更新 %1$s: %2$s "</string>
|
||||
<string name="clash_meta_for_android">LiteVPN</string>
|
||||
<string name="profiles_and_providers">配置文件和外部资源</string>
|
||||
<string name="configuration_yaml">配置文件.yaml</string>
|
||||
<string name="provider_files">外部资源文件列表</string>
|
||||
<string name="loading">载入中</string>
|
||||
<string name="profile_process_status">配置文件处理状态</string>
|
||||
<string name="update_successfully">更新成功</string>
|
||||
<string name="update_failure">更新失败</string>
|
||||
<string name="profile_updater">配置更新服务</string>
|
||||
<string name="profile_updating">配置更新中</string>
|
||||
<string name="profile_service_status">配置文件服务状态</string>
|
||||
<string name="profile_process_result">配置文件处理结果</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,91 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- exclude 127.0.0.0/8 169.254.0.0/16 10.0.0.0/8 192.168.0.0/16 172.16.0.0/12 -->
|
||||
<string-array name="bypass_private_route" translatable="false">
|
||||
<item>1.0.0.0/8</item>
|
||||
<item>2.0.0.0/7</item>
|
||||
<item>4.0.0.0/6</item>
|
||||
<item>8.0.0.0/7</item>
|
||||
<item>11.0.0.0/8</item>
|
||||
<item>12.0.0.0/6</item>
|
||||
<item>16.0.0.0/4</item>
|
||||
<item>32.0.0.0/3</item>
|
||||
<item>64.0.0.0/3</item>
|
||||
<item>96.0.0.0/4</item>
|
||||
<item>112.0.0.0/5</item>
|
||||
<item>120.0.0.0/6</item>
|
||||
<item>124.0.0.0/7</item>
|
||||
<item>126.0.0.0/8</item>
|
||||
<item>128.0.0.0/3</item>
|
||||
<item>160.0.0.0/5</item>
|
||||
<item>168.0.0.0/8</item>
|
||||
<item>169.0.0.0/9</item>
|
||||
<item>169.128.0.0/10</item>
|
||||
<item>169.192.0.0/11</item>
|
||||
<item>169.224.0.0/12</item>
|
||||
<item>169.240.0.0/13</item>
|
||||
<item>169.248.0.0/14</item>
|
||||
<item>169.252.0.0/15</item>
|
||||
<item>169.255.0.0/16</item>
|
||||
<item>170.0.0.0/7</item>
|
||||
<item>172.0.0.0/12</item>
|
||||
<item>172.32.0.0/11</item>
|
||||
<item>172.64.0.0/10</item>
|
||||
<item>172.128.0.0/9</item>
|
||||
<item>173.0.0.0/8</item>
|
||||
<item>174.0.0.0/7</item>
|
||||
<item>176.0.0.0/4</item>
|
||||
<item>192.0.0.0/9</item>
|
||||
<item>192.128.0.0/11</item>
|
||||
<item>192.160.0.0/13</item>
|
||||
<item>192.169.0.0/16</item>
|
||||
<item>192.170.0.0/15</item>
|
||||
<item>192.172.0.0/14</item>
|
||||
<item>192.176.0.0/12</item>
|
||||
<item>192.192.0.0/10</item>
|
||||
<item>193.0.0.0/8</item>
|
||||
<item>194.0.0.0/7</item>
|
||||
<item>196.0.0.0/6</item>
|
||||
<item>200.0.0.0/5</item>
|
||||
<item>208.0.0.0/4</item>
|
||||
<item>240.0.0.0/5</item>
|
||||
<item>248.0.0.0/6</item>
|
||||
<item>252.0.0.0/7</item>
|
||||
<item>254.0.0.0/8</item>
|
||||
<item>255.0.0.0/9</item>
|
||||
<item>255.128.0.0/10</item>
|
||||
<item>255.192.0.0/11</item>
|
||||
<item>255.224.0.0/12</item>
|
||||
<item>255.240.0.0/13</item>
|
||||
<item>255.248.0.0/14</item>
|
||||
<item>255.252.0.0/15</item>
|
||||
<item>255.254.0.0/16</item>
|
||||
<item>255.255.0.0/17</item>
|
||||
<item>255.255.128.0/18</item>
|
||||
<item>255.255.192.0/19</item>
|
||||
<item>255.255.224.0/20</item>
|
||||
<item>255.255.240.0/21</item>
|
||||
<item>255.255.248.0/22</item>
|
||||
<item>255.255.252.0/23</item>
|
||||
<item>255.255.254.0/24</item>
|
||||
<item>255.255.255.0/25</item>
|
||||
<item>255.255.255.128/26</item>
|
||||
<item>255.255.255.192/27</item>
|
||||
<item>255.255.255.224/28</item>
|
||||
<item>255.255.255.240/29</item>
|
||||
<item>255.255.255.248/30</item>
|
||||
<item>255.255.255.252/31</item>
|
||||
<item>255.255.255.254/32</item>
|
||||
</string-array>
|
||||
<!-- exclude fc00::/7, fe80::/10, ff00::/8 -->
|
||||
<string-array name="bypass_private_route6" translatable="false">
|
||||
<item>::/1</item>
|
||||
<item>8000::/2</item>
|
||||
<item>c000::/3</item>
|
||||
<item>e000::/4</item>
|
||||
<item>f000::/5</item>
|
||||
<item>f800::/6</item>
|
||||
<item>fe00::/9</item>
|
||||
<item>fec0::/10</item>
|
||||
</string-array>
|
||||
</resources>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="color_clash">#1E4376</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<item name="nf_clash_status" type="id" />
|
||||
<item name="nf_vpn_status" type="id" />
|
||||
<item name="nf_profile_worker" type="id" />
|
||||
</resources>
|
||||
@@ -0,0 +1,21 @@
|
||||
<resources xmlns:tools="http://schemas.android.com/tools" tools:ignore="PluralsCandidate">
|
||||
<!-- from https://github.com/shadowsocks/shadowsocks-android/blob/master/core/src/main/res/values/strings.xml -->
|
||||
<string name="clash_notification_content" translatable="false">"%1$s↑\t%2$s↓"</string>
|
||||
|
||||
<string name="clash_service_status_channel">Clash Status</string>
|
||||
<string name="profile_service_status">Profile Service Status</string>
|
||||
<string name="profile_process_status">Profile Processing Status</string>
|
||||
<string name="profile_process_result">Profile Process Result</string>
|
||||
<string name="update_successfully">Update Successfully</string>
|
||||
<string name="update_failure">Update Failure</string>
|
||||
<string name="format_update_complete">Update %s completed</string>
|
||||
<string name="format_update_failure">Update %1$s: %2$s</string>
|
||||
<string name="running">Running</string>
|
||||
<string name="loading">Loading</string>
|
||||
<string name="clash_meta_for_android">LiteVPN</string>
|
||||
<string name="profiles_and_providers">Profiles and Providers</string>
|
||||
<string name="configuration_yaml">Configuration.yaml</string>
|
||||
<string name="provider_files">Provider Files</string>
|
||||
<string name="profile_updater">Profile Updater</string>
|
||||
<string name="profile_updating">Profile Updating</string>
|
||||
</resources>
|
||||
Reference in New Issue
Block a user