add android code

add android code
This commit is contained in:
Zeus
2024-10-23 22:33:02 +08:00
parent e5ed61e5e1
commit f28fdbb377
1672 changed files with 194066 additions and 0 deletions
@@ -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>
@@ -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()
}
}
@@ -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()
}
}
}
}
}
}
}
}
@@ -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()
}
}
@@ -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)
}
}
@@ -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
)
}
}
}
}
@@ -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)
}
}
}
@@ -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")
}
}
}
@@ -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)
)
}
}
}
@@ -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()
}
}
@@ -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!!
}
}
@@ -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
}
}
@@ -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",
)
}
}
@@ -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()
}
}
}
@@ -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))
}
}
}
@@ -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)
}
}
@@ -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"))
}
}
}
}
@@ -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()
}
}
}
}
}
}
@@ -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()
}
@@ -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()
}
}
@@ -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)
}
}
}
@@ -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)
}
}
}
}
@@ -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()
}
}
}
@@ -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()
}
}
}
@@ -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()
}
@@ -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)
}
}
}
}
@@ -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,
)
@@ -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
}
@@ -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(),
)
@@ -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)
}
@@ -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,
)
@@ -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>)
}
@@ -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())
}
}
@@ -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
@@ -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>
}
@@ -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()
}
@@ -0,0 +1,5 @@
package com.github.kr328.clash.service.document
enum class Flag {
Writable, Deletable, Virtual
}
@@ -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 = "/")}"
}
}
@@ -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),
)
}
}
}
@@ -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)
}
}
@@ -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
@@ -0,0 +1,5 @@
package com.github.kr328.clash.service.model
enum class AccessControlMode {
AcceptAll, AcceptSelected, DenySelected
}
@@ -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)
}
}
}
@@ -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?)
}
@@ -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)
}
@@ -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)
}
@@ -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)
}
@@ -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
}
@@ -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
)
}
@@ -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()
}
@@ -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
)
)
}
@@ -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)
}
@@ -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()
}
}
@@ -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
}
@@ -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()
}
@@ -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)
}
@@ -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>