Compare commits
40 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
126291d544 | ||
|
|
37db81f6b1 | ||
|
|
a3cfd10d39 | ||
|
|
0e61f02046 | ||
|
|
d8b1c0b7b8 | ||
|
|
8bf908b731 | ||
|
|
568f1f12e7 | ||
|
|
ca739a5e2f | ||
|
|
7833f747d5 | ||
|
|
977519b383 | ||
|
|
b39b3812d0 | ||
|
|
f7dddb41fa | ||
|
|
053300919c | ||
|
|
93dff86383 | ||
|
|
1b7e9c6b12 | ||
|
|
f072cd0839 | ||
|
|
f626a0fc5c | ||
|
|
63da12f046 | ||
|
|
ce1beafb08 | ||
|
|
f6ecc5c953 | ||
|
|
de87e3fd74 | ||
|
|
e34a7a3081 | ||
|
|
ecb1678e48 | ||
|
|
ca395033a5 | ||
|
|
8095451e14 | ||
|
|
c0435ead01 | ||
|
|
d346344e17 | ||
|
|
68d6af4255 | ||
|
|
253d465d32 | ||
|
|
7ab7c8abee | ||
|
|
ea3aee0883 | ||
|
|
b4a5d7b1be | ||
|
|
028346713e | ||
|
|
d519bc1a23 | ||
|
|
a6d5fbc2ca | ||
|
|
e9a6bc2fbc | ||
|
|
ef42612c4b | ||
|
|
f4b421c953 | ||
|
|
14aa981ca4 | ||
|
|
515942274d |
@@ -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**
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ android {
|
||||
buildTypes {
|
||||
release {
|
||||
minifyEnabled true
|
||||
shrinkResources true
|
||||
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||
}
|
||||
}
|
||||
@@ -27,6 +28,12 @@ android {
|
||||
kotlinOptions {
|
||||
jvmTarget = "1.8"
|
||||
}
|
||||
splits {
|
||||
abi {
|
||||
enable true
|
||||
universalApk true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 16 KiB |
@@ -13,8 +13,10 @@ class ApkBrokenActivity : BaseActivity() {
|
||||
setContentView(R.layout.activity_application_broken)
|
||||
setSupportActionBar(toolbar)
|
||||
|
||||
text.text = Html.fromHtml(getString(R.string.application_broken_description),
|
||||
Html.FROM_HTML_MODE_COMPACT)
|
||||
text.text = Html.fromHtml(
|
||||
getString(R.string.application_broken_description),
|
||||
Html.FROM_HTML_MODE_COMPACT
|
||||
)
|
||||
|
||||
commonUi.build {
|
||||
option(
|
||||
@@ -22,8 +24,10 @@ class ApkBrokenActivity : BaseActivity() {
|
||||
title = getString(R.string.learn_more_about_split_apks)
|
||||
) {
|
||||
onClick {
|
||||
startActivity(Intent(Intent.ACTION_VIEW)
|
||||
.setData(Uri.parse(getString(R.string.about_split_apks_url))))
|
||||
startActivity(
|
||||
Intent(Intent.ACTION_VIEW)
|
||||
.setData(Uri.parse(getString(R.string.about_split_apks_url)))
|
||||
)
|
||||
}
|
||||
}
|
||||
option(
|
||||
@@ -31,8 +35,10 @@ class ApkBrokenActivity : BaseActivity() {
|
||||
title = getString(R.string.reinstall_from_google_play)
|
||||
) {
|
||||
onClick {
|
||||
startActivity(Intent(Intent.ACTION_VIEW)
|
||||
.setData(Uri.parse(getString(R.string.google_play_url))))
|
||||
startActivity(
|
||||
Intent(Intent.ACTION_VIEW)
|
||||
.setData(Uri.parse(getString(R.string.google_play_url)))
|
||||
)
|
||||
}
|
||||
}
|
||||
option(
|
||||
@@ -40,8 +46,10 @@ class ApkBrokenActivity : BaseActivity() {
|
||||
title = getString(R.string.download_from_github_releases)
|
||||
) {
|
||||
onClick {
|
||||
startActivity(Intent(Intent.ACTION_VIEW)
|
||||
.setData(Uri.parse(getString(R.string.github_releases_url))))
|
||||
startActivity(
|
||||
Intent(Intent.ACTION_VIEW)
|
||||
.setData(Uri.parse(getString(R.string.github_releases_url)))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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?) {
|
||||
@@ -127,12 +114,8 @@ abstract class BaseActivity : AppCompatActivity(), CoroutineScope by MainScope()
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
|
||||
if (language != uiSettings.get(UiSettings.LANGUAGE))
|
||||
if (language != uiSettings.get(UiSettings.LANGUAGE) || darkMode != uiSettings.get(UiSettings.DARK_MODE))
|
||||
recreate()
|
||||
if (darkMode != uiSettings.get(UiSettings.DARK_MODE)) {
|
||||
resetDarkMode()
|
||||
recreate()
|
||||
}
|
||||
|
||||
Broadcasts.register(receiver)
|
||||
}
|
||||
@@ -204,7 +187,7 @@ abstract class BaseActivity : AppCompatActivity(), CoroutineScope by MainScope()
|
||||
}
|
||||
|
||||
private fun resetDarkMode() {
|
||||
when ( uiSettings.get(UiSettings.DARK_MODE).also { darkMode = it } ) {
|
||||
when (uiSettings.get(UiSettings.DARK_MODE).also { darkMode = it }) {
|
||||
UiSettings.DARK_MODE_AUTO ->
|
||||
delegate.localNightMode = AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
|
||||
UiSettings.DARK_MODE_DARK ->
|
||||
|
||||
@@ -70,7 +70,7 @@ class LogViewerActivity : BaseActivity() {
|
||||
super.onStart()
|
||||
|
||||
launch {
|
||||
if ( pauseMutex.isLocked )
|
||||
if (pauseMutex.isLocked)
|
||||
pauseMutex.unlock()
|
||||
}
|
||||
}
|
||||
@@ -99,13 +99,12 @@ class LogViewerActivity : BaseActivity() {
|
||||
.split("\n")
|
||||
.parallelStream()
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotEmpty() && !it.startsWith("#")}
|
||||
.filter { it.isNotEmpty() && !it.startsWith("#") }
|
||||
.map { it.split(" ", limit = 3) }
|
||||
.filter { it.size == 3 }
|
||||
.map { LogEvent(LogEvent.Level.valueOf(it[1]), it[2], it[0].toLong()) }
|
||||
.toList()
|
||||
}
|
||||
catch (e: Exception) {
|
||||
} catch (e: Exception) {
|
||||
makeSnackbarException(getString(R.string.open_log_failure), e.message)
|
||||
|
||||
throw CancellationException()
|
||||
@@ -130,7 +129,7 @@ class LogViewerActivity : BaseActivity() {
|
||||
(mainList.adapter as LiveLogAdapter).insertItems(response.logs)
|
||||
|
||||
mainList.apply {
|
||||
if ( computeVerticalScrollOffset() < 30 )
|
||||
if (computeVerticalScrollOffset() < 30)
|
||||
scrollToPosition(0)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -38,6 +40,7 @@ class LogcatService : Service(), CoroutineScope by MainScope(), IInterface {
|
||||
private const val NOTIFICATION_CHANNEL_ID = "clash_logcat_channel"
|
||||
private const val NOTIFICATION_ID = 256
|
||||
private const val MAX_CACHE_COUNT = 200
|
||||
private const val LOG_LISTENER_KEY = "logcat_service"
|
||||
|
||||
private const val LOG_CONTENT_FORMAT = "%d %s %s"
|
||||
|
||||
@@ -54,27 +57,23 @@ class LogcatService : Service(), CoroutineScope by MainScope(), IInterface {
|
||||
private val entity = LogFile.generate()
|
||||
|
||||
private val connection = object : ServiceConnection {
|
||||
private var manager: IClashManager? = null
|
||||
|
||||
override fun onServiceDisconnected(name: ComponentName?) {
|
||||
stopSelf()
|
||||
manager?.unregisterLogListener(LOG_LISTENER_KEY)
|
||||
}
|
||||
|
||||
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
|
||||
val manager = IClashManager.Stub.asInterface(service) ?: return stopSelf()
|
||||
|
||||
manager.openLogEvent(object : IStreamCallback.Stub() {
|
||||
override fun complete() {
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
override fun completeExceptionally(reason: String?) {
|
||||
stopSelf()
|
||||
}
|
||||
manager = IClashManager.Stub.asInterface(service) ?: return stopSelf()
|
||||
|
||||
manager?.registerLogListener(LOG_LISTENER_KEY, object : IStreamCallback.Stub() {
|
||||
override fun complete() {}
|
||||
override fun completeExceptionally(reason: String?) {}
|
||||
override fun send(data: ParcelableContainer?) {
|
||||
val logEvent = (data?.data as LogEvent?) ?: return
|
||||
data ?: return
|
||||
data.data ?: return
|
||||
|
||||
if (!logChannel.offer(logEvent))
|
||||
Log.w("Drop log $logEvent")
|
||||
logChannel.offer(data.data as LogEvent)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -100,11 +99,15 @@ class LogcatService : Service(), CoroutineScope by MainScope(), IInterface {
|
||||
|
||||
cancel()
|
||||
|
||||
connection.onServiceDisconnected(null)
|
||||
|
||||
unbindService(connection)
|
||||
|
||||
stopForeground(true)
|
||||
|
||||
super.onDestroy()
|
||||
|
||||
isServiceRunning = false
|
||||
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? {
|
||||
@@ -119,6 +122,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())
|
||||
@@ -171,8 +182,7 @@ class LogcatService : Service(), CoroutineScope by MainScope(), IInterface {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (e: Exception) {
|
||||
} catch (e: Exception) {
|
||||
return@launch
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -40,7 +40,7 @@ class LogsActivity : BaseActivity() {
|
||||
|
||||
setSupportActionBar(toolbar)
|
||||
|
||||
if ( LogcatService.isServiceRunning ) {
|
||||
if (LogcatService.isServiceRunning) {
|
||||
startActivity(LogViewerActivity::class.intent)
|
||||
finish()
|
||||
return
|
||||
@@ -67,12 +67,16 @@ class LogsActivity : BaseActivity() {
|
||||
showClearAllDialog()
|
||||
}
|
||||
|
||||
val adapter = LogFileAdapter(this@LogsActivity,
|
||||
val adapter = LogFileAdapter(
|
||||
this@LogsActivity,
|
||||
onItemClicked = {
|
||||
startActivity(LogViewerActivity::class.intent
|
||||
.setData(Uri.fromFile(logsDir.resolve(it.fileName))))
|
||||
startActivity(
|
||||
LogViewerActivity::class.intent
|
||||
.setData(Uri.fromFile(logsDir.resolve(it.fileName)))
|
||||
)
|
||||
},
|
||||
onMenuClicked = this::showMenu)
|
||||
onMenuClicked = this::showMenu
|
||||
)
|
||||
val layoutManager = LinearLayoutManager(this@LogsActivity)
|
||||
|
||||
mainList.layoutManager = layoutManager
|
||||
@@ -82,7 +86,7 @@ class LogsActivity : BaseActivity() {
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
|
||||
if ( LogcatService.isServiceRunning )
|
||||
if (LogcatService.isServiceRunning)
|
||||
return
|
||||
|
||||
refreshList()
|
||||
@@ -92,8 +96,8 @@ class LogsActivity : BaseActivity() {
|
||||
get() = getText(R.string.logs)
|
||||
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||
if ( requestCode == REQUEST_CODE ) {
|
||||
if ( resultCode == Activity.RESULT_OK ) {
|
||||
if (requestCode == REQUEST_CODE) {
|
||||
if (resultCode == Activity.RESULT_OK) {
|
||||
val url = data?.data ?: return
|
||||
val file = lastWriteFile ?: return
|
||||
|
||||
@@ -135,8 +139,11 @@ class LogsActivity : BaseActivity() {
|
||||
val old = adapter.fileList
|
||||
|
||||
val result = withContext(Dispatchers.Default) {
|
||||
DiffUtil.calculateDiff(object: DiffUtil.Callback() {
|
||||
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
|
||||
DiffUtil.calculateDiff(object : DiffUtil.Callback() {
|
||||
override fun areItemsTheSame(
|
||||
oldItemPosition: Int,
|
||||
newItemPosition: Int
|
||||
): Boolean {
|
||||
return old[oldItemPosition].fileName == files[newItemPosition].fileName
|
||||
}
|
||||
|
||||
@@ -188,7 +195,8 @@ class LogsActivity : BaseActivity() {
|
||||
menu.build {
|
||||
option(
|
||||
icon = getDrawable(R.drawable.ic_save),
|
||||
title = getString(R.string.export)) {
|
||||
title = getString(R.string.export)
|
||||
) {
|
||||
onClick {
|
||||
export(logFile)
|
||||
|
||||
@@ -197,7 +205,8 @@ class LogsActivity : BaseActivity() {
|
||||
}
|
||||
option(
|
||||
icon = getDrawable(R.drawable.ic_delete_colorful),
|
||||
title = getString(R.string.delete)) {
|
||||
title = getString(R.string.delete)
|
||||
) {
|
||||
textColor = errorColor
|
||||
|
||||
onClick {
|
||||
@@ -224,7 +233,7 @@ class LogsActivity : BaseActivity() {
|
||||
}
|
||||
|
||||
private fun export(file: LogFile) {
|
||||
if ( lastWriteFile != null )
|
||||
if (lastWriteFile != null)
|
||||
return
|
||||
|
||||
val d = Date(file.date)
|
||||
|
||||
@@ -12,11 +12,9 @@ import com.github.kr328.clash.core.model.General
|
||||
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 +32,7 @@ class MainActivity : BaseActivity() {
|
||||
|
||||
status.setOnClickListener {
|
||||
if (clashRunning) {
|
||||
stopService(ClashService::class.intent)
|
||||
stopClashService()
|
||||
} else {
|
||||
val vpnRequest = startClashService()
|
||||
if (vpnRequest != null)
|
||||
@@ -84,7 +82,7 @@ class MainActivity : BaseActivity() {
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||
if (requestCode == REQUEST_CODE) {
|
||||
if (resultCode == Activity.RESULT_OK)
|
||||
startForegroundServiceCompat(ClashService::class.intent)
|
||||
startClashService()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -102,7 +100,7 @@ class MainActivity : BaseActivity() {
|
||||
makeSnackbarException(getString(R.string.clash_start_failure), reason)
|
||||
}
|
||||
|
||||
override suspend fun onClashProfileLoaded(profile: ClashProfileEntity) {
|
||||
override suspend fun onClashProfileLoaded() {
|
||||
updateClashStatus()
|
||||
}
|
||||
|
||||
|
||||
@@ -3,11 +3,15 @@ package com.github.kr328.clash
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import com.github.kr328.clash.core.Global
|
||||
import com.github.kr328.clash.dump.LogcatDumper
|
||||
import com.github.kr328.clash.remote.Broadcasts
|
||||
import com.github.kr328.clash.remote.Remote
|
||||
import com.microsoft.appcenter.AppCenter
|
||||
import com.microsoft.appcenter.analytics.Analytics
|
||||
import com.microsoft.appcenter.crashes.AbstractCrashesListener
|
||||
import com.microsoft.appcenter.crashes.Crashes
|
||||
import com.microsoft.appcenter.crashes.ingestion.models.ErrorAttachmentLog
|
||||
import com.microsoft.appcenter.crashes.model.ErrorReport
|
||||
|
||||
@Suppress("unused")
|
||||
class MainApplication : Application() {
|
||||
@@ -27,6 +31,21 @@ class MainApplication : Application() {
|
||||
BuildConfig.APP_CENTER_KEY,
|
||||
Analytics::class.java, Crashes::class.java
|
||||
)
|
||||
|
||||
Crashes.setListener(object : AbstractCrashesListener() {
|
||||
override fun getErrorAttachments(report: ErrorReport?): MutableIterable<ErrorAttachmentLog> {
|
||||
report ?: return mutableListOf()
|
||||
|
||||
if (!report.stackTrace.contains("DeadObjectException"))
|
||||
return mutableListOf()
|
||||
|
||||
val logcat = LogcatDumper.dump().joinToString(separator = "\n")
|
||||
|
||||
return mutableListOf(
|
||||
ErrorAttachmentLog.attachmentWithText(logcat, "logcat.txt")
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Remote.init(this)
|
||||
|
||||
@@ -9,13 +9,15 @@ import com.github.kr328.clash.service.util.componentName
|
||||
import com.github.kr328.clash.service.util.startForegroundServiceCompat
|
||||
import com.github.kr328.clash.utils.startClashService
|
||||
|
||||
class OnBootReceiver: BroadcastReceiver() {
|
||||
class OnBootReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
if ( intent?.action != Intent.ACTION_BOOT_COMPLETED || context == null )
|
||||
if (intent?.action != Intent.ACTION_BOOT_COMPLETED || context == null)
|
||||
return
|
||||
|
||||
context.startClashService()
|
||||
context.startForegroundServiceCompat(Intent(Intents.INTENT_ACTION_PROFILE_SETUP)
|
||||
.setComponent(ProfileBackgroundService::class.componentName))
|
||||
context.startForegroundServiceCompat(
|
||||
Intent(Intents.INTENT_ACTION_PROFILE_SETUP)
|
||||
.setComponent(ProfileBackgroundService::class.componentName)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -141,7 +141,7 @@ class ProfileEditActivity : BaseActivity() {
|
||||
val name = requireElement<TextInput>(KEY_NAME).content.toString()
|
||||
val url = Uri.parse(requireElement<TextInput>(KEY_URL).content.toString())
|
||||
val interval = requireElement<TextInput>(KEY_AUTO_UPDATE).content.toString()
|
||||
.toLongOrNull()?: 0
|
||||
.toLongOrNull() ?: 0
|
||||
|
||||
if (name.isBlank()) {
|
||||
Snackbar.make(rootView, R.string.empty_name, Snackbar.LENGTH_LONG).show()
|
||||
|
||||
@@ -148,11 +148,11 @@ class ProfilesActivity : BaseActivity(), ProfileAdapter.Callback, ProfilesMenu.C
|
||||
|
||||
val editor = ProfileEditActivity::class.intent
|
||||
.putExtra("id", if (duplicate) -1L else entity.id)
|
||||
.putExtra("type", type)
|
||||
.putExtra("type", if (duplicate) Constants.URL_PROVIDER_TYPE_FILE else type)
|
||||
.putExtra("intent", intent)
|
||||
.putExtra("name", name)
|
||||
.putExtra("url", uri)
|
||||
.putExtra("interval", interval)
|
||||
.putExtra("interval", if (duplicate) "0" else interval)
|
||||
|
||||
startActivity(editor)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -89,8 +59,10 @@ class ProxiesActivity : BaseActivity(), ScrollBinding.Callback {
|
||||
|
||||
override fun onStop() {
|
||||
uiSettings.commit {
|
||||
put(UiSettings.PROXY_LAST_SELECT_GROUP,
|
||||
(chipList.adapter!! as ProxyChipAdapter).selected)
|
||||
put(
|
||||
UiSettings.PROXY_LAST_SELECT_GROUP,
|
||||
(chipList.adapter!! as ProxyChipAdapter).selected
|
||||
)
|
||||
}
|
||||
|
||||
super.onStop()
|
||||
@@ -117,6 +89,8 @@ class ProxiesActivity : BaseActivity(), ScrollBinding.Callback {
|
||||
}
|
||||
|
||||
launch {
|
||||
var scrollTop = false
|
||||
|
||||
when (item.itemId) {
|
||||
R.id.modeDirect -> {
|
||||
withClash {
|
||||
@@ -126,6 +100,8 @@ class ProxiesActivity : BaseActivity(), ScrollBinding.Callback {
|
||||
R.id.modeGlobal -> {
|
||||
withClash {
|
||||
setProxyMode(General.Mode.GLOBAL)
|
||||
|
||||
scrollTop = true
|
||||
}
|
||||
}
|
||||
R.id.modeRule -> {
|
||||
@@ -179,7 +155,7 @@ class ProxiesActivity : BaseActivity(), ScrollBinding.Callback {
|
||||
|
||||
item.isChecked = true
|
||||
|
||||
refreshList()
|
||||
refreshList(scrollTop)
|
||||
}
|
||||
|
||||
return true
|
||||
@@ -188,7 +164,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 +206,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 +246,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 +292,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)
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import android.os.Bundle
|
||||
import com.github.kr328.clash.service.util.intent
|
||||
import kotlinx.android.synthetic.main.activity_settings.*
|
||||
|
||||
class SettingsActivity: BaseActivity() {
|
||||
class SettingsActivity : BaseActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_settings)
|
||||
@@ -13,7 +13,8 @@ class SettingsActivity: BaseActivity() {
|
||||
commonUi.build {
|
||||
option(
|
||||
icon = getDrawable(R.drawable.ic_settings_applications),
|
||||
title = getString(R.string.behavior)) {
|
||||
title = getString(R.string.behavior)
|
||||
) {
|
||||
paddingHeight = true
|
||||
|
||||
onClick {
|
||||
@@ -22,7 +23,8 @@ class SettingsActivity: BaseActivity() {
|
||||
}
|
||||
option(
|
||||
icon = getDrawable(R.drawable.ic_network),
|
||||
title = getString(R.string.network)) {
|
||||
title = getString(R.string.network)
|
||||
) {
|
||||
paddingHeight = true
|
||||
|
||||
onClick {
|
||||
@@ -31,7 +33,8 @@ class SettingsActivity: BaseActivity() {
|
||||
}
|
||||
option(
|
||||
icon = getDrawable(R.drawable.ic_interface),
|
||||
title = getString(R.string.interface_)) {
|
||||
title = getString(R.string.interface_)
|
||||
) {
|
||||
paddingHeight = true
|
||||
|
||||
onClick {
|
||||
|
||||
@@ -3,7 +3,7 @@ package com.github.kr328.clash
|
||||
import android.os.Bundle
|
||||
import com.github.kr328.clash.settings.BehaviorFragment
|
||||
|
||||
class SettingsBehaviorActivity: BaseActivity() {
|
||||
class SettingsBehaviorActivity : BaseActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ package com.github.kr328.clash
|
||||
import android.os.Bundle
|
||||
import com.github.kr328.clash.settings.InterfaceFragment
|
||||
|
||||
class SettingsInterfaceActivity: BaseActivity() {
|
||||
class SettingsInterfaceActivity : BaseActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ class SettingsNetworkActivity : BaseActivity() {
|
||||
.replace(R.id.fragment, NetworkFragment())
|
||||
.commit()
|
||||
|
||||
if ( clashRunning )
|
||||
if (clashRunning)
|
||||
Snackbar.make(rootView, R.string.options_unavailable, Snackbar.LENGTH_INDEFINITE).show()
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import androidx.core.content.getSystemService
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import kotlinx.android.synthetic.main.activity_support.*
|
||||
|
||||
class SupportActivity: BaseActivity() {
|
||||
class SupportActivity : BaseActivity() {
|
||||
override val activityLabel: CharSequence?
|
||||
get() = getText(R.string.support)
|
||||
|
||||
@@ -24,10 +24,13 @@ class SupportActivity: BaseActivity() {
|
||||
|
||||
option(
|
||||
title = getString(R.string.clash),
|
||||
summary = getString(R.string.clash_url)) {
|
||||
summary = getString(R.string.clash_url)
|
||||
) {
|
||||
onClick {
|
||||
startActivity(Intent(Intent.ACTION_VIEW)
|
||||
.setData(Uri.parse(getString(R.string.clash_url))))
|
||||
startActivity(
|
||||
Intent(Intent.ACTION_VIEW)
|
||||
.setData(Uri.parse(getString(R.string.clash_url)))
|
||||
)
|
||||
}
|
||||
}
|
||||
option(
|
||||
@@ -35,8 +38,10 @@ class SupportActivity: BaseActivity() {
|
||||
summary = getString(R.string.clash_for_android_url)
|
||||
) {
|
||||
onClick {
|
||||
startActivity(Intent(Intent.ACTION_VIEW)
|
||||
.setData(Uri.parse(getString(R.string.clash_for_android_url))))
|
||||
startActivity(
|
||||
Intent(Intent.ACTION_VIEW)
|
||||
.setData(Uri.parse(getString(R.string.clash_for_android_url)))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,20 +64,25 @@ class SupportActivity: BaseActivity() {
|
||||
summary = getString(R.string.github_issues_url)
|
||||
) {
|
||||
onClick {
|
||||
startActivity(Intent(Intent.ACTION_VIEW)
|
||||
.setData(Uri.parse(getString(R.string.github_issues_url))))
|
||||
startActivity(
|
||||
Intent(Intent.ACTION_VIEW)
|
||||
.setData(Uri.parse(getString(R.string.github_issues_url)))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if ( resources.configuration.locales.get(0)
|
||||
.language.equals("zh", true) ) {
|
||||
if (resources.configuration.locales.get(0)
|
||||
.language.equals("zh", true)
|
||||
) {
|
||||
option(
|
||||
title = getString(R.string.telegram_channel),
|
||||
summary = getString(R.string.telegram_channel_url)
|
||||
) {
|
||||
onClick {
|
||||
startActivity(Intent(Intent.ACTION_VIEW)
|
||||
.setData(Uri.parse(getString(R.string.telegram_channel_url))))
|
||||
startActivity(
|
||||
Intent(Intent.ACTION_VIEW)
|
||||
.setData(Uri.parse(getString(R.string.telegram_channel_url)))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -38,22 +37,24 @@ class TileService : TileService() {
|
||||
if (qsTile == null)
|
||||
return
|
||||
|
||||
qsTile.state = if ( clashRunning )
|
||||
qsTile.state = if (clashRunning)
|
||||
Tile.STATE_ACTIVE
|
||||
else
|
||||
Tile.STATE_INACTIVE
|
||||
|
||||
qsTile.label = if ( currentProfile.isEmpty() )
|
||||
qsTile.label = if (currentProfile.isEmpty())
|
||||
getText(R.string.launch_name)
|
||||
else
|
||||
currentProfile
|
||||
|
||||
qsTile.icon = Icon.createWithResource(this, R.drawable.ic_notification)
|
||||
|
||||
qsTile.updateTile()
|
||||
}
|
||||
|
||||
private val clashStatusReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
when ( intent?.action ) {
|
||||
when (intent?.action) {
|
||||
Intents.INTENT_ACTION_CLASH_STARTED -> {
|
||||
clashRunning = true
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -34,14 +34,13 @@ class LiveLogAdapter(private val context: Context) : RecyclerView.Adapter<LogAda
|
||||
}
|
||||
|
||||
fun insertItems(i: List<LogEvent>) {
|
||||
val items = if ( i.size > MAX_LOG_ITEMS ) {
|
||||
val items = if (i.size > MAX_LOG_ITEMS) {
|
||||
i.subList(i.size - MAX_LOG_ITEMS, i.size)
|
||||
}
|
||||
else i
|
||||
} else i
|
||||
|
||||
val predictSize = items.size + circularArray.size()
|
||||
|
||||
if ( predictSize > MAX_LOG_ITEMS ) {
|
||||
if (predictSize > MAX_LOG_ITEMS) {
|
||||
val removeSize = predictSize - MAX_LOG_ITEMS
|
||||
notifyItemRangeRemoved(MAX_LOG_ITEMS - removeSize, removeSize)
|
||||
circularArray.removeFromEnd(removeSize)
|
||||
|
||||
@@ -15,11 +15,15 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlin.streams.toList
|
||||
|
||||
class PackagesAdapter(private val context: Context,
|
||||
private val apps: List<AppInfo>) :
|
||||
class PackagesAdapter(
|
||||
private val context: Context,
|
||||
private val apps: List<AppInfo>
|
||||
) :
|
||||
RecyclerView.Adapter<PackagesAdapter.Holder>() {
|
||||
data class AppInfo(val packageName: String, val label: String, val icon: Drawable,
|
||||
val installTime: Long, val updateTime: Long, val isSystem: Boolean)
|
||||
data class AppInfo(
|
||||
val packageName: String, val label: String, val icon: Drawable,
|
||||
val installTime: Long, val updateTime: Long, val isSystem: Boolean
|
||||
)
|
||||
|
||||
enum class Sort {
|
||||
NAME, PACKAGE, INSTALL_TIME, UPDATE_TIME
|
||||
@@ -49,7 +53,7 @@ class PackagesAdapter(private val context: Context,
|
||||
val sA = selectedPackages.contains(a.packageName)
|
||||
val sB = selectedPackages.contains(b.packageName)
|
||||
|
||||
if ( sA != sB ) {
|
||||
if (sA != sB) {
|
||||
when {
|
||||
sA -> return@sorted -1
|
||||
sB -> return@sorted 1
|
||||
@@ -115,10 +119,9 @@ class PackagesAdapter(private val context: Context,
|
||||
holder.packageName.text = current.packageName
|
||||
holder.checkbox.isChecked = selectedPackages.contains(current.packageName)
|
||||
holder.root.setOnClickListener {
|
||||
if ( selectedPackages.contains(current.packageName) ) {
|
||||
if (selectedPackages.contains(current.packageName)) {
|
||||
selectedPackages.remove(current.packageName)
|
||||
}
|
||||
else {
|
||||
} else {
|
||||
selectedPackages.add(current.packageName)
|
||||
}
|
||||
|
||||
|
||||
@@ -132,6 +132,6 @@ class ProfileAdapter(private val context: Context, private val callback: Callbac
|
||||
}
|
||||
|
||||
private fun offsetDate(date: Long): CharSequence {
|
||||
return IntervalUtils.intervalString(context,System.currentTimeMillis() - date)
|
||||
return IntervalUtils.intervalString(context, System.currentTimeMillis() - date)
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
@@ -142,7 +141,7 @@ class ProxyAdapter(
|
||||
groupCache[it.name] = index
|
||||
is ProxyRenderInfo -> {
|
||||
if (it.info.active)
|
||||
activeCache[it.name] = index
|
||||
activeCache[it.group] = index
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -154,9 +153,11 @@ class ProxyAdapter(
|
||||
groupPosition = groupCache
|
||||
activeList = activeCache
|
||||
result.dispatchUpdatesTo(this@ProxyAdapter)
|
||||
}
|
||||
|
||||
rootMutex.unlock()
|
||||
groupCache.forEach { (_, u) ->
|
||||
notifyItemChanged(u)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getGroupPosition(name: String): Int {
|
||||
@@ -193,9 +194,10 @@ class ProxyAdapter(
|
||||
is ProxyGroupHeader -> {
|
||||
val current = renderList[position] as ProxyGroupRenderInfo
|
||||
|
||||
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 +216,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()
|
||||
@@ -223,8 +225,6 @@ class ProxyAdapter(
|
||||
holder.delay.text = if (current.info.selectable) "" else "N/A"
|
||||
|
||||
if (current.info.active) {
|
||||
activeList[current.group] = position
|
||||
|
||||
holder.prefix.setTextColor(Color.WHITE)
|
||||
holder.content.setTextColor(Color.WHITE)
|
||||
holder.delay.setTextColor(Color.WHITE)
|
||||
@@ -239,14 +239,22 @@ 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))
|
||||
|
||||
activeList[current.group] = position
|
||||
|
||||
notifyItemChanged(oldPosition)
|
||||
notifyItemChanged(position)
|
||||
notifyItemChanged(groupPosition)
|
||||
|
||||
onSelect(current.group, current.name)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.github.kr328.clash.dump
|
||||
|
||||
object LogcatDumper {
|
||||
fun dump(): List<String> {
|
||||
return try {
|
||||
val process =
|
||||
Runtime.getRuntime().exec(arrayOf("logcat", "-d", "-s", "-v", "raw", "Go"))
|
||||
|
||||
val result = process.inputStream.bufferedReader().useLines {
|
||||
var list = mutableListOf<String>()
|
||||
var capture = false
|
||||
|
||||
it.forEach { line ->
|
||||
if (line.startsWith("panic")) {
|
||||
capture = true
|
||||
|
||||
list = mutableListOf()
|
||||
}
|
||||
|
||||
if (capture)
|
||||
list.add(line)
|
||||
}
|
||||
|
||||
list
|
||||
}
|
||||
|
||||
process.waitFor()
|
||||
|
||||
result
|
||||
} catch (e: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -0,0 +1,101 @@
|
||||
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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ package com.github.kr328.clash.preference
|
||||
import android.content.Context
|
||||
import com.github.kr328.clash.service.settings.BaseSettings
|
||||
|
||||
class UiSettings(context: Context):
|
||||
class UiSettings(context: Context) :
|
||||
BaseSettings(context.getSharedPreferences(FILE_NAME, Context.MODE_PRIVATE)) {
|
||||
companion object {
|
||||
private const val FILE_NAME = "ui"
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import kotlinx.coroutines.withContext
|
||||
|
||||
class ClashClient(val service: IClashManager) {
|
||||
suspend fun setSelectProxy(name: String, proxy: String): Boolean = withContext(Dispatchers.IO) {
|
||||
return@withContext service.setSelectProxy(name, proxy)
|
||||
service.setSelectProxy(name, proxy)
|
||||
}
|
||||
|
||||
suspend fun startHealthCheck(group: String) = withContext(Dispatchers.IO) {
|
||||
@@ -39,10 +39,9 @@ class ClashClient(val service: IClashManager) {
|
||||
service.queryGeneral()
|
||||
}
|
||||
|
||||
suspend fun queryBandwidth(): Long =
|
||||
withContext(Dispatchers.IO) {
|
||||
service.queryBandwidth()
|
||||
}
|
||||
suspend fun queryBandwidth(): Long = withContext(Dispatchers.IO) {
|
||||
service.queryBandwidth()
|
||||
}
|
||||
|
||||
suspend fun setProxyMode(mode: General.Mode) = withContext(Dispatchers.IO) {
|
||||
service.setProxyMode(mode.toString())
|
||||
|
||||
@@ -7,17 +7,21 @@ import android.content.Intent
|
||||
import android.content.ServiceConnection
|
||||
import android.os.Handler
|
||||
import android.os.IBinder
|
||||
import android.os.RemoteException
|
||||
import androidx.core.content.edit
|
||||
import androidx.lifecycle.DefaultLifecycleObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.ProcessLifecycleOwner
|
||||
import com.github.kr328.clash.ApkBrokenActivity
|
||||
import com.github.kr328.clash.Constants
|
||||
import com.github.kr328.clash.dump.LogcatDumper
|
||||
import com.github.kr328.clash.service.ClashManagerService
|
||||
import com.github.kr328.clash.service.IClashManager
|
||||
import com.github.kr328.clash.service.IProfileService
|
||||
import com.github.kr328.clash.service.ProfileService
|
||||
import com.github.kr328.clash.service.util.intent
|
||||
import com.microsoft.appcenter.crashes.Crashes
|
||||
import com.microsoft.appcenter.crashes.ingestion.models.ErrorAttachmentLog
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import java.util.zip.ZipFile
|
||||
@@ -43,9 +47,7 @@ object Remote {
|
||||
if (service != null)
|
||||
instance = ClashClient(IClashManager.Stub.asInterface(service))
|
||||
|
||||
service?.linkToDeath({
|
||||
onServiceDisconnected(null)
|
||||
}, 0)
|
||||
service?.linkToDeath({ onServiceDisconnected(null) }, 0)
|
||||
|
||||
sender = GlobalScope.launch {
|
||||
while (isActive) {
|
||||
@@ -70,9 +72,7 @@ object Remote {
|
||||
if (service != null)
|
||||
instance = ProfileClient(IProfileService.Stub.asInterface(service))
|
||||
|
||||
service?.linkToDeath({
|
||||
onServiceDisconnected(null)
|
||||
}, 0)
|
||||
service?.linkToDeath({ onServiceDisconnected(null) }, 0)
|
||||
|
||||
sender = GlobalScope.launch {
|
||||
while (isActive) {
|
||||
@@ -92,8 +92,10 @@ object Remote {
|
||||
|
||||
GlobalScope.launch {
|
||||
if (!verifyApk(application)) {
|
||||
application.startActivity(ApkBrokenActivity::class.intent
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
|
||||
application.startActivity(
|
||||
ApkBrokenActivity::class.intent
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
)
|
||||
return@launch
|
||||
}
|
||||
|
||||
@@ -133,30 +135,28 @@ object Remote {
|
||||
})
|
||||
}
|
||||
|
||||
private suspend fun verifyApk(application: Application): Boolean {
|
||||
return withContext(Dispatchers.IO) {
|
||||
val sp = application.getSharedPreferences(
|
||||
Constants.PREFERENCE_NAME_APP,
|
||||
Context.MODE_PRIVATE
|
||||
)
|
||||
val pkg = application.packageManager.getPackageInfo(application.packageName, 0)
|
||||
private suspend fun verifyApk(application: Application) = withContext(Dispatchers.IO) {
|
||||
val sp = application.getSharedPreferences(
|
||||
Constants.PREFERENCE_NAME_APP,
|
||||
Context.MODE_PRIVATE
|
||||
)
|
||||
val pkg = application.packageManager.getPackageInfo(application.packageName, 0)
|
||||
|
||||
if (sp.getLong(Constants.PREFERENCE_KEY_LAST_INSTALL, 0) == pkg.lastUpdateTime)
|
||||
return@withContext true
|
||||
if (sp.getLong(Constants.PREFERENCE_KEY_LAST_INSTALL, 0) == pkg.lastUpdateTime)
|
||||
return@withContext true
|
||||
|
||||
val info = application.applicationInfo
|
||||
val sources =
|
||||
info.splitSourceDirs ?: arrayOf(info.sourceDir) ?: return@withContext false
|
||||
val info = application.applicationInfo
|
||||
val sources =
|
||||
info.splitSourceDirs ?: arrayOf(info.sourceDir) ?: return@withContext false
|
||||
|
||||
for (apk in sources) {
|
||||
if (ZipFile(apk).entries().asSequence().any { it.name.endsWith("libgojni.so") }) {
|
||||
sp.edit {
|
||||
putLong(Constants.PREFERENCE_KEY_LAST_INSTALL, pkg.lastUpdateTime)
|
||||
}
|
||||
return@withContext true
|
||||
for (apk in sources) {
|
||||
if (ZipFile(apk).entries().asSequence().any { it.name.endsWith("libgojni.so") }) {
|
||||
sp.edit {
|
||||
putLong(Constants.PREFERENCE_KEY_LAST_INSTALL, pkg.lastUpdateTime)
|
||||
}
|
||||
return@withContext true
|
||||
}
|
||||
return@withContext false
|
||||
}
|
||||
return@withContext false
|
||||
}
|
||||
}
|
||||
@@ -1,24 +1,55 @@
|
||||
package com.github.kr328.clash.remote
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import com.github.kr328.clash.ApkBrokenActivity
|
||||
import com.github.kr328.clash.service.Constants
|
||||
import com.github.kr328.clash.service.ServiceStatusProvider
|
||||
import com.github.kr328.clash.service.util.intent
|
||||
|
||||
object RemoteUtils {
|
||||
fun detectClashRunning(context: Context): Boolean {
|
||||
val authority = Uri.Builder()
|
||||
.scheme("content")
|
||||
.authority("${context.packageName}${Constants.STATUS_PROVIDER_SUFFIX}")
|
||||
.build()
|
||||
try {
|
||||
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
|
||||
)
|
||||
val pong = context.contentResolver.call(
|
||||
authority,
|
||||
ServiceStatusProvider.METHOD_PING_CLASH_SERVICE,
|
||||
null,
|
||||
null
|
||||
)
|
||||
|
||||
return pong != null
|
||||
return pong != null
|
||||
} catch (e: IllegalArgumentException) {
|
||||
context.startActivity(ApkBrokenActivity::class.intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
fun getCurrentClashProfileName(context: Context): String? {
|
||||
try {
|
||||
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")
|
||||
} catch (e: IllegalArgumentException) {
|
||||
context.startActivity(ApkBrokenActivity::class.intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
|
||||
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import com.github.kr328.clash.preference.UiSettings
|
||||
import com.github.kr328.clash.service.settings.ServiceSettings
|
||||
import moe.shizuku.preference.PreferenceFragment
|
||||
|
||||
abstract class BaseSettingFragment: PreferenceFragment() {
|
||||
abstract class BaseSettingFragment : PreferenceFragment() {
|
||||
abstract fun onCreateDataStore(): SettingsDataStore
|
||||
abstract val xmlResourceId: Int
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import com.github.kr328.clash.remote.Broadcasts
|
||||
import com.github.kr328.clash.service.settings.ServiceSettings
|
||||
import com.github.kr328.clash.service.util.componentName
|
||||
|
||||
class BehaviorFragment: BaseSettingFragment() {
|
||||
class BehaviorFragment : BaseSettingFragment() {
|
||||
companion object {
|
||||
private const val KEY_START_ON_BOOT = "start_on_boot"
|
||||
private const val KEY_SHOW_TRAFFIC = "show_traffic"
|
||||
@@ -30,11 +30,11 @@ class BehaviorFragment: BaseSettingFragment() {
|
||||
override val xmlResourceId: Int
|
||||
get() = R.xml.settings_behavior
|
||||
|
||||
private inner class StartOnBootSource: SettingsDataStore.Source {
|
||||
private inner class StartOnBootSource : SettingsDataStore.Source {
|
||||
override fun set(value: Any?) {
|
||||
val v = value as Boolean? ?: return
|
||||
|
||||
val status = if ( v )
|
||||
val status = if (v)
|
||||
PackageManager.COMPONENT_ENABLED_STATE_ENABLED
|
||||
else
|
||||
PackageManager.COMPONENT_ENABLED_STATE_DISABLED
|
||||
@@ -42,7 +42,8 @@ class BehaviorFragment: BaseSettingFragment() {
|
||||
requireActivity().packageManager.setComponentEnabledSetting(
|
||||
OnBootReceiver::class.componentName,
|
||||
status,
|
||||
PackageManager.DONT_KILL_APP)
|
||||
PackageManager.DONT_KILL_APP
|
||||
)
|
||||
}
|
||||
|
||||
override fun get(): Any? {
|
||||
|
||||
@@ -4,7 +4,7 @@ import com.github.kr328.clash.R
|
||||
import com.github.kr328.clash.preference.UiSettings
|
||||
import com.github.kr328.clash.service.settings.ServiceSettings
|
||||
|
||||
class InterfaceFragment: BaseSettingFragment() {
|
||||
class InterfaceFragment : BaseSettingFragment() {
|
||||
companion object {
|
||||
private const val KEY_DARK_MODE = "dark_mode"
|
||||
private const val KEY_LANGUAGE = "language"
|
||||
|
||||
@@ -8,7 +8,7 @@ import com.github.kr328.clash.remote.Broadcasts
|
||||
import com.github.kr328.clash.service.settings.ServiceSettings
|
||||
import com.github.kr328.clash.service.util.intent
|
||||
|
||||
class NetworkFragment: BaseSettingFragment() {
|
||||
class NetworkFragment : BaseSettingFragment() {
|
||||
companion object {
|
||||
private const val KEY_ENABLE_VPN_SERVICE = "enable_vpn_service"
|
||||
private const val KEY_IPV6 = "ipv6"
|
||||
|
||||
@@ -3,7 +3,7 @@ package com.github.kr328.clash.settings
|
||||
import com.github.kr328.clash.service.settings.BaseSettings
|
||||
import moe.shizuku.preference.PreferenceDataStore
|
||||
|
||||
class SettingsDataStore: PreferenceDataStore() {
|
||||
class SettingsDataStore : PreferenceDataStore() {
|
||||
interface Source {
|
||||
fun set(value: Any?)
|
||||
fun get(): Any?
|
||||
@@ -20,8 +20,8 @@ class SettingsDataStore: PreferenceDataStore() {
|
||||
this.applyListener = block
|
||||
}
|
||||
|
||||
inline fun <reified T>BaseSettings.Entry<T>.asSource(settings: BaseSettings): Source {
|
||||
return object: Source {
|
||||
inline fun <reified T> BaseSettings.Entry<T>.asSource(settings: BaseSettings): Source {
|
||||
return object : Source {
|
||||
override fun set(value: Any?) {
|
||||
val v = value ?: throw NullPointerException()
|
||||
|
||||
|
||||
@@ -8,7 +8,12 @@ const val DATE_DATE_ONLY = "yyyy-MM-dd"
|
||||
const val DATE_TIME_ONLY = "HH:mm:ss"
|
||||
const val DATE_ALL = "$DATE_DATE_ONLY $DATE_TIME_ONLY"
|
||||
|
||||
fun Date.format(context: Context, includeDate: Boolean = true, includeTime: Boolean = true, custom: String = ""): String {
|
||||
fun Date.format(
|
||||
context: Context,
|
||||
includeDate: Boolean = true,
|
||||
includeTime: Boolean = true,
|
||||
custom: String = ""
|
||||
): String {
|
||||
val locale = context.resources.configuration.locales[0]
|
||||
|
||||
return when {
|
||||
|
||||
@@ -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,12 +53,13 @@ object PrefixMerger {
|
||||
}
|
||||
|
||||
group.forEach {
|
||||
val prefix = it.first.subList(0, diffIndex)
|
||||
val content = it.first.subList(diffIndex, it.first.size)
|
||||
|
||||
result.add(
|
||||
Result(
|
||||
it.first.substring(0, diffIndex)
|
||||
.replace(REGEX_PREFIX_TRIM, ""),
|
||||
it.first.substring(diffIndex),
|
||||
it.second
|
||||
prefix.asCodePointString().replace(REGEX_PREFIX_TRIM, ""),
|
||||
content.asCodePointString(), it.second
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -6,20 +6,27 @@ 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? {
|
||||
val startTun = UiSettings(this).get(UiSettings.ENABLE_VPN)
|
||||
|
||||
if ( startTun ) {
|
||||
if (startTun) {
|
||||
val vpnRequest = VpnService.prepare(this)
|
||||
if ( vpnRequest != null )
|
||||
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))
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 5.0 KiB After Width: | Height: | Size: 5.0 KiB |
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 8.0 KiB After Width: | Height: | Size: 7.8 KiB |
|
Before Width: | Height: | Size: 5.1 KiB After Width: | Height: | Size: 5.0 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
@@ -109,7 +109,7 @@
|
||||
|
||||
<string name="boot">Boot</string>
|
||||
<string name="start_on_boot">Start on Boot</string>
|
||||
<string name="start_clash_on_system_boot">Start slash on system boot</string>
|
||||
<string name="start_clash_on_system_boot">Start Clash on system boot</string>
|
||||
|
||||
<string name="notification">Notification</string>
|
||||
<string name="show_traffic">Show Traffic</string>
|
||||
@@ -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>
|
||||
|
||||
@@ -8,8 +8,8 @@ buildscript {
|
||||
gMinSdkVersion = 24
|
||||
gTargetSdkVersion = 29
|
||||
|
||||
gVersionCode = 10100
|
||||
gVersionName = "1.1.0"
|
||||
gVersionCode = 10107
|
||||
gVersionName = "1.1.7"
|
||||
|
||||
gKotlinVersion = '1.3.61'
|
||||
gKotlinCoroutineVersion = '1.3.3'
|
||||
@@ -20,7 +20,7 @@ buildscript {
|
||||
gLifecycleVersion = "2.2.0"
|
||||
gRecyclerviewVersion = "1.1.0"
|
||||
gAppCompatVersion = "1.1.0"
|
||||
gMaterialDesignVersion = "1.2.0-alpha04"
|
||||
gMaterialDesignVersion = '1.2.0-alpha05'
|
||||
gShizukuPreferenceVersion = "4.2.0"
|
||||
gMultiprocessPreferenceVersion = "1.0.0"
|
||||
}
|
||||
@@ -29,7 +29,7 @@ buildscript {
|
||||
jcenter()
|
||||
}
|
||||
dependencies {
|
||||
classpath 'com.android.tools.build:gradle:3.5.3'
|
||||
classpath 'com.android.tools.build:gradle:3.6.0'
|
||||
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$gKotlinVersion"
|
||||
classpath "org.jetbrains.kotlin:kotlin-serialization:$gKotlinVersion"
|
||||
// NOTE: Do not place your application dependencies here; they belong
|
||||
|
||||
@@ -103,7 +103,7 @@ open class GolangBindTask : DefaultTask() {
|
||||
.copyRecursively(goPath.resolve("src"), overwrite = true)
|
||||
|
||||
"gomobile init".exec(goBuildPath)
|
||||
"gomobile bind -target=android \"-gcflags=all=-trimpath=$goPath\" github.com/kr328/cfa/bridge".exec(goBuildPath)
|
||||
"gomobile bind -target=android \"-gcflags=all=-trimpath=$goPath\" \"-ldflags=-w -s\" github.com/kr328/cfa/bridge".exec(goBuildPath)
|
||||
|
||||
nativeOutput.deleteRecursively()
|
||||
javaOutput.deleteRecursively()
|
||||
@@ -161,7 +161,7 @@ open class GolangBindTask : DefaultTask() {
|
||||
.asSequence()
|
||||
.filter { line -> line.startsWith("replace") }
|
||||
.map { replace ->
|
||||
replace.replace(REGEX_REPLACE_TARGET_LOCAL, "=> " + file.parentFile.absolutePath + "/")
|
||||
replace.replace(REGEX_REPLACE_TARGET_LOCAL, "=> " + file.parentFile.absolutePath.replace('\\','/') + "/")
|
||||
}
|
||||
.map { replace ->
|
||||
replace.replace(REGEX_REPLACE_SOURCE_VERSION, " =>")
|
||||
|
||||
@@ -1,12 +1,24 @@
|
||||
package bridge
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"github.com/Dreamacro/clash/component/mmdb"
|
||||
C "github.com/Dreamacro/clash/constant"
|
||||
"github.com/Dreamacro/clash/log"
|
||||
"github.com/Dreamacro/clash/tunnel"
|
||||
"github.com/kr328/cfa/profile"
|
||||
)
|
||||
|
||||
var (
|
||||
logCallback LogCallback
|
||||
logSubscribe sync.Once
|
||||
)
|
||||
|
||||
type LogCallback interface {
|
||||
OnLogEvent(level, payload string)
|
||||
}
|
||||
|
||||
func LoadMMDB(data []byte) {
|
||||
dataClone := make([]byte, len(data))
|
||||
copy(dataClone, data)
|
||||
@@ -26,3 +38,27 @@ func Reset() {
|
||||
func SetApplicationVersion(version string) {
|
||||
profile.ApplicationVersion = version
|
||||
}
|
||||
|
||||
func SetLogCallback(callback LogCallback) {
|
||||
logSubscribe.Do(func() {
|
||||
go func() {
|
||||
sub := log.Subscribe()
|
||||
defer log.UnSubscribe(sub)
|
||||
|
||||
for {
|
||||
elm := <-sub
|
||||
l := elm.(*log.Event)
|
||||
|
||||
if l.LogLevel < log.Level() {
|
||||
continue
|
||||
}
|
||||
|
||||
if cb := logCallback; cb != nil {
|
||||
cb.OnLogEvent(l.LogLevel.String(), l.Payload)
|
||||
}
|
||||
}
|
||||
}()
|
||||
})
|
||||
|
||||
logCallback = callback
|
||||
}
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
package bridge
|
||||
|
||||
import (
|
||||
"github.com/Dreamacro/clash/log"
|
||||
"sync"
|
||||
|
||||
"github.com/Dreamacro/clash/tunnel"
|
||||
)
|
||||
|
||||
type EventPoll struct {
|
||||
stop sync.Once
|
||||
|
||||
onStop func()
|
||||
}
|
||||
|
||||
func (e *EventPoll) Stop() {
|
||||
e.onStop()
|
||||
e.stop.Do(func() {
|
||||
e.onStop()
|
||||
})
|
||||
}
|
||||
|
||||
type Traffic struct {
|
||||
@@ -40,35 +45,3 @@ func QueryTraffic() *Traffic {
|
||||
Download: down,
|
||||
}
|
||||
}
|
||||
|
||||
func PollLogs(logs Logs) *EventPoll {
|
||||
stopChannel := make(chan int, 1)
|
||||
sub := log.Subscribe()
|
||||
|
||||
go func() {
|
||||
defer log.UnSubscribe(sub)
|
||||
defer close(stopChannel)
|
||||
defer log.Infoln("Logs Poll Stopped")
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-stopChannel:
|
||||
return
|
||||
case elm := <-sub:
|
||||
l := elm.(*log.Event)
|
||||
|
||||
if l.LogLevel < log.Level() {
|
||||
break
|
||||
}
|
||||
|
||||
logs.OnEvent(l.Type(), l.Payload)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return &EventPoll{
|
||||
onStop: func() {
|
||||
stopChannel <- 0
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,33 +1,3 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"io/ioutil"
|
||||
"net"
|
||||
"os"
|
||||
|
||||
"github.com/Dreamacro/clash/component/mmdb"
|
||||
)
|
||||
|
||||
func main() {
|
||||
f, err := os.Open("./Country.mmdb")
|
||||
if err != nil {
|
||||
println(err)
|
||||
return
|
||||
}
|
||||
|
||||
buf, err := ioutil.ReadAll(f)
|
||||
if err != nil {
|
||||
println(err)
|
||||
return
|
||||
}
|
||||
|
||||
mmdb.LoadFromBytes(buf)
|
||||
|
||||
c, err := mmdb.Instance().Country(net.ParseIP("114.114.114.114"))
|
||||
if err != nil {
|
||||
println(err)
|
||||
return
|
||||
}
|
||||
|
||||
println(c.Country.IsoCode)
|
||||
}
|
||||
func main() {}
|
||||
|
||||
@@ -83,10 +83,14 @@ func ReadAndCheck(fd int, output, baseDir string) error {
|
||||
}
|
||||
|
||||
func SaveAndCheck(data []byte, output, baseDir string) error {
|
||||
_, err := parseConfig(data, baseDir)
|
||||
cfg, err := parseConfig(data, baseDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, v := range cfg.Providers {
|
||||
v.Destroy()
|
||||
}
|
||||
|
||||
return ioutil.WriteFile(output, data, defaultFileMode)
|
||||
}
|
||||
|
||||
@@ -107,7 +107,7 @@ func parseConfig(data []byte, baseDir string) (*config.Config, error) {
|
||||
raw.Experimental.Interface = ""
|
||||
raw.ExternalUI = ""
|
||||
raw.ExternalController = ""
|
||||
raw.Rule = append([]string{fmt.Sprintf("IP-CIDR,%s,REJECT", tunAddress)}, raw.Rule...)
|
||||
raw.Rule = append([]string{fmt.Sprintf("IP-CIDR,%s,REJECT,no-resolve", tunAddress)}, raw.Rule...)
|
||||
|
||||
patchRawConfig(raw)
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ package com.github.kr328.clash.core
|
||||
import android.content.Context
|
||||
import bridge.Bridge
|
||||
import bridge.TunCallback
|
||||
import com.github.kr328.clash.core.event.EventStream
|
||||
import com.github.kr328.clash.core.event.LogEvent
|
||||
import com.github.kr328.clash.core.model.General
|
||||
import com.github.kr328.clash.core.model.Proxy
|
||||
@@ -12,12 +11,13 @@ import com.github.kr328.clash.core.model.Traffic
|
||||
import com.github.kr328.clash.core.transact.DoneCallbackImpl
|
||||
import com.github.kr328.clash.core.transact.ProxyCollectionImpl
|
||||
import com.github.kr328.clash.core.transact.ProxyGroupCollectionImpl
|
||||
import com.github.kr328.clash.core.utils.Log
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import java.io.File
|
||||
import java.io.InputStream
|
||||
|
||||
object Clash {
|
||||
private val logReceivers = mutableMapOf<String, (LogEvent) -> Unit>()
|
||||
|
||||
private var initialized = false
|
||||
|
||||
@Synchronized
|
||||
@@ -33,8 +33,6 @@ object Clash {
|
||||
Bridge.setHome(context.cacheDir.absolutePath)
|
||||
Bridge.setApplicationVersion(BuildConfig.VERSION_NAME)
|
||||
Bridge.reset()
|
||||
|
||||
Log.d("MMDB loaded ${bytes.size}")
|
||||
}
|
||||
|
||||
fun start() {
|
||||
@@ -52,10 +50,11 @@ object Clash {
|
||||
onNewSocket: (Int) -> Boolean,
|
||||
onTunStop: () -> Unit
|
||||
) {
|
||||
Bridge.startTunDevice(fd.toLong(), mtu.toLong(), dns, object: TunCallback {
|
||||
Bridge.startTunDevice(fd.toLong(), mtu.toLong(), dns, object : TunCallback {
|
||||
override fun onCreateSocket(fd: Long) {
|
||||
onNewSocket(fd.toInt())
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
onTunStop()
|
||||
}
|
||||
@@ -143,14 +142,27 @@ object Clash {
|
||||
return Traffic(data.upload, data.download)
|
||||
}
|
||||
|
||||
fun openLogEvent(): EventStream<LogEvent> {
|
||||
return object : EventStream<LogEvent>() {
|
||||
val log = Bridge.pollLogs { level, payload ->
|
||||
send(LogEvent(LogEvent.Level.fromString(level), payload))
|
||||
}
|
||||
fun registerLogReceiver(key: String, receiver: (LogEvent) -> Unit) {
|
||||
synchronized(logReceivers) {
|
||||
logReceivers[key] = receiver
|
||||
|
||||
override fun onClose() {
|
||||
log.stop()
|
||||
Bridge.setLogCallback(this::onLogEvent)
|
||||
}
|
||||
}
|
||||
|
||||
fun unregisterLogReceiver(key: String) {
|
||||
synchronized(logReceivers) {
|
||||
logReceivers.remove(key)
|
||||
|
||||
if (logReceivers.isEmpty())
|
||||
Bridge.setLogCallback(null)
|
||||
}
|
||||
}
|
||||
|
||||
private fun onLogEvent(level: String, payload: String) {
|
||||
synchronized(logReceivers) {
|
||||
logReceivers.forEach {
|
||||
it.value(LogEvent(LogEvent.Level.fromString(level), payload))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ data class LogEvent(
|
||||
val level: Level,
|
||||
val message: String,
|
||||
val time: Long = System.currentTimeMillis()
|
||||
): Event {
|
||||
) : Event {
|
||||
companion object {
|
||||
const val DEBUG_VALUE = "debug"
|
||||
const val INFO_VALUE = "info"
|
||||
|
||||
@@ -34,6 +34,6 @@ abstract class Base(val screen: CommonUiScreen) {
|
||||
abstract fun restoreState(bundle: Bundle)
|
||||
protected open fun applyAttribute(enabled: Boolean, hidden: Boolean) {
|
||||
view.isEnabled = enabled
|
||||
view.visibility = if ( hidden ) View.GONE else View.VISIBLE
|
||||
view.visibility = if (hidden) View.GONE else View.VISIBLE
|
||||
}
|
||||
}
|
||||
@@ -6,8 +6,9 @@ import android.view.View
|
||||
import android.widget.TextView
|
||||
import com.github.kr328.clash.design.R
|
||||
|
||||
class Category(screen: CommonUiScreen): Base(screen) {
|
||||
override val view: View = LayoutInflater.from(context).inflate(R.layout.view_category, screen.layout, false)
|
||||
class Category(screen: CommonUiScreen) : Base(screen) {
|
||||
override val view: View =
|
||||
LayoutInflater.from(context).inflate(R.layout.view_category, screen.layout, false)
|
||||
|
||||
private val vText: TextView = view.findViewById(R.id.text)
|
||||
private val vTopSeparator: View = view.findViewById(R.id.topSeparator)
|
||||
@@ -15,19 +16,21 @@ class Category(screen: CommonUiScreen): Base(screen) {
|
||||
|
||||
var text: CharSequence
|
||||
get() = vText.text
|
||||
set(value) { vText.text = value }
|
||||
set(value) {
|
||||
vText.text = value
|
||||
}
|
||||
|
||||
var showTopSeparator: Boolean
|
||||
get() = vTopSeparator.visibility == View.VISIBLE
|
||||
set(value) {
|
||||
vTopSeparator.visibility =
|
||||
if ( value ) View.VISIBLE else View.GONE
|
||||
if (value) View.VISIBLE else View.GONE
|
||||
}
|
||||
var showBottomSeparator: Boolean
|
||||
get() = vBottomSeparator.visibility == View.VISIBLE
|
||||
set(value) {
|
||||
vBottomSeparator.visibility =
|
||||
if ( value ) View.VISIBLE else View.GONE
|
||||
if (value) View.VISIBLE else View.GONE
|
||||
}
|
||||
|
||||
override fun saveState(bundle: Bundle) {}
|
||||
|
||||
@@ -73,7 +73,8 @@ class CommonUiBuilder(val screen: CommonUiScreen) {
|
||||
fun tips(
|
||||
title: String = "",
|
||||
icon: Drawable? = null,
|
||||
setup: Tips.() -> Unit) {
|
||||
setup: Tips.() -> Unit
|
||||
) {
|
||||
val tips = Tips(screen)
|
||||
|
||||
tips.title = title
|
||||
|
||||
@@ -41,7 +41,7 @@ class CommonUiScreen(val layout: CommonUiLayout) {
|
||||
}
|
||||
|
||||
fun restoreState(bundle: Bundle?) {
|
||||
if ( bundle == null )
|
||||
if (bundle == null)
|
||||
return
|
||||
|
||||
elements.forEach {
|
||||
|
||||
@@ -7,8 +7,9 @@ import android.view.View
|
||||
import android.widget.TextView
|
||||
import com.github.kr328.clash.design.R
|
||||
|
||||
class Option(screen: CommonUiScreen): Base(screen) {
|
||||
override val view: View = LayoutInflater.from(context).inflate(R.layout.view_setting_option, screen.layout, false)
|
||||
class Option(screen: CommonUiScreen) : Base(screen) {
|
||||
override val view: View =
|
||||
LayoutInflater.from(context).inflate(R.layout.view_setting_option, screen.layout, false)
|
||||
|
||||
private val vIcon: View = view.findViewById(android.R.id.icon)
|
||||
private val vTitle: TextView = view.findViewById(android.R.id.title)
|
||||
@@ -19,15 +20,19 @@ class Option(screen: CommonUiScreen): Base(screen) {
|
||||
|
||||
var icon: Drawable?
|
||||
get() = vIcon.background
|
||||
set(value) { vIcon.background = value }
|
||||
set(value) {
|
||||
vIcon.background = value
|
||||
}
|
||||
var title: CharSequence
|
||||
get() = vTitle.text
|
||||
set(value) { vTitle.text = value }
|
||||
set(value) {
|
||||
vTitle.text = value
|
||||
}
|
||||
var summary: CharSequence
|
||||
get() = vSummary.text
|
||||
set(value) {
|
||||
vSummary.text = value
|
||||
if ( value.isEmpty() )
|
||||
if (value.isEmpty())
|
||||
vSummary.visibility = View.GONE
|
||||
else
|
||||
vSummary.visibility = View.VISIBLE
|
||||
@@ -41,7 +46,7 @@ class Option(screen: CommonUiScreen): Base(screen) {
|
||||
var paddingHeight: Boolean
|
||||
get() = vPadding.visibility != View.GONE
|
||||
set(value) {
|
||||
if ( value )
|
||||
if (value)
|
||||
vPadding.visibility = View.INVISIBLE
|
||||
else
|
||||
vPadding.visibility = View.GONE
|
||||
|
||||
@@ -74,14 +74,14 @@ class TextInput(screen: CommonUiScreen) : Base(screen) {
|
||||
}
|
||||
|
||||
override fun saveState(bundle: Bundle) {
|
||||
if ( id == null )
|
||||
if (id == null)
|
||||
return
|
||||
|
||||
bundle.putCharSequence(id, content)
|
||||
}
|
||||
|
||||
override fun restoreState(bundle: Bundle) {
|
||||
if ( id == null )
|
||||
if (id == null)
|
||||
return
|
||||
|
||||
bundle.getCharSequence(id)?.apply {
|
||||
|
||||
@@ -7,7 +7,7 @@ import android.view.View
|
||||
import android.widget.TextView
|
||||
import com.github.kr328.clash.design.R
|
||||
|
||||
class Tips(screen: CommonUiScreen): Base(screen) {
|
||||
class Tips(screen: CommonUiScreen) : Base(screen) {
|
||||
override val view: View = LayoutInflater.from(context)
|
||||
.inflate(R.layout.view_setting_tip, screen.layout, false)
|
||||
|
||||
@@ -16,11 +16,15 @@ class Tips(screen: CommonUiScreen): Base(screen) {
|
||||
|
||||
var icon: Drawable?
|
||||
get() = vIcon.background
|
||||
set(value) { vIcon.background = value }
|
||||
set(value) {
|
||||
vIcon.background = value
|
||||
}
|
||||
|
||||
var title: CharSequence
|
||||
get() = vTitle.text
|
||||
set(value) { vTitle.text = value }
|
||||
set(value) {
|
||||
vTitle.text = value
|
||||
}
|
||||
|
||||
override fun saveState(bundle: Bundle) {}
|
||||
override fun restoreState(bundle: Bundle) {}
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="25dp"
|
||||
android:layout_alignParentEnd="true"
|
||||
android:textAppearance="@style/TextAppearance.AppCompat.Medium" />
|
||||
|
||||
<TextView
|
||||
@@ -29,5 +30,6 @@
|
||||
android:layout_alignStart="@android:id/title"
|
||||
android:layout_below="@android:id/title"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content" />
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_alignParentEnd="true"/>
|
||||
</RelativeLayout>
|
||||
12
gradle/wrapper/gradle-wrapper.properties
vendored
@@ -1,6 +1,6 @@
|
||||
#Thu Nov 14 20:04:34 CST 2019
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-5.4.1-all.zip
|
||||
#Tue Feb 25 12:40:41 CST 2020
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-5.6.4-all.zip
|
||||
|
||||
@@ -16,9 +16,6 @@ interface IClashManager {
|
||||
long queryBandwidth();
|
||||
|
||||
// Events
|
||||
void openLogEvent(IStreamCallback callback);
|
||||
|
||||
// Settings
|
||||
boolean putSetting(String key, String value);
|
||||
String getSetting(String key);
|
||||
void registerLogListener(String key, IStreamCallback callback);
|
||||
void unregisterLogListener(String key);
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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.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.stopTunDevice()
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
package com.github.kr328.clash.service
|
||||
|
||||
import android.content.Context
|
||||
import androidx.core.content.edit
|
||||
import com.github.kr328.clash.core.Clash
|
||||
import com.github.kr328.clash.core.model.General
|
||||
import com.github.kr328.clash.core.model.ProxyGroupList
|
||||
@@ -42,37 +41,12 @@ class ClashManager(context: Context, parent: CoroutineScope) :
|
||||
return Clash.setSelectedProxy(proxy, selected)
|
||||
}
|
||||
|
||||
override fun putSetting(key: String?, value: String?): Boolean {
|
||||
settings.edit(commit = false) {
|
||||
putString(key, value)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
override fun queryBandwidth(): Long {
|
||||
val data = Clash.queryBandwidth()
|
||||
|
||||
return data.download + data.upload
|
||||
}
|
||||
|
||||
override fun openLogEvent(callback: IStreamCallback?) {
|
||||
require(callback != null)
|
||||
|
||||
Clash.openLogEvent().apply {
|
||||
callback.asBinder()?.linkToDeath({
|
||||
close()
|
||||
}, 0)
|
||||
|
||||
onEvent {
|
||||
try {
|
||||
callback.send(ParcelableContainer(it))
|
||||
} catch (e: Exception) {
|
||||
close()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun startHealthCheck(group: String?, callback: IStreamCallback?) {
|
||||
require(group != null && callback != null)
|
||||
|
||||
@@ -84,7 +58,26 @@ class ClashManager(context: Context, parent: CoroutineScope) :
|
||||
}
|
||||
}
|
||||
|
||||
override fun getSetting(key: String?): String? {
|
||||
return settings.getString(key, null)
|
||||
override fun registerLogListener(key: String?, callback: IStreamCallback?) {
|
||||
requireNotNull(key)
|
||||
requireNotNull(callback)
|
||||
|
||||
callback.asBinder().linkToDeath({
|
||||
Clash.unregisterLogReceiver(key)
|
||||
}, 0)
|
||||
|
||||
Clash.registerLogReceiver(key) {
|
||||
try {
|
||||
callback.send(ParcelableContainer(it))
|
||||
} catch (e: Exception) {
|
||||
Clash.unregisterLogReceiver(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun unregisterLogListener(key: String?) {
|
||||
requireNotNull(key)
|
||||
|
||||
Clash.unregisterLogReceiver(key)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
context.stopForeground(true)
|
||||
|
||||
updateDestroy()
|
||||
}
|
||||
|
||||
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,37 @@ 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))
|
||||
.setSubText(null)
|
||||
.build()
|
||||
|
||||
NotificationManagerCompat.from(context).apply {
|
||||
notify(CLASH_STATUS_NOTIFICATION_ID, notification)
|
||||
cancel(CLASH_STATUS_NOTIFICATION_ID)
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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 =
|
||||
|
||||
@@ -100,7 +100,7 @@ class ProfileBackgroundService : BaseService() {
|
||||
do {
|
||||
select<Unit> {
|
||||
requestChannel.onReceive {
|
||||
if ( !queue.containsKey(it.id) ) {
|
||||
if (!queue.containsKey(it.id)) {
|
||||
queue[it.id] = it
|
||||
|
||||
sendRequest(it, service)
|
||||
@@ -112,7 +112,7 @@ class ProfileBackgroundService : BaseService() {
|
||||
}
|
||||
|
||||
refreshStatusNotification(queue.size)
|
||||
} while ( queue.isNotEmpty() )
|
||||
} while (queue.isNotEmpty())
|
||||
|
||||
stopSelf()
|
||||
}
|
||||
@@ -120,7 +120,7 @@ class ProfileBackgroundService : BaseService() {
|
||||
private fun sendRequest(request: ProfileRequest, service: IProfileService) {
|
||||
val originalCallback = request.callback
|
||||
|
||||
request.withCallback(object: IStreamCallback.Stub() {
|
||||
request.withCallback(object : IStreamCallback.Stub() {
|
||||
override fun complete() {
|
||||
originalCallback?.complete()
|
||||
|
||||
@@ -137,7 +137,7 @@ class ProfileBackgroundService : BaseService() {
|
||||
launch {
|
||||
responseChannel.send(request)
|
||||
|
||||
sendUpdateFailure(request.id, reason?: "Unknown")
|
||||
sendUpdateFailure(request.id, reason ?: "Unknown")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
@@ -204,11 +206,12 @@ class ProfileBackgroundService : BaseService() {
|
||||
val entity = profiles.queryProfileById(id) ?: return
|
||||
|
||||
val notification = NotificationCompat.Builder(this, SERVICE_RESULT_CHANNEL)
|
||||
.setContentTitle(getString(R.string.format_update_failure, entity.name, reason))
|
||||
.setContentText(reason)
|
||||
.setContentTitle(getString(R.string.format_update_failure, entity.name))
|
||||
.setColor(getColor(R.color.colorAccentService))
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setStyle(NotificationCompat.BigTextStyle().bigText(reason))
|
||||
.setOnlyAlertOnce(true)
|
||||
.setGroup(SERVICE_RESULT_CHANNEL)
|
||||
.build()
|
||||
|
||||
NotificationManagerCompat.from(this)
|
||||
|
||||
@@ -22,7 +22,8 @@ class ProfileProcessor(private val context: Context) {
|
||||
downloadProfile(
|
||||
uri,
|
||||
resolveProfile(entity.id),
|
||||
resolveBase(entity.id)
|
||||
resolveBase(entity.id),
|
||||
newRecord
|
||||
)
|
||||
|
||||
val newEntity = if (entity.type == ClashProfileEntity.TYPE_FILE)
|
||||
@@ -58,7 +59,7 @@ class ProfileProcessor(private val context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun downloadProfile(source: Uri, target: File, baseDir: File) {
|
||||
private suspend fun downloadProfile(source: Uri, target: File, baseDir: File, newRecord: Boolean) {
|
||||
try {
|
||||
target.parentFile?.mkdirs()
|
||||
baseDir.mkdirs()
|
||||
@@ -74,8 +75,10 @@ class ProfileProcessor(private val context: Context) {
|
||||
Clash.downloadProfile(source.toString(), target, baseDir).await()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
target.delete()
|
||||
baseDir.deleteRecursively()
|
||||
if ( newRecord ) {
|
||||
target.delete()
|
||||
baseDir.deleteRecursively()
|
||||
}
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ class ProfileRequestReceiver : BroadcastReceiver() {
|
||||
return
|
||||
|
||||
val id = intent.getLongExtra(Intents.INTENT_EXTRA_PROFILE_ID, -1)
|
||||
if ( id < 0 )
|
||||
if (id < 0)
|
||||
return
|
||||
|
||||
val request = ProfileRequest()
|
||||
|
||||
@@ -186,7 +186,7 @@ class ProfileService : BaseService() {
|
||||
ClashProfileEntity(
|
||||
requireNotNull(request.name),
|
||||
requireNotNull(request.type),
|
||||
requireNotNull(request.url).toString(),
|
||||
requireNotNull(request.url).toString().toLowerCase(Locale.getDefault()),
|
||||
request.source?.toString(),
|
||||
false,
|
||||
0,
|
||||
|
||||
@@ -2,10 +2,25 @@ package com.github.kr328.clash.service
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import rikka.preference.MultiProcessPreference
|
||||
import rikka.preference.PreferenceProvider
|
||||
|
||||
class ServiceSettingsProvider: PreferenceProvider() {
|
||||
class ServiceSettingsProvider : PreferenceProvider() {
|
||||
override fun onCreatePreference(context: Context?): SharedPreferences {
|
||||
return context!!.getSharedPreferences(Constants.SERVICE_SETTING_FILE_NAME, Context.MODE_PRIVATE)
|
||||
return context!!.getSharedPreferences(
|
||||
Constants.SERVICE_SETTING_FILE_NAME,
|
||||
Context.MODE_PRIVATE
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun createSharedPreferencesFromContext(context: Context): SharedPreferences {
|
||||
return when ( context ) {
|
||||
is BaseService, is TunService ->
|
||||
context.getSharedPreferences(Constants.SERVICE_SETTING_FILE_NAME, Context.MODE_PRIVATE)
|
||||
else ->
|
||||
MultiProcessPreference(context, context.packageName + Constants.SETTING_PROVIDER_SUFFIX)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
@@ -16,10 +16,13 @@ class TunService : VpnService(), CoroutineScope by MainScope() {
|
||||
private const val VPN_MTU = 1500
|
||||
private const val PRIVATE_VLAN4_SUBNET = 30
|
||||
private const val PRIVATE_VLAN4_CLIENT = "172.31.255.253"
|
||||
private const val PRIVATE_VLAN6_CLIENT = "fdfe:dcba:9876::1"
|
||||
private const val PRIVATE_VLAN_DNS = "172.31.255.254"
|
||||
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 +58,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 +93,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 +108,11 @@ class TunService : VpnService(), CoroutineScope by MainScope() {
|
||||
override fun onDestroy() {
|
||||
cancel()
|
||||
|
||||
defaultNetworkChannel.unregister()
|
||||
clashCore?.apply {
|
||||
destroy()
|
||||
|
||||
defaultNetworkChannel.unregister()
|
||||
}
|
||||
|
||||
Log.i("TunService.onDestroy")
|
||||
|
||||
@@ -117,8 +126,11 @@ class TunService : VpnService(), CoroutineScope by MainScope() {
|
||||
}
|
||||
|
||||
private fun Builder.addBypassPrivateRoute(): Builder {
|
||||
val ipv6Support = settings.get(ServiceSettings.IPV6_SUPPORT)
|
||||
val bypassPrivate = settings.get(ServiceSettings.BYPASS_PRIVATE_NETWORK)
|
||||
|
||||
// IPv4
|
||||
if (settings.get(ServiceSettings.BYPASS_PRIVATE_NETWORK)) {
|
||||
if (bypassPrivate) {
|
||||
resources.getStringArray(R.array.bypass_private_route).forEach {
|
||||
val address = it.split("/")
|
||||
addRoute(address[0], address[1].toInt())
|
||||
@@ -128,8 +140,12 @@ class TunService : VpnService(), CoroutineScope by MainScope() {
|
||||
}
|
||||
|
||||
// IPv6
|
||||
if (settings.get(ServiceSettings.IPV6_SUPPORT)) {
|
||||
addRoute("::", 0)
|
||||
if (ipv6Support) {
|
||||
if (bypassPrivate)
|
||||
// from https://github.com/shadowsocks/shadowsocks-android/commit/cc840c9fddb3f4f6677005de18f1fcb387b84064#diff-e089fe63dcb3674c0a1e459a95508e3e
|
||||
addRoute("2000::", 3)
|
||||
else
|
||||
addRoute("::", 0)
|
||||
}
|
||||
|
||||
return this
|
||||
@@ -137,16 +153,9 @@ class TunService : VpnService(), CoroutineScope by MainScope() {
|
||||
|
||||
private fun Builder.addBypassApplications(): Builder {
|
||||
when (settings.get(ServiceSettings.ACCESS_CONTROL_MODE)) {
|
||||
ServiceSettings.ACCESS_CONTROL_MODE_ALL -> {
|
||||
for (app in resources.getStringArray(R.array.default_disallow_application)) {
|
||||
runCatching {
|
||||
addDisallowedApplication(app)
|
||||
}
|
||||
}
|
||||
}
|
||||
ServiceSettings.ACCESS_CONTROL_MODE_ALL -> {}
|
||||
ServiceSettings.ACCESS_CONTROL_MODE_WHITELIST -> {
|
||||
for (app in settings.get(ServiceSettings.ACCESS_CONTROL_PACKAGES).toSet() -
|
||||
resources.getStringArray(R.array.default_disallow_application)) {
|
||||
for (app in settings.get(ServiceSettings.ACCESS_CONTROL_PACKAGES).toSet()) {
|
||||
runCatching {
|
||||
addAllowedApplication(app)
|
||||
}.onFailure {
|
||||
@@ -155,8 +164,7 @@ class TunService : VpnService(), CoroutineScope by MainScope() {
|
||||
}
|
||||
}
|
||||
ServiceSettings.ACCESS_CONTROL_MODE_BLACKLIST -> {
|
||||
for (app in settings.get(ServiceSettings.ACCESS_CONTROL_PACKAGES).toSet() +
|
||||
resources.getStringArray(R.array.default_disallow_application)) {
|
||||
for (app in settings.get(ServiceSettings.ACCESS_CONTROL_PACKAGES).toSet()) {
|
||||
runCatching {
|
||||
addDisallowedApplication(app)
|
||||
}.onFailure {
|
||||
@@ -173,6 +181,9 @@ class TunService : VpnService(), CoroutineScope by MainScope() {
|
||||
private fun Builder.addAddress(): Builder {
|
||||
addAddress(PRIVATE_VLAN4_CLIENT, PRIVATE_VLAN4_SUBNET)
|
||||
|
||||
if (settings.get(ServiceSettings.IPV6_SUPPORT))
|
||||
addAddress(PRIVATE_VLAN6_CLIENT, 126)
|
||||
|
||||
return this
|
||||
}
|
||||
}
|
||||
@@ -68,13 +68,13 @@ object ClashDatabaseMigrations {
|
||||
cursor.moveToNext()
|
||||
}
|
||||
cursor.close()
|
||||
}
|
||||
catch (e: Exception) {
|
||||
} catch (e: Exception) {
|
||||
Log.d("Migration profiles failure", e)
|
||||
}
|
||||
|
||||
try {
|
||||
val cursor = database.query("SELECT profile_id, proxy, selected FROM _profile_select_proxies ORDER BY id")
|
||||
val cursor =
|
||||
database.query("SELECT profile_id, proxy, selected FROM _profile_select_proxies ORDER BY id")
|
||||
|
||||
cursor.moveToFirst()
|
||||
while (!cursor.isAfterLast) {
|
||||
@@ -98,8 +98,7 @@ object ClashDatabaseMigrations {
|
||||
cursor.moveToNext()
|
||||
}
|
||||
cursor.close()
|
||||
}
|
||||
catch (e: Exception) {
|
||||
} catch (e: Exception) {
|
||||
Log.d("Migration selected failure")
|
||||
}
|
||||
|
||||
@@ -109,8 +108,10 @@ object ClashDatabaseMigrations {
|
||||
// Migration settings
|
||||
val oldSettings = Global.application
|
||||
.getSharedPreferences("clash_service", Context.MODE_PRIVATE)
|
||||
val newSettings = ServiceSettings(Global.application
|
||||
.getSharedPreferences(Constants.SERVICE_SETTING_FILE_NAME, Context.MODE_PRIVATE))
|
||||
val newSettings = ServiceSettings(
|
||||
Global.application
|
||||
.getSharedPreferences(Constants.SERVICE_SETTING_FILE_NAME, Context.MODE_PRIVATE)
|
||||
)
|
||||
|
||||
val accessMode = oldSettings
|
||||
.getInt("key_access_control_mode", 0)
|
||||
@@ -124,7 +125,7 @@ object ClashDatabaseMigrations {
|
||||
.getBoolean("key_bypass_private_network", true)
|
||||
|
||||
newSettings.commit {
|
||||
val newAccessMode = when ( accessMode ) {
|
||||
val newAccessMode = when (accessMode) {
|
||||
0 -> ServiceSettings.ACCESS_CONTROL_MODE_ALL
|
||||
1 -> ServiceSettings.ACCESS_CONTROL_MODE_WHITELIST
|
||||
2 -> ServiceSettings.ACCESS_CONTROL_MODE_BLACKLIST
|
||||
@@ -143,8 +144,7 @@ object ClashDatabaseMigrations {
|
||||
try {
|
||||
process(database)
|
||||
Log.i("Database Migrated 1 -> 2")
|
||||
}
|
||||
catch (e: Exception) {
|
||||
} catch (e: Exception) {
|
||||
Log.e("Migration failure", e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -40,7 +40,8 @@ class DefaultNetworkChannel(val context: Context, scope: CoroutineScope) :
|
||||
val cap = capabilitiesCache[network]
|
||||
|
||||
if (cap?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||
!= networkCapabilities?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)) {
|
||||
!= networkCapabilities?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||
) {
|
||||
sendDefaultNetwork(true)
|
||||
}
|
||||
|
||||
@@ -50,7 +51,7 @@ class DefaultNetworkChannel(val context: Context, scope: CoroutineScope) :
|
||||
override fun onLinkPropertiesChanged(network: Network, linkProperties: LinkProperties) {
|
||||
val cache = dnsServerCache[network]
|
||||
|
||||
if ( cache != linkProperties.dnsServers ) {
|
||||
if (cache != linkProperties.dnsServers) {
|
||||
sendDefaultNetwork(false)
|
||||
}
|
||||
|
||||
@@ -74,7 +75,7 @@ class DefaultNetworkChannel(val context: Context, scope: CoroutineScope) :
|
||||
val network = detectDefaultNetwork()
|
||||
val link = network?.let(connectivity::getLinkProperties)
|
||||
|
||||
if ( ignoreSame && network == currentNetwork )
|
||||
if (ignoreSame && network == currentNetwork)
|
||||
return@launch sendLock.unlock()
|
||||
|
||||
currentNetwork = network
|
||||
|
||||
@@ -8,7 +8,7 @@ abstract class BaseSettings(private val preferences: SharedPreferences) {
|
||||
fun put(editor: SharedPreferences.Editor, value: T)
|
||||
}
|
||||
|
||||
class StringEntry(private val key: String, private val defaultValue: String): Entry<String> {
|
||||
class StringEntry(private val key: String, private val defaultValue: String) : Entry<String> {
|
||||
override fun get(preferences: SharedPreferences): String {
|
||||
return preferences.getString(key, defaultValue)!!
|
||||
}
|
||||
@@ -18,7 +18,8 @@ abstract class BaseSettings(private val preferences: SharedPreferences) {
|
||||
}
|
||||
}
|
||||
|
||||
class BooleanEntry(private val key: String, private val defaultValue: Boolean): Entry<Boolean> {
|
||||
class BooleanEntry(private val key: String, private val defaultValue: Boolean) :
|
||||
Entry<Boolean> {
|
||||
override fun get(preferences: SharedPreferences): Boolean {
|
||||
return preferences.getBoolean(key, defaultValue)
|
||||
}
|
||||
@@ -28,7 +29,8 @@ abstract class BaseSettings(private val preferences: SharedPreferences) {
|
||||
}
|
||||
}
|
||||
|
||||
class StringSetEntry(private val key: String, private val defaultValue: Set<String>): Entry<Set<String>> {
|
||||
class StringSetEntry(private val key: String, private val defaultValue: Set<String>) :
|
||||
Entry<Set<String>> {
|
||||
override fun get(preferences: SharedPreferences): Set<String> {
|
||||
return preferences.getStringSet(key, defaultValue)!!
|
||||
}
|
||||
@@ -39,12 +41,12 @@ abstract class BaseSettings(private val preferences: SharedPreferences) {
|
||||
}
|
||||
|
||||
class Editor(private val editor: SharedPreferences.Editor) {
|
||||
fun <T>put(entry: Entry<T>, value: T) {
|
||||
fun <T> put(entry: Entry<T>, value: T) {
|
||||
entry.put(editor, value)
|
||||
}
|
||||
}
|
||||
|
||||
fun <T>get(entry: Entry<T>): T {
|
||||
fun <T> get(entry: Entry<T>): T {
|
||||
return entry.get(preferences)
|
||||
}
|
||||
|
||||
@@ -53,7 +55,7 @@ abstract class BaseSettings(private val preferences: SharedPreferences) {
|
||||
|
||||
Editor(editor).apply(block)
|
||||
|
||||
if ( async )
|
||||
if (async)
|
||||
editor.apply()
|
||||
else
|
||||
editor.commit()
|
||||
|
||||
@@ -3,12 +3,15 @@ package com.github.kr328.clash.service.settings
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import com.github.kr328.clash.service.Constants
|
||||
import com.github.kr328.clash.service.ServiceSettingsProvider
|
||||
import rikka.preference.MultiProcessPreference
|
||||
|
||||
class ServiceSettings(preference: SharedPreferences):
|
||||
class ServiceSettings(preference: SharedPreferences) :
|
||||
BaseSettings(preference) {
|
||||
constructor(context: Context): this(MultiProcessPreference(context,
|
||||
context.packageName + Constants.SETTING_PROVIDER_SUFFIX))
|
||||
constructor(context: Context) : this(
|
||||
ServiceSettingsProvider.createSharedPreferencesFromContext(context)
|
||||
)
|
||||
|
||||
companion object {
|
||||
const val ACCESS_CONTROL_MODE_ALL = "access_control_mode_all"
|
||||
const val ACCESS_CONTROL_MODE_BLACKLIST = "access_control_mode_blacklist"
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||