Adjust android process

This commit is contained in:
chen08209
2026-08-05 10:54:54 +08:00
parent 7c831855ef
commit 51e24ef9de
80 changed files with 1930 additions and 2381 deletions
+17 -17
View File
@@ -15,21 +15,20 @@ val localProperties = Properties().apply {
}
}
val mStoreFile: File = file("keystore.jks")
val mStorePassword: String? = localProperties.getProperty("storePassword")
val mKeyAlias: String? = localProperties.getProperty("keyAlias")
val mKeyPassword: String? = localProperties.getProperty("keyPassword")
val isRelease =
mStoreFile.exists() && mStorePassword != null && mKeyAlias != null && mKeyPassword != null
val releaseStoreFile = file("keystore.jks")
val releaseStorePassword = localProperties.getProperty("storePassword")
val releaseKeyAlias = localProperties.getProperty("keyAlias")
val releaseKeyPassword = localProperties.getProperty("keyPassword")
val hasReleaseSigning = releaseStoreFile.exists() &&
releaseStorePassword != null &&
releaseKeyAlias != null &&
releaseKeyPassword != null
android {
namespace = "com.follow.clash"
compileSdk = libs.versions.compileSdk.get().toInt()
ndkVersion = libs.versions.ndkVersion.get()
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
@@ -44,12 +43,12 @@ android {
}
signingConfigs {
if (isRelease) {
if (hasReleaseSigning) {
create("release") {
storeFile = mStoreFile
storePassword = mStorePassword
keyAlias = mKeyAlias
keyPassword = mKeyPassword
storeFile = releaseStoreFile
storePassword = releaseStorePassword
keyAlias = releaseKeyAlias
keyPassword = releaseKeyPassword
}
}
}
@@ -69,7 +68,7 @@ android {
release {
isMinifyEnabled = true
isShrinkResources = true
if (isRelease) {
if (hasReleaseSigning) {
signingConfig = signingConfigs.getByName("release")
} else {
signingConfig = signingConfigs.getByName("debug")
@@ -77,7 +76,8 @@ android {
}
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro"
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
}
}
@@ -93,10 +93,10 @@ flutter {
source = "../.."
}
dependencies {
implementation(project(":service"))
implementation(project(":common"))
implementation(project(":core"))
implementation(libs.core.splashscreen)
implementation(libs.gson)
implementation(libs.smali.dexlib2) {
+2 -2
View File
@@ -1,4 +1,4 @@
-keep class com.follow.clash.models.**{ *; }
-keep class com.follow.clash.models.** { *; }
-keep class com.follow.clash.service.models.**{ *; }
-keep class com.follow.clash.service.models.** { *; }
@@ -1,9 +1,5 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<application
android:icon="@mipmap/ic_launcher"
android:label="FlClash Debug"
+11 -10
View File
@@ -11,10 +11,12 @@
<uses-feature
android:name="android.software.leanback"
android:required="false" />
<uses-feature
android:name="android.hardware.wifi"
android:required="false" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
@@ -23,15 +25,14 @@
tools:ignore="QueryAllPackagesPermission" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.POST_PROMOTED_NOTIFICATIONS" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<application
android:name=".Application"
android:name=".FlClashApplication"
android:banner="@mipmap/ic_banner"
android:hardwareAccelerated="true"
android:icon="@mipmap/ic_launcher"
android:label="FlClash">
android:label="@string/app_name">
<activity
android:name=".MainActivity"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
@@ -68,7 +69,7 @@
</activity>
<activity
android:name=".TempActivity"
android:name=".QuickActionActivity"
android:excludeFromRecents="true"
android:exported="true"
android:theme="@style/TransparentTheme">
@@ -90,7 +91,7 @@
android:name=".TileService"
android:exported="true"
android:icon="@drawable/ic"
android:label="FlClash"
android:label="@string/app_name"
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
<intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE" />
@@ -101,13 +102,13 @@
</service>
<receiver
android:name=".BroadcastReceiver"
android:name=".ServiceBroadcastReceiver"
android:enabled="true"
android:exported="true"
android:permission="${applicationId}.permission.RECEIVE_BROADCASTS">
<intent-filter>
<action android:name="${applicationId}.intent.action.SERVICE_CREATED" />
<action android:name="${applicationId}.intent.action.SERVICE_DESTROYED" />
<action android:name="${applicationId}.intent.action.VPN_START_REQUESTED" />
<action android:name="${applicationId}.intent.action.VPN_REVOKED" />
</intent-filter>
</receiver>
@@ -115,4 +116,4 @@
android:name="flutterEmbedding"
android:value="2" />
</application>
</manifest>
</manifest>
@@ -1,29 +0,0 @@
package com.follow.clash
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import com.follow.clash.common.BroadcastAction
import com.follow.clash.common.GlobalState
import com.follow.clash.common.action
import kotlinx.coroutines.launch
class BroadcastReceiver : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
when (intent?.action) {
BroadcastAction.SERVICE_CREATED.action -> {
GlobalState.log("Receiver service created")
GlobalState.launch {
State.handleStartServiceAction()
}
}
BroadcastAction.SERVICE_DESTROYED.action -> {
GlobalState.log("Receiver service destroyed")
GlobalState.launch {
State.handleStopServiceAction()
}
}
}
}
}
@@ -17,37 +17,32 @@ import io.flutter.embedding.engine.FlutterEngine
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.plugin.common.MethodChannel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import java.io.File
import java.io.FileOutputStream
import java.util.concurrent.TimeUnit
import kotlin.coroutines.resume
private const val ICON_TTL_DAYS = 1L
val Application.sharedState: SharedState
get() {
try {
val sp = getSharedPreferences("FlutterSharedPreferences", MODE_PRIVATE)
val res = sp.getString("flutter.sharedState", "")
return Gson().fromJson(res, SharedState::class.java)
} catch (_: Exception) {
return SharedState()
}
get() = try {
val preferences = getSharedPreferences("FlutterSharedPreferences", MODE_PRIVATE)
val json = preferences.getString("flutter.sharedState", null)
Gson().fromJson(json, SharedState::class.java) ?: SharedState()
} catch (_: Exception) {
SharedState()
}
private var lastToast: Toast? = null
fun Application.showToast(text: String?) {
if (text.isNullOrEmpty()) return
Handler(Looper.getMainLooper()).post {
lastToast?.cancel()
lastToast = Toast.makeText(this, text, Toast.LENGTH_LONG).apply {
show()
}
}
}
suspend fun PackageManager.getPackageIconPath(packageName: String): String =
@@ -55,8 +50,7 @@ suspend fun PackageManager.getPackageIconPath(packageName: String): String =
val cacheDir = GlobalState.application.cacheDir
val iconDir = File(cacheDir, "icons").apply { mkdirs() }
return@withContext try {
val pkgInfo = getPackageInfo(packageName, 0)
val lastUpdateTime = pkgInfo.lastUpdateTime
val lastUpdateTime = getPackageInfo(packageName, 0).lastUpdateTime
val iconFile = File(iconDir, "${packageName}_${lastUpdateTime}.webp")
if (iconFile.exists() && !isExpired(iconFile)) {
return@withContext iconFile.absolutePath
@@ -80,14 +74,11 @@ private suspend fun saveDrawableToFile(drawable: Drawable, file: File) {
drawable.toBitmap(width = 128, height = 128)
}
try {
val format = when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> {
Bitmap.CompressFormat.WEBP_LOSSY
}
else -> {
Bitmap.CompressFormat.WEBP
}
@Suppress("DEPRECATION")
val format = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
Bitmap.CompressFormat.WEBP_LOSSY
} else {
Bitmap.CompressFormat.WEBP
}
FileOutputStream(file).use { fos ->
bitmap.compress(format, 90, fos)
@@ -103,48 +94,12 @@ private fun isExpired(file: File): Boolean {
return age > TimeUnit.DAYS.toMillis(ICON_TTL_DAYS)
}
suspend fun <T> MethodChannel.awaitResult(
method: String, arguments: Any? = null
): T? = withContext(Dispatchers.Main) {
suspendCancellableCoroutine { continuation ->
invokeMethod(method, arguments, object : MethodChannel.Result {
override fun success(result: Any?) {
@Suppress("UNCHECKED_CAST") continuation.resume(result as T?)
}
override fun error(code: String, message: String?, details: Any?) {
continuation.resume(null)
}
override fun notImplemented() {
continuation.resume(null)
}
})
}
}
inline fun <reified T : FlutterPlugin> FlutterEngine.plugin(): T? {
return plugins.get(T::class.java) as T?
}
fun <T> MethodChannel.invokeMethodOnMainThread(
method: String, arguments: Any? = null, callback: ((Result<T>) -> Unit)? = null
) {
fun MethodChannel.invokeMethodOnMainThread(method: String, arguments: Any? = null) {
Handler(Looper.getMainLooper()).post {
invokeMethod(method, arguments, object : MethodChannel.Result {
override fun success(result: Any?) {
@Suppress("UNCHECKED_CAST") callback?.invoke(Result.success(result as T))
}
override fun error(errorCode: String, errorMessage: String?, errorDetails: Any?) {
val exception = Exception("MethodChannel error: $errorCode - $errorMessage")
callback?.invoke(Result.failure(exception))
}
override fun notImplemented() {
val exception = NotImplementedError("Method not implemented: $method")
callback?.invoke(Result.failure(exception))
}
})
invokeMethod(method, arguments)
}
}
@@ -4,8 +4,7 @@ import android.app.Application
import android.content.Context
import com.follow.clash.common.GlobalState
class Application : Application() {
class FlClashApplication : Application() {
override fun attachBaseContext(base: Context?) {
super.attachBaseContext(base)
GlobalState.init(this)
@@ -1,37 +1,22 @@
package com.follow.clash
import android.os.Bundle
import com.follow.clash.common.GlobalState
import com.follow.clash.plugins.AppPlugin
import com.follow.clash.plugins.ServicePlugin
import com.follow.clash.plugins.TilePlugin
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
class MainActivity : FlutterActivity(),
CoroutineScope by CoroutineScope(SupervisorJob() + Dispatchers.Default) {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
}
class MainActivity : FlutterActivity() {
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
flutterEngine.plugins.add(AppPlugin())
flutterEngine.plugins.add(ServicePlugin())
flutterEngine.plugins.add(TilePlugin())
State.flutterEngine = flutterEngine
ServiceState.attachFlutterEngine(flutterEngine)
}
override fun onDestroy() {
GlobalState.launch {
Service.setEventListener(null)
}
State.flutterEngine = null
flutterEngine?.let(ServiceState::detachFlutterEngine)
super.onDestroy()
}
}
}
@@ -0,0 +1,28 @@
package com.follow.clash
import android.app.Activity
import android.os.Bundle
import androidx.core.content.pm.ShortcutManagerCompat
import com.follow.clash.common.GlobalState
import com.follow.clash.common.QuickAction
import com.follow.clash.common.action
import kotlinx.coroutines.launch
class QuickActionActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
when (intent.action) {
QuickAction.START.action -> GlobalState.launch { ServiceState.handleStartAction() }
QuickAction.STOP.action -> GlobalState.launch { ServiceState.handleStopAction() }
QuickAction.TOGGLE.action -> {
ShortcutManagerCompat.reportShortcutUsed(this, SHORTCUT_ID)
GlobalState.launch { ServiceState.handleToggleAction() }
}
}
finish()
}
private companion object {
const val SHORTCUT_ID = "toggle"
}
}
@@ -1,187 +0,0 @@
package com.follow.clash
import com.follow.clash.common.GlobalState
import com.follow.clash.common.ServiceDelegate
import com.follow.clash.common.formatString
import com.follow.clash.common.intent
import com.follow.clash.service.IAckInterface
import com.follow.clash.service.ICallbackInterface
import com.follow.clash.service.IEventInterface
import com.follow.clash.service.IRemoteInterface
import com.follow.clash.service.IResultInterface
import com.follow.clash.service.IVoidInterface
import com.follow.clash.service.RemoteService
import com.follow.clash.service.models.NotificationParams
import com.follow.clash.service.models.VpnOptions
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
object Service {
private val delegate by lazy {
ServiceDelegate<IRemoteInterface>(
RemoteService::class.intent, ::handleServiceDisconnected
) {
IRemoteInterface.Stub.asInterface(it)
}
}
var onServiceDisconnected: ((String) -> Unit)? = null
private fun handleServiceDisconnected(message: String) {
onServiceDisconnected?.let {
it(message)
}
}
fun bind() {
delegate.bind()
}
fun unbind() {
delegate.unbind()
}
suspend fun invokeAction(data: String, cb: ((result: String) -> Unit)?): Result<Unit> {
val res = mutableListOf<ByteArray>()
return delegate.useService {
it.invokeAction(
data, object : ICallbackInterface.Stub() {
override fun onResult(
result: ByteArray?, isSuccess: Boolean, ack: IAckInterface?
) {
res.add(result ?: byteArrayOf())
ack?.onAck()
if (isSuccess) {
cb?.let { cb ->
cb(res.formatString())
}
}
}
})
}
}
suspend fun quickSetup(
initParamsString: String,
setupParamsString: String,
onStarted: (() -> Unit)?,
onResult: ((result: String) -> Unit)?,
): Result<Unit> {
val res = mutableListOf<ByteArray>()
return delegate.useService {
it.quickSetup(
initParamsString,
setupParamsString,
object : ICallbackInterface.Stub() {
override fun onResult(
result: ByteArray?, isSuccess: Boolean, ack: IAckInterface?
) {
res.add(result ?: byteArrayOf())
ack?.onAck()
if (isSuccess) {
onResult?.let { cb ->
cb(res.formatString())
}
}
}
},
object : IVoidInterface.Stub() {
override fun invoke() {
onStarted?.let { onStarted ->
onStarted()
}
}
}
)
}
}
suspend fun setEventListener(
cb: ((result: String?) -> Unit)?
): Result<Unit> {
val results = HashMap<String, MutableList<ByteArray>>()
return delegate.useService {
it.setEventListener(
when (cb != null) {
true -> object : IEventInterface.Stub() {
override fun onEvent(
id: String, data: ByteArray?, isSuccess: Boolean, ack: IAckInterface?
) {
if (results[id] == null) {
results[id] = mutableListOf()
}
results[id]?.add(data ?: byteArrayOf())
ack?.onAck()
if (isSuccess) {
cb(results[id]?.formatString())
results.remove(id)
}
}
}
false -> null
})
}
}
suspend fun updateNotificationParams(
params: NotificationParams
): Result<Unit> {
return delegate.useService {
it.updateNotificationParams(params)
}
}
suspend fun setCrashlytics(
enable: Boolean
): Result<Unit> {
return delegate.useService {
it.setCrashlytics(enable)
}
}
private suspend fun awaitIResultInterface(
block: (IResultInterface) -> Unit
): Long = suspendCancellableCoroutine { continuation ->
val callback = object : IResultInterface.Stub() {
override fun onResult(time: Long) {
if (continuation.isActive) {
continuation.resume(time)
}
}
}
try {
block(callback)
} catch (e: Exception) {
GlobalState.log("awaitIResultInterface $e")
if (continuation.isActive) {
continuation.resumeWithException(e)
}
}
}
suspend fun startService(options: VpnOptions, runTime: Long): Long {
return delegate.useService {
awaitIResultInterface { callback ->
it.startService(options, runTime, callback)
}
}.getOrNull() ?: 0L
}
suspend fun stopService(): Long {
return delegate.useService {
awaitIResultInterface { callback ->
it.stopService(callback)
}
}.getOrNull() ?: 0L
}
suspend fun getRunTime(): Long {
return delegate.useService {
it.runTime
}.getOrNull() ?: 0L
}
}
@@ -0,0 +1,58 @@
package com.follow.clash
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.os.Handler
import android.os.Looper
import com.follow.clash.common.BroadcastAction
import com.follow.clash.common.GlobalState
import com.follow.clash.common.action
import java.util.concurrent.atomic.AtomicBoolean
import kotlinx.coroutines.launch
class ServiceBroadcastReceiver : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
val action = intent?.action ?: return
val pendingResult = goAsync()
val finished = AtomicBoolean(false)
val timeout = Runnable {
if (finished.compareAndSet(false, true)) {
GlobalState.log("Broadcast handling timed out: $action")
pendingResult.finish()
}
}
mainHandler.postDelayed(timeout, BROADCAST_TIMEOUT_MILLIS)
GlobalState.launch {
try {
handleAction(action)
} catch (error: Exception) {
GlobalState.log("Unable to handle service broadcast $action: $error")
} finally {
mainHandler.removeCallbacks(timeout)
if (finished.compareAndSet(false, true)) {
pendingResult.finish()
}
}
}
}
private suspend fun handleAction(action: String) {
when (action) {
BroadcastAction.VPN_START_REQUESTED.action -> {
GlobalState.log("System requested VPN service start")
ServiceState.handleStartAction()
}
BroadcastAction.VPN_REVOKED.action -> {
GlobalState.log("VPN permission revoked")
ServiceState.handleVpnRevokeAction()
}
}
}
companion object {
private const val BROADCAST_TIMEOUT_MILLIS = 9_000L
private val mainHandler = Handler(Looper.getMainLooper())
}
}
@@ -0,0 +1,227 @@
package com.follow.clash
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import com.follow.clash.common.GlobalState
import com.follow.clash.common.intent
import com.follow.clash.core.Core
import com.follow.clash.service.ManagedService
import com.follow.clash.service.ProxyService
import com.follow.clash.service.ServiceConfig
import com.follow.clash.service.VpnService
import com.follow.clash.service.models.VpnOptions
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import kotlin.coroutines.resume
object ServiceController {
private val lock = Mutex()
private var binding: ManagedServiceBinding? = null
@Volatile
private var runTimeMillis = 0L
suspend fun unbind() = lock.withLock {
clearBinding()
}
private fun clearBinding() {
binding?.unbind()
binding = null
}
fun invokeMethod(data: String, callback: ((String) -> Unit)?): Result<Unit> = runCatching {
Core.invokeMethod(data) { result ->
callback?.invoke(result.orEmpty())
}
}
suspend fun quickSetup(
initParams: String,
setupParams: String,
): Result<String> = runCatching {
suspendCancellableCoroutine { continuation ->
Core.quickSetup(initParams, setupParams) { result ->
continuation.resume(result.orEmpty())
}
}
}
fun setEventListener(callback: ((String?) -> Unit)?): Result<Unit> = runCatching {
Core.updateEventListener(callback)
}
suspend fun start(options: VpnOptions, previousRunTimeMillis: Long): Long = lock.withLock {
ServiceConfig.updateVpnOptions(options)
val nextIntent = if (options.enable) {
VpnService::class.intent
} else {
ProxyService::class.intent
}
if (binding?.component != nextIntent.component) {
clearBinding()
lateinit var nextBinding: ManagedServiceBinding
nextBinding = ManagedServiceBinding(nextIntent) { message ->
handleServiceDisconnected(nextBinding, message)
}
binding = nextBinding
nextBinding.bind().onFailure { error ->
GlobalState.log("Unable to bind background service: $error")
clearBinding()
runTimeMillis = 0L
return@withLock runTimeMillis
}
}
val currentBinding = binding ?: return@withLock 0L
val result = currentBinding.useService { service -> service.start() }
if (result.isFailure) {
GlobalState.log("Unable to start background service: ${result.exceptionOrNull()}")
currentBinding.stopIfConnected()
.onFailure { error ->
GlobalState.log("Unable to clean up failed background service start: $error")
}
clearBinding()
runTimeMillis = 0L
return@withLock runTimeMillis
}
runTimeMillis = previousRunTimeMillis.takeIf { it != 0L }
?: System.currentTimeMillis()
runTimeMillis
}
suspend fun stop(): Long = lock.withLock {
binding?.useService { service -> service.stop() }
?.onFailure { error ->
GlobalState.log("Unable to stop background service: $error")
}
clearBinding()
runTimeMillis = 0L
runTimeMillis
}
suspend fun isVpnServiceActive(): Boolean = lock.withLock {
runTimeMillis != 0L && binding?.component == VpnService::class.intent.component
}
fun getRunTimeMillis(): Long = runTimeMillis
private fun handleServiceDisconnected(
disconnectedBinding: ManagedServiceBinding,
message: String,
) {
GlobalState.launch {
lock.withLock {
if (binding !== disconnectedBinding) {
return@withLock
}
GlobalState.log("Background service disconnected: $message")
clearBinding()
runTimeMillis = 0L
}
}
}
}
private class ManagedServiceBinding(
private val intent: Intent,
private val onDisconnected: (String) -> Unit,
) : ServiceConnection {
val component: ComponentName?
get() = intent.component
private val serviceState = MutableStateFlow<Result<ManagedService>?>(null)
@Volatile
private var isBound = false
suspend fun bind(): Result<Unit> = runCatching {
withContext(Dispatchers.Main.immediate) {
serviceState.value = null
isBound = GlobalState.application.bindService(
intent,
this@ManagedServiceBinding,
Context.BIND_AUTO_CREATE,
)
check(isBound) { "bindService() failed" }
}
}
suspend fun <R> useService(
connectionTimeoutMillis: Long = 5_000,
block: suspend (ManagedService) -> R,
): Result<R> = runCatching {
val service = withTimeout(connectionTimeoutMillis) {
serviceState.filterNotNull().first().getOrThrow()
}
withContext(Dispatchers.Default) {
block(service)
}
}
suspend fun stopIfConnected(): Result<Unit> = runCatching {
val service = serviceState.value?.getOrNull() ?: return@runCatching
withContext(Dispatchers.Default) {
service.stop()
}
}
fun unbind() {
serviceState.value = null
if (!isBound) return
isBound = false
Handler(Looper.getMainLooper()).post {
runCatching {
GlobalState.application.unbindService(this)
}.onFailure { error ->
GlobalState.log("Unable to unbind background service: $error")
}
}
}
override fun onServiceConnected(name: ComponentName?, binder: IBinder?) {
runCatching {
when (binder) {
is VpnService.LocalBinder -> binder.service
is ProxyService.LocalBinder -> binder.service
null -> error("Binder is empty")
else -> error("Unsupported service binder: ${binder.javaClass.name}")
}
}.onSuccess { service ->
serviceState.value = Result.success(service)
}.onFailure { error ->
disconnect(error.message.orEmpty())
}
}
override fun onServiceDisconnected(name: ComponentName?) {
disconnect("Service disconnected")
}
override fun onBindingDied(name: ComponentName?) {
disconnect("Service binding died")
}
override fun onNullBinding(name: ComponentName?) {
disconnect("Service returned an empty binder")
}
private fun disconnect(message: String) {
serviceState.value = Result.failure(IllegalStateException(message))
onDisconnected(message)
}
}
@@ -0,0 +1,299 @@
package com.follow.clash
import android.net.VpnService
import com.follow.clash.common.GlobalState
import com.follow.clash.models.SharedState
import com.follow.clash.plugins.AppPlugin
import com.follow.clash.plugins.TilePlugin
import com.follow.clash.service.ServiceConfig
import com.follow.clash.service.models.NotificationParams
import com.follow.clash.service.models.VpnOptions
import com.google.gson.Gson
import io.flutter.embedding.engine.FlutterEngine
import java.util.concurrent.atomic.AtomicReference
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlin.coroutines.resume
enum class RunState {
STARTED,
STARTING,
STOPPING,
STOPPED,
}
private const val MISSING_CONFIG_MESSAGE = "No configuration found."
private const val INVALID_CONFIG_MESSAGE = "Invalid configuration."
private const val VPN_PERMISSION_MESSAGE = "VPN permission required."
private const val START_FAILED_MESSAGE = "Failed to start service."
object ServiceState {
private val transitionLock = Mutex()
private val startPreparationLock = Mutex()
private val mutableRunState = MutableStateFlow(RunState.STOPPED)
private val latestRequest = AtomicReference(RunRequest(running = false))
@Volatile
private var sharedState = SharedState()
@Volatile
private var flutterEngine: FlutterEngine? = null
val runState = mutableRunState.asStateFlow()
var runTimeMillis = 0L
private set
private val appPlugin: AppPlugin?
get() = flutterEngine?.plugin<AppPlugin>()
private val tilePlugin: TilePlugin?
get() = flutterEngine?.plugin<TilePlugin>()
fun attachFlutterEngine(engine: FlutterEngine) {
flutterEngine = engine
}
fun detachFlutterEngine(engine: FlutterEngine) {
if (flutterEngine === engine) {
flutterEngine = null
}
}
suspend fun handleToggleAction() {
if (isRunningRequested()) {
handleStopAction()
} else {
handleStartAction()
}
}
suspend fun refresh() = transitionLock.withLock {
runTimeMillis = ServiceController.getRunTimeMillis()
mutableRunState.value = if (runTimeMillis == 0L) RunState.STOPPED else RunState.STARTED
}
suspend fun handleStartAction() {
if (isRunningRequested()) {
return
}
if (flutterEngine != null) {
tilePlugin?.handleStart()
return
}
loadPreferencesAndStart()
}
suspend fun handleStopAction() {
if (!isRunningRequested()) {
return
}
if (flutterEngine != null) {
tilePlugin?.handleStop()
return
}
GlobalState.application.showToast(sharedState.stopTip)
requestStop().await()
}
suspend fun handleVpnRevokeAction() {
if (!ServiceController.isVpnServiceActive()) {
return
}
handleStopAction()
}
fun requestStart(): Deferred<Boolean> {
val request = createRequest(running = true)
val result = CompletableDeferred<Boolean>()
val launchRequest: (Boolean) -> Unit = { shouldStart ->
if (!shouldStart) {
fail(request)
result.complete(false)
} else {
GlobalState.launch {
result.complete(
runCatching { start(request) }
.onFailure { error ->
GlobalState.log("Unable to process service start request: $error")
fail(request)
}
.getOrDefault(false),
)
}
}
}
appPlugin?.requestNotificationPermission(launchRequest) ?: launchRequest(true)
return result
}
fun requestStop(): Deferred<Boolean> {
val request = createRequest(running = false)
val result = CompletableDeferred<Boolean>()
GlobalState.launch {
result.complete(
runCatching { stop(request) }
.onFailure { error ->
GlobalState.log("Unable to process service stop request: $error")
}
.getOrDefault(false),
)
}
return result
}
fun syncSharedState(state: SharedState) {
sharedState = state
applySharedState()
}
private suspend fun loadPreferencesAndStart() {
sharedState = GlobalState.application.sharedState
if (sharedState.setupParams == null || sharedState.vpnOptions == null) {
GlobalState.application.showToast(MISSING_CONFIG_MESSAGE)
return
}
if (setupCore()) {
if (!requestStart().await()) {
GlobalState.application.showToast(START_FAILED_MESSAGE)
}
}
}
private fun applySharedState() {
GlobalState.setCrashlytics(sharedState.crashlytics)
ServiceConfig.updateNotificationParams(
NotificationParams(
title = sharedState.currentProfileName,
stopText = sharedState.stopText,
onlyStatisticsProxy = sharedState.onlyStatisticsProxy,
),
)
}
private suspend fun setupCore(): Boolean {
applySharedState()
GlobalState.application.showToast(sharedState.startTip)
val initParams = Gson().toJson(
mapOf(
"home-dir" to GlobalState.application.filesDir.path,
"version" to android.os.Build.VERSION.SDK_INT,
),
)
val setupParams = Gson().toJson(sharedState.setupParams)
return ServiceController.quickSetup(
initParams,
setupParams,
).fold(
onSuccess = { message ->
if (message.isEmpty()) {
true
} else {
GlobalState.log("Unable to set up core: $message")
showConfigError(message)
false
}
},
onFailure = { error ->
GlobalState.log("Unable to set up core: $error")
showConfigError(error.message)
false
},
)
}
private fun showConfigError(message: String?) {
GlobalState.application.showToast(
message?.takeIf { it.isNotBlank() } ?: INVALID_CONFIG_MESSAGE,
)
}
private suspend fun start(request: RunRequest): Boolean = startPreparationLock.withLock {
if (!isCurrent(request)) {
return@withLock false
}
val options = sharedState.vpnOptions
if (options == null) {
fail(request)
return@withLock false
}
if (!prepareVpn(options)) {
if (appPlugin == null && isCurrent(request)) {
GlobalState.application.showToast(VPN_PERMISSION_MESSAGE)
}
fail(request)
return@withLock false
}
if (!isCurrent(request)) {
return@withLock false
}
transitionLock.withLock transition@{
if (!isCurrent(request)) {
return@transition false
}
if (runState.value == RunState.STARTED && runTimeMillis != 0L) {
return@transition true
}
mutableRunState.value = RunState.STARTING
runTimeMillis = ServiceController.start(options, runTimeMillis)
mutableRunState.value =
if (runTimeMillis == 0L) RunState.STOPPED else RunState.STARTED
if (runTimeMillis == 0L) {
fail(request)
return@transition false
}
isCurrent(request)
}
}
private suspend fun stop(request: RunRequest): Boolean = transitionLock.withLock {
if (!isCurrent(request)) {
return@withLock false
}
if (runState.value == RunState.STOPPED && runTimeMillis == 0L) {
return@withLock true
}
mutableRunState.value = RunState.STOPPING
runTimeMillis = ServiceController.stop()
mutableRunState.value = RunState.STOPPED
isCurrent(request)
}
private suspend fun prepareVpn(options: VpnOptions): Boolean {
val plugin = appPlugin
?: return !options.enable || VpnService.prepare(GlobalState.application) == null
return suspendCancellableCoroutine { continuation ->
val callback: (Boolean) -> Unit = { granted ->
if (continuation.isActive) {
continuation.resume(granted)
}
}
continuation.invokeOnCancellation {
plugin.cancelVpnPreparation(callback)
}
plugin.prepareVpn(options.enable, callback)
}
}
private fun createRequest(running: Boolean): RunRequest =
RunRequest(running).also(latestRequest::set)
private fun isRunningRequested(): Boolean = latestRequest.get().running
private fun isCurrent(request: RunRequest): Boolean = latestRequest.get() === request
private fun fail(request: RunRequest) {
latestRequest.compareAndSet(request, RunRequest(running = false))
}
private class RunRequest(
val running: Boolean,
)
}
@@ -1,205 +0,0 @@
package com.follow.clash
import android.net.VpnService
import com.follow.clash.common.GlobalState
import com.follow.clash.models.SharedState
import com.follow.clash.plugins.AppPlugin
import com.follow.clash.plugins.TilePlugin
import com.follow.clash.service.models.NotificationParams
import com.google.gson.Gson
import io.flutter.embedding.engine.FlutterEngine
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
enum class RunState {
START, PENDING, STOP
}
object State {
val runLock = Mutex()
var runTime: Long = 0
var sharedState: SharedState = SharedState()
val runStateFlow: MutableStateFlow<RunState> = MutableStateFlow(RunState.STOP)
var flutterEngine: FlutterEngine? = null
val appPlugin: AppPlugin?
get() = flutterEngine?.plugin<AppPlugin>()
val tilePlugin: TilePlugin?
get() = flutterEngine?.plugin<TilePlugin>()
suspend fun handleToggleAction() {
var action: (suspend () -> Unit)?
runLock.withLock {
action = when (runStateFlow.value) {
RunState.PENDING -> null
RunState.START -> ::handleStopServiceAction
RunState.STOP -> ::handleStartServiceAction
}
}
action?.invoke()
}
suspend fun handleSyncState() {
runLock.withLock {
try {
Service.bind()
runTime = Service.getRunTime()
val runState = when (runTime == 0L) {
true -> RunState.STOP
false -> RunState.START
}
runStateFlow.tryEmit(runState)
} catch (_: Exception) {
runStateFlow.tryEmit(RunState.STOP)
}
}
}
suspend fun handleStartServiceAction() {
runLock.withLock {
if (runStateFlow.value != RunState.STOP) {
return
}
tilePlugin?.handleStart()
if (flutterEngine != null) {
return
}
startServiceWithPref()
}
}
suspend fun handleStopServiceAction() {
runLock.withLock {
if (runStateFlow.value != RunState.START) {
return
}
tilePlugin?.handleStop()
if (flutterEngine != null) {
return
}
GlobalState.application.showToast(sharedState.stopTip)
handleStopService()
}
}
fun handleStartService() {
val appPlugin = flutterEngine?.plugin<AppPlugin>()
if (appPlugin != null) {
appPlugin.requestNotificationsPermission {
startService()
}
return
}
startService()
}
private fun startServiceWithPref() {
GlobalState.launch {
runLock.withLock {
if (runStateFlow.value != RunState.STOP) {
return@launch
}
sharedState = GlobalState.application.sharedState
setupAndStart()
}
}
}
suspend fun syncState() {
GlobalState.setCrashlytics(sharedState.crashlytics)
Service.updateNotificationParams(
NotificationParams(
title = sharedState.currentProfileName,
stopText = sharedState.stopText,
onlyStatisticsProxy = sharedState.onlyStatisticsProxy
)
)
Service.setCrashlytics(sharedState.crashlytics)
}
private suspend fun setupAndStart() {
Service.bind()
syncState()
GlobalState.application.showToast(sharedState.startTip)
val initParams = mutableMapOf<String, Any>()
initParams["home-dir"] = GlobalState.application.filesDir.path
initParams["version"] = android.os.Build.VERSION.SDK_INT
val initParamsString = Gson().toJson(initParams)
val setupParamsString = Gson().toJson(sharedState.setupParams)
Service.quickSetup(
initParamsString,
setupParamsString,
onStarted = {
startService()
},
onResult = {
if (it.isNotEmpty()) {
GlobalState.application.showToast(it)
}
},
)
}
private fun startService() {
GlobalState.launch {
runLock.withLock {
if (runStateFlow.value != RunState.STOP) {
return@launch
}
try {
runStateFlow.tryEmit(RunState.PENDING)
val options = sharedState.vpnOptions ?: return@launch
appPlugin?.let {
it.prepare(options.enable) {
runTime = Service.startService(options, runTime)
runStateFlow.tryEmit(RunState.START)
}
} ?: run {
val intent = VpnService.prepare(GlobalState.application)
if (intent != null) {
return@launch
}
runTime = Service.startService(options, runTime)
runStateFlow.tryEmit(RunState.START)
}
} finally {
if (runStateFlow.value == RunState.PENDING) {
runStateFlow.tryEmit(RunState.STOP)
}
}
}
}
}
fun handleStopService() {
GlobalState.launch {
runLock.withLock {
if (runStateFlow.value != RunState.START) {
return@launch
}
try {
runStateFlow.tryEmit(RunState.PENDING)
runTime = Service.stopService()
runStateFlow.tryEmit(RunState.STOP)
} finally {
if (runStateFlow.value == RunState.PENDING) {
runStateFlow.tryEmit(RunState.START)
}
}
}
}
}
}
@@ -1,37 +0,0 @@
package com.follow.clash
import android.app.Activity
import android.os.Bundle
import com.follow.clash.common.QuickAction
import com.follow.clash.common.action
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
class TempActivity : Activity(),
CoroutineScope by CoroutineScope(SupervisorJob() + Dispatchers.Default) {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
when (intent.action) {
QuickAction.START.action -> {
launch {
State.handleStartServiceAction()
}
}
QuickAction.STOP.action -> {
launch {
State.handleStopServiceAction()
}
}
QuickAction.TOGGLE.action -> {
launch {
State.handleToggleAction()
}
}
}
finish()
}
}
@@ -3,7 +3,6 @@ package com.follow.clash
import android.annotation.SuppressLint
import android.os.Build
import android.service.quicksettings.Tile
import android.service.quicksettings.TileService
import com.follow.clash.common.QuickAction
import com.follow.clash.common.quickIntent
import com.follow.clash.common.toPendingIntent
@@ -13,49 +12,50 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
class TileService : TileService() {
class TileService : android.service.quicksettings.TileService() {
private var scope: CoroutineScope? = null
private fun updateTile(runState: RunState) {
if (qsTile != null) {
qsTile.state = when (runState) {
RunState.START -> Tile.STATE_ACTIVE
RunState.PENDING -> Tile.STATE_UNAVAILABLE
RunState.STOP -> Tile.STATE_INACTIVE
}
qsTile.updateTile()
}
}
override fun onStartListening() {
super.onStartListening()
scope?.cancel()
scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
scope?.launch {
State.handleSyncState()
State.runStateFlow.collect {
updateTile(it)
scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate).also { scope ->
scope.launch {
ServiceState.refresh()
ServiceState.runState.collect(::updateTile)
}
}
}
@SuppressLint("StartActivityAndCollapseDeprecated")
private fun handleToggle() {
val intent = QuickAction.TOGGLE.quickIntent
val pendingIntent = intent.toPendingIntent
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
startActivityAndCollapse(pendingIntent)
} else {
@Suppress("DEPRECATION") startActivityAndCollapse(intent)
}
}
override fun onClick() {
super.onClick()
handleToggle()
openQuickAction()
}
override fun onStopListening() {
scope?.cancel()
scope = null
super.onStopListening()
}
}
private fun updateTile(runState: RunState) {
qsTile?.apply {
state = when (runState) {
RunState.STARTED -> Tile.STATE_ACTIVE
RunState.STARTING, RunState.STOPPING -> Tile.STATE_UNAVAILABLE
RunState.STOPPED -> Tile.STATE_INACTIVE
}
updateTile()
}
}
@SuppressLint("StartActivityAndCollapseDeprecated")
private fun openQuickAction() {
val intent = QuickAction.TOGGLE.quickIntent
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
startActivityAndCollapse(intent.toPendingIntent)
} else {
@Suppress("DEPRECATION")
startActivityAndCollapse(intent)
}
}
}
@@ -1,6 +1,6 @@
package com.follow.clash.models
data class Package(
data class InstalledPackage(
val packageName: String,
val label: String,
val system: Boolean,
@@ -0,0 +1,174 @@
package com.follow.clash.packages
import android.Manifest
import android.content.pm.ApplicationInfo
import android.content.pm.ComponentInfo
import android.content.pm.PackageManager
import android.os.Build
import com.android.tools.smali.dexlib2.dexbacked.DexBackedDexFile
import com.follow.clash.models.InstalledPackage
import java.io.File
import java.util.zip.ZipFile
internal class PackageResolver(
private val packageManager: PackageManager,
private val appPackageName: String,
) {
val installedPackages: List<InstalledPackage> by lazy(LazyThreadSafetyMode.SYNCHRONIZED) {
loadPackages()
}
fun getChinaPackageNames(): List<String> = installedPackages
.map { it.packageName }
.filter(::isChinaPackage)
private fun loadPackages(): List<InstalledPackage> {
val flags = PackageManager.GET_PERMISSIONS
val packages = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
packageManager.getInstalledPackages(
PackageManager.PackageInfoFlags.of(flags.toLong()),
)
} else {
@Suppress("DEPRECATION")
packageManager.getInstalledPackages(flags)
}
return packages.asSequence()
.filter { info ->
info.packageName != appPackageName && info.packageName != ANDROID_PACKAGE_NAME
}
.map { info ->
InstalledPackage(
packageName = info.packageName,
label = info.applicationInfo?.loadLabel(packageManager)?.toString()
?: info.packageName,
system = info.applicationInfo?.let { applicationInfo ->
applicationInfo.flags and ApplicationInfo.FLAG_SYSTEM != 0
} == true,
internet = info.requestedPermissions
?.contains(Manifest.permission.INTERNET) == true,
lastUpdateTime = info.lastUpdateTime,
)
}.toList()
}
private fun isChinaPackage(packageName: String): Boolean {
if (SKIPPED_PREFIXES.any { packageName == it || packageName.startsWith("$it.") }) {
return false
}
if (packageName.matches(CHINA_PACKAGE_REGEX)) {
return true
}
return runCatching {
val packageInfo = getPackageInfo(packageName)
packageInfo.componentNames().any { it.matches(CHINA_PACKAGE_REGEX) } ||
packageInfo.applicationInfo?.publicSourceDir?.let(::scanArchive) == true
}.getOrDefault(false)
}
private fun getPackageInfo(packageName: String) = if (
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU
) {
packageManager.getPackageInfo(
packageName,
PackageManager.PackageInfoFlags.of(PACKAGE_INFO_FLAGS.toLong()),
)
} else {
@Suppress("DEPRECATION")
packageManager.getPackageInfo(packageName, PACKAGE_INFO_FLAGS)
}
private fun android.content.pm.PackageInfo.componentNames(): Sequence<String> = sequence {
yieldAll(services.orEmpty().asSequence().map(ComponentInfo::name))
yieldAll(activities.orEmpty().asSequence().map(ComponentInfo::name))
yieldAll(receivers.orEmpty().asSequence().map(ComponentInfo::name))
yieldAll(providers.orEmpty().asSequence().map(ComponentInfo::name))
}
private fun scanArchive(sourcePath: String): Boolean = ZipFile(File(sourcePath)).use { archive ->
if (archive.entries().asSequence().any { it.name.startsWith("firebase-") }) {
return false
}
archive.entries().asSequence()
.filter { entry ->
entry.name.startsWith("classes") && entry.name.endsWith(".dex")
}.any { entry ->
if (entry.size > MAX_DEX_SIZE_BYTES) {
return@any true
}
val dexFile = archive.getInputStream(entry).buffered().use { input ->
DexBackedDexFile.fromInputStream(null, input)
}
dexFile.classes.any { clazz ->
clazz.type
.removeSurrounding("L", ";")
.replace('/', '.')
.replace('$', '.')
.matches(CHINA_PACKAGE_REGEX)
}
}
}
private companion object {
const val ANDROID_PACKAGE_NAME = "android"
const val MAX_DEX_SIZE_BYTES = 15_000_000L
val PACKAGE_INFO_FLAGS = PackageManager.GET_ACTIVITIES or
PackageManager.GET_SERVICES or
PackageManager.GET_RECEIVERS or
PackageManager.GET_PROVIDERS
val SKIPPED_PREFIXES = listOf(
"com.google",
"com.android.chrome",
"com.android.vending",
"com.microsoft",
"com.apple",
"com.zhiliaoapp.musically",
)
val CHINA_PACKAGE_REGEX = listOf(
"com.tencent",
"com.alibaba",
"com.umeng",
"com.qihoo",
"com.ali",
"com.alipay",
"com.amap",
"com.sina",
"com.weibo",
"com.vivo",
"com.xiaomi",
"com.huawei",
"com.taobao",
"com.secneo",
"s.h.e.l.l",
"com.stub",
"com.kiwisec",
"com.secshell",
"com.wrapper",
"cn.securitystack",
"com.mogosec",
"com.secoen",
"com.netease",
"com.mx",
"com.qq.e",
"com.baidu",
"com.bytedance",
"com.bugly",
"com.miui",
"com.oppo",
"com.coloros",
"com.iqoo",
"com.meizu",
"com.gionee",
"cn.nubia",
"com.oplus",
"andes.oplus",
"com.unionpay",
"cn.wps",
).joinToString("|", prefix = "(", postfix = ").*") { prefix ->
Regex.escape(prefix)
}.toRegex()
}
}
@@ -1,11 +1,10 @@
package com.follow.clash.plugins
import android.Manifest
import android.annotation.SuppressLint
import android.app.Activity
import android.app.ActivityManager
import android.content.Intent
import android.content.pm.ApplicationInfo
import android.content.pm.ComponentInfo
import android.content.pm.PackageManager
import android.net.VpnService
import android.os.Build
@@ -18,17 +17,15 @@ import androidx.core.content.pm.ShortcutInfoCompat
import androidx.core.content.pm.ShortcutManagerCompat
import androidx.core.graphics.drawable.IconCompat
import androidx.core.net.toUri
import com.android.tools.smali.dexlib2.dexbacked.DexBackedDexFile
import com.follow.clash.R
import com.follow.clash.common.Components
import com.follow.clash.common.GlobalState
import com.follow.clash.common.QuickAction
import com.follow.clash.common.quickIntent
import com.follow.clash.getPackageIconPath
import com.follow.clash.models.Package
import com.follow.clash.packages.PackageResolver
import com.follow.clash.showToast
import com.google.gson.Gson
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.embedding.engine.plugins.activity.ActivityAware
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
@@ -37,93 +34,39 @@ import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.Result
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
import java.lang.ref.WeakReference
import java.util.zip.ZipFile
class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware {
companion object {
const val VPN_PERMISSION_REQUEST_CODE = 1001
const val NOTIFICATION_PERMISSION_REQUEST_CODE = 1002
}
private var activityRef: WeakReference<Activity>? = null
private var activity: Activity? = null
private lateinit var channel: MethodChannel
private lateinit var scope: CoroutineScope
private var vpnPrepareCallback: (suspend () -> Unit)? = null
private var vpnPrepareCallback: ((Boolean) -> Unit)? = null
private var requestNotificationCallback: (() -> Unit)? = null
private var requestNotificationCallback: ((Boolean) -> Unit)? = null
private val packages = mutableListOf<Package>()
private var isRequestingNotificationPermission = false
private val skipPrefixList = listOf(
"com.google",
"com.android.chrome",
"com.android.vending",
"com.microsoft",
"com.apple",
"com.zhiliaoapp.musically", // Banned by China
)
private val gson = Gson()
private val chinaAppPrefixList = listOf(
"com.tencent",
"com.alibaba",
"com.umeng",
"com.qihoo",
"com.ali",
"com.alipay",
"com.amap",
"com.sina",
"com.weibo",
"com.vivo",
"com.xiaomi",
"com.huawei",
"com.taobao",
"com.secneo",
"s.h.e.l.l",
"com.stub",
"com.kiwisec",
"com.secshell",
"com.wrapper",
"cn.securitystack",
"com.mogosec",
"com.secoen",
"com.netease",
"com.mx",
"com.qq.e",
"com.baidu",
"com.bytedance",
"com.bugly",
"com.miui",
"com.oppo",
"com.coloros",
"com.iqoo",
"com.meizu",
"com.gionee",
"cn.nubia",
"com.oplus",
"andes.oplus",
"com.unionpay",
"cn.wps"
)
private val chinaAppRegex by lazy {
("(" + chinaAppPrefixList.joinToString("|").replace(".", "\\.") + ").*").toRegex()
private val packageResolver by lazy {
PackageResolver(
GlobalState.application.packageManager,
GlobalState.application.packageName,
)
}
private var isBlockNotification: Boolean = false
private var skipNotificationPermissionRequest = false
override fun onMethodCall(call: MethodCall, result: Result) {
when (call.method) {
"moveTaskToBack" -> {
activityRef?.get()?.moveTaskToBack(true)
activity?.moveTaskToBack(true)
result.success(true)
}
@@ -134,19 +77,24 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
}
"initShortcuts" -> {
initShortcuts(call.arguments as String)
result.success(true)
val label = call.arguments as? String
if (label == null) {
result.error("INVALID_ARGUMENT", "Shortcut label must be a string", null)
} else {
initShortcuts(label)
result.success(true)
}
}
"getPackages" -> {
scope.launch {
result.success(getPackagesToJson())
scope.launch(Dispatchers.IO) {
result.success(gson.toJson(packageResolver.installedPackages))
}
}
"getChinaPackageNames" -> {
scope.launch {
result.success(getChinaPackageNames())
scope.launch(Dispatchers.IO) {
result.success(gson.toJson(packageResolver.getChinaPackageNames()))
}
}
@@ -156,7 +104,7 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
"tip" -> {
val message = call.argument<String>("message")
tip(message)
GlobalState.application.showToast(message)
result.success(true)
}
@@ -172,6 +120,10 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
result.success(openAppSettings())
}
"didCrashOnPreviousExecution" -> {
result.success(GlobalState.didCrashOnPreviousExecution())
}
else -> {
result.notImplemented()
}
@@ -197,32 +149,32 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
IconCompat.createWithResource(
GlobalState.application,
R.mipmap.ic_launcher_round,
)
),
)
setIntent(QuickAction.TOGGLE.quickIntent)
build()
}
ShortcutManagerCompat.setDynamicShortcuts(
GlobalState.application, listOf(shortcut)
GlobalState.application,
listOf(shortcut),
)
}
private fun tip(message: String?) {
GlobalState.application.showToast(message)
}
private fun isBatteryOptimizationDisabled(): Boolean {
val powerManager = getSystemService(GlobalState.application, PowerManager::class.java)
return powerManager?.isIgnoringBatteryOptimizations(GlobalState.application.packageName)
?: false
}
@SuppressLint("BatteryLife")
private fun openBatteryOptimizationSettings(): Boolean {
// VPN continuity is the user-requested core function, so the direct exemption is intentional.
val activity = activity ?: return false
return try {
val intent = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
data = "package:${GlobalState.application.packageName}".toUri()
}
activityRef?.get()?.startActivity(intent)
activity.startActivity(intent)
true
} catch (_: Exception) {
false
@@ -230,11 +182,12 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
}
private fun openAppSettings(): Boolean {
val activity = activity ?: return false
return try {
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = "package:${GlobalState.application.packageName}".toUri()
}
activityRef?.get()?.startActivity(intent)
activity.startActivity(intent)
true
} catch (_: Exception) {
false
@@ -246,170 +199,82 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
val am = getSystemService(GlobalState.application, ActivityManager::class.java)
val task = am?.appTasks?.firstOrNull {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
it.taskInfo.taskId == activityRef?.get()?.taskId
it.taskInfo.taskId == activity?.taskId
} else {
it.taskInfo.id == activityRef?.get()?.taskId
it.taskInfo.id == activity?.taskId
}
}
when (value) {
true -> task?.setExcludeFromRecents(value)
false -> task?.setExcludeFromRecents(value)
null -> task?.setExcludeFromRecents(false)
}
task?.setExcludeFromRecents(value ?: false)
}
private fun getPackages(): List<Package> {
val packageManager = GlobalState.application.packageManager
if (packages.isNotEmpty()) return packages
packageManager?.getInstalledPackages(PackageManager.GET_META_DATA or PackageManager.GET_PERMISSIONS)
?.filter {
it.packageName != GlobalState.application.packageName && it.packageName != "android"
}?.map {
Package(
packageName = it.packageName,
label = it.applicationInfo?.loadLabel(packageManager).toString(),
system = (it.applicationInfo?.flags?.and(ApplicationInfo.FLAG_SYSTEM)) != 0,
lastUpdateTime = it.lastUpdateTime,
internet = it.requestedPermissions?.contains(Manifest.permission.INTERNET) == true
)
}?.let { packages.addAll(it) }
return packages
}
private suspend fun getPackagesToJson(): String {
return withContext(Dispatchers.Default) {
Gson().toJson(getPackages())
}
}
private suspend fun getChinaPackageNames(): String {
return withContext(Dispatchers.Default) {
val packages: List<String> =
getPackages().map { it.packageName }.filter { isChinaPackage(it) }
Gson().toJson(packages)
}
}
fun requestNotificationsPermission(callBack: () -> Unit) {
requestNotificationCallback = callBack
fun requestNotificationPermission(callback: (Boolean) -> Unit) {
requestNotificationCallback?.invoke(false)
requestNotificationCallback = callback
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
val permission = ContextCompat.checkSelfPermission(
GlobalState.application, Manifest.permission.POST_NOTIFICATIONS
GlobalState.application,
Manifest.permission.POST_NOTIFICATIONS,
)
if (permission == PackageManager.PERMISSION_GRANTED || isBlockNotification) {
invokeRequestNotificationCallback()
if (permission == PackageManager.PERMISSION_GRANTED || skipNotificationPermissionRequest) {
invokeRequestNotificationCallback(true)
return
}
activityRef?.get()?.let {
if (isRequestingNotificationPermission) {
return
}
isRequestingNotificationPermission = true
activity?.let {
ActivityCompat.requestPermissions(
it,
arrayOf(Manifest.permission.POST_NOTIFICATIONS),
NOTIFICATION_PERMISSION_REQUEST_CODE
NOTIFICATION_PERMISSION_REQUEST_CODE,
)
}
} ?: invokeRequestNotificationCallback(true)
return
} else {
invokeRequestNotificationCallback()
}
invokeRequestNotificationCallback(true)
}
fun invokeRequestNotificationCallback() {
requestNotificationCallback?.invoke()
private fun invokeRequestNotificationCallback(shouldStart: Boolean) {
isRequestingNotificationPermission = false
requestNotificationCallback?.invoke(shouldStart)
requestNotificationCallback = null
}
fun prepare(needPrepare: Boolean, callBack: (suspend () -> Unit)) {
vpnPrepareCallback = callBack
fun prepareVpn(needPrepare: Boolean, callback: (Boolean) -> Unit) {
invokeVpnPrepareCallback(false)
vpnPrepareCallback = callback
if (!needPrepare) {
invokeVpnPrepareCallback()
invokeVpnPrepareCallback(true)
return
}
val intent = VpnService.prepare(GlobalState.application)
if (intent != null) {
activityRef?.get()?.startActivityForResult(intent, VPN_PERMISSION_REQUEST_CODE)
val activity = activity
if (activity == null) {
invokeVpnPrepareCallback(false)
} else {
@Suppress("DEPRECATION")
activity.startActivityForResult(intent, VPN_PERMISSION_REQUEST_CODE)
}
return
}
invokeVpnPrepareCallback()
invokeVpnPrepareCallback(true)
}
fun invokeVpnPrepareCallback() {
GlobalState.launch {
vpnPrepareCallback?.invoke()
fun cancelVpnPreparation(callback: (Boolean) -> Unit) {
if (vpnPrepareCallback === callback) {
vpnPrepareCallback = null
}
}
@Suppress("DEPRECATION")
private fun isChinaPackage(packageName: String): Boolean {
val packageManager = GlobalState.application.packageManager ?: return false
skipPrefixList.forEach {
if (packageName == it || packageName.startsWith("$it.")) return false
}
val packageManagerFlags =
PackageManager.MATCH_UNINSTALLED_PACKAGES or PackageManager.GET_ACTIVITIES or PackageManager.GET_SERVICES or PackageManager.GET_RECEIVERS or PackageManager.GET_PROVIDERS
if (packageName.matches(chinaAppRegex)) {
return true
}
try {
val packageInfo = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
packageManager.getPackageInfo(
packageName, PackageManager.PackageInfoFlags.of(packageManagerFlags.toLong())
)
} else {
packageManager.getPackageInfo(
packageName, packageManagerFlags
)
}
mutableListOf<ComponentInfo>().apply {
packageInfo.services?.let { addAll(it) }
packageInfo.activities?.let { addAll(it) }
packageInfo.receivers?.let { addAll(it) }
packageInfo.providers?.let { addAll(it) }
}.forEach {
if (it.name.matches(chinaAppRegex)) return true
}
packageInfo.applicationInfo?.publicSourceDir?.let {
ZipFile(File(it)).use {
for (packageEntry in it.entries()) {
if (packageEntry.name.startsWith("firebase-")) return false
}
for (packageEntry in it.entries()) {
if (!(packageEntry.name.startsWith("classes") && packageEntry.name.endsWith(
".dex"
))
) {
continue
}
if (packageEntry.size > 15000000) {
return true
}
val input = it.getInputStream(packageEntry).buffered()
val dexFile = try {
DexBackedDexFile.fromInputStream(null, input)
} catch (e: Exception) {
return false
}
for (clazz in dexFile.classes) {
val clazzName =
clazz.type.substring(1, clazz.type.length - 1).replace("/", ".")
.replace("$", ".")
if (clazzName.matches(chinaAppRegex)) return true
}
}
}
}
} catch (_: Exception) {
return false
}
return false
private fun invokeVpnPrepareCallback(granted: Boolean) {
vpnPrepareCallback?.invoke(granted)
vpnPrepareCallback = null
}
override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
scope = CoroutineScope(Dispatchers.Default)
scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
channel =
MethodChannel(flutterPluginBinding.binaryMessenger, "${Components.PACKAGE_NAME}/app")
channel.setMethodCallHandler(this)
@@ -418,43 +283,58 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
channel.setMethodCallHandler(null)
scope.cancel()
invokeVpnPrepareCallback(false)
invokeRequestNotificationCallback(false)
}
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
activityRef = WeakReference(binding.activity)
attachToActivity(binding)
}
private fun attachToActivity(binding: ActivityPluginBinding) {
activity = binding.activity
binding.addActivityResultListener(::onActivityResult)
binding.addRequestPermissionsResultListener(::onRequestPermissionsResultListener)
}
override fun onDetachedFromActivityForConfigChanges() {
activityRef = null
activity = null
}
override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
activityRef = WeakReference(binding.activity)
attachToActivity(binding)
}
override fun onDetachedFromActivity() {
channel.invokeMethod("exit", null)
activityRef = null
activity = null
invokeVpnPrepareCallback(false)
invokeRequestNotificationCallback(false)
}
private fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean {
if (requestCode == VPN_PERMISSION_REQUEST_CODE) {
if (resultCode == FlutterActivity.RESULT_OK) {
invokeVpnPrepareCallback()
}
if (requestCode != VPN_PERMISSION_REQUEST_CODE) {
return false
}
invokeVpnPrepareCallback(resultCode == Activity.RESULT_OK)
return true
}
private fun onRequestPermissionsResultListener(
requestCode: Int, permissions: Array<String>, grantResults: IntArray
requestCode: Int,
permissions: Array<String>,
grantResults: IntArray,
): Boolean {
if (requestCode == NOTIFICATION_PERMISSION_REQUEST_CODE) {
isBlockNotification = true
if (requestCode != NOTIFICATION_PERMISSION_REQUEST_CODE) {
return false
}
invokeRequestNotificationCallback()
skipNotificationPermissionRequest = true
invokeRequestNotificationCallback(true)
return true
}
private companion object {
const val VPN_PERMISSION_REQUEST_CODE = 1001
const val NOTIFICATION_PERMISSION_REQUEST_CODE = 1002
}
}
@@ -1,10 +1,8 @@
package com.follow.clash.plugins
import com.follow.clash.RunState
import com.follow.clash.Service
import com.follow.clash.State
import com.follow.clash.ServiceController
import com.follow.clash.ServiceState
import com.follow.clash.common.Components
import com.follow.clash.invokeMethodOnMainThread
import com.follow.clash.models.SharedState
import com.google.gson.Gson
import io.flutter.embedding.engine.plugins.FlutterPlugin
@@ -13,127 +11,102 @@ import io.flutter.plugin.common.MethodChannel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
class ServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler,
CoroutineScope by CoroutineScope(SupervisorJob() + Dispatchers.Default) {
private lateinit var flutterMethodChannel: MethodChannel
class ServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
private lateinit var channel: MethodChannel
private lateinit var scope: CoroutineScope
private val gson = Gson()
override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
flutterMethodChannel = MethodChannel(
flutterPluginBinding.binaryMessenger, "${Components.PACKAGE_NAME}/service"
)
flutterMethodChannel.setMethodCallHandler(this)
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
channel = MethodChannel(binding.binaryMessenger, "${Components.PACKAGE_NAME}/service")
channel.setMethodCallHandler(this)
}
override fun onDetachedFromEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
flutterMethodChannel.setMethodCallHandler(null)
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
channel.setMethodCallHandler(null)
scope.cancel()
ServiceController.setEventListener(null)
}
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) = when (call.method) {
"init" -> {
handleInit(result)
}
"shutdown" -> {
handleShutdown(result)
}
"invokeAction" -> {
handleInvokeAction(call, result)
}
"getRunTime" -> {
handleGetRunTime(result)
}
"syncState" -> {
handleSyncState(call, result)
}
"start" -> {
handleStart(result)
}
"stop" -> {
handleStop(result)
}
else -> {
result.notImplemented()
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"init" -> initialize(result)
"shutdown" -> shutdown(result)
"invokeMethod" -> invokeMethod(call, result)
"getRunTime" -> getRunTime(result)
"syncState" -> syncState(call, result)
"start" -> start(result)
"stop" -> stop(result)
else -> result.notImplemented()
}
}
private fun handleInvokeAction(call: MethodCall, result: MethodChannel.Result) {
launch {
val data = call.arguments<String>()!!
Service.invokeAction(data) {
result.success(it)
private fun initialize(result: MethodChannel.Result) {
ServiceController.setEventListener(::sendEvent)
.onSuccess { result.success("") }
.onFailure { error -> result.success(error.message.orEmpty()) }
}
private fun shutdown(result: MethodChannel.Result) {
scope.launch {
ServiceController.unbind()
result.success(true)
}
}
private fun invokeMethod(call: MethodCall, result: MethodChannel.Result) {
val data = call.arguments as? String
if (data == null) {
result.error("INVALID_ARGUMENT", "Method call payload must be a string", null)
return
}
scope.launch {
ServiceController.invokeMethod(data) { response ->
result.success(response)
}.onFailure { error ->
result.error("CORE_ERROR", error.message, null)
}
}
}
private fun handleShutdown(result: MethodChannel.Result) {
Service.unbind()
result.success(true)
}
private fun handleStart(result: MethodChannel.Result) {
State.handleStartService()
result.success(true)
}
private fun handleStop(result: MethodChannel.Result) {
State.handleStopService()
result.success(true)
}
val semaphore = Semaphore(10)
fun handleSendEvent(value: String?) {
launch(Dispatchers.Main) {
semaphore.withPermit {
flutterMethodChannel.invokeMethod("event", value)
}
private fun getRunTime(result: MethodChannel.Result) {
scope.launch {
ServiceState.refresh()
result.success(ServiceState.runTimeMillis)
}
}
private fun onServiceDisconnected(message: String) {
State.runStateFlow.tryEmit(RunState.STOP)
flutterMethodChannel.invokeMethodOnMainThread<Any>("crash", message)
}
private fun handleSyncState(call: MethodCall, result: MethodChannel.Result) {
val data = call.arguments<String>()!!
State.sharedState = Gson().fromJson(data, SharedState::class.java)
launch {
State.syncState()
private fun syncState(call: MethodCall, result: MethodChannel.Result) {
val data = call.arguments as? String
val state = runCatching {
gson.fromJson(data, SharedState::class.java)
}.getOrNull()
if (state == null) {
result.success("Invalid shared state")
return
}
scope.launch {
ServiceState.syncSharedState(state)
result.success("")
}
}
fun handleInit(result: MethodChannel.Result) {
Service.bind()
launch {
Service.setEventListener {
handleSendEvent(it)
}.onSuccess {
result.success("")
}.onFailure {
result.success(it.message)
}
}
Service.onServiceDisconnected = ::onServiceDisconnected
private fun start(result: MethodChannel.Result) {
ServiceState.requestStart()
result.success(true)
}
private fun handleGetRunTime(result: MethodChannel.Result) {
launch {
State.handleSyncState()
result.success(State.runTime)
private fun stop(result: MethodChannel.Result) {
ServiceState.requestStop()
result.success(true)
}
private fun sendEvent(value: String?) {
scope.launch(Dispatchers.Main) {
channel.invokeMethod("event", value)
}
}
}
}
@@ -7,7 +7,6 @@ import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
class TilePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
private lateinit var channel: MethodChannel
override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
@@ -21,13 +20,14 @@ class TilePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
}
fun handleStart() {
channel.invokeMethodOnMainThread<Any>("start", null)
channel.invokeMethodOnMainThread("start")
}
fun handleStop() {
channel.invokeMethodOnMainThread<Any>("stop", null)
channel.invokeMethodOnMainThread("stop")
}
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {}
}
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
result.notImplemented()
}
}
@@ -1,6 +0,0 @@
<paths>
<files-path
name="files"
path="."/>
</paths>
@@ -1,14 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config xmlns:tools="http://schemas.android.com/tools"
tools:ignore="AcceptsUserCertificates">
<base-config>
<trust-anchors>
<certificates src="system" />
<certificates src="user" />
</trust-anchors>
</base-config>
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="true">localhost</domain>
<domain includeSubdomains="true">127.0.0.1</domain>
</domain-config>
</network-security-config>
@@ -1,7 +0,0 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>
-3
View File
@@ -14,9 +14,6 @@ rootProject.layout.buildDirectory.value(newBuildDir)
subprojects {
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
project.layout.buildDirectory.value(newSubprojectBuildDir)
}
subprojects {
project.evaluationDependsOn(":app")
}
+1 -12
View File
@@ -9,24 +9,13 @@ android {
compileSdk = libs.versions.compileSdk.get().toInt()
defaultConfig {
minSdk = 21
consumerProguardFiles("consumer-rules.pro")
}
buildTypes {
release {
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
minSdk = libs.versions.minSdk.get().toInt()
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
}
kotlin {
View File
@@ -5,12 +5,12 @@ import android.content.ComponentName
object Components {
const val PACKAGE_NAME = "com.follow.clash"
val MAIN_ACTIVITY =
val mainActivity =
ComponentName(GlobalState.packageName, "${PACKAGE_NAME}.MainActivity")
val TEMP_ACTIVITY =
ComponentName(GlobalState.packageName, "${PACKAGE_NAME}.TempActivity")
val quickActionActivity =
ComponentName(GlobalState.packageName, "${PACKAGE_NAME}.QuickActionActivity")
val BROADCAST_RECEIVER =
ComponentName(GlobalState.packageName, "${PACKAGE_NAME}.BroadcastReceiver")
}
val serviceBroadcastReceiver =
ComponentName(GlobalState.packageName, "${PACKAGE_NAME}.ServiceBroadcastReceiver")
}
@@ -2,7 +2,6 @@ package com.follow.clash.common
import com.google.gson.annotations.SerializedName
enum class QuickAction {
STOP,
START,
@@ -10,8 +9,8 @@ enum class QuickAction {
}
enum class BroadcastAction {
SERVICE_CREATED,
SERVICE_DESTROYED,
VPN_START_REQUESTED,
VPN_REVOKED,
}
enum class AccessControlMode {
@@ -20,4 +19,4 @@ enum class AccessControlMode {
@SerializedName("rejectSelected")
REJECT_SELECTED,
}
}
@@ -13,55 +13,27 @@ import android.content.Context
import android.content.Context.RECEIVER_NOT_EXPORTED
import android.content.Intent
import android.content.IntentFilter
import android.content.ServiceConnection
import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE
import android.os.Build
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import android.os.RemoteException
import android.util.Log
import androidx.core.content.getSystemService
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.retryWhen
import kotlinx.coroutines.withContext
import java.nio.charset.Charset
import kotlin.reflect.KClass
//fun Context.startForegroundServiceCompat(intent: Intent?) {
// if (Build.VERSION.SDK_INT >= 26) {
// startForegroundService(intent)
// } else {
// startService(intent)
// }
//}
val KClass<*>.intent: Intent
get() = Intent(GlobalState.application, this.java)
fun Service.startForegroundCompat(id: Int, notification: Notification) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
startForeground(id, notification, FOREGROUND_SERVICE_TYPE_SPECIAL_USE)
} else {
startForeground(id, notification)
}
}
val ComponentName.intent: Intent
get() = Intent().apply {
setComponent(this@intent)
setPackage(GlobalState.packageName)
component = this@intent
}
val QuickAction.action: String
get() = "${GlobalState.application.packageName}.action.${this.name}"
val QuickAction.quickIntent: Intent
get() = Components.TEMP_ACTIVITY.intent.apply {
get() = Components.quickActionActivity.intent.apply {
action = this@quickIntent.action
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_MULTIPLE_TASK)
}
@@ -70,41 +42,31 @@ val BroadcastAction.action: String
get() = "${GlobalState.application.packageName}.intent.action.${this.name}"
val Context.processName: String?
get() {
val pid = android.os.Process.myPid()
val activityManager = getSystemService<ActivityManager>()
activityManager?.runningAppProcesses?.find { it.pid == pid }?.let {
return it.processName
}
return null
}
val BroadcastAction.quickIntent: Intent
get() = Components.BROADCAST_RECEIVER.intent.apply {
action = this@quickIntent.action
}
get() = getSystemService<ActivityManager>()
?.runningAppProcesses
?.firstOrNull { it.pid == android.os.Process.myPid() }
?.processName
fun BroadcastAction.sendBroadcast() {
val intent = Intent().apply {
action = this@sendBroadcast.action
Log.d("[sendBroadcast]", "$action")
setPackage(GlobalState.packageName)
val broadcastAction = action
val intent = Intent(broadcastAction).apply {
component = Components.serviceBroadcastReceiver
}
GlobalState.log("Send broadcast: $broadcastAction")
GlobalState.application.sendBroadcast(
intent, GlobalState.RECEIVE_BROADCASTS_PERMISSIONS
intent,
GlobalState.receiveBroadcastPermission,
)
}
val Intent.toPendingIntent: PendingIntent
get() = PendingIntent.getActivity(
GlobalState.application,
0,
this,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
fun Service.startForeground(notification: Notification) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val manager = getSystemService(NotificationManager::class.java)
@@ -112,13 +74,21 @@ fun Service.startForeground(notification: Notification) {
if (channel == null) {
channel = NotificationChannel(
GlobalState.NOTIFICATION_CHANNEL,
"SERVICE_CHANNEL",
NotificationManager.IMPORTANCE_LOW
getString(R.string.service_channel_name),
NotificationManager.IMPORTANCE_LOW,
)
manager?.createNotificationChannel(channel)
}
}
startForegroundCompat(GlobalState.NOTIFICATION_ID, notification)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
startForeground(
GlobalState.NOTIFICATION_ID,
notification,
FOREGROUND_SERVICE_TYPE_SPECIAL_USE,
)
} else {
startForeground(GlobalState.NOTIFICATION_ID, notification)
}
}
@SuppressLint("UnspecifiedRegisterReceiverFlag")
@@ -144,107 +114,3 @@ fun Context.receiveBroadcastFlow(
registerReceiverCompat(receiver, filter)
awaitClose { unregisterReceiver(receiver) }
}
inline fun <reified T : IBinder> Context.bindServiceFlow(
intent: Intent,
flags: Int = Context.BIND_AUTO_CREATE,
maxRetries: Int = 10,
retryDelayMillis: Long = 200L
): Flow<Pair<IBinder?, String>> = callbackFlow {
val connection = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName?, binder: IBinder?) {
if (binder != null) {
try {
@Suppress("UNCHECKED_CAST") val casted = binder as? T
if (casted != null) {
trySend(Pair(casted, ""))
} else {
trySend(Pair(null, "Binder is not of type ${T::class.java}"))
}
} catch (e: RemoteException) {
trySend(Pair(null, "Failed to link to death: ${e.message}"))
}
} else {
trySend(Pair(null, "Binder empty"))
}
}
override fun onServiceDisconnected(name: ComponentName?) {
trySend(Pair(null, "Service disconnected"))
}
}
val success = withContext(Dispatchers.Main) {
bindService(intent, connection, flags)
}
if (!success) {
throw IllegalStateException("bindService() failed, will retry")
}
awaitClose {
Handler(Looper.getMainLooper()).post {
unbindService(connection)
trySend(Pair(null, ""))
}
}
}.retryWhen { cause, attempt ->
if (attempt < maxRetries && cause is Exception) {
delay(retryDelayMillis)
true
} else {
false
}
}
val Long.formatBytes: String
get() {
val units = arrayOf("B", "KB", "MB", "GB", "TB")
var size = this.toDouble()
var unitIndex = 0
while (size >= 1024 && unitIndex < units.size - 1) {
size /= 1024
unitIndex++
}
return if (unitIndex == 0) {
"${size.toLong()}${units[unitIndex]}"
} else {
"%.1f${units[unitIndex]}".format(size)
}
}
fun String.chunkedForAidl(charset: Charset = Charsets.UTF_8): List<ByteArray> {
val allBytes = toByteArray(charset)
val total = allBytes.size
val maxBytes = when {
total <= 100 * 1024 -> total
total <= 1024 * 1024 -> 64 * 1024
total <= 10 * 1024 * 1024 -> 128 * 1024
else -> 256 * 1024
}
val result = mutableListOf<ByteArray>()
var index = 0
while (index < total) {
val end = minOf(index + maxBytes, total)
result.add(allBytes.copyOfRange(index, end))
index = end
}
return result
}
fun <T : List<ByteArray>> T.formatString(charset: Charset = Charsets.UTF_8): String {
val totalSize = this.sumOf { it.size }
val combined = ByteArray(totalSize)
var offset = 0
forEach { byteArray ->
byteArray.copyInto(combined, offset)
offset += byteArray.size
}
return String(combined, charset)
}
@@ -1,47 +1,47 @@
package com.follow.clash.common
import android.app.Application
import android.util.Log
import com.google.firebase.FirebaseApp
import com.google.firebase.crashlytics.FirebaseCrashlytics
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
object GlobalState : CoroutineScope by CoroutineScope(Dispatchers.Default) {
object GlobalState : CoroutineScope by CoroutineScope(SupervisorJob() + Dispatchers.Default) {
const val NOTIFICATION_CHANNEL = "FlClash"
const val NOTIFICATION_ID = 1
val packageName: String
get() = application.packageName
val RECEIVE_BROADCASTS_PERMISSIONS: String
get() = "${packageName}.permission.RECEIVE_BROADCASTS"
private var _application: Application? = null
val receiveBroadcastPermission: String
get() = "$packageName.permission.RECEIVE_BROADCASTS"
val application: Application
get() = _application!!
get() = checkNotNull(appInstance) { "GlobalState is not initialized" }
fun log(text: String) {
Log.d("[FlClash]", text)
}
@Volatile
private var appInstance: Application? = null
fun init(application: Application) {
_application = application
appInstance = application
}
fun log(text: String) {
Log.d("FlClash", text)
}
fun setCrashlytics(enable: Boolean) {
_application?.let {
FirebaseApp.initializeApp(it)
FirebaseCrashlytics.getInstance().isCrashlyticsCollectionEnabled = enable
if (enable) {
log("init crashlytics ${it.processName}")
}
FirebaseApp.initializeApp(application)
FirebaseCrashlytics.getInstance().isCrashlyticsCollectionEnabled = enable
if (enable) {
log("Crashlytics enabled for ${application.processName}")
}
}
}
fun didCrashOnPreviousExecution(): Boolean {
FirebaseApp.initializeApp(application)
return FirebaseCrashlytics.getInstance().didCrashOnPreviousExecution()
}
}
@@ -1,78 +0,0 @@
package com.follow.clash.common
import android.content.Intent
import android.os.IBinder
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import java.util.concurrent.atomic.AtomicBoolean
class ServiceDelegate<T>(
private val intent: Intent,
private val onServiceDisconnected: ((String) -> Unit)? = null,
private val interfaceCreator: (IBinder) -> T,
) : CoroutineScope by CoroutineScope(SupervisorJob() + Dispatchers.Default) {
private val _bindingState = AtomicBoolean(false)
private var _serviceState = MutableStateFlow<Pair<T?, String>?>(null)
val serviceState: StateFlow<Pair<T?, String>?> = _serviceState
private var job: Job? = null
private fun handleBind(data: Pair<IBinder?, String>) {
data.first?.let {
_serviceState.value = Pair(interfaceCreator(it), data.second)
} ?: run {
_serviceState.value = Pair(null, data.second)
unbind()
onServiceDisconnected?.invoke(data.second)
_bindingState.set(false)
}
}
fun bind() {
if (_bindingState.compareAndSet(false, true)) {
job?.cancel()
job = null
_serviceState.value = null
job = launch {
runCatching {
GlobalState.application.bindServiceFlow<IBinder>(intent)
.collect { handleBind(it) }
}
}
}
}
suspend inline fun <R> useService(
timeoutMillis: Long = 5000, crossinline block: suspend (T) -> R
): Result<R> {
return runCatching {
withTimeout(timeoutMillis) {
val state = serviceState.filterNotNull().first()
state.first?.let {
withContext(Dispatchers.Default) {
block(it)
}
} ?: throw Exception(state.second)
}
}
}
fun unbind() {
if (_bindingState.compareAndSet(true, false)) {
job?.cancel()
job = null
_serviceState.value = null
}
}
}
@@ -1,14 +0,0 @@
package com.follow.clash.common
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
fun tickerFlow(delayMillis: Long, initialDelayMillis: Long = delayMillis): Flow<Unit> = flow {
delay(initialDelayMillis)
while (true) {
emit(Unit)
delay(delayMillis)
}
}
@@ -1,4 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="FlClash">FlClash</string>
<string name="app_name">FlClash</string>
<string name="service_channel_name">FlClash Service</string>
</resources>
-16
View File
@@ -13,13 +13,6 @@ android {
minSdk = libs.versions.minSdk.get().toInt()
}
sourceSets {
getByName("main") {
jniLibs.srcDirs("src/main/jniLibs")
}
}
externalNativeBuild {
cmake {
path("src/main/cpp/CMakeLists.txt")
@@ -32,14 +25,6 @@ android {
targetCompatibility = JavaVersion.VERSION_17
}
buildTypes {
release {
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
}
kotlin {
@@ -48,7 +33,6 @@ kotlin {
}
}
dependencies {
implementation(libs.annotation.jvm)
}
+25 -11
View File
@@ -34,9 +34,9 @@ Java_com_follow_clash_core_Core_updateDNS(JNIEnv *env, jobject thiz, jstring dns
extern "C"
JNIEXPORT void JNICALL
Java_com_follow_clash_core_Core_invokeAction(JNIEnv *env, jobject thiz, jstring data, jobject cb) {
Java_com_follow_clash_core_Core_invokeMethod(JNIEnv *env, jobject thiz, jstring data, jobject cb) {
const auto interface = new_global(cb);
invokeAction(interface, get_string(data));
invokeMethod(interface, get_string(data));
}
extern "C"
@@ -54,14 +54,16 @@ extern "C"
JNIEXPORT jstring JNICALL
Java_com_follow_clash_core_Core_getTraffic(JNIEnv *env, jobject thiz,
const jboolean only_statistics_proxy) {
return new_string(getTraffic(only_statistics_proxy));
scoped_string traffic = getTraffic(only_statistics_proxy);
return new_string(traffic);
}
extern "C"
JNIEXPORT jstring JNICALL
Java_com_follow_clash_core_Core_getTotalTraffic(JNIEnv *env, jobject thiz,
const jboolean only_statistics_proxy) {
return new_string(getTotalTraffic(only_statistics_proxy));
scoped_string traffic = getTotalTraffic(only_statistics_proxy);
return new_string(traffic);
}
extern "C"
@@ -106,21 +108,31 @@ call_tun_interface_resolve_process_impl(void *tun_interface, const int protocol,
const char *target,
const int uid) {
ATTACH_JNI();
const auto packageName = reinterpret_cast<jstring>(env->CallObjectMethod(
const auto source_string = new_string(source);
const auto target_string = new_string(target);
const auto package_name = reinterpret_cast<jstring>(env->CallObjectMethod(
static_cast<jobject>(tun_interface),
m_tun_interface_resolve_process,
protocol,
new_string(source),
new_string(target),
source_string,
target_string,
uid));
return get_string(packageName);
env->DeleteLocalRef(source_string);
env->DeleteLocalRef(target_string);
const auto result = get_string(package_name);
if (package_name != nullptr) {
env->DeleteLocalRef(package_name);
}
return result;
}
static void call_invoke_interface_result_impl(void *invoke_interface, const char *data) {
ATTACH_JNI();
const auto value = new_string(data);
env->CallVoidMethod(static_cast<jobject>(invoke_interface),
m_invoke_interface_result,
new_string(data));
value);
env->DeleteLocalRef(value);
}
extern "C"
@@ -166,7 +178,7 @@ Java_com_follow_clash_core_Core_stopTun(JNIEnv *env, jobject thiz) {
extern "C"
JNIEXPORT void JNICALL
Java_com_follow_clash_core_Core_invokeAction(JNIEnv *env, jobject thiz, jstring data, jobject cb) {
Java_com_follow_clash_core_Core_invokeMethod(JNIEnv *env, jobject thiz, jstring data, jobject cb) {
}
extern "C"
@@ -188,11 +200,13 @@ extern "C"
JNIEXPORT jstring JNICALL
Java_com_follow_clash_core_Core_getTraffic(JNIEnv *env, jobject thiz,
const jboolean only_statistics_proxy) {
return env->NewStringUTF("{}");
}
extern "C"
JNIEXPORT jstring JNICALL
Java_com_follow_clash_core_Core_getTotalTraffic(JNIEnv *env, jobject thiz,
const jboolean only_statistics_proxy) {
return env->NewStringUTF("{}");
}
extern "C"
@@ -205,4 +219,4 @@ JNIEXPORT void JNICALL
Java_com_follow_clash_core_Core_quickSetup(JNIEnv *env, jobject thiz, jstring init_params_string,
jstring setup_params_string, jobject cb) {
}
#endif
#endif
+12 -22
View File
@@ -1,7 +1,6 @@
#include "jni_helper.h"
#include <cstdlib>
#include <malloc.h>
#include <cstring>
static JavaVM *global_vm;
@@ -18,15 +17,15 @@ void initialize_jni(JavaVM *vm, JNIEnv *env) {
m_get_bytes = find_method(c_string, "getBytes", "()[B");
}
JavaVM *global_java_vm() {
return global_vm;
}
char *jni_get_string(JNIEnv *env, jstring str) {
if (str == nullptr) {
return static_cast<char *>(calloc(1, 1));
}
const auto array = reinterpret_cast<jbyteArray>(env->CallObjectMethod(str, m_get_bytes));
const int length = env->GetArrayLength(array);
const auto content = static_cast<char *>(malloc(length + 1));
env->GetByteArrayRegion(array, 0, length, reinterpret_cast<jbyte *>(content));
env->DeleteLocalRef(array);
content[length] = 0;
return content;
}
@@ -35,37 +34,28 @@ jstring jni_new_string(JNIEnv *env, const char *str) {
const auto length = static_cast<int>(strlen(str));
const auto array = env->NewByteArray(length);
env->SetByteArrayRegion(array, 0, length, reinterpret_cast<const jbyte *>(str));
return reinterpret_cast<jstring>(env->NewObject(c_string, m_new_string, array));
}
int jni_catch_exception(JNIEnv *env) {
const int result = env->ExceptionCheck();
if (result) {
env->ExceptionDescribe();
env->ExceptionClear();
}
const auto result = reinterpret_cast<jstring>(env->NewObject(c_string, m_new_string, array));
env->DeleteLocalRef(array);
return result;
}
void jni_attach_thread(scoped_jni *jni) {
JavaVM *vm = global_java_vm();
if (vm->GetEnv(reinterpret_cast<void **>(&jni->env), JNI_VERSION_1_6) == JNI_OK) {
if (global_vm->GetEnv(reinterpret_cast<void **>(&jni->env), JNI_VERSION_1_6) == JNI_OK) {
jni->require_release = 0;
return;
}
if (vm->AttachCurrentThread(&jni->env, nullptr) != JNI_OK) {
if (global_vm->AttachCurrentThread(&jni->env, nullptr) != JNI_OK) {
abort();
}
jni->require_release = 1;
}
void jni_detach_thread(const scoped_jni *env) {
JavaVM *vm = global_java_vm();
if (env->require_release) {
vm->DetachCurrentThread();
global_vm->DetachCurrentThread();
}
}
void release_string(char **str) {
free(*str);
}
void release_string(char **value) {
free(*value);
}
+1 -3
View File
@@ -13,13 +13,11 @@ extern jstring jni_new_string(JNIEnv *env, const char *str);
extern char *jni_get_string(JNIEnv *env, jstring str);
extern int jni_catch_exception(JNIEnv *env);
extern void jni_attach_thread(scoped_jni *jni);
extern void jni_detach_thread(const scoped_jni *env);
extern void release_string( char **str);
extern void release_string(char **value);
#define ATTACH_JNI() __attribute__((unused, cleanup(jni_detach_thread))) \
scoped_jni _jni{}; \
@@ -2,9 +2,9 @@ package com.follow.clash.core
import java.net.InetAddress
import java.net.InetSocketAddress
import java.net.URL
import java.net.URI
data object Core {
object Core {
private external fun startTun(
fd: Int,
cb: TunInterface,
@@ -13,17 +13,17 @@ data object Core {
dns: String,
)
external fun forceGC(
)
external fun forceGC()
external fun updateDNS(
dns: String,
)
private fun parseInetSocketAddress(address: String): InetSocketAddress {
val url = URL("https://$address")
return InetSocketAddress(InetAddress.getByName(url.host), url.port)
val uri = URI("tcp://$address")
val host = requireNotNull(uri.host) { "Missing host in address: $address" }
require(uri.port >= 0) { "Missing port in address: $address" }
return InetSocketAddress(InetAddress.getByName(host), uri.port)
}
fun startTun(
@@ -45,7 +45,7 @@ data object Core {
protocol: Int,
source: String,
target: String,
uid: Int
uid: Int,
): String {
return resolverProcess(
protocol,
@@ -57,7 +57,7 @@ data object Core {
},
stack,
address,
dns
dns,
)
}
@@ -65,16 +65,16 @@ data object Core {
suspended: Boolean,
)
private external fun invokeAction(
private external fun invokeMethod(
data: String,
cb: InvokeInterface
cb: InvokeInterface,
)
fun invokeAction(
fun invokeMethod(
data: String,
cb: (result: String?) -> Unit
cb: (result: String?) -> Unit,
) {
invokeAction(
invokeMethod(
data,
object : InvokeInterface {
override fun onResult(result: String?) {
@@ -86,33 +86,33 @@ data object Core {
private external fun setEventListener(cb: InvokeInterface?)
fun callSetEventListener(
cb: ((result: String?) -> Unit)?
fun updateEventListener(
callback: ((result: String?) -> Unit)?,
) {
when (cb != null) {
true -> setEventListener(
if (callback == null) {
setEventListener(null)
} else {
setEventListener(
object : InvokeInterface {
override fun onResult(result: String?) {
cb(result)
callback(result)
}
},
)
false -> setEventListener(null)
}
}
fun quickSetup(
initParamsString: String,
setupParamsString: String,
cb: (result: String?) -> Unit,
callback: (result: String?) -> Unit,
) {
quickSetup(
initParamsString,
setupParamsString,
object : InvokeInterface {
override fun onResult(result: String?) {
cb(result)
callback(result)
}
},
)
@@ -121,7 +121,7 @@ data object Core {
private external fun quickSetup(
initParamsString: String,
setupParamsString: String,
cb: InvokeInterface
cb: InvokeInterface,
)
external fun stopTun()
@@ -133,4 +133,4 @@ data object Core {
init {
System.loadLibrary("core")
}
}
}
@@ -5,5 +5,6 @@ import androidx.annotation.Keep
@Keep
interface TunInterface {
fun protect(fd: Int)
fun resolverProcess(protocol: Int, source: String, target: String, uid: Int): String
}
}
-1
View File
@@ -1,6 +1,5 @@
org.gradle.jvmargs=-Xmx4G
android.useAndroidX=true
android.enableJetifier=true
# This builtInKotlin flag was added automatically by Flutter migrator
android.builtInKotlin=false
# This newDsl flag was added automatically by Flutter migrator
-6
View File
@@ -1,5 +1,4 @@
[versions]
#agp = "8.10.1"
firebaseBom = "34.15.0"
minSdk = "23"
targetSdk = "36"
@@ -9,10 +8,7 @@ coreKtx = "1.17.0"
annotationJvm = "1.9.1"
coreSplashscreen = "1.0.1"
gson = "2.13.1"
kotlin = "2.2.20"
smaliDexlib2 = "3.0.9"
firebaseCrashlyticsKtx = "20.0.6"
firebaseCommonKtx = "22.1.0"
[libraries]
androidx-core = { module = "androidx.core:core-ktx", version.ref = "coreKtx" }
@@ -23,5 +19,3 @@ firebase-bom = { module = "com.google.firebase:firebase-bom", version.ref = "fir
firebase-crashlytics-ndk = { module = "com.google.firebase:firebase-crashlytics-ndk" }
gson = { module = "com.google.code.gson:gson", version.ref = "gson" }
smali-dexlib2 = { module = "com.android.tools.smali:smali-dexlib2", version.ref = "smaliDexlib2" }
firebase-crashlytics-ktx = { group = "com.google.firebase", name = "firebase-crashlytics-ktx", version.ref = "firebaseCrashlyticsKtx" }
firebase-common-ktx = { group = "com.google.firebase", name = "firebase-common-ktx", version.ref = "firebaseCommonKtx" }
+1 -14
View File
@@ -6,29 +6,17 @@ plugins {
android {
namespace = "com.follow.clash.service"
compileSdk = 36
compileSdk = libs.versions.compileSdk.get().toInt()
defaultConfig {
minSdk = libs.versions.minSdk.get().toInt()
}
buildFeatures {
aidl = true
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
buildTypes {
release {
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
}
kotlin {
@@ -37,7 +25,6 @@ kotlin {
}
}
dependencies {
implementation(project(":core"))
implementation(project(":common"))
+5 -14
View File
@@ -8,8 +8,7 @@
android:name=".VpnService"
android:exported="false"
android:foregroundServiceType="specialUse"
android:permission="android.permission.BIND_VPN_SERVICE"
android:process=":remote">
android:permission="android.permission.BIND_VPN_SERVICE">
<intent-filter>
<action android:name="android.net.VpnService" />
</intent-filter>
@@ -19,31 +18,23 @@
</service>
<service
android:name=".CommonService"
android:name=".ProxyService"
android:exported="false"
android:foregroundServiceType="specialUse"
android:process=":remote">
android:foregroundServiceType="specialUse">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="proxy" />
</service>
<service
android:name=".RemoteService"
android:enabled="true"
android:exported="false"
android:process=":remote" />
<provider
android:name=".FilesProvider"
android:authorities="${applicationId}.files"
android:exported="true"
android:grantUriPermissions="true"
android:permission="android.permission.MANAGE_DOCUMENTS"
android:process=":remote">
android:permission="android.permission.MANAGE_DOCUMENTS">
<intent-filter>
<action android:name="android.content.action.DOCUMENTS_PROVIDER" />
</intent-filter>
</provider>
</application>
</manifest>
</manifest>
@@ -1,8 +0,0 @@
// IAckInterface.aidl
package com.follow.clash.service;
import com.follow.clash.service.IAckInterface;
interface IAckInterface {
oneway void onAck();
}
@@ -1,8 +0,0 @@
// ICallbackInterface.aidl
package com.follow.clash.service;
import com.follow.clash.service.IAckInterface;
interface ICallbackInterface {
oneway void onResult(in byte[] data,in boolean isSuccess, in IAckInterface ack);
}
@@ -1,8 +0,0 @@
// IEventInterface.aidl
package com.follow.clash.service;
import com.follow.clash.service.IAckInterface;
interface IEventInterface {
oneway void onEvent(in String id, in byte[] data,in boolean isSuccess, in IAckInterface ack);
}
@@ -1,20 +0,0 @@
// IRemoteInterface.aidl
package com.follow.clash.service;
import com.follow.clash.service.ICallbackInterface;
import com.follow.clash.service.IEventInterface;
import com.follow.clash.service.IResultInterface;
import com.follow.clash.service.IVoidInterface;
import com.follow.clash.service.models.VpnOptions;
import com.follow.clash.service.models.NotificationParams;
interface IRemoteInterface {
void invokeAction(in String data, in ICallbackInterface callback);
void quickSetup(in String initParamsString, in String setupParamsString, in ICallbackInterface callback, in IVoidInterface onStarted);
void updateNotificationParams(in NotificationParams params);
void startService(in VpnOptions options, in long runTime, in IResultInterface result);
void stopService(in IResultInterface result);
void setEventListener(in IEventInterface event);
void setCrashlytics(in boolean enable);
long getRunTime();
}
@@ -1,6 +0,0 @@
// IResultInterface.aidl
package com.follow.clash.service;
interface IResultInterface {
oneway void onResult(in long runTime);
}
@@ -1,6 +0,0 @@
// IVoidInterface.aidl
package com.follow.clash.service;
interface IVoidInterface {
oneway void invoke();
}
@@ -1,4 +0,0 @@
//AccessControl.aidl
package com.follow.clash.service.models;
parcelable AccessControl;
@@ -1,4 +0,0 @@
//NotificationParams.aidl
package com.follow.clash.service.models;
parcelable NotificationParams;
@@ -1,6 +0,0 @@
//VpnOptions.aidl
package com.follow.clash.service.models;
import com.follow.clash.service.models.AccessControl;
parcelable VpnOptions;
@@ -1,64 +0,0 @@
package com.follow.clash.service
import android.app.Service
import android.content.Intent
import android.os.Binder
import android.os.IBinder
import com.follow.clash.core.Core
import com.follow.clash.service.modules.NetworkObserveModule
import com.follow.clash.service.modules.NotificationModule
import com.follow.clash.service.modules.SuspendModule
import com.follow.clash.service.modules.moduleLoader
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
class CommonService : Service(), IBaseService,
CoroutineScope by CoroutineScope(Dispatchers.Default) {
private val self: CommonService
get() = this
private val loader = moduleLoader {
install(NetworkObserveModule(self))
install(NotificationModule(self))
install(SuspendModule(self))
}
override fun onCreate() {
super.onCreate()
handleCreate()
}
override fun onDestroy() {
handleDestroy()
super.onDestroy()
}
override fun onLowMemory() {
Core.forceGC()
super.onLowMemory()
}
private val binder = LocalBinder()
inner class LocalBinder : Binder() {
fun getService(): CommonService = this@CommonService
}
override fun onBind(intent: Intent): IBinder {
return binder
}
override fun start() {
try {
loader.load()
} catch (_: Exception) {
stop()
}
}
override fun stop() {
loader.cancel()
stopSelf()
}
}
@@ -6,60 +6,34 @@ import android.os.CancellationSignal
import android.os.ParcelFileDescriptor
import android.provider.DocumentsContract
import android.provider.DocumentsProvider
import com.follow.clash.common.R as CommonR
import java.io.File
import java.io.FileNotFoundException
import java.io.IOException
class FilesProvider : DocumentsProvider() {
override fun onCreate() = true
companion object {
private const val DEFAULT_ROOT_ID = "0"
private val DEFAULT_DOCUMENT_COLUMNS = arrayOf(
DocumentsContract.Document.COLUMN_DOCUMENT_ID,
DocumentsContract.Document.COLUMN_DISPLAY_NAME,
DocumentsContract.Document.COLUMN_MIME_TYPE,
DocumentsContract.Document.COLUMN_FLAGS,
DocumentsContract.Document.COLUMN_SIZE,
)
private val DEFAULT_ROOT_COLUMNS = arrayOf(
DocumentsContract.Root.COLUMN_ROOT_ID,
DocumentsContract.Root.COLUMN_FLAGS,
DocumentsContract.Root.COLUMN_ICON,
DocumentsContract.Root.COLUMN_TITLE,
DocumentsContract.Root.COLUMN_SUMMARY,
DocumentsContract.Root.COLUMN_DOCUMENT_ID
)
}
override fun onCreate(): Boolean {
return true
}
override fun queryRoots(projection: Array<String>?): Cursor {
return MatrixCursor(projection ?: DEFAULT_ROOT_COLUMNS).apply {
newRow().apply {
add(DocumentsContract.Root.COLUMN_ROOT_ID, DEFAULT_ROOT_ID)
add(DocumentsContract.Root.COLUMN_FLAGS, DocumentsContract.Root.FLAG_LOCAL_ONLY)
add(DocumentsContract.Root.COLUMN_ICON, R.drawable.ic_service)
add(DocumentsContract.Root.COLUMN_TITLE, "FlClash")
add(DocumentsContract.Root.COLUMN_SUMMARY, "Data")
add(DocumentsContract.Root.COLUMN_DOCUMENT_ID, "/")
}
override fun queryRoots(projection: Array<String>?): Cursor =
MatrixCursor(projection ?: DEFAULT_ROOT_COLUMNS).apply {
newRow()
.add(DocumentsContract.Root.COLUMN_ROOT_ID, DEFAULT_ROOT_ID)
.add(DocumentsContract.Root.COLUMN_FLAGS, DocumentsContract.Root.FLAG_LOCAL_ONLY)
.add(DocumentsContract.Root.COLUMN_ICON, R.drawable.ic_service)
.add(
DocumentsContract.Root.COLUMN_TITLE,
context?.getString(CommonR.string.app_name).orEmpty(),
)
.add(DocumentsContract.Root.COLUMN_DOCUMENT_ID, ROOT_DOCUMENT_ID)
}
}
override fun queryChildDocuments(
parentDocumentId: String,
projection: Array<String>?,
sortOrder: String?
sortOrder: String?,
): Cursor {
val result = MatrixCursor(resolveDocumentProjection(projection))
val parentFile = if (parentDocumentId == "/") {
context?.filesDir
} else {
File(parentDocumentId)
} ?: throw FileNotFoundException("Parent directory not found")
val result = MatrixCursor(projection ?: DEFAULT_DOCUMENT_COLUMNS)
val parentFile = resolveFile(parentDocumentId)
parentFile.listFiles()?.forEach { file ->
includeFile(result, file)
}
@@ -67,20 +41,18 @@ class FilesProvider : DocumentsProvider() {
}
override fun queryDocument(documentId: String, projection: Array<String>?): Cursor {
val result = MatrixCursor(resolveDocumentProjection(projection))
val file = File(documentId)
includeFile(result, file)
val result = MatrixCursor(projection ?: DEFAULT_DOCUMENT_COLUMNS)
includeFile(result, resolveFile(documentId))
return result
}
override fun openDocument(
documentId: String,
mode: String,
signal: CancellationSignal?
signal: CancellationSignal?,
): ParcelFileDescriptor {
val file = File(documentId)
val accessMode = ParcelFileDescriptor.parseMode(mode)
return ParcelFileDescriptor.open(file, accessMode)
return ParcelFileDescriptor.open(resolveFile(documentId), accessMode)
}
private fun includeFile(result: MatrixCursor, file: File) {
@@ -88,23 +60,54 @@ class FilesProvider : DocumentsProvider() {
add(DocumentsContract.Document.COLUMN_DOCUMENT_ID, file.absolutePath)
add(DocumentsContract.Document.COLUMN_DISPLAY_NAME, file.name)
add(DocumentsContract.Document.COLUMN_SIZE, file.length())
add(
DocumentsContract.Document.COLUMN_FLAGS,
DocumentsContract.Document.FLAG_SUPPORTS_WRITE or DocumentsContract.Document.FLAG_SUPPORTS_DELETE
)
add(DocumentsContract.Document.COLUMN_MIME_TYPE, getDocumentType(file))
val flags = if (file.isFile) {
DocumentsContract.Document.FLAG_SUPPORTS_WRITE
} else {
0
}
val mimeType = if (file.isDirectory) {
DocumentsContract.Document.MIME_TYPE_DIR
} else {
"application/octet-stream"
}
add(DocumentsContract.Document.COLUMN_FLAGS, flags)
add(DocumentsContract.Document.COLUMN_MIME_TYPE, mimeType)
}
}
private fun getDocumentType(file: File): String {
return if (file.isDirectory) {
DocumentsContract.Document.MIME_TYPE_DIR
} else {
"application/octet-stream"
private fun resolveFile(documentId: String): File {
val root = context?.filesDir?.canonicalFile
?: throw FileNotFoundException("App files directory is unavailable")
val file = try {
if (documentId == ROOT_DOCUMENT_ID) root else File(documentId).canonicalFile
} catch (error: IOException) {
throw FileNotFoundException(error.message).apply { initCause(error) }
}
val isInsideRoot = file == root || file.path.startsWith("${root.path}${File.separator}")
if (!isInsideRoot) {
throw FileNotFoundException("Document is outside the app files directory")
}
return file
}
private fun resolveDocumentProjection(projection: Array<String>?): Array<String> {
return projection ?: DEFAULT_DOCUMENT_COLUMNS
private companion object {
const val DEFAULT_ROOT_ID = "0"
const val ROOT_DOCUMENT_ID = "/"
val DEFAULT_DOCUMENT_COLUMNS = arrayOf(
DocumentsContract.Document.COLUMN_DOCUMENT_ID,
DocumentsContract.Document.COLUMN_DISPLAY_NAME,
DocumentsContract.Document.COLUMN_MIME_TYPE,
DocumentsContract.Document.COLUMN_FLAGS,
DocumentsContract.Document.COLUMN_SIZE,
)
val DEFAULT_ROOT_COLUMNS = arrayOf(
DocumentsContract.Root.COLUMN_ROOT_ID,
DocumentsContract.Root.COLUMN_FLAGS,
DocumentsContract.Root.COLUMN_ICON,
DocumentsContract.Root.COLUMN_TITLE,
DocumentsContract.Root.COLUMN_SUMMARY,
DocumentsContract.Root.COLUMN_DOCUMENT_ID,
)
}
}
}
@@ -1,21 +0,0 @@
package com.follow.clash.service
import com.follow.clash.common.BroadcastAction
import com.follow.clash.common.GlobalState
import com.follow.clash.common.sendBroadcast
interface IBaseService {
fun handleCreate() {
GlobalState.log("Service create")
BroadcastAction.SERVICE_CREATED.sendBroadcast()
}
fun handleDestroy() {
GlobalState.log("Service destroy")
BroadcastAction.SERVICE_DESTROYED.sendBroadcast()
}
fun start()
fun stop()
}
@@ -0,0 +1,22 @@
package com.follow.clash.service
import android.app.Service
import com.follow.clash.common.BroadcastAction
import com.follow.clash.common.GlobalState
import com.follow.clash.common.sendBroadcast
interface ManagedService {
fun start()
fun stop()
}
internal fun Service.notifyVpnStartRequested() {
GlobalState.log("VPN start requested")
BroadcastAction.VPN_START_REQUESTED.sendBroadcast()
}
internal fun Service.notifyVpnRevoked() {
GlobalState.log("VPN permission revoked")
BroadcastAction.VPN_REVOKED.sendBroadcast()
}
@@ -0,0 +1,52 @@
package com.follow.clash.service
import android.app.Service
import android.content.Intent
import android.os.Binder
import android.os.IBinder
import com.follow.clash.core.Core
import com.follow.clash.service.modules.ServiceModules
class ProxyService : Service(), ManagedService {
private val modules = ServiceModules(this)
private val binder = LocalBinder()
override fun onDestroy() {
try {
cleanup()
} finally {
super.onDestroy()
}
}
override fun onLowMemory() {
Core.forceGC()
super.onLowMemory()
}
inner class LocalBinder : Binder() {
val service: ProxyService
get() = this@ProxyService
}
override fun onBind(intent: Intent): IBinder = binder
override fun start() {
try {
modules.start()
} catch (error: Exception) {
stop()
throw error
}
}
override fun stop() {
try {
cleanup()
} finally {
stopSelf()
}
}
private fun cleanup() = modules.stop()
}
@@ -1,196 +0,0 @@
package com.follow.clash.service
import android.app.Service
import android.content.Intent
import android.os.IBinder
import com.follow.clash.common.GlobalState
import com.follow.clash.common.ServiceDelegate
import com.follow.clash.common.chunkedForAidl
import com.follow.clash.common.intent
import com.follow.clash.core.Core
import com.follow.clash.service.State.delegate
import com.follow.clash.service.State.intent
import com.follow.clash.service.State.runLock
import com.follow.clash.service.models.NotificationParams
import com.follow.clash.service.models.VpnOptions
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.sync.withLock
import java.util.UUID
import kotlin.coroutines.resume
class RemoteService : Service(),
CoroutineScope by CoroutineScope(SupervisorJob() + Dispatchers.Default) {
private fun handleStopService(result: IResultInterface) {
launch {
runLock.withLock {
delegate?.useService { service ->
service.stop()
delegate?.unbind()
}
State.runTime = 0
result.onResult(0)
}
}
}
private fun handleServiceDisconnected(message: String) {
GlobalState.log("Background service disconnected: $message")
intent = null
delegate = null
}
private fun handleStartService(runTime: Long, result: IResultInterface) {
launch {
runLock.withLock {
val nextIntent = when (State.options?.enable == true) {
true -> VpnService::class.intent
false -> CommonService::class.intent
}
if (intent != nextIntent) {
delegate?.unbind()
delegate = ServiceDelegate(nextIntent, ::handleServiceDisconnected) { binder ->
when (binder) {
is VpnService.LocalBinder -> binder.getService()
is CommonService.LocalBinder -> binder.getService()
else -> throw IllegalArgumentException("Invalid binder type")
}
}
intent = nextIntent
delegate?.bind()
}
delegate?.useService { service ->
service.start()
}
State.runTime = when (runTime != 0L) {
true -> runTime
false -> System.currentTimeMillis()
}
result.onResult(State.runTime)
}
}
}
private val binder = object : IRemoteInterface.Stub() {
override fun invokeAction(data: String, callback: ICallbackInterface) {
Core.invokeAction(data) {
launch {
runCatching {
val chunks = it?.chunkedForAidl() ?: listOf()
for ((index, chunk) in chunks.withIndex()) {
suspendCancellableCoroutine { cont ->
callback.onResult(
chunk,
index == chunks.lastIndex,
object : IAckInterface.Stub() {
override fun onAck() {
cont.resume(Unit)
}
},
)
}
}
}
}
}
}
override fun quickSetup(
initParamsString: String,
setupParamsString: String,
callback: ICallbackInterface,
onStarted: IVoidInterface
) {
Core.quickSetup(initParamsString, setupParamsString) {
launch {
runCatching {
val chunks = it?.chunkedForAidl() ?: listOf()
for ((index, chunk) in chunks.withIndex()) {
suspendCancellableCoroutine { cont ->
callback.onResult(
chunk,
index == chunks.lastIndex,
object : IAckInterface.Stub() {
override fun onAck() {
cont.resume(Unit)
}
},
)
}
}
}
}
}
onStarted()
}
override fun updateNotificationParams(params: NotificationParams?) {
State.notificationParamsFlow.tryEmit(params)
}
override fun startService(
options: VpnOptions,
runtime: Long,
result: IResultInterface,
) {
GlobalState.log("remote startService")
State.options = options
handleStartService(runtime, result)
}
override fun stopService(result: IResultInterface) {
handleStopService(result)
}
override fun setEventListener(eventListener: IEventInterface?) {
GlobalState.log("RemoveEventListener ${eventListener == null}")
when (eventListener != null) {
true -> Core.callSetEventListener {
launch {
runCatching {
val id = UUID.randomUUID().toString()
val chunks = it?.chunkedForAidl() ?: listOf()
for ((index, chunk) in chunks.withIndex()) {
suspendCancellableCoroutine { cont ->
eventListener.onEvent(
id,
chunk,
index == chunks.lastIndex,
object : IAckInterface.Stub() {
override fun onAck() {
cont.resume(Unit)
}
},
)
}
}
}
}
}
false -> Core.callSetEventListener(null)
}
}
override fun setCrashlytics(enable: Boolean) {
GlobalState.setCrashlytics(enable)
}
override fun getRunTime(): Long {
return State.runTime
}
}
override fun onBind(intent: Intent?): IBinder {
return binder
}
override fun onDestroy() {
GlobalState.log("Remote service destroy")
super.onDestroy()
}
}
@@ -0,0 +1,25 @@
package com.follow.clash.service
import com.follow.clash.service.models.NotificationParams
import com.follow.clash.service.models.VpnOptions
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
object ServiceConfig {
@Volatile
private var currentVpnOptions: VpnOptions? = null
private val mutableNotificationParams = MutableStateFlow(NotificationParams())
val vpnOptions: VpnOptions?
get() = currentVpnOptions
val notificationParams = mutableNotificationParams.asStateFlow()
fun updateVpnOptions(options: VpnOptions) {
currentVpnOptions = options
}
fun updateNotificationParams(params: NotificationParams) {
mutableNotificationParams.value = params
}
}
@@ -1,22 +0,0 @@
package com.follow.clash.service
import android.content.Intent
import com.follow.clash.common.ServiceDelegate
import com.follow.clash.service.models.NotificationParams
import com.follow.clash.service.models.VpnOptions
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.sync.Mutex
object State {
var options: VpnOptions? = null
var notificationParamsFlow: MutableStateFlow<NotificationParams?> = MutableStateFlow(
NotificationParams()
)
val runLock = Mutex()
var runTime: Long = 0L
var delegate: ServiceDelegate<IBaseService>? = null
var intent: Intent? = null
}
@@ -6,52 +6,40 @@ import android.net.ProxyInfo
import android.os.Binder
import android.os.Build
import android.os.IBinder
import android.os.Parcel
import android.os.RemoteException
import android.util.Log
import androidx.core.content.getSystemService
import com.follow.clash.common.AccessControlMode
import com.follow.clash.common.GlobalState
import com.follow.clash.common.R as CommonR
import com.follow.clash.core.Core
import com.follow.clash.service.models.CIDR
import com.follow.clash.service.models.VpnOptions
import com.follow.clash.service.models.getIpv4RouteAddress
import com.follow.clash.service.models.getIpv6RouteAddress
import com.follow.clash.service.models.toCIDR
import com.follow.clash.service.modules.NetworkObserveModule
import com.follow.clash.service.modules.NotificationModule
import com.follow.clash.service.modules.SuspendModule
import com.follow.clash.service.modules.moduleLoader
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import com.follow.clash.service.modules.ServiceModules
import java.net.InetSocketAddress
import java.util.concurrent.ConcurrentHashMap
import android.net.VpnService as SystemVpnService
class VpnService : SystemVpnService(), IBaseService,
CoroutineScope by CoroutineScope(Dispatchers.Default) {
private val self: VpnService
get() = this
private val loader = moduleLoader {
install(NetworkObserveModule(self))
install(NotificationModule(self))
install(SuspendModule(self))
}
override fun onCreate() {
super.onCreate()
handleCreate()
}
class VpnService : SystemVpnService(), ManagedService {
private val modules = ServiceModules(this)
private val binder = LocalBinder()
private val tunLock = Any()
private var tunRunning = false
override fun onDestroy() {
handleDestroy()
super.onDestroy()
try {
cleanup()
} finally {
super.onDestroy()
}
}
private val connectivity by lazy {
getSystemService<ConnectivityManager>()
}
private val uidPageNameMap = mutableMapOf<Int, String>()
private val uidPackageNameMap = ConcurrentHashMap<Int, String>()
private fun resolverProcess(
protocol: Int,
@@ -67,13 +55,15 @@ class VpnService : SystemVpnService(), IBaseService,
if (nextUid == -1) {
return ""
}
if (!uidPageNameMap.containsKey(nextUid)) {
uidPageNameMap[nextUid] = this.packageManager?.getPackagesForUid(nextUid)?.first() ?: ""
return uidPackageNameMap.getOrPut(nextUid) {
packageManager
.getPackagesForUid(nextUid)
?.firstOrNull()
.orEmpty()
}
return uidPageNameMap[nextUid] ?: ""
}
val VpnOptions.address
private val VpnOptions.tunAddress
get(): String = buildString {
append(IPV4_ADDRESS)
if (ipv6) {
@@ -82,7 +72,7 @@ class VpnService : SystemVpnService(), IBaseService,
}
}
val VpnOptions.dns
private val VpnOptions.tunDns
get(): String {
if (dnsHijacking) {
return NET_ANY
@@ -96,158 +86,180 @@ class VpnService : SystemVpnService(), IBaseService,
}
}
override fun onLowMemory() {
Core.forceGC()
super.onLowMemory()
}
private val binder = LocalBinder()
inner class LocalBinder : Binder() {
fun getService(): VpnService = this@VpnService
override fun onTransact(code: Int, data: Parcel, reply: Parcel?, flags: Int): Boolean {
try {
val isSuccess = super.onTransact(code, data, reply, flags)
if (!isSuccess) {
GlobalState.log("VpnService disconnected")
handleDestroy()
}
return isSuccess
} catch (e: RemoteException) {
GlobalState.log("VpnService onTransact $e")
return false
}
}
val service: VpnService
get() = this@VpnService
}
override fun onBind(intent: Intent): IBinder {
return binder
override fun onBind(intent: Intent): IBinder? =
if (intent.action == SystemVpnService.SERVICE_INTERFACE) {
super.onBind(intent)
} else {
binder
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
// Android starts always-on VPN through this callback instead of FlClash's bound-service
// path. Notify the app layer so it can restore Core and fully initialize the VPN service.
notifyVpnStartRequested()
return super.onStartCommand(intent, flags, startId)
}
override fun onRevoke() {
stop()
notifyVpnRevoked()
}
private fun handleStart(options: VpnOptions) {
val fd = with(Builder()) {
val cidr = IPV4_ADDRESS.toCIDR()
addAddress(cidr.address, cidr.prefixLength)
Log.d(
"addAddress", "address: ${cidr.address} prefixLength:${cidr.prefixLength}"
)
val routeAddress = options.getIpv4RouteAddress()
if (routeAddress.isNotEmpty()) {
try {
routeAddress.forEach { i ->
Log.d(
"addRoute4", "address: ${i.address} prefixLength:${i.prefixLength}"
)
addRoute(i.address, i.prefixLength)
}
} catch (_: Exception) {
addRoute(NET_ANY, 0)
}
} else {
addRoute(NET_ANY, 0)
}
if (options.ipv6) {
try {
val cidr = IPV6_ADDRESS.toCIDR()
Log.d(
"addAddress6", "address: ${cidr.address} prefixLength:${cidr.prefixLength}"
)
addAddress(cidr.address, cidr.prefixLength)
} catch (_: Exception) {
Log.d(
"addAddress6", "IPv6 is not supported."
)
}
try {
val routeAddress = options.getIpv6RouteAddress()
if (routeAddress.isNotEmpty()) {
try {
routeAddress.forEach { i ->
Log.d(
"addRoute6",
"address: ${i.address} prefixLength:${i.prefixLength}"
)
addRoute(i.address, i.prefixLength)
}
} catch (_: Exception) {
addRoute("::", 0)
}
} else {
addRoute(NET_ANY6, 0)
}
} catch (_: Exception) {
addRoute(NET_ANY6, 0)
}
}
addDnsServer(DNS)
if (options.ipv6) {
addDnsServer(DNS6)
}
setMtu(9000)
options.accessControlProps.let { accessControl ->
if (accessControl.enable) {
when (accessControl.mode) {
AccessControlMode.ACCEPT_SELECTED -> {
(accessControl.acceptList + packageName).forEach {
addAllowedApplication(it)
}
}
AccessControlMode.REJECT_SELECTED -> {
(accessControl.rejectList - packageName).forEach {
addDisallowedApplication(it)
}
}
}
}
}
setSession("FlClash")
addAddressAndRoutes(options)
addDnsServers(options)
setMtu(MTU)
configureAccessControl(options)
setSession(getString(CommonR.string.app_name))
setBlocking(false)
if (Build.VERSION.SDK_INT >= 29) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
setMetered(false)
}
if (options.allowBypass) {
allowBypass()
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && options.systemProxy) {
GlobalState.log("Open http proxy")
GlobalState.log("Enable VPN HTTP proxy")
setHttpProxy(
ProxyInfo.buildDirectProxy(
"127.0.0.1", options.port, options.bypassDomain
)
LOCAL_HOST,
options.port,
options.bypassDomain,
),
)
}
establish()?.detachFd()
?: throw NullPointerException("Establish VPN rejected by system")
?: error("VPN establishment was rejected by the system")
}
Core.startTun(
fd,
protect = this::protect,
resolverProcess = this::resolverProcess,
options.stack,
options.address,
options.dns
synchronized(tunLock) {
tunRunning = true
try {
Core.startTun(
fd = fd,
protect = this::protect,
resolverProcess = this::resolverProcess,
stack = options.stack,
address = options.tunAddress,
dns = options.tunDns,
)
} catch (error: Exception) {
stopTunLocked()
throw error
}
}
}
private fun Builder.addAddressAndRoutes(options: VpnOptions) {
val ipv4Address = IPV4_ADDRESS.toCIDR()
addAddress(ipv4Address.address, ipv4Address.prefixLength)
addRoutes(
routes = options::getIpv4RouteAddress,
fallbackAddress = NET_ANY,
logTag = "addRoute4",
)
if (options.ipv6) {
try {
val ipv6Address = IPV6_ADDRESS.toCIDR()
addAddress(ipv6Address.address, ipv6Address.prefixLength)
} catch (_: Exception) {
GlobalState.log("IPv6 VPN address is not supported")
}
addRoutes(
routes = options::getIpv6RouteAddress,
fallbackAddress = NET_ANY6,
logTag = "addRoute6",
)
}
}
private fun Builder.addRoutes(
routes: () -> List<CIDR>,
fallbackAddress: String,
logTag: String,
) {
val routeList = runCatching(routes).getOrDefault(emptyList())
if (routeList.isEmpty()) {
addRoute(fallbackAddress, 0)
return
}
try {
routeList.forEach { route ->
Log.d(logTag, "address: ${route.address} prefixLength: ${route.prefixLength}")
addRoute(route.address, route.prefixLength)
}
} catch (_: Exception) {
addRoute(fallbackAddress, 0)
}
}
private fun Builder.addDnsServers(options: VpnOptions) {
addDnsServer(DNS)
if (options.ipv6) {
addDnsServer(DNS6)
}
}
private fun Builder.configureAccessControl(options: VpnOptions) {
val accessControl = options.accessControlProps
if (!accessControl.enable) return
when (accessControl.mode) {
AccessControlMode.ACCEPT_SELECTED -> {
(accessControl.acceptList + packageName).forEach(::addAllowedApplication)
}
AccessControlMode.REJECT_SELECTED -> {
(accessControl.rejectList - packageName).forEach(::addDisallowedApplication)
}
}
}
override fun start() {
try {
loader.load()
State.options?.let {
handleStart(it)
}
} catch (_: Exception) {
modules.start()
handleStart(requireNotNull(ServiceConfig.vpnOptions) { "VPN options are missing" })
} catch (error: Exception) {
stop()
throw error
}
}
override fun stop() {
loader.cancel()
Core.stopTun()
stopSelf()
try {
cleanup()
} finally {
stopSelf()
}
}
private fun cleanup() {
try {
modules.stop()
} finally {
stopTun()
}
}
private fun stopTun() = synchronized(tunLock) {
stopTunLocked()
}
private fun stopTunLocked() {
if (tunRunning) {
Core.stopTun()
tunRunning = false
}
}
companion object {
@@ -257,5 +269,7 @@ class VpnService : SystemVpnService(), IBaseService,
private const val DNS6 = "fdfe:dcba:9876::2"
private const val NET_ANY = "0.0.0.0"
private const val NET_ANY6 = "::"
private const val LOCAL_HOST = "127.0.0.1"
private const val MTU = 9000
}
}
}
@@ -1,36 +1,7 @@
package com.follow.clash.service.models
import android.os.Parcel
import android.os.Parcelable
data class NotificationParams(
val title: String = "FlClash",
val stopText: String = "STOP",
val onlyStatisticsProxy: Boolean = false,
) : Parcelable {
constructor(parcel: Parcel) : this(
title = parcel.readString() ?: "FlClash",
stopText = parcel.readString() ?: "STOP",
onlyStatisticsProxy = parcel.readByte() != 0.toByte(),
)
override fun writeToParcel(parcel: Parcel, flags: Int) {
parcel.writeString(title)
parcel.writeString(stopText)
parcel.writeByte(if (onlyStatisticsProxy) 1.toByte() else 0.toByte())
}
override fun describeContents(): Int {
return 0
}
companion object CREATOR : Parcelable.Creator<NotificationParams> {
override fun createFromParcel(parcel: Parcel): NotificationParams {
return NotificationParams(parcel)
}
override fun newArray(size: Int): Array<NotificationParams?> {
return arrayOfNulls(size)
}
}
}
)
@@ -1,25 +1,39 @@
package com.follow.clash.service.models
import com.follow.clash.common.GlobalState
import com.follow.clash.common.formatBytes
import com.follow.clash.core.Core
import com.google.gson.Gson
private val gson = Gson()
data class Traffic(
val up: Long,
val down: Long,
)
private val Long.formatBytes: String
get() {
val units = arrayOf("B", "KB", "MB", "GB", "TB")
var value = toDouble()
var unit = 0
while (value >= 1024 && unit < units.lastIndex) {
value /= 1024
unit++
}
return if (unit == 0) {
"${value.toLong()}${units[unit]}"
} else {
"%.1f${units[unit]}".format(value)
}
}
val Traffic.speedText: String
get() = "${up.formatBytes}/s↑ ${down.formatBytes}/s↓"
fun Core.getSpeedTrafficText(onlyStatisticsProxy: Boolean): String {
try {
val res = getTraffic(onlyStatisticsProxy)
val traffic = Gson().fromJson(res, Traffic::class.java)
return traffic.speedText
} catch (e: Exception) {
GlobalState.log(e.message + "")
return ""
}
}
return runCatching {
gson.fromJson(getTraffic(onlyStatisticsProxy), Traffic::class.java).speedText
}.onFailure { error ->
GlobalState.log("Unable to read traffic: $error")
}.getOrDefault("")
}
@@ -1,8 +1,8 @@
package com.follow.clash.service.models
import android.os.Parcel
import android.os.Parcelable
import com.follow.clash.common.AccessControlMode
import java.net.Inet4Address
import java.net.Inet6Address
import java.net.InetAddress
data class AccessControlProps(
@@ -10,35 +10,7 @@ data class AccessControlProps(
val mode: AccessControlMode,
val acceptList: List<String>,
val rejectList: List<String>,
) : Parcelable {
constructor(parcel: Parcel) : this(
enable = parcel.readByte() != 0.toByte(),
mode = AccessControlMode.valueOf(parcel.readString() ?: AccessControlMode.ACCEPT_SELECTED.name),
acceptList = parcel.createStringArrayList() ?: emptyList(),
rejectList = parcel.createStringArrayList() ?: emptyList(),
)
override fun writeToParcel(parcel: Parcel, flags: Int) {
parcel.writeByte(if (enable) 1.toByte() else 0.toByte())
parcel.writeString(mode.name)
parcel.writeStringList(acceptList)
parcel.writeStringList(rejectList)
}
override fun describeContents(): Int {
return 0
}
companion object CREATOR : Parcelable.Creator<AccessControlProps> {
override fun createFromParcel(parcel: Parcel): AccessControlProps {
return AccessControlProps(parcel)
}
override fun newArray(size: Int): Array<AccessControlProps?> {
return arrayOfNulls(size)
}
}
}
)
data class VpnOptions(
val enable: Boolean,
@@ -51,110 +23,32 @@ data class VpnOptions(
val bypassDomain: List<String>,
val stack: String,
val routeAddress: List<String>,
) : Parcelable {
constructor(parcel: Parcel) : this(
enable = parcel.readByte() != 0.toByte(),
port = parcel.readInt(),
ipv6 = parcel.readByte() != 0.toByte(),
dnsHijacking = parcel.readByte() != 0.toByte(),
accessControlProps = readAccessControlProps(parcel),
allowBypass = parcel.readByte() != 0.toByte(),
systemProxy = parcel.readByte() != 0.toByte(),
bypassDomain = parcel.createStringArrayList() ?: emptyList(),
stack = parcel.readString() ?: "",
routeAddress = parcel.createStringArrayList() ?: emptyList(),
)
)
override fun writeToParcel(parcel: Parcel, flags: Int) {
parcel.writeByte(if (enable) 1.toByte() else 0.toByte())
parcel.writeInt(port)
parcel.writeByte(if (ipv6) 1.toByte() else 0.toByte())
parcel.writeByte(if (dnsHijacking) 1.toByte() else 0.toByte())
parcel.writeParcelable(accessControlProps, flags)
parcel.writeByte(if (allowBypass) 1.toByte() else 0.toByte())
parcel.writeByte(if (systemProxy) 1.toByte() else 0.toByte())
parcel.writeStringList(bypassDomain)
parcel.writeString(stack)
parcel.writeStringList(routeAddress)
}
data class CIDR(
val address: InetAddress,
val prefixLength: Int,
)
override fun describeContents(): Int {
return 0
}
fun VpnOptions.getIpv4RouteAddress(): List<CIDR> = routeAddress
.map(String::toCIDR)
.filter { it.address is Inet4Address }
companion object CREATOR : Parcelable.Creator<VpnOptions> {
@Suppress("DEPRECATION")
private fun readAccessControlProps(parcel: Parcel): AccessControlProps {
return parcel.readParcelable<AccessControlProps>(
AccessControlProps::class.java.classLoader,
) ?: AccessControlProps(
enable = false,
mode = AccessControlMode.ACCEPT_SELECTED,
acceptList = emptyList(),
rejectList = emptyList(),
)
}
override fun createFromParcel(parcel: Parcel): VpnOptions {
return VpnOptions(parcel)
}
override fun newArray(size: Int): Array<VpnOptions?> {
return arrayOfNulls(size)
}
}
}
data class CIDR(val address: InetAddress, val prefixLength: Int)
fun VpnOptions.getIpv4RouteAddress(): List<CIDR> {
return routeAddress.filter {
it.isIpv4()
}.map {
it.toCIDR()
}
}
fun VpnOptions.getIpv6RouteAddress(): List<CIDR> {
return routeAddress.filter {
it.isIpv6()
}.map {
it.toCIDR()
}
}
fun String.isIpv4(): Boolean {
val parts = split("/")
if (parts.size != 2) {
throw IllegalArgumentException("Invalid CIDR format")
}
val address = InetAddress.getByName(parts[0])
return address.address.size == 4
}
fun String.isIpv6(): Boolean {
val parts = split("/")
if (parts.size != 2) {
throw IllegalArgumentException("Invalid CIDR format")
}
val address = InetAddress.getByName(parts[0])
return address.address.size == 16
}
fun VpnOptions.getIpv6RouteAddress(): List<CIDR> = routeAddress
.map(String::toCIDR)
.filter { it.address is Inet6Address }
fun String.toCIDR(): CIDR {
val parts = split("/")
if (parts.size != 2) {
throw IllegalArgumentException("Invalid CIDR format")
}
require(parts.size == 2) { "Invalid CIDR format: $this" }
val ipAddress = parts[0]
val prefixLength =
parts[1].toIntOrNull() ?: throw IllegalArgumentException("Invalid prefix length")
val prefixLength = parts[1].toIntOrNull()
?: throw IllegalArgumentException("Invalid prefix length: ${parts[1]}")
val address = InetAddress.getByName(ipAddress)
val maxPrefix = if (address.address.size == 4) 32 else 128
if (prefixLength < 0 || prefixLength > maxPrefix) {
throw IllegalArgumentException("Invalid prefix length for IP version")
require(prefixLength in 0..maxPrefix) {
"Invalid prefix length $prefixLength for $ipAddress"
}
return CIDR(address, prefixLength)
@@ -1,19 +0,0 @@
package com.follow.clash.service.modules
abstract class Module {
private var isInstall: Boolean = false
protected abstract fun onInstall()
protected abstract fun onUninstall()
fun install() {
isInstall = true
onInstall()
}
fun uninstall() {
onUninstall()
isInstall = false
}
}
@@ -1,51 +0,0 @@
package com.follow.clash.service.modules
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
interface ModuleLoaderScope {
fun <T : Module> install(module: T): T
}
interface ModuleLoader {
fun load()
fun cancel()
}
private val mutex = Mutex()
fun CoroutineScope.moduleLoader(block: suspend ModuleLoaderScope.() -> Unit): ModuleLoader {
val modules = mutableListOf<Module>()
var job: Job? = null
return object : ModuleLoader {
override fun load() {
job = launch(Dispatchers.IO) {
mutex.withLock {
val scope = object : ModuleLoaderScope {
override fun <T : Module> install(module: T): T {
modules.add(module)
module.install()
return module
}
}
scope.block()
}
}
}
override fun cancel() {
launch(Dispatchers.IO) {
job?.cancel()
mutex.withLock {
modules.asReversed().forEach { it.uninstall() }
modules.clear()
}
}
}
}
}
@@ -17,18 +17,20 @@ import java.net.InetAddress
import java.util.concurrent.ConcurrentHashMap
private data class NetworkInfo(
@Volatile var losingMs: Long = 0, @Volatile var dnsList: List<InetAddress> = emptyList()
@Volatile var losingUntilMillis: Long = 0,
@Volatile var dnsList: List<InetAddress> = emptyList(),
) {
fun isAvailable(): Boolean = losingMs < System.currentTimeMillis()
val priorityPenalty: Int
get() = if (losingUntilMillis > System.currentTimeMillis()) 10 else 0
}
class NetworkObserveModule(private val service: Service) : Module() {
internal class NetworkObserveModule(private val service: Service) : ServiceModule {
private val networkInfos = ConcurrentHashMap<Network, NetworkInfo>()
private val connectivity by lazy {
service.getSystemService<ConnectivityManager>()
}
private var preDnsList = listOf<String>()
private var currentDnsList = listOf<String>()
private val request = NetworkRequest.Builder().apply {
addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)
@@ -42,108 +44,79 @@ class NetworkObserveModule(private val service: Service) : Module() {
private val callback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
networkInfos[network] = NetworkInfo()
onUpdateNetwork()
super.onAvailable(network)
updateDns()
}
override fun onLosing(network: Network, maxMsToLive: Int) {
networkInfos[network]?.losingMs = System.currentTimeMillis() + maxMsToLive
onUpdateNetwork()
setUnderlyingNetworks(network)
super.onLosing(network, maxMsToLive)
networkInfos[network]?.losingUntilMillis = System.currentTimeMillis() + maxMsToLive
updateDns()
}
override fun onLost(network: Network) {
networkInfos.remove(network)
onUpdateNetwork()
setUnderlyingNetworks(network)
super.onLost(network)
updateDns()
}
override fun onLinkPropertiesChanged(network: Network, linkProperties: LinkProperties) {
networkInfos[network]?.dnsList = linkProperties.dnsServers
onUpdateNetwork()
setUnderlyingNetworks(network)
super.onLinkPropertiesChanged(network, linkProperties)
updateDns()
}
}
override fun onInstall() {
onUpdateNetwork()
override fun start() {
updateDns()
connectivity?.registerNetworkCallback(request, callback)
}
private fun networkToInt(entry: Map.Entry<Network, NetworkInfo>): Int {
private fun networkPriority(entry: Map.Entry<Network, NetworkInfo>): Int {
val capabilities = connectivity?.getNetworkCapabilities(entry.key)
return when {
capabilities == null -> 100
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN) -> 90
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> 0
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> 1
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && capabilities.hasTransport(
TRANSPORT_USB
) -> 2
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S &&
capabilities.hasTransport(TRANSPORT_USB) -> 2
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_BLUETOOTH) -> 3
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> 4
Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM && capabilities.hasTransport(
TRANSPORT_SATELLITE
) -> 5
Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM &&
capabilities.hasTransport(TRANSPORT_SATELLITE) -> 5
else -> 20
} + (if (entry.value.isAvailable()) 0 else 10)
} + entry.value.priorityPenalty
}
fun onUpdateNetwork() {
val dnsList = (networkInfos.asSequence().minByOrNull { networkToInt(it) }?.value?.dnsList
?: emptyList()).map { x -> x.asSocketAddressText(53) }
if (dnsList == preDnsList) {
@Synchronized
private fun updateDns() {
val dnsList = networkInfos.asSequence()
.minByOrNull(::networkPriority)
?.value
?.dnsList
.orEmpty()
.map { address -> address.asSocketAddressText(DNS_PORT) }
.distinct()
if (dnsList == currentDnsList) {
return
}
preDnsList = dnsList
Core.updateDNS(dnsList.toSet().joinToString(","))
currentDnsList = dnsList
Core.updateDNS(dnsList.joinToString(","))
}
fun setUnderlyingNetworks(network: Network) {
// if (service is VpnService && Build.VERSION.SDK_INT in 22..28) {
// service.setUnderlyingNetworks(arrayOf(network))
// }
}
override fun onUninstall() {
connectivity?.unregisterNetworkCallback(callback)
networkInfos.clear()
onUpdateNetwork()
}
}
fun InetAddress.asSocketAddressText(port: Int): String {
return when (this) {
is Inet6Address -> "[${numericToTextFormat(this)}]:$port"
is Inet4Address -> "${this.hostAddress}:$port"
else -> throw IllegalArgumentException("Unsupported Inet type ${this.javaClass}")
}
}
private fun numericToTextFormat(address: Inet6Address): String {
val src = address.address
val sb = StringBuilder(39)
for (i in 0 until 8) {
sb.append(
Integer.toHexString(
src[i shl 1].toInt() shl 8 and 0xff00 or (src[(i shl 1) + 1].toInt() and 0xff)
)
)
if (i < 7) {
sb.append(":")
override fun stop() {
try {
connectivity?.unregisterNetworkCallback(callback)
} finally {
networkInfos.clear()
updateDns()
}
}
if (address.scopeId > 0) {
sb.append("%")
sb.append(address.scopeId)
}
return sb.toString()
}
}
private const val DNS_PORT = 53
private fun InetAddress.asSocketAddressText(port: Int): String = when (this) {
is Inet6Address -> "[$hostAddress]:$port"
is Inet4Address -> "$hostAddress:$port"
else -> error("Unsupported address type: ${javaClass.name}")
}
@@ -14,39 +14,41 @@ import com.follow.clash.common.QuickAction
import com.follow.clash.common.quickIntent
import com.follow.clash.common.receiveBroadcastFlow
import com.follow.clash.common.startForeground
import com.follow.clash.common.tickerFlow
import com.follow.clash.common.toPendingIntent
import com.follow.clash.core.Core
import com.follow.clash.service.R
import com.follow.clash.service.State
import com.follow.clash.service.ServiceConfig
import com.follow.clash.service.models.NotificationParams
import com.follow.clash.service.models.getSpeedTrafficText
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.launch
data class ExtendedNotificationParams(
private data class ExtendedNotificationParams(
val title: String,
val stopText: String,
val onlyStatisticsProxy: Boolean,
val contentText: String,
)
val NotificationParams.extended: ExtendedNotificationParams
private val NotificationParams.extended: ExtendedNotificationParams
get() = ExtendedNotificationParams(
title, stopText, onlyStatisticsProxy, Core.getSpeedTrafficText(onlyStatisticsProxy)
title,
stopText,
Core.getSpeedTrafficText(onlyStatisticsProxy),
)
class NotificationModule(private val service: Service) : Module() {
private val scope = CoroutineScope(Dispatchers.Default)
override fun onInstall() {
internal class NotificationModule(
private val service: Service,
private val scope: CoroutineScope,
) : ServiceModule {
override fun start() {
update(ServiceConfig.notificationParams.value.extended)
scope.launch {
val screenFlow = service.receiveBroadcastFlow {
addAction(Intent.ACTION_SCREEN_ON)
@@ -58,41 +60,36 @@ class NotificationModule(private val service: Service) : Module() {
}
combine(
tickerFlow(1000, 0), State.notificationParamsFlow, screenFlow
flow {
while (true) {
delay(1_000)
emit(Unit)
}
},
ServiceConfig.notificationParams,
screenFlow,
) { _, params, screenOn ->
params?.extended to screenOn
}.filter { (params, screenOn) -> params != null && screenOn }
.distinctUntilChanged { old, new -> old.first == new.first && old.second == new.second }
.collect { (params, _) ->
update(params!!)
}
State.notificationParamsFlow.value?.let {
update(it.extended)
} ?: run {
update(NotificationParams().extended)
}
params.takeIf { screenOn }?.extended
}.filterNotNull()
.distinctUntilChanged()
.collect(::update)
}
}
private fun isScreenOn(): Boolean {
val pm = service.getSystemService<PowerManager>()
return when (pm != null) {
true -> pm.isInteractive
false -> true
}
}
private fun isScreenOn() =
service.getSystemService<PowerManager>()?.isInteractive ?: true
private val notificationBuilder: NotificationCompat.Builder by lazy {
val intent = Intent().setComponent(Components.MAIN_ACTIVITY)
val intent = Intent().setComponent(Components.mainActivity)
NotificationCompat.Builder(
service, GlobalState.NOTIFICATION_CHANNEL
service,
GlobalState.NOTIFICATION_CHANNEL,
).apply {
setSmallIcon(R.drawable.ic_service)
setContentTitle("FlClash")
setContentIntent(intent.toPendingIntent)
setPriority(NotificationCompat.PRIORITY_HIGH)
setPriority(NotificationCompat.PRIORITY_LOW)
setCategory(NotificationCompat.CATEGORY_SERVICE)
setOngoing(true)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
@@ -100,9 +97,6 @@ class NotificationModule(private val service: Service) : Module() {
}
setShowWhen(true)
setOnlyAlertOnce(true)
// if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
// setRequestPromotedOngoing(true)
// }
}
}
@@ -113,17 +107,20 @@ class NotificationModule(private val service: Service) : Module() {
setContentText(params.contentText)
clearActions()
addAction(
0, params.stopText, QuickAction.STOP.quickIntent.toPendingIntent
0,
params.stopText,
QuickAction.STOP.quickIntent.toPendingIntent,
).build()
})
},
)
}
override fun onUninstall() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
@Suppress("DEPRECATION")
override fun stop() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
service.stopForeground(STOP_FOREGROUND_REMOVE)
} else {
service.stopForeground(true)
}
scope.cancel()
}
}
}
@@ -0,0 +1,59 @@
package com.follow.clash.service.modules
import android.app.Service
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
internal interface ServiceModule {
fun start()
fun stop()
}
internal class ServiceModules(private val service: Service) {
private var scope: CoroutineScope? = null
private var modules = emptyList<ServiceModule>()
@Synchronized
fun start() {
if (scope != null) return
val nextScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
val nextModules = listOf(
NotificationModule(service, nextScope),
NetworkObserveModule(service),
SuspendModule(service, nextScope),
)
val startedModules = mutableListOf<ServiceModule>()
try {
nextModules.forEach { module ->
module.start()
startedModules.add(module)
}
scope = nextScope
modules = nextModules
} catch (error: Throwable) {
nextScope.cancel()
startedModules.asReversed().forEach { module ->
runCatching { module.stop() }
}
throw error
}
}
@Synchronized
fun stop() {
val currentScope = scope ?: return
val currentModules = modules
scope = null
modules = emptyList()
currentScope.cancel()
currentModules.asReversed().forEach { module ->
runCatching { module.stop() }
}
}
}
@@ -7,55 +7,41 @@ import androidx.core.content.getSystemService
import com.follow.clash.common.receiveBroadcastFlow
import com.follow.clash.core.Core
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.launch
internal class SuspendModule(
private val service: Service,
private val scope: CoroutineScope,
) : ServiceModule {
private fun isScreenOn() =
service.getSystemService<PowerManager>()?.isInteractive ?: true
class SuspendModule(private val service: Service) : Module() {
private val scope = CoroutineScope(Dispatchers.Default)
private val isDeviceIdle: Boolean
get() = service.getSystemService<PowerManager>()?.isDeviceIdleMode ?: true
private fun isScreenOn(): Boolean {
val pm = service.getSystemService<PowerManager>()
return when (pm != null) {
true -> pm.isInteractive
false -> true
}
private fun updateSuspension(screenOn: Boolean) {
Core.suspended(!screenOn && isDeviceIdle)
}
val isDeviceIdleMode: Boolean
get() {
return service.getSystemService<PowerManager>()?.isDeviceIdleMode ?: true
}
private fun onUpdate(isScreenOn: Boolean) {
if (isScreenOn) {
Core.suspended(false)
return
}
Core.suspended(isDeviceIdleMode)
}
override fun onInstall() {
override fun start() {
scope.launch {
val screenFlow = service.receiveBroadcastFlow {
addAction(Intent.ACTION_SCREEN_ON)
addAction(Intent.ACTION_SCREEN_OFF)
}.map { intent ->
intent.action == Intent.ACTION_SCREEN_ON
addAction(PowerManager.ACTION_DEVICE_IDLE_MODE_CHANGED)
}.map {
isScreenOn()
}.onStart {
emit(isScreenOn())
}
screenFlow.collect {
onUpdate(it)
}
screenFlow.collect(::updateSuspension)
}
}
override fun onUninstall() {
scope.cancel()
override fun stop() {
Core.suspended(false)
}
}
}
+2 -3
View File
@@ -21,11 +21,10 @@ plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "9.0.1" apply false
id("org.jetbrains.kotlin.android") version "2.4.0" apply false
id("com.google.gms.google-services") version ("4.5.0") apply false
id("com.google.firebase.crashlytics") version ("3.0.7") apply false
id("com.google.gms.google-services") version "4.5.0" apply false
id("com.google.firebase.crashlytics") version "3.0.7" apply false
}
include(":app")
include(":core")
include(":service")