Initial: initial commit
This commit is contained in:
80
service/src/main/AndroidManifest.xml
Normal file
80
service/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,80 @@
|
||||
<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:process=":background" />
|
||||
<service
|
||||
android:name="TunService"
|
||||
android:exported="false"
|
||||
android:permission="android.permission.BIND_VPN_SERVICE"
|
||||
android:process=":background">
|
||||
<intent-filter>
|
||||
<action android:name="android.net.VpnService" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
<service
|
||||
android:name=".ClashManager"
|
||||
android:exported="false"
|
||||
android:process=":background" />
|
||||
<service
|
||||
android:name=".ProfileService"
|
||||
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,118 @@
|
||||
package com.github.kr328.clash.service
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.IBinder
|
||||
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.remote.wrap
|
||||
import com.github.kr328.clash.service.store.ServiceStore
|
||||
import com.github.kr328.clash.service.util.sendOverrideChanged
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.channels.ReceiveChannel
|
||||
import java.util.*
|
||||
|
||||
class ClashManager : BaseService(), IClashManager {
|
||||
private val store by lazy { ServiceStore(this) }
|
||||
private val binder = this.wrap()
|
||||
private var logReceiver: ReceiveChannel<LogMessage>? = null
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder {
|
||||
return binder
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
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,117 @@
|
||||
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))
|
||||
val sideload = install(SideloadDatabaseModule(self))
|
||||
|
||||
if (store.dynamicNotification)
|
||||
install(DynamicNotificationModule(self))
|
||||
else
|
||||
install(StaticNotificationModule(self))
|
||||
|
||||
install(AppListCacheModule(self))
|
||||
install(SuspendModule(self))
|
||||
|
||||
try {
|
||||
while (isActive) {
|
||||
val quit = select<Boolean> {
|
||||
close.onEvent {
|
||||
true
|
||||
}
|
||||
config.onEvent {
|
||||
reason = it.message
|
||||
|
||||
true
|
||||
}
|
||||
sideload.onEvent {
|
||||
reason = it.message
|
||||
|
||||
true
|
||||
}
|
||||
network.onEvent {
|
||||
config.reload()
|
||||
|
||||
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_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,192 @@
|
||||
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 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)
|
||||
|
||||
val new = Imported(
|
||||
snapshot.uuid,
|
||||
snapshot.name,
|
||||
snapshot.type,
|
||||
snapshot.source,
|
||||
snapshot.interval,
|
||||
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.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.GlobalScope
|
||||
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 -> {
|
||||
GlobalScope.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,215 @@
|
||||
package com.github.kr328.clash.service
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.IBinder
|
||||
import com.github.kr328.clash.service.data.Database
|
||||
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.remote.wrap
|
||||
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 kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.FileNotFoundException
|
||||
import java.util.*
|
||||
|
||||
class ProfileService : BaseService(), IProfileManager {
|
||||
private val service = this
|
||||
private val store by lazy { ServiceStore(this) }
|
||||
private val binder = this.wrap()
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder {
|
||||
return binder
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
Database.database //.init
|
||||
|
||||
launch {
|
||||
ProfileReceiver.rescheduleAll(service)
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
PendingDao().insert(pending)
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
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,
|
||||
)
|
||||
)
|
||||
} else {
|
||||
val newPending = pending.copy(
|
||||
name = name,
|
||||
source = source,
|
||||
interval = interval
|
||||
)
|
||||
|
||||
PendingDao().update(newPending)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun update(uuid: UUID) {
|
||||
scheduleUpdate(uuid, true)
|
||||
}
|
||||
|
||||
override suspend fun commit(uuid: UUID, callback: IFetchObserver?) {
|
||||
ProfileProcessor.apply(service, uuid, callback)
|
||||
|
||||
scheduleUpdate(uuid, false)
|
||||
}
|
||||
|
||||
override suspend fun release(uuid: UUID) {
|
||||
ProfileProcessor.release(this, uuid)
|
||||
}
|
||||
|
||||
override suspend fun delete(uuid: UUID) {
|
||||
ImportedDao().queryByUUID(uuid)?.also {
|
||||
ProfileReceiver.cancelNext(service, it)
|
||||
}
|
||||
|
||||
ProfileProcessor.delete(service, 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(this, 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
|
||||
|
||||
return Profile(
|
||||
uuid,
|
||||
name,
|
||||
type,
|
||||
source,
|
||||
active != null && imported?.uuid == active,
|
||||
interval,
|
||||
resolveUpdatedAt(uuid),
|
||||
imported != null,
|
||||
pending != null
|
||||
)
|
||||
}
|
||||
|
||||
private fun resolveUpdatedAt(uuid: UUID): Long {
|
||||
return pendingDir.resolve(uuid.toString()).directoryLastModified
|
||||
?: importedDir.resolve(uuid.toString()).directoryLastModified
|
||||
?: -1
|
||||
}
|
||||
|
||||
private fun cloneImportedFiles(source: UUID, target: UUID = source) {
|
||||
val s = importedDir.resolve(source.toString())
|
||||
val t = 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(service, imported)
|
||||
} else {
|
||||
ProfileReceiver.scheduleNext(service, imported)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
package com.github.kr328.clash.service
|
||||
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Intent
|
||||
import android.os.Binder
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
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.log.Log
|
||||
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 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() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O)
|
||||
return
|
||||
|
||||
NotificationManagerCompat.from(this).createNotificationChannels(
|
||||
listOf(
|
||||
NotificationChannel(
|
||||
SERVICE_CHANNEL,
|
||||
getString(R.string.profile_service_status),
|
||||
NotificationManager.IMPORTANCE_LOW
|
||||
),
|
||||
NotificationChannel(
|
||||
STATUS_CHANNEL,
|
||||
getString(R.string.profile_process_status),
|
||||
NotificationManager.IMPORTANCE_LOW
|
||||
),
|
||||
NotificationChannel(
|
||||
RESULT_CHANNEL,
|
||||
getString(R.string.profile_process_result),
|
||||
NotificationManager.IMPORTANCE_DEFAULT
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
Log.d("notify processing $name: id = $id")
|
||||
|
||||
try {
|
||||
block()
|
||||
} finally {
|
||||
withContext(NonCancellable) {
|
||||
NotificationManagerCompat.from(applicationContext)
|
||||
.cancel(id)
|
||||
|
||||
Log.d("notify processed $name: id = $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)
|
||||
|
||||
Log.d("notify completed $name: id = $id")
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
Log.d("notify failed $name: id = $id")
|
||||
}
|
||||
|
||||
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,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,237 @@
|
||||
package com.github.kr328.clash.service
|
||||
|
||||
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))
|
||||
val sideload = install(SideloadDatabaseModule(self))
|
||||
|
||||
if (store.dynamicNotification)
|
||||
install(DynamicNotificationModule(self))
|
||||
else
|
||||
install(StaticNotificationModule(self))
|
||||
|
||||
install(AppListCacheModule(self))
|
||||
install(SuspendModule(self))
|
||||
|
||||
try {
|
||||
tun.open()
|
||||
|
||||
while (isActive) {
|
||||
val quit = select<Boolean> {
|
||||
close.onEvent {
|
||||
true
|
||||
}
|
||||
config.onEvent {
|
||||
reason = it.message
|
||||
|
||||
true
|
||||
}
|
||||
sideload.onEvent {
|
||||
reason = it.message
|
||||
|
||||
true
|
||||
}
|
||||
network.onEvent { e ->
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1) {
|
||||
setUnderlyingNetworks(e.network?.let { arrayOf(it) })
|
||||
}
|
||||
|
||||
config.reload()
|
||||
|
||||
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)
|
||||
|
||||
// Route
|
||||
if (store.bypassPrivateNetwork) {
|
||||
resources.getStringArray(R.array.bypass_private_route).map(::parseCIDR).forEach {
|
||||
addRoute(it.ip, it.prefix)
|
||||
}
|
||||
} else {
|
||||
addRoute(NET_ANY, 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)
|
||||
|
||||
// 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 >= Build.VERSION_CODES.Q) {
|
||||
setMetered(false)
|
||||
}
|
||||
|
||||
// System Proxy
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && store.systemProxy) {
|
||||
listenHttp()?.let {
|
||||
setHttpProxy(
|
||||
ProxyInfo.buildDirectProxy(
|
||||
it.address.hostAddress,
|
||||
it.port,
|
||||
if (store.bypassPrivateNetwork)
|
||||
listOf(
|
||||
"localhost",
|
||||
"*.local",
|
||||
"127.*",
|
||||
"10.*",
|
||||
"172.16.*",
|
||||
"172.17.*",
|
||||
"172.18.*",
|
||||
"172.19.*",
|
||||
"172.2*",
|
||||
"172.30.*",
|
||||
"172.31.*",
|
||||
"192.168.*"
|
||||
)
|
||||
else
|
||||
emptyList()
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
TunModule.TunDevice(
|
||||
fd = establish()?.detachFd()
|
||||
?: throw NullPointerException("Establish VPN rejected by system"),
|
||||
mtu = TUN_MTU,
|
||||
gateway = TUN_GATEWAY,
|
||||
mirror = TUN_MIRROR,
|
||||
dns = if (store.dnsHijacking) NET_ANY else TUN_DNS,
|
||||
)
|
||||
}
|
||||
|
||||
attach(device)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TUN_MTU = 9000
|
||||
private const val TUN_SUBNET_PREFIX = 30
|
||||
private const val TUN_GATEWAY = "172.31.255.253"
|
||||
private const val TUN_MIRROR = "172.31.255.254"
|
||||
private const val TUN_DNS = "198.18.0.1"
|
||||
private const val NET_ANY = "0.0.0.0"
|
||||
}
|
||||
}
|
||||
@@ -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,40 @@
|
||||
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)
|
||||
.map { it.applicationInfo.uid to it.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,80 @@
|
||||
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.offer(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"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun reload() {
|
||||
reload.offer(Unit)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
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.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")
|
||||
.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 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()
|
||||
|
||||
service.startForeground(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.offer(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,104 @@
|
||||
package com.github.kr328.clash.service.clash.module
|
||||
|
||||
import android.annotation.TargetApi
|
||||
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.resolveDns
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class NetworkObserveModule(service: Service) :
|
||||
Module<NetworkObserveModule.NetworkChanged>(service) {
|
||||
data class NetworkChanged(val network: Network?)
|
||||
|
||||
private val connectivity = service.getSystemService<ConnectivityManager>()!!
|
||||
private val networks: Channel<Network?> = Channel(Channel.CONFLATED)
|
||||
private val request = NetworkRequest.Builder().apply {
|
||||
addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||
addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_RESTRICTED)
|
||||
if (Build.VERSION.SDK_INT == 23) { // workarounds for OEM bugs
|
||||
removeCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
|
||||
removeCapability(NetworkCapabilities.NET_CAPABILITY_CAPTIVE_PORTAL)
|
||||
}
|
||||
}.build()
|
||||
|
||||
private val callback = object : ConnectivityManager.NetworkCallback() {
|
||||
private var internet: Boolean = false
|
||||
private var network: Network? = null
|
||||
|
||||
override fun onAvailable(network: Network) {
|
||||
this.network = network
|
||||
|
||||
networks.offer(network)
|
||||
}
|
||||
|
||||
override fun onCapabilitiesChanged(
|
||||
network: Network,
|
||||
networkCapabilities: NetworkCapabilities
|
||||
) {
|
||||
val internet = networkCapabilities
|
||||
.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||
|
||||
if (this.network == network && this.internet != internet) {
|
||||
this.internet = internet
|
||||
|
||||
networks.offer(network)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onLost(network: Network) {
|
||||
if (this.network == network) {
|
||||
networks.offer(null)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onLinkPropertiesChanged(network: Network, linkProperties: LinkProperties) {
|
||||
if (this.network == network) {
|
||||
networks.offer(network)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun run() {
|
||||
try {
|
||||
if (Build.VERSION.SDK_INT in 24..27) @TargetApi(24) {
|
||||
connectivity.registerDefaultNetworkCallback(callback)
|
||||
} else {
|
||||
connectivity.requestNetwork(request, callback)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w("Observe network changed: $e", e)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
delay(TimeUnit.SECONDS.toMillis(10))
|
||||
|
||||
while (true) {
|
||||
val network = networks.receive()
|
||||
|
||||
val dns = connectivity.resolveDns(network)
|
||||
|
||||
Clash.notifyDnsChanged(dns)
|
||||
|
||||
Log.d("Network changed, system dns = $dns")
|
||||
|
||||
enqueueEvent(NetworkChanged(network))
|
||||
}
|
||||
} finally {
|
||||
withContext(NonCancellable) {
|
||||
connectivity.unregisterNetworkCallback(callback)
|
||||
|
||||
Clash.notifyDnsChanged(emptyList())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package com.github.kr328.clash.service.clash.module
|
||||
|
||||
import android.app.Service
|
||||
import android.content.Intent
|
||||
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.sideload.readGeoipDatabaseFrom
|
||||
import com.github.kr328.clash.service.store.ServiceStore
|
||||
import com.github.kr328.clash.service.util.packageName
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.selects.select
|
||||
import java.io.FileNotFoundException
|
||||
import java.io.IOException
|
||||
|
||||
class SideloadDatabaseModule(service: Service) :
|
||||
Module<SideloadDatabaseModule.LoadException>(service) {
|
||||
data class LoadException(val message: String)
|
||||
|
||||
private val store = ServiceStore(service)
|
||||
|
||||
private var current: String = ""
|
||||
|
||||
override suspend fun run() {
|
||||
val packagesChanged = receiveBroadcast(false) {
|
||||
addAction(Intent.ACTION_PACKAGE_ADDED)
|
||||
addAction(Intent.ACTION_PACKAGE_REPLACED)
|
||||
addAction(Intent.ACTION_PACKAGE_FULLY_REMOVED)
|
||||
addDataScheme("package")
|
||||
}
|
||||
val profileChanged = receiveBroadcast(capacity = Channel.CONFLATED) {
|
||||
addAction(Intents.ACTION_PROFILE_CHANGED)
|
||||
}
|
||||
val initial = Channel<Unit>(1).apply { send(Unit) }
|
||||
|
||||
while (true) {
|
||||
val (reload, force) = select<Pair<Boolean, Boolean>> {
|
||||
packagesChanged.onReceive {
|
||||
when (it.action) {
|
||||
Intent.ACTION_PACKAGE_ADDED ->
|
||||
(it.packageName == store.sideloadGeoip) to true
|
||||
Intent.ACTION_PACKAGE_REPLACED ->
|
||||
(it.packageName == current) to true
|
||||
Intent.ACTION_PACKAGE_FULLY_REMOVED ->
|
||||
(it.packageName == current) to true
|
||||
else -> false to false
|
||||
}
|
||||
}
|
||||
profileChanged.onReceive {
|
||||
true to false
|
||||
}
|
||||
initial.onReceive {
|
||||
true to true
|
||||
}
|
||||
}
|
||||
|
||||
if (!reload) continue
|
||||
|
||||
val pkg = store.sideloadGeoip
|
||||
|
||||
try {
|
||||
if (!force && pkg == current)
|
||||
continue
|
||||
|
||||
current = pkg
|
||||
|
||||
if (pkg.isNotBlank()) {
|
||||
val data = service.readGeoipDatabaseFrom(pkg)
|
||||
|
||||
Clash.installSideloadGeoip(data)
|
||||
|
||||
if (data != null) {
|
||||
Log.d("Sideload geoip loaded, pkg = $pkg")
|
||||
} else {
|
||||
Log.d("Sideload geoip not found")
|
||||
}
|
||||
}
|
||||
} catch (e: FileNotFoundException) {
|
||||
return enqueueEvent(LoadException("file $pkg/assets/${e.message} not found"))
|
||||
} catch (e: IOException) {
|
||||
return enqueueEvent(LoadException("read data from $pkg: ${e.message}"))
|
||||
} catch (e: Exception) {
|
||||
return enqueueEvent(LoadException(e.toString()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package com.github.kr328.clash.service.clash.module
|
||||
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
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)
|
||||
.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) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O)
|
||||
return
|
||||
NotificationManagerCompat.from(service).createNotificationChannel(
|
||||
NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
service.getText(R.string.clash_service_status_channel),
|
||||
NotificationManager.IMPORTANCE_LOW
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
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,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,
|
||||
val mtu: Int,
|
||||
val gateway: String,
|
||||
val mirror: 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 < Build.VERSION_CODES.Q)
|
||||
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,
|
||||
mtu = device.mtu,
|
||||
gateway = device.gateway,
|
||||
mirror = device.mirror,
|
||||
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,49 @@
|
||||
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.GlobalScope
|
||||
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 {
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
LEGACY_MIGRATION(Global.application)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
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 = "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,18 @@
|
||||
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 = "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,199 @@
|
||||
@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 com.microsoft.appcenter.crashes.Crashes
|
||||
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) {
|
||||
Crashes.trackError(e)
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
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,146 @@
|
||||
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_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
|
||||
)
|
||||
)
|
||||
|
||||
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,47 @@
|
||||
@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 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,21 @@
|
||||
package com.github.kr328.clash.service.sideload
|
||||
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import com.github.kr328.clash.common.constants.Metadata
|
||||
import com.github.kr328.clash.common.log.Log
|
||||
import java.io.InputStream
|
||||
|
||||
fun Context.readGeoipDatabaseFrom(packageName: String): ByteArray? {
|
||||
return try {
|
||||
val appInfo = packageManager.getApplicationInfo(packageName, PackageManager.GET_META_DATA)
|
||||
val path = appInfo.metaData.getString(Metadata.GEOIP_FILE_NAME) ?: return null
|
||||
|
||||
createPackageContext(packageName, 0)
|
||||
.resources.assets.open(path).use(InputStream::readBytes)
|
||||
} catch (e: PackageManager.NameNotFoundException) {
|
||||
Log.w("Sideload geoip: $packageName not found", e)
|
||||
|
||||
null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
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 = false
|
||||
)
|
||||
|
||||
var dynamicNotification by store.boolean(
|
||||
key = "dynamic_notification",
|
||||
defaultValue = true
|
||||
)
|
||||
|
||||
var sideloadGeoip by store.string(
|
||||
key = "sideload_geoip",
|
||||
defaultValue = ""
|
||||
)
|
||||
}
|
||||
@@ -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,51 @@
|
||||
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.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,11 @@
|
||||
package com.github.kr328.clash.service.util
|
||||
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.Network
|
||||
|
||||
fun ConnectivityManager.resolveDns(network: Network?): List<String> {
|
||||
return network?.run(this::getLinkProperties)
|
||||
?.dnsServers
|
||||
?.map { it.asSocketAddressText(53) }
|
||||
?: emptyList()
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
12
service/src/main/res/drawable/ic_logo_service.xml
Normal file
12
service/src/main/res/drawable/ic_logo_service.xml
Normal file
@@ -0,0 +1,12 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:width="200dp"
|
||||
android:height="200dp"
|
||||
android:viewportWidth="200"
|
||||
android:viewportHeight="200"
|
||||
android:tint="@color/color_clash">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M47.211,168.128C70.531,-34.962 67.471,13.788 94.071,43.818c13.45,-1.52 27.24,-3.47 40.82,-0.67c2.64,0.13 5.42,1.86 7.71,0.18c4.12,-6.27 7.35,-13.54 11.35,-20c12.19,-24.44 12.85,19.54 15.48,26.52c5.23,32.99 10.89,64.46 14.67,97.59c0.31,10.72 5.74,32.92 1.08,33.56c-49.36,5.23 -147.71,3.91 -160.84,-6.3c-15.85,-10.5 -15.18,-35.33 2.03,-43.72c3.63,-2.03 10.68,-3.72 11.94,0.7c-2.41,4.99 -8.79,5.77 -12.12,11.17C16.621,158.948 33.111,168.888 47.211,168.128zM87.841,74.008c-10.42,0.52 -9.59,14.89 -0.07,15.18C98.191,88.668 97.361,74.298 87.841,74.008zM149.121,89.188c10.46,-0.34 9.85,-14.71 0.38,-15.18C139.031,74.348 139.651,88.718 149.121,89.188zM107.871,99.228c2.16,3.48 5.28,3.29 9.79,0.16c3.81,3.17 8.06,3.28 9.18,-0.19c-3.78,1.17 -7.04,0.79 -9.4,-3.49C115.371,100.108 112.071,100.428 107.871,99.228z"
|
||||
tools:ignore="VectorPath" />
|
||||
</vector>
|
||||
19
service/src/main/res/values-zh-rHK/strings.xml
Normal file
19
service/src/main/res/values-zh-rHK/strings.xml
Normal file
@@ -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_for_android">Clash for Android</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>
|
||||
19
service/src/main/res/values-zh-rTW/strings.xml
Normal file
19
service/src/main/res/values-zh-rTW/strings.xml
Normal file
@@ -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_for_android">Clash for Android</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>
|
||||
19
service/src/main/res/values-zh/strings.xml
Normal file
19
service/src/main/res/values-zh/strings.xml
Normal file
@@ -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_for_android">Clash for Android</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>
|
||||
81
service/src/main/res/values/arrays.xml
Normal file
81
service/src/main/res/values/arrays.xml
Normal file
@@ -0,0 +1,81 @@
|
||||
<?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>
|
||||
<item>172.31.255.252/30</item> <!-- tun device address -->
|
||||
</string-array>
|
||||
</resources>
|
||||
4
service/src/main/res/values/colors.xml
Normal file
4
service/src/main/res/values/colors.xml
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="color_clash">#1E4376</color>
|
||||
</resources>
|
||||
6
service/src/main/res/values/ids.xml
Normal file
6
service/src/main/res/values/ids.xml
Normal file
@@ -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>
|
||||
21
service/src/main/res/values/strings.xml
Normal file
21
service/src/main/res/values/strings.xml
Normal file
@@ -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_for_android">Clash for Android</string>
|
||||
<string name="profiles_and_providers">Profiles and Providers</string>
|
||||
<string name="configuration_yaml">Configuration.yaml</string>
|
||||
<string name="provider_files">Provider Files</string>
|
||||
<string name="profile_updater">Profile Updater</string>
|
||||
<string name="profile_updating">Profile Updating</string>
|
||||
</resources>
|
||||
Reference in New Issue
Block a user