Compare commits

...

10 Commits
1.1.3 ... 1.1.2

Author SHA1 Message Date
Kr328
7ab7c8abee fix receiver leak 2020-02-22 21:43:28 +08:00
Kr328
ea3aee0883 fix proxy only not load profile default 2020-02-22 21:19:33 +08:00
Kr328
b4a5d7b1be Use single service to handle clash 2020-02-22 21:17:29 +08:00
Kr328
d519bc1a23 update README.md 2020-02-22 01:48:38 +08:00
Kr328
a6d5fbc2ca update PRIVACY_POLICY.md 2020-02-22 01:46:45 +08:00
Kr328
e9a6bc2fbc add delay for proxies 2020-02-22 01:13:15 +08:00
Kr328
ef42612c4b change launchMode & fix tile icon size 2020-02-22 00:51:04 +08:00
Kr328
f4b421c953 1.1.1 2020-02-22 00:35:25 +08:00
Kr328
14aa981ca4 update base version 2020-02-20 23:33:50 +08:00
Kr328
515942274d bug fix 2020-02-20 23:33:30 +08:00
50 changed files with 688 additions and 489 deletions

View File

@@ -17,7 +17,7 @@ The app does use third party services that may collect information used to ident
Link to privacy policy of third party service providers used by the app
* [Google Play Services](https://www.google.com/policies/privacy/)
* [Firebase Analytics](https://firebase.google.com/policies/analytics)
* [AppCenter](https://docs.microsoft.com/en-us/appcenter/gdpr/)
**Log Data**

View File

@@ -39,7 +39,7 @@ See also [PRIVACY_POLICY.md](./PRIVACY_POLICY.md)
git submodule update --init --recursive
```
2. Install `Android SDK` ,`Android NDK` and `Golang`
2. Install `Android SDK (include JDK)` ,`Android NDK` and `Golang`
3. Configure `local.properties`

View File

@@ -20,7 +20,7 @@
android:name=".MainActivity"
android:label="@string/launch_name"
android:configChanges="uiMode"
android:launchMode="singleTask">
android:launchMode="singleTop">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
@@ -65,7 +65,7 @@
android:label="@string/log_viewer"
android:exported="false"
android:configChanges="uiMode"
android:launchMode="singleTask"/>
android:launchMode="singleTop"/>
<activity
android:name=".SettingsActivity"
android:label="@string/settings"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 16 KiB

View File

@@ -15,13 +15,12 @@ import androidx.appcompat.widget.Toolbar
import androidx.coordinatorlayout.widget.CoordinatorLayout
import com.github.kr328.clash.preference.UiSettings
import com.github.kr328.clash.remote.Broadcasts
import com.github.kr328.clash.service.data.ClashProfileEntity
import com.github.kr328.clash.service.util.createLanguageConfigurationContext
import com.google.android.material.snackbar.Snackbar
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import java.util.*
abstract class BaseActivity : AppCompatActivity(), CoroutineScope by MainScope() {
class EmptyBroadcastReceiver : BroadcastReceiver() {
@@ -47,9 +46,9 @@ abstract class BaseActivity : AppCompatActivity(), CoroutineScope by MainScope()
}
}
override fun onProfileLoaded(profileEntity: ClashProfileEntity) {
override fun onProfileLoaded() {
launch {
onClashProfileLoaded(profileEntity)
onClashProfileLoaded()
}
}
}
@@ -69,7 +68,7 @@ abstract class BaseActivity : AppCompatActivity(), CoroutineScope by MainScope()
open suspend fun onClashStarted() {}
open suspend fun onClashStopped(reason: String?) {}
open suspend fun onClashProfileChanged() {}
open suspend fun onClashProfileLoaded(profile: ClashProfileEntity) {}
open suspend fun onClashProfileLoaded() {}
override fun setContentView(layoutResID: Int) {
val base = CoordinatorLayout(this).apply {
@@ -99,19 +98,7 @@ abstract class BaseActivity : AppCompatActivity(), CoroutineScope by MainScope()
language = uiSettings.get(UiSettings.LANGUAGE)
val languageOverride = language.split("-")
if (language.isEmpty())
return super.attachBaseContext(base)
val configuration = Configuration()
val localeOverride = if (languageOverride.size == 2)
Locale(languageOverride[0], languageOverride[1])
else
Locale(languageOverride[0])
configuration.setLocale(localeOverride)
super.attachBaseContext(base.createConfigurationContext(configuration))
super.attachBaseContext(base.createLanguageConfigurationContext(language))
}
override fun onCreate(savedInstanceState: Bundle?) {

View File

@@ -18,10 +18,12 @@ import androidx.core.app.NotificationManagerCompat
import com.github.kr328.clash.core.event.LogEvent
import com.github.kr328.clash.core.utils.Log
import com.github.kr328.clash.model.LogFile
import com.github.kr328.clash.preference.UiSettings
import com.github.kr328.clash.service.ClashManagerService
import com.github.kr328.clash.service.IClashManager
import com.github.kr328.clash.service.ipc.IStreamCallback
import com.github.kr328.clash.service.ipc.ParcelableContainer
import com.github.kr328.clash.service.util.createLanguageConfigurationContext
import com.github.kr328.clash.service.util.intent
import com.github.kr328.clash.utils.format
import com.github.kr328.clash.utils.logsDir
@@ -119,6 +121,14 @@ class LogcatService : Service(), CoroutineScope by MainScope(), IInterface {
}
}
override fun attachBaseContext(base: Context?) {
val b = base ?: return super.attachBaseContext(base)
val language = UiSettings(b).get(UiSettings.LANGUAGE)
super.attachBaseContext(b.createLanguageConfigurationContext(language))
}
// Export to UI
suspend fun pollLogEvent(offset: Long): CompletableDeferred<Response> {
val request = Request(offset, CompletableDeferred())
@@ -239,6 +249,7 @@ class LogcatService : Service(), CoroutineScope by MainScope(), IInterface {
.setColor(getColor(R.color.colorAccentService))
.setContentTitle(getString(R.string.clash_logcat))
.setContentText(getString(R.string.running))
.setGroup(NOTIFICATION_CHANNEL_ID)
.setContentIntent(
PendingIntent.getActivity(
this,

View File

@@ -13,10 +13,10 @@ import com.github.kr328.clash.core.utils.asBytesString
import com.github.kr328.clash.remote.withClash
import com.github.kr328.clash.remote.withProfile
import com.github.kr328.clash.service.ClashService
import com.github.kr328.clash.service.data.ClashProfileEntity
import com.github.kr328.clash.service.util.intent
import com.github.kr328.clash.service.util.startForegroundServiceCompat
import com.github.kr328.clash.utils.startClashService
import com.github.kr328.clash.utils.stopClashService
import kotlinx.android.synthetic.main.activity_main.*
import kotlinx.coroutines.*
@@ -34,7 +34,7 @@ class MainActivity : BaseActivity() {
status.setOnClickListener {
if (clashRunning) {
stopService(ClashService::class.intent)
stopClashService()
} else {
val vpnRequest = startClashService()
if (vpnRequest != null)
@@ -102,7 +102,7 @@ class MainActivity : BaseActivity() {
makeSnackbarException(getString(R.string.clash_start_failure), reason)
}
override suspend fun onClashProfileLoaded(profile: ClashProfileEntity) {
override suspend fun onClashProfileLoaded() {
updateClashStatus()
}

View File

@@ -9,27 +9,22 @@ import androidx.recyclerview.widget.RecyclerView
import com.github.kr328.clash.adapter.ProxyAdapter
import com.github.kr328.clash.adapter.ProxyChipAdapter
import com.github.kr328.clash.core.model.General
import com.github.kr328.clash.core.model.Proxy
import com.github.kr328.clash.pipeline.Pipeline
import com.github.kr328.clash.pipeline.mergePrefix
import com.github.kr328.clash.pipeline.sort
import com.github.kr328.clash.pipeline.toAdapterElement
import com.github.kr328.clash.preference.UiSettings
import com.github.kr328.clash.remote.withClash
import com.github.kr328.clash.utils.PrefixMerger
import com.github.kr328.clash.utils.ProxySorter
import com.github.kr328.clash.utils.ScrollBinding
import kotlinx.android.synthetic.main.activity_proxies.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.sync.Mutex
class ProxiesActivity : BaseActivity(), ScrollBinding.Callback {
private val refreshMutex = Mutex()
private val scrollBinding = ScrollBinding(this, this)
private val doScrollToLastProxy by lazy {
val selected = uiSettings.get(UiSettings.PROXY_LAST_SELECT_GROUP)
launch {
scrollBinding.scrollMaster(selected)
}
}
private var scrollToLast = true
private val mainListAdapter: ProxyAdapter
get() = mainList.adapter as ProxyAdapter
@@ -42,35 +37,10 @@ class ProxiesActivity : BaseActivity(), ScrollBinding.Callback {
setContentView(R.layout.activity_proxies)
setSupportActionBar(toolbar)
mainList.adapter = ProxyAdapter(this, { group, proxy ->
launch {
withClash {
setSelectProxy(group, proxy)
}
}
}, {
launch {
urlTesting.add(it)
withClash {
urlTesting.add(it)
startHealthCheck(it)
urlTesting.remove(it)
refreshList()
}
}
})
mainList.adapter = ProxyAdapter(this, this::setGroupSelected, this::startUrlTesting)
mainList.layoutManager = mainListAdapter.layoutManager
chipList.adapter = ProxyChipAdapter(this) {
launch {
scrollBinding.scrollMaster(it)
}
}
chipList.adapter = ProxyChipAdapter(this, this::chipClicked)
chipList.layoutManager = LinearLayoutManager(this, LinearLayoutManager.HORIZONTAL, false)
chipList.itemAnimator?.changeDuration = 0
@@ -117,6 +87,8 @@ class ProxiesActivity : BaseActivity(), ScrollBinding.Callback {
}
launch {
var scrollTop = false
when (item.itemId) {
R.id.modeDirect -> {
withClash {
@@ -126,6 +98,8 @@ class ProxiesActivity : BaseActivity(), ScrollBinding.Callback {
R.id.modeGlobal -> {
withClash {
setProxyMode(General.Mode.GLOBAL)
scrollTop = true
}
}
R.id.modeRule -> {
@@ -179,7 +153,7 @@ class ProxiesActivity : BaseActivity(), ScrollBinding.Callback {
item.isChecked = true
refreshList()
refreshList(scrollTop)
}
return true
@@ -188,7 +162,7 @@ class ProxiesActivity : BaseActivity(), ScrollBinding.Callback {
override val activityLabel: CharSequence?
get() = getText(R.string.proxy)
override suspend fun onClashStarted() {
override suspend fun onClashStopped(reason: String?) {
finish()
}
@@ -230,8 +204,39 @@ class ProxiesActivity : BaseActivity(), ScrollBinding.Callback {
}
}
private fun refreshList() {
private fun setGroupSelected(group: String, select: String) {
launch {
withClash {
setSelectProxy(group, select)
}
}
}
private fun startUrlTesting(group: String) {
launch {
urlTesting.add(group)
withClash {
startHealthCheck(group)
}
urlTesting.remove(group)
refreshList()
}
}
private fun chipClicked(name: String) {
launch {
scrollBinding.scrollMaster(name)
}
}
private fun refreshList(scrollTop: Boolean = false) {
launch {
if ( !refreshMutex.tryLock() )
return@launch
val general = withClash {
queryGeneral()
}
@@ -239,90 +244,31 @@ class ProxiesActivity : BaseActivity(), ScrollBinding.Callback {
queryAllProxyGroups()
}
val prefixDeferred = async {
if (uiSettings.get(UiSettings.PROXY_MERGE_PREFIX)) {
proxies.map {
async { PrefixMerger.merge(it.proxies.map { p -> it.name to p.name }) { it.second } }
}.flatMap {
it.await()
}.map {
it.value to it
}.toMap()
} else emptyMap()
}
val merged = Pipeline(proxies, uiSettings).mergePrefix()
val sorted = Pipeline(proxies, uiSettings).sort()
val sortDeferred = async {
val groupSort = when (uiSettings.get(UiSettings.PROXY_GROUP_SORT)) {
UiSettings.PROXY_SORT_DEFAULT ->
ProxySorter.Order.DEFAULT
UiSettings.PROXY_SORT_NAME ->
ProxySorter.Order.NAME_INCREASE
UiSettings.PROXY_SORT_DELAY ->
ProxySorter.Order.DELAY_INCREASE
else -> throw IllegalArgumentException()
}
val proxySort = when (uiSettings.get(UiSettings.PROXY_PROXY_SORT)) {
UiSettings.PROXY_SORT_DEFAULT ->
ProxySorter.Order.DEFAULT
UiSettings.PROXY_SORT_NAME ->
ProxySorter.Order.NAME_INCREASE
UiSettings.PROXY_SORT_DELAY ->
ProxySorter.Order.DELAY_INCREASE
else -> throw IllegalArgumentException()
}
val sorter = ProxySorter(groupSort, proxySort)
sorter.sort(proxies).run {
when (general.mode) {
General.Mode.GLOBAL -> this
General.Mode.DIRECT -> emptyList()
General.Mode.RULE -> this.filter { it.name != "GLOBAL" }
}
}
}
val prefix = prefixDeferred.await()
val sorted = sortDeferred.await()
val newList = withContext(Dispatchers.Default) {
sorted.map {
ProxyAdapter.ProxyGroupInfo(it.name,
it.proxies.map { p ->
val r = prefix.getOrElse(it.name to p.name) {
PrefixMerger.Result(p.name, "", p)
}
val data = if ( r.content.isEmpty() ) {
r.prefix to p.type.toString()
}
else {
r.content to r.prefix
}
ProxyAdapter.ProxyInfo(
p.name,
it.name,
data.first,
data.second,
p.delay.toShort(),
it.type == Proxy.Type.SELECT,
p.name == it.current
)
}
)
}
}
val newList = sorted.toAdapterElement(merged.input, general)
mainListAdapter.applyChange(newList, urlTesting)
(chipList.adapter!! as ProxyChipAdapter).apply {
chips = sorted.map { it.name }
chips = newList.map { it.name }
notifyDataSetChanged()
}
doScrollToLastProxy
if ( scrollTop )
mainList.smoothScrollToPosition(0)
else if ( scrollToLast ) {
scrollToLast = false
val selected = uiSettings.get(UiSettings.PROXY_LAST_SELECT_GROUP)
scrollBinding.scrollMaster(selected)
}
delay(200)
refreshMutex.unlock()
}
}
@@ -344,7 +290,7 @@ class ProxiesActivity : BaseActivity(), ScrollBinding.Callback {
return mainListAdapter.getGroupPosition(token)
}
override fun doMasterScroll(scroller: LinearSmoothScroller) {
override fun doMasterScroll(scroller: LinearSmoothScroller, target: Int) {
mainListAdapter.layoutManager.startSmoothScroll(scroller)
}
}

View File

@@ -4,14 +4,13 @@ import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.graphics.drawable.Icon
import android.service.quicksettings.Tile
import android.service.quicksettings.TileService
import com.github.kr328.clash.remote.RemoteUtils
import com.github.kr328.clash.service.ClashService
import com.github.kr328.clash.service.Intents
import com.github.kr328.clash.service.data.ClashProfileEntity
import com.github.kr328.clash.service.util.intent
import com.github.kr328.clash.utils.startClashService
import com.github.kr328.clash.utils.stopClashService
class TileService : TileService() {
private var currentProfile = ""
@@ -25,7 +24,7 @@ class TileService : TileService() {
startClashService()
}
Tile.STATE_ACTIVE -> {
stopService(ClashService::class.intent)
stopClashService()
}
}
}
@@ -48,6 +47,8 @@ class TileService : TileService() {
else
currentProfile
qsTile.icon = Icon.createWithResource(this, R.drawable.ic_notification)
qsTile.updateTile()
}
@@ -65,10 +66,8 @@ class TileService : TileService() {
currentProfile = ""
}
Intents.INTENT_ACTION_PROFILE_LOADED -> {
val entity = intent.
getParcelableExtra<ClashProfileEntity>(Intents.INTENT_EXTRA_PROFILE)
currentProfile = entity?.name ?: ""
currentProfile = RemoteUtils
.getCurrentClashProfileName(this@TileService) ?: ""
}
}
@@ -88,7 +87,10 @@ class TileService : TileService() {
}
)
clashRunning = RemoteUtils.detectClashRunning(this)
val name = RemoteUtils.getCurrentClashProfileName(this)
clashRunning = name != null
currentProfile = name ?: ""
}
override fun onDestroy() {

View File

@@ -14,7 +14,6 @@ import androidx.recyclerview.widget.RecyclerView
import com.github.kr328.clash.R
import com.google.android.material.card.MaterialCardView
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.withContext
@@ -29,14 +28,15 @@ class ProxyAdapter(
data class ProxyGroupInfo(
val name: String,
val current: String,
val proxies: List<ProxyInfo>
)
data class ProxyInfo(
val name: String,
val group: String,
val prefix: String,
val content: String,
val title: String,
val summary: String,
val delay: Short,
val selectable: Boolean,
val active: Boolean
@@ -62,8 +62,6 @@ class ProxyAdapter(
get() = info.group
}
private var rootMutex = Mutex()
private var urlTesting: Set<String> = emptySet()
private var renderList = mutableListOf<RenderInfo>()
private var activeList: MutableMap<String, Int> = mutableMapOf()
@@ -86,7 +84,10 @@ class ProxyAdapter(
val layoutManager = GridLayoutManager(context, DEFAULT_SPAN_COUNT).apply {
spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() {
override fun getSpanSize(position: Int): Int {
return when (renderList[position]) {
val current = renderList.getOrNull(position)
?: renderList.getOrNull(position) ?: return spanCount
return when (current) {
is ProxyGroupRenderInfo -> spanCount
is ProxyRenderInfo -> 1
else -> throw IllegalArgumentException()
@@ -98,7 +99,7 @@ class ProxyAdapter(
private var root = listOf<ProxyGroupInfo>()
private class ProxyGroupHeader(view: View) : RecyclerView.ViewHolder(view) {
val name: TextView = view.findViewById(R.id.name)
val title: TextView = view.findViewById(R.id.name)
val urlTest: View = view.findViewById(R.id.urlTest)
val urlTestProgress: View = view.findViewById(R.id.urlTestProgress)
}
@@ -112,8 +113,6 @@ class ProxyAdapter(
suspend fun applyChange(newList: List<ProxyGroupInfo>, testing: Set<String>) =
withContext(Dispatchers.Default) {
rootMutex.lock()
val newRenderList = newList
.flatMap {
listOf(ProxyGroupRenderInfo(it)) + it.proxies.map { p -> ProxyRenderInfo(p) }
@@ -155,8 +154,6 @@ class ProxyAdapter(
activeList = activeCache
result.dispatchUpdatesTo(this@ProxyAdapter)
}
rootMutex.unlock()
}
fun getGroupPosition(name: String): Int {
@@ -195,7 +192,8 @@ class ProxyAdapter(
groupPosition[current.name] = position
holder.name.text = current.info.name
holder.title.text = context.getString(R.string.format_proxy_group_title,
current.info.name, current.info.current)
holder.urlTest.setOnClickListener {
holder.urlTest.visibility = View.GONE
holder.urlTestProgress.visibility = View.VISIBLE
@@ -214,8 +212,8 @@ class ProxyAdapter(
is ProxyItem -> {
val current = renderList[position] as ProxyRenderInfo
holder.prefix.text = current.info.prefix
holder.content.text = current.info.content
holder.prefix.text = current.info.title
holder.content.text = current.info.summary
if (current.info.delay > 0)
holder.delay.text = current.info.delay.toString()
@@ -239,14 +237,18 @@ class ProxyAdapter(
if (current.info.selectable) {
holder.root.setOnClickListener {
val oldPosition = activeList[current.group] ?: return@setOnClickListener
val groupPosition = groupPosition[current.group] ?: return@setOnClickListener
val old = renderList[oldPosition] as ProxyRenderInfo
val new = renderList[position] as ProxyRenderInfo
val group = renderList[groupPosition] as ProxyGroupRenderInfo
renderList[oldPosition] = old.copy(info = old.info.copy(active = false))
renderList[position] = new.copy(info = new.info.copy(active = true))
renderList[groupPosition] = group.copy(info = group.info.copy(current = current.name))
notifyItemChanged(oldPosition)
notifyItemChanged(position)
notifyItemChanged(groupPosition)
onSelect(current.group, current.name)
}

View File

@@ -0,0 +1,5 @@
package com.github.kr328.clash.pipeline
import com.github.kr328.clash.service.settings.BaseSettings
data class Pipeline<T>(val input: T, val settings: BaseSettings)

View File

@@ -0,0 +1,93 @@
package com.github.kr328.clash.pipeline
import com.github.kr328.clash.adapter.ProxyAdapter
import com.github.kr328.clash.core.model.General
import com.github.kr328.clash.core.model.Proxy
import com.github.kr328.clash.core.model.ProxyGroup
import com.github.kr328.clash.preference.UiSettings
import com.github.kr328.clash.utils.PrefixMerger
import com.github.kr328.clash.utils.ProxySorter
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.withContext
data class ProxyEntry(val group: String, val name: String)
data class ProxyMerged(val prefix: String, val content: String)
suspend fun Pipeline<List<ProxyGroup>>.mergePrefix(): Pipeline<Map<ProxyEntry, ProxyMerged>> {
if ( !settings.get(UiSettings.PROXY_MERGE_PREFIX) )
return Pipeline(emptyMap(), settings)
val result = coroutineScope {
input
.map {
async {
it.name to PrefixMerger.merge(it.proxies, Proxy::name)
}
}
.map {
it.await()
}
.flatMap {
it.second.map { merged ->
ProxyEntry(it.first, merged.value.name) to ProxyMerged(merged.prefix, merged.content)
}
}
.toMap()
}
return Pipeline(result, settings)
}
suspend fun Pipeline<List<ProxyGroup>>.sort(): Pipeline<List<ProxyGroup>> {
val groupSort = when (settings.get(UiSettings.PROXY_GROUP_SORT)) {
UiSettings.PROXY_SORT_DEFAULT ->
ProxySorter.Order.DEFAULT
UiSettings.PROXY_SORT_NAME ->
ProxySorter.Order.NAME_INCREASE
UiSettings.PROXY_SORT_DELAY ->
ProxySorter.Order.DELAY_INCREASE
else -> throw IllegalArgumentException()
}
val proxySort = when (settings.get(UiSettings.PROXY_PROXY_SORT)) {
UiSettings.PROXY_SORT_DEFAULT ->
ProxySorter.Order.DEFAULT
UiSettings.PROXY_SORT_NAME ->
ProxySorter.Order.NAME_INCREASE
UiSettings.PROXY_SORT_DELAY ->
ProxySorter.Order.DELAY_INCREASE
else -> throw IllegalArgumentException()
}
val sorter = ProxySorter(groupSort, proxySort)
return copy(input = sorter.sort(input))
}
suspend fun Pipeline<List<ProxyGroup>>.toAdapterElement(prefixMerged: Map<ProxyEntry, ProxyMerged>, general: General):
List<ProxyAdapter.ProxyGroupInfo> {
return input.map { group ->
val proxies = group.proxies.map { proxy ->
val merged = prefixMerged[ProxyEntry(group.name, proxy.name)]?.takeIf {
it.prefix.isNotBlank() && it.content.isNotBlank()
} ?: ProxyMerged(proxy.type.toString(), proxy.name)
ProxyAdapter.ProxyInfo(proxy.name, group.name, merged.content, merged.prefix,
proxy.delay.toShort(), group.type == Proxy.Type.SELECT,
group.current == proxy.name)
}
ProxyAdapter.ProxyGroupInfo(group.name, group.current, proxies)
}.let {
withContext(Dispatchers.Default) {
when ( general.mode ) {
General.Mode.DIRECT -> emptyList()
General.Mode.GLOBAL -> it
General.Mode.RULE -> it.filter { it.name != "GLOBAL"}
}
}
}
}

View File

@@ -9,14 +9,13 @@ import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ProcessLifecycleOwner
import com.github.kr328.clash.service.Intents
import com.github.kr328.clash.service.data.ClashProfileEntity
object Broadcasts {
interface Receiver {
fun onStarted()
fun onStopped(cause: String?)
fun onProfileChanged()
fun onProfileLoaded(profileEntity: ClashProfileEntity)
fun onProfileLoaded()
}
var clashRunning: Boolean = false
@@ -47,11 +46,8 @@ object Broadcasts {
it.onProfileChanged()
}
Intents.INTENT_ACTION_PROFILE_LOADED -> {
val profile = intent
.getParcelableExtra<ClashProfileEntity>(Intents.INTENT_EXTRA_PROFILE) ?: return
receivers.forEach {
it.onProfileLoaded(profile)
it.onProfileLoaded()
}
}
}

View File

@@ -21,4 +21,20 @@ object RemoteUtils {
return pong != null
}
fun getCurrentClashProfileName(context: Context): String? {
val authority = Uri.Builder()
.scheme("content")
.authority("${context.packageName}${Constants.STATUS_PROVIDER_SUFFIX}")
.build()
val pong = context.contentResolver.call(
authority,
ServiceStatusProvider.METHOD_PING_CLASH_SERVICE,
null,
null
)
return pong?.getString("name")
}
}

View File

@@ -11,16 +11,19 @@ object PrefixMerger {
suspend fun <T> merge(values: List<T>, transform: (T) -> String): List<Result<T>> =
withContext(Dispatchers.Default) {
val pairs = values.map {
transform(it).trim() to it
transform(it).trim().toCodePointList() to it
}
val groups = mutableListOf<List<Pair<String, T>>>()
var mergingGroup = mutableListOf<Pair<String, T>>()
var currentChar: Char = 0.toChar()
val groups = mutableListOf<List<Pair<List<Int>, T>>>()
var mergingGroup = mutableListOf<Pair<List<Int>, T>>()
var currentCodePoint = 0
val result = mutableListOf<Result<T>>()
for (pair in pairs) {
if (pair.first[0] == currentChar) {
if ( pair.first.isEmpty() )
continue
if (pair.first[0] == currentCodePoint) {
mergingGroup.add(pair)
} else {
if (mergingGroup.isNotEmpty()) {
@@ -28,7 +31,7 @@ object PrefixMerger {
mergingGroup = mutableListOf()
}
currentChar = pair.first[0]
currentCodePoint = pair.first[0]
mergingGroup.add(pair)
}
}
@@ -38,7 +41,7 @@ object PrefixMerger {
for (group in groups) {
var diffIndex = 0
val size = group.map { it.first.length }.min() ?: 0
val size = group.map { it.first.size }.min() ?: 0
diff@ for (charIndex in 0 until size) {
for (stringIndex in 0 until (group.size - 1)) {
@@ -50,14 +53,11 @@ object PrefixMerger {
}
group.forEach {
result.add(
Result(
it.first.substring(0, diffIndex)
.replace(REGEX_PREFIX_TRIM, ""),
it.first.substring(diffIndex),
it.second
)
)
val prefix = it.first.subList(0, diffIndex)
val content = it.first.subList(diffIndex, it.first.size)
result.add(Result(prefix.asCodePointString().replace(REGEX_PREFIX_TRIM, ""),
content.asCodePointString(), it.second))
}
}

View File

@@ -12,19 +12,30 @@ class ProxySorter(private val groupOrder: Order, private val proxyOrder: Order)
suspend fun sort(proxyGroup: List<ProxyGroup>): List<ProxyGroup> =
withContext(Dispatchers.Default) {
val global = proxyGroup.singleOrNull {
it.name == "GLOBAL"
val groups = proxyGroup.groupBy {
if ( it.name == "GLOBAL" )
"GLOBAL"
else
"OTHER"
}
val global = groups["GLOBAL"]?.singleOrNull()
val other = groups["OTHER"] ?: emptyList()
val sortedGroup = when (groupOrder) {
Order.DEFAULT -> groupSortWithDefault(global, proxyGroup)
Order.DELAY_INCREASE -> groupSortWithDelay(true, proxyGroup)
Order.DELAY_DECREASE -> groupSortWithDelay(false, proxyGroup)
Order.NAME_INCREASE -> groupSortWithName(true, proxyGroup)
Order.NAME_DECREASE -> groupSortWithName(false, proxyGroup)
Order.DEFAULT -> groupSortWithDefault(global, other)
Order.DELAY_INCREASE -> groupSortWithDelay(true, other)
Order.DELAY_DECREASE -> groupSortWithDelay(false, other)
Order.NAME_INCREASE -> groupSortWithName(true, other)
Order.NAME_DECREASE -> groupSortWithName(false, other)
}
sortedGroup.map {
val sorted = if ( global == null )
sortedGroup
else
listOf(global) + sortedGroup
sorted.map {
val sortedProxy = when (proxyOrder) {
Order.DEFAULT -> it.proxies
Order.DELAY_INCREASE -> proxySortWithDelay(true, it.proxies)

View File

@@ -0,0 +1,60 @@
package com.github.kr328.clash.utils
import android.content.Context
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.LinearSmoothScroller
import androidx.recyclerview.widget.RecyclerView
class QuickSmoothScroller(context: Context, target: Int):
LinearSmoothScroller(context) {
companion object {
const val MAX_OFFSET = 2
}
var started = {}
var stopped = {}
init {
targetPosition = target
}
override fun getVerticalSnapPreference(): Int {
return SNAP_TO_START
}
override fun onStop() {
super.onStop()
stopped()
}
override fun onStart() {
super.onStart()
started()
}
override fun onSeekTargetStep(dx: Int, dy: Int, state: RecyclerView.State, action: Action) {
when (val lm = layoutManager) {
is LinearLayoutManager -> {
val current = lm.findFirstCompletelyVisibleItemPosition()
if (targetPosition > current && targetPosition - current > MAX_OFFSET)
action.jumpTo(targetPosition - MAX_OFFSET)
else if (current > targetPosition && current - targetPosition > MAX_OFFSET)
action.jumpTo(targetPosition + MAX_OFFSET)
}
is GridLayoutManager -> {
val current = lm.findFirstCompletelyVisibleItemPosition()
if (targetPosition > current && targetPosition - current > MAX_OFFSET)
action.jumpTo(targetPosition - MAX_OFFSET)
else if (current > targetPosition && current - targetPosition > MAX_OFFSET)
action.jumpTo(targetPosition + MAX_OFFSET)
}
}
super.onSeekTargetStep(dx, dy, state, action)
}
}

View File

@@ -1,7 +1,6 @@
package com.github.kr328.clash.utils
import android.content.Context
import android.util.DisplayMetrics
import androidx.recyclerview.widget.LinearSmoothScroller
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
@@ -14,7 +13,7 @@ class ScrollBinding(
fun getCurrentMasterToken(): String
fun onMasterTokenChanged(token: String)
fun getMasterTokenPosition(token: String): Int
fun doMasterScroll(scroller: LinearSmoothScroller)
fun doMasterScroll(scroller: LinearSmoothScroller, target: Int)
}
private val updateChannel = Channel<Unit>(Channel.CONFLATED)
@@ -30,33 +29,9 @@ class ScrollBinding(
if (position < 0)
return
val scroller = (object : LinearSmoothScroller(context) {
override fun getVerticalSnapPreference(): Int {
return SNAP_TO_START
}
val scroller = QuickSmoothScroller(context, position)
override fun onStop() {
super.onStop()
preventSlaveScroll = false
}
override fun onStart() {
super.onStart()
preventSlaveScroll = true
}
override fun calculateSpeedPerPixel(displayMetrics: DisplayMetrics?): Float {
return super.calculateSpeedPerPixel(displayMetrics) * 0.8f
}
init {
targetPosition = position
}
})
callback.doMasterScroll(scroller)
callback.doMasterScroll(scroller, position)
}
suspend fun exec() {

View File

@@ -6,7 +6,9 @@ import android.net.VpnService
import com.github.kr328.clash.preference.UiSettings
import com.github.kr328.clash.service.ClashService
import com.github.kr328.clash.service.Intents
import com.github.kr328.clash.service.TunService
import com.github.kr328.clash.service.util.intent
import com.github.kr328.clash.service.util.sendBroadcastSelf
import com.github.kr328.clash.service.util.startForegroundServiceCompat
fun Context.startClashService(): Intent? {
@@ -16,10 +18,16 @@ fun Context.startClashService(): Intent? {
val vpnRequest = VpnService.prepare(this)
if ( vpnRequest != null )
return vpnRequest
startForegroundServiceCompat(TunService::class.intent)
}
else {
startForegroundServiceCompat(ClashService::class.intent)
}
startForegroundServiceCompat(ClashService::class.intent
.putExtra(Intents.INTENT_EXTRA_START_TUN, startTun))
return null
}
fun Context.stopClashService() {
sendBroadcastSelf(Intent(Intents.INTENT_ACTION_REQUEST_STOP))
}

View File

@@ -0,0 +1,25 @@
package com.github.kr328.clash.utils
fun String.toCodePointList(): List<Int> {
var offset = 0
val result = mutableListOf<Int>()
while ( offset < length ) {
val codePoint = codePointAt(offset)
result.add(codePoint)
offset += Character.charCount(codePoint)
}
return result.toList()
}
fun List<Int>.asCodePointString(): String {
val sb = StringBuilder()
forEach {
sb.appendCodePoint(it)
}
return sb.toString()
}

View File

@@ -1,12 +1,14 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:width="108dp"
android:height="108dp"
android:viewportWidth="796.13654"
android:viewportHeight="796.13654">
<group android:translateX="196.83206"
android:translateY="207.54466">
<path
android:fillColor="#1E4376"
android:pathData="M95.758,327.156l4,-41l12,-97l13,-94l12,-61l2,-7c2,-5 4,-6 9,-3l10,9l26,41c3,4 6,5 11,4c28,-6 57,-5 85,1c5,1 7,-1 9,-5a1453,1453 0,0 1,30 -49c4,-4 7,-4 9,2l8,36l28,177c5,34 8,67 11,100c2,12 1,13 -11,14c-54,5 -108,5 -162,4c-30,0 -59,-2 -88,-3c-6,0 -12,-3 -18,-4l-17,-1c-18,-2 -34,-9 -44,-26c-17,-28 -6,-65 29,-75c4,-1 8,0 12,1c3,2 3,5 0,7l-6,5c-4,3 -9,6 -12,10a31,31 0,0 0,7 49c10,5 20,6 31,6zM176.758,139.156c-8,0 -15,7 -15,15s6,15 15,15c8,0 15,-6 15,-15c0,-8 -7,-15 -15,-15zM299.758,169.156c8,0 15,-6 15,-15c0,-8 -6,-15 -15,-15a15,15 0,1 0,0 30zM216.758,189.156c4,7 11,7 20,1c7,6 16,6 18,-1c-8,3 -14,2 -19,-7c-4,9 -10,10 -19,7z"/>
android:viewportWidth="406.92642"
android:viewportHeight="406.92642">
<group android:translateX="103.4632"
android:translateY="103.4632">
<path
android:fillColor="#1E4376"
android:pathData="M47.211,168.128C70.531,-34.962 67.471,13.788 94.071,43.818c13.45,-1.52 27.24,-3.47 40.82,-0.67c2.64,0.13 5.42,1.86 7.71,0.18c4.12,-6.27 7.35,-13.54 11.35,-20c12.19,-24.44 12.85,19.54 15.48,26.52c5.23,32.99 10.89,64.46 14.67,97.59c0.31,10.72 5.74,32.92 1.08,33.56c-49.36,5.23 -147.71,3.91 -160.84,-6.3c-15.85,-10.5 -15.18,-35.33 2.03,-43.72c3.63,-2.03 10.68,-3.72 11.94,0.7c-2.41,4.99 -8.79,5.77 -12.12,11.17C16.621,158.948 33.111,168.888 47.211,168.128zM87.841,74.008c-10.42,0.52 -9.59,14.89 -0.07,15.18C98.191,88.668 97.361,74.298 87.841,74.008zM149.121,89.188c10.46,-0.34 9.85,-14.71 0.38,-15.18C139.031,74.348 139.651,88.718 149.121,89.188zM107.871,99.228c2.16,3.48 5.28,3.29 9.79,0.16c3.81,3.17 8.06,3.28 9.18,-0.19c-3.78,1.17 -7.04,0.79 -9.4,-3.49C115.371,100.108 112.071,100.428 107.871,99.228z"
tools:ignore="VectorPath" />
</group>
</vector>

View File

@@ -1,9 +1,11 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="402.47244dp"
android:height="381.04724dp"
android:viewportWidth="402.47244"
android:viewportHeight="381.04724">
<path
android:fillColor="?attr/colorPrimary"
android:pathData="M95.758,327.156l4,-41l12,-97l13,-94l12,-61l2,-7c2,-5 4,-6 9,-3l10,9l26,41c3,4 6,5 11,4c28,-6 57,-5 85,1c5,1 7,-1 9,-5a1453,1453 0,0 1,30 -49c4,-4 7,-4 9,2l8,36l28,177c5,34 8,67 11,100c2,12 1,13 -11,14c-54,5 -108,5 -162,4c-30,0 -59,-2 -88,-3c-6,0 -12,-3 -18,-4l-17,-1c-18,-2 -34,-9 -44,-26c-17,-28 -6,-65 29,-75c4,-1 8,0 12,1c3,2 3,5 0,7l-6,5c-4,3 -9,6 -12,10a31,31 0,0 0,7 49c10,5 20,6 31,6zM176.758,139.156c-8,0 -15,7 -15,15s6,15 15,15c8,0 15,-6 15,-15c0,-8 -7,-15 -15,-15zM299.758,169.156c8,0 15,-6 15,-15c0,-8 -6,-15 -15,-15a15,15 0,1 0,0 30zM216.758,189.156c4,7 11,7 20,1c7,6 16,6 18,-1c-8,3 -14,2 -19,-7c-4,9 -10,10 -19,7z"/>
xmlns:tools="http://schemas.android.com/tools"
android:width="200dp"
android:height="200dp"
android:viewportWidth="200"
android:viewportHeight="200">
<path
android:fillColor="?attr/colorPrimary"
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>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.0 KiB

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

View File

@@ -170,4 +170,6 @@
<string name="about_split_apks_url" translatable="false">https://developer.android.com/platform/technology/app-bundle</string>
<string name="google_play_url" translatable="false">https://play.google.com/store/apps/details?id=com.github.kr328.clash</string>
<string name="github_releases_url" translatable="false">https://github.com/Kr328/ClashForAndroid/releases</string>
<string name="format_proxy_group_title" translatable="false">%s - %s</string>
</resources>

View File

@@ -8,8 +8,8 @@ buildscript {
gMinSdkVersion = 24
gTargetSdkVersion = 29
gVersionCode = 10100
gVersionName = "1.1.0"
gVersionCode = 10102
gVersionName = "1.1.2"
gKotlinVersion = '1.3.61'
gKotlinCoroutineVersion = '1.3.3'

View File

@@ -5,6 +5,7 @@ import (
"sync"
"github.com/Dreamacro/clash/component/resolver"
"github.com/Dreamacro/clash/dns"
"github.com/Dreamacro/clash/log"
"github.com/Dreamacro/clash/proxy/tun"
)
@@ -56,5 +57,5 @@ func ResetDnsRedirect() {
return
}
(*tunInstance).ReCreateDNSServer(resolver.DefaultResolver, dnsAddress)
(*tunInstance).ReCreateDNSServer(resolver.DefaultResolver.(*dns.Resolver), dnsAddress)
}

View File

@@ -2,13 +2,12 @@ package com.github.kr328.clash.service
import android.app.Service
import android.content.Context
import android.content.res.Configuration
import com.github.kr328.clash.core.Clash
import com.github.kr328.clash.service.settings.ServiceSettings
import com.github.kr328.clash.service.util.createLanguageConfigurationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.cancel
import java.util.*
abstract class BaseService : Service(), CoroutineScope by MainScope() {
lateinit var settings: ServiceSettings
@@ -17,19 +16,8 @@ abstract class BaseService : Service(), CoroutineScope by MainScope() {
settings = ServiceSettings(base ?: return super.attachBaseContext(base))
val language = settings.get(ServiceSettings.LANGUAGE)
if ( language.isEmpty() )
return super.attachBaseContext(base)
val languageOverride = language.split("-")
val configuration = Configuration()
val localeOverride = if (languageOverride.size == 2)
Locale(languageOverride[0], languageOverride[1])
else
Locale(languageOverride[0])
configuration.setLocale(localeOverride)
super.attachBaseContext(base.createConfigurationContext(configuration))
super.attachBaseContext(base.createLanguageConfigurationContext(language))
}
override fun onCreate() {

View File

@@ -0,0 +1,151 @@
package com.github.kr328.clash.service
import android.app.Service
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.PowerManager
import androidx.core.content.contentValuesOf
import androidx.core.content.getSystemService
import com.github.kr328.clash.core.Clash
import com.github.kr328.clash.core.utils.Log
import com.github.kr328.clash.service.data.ClashDatabase
import com.github.kr328.clash.service.settings.ServiceSettings
import com.github.kr328.clash.service.util.*
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.selects.select
class ClashCore(private val service: Service): CoroutineScope by MainScope() {
private var stopReason: String? = null
private val settings = ServiceSettings(service)
private val database = ClashDatabase.getInstance(service)
private val notification = ClashNotification(service)
private val reloadChannel = Channel<Unit>(Channel.CONFLATED)
private val screenChannel = Channel<Boolean>(Channel.CONFLATED)
private val receivers = object: BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
when ( intent?.action ) {
Intent.ACTION_SCREEN_ON ->
screenChannel.offer(true)
Intent.ACTION_SCREEN_OFF ->
screenChannel.offer(false)
Intents.INTENT_ACTION_PROFILE_CHANGED ->
intent.enforceSelfPackage {
reloadChannel.offer(Unit)
}
Intents.INTENT_ACTION_NETWORK_CHANGED ->
intent.enforceSelfPackage {
reloadChannel.offer(Unit)
}
Intents.INTENT_ACTION_REQUEST_STOP ->
intent.enforceSelfPackage {
stopSelf(null)
}
}
}
}
fun start() {
service.registerReceiver(receivers, IntentFilter().apply {
addAction(Intents.INTENT_ACTION_PROFILE_CHANGED)
addAction(Intents.INTENT_ACTION_REQUEST_STOP)
addAction(Intents.INTENT_ACTION_NETWORK_CHANGED)
if ( settings.get(ServiceSettings.NOTIFICATION_REFRESH) ) {
addAction(Intent.ACTION_SCREEN_ON)
addAction(Intent.ACTION_SCREEN_OFF)
}
})
broadcastClashStarted(service)
Clash.start()
ServiceStatusProvider.serviceRunning = true
reloadChannel.offer(Unit)
launch {
val ticker = Channel<Unit>()
var enableRefresh = settings.get(ServiceSettings.NOTIFICATION_REFRESH)
&& service.getSystemService<PowerManager>()!!.isInteractive
launch {
while ( isActive ) {
ticker.send(Unit)
delay(1000)
}
}
while ( isActive ) {
select<Unit> {
reloadChannel.onReceive {
reload()
}
screenChannel.onReceive {
enableRefresh = it
Log.i("Clash Notification Status $it")
}
if ( enableRefresh ) {
ticker.onReceive {
notification.update()
}
}
}
}
}
}
fun destroy() {
cancel()
service.unregisterReceiver(receivers)
reloadChannel.close()
notification.destroy()
broadcastClashStopped(service, stopReason)
Clash.stop()
ServiceStatusProvider.serviceRunning = false
}
private fun stopSelf(reason: String?) {
stopReason = reason
service.stopSelf()
Clash.stopTunDevice()
}
private suspend fun reload() {
try {
val active = database.openClashProfileDao()
.queryActiveProfile() ?: return stopSelf("Active Profile not Found")
Clash.loadProfile(
resolveProfile(active.id),
resolveBase(active.id)
).await()
ClashDatabase.getInstance(service).openClashProfileProxyDao()
.querySelectedForProfile(active.id).forEach {
Clash.setSelectedProxy(it.proxy, it.selected)
}
ServiceStatusProvider.currentProfile = active.name
notification.setProfile(active.name)
broadcastProfileLoaded(service)
} catch (e: Exception) {
stopSelf(e.message ?: "Unknown")
}
}
}

View File

@@ -1,26 +1,19 @@
package com.github.kr328.clash.service
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.app.*
import android.content.Intent
import android.content.IntentFilter
import android.os.Build
import android.os.PowerManager
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import com.github.kr328.clash.core.Clash
import com.github.kr328.clash.core.utils.Log
import com.github.kr328.clash.core.utils.asBytesString
import com.github.kr328.clash.core.utils.asSpeedString
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class ClashNotification(private val context: ClashService, private val enableRefresh: Boolean) :
CoroutineScope by context {
class ClashNotification(
private val context: Service
) {
companion object {
private const val CLASH_STATUS_NOTIFICATION_CHANNEL = "clash_status_channel"
private const val CLASH_STATUS_NOTIFICATION_ID = 413
@@ -37,6 +30,7 @@ class ClashNotification(private val context: ClashService, private val enableRef
.setColor(context.getColor(R.color.colorAccentService))
.setOnlyAlertOnce(true)
.setShowWhen(false)
.setGroup(CLASH_STATUS_NOTIFICATION_CHANNEL)
.setContentIntent(
PendingIntent.getActivity(
context,
@@ -45,107 +39,26 @@ class ClashNotification(private val context: ClashService, private val enableRef
PendingIntent.FLAG_UPDATE_CURRENT
)
)
private val screenChannel: Channel<Boolean> = Channel(Channel.CONFLATED)
private val tickerChannel: Channel<Unit> = Channel()
private var profile = "None"
private val observer = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
when (intent?.action) {
Intent.ACTION_SCREEN_ON ->
screenChannel.offer(true)
Intent.ACTION_SCREEN_OFF ->
screenChannel.offer(false)
}
}
}
private var currentProfile = "None"
init {
createNotificationChannel()
updateBase()
if (enableRefresh) {
launch {
val powerManager =
requireNotNull(context.getSystemService(PowerManager::class.java))
screenChannel.send(powerManager.isInteractive)
var tickerJob: Job? = null
launch {
while (isActive) {
tickerJob = if (screenChannel.receive()) {
tickerJob?.cancel()
startTicker()
} else {
tickerJob?.cancel()
null
}
}
}
launch {
while (isActive) {
tickerChannel.receive()
update()
}
}
context.registerReceiver(observer, IntentFilter().apply {
addAction(Intent.ACTION_SCREEN_ON)
addAction(Intent.ACTION_SCREEN_OFF)
})
}
}
}
private fun startTicker(): Job {
return launch {
Log.d("Clash Notification Started")
try {
while (isActive) {
tickerChannel.send(Unit)
delay(1000)
}
} finally {
Log.d("Clash Notification Stopped")
}
}
}
fun destroy() {
if ( enableRefresh )
context.unregisterReceiver(observer)
updateDestroy()
context.stopForeground(true)
}
fun setProfile(profile: String) {
launch {
this@ClashNotification.profile = profile
if ( enableRefresh )
update()
else
updateBase()
}
currentProfile = profile
}
private fun updateBase() {
val notification = baseBuilder
.setContentTitle(profile)
.setContentText(context.getText(R.string.running))
.build()
context.startForeground(CLASH_STATUS_NOTIFICATION_ID, notification)
}
private suspend fun update() {
suspend fun update() {
val notification = withContext(Dispatchers.Default) {
createNotification()
}
@@ -153,12 +66,30 @@ class ClashNotification(private val context: ClashService, private val enableRef
context.startForeground(CLASH_STATUS_NOTIFICATION_ID, notification)
}
private fun updateBase() {
val notification = baseBuilder
.setContentTitle(currentProfile)
.setContentText(context.getText(R.string.running))
.build()
context.startForeground(CLASH_STATUS_NOTIFICATION_ID, notification)
}
private fun updateDestroy() {
// just waiting system cancel our notification :)
// fxxking google
val notification = baseBuilder
.setContentTitle(context.getText(R.string.destroying))
.setContentText(context.getText(R.string.recycling_resources))
.build()
}
private fun createNotification(): Notification {
val traffic = Clash.queryTraffic()
val bandwidth = Clash.queryBandwidth()
return baseBuilder
.setContentTitle(profile)
.setContentTitle(currentProfile)
.setContentText(
context.getString(
R.string.clash_notification_content,

View File

@@ -1,79 +1,25 @@
package com.github.kr328.clash.service
import android.content.BroadcastReceiver
import android.content.Context
import android.app.Service
import android.content.Intent
import android.content.IntentFilter
import android.os.Binder
import android.os.IBinder
import com.github.kr328.clash.core.Clash
import com.github.kr328.clash.service.data.ClashDatabase
import com.github.kr328.clash.service.settings.ServiceSettings
import com.github.kr328.clash.service.util.*
import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
class ClashService : BaseService() {
companion object {
var isServiceRunning = false
}
private val loadLock = Mutex()
private val service = this
private lateinit var notification: ClashNotification
private var stopReason: String? = null
private val reloadChannel = Channel<Unit>(Channel.CONFLATED)
private val reloadReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
if (intent?.`package` != packageName)
return
reloadChannel.offer(Unit)
}
}
class ClashService : Service() {
private var clashCore: ClashCore? = null
override fun onCreate() {
super.onCreate()
notification = ClashNotification(service, settings.get(ServiceSettings.NOTIFICATION_REFRESH))
if ( ServiceStatusProvider.serviceRunning )
return stopSelf()
launch {
while (isActive) {
reloadChannel.receive()
Clash.initialize(this)
reloadProfile()
}
}
}
clashCore = ClashCore(this)
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
super.onStartCommand(intent, flags, startId)
if (isServiceRunning)
return START_NOT_STICKY
isServiceRunning = true
Clash.start()
broadcastClashStarted(this)
val startVpn = intent?.getBooleanExtra(
Intents.INTENT_EXTRA_START_TUN, true) ?: true
if (startVpn)
startService(TunService::class.intent)
registerReceiver(reloadReceiver, IntentFilter().apply {
addAction(Intents.INTENT_ACTION_PROFILE_CHANGED)
addAction(Intents.INTENT_ACTION_NETWORK_CHANGED)
})
reloadChannel.offer(Unit)
return START_NOT_STICKY
clashCore?.start()
}
override fun onBind(intent: Intent?): IBinder? {
@@ -81,52 +27,6 @@ class ClashService : BaseService() {
}
override fun onDestroy() {
cancel()
Clash.stopTunDevice()
Clash.stop()
notification.destroy()
broadcastClashStopped(this, stopReason)
unregisterReceiver(reloadReceiver)
isServiceRunning = false
super.onDestroy()
}
private suspend fun reloadProfile() {
if (!loadLock.tryLock())
return
try {
val active = ClashDatabase.getInstance(service).openClashProfileDao()
.queryActiveProfile() ?: return stopSelf("Empty active profile")
Clash.loadProfile(
resolveProfile(active.id),
resolveBase(active.id)
).await()
ClashDatabase.getInstance(service).openClashProfileProxyDao()
.querySelectedForProfile(active.id).forEach {
Clash.setSelectedProxy(it.proxy, it.selected)
}
notification.setProfile(active.name)
broadcastProfileLoaded(this, active)
} catch (e: Exception) {
stopSelf("Load profile failure")
} finally {
loadLock.unlock()
}
}
private fun stopSelf(reason: String) {
stopReason = reason
stopSelf()
clashCore?.destroy()
}
}

View File

@@ -15,11 +15,11 @@ object Intents {
"${BuildConfig.LIBRARY_PACKAGE_NAME}.intent.action.profile.loaded"
const val INTENT_ACTION_NETWORK_CHANGED =
"${BuildConfig.LIBRARY_PACKAGE_NAME}.intent.action.network.changed"
const val INTENT_ACTION_REQUEST_STOP =
"${BuildConfig.LIBRARY_PACKAGE_NAME}.intent.action.request.stop"
const val INTENT_EXTRA_CLASH_STOP_REASON =
"${BuildConfig.LIBRARY_PACKAGE_NAME}.intent.extra.clash.stop.reason"
const val INTENT_EXTRA_PROFILE =
"${BuildConfig.LIBRARY_PACKAGE_NAME}.intent.extra.profile"
const val INTENT_EXTRA_PROFILE_REQUEST =
"${BuildConfig.LIBRARY_PACKAGE_NAME}.intent.extra.profile.request"
const val INTENT_EXTRA_START_TUN =

View File

@@ -180,6 +180,7 @@ class ProfileBackgroundService : BaseService() {
.setColor(getColor(R.color.colorAccentService))
.setSmallIcon(R.drawable.ic_notification)
.setOnlyAlertOnce(true)
.setGroup(SERVICE_STATUS_CHANNEL)
.build()
startForeground(RandomUtils.nextInt(), notification)
@@ -194,6 +195,7 @@ class ProfileBackgroundService : BaseService() {
.setColor(getColor(R.color.colorAccentService))
.setSmallIcon(R.drawable.ic_notification)
.setOnlyAlertOnce(true)
.setGroup(SERVICE_RESULT_CHANNEL)
.build()
NotificationManagerCompat.from(this)
@@ -209,6 +211,7 @@ class ProfileBackgroundService : BaseService() {
.setColor(getColor(R.color.colorAccentService))
.setSmallIcon(R.drawable.ic_notification)
.setOnlyAlertOnce(true)
.setGroup(SERVICE_RESULT_CHANNEL)
.build()
NotificationManagerCompat.from(this)

View File

@@ -9,13 +9,18 @@ import android.os.Bundle
class ServiceStatusProvider : ContentProvider() {
companion object {
const val METHOD_PING_CLASH_SERVICE = "pingClashService"
var serviceRunning: Boolean = false
var currentProfile: String? = null
}
override fun call(method: String, arg: String?, extras: Bundle?): Bundle? {
return when (method) {
METHOD_PING_CLASH_SERVICE -> {
return if (ClashService.isServiceRunning)
Bundle()
return if (serviceRunning)
Bundle().apply {
putString("name", currentProfile)
}
else
null
}

View File

@@ -6,9 +6,9 @@ import com.github.kr328.clash.core.Clash
import com.github.kr328.clash.core.utils.Log
import com.github.kr328.clash.service.net.DefaultNetworkChannel
import com.github.kr328.clash.service.settings.ServiceSettings
import com.github.kr328.clash.service.util.asSocketAddressText
import com.github.kr328.clash.service.util.broadcastNetworkChanged
import kotlinx.coroutines.*
import java.net.Inet6Address
class TunService : VpnService(), CoroutineScope by MainScope() {
companion object {
@@ -20,6 +20,8 @@ class TunService : VpnService(), CoroutineScope by MainScope() {
private const val VLAN4_ANY = "0.0.0.0"
}
private var clashCore: ClashCore? = null
private lateinit var defaultNetworkChannel: DefaultNetworkChannel
private lateinit var settings: ServiceSettings
@@ -55,6 +57,13 @@ class TunService : VpnService(), CoroutineScope by MainScope() {
Clash.initialize(this)
if ( ServiceStatusProvider.serviceRunning )
return stopSelf()
clashCore = ClashCore(this)
clashCore?.start()
settings = ServiceSettings(this)
defaultNetworkChannel = DefaultNetworkChannel(this, this)
@@ -83,12 +92,7 @@ class TunService : VpnService(), CoroutineScope by MainScope() {
val dnsServers = d.second?.dnsServers ?: emptyList()
val dnsStrings = dnsServers.map {
when ( it ) {
is Inet6Address ->
"[${it.hostName}]:53"
else ->
"${it.hostName}:53"
}
it.asSocketAddressText(53)
}
Clash.appendDns(dnsStrings)
@@ -103,7 +107,11 @@ class TunService : VpnService(), CoroutineScope by MainScope() {
override fun onDestroy() {
cancel()
defaultNetworkChannel.unregister()
clashCore?.apply {
destroy()
defaultNetworkChannel.unregister()
}
Log.i("TunService.onDestroy")

View File

@@ -2,6 +2,7 @@ package com.github.kr328.clash.service.data
import android.os.Parcel
import android.os.Parcelable
import androidx.annotation.Keep
import androidx.room.ColumnInfo
import androidx.room.Entity
import com.github.kr328.clash.core.serialization.Parcels
@@ -9,6 +10,7 @@ import kotlinx.serialization.Serializable
@Entity(tableName = "profiles", primaryKeys = ["id"])
@Serializable
@Keep
data class ClashProfileEntity(
@ColumnInfo(name = "name") val name: String,
@ColumnInfo(name = "type") val type: Int,

View File

@@ -2,8 +2,8 @@ package com.github.kr328.clash.service.util
import android.content.Context
import android.content.Intent
import com.github.kr328.clash.core.Global
import com.github.kr328.clash.service.Intents
import com.github.kr328.clash.service.data.ClashProfileEntity
fun Context.sendBroadcastSelf(intent: Intent) {
this.sendBroadcast(intent.setPackage(this.packageName))
@@ -15,9 +15,8 @@ fun broadcastProfileChanged(context: Context) {
context.sendBroadcastSelf(intent)
}
fun broadcastProfileLoaded(context: Context, profileEntity: ClashProfileEntity) {
fun broadcastProfileLoaded(context: Context) {
val intent = Intent(Intents.INTENT_ACTION_PROFILE_LOADED)
.putExtra(Intents.INTENT_EXTRA_PROFILE, profileEntity)
context.sendBroadcastSelf(intent)
}
@@ -37,4 +36,11 @@ fun broadcastClashStopped(context: Context, reason: String?) {
reason
)
)
}
fun Intent.enforceSelfPackage(block: () -> Unit) {
if ( `package` != Global.application.packageName )
return
block()
}

View File

@@ -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()
}

View File

@@ -0,0 +1,23 @@
package com.github.kr328.clash.service.util
import android.content.Context
import android.content.res.Configuration
import java.util.*
fun Context.createLanguageConfigurationContext(language: String): Context {
if ( language.isBlank() ) {
return this
}
val split = language.split("-")
val locale = if ( split.size == 1 )
Locale(split[0])
else
Locale(split[0], split[1])
val configuration = Configuration()
configuration.setLocale(locale)
return createConfigurationContext(configuration)
}

View File

@@ -1,9 +1,11 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="402.47244dp"
android:height="381.04724dp"
android:viewportWidth="402.47244"
android:viewportHeight="381.04724">
xmlns:tools="http://schemas.android.com/tools"
android:width="200dp"
android:height="200dp"
android:viewportWidth="200"
android:viewportHeight="200">
<path
android:fillColor="#FF000000"
android:pathData="M95.758,327.156l4,-41l12,-97l13,-94l12,-61l2,-7c2,-5 4,-6 9,-3l10,9l26,41c3,4 6,5 11,4c28,-6 57,-5 85,1c5,1 7,-1 9,-5a1453,1453 0,0 1,30 -49c4,-4 7,-4 9,2l8,36l28,177c5,34 8,67 11,100c2,12 1,13 -11,14c-54,5 -108,5 -162,4c-30,0 -59,-2 -88,-3c-6,0 -12,-3 -18,-4l-17,-1c-18,-2 -34,-9 -44,-26c-17,-28 -6,-65 29,-75c4,-1 8,0 12,1c3,2 3,5 0,7l-6,5c-4,3 -9,6 -12,10a31,31 0,0 0,7 49c10,5 20,6 31,6zM176.758,139.156c-8,0 -15,7 -15,15s6,15 15,15c8,0 15,-6 15,-15c0,-8 -7,-15 -15,-15zM299.758,169.156c8,0 15,-6 15,-15c0,-8 -6,-15 -15,-15a15,15 0,1 0,0 30zM216.758,189.156c4,7 11,7 20,1c7,6 16,6 18,-1c-8,3 -14,2 -19,-7c-4,9 -10,10 -19,7z"/>
android:fillColor="#000000"
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>

View File

@@ -11,4 +11,6 @@
<string name="format_update_failure">Update %s Failure\n%s</string>
<string name="profile_status_channel">Profile Processing Status</string>
<string name="running">Running</string>
<string name="destroying">Destroying</string>
<string name="recycling_resources">Recycling resources</string>
</resources>