feat(android): rework the VPN service, tile, lifecycle arbitration, and native build
ServiceState serializes start, stop, and restart intents so the latest request wins, and switching between VPN and proxy mode tears the previous service down the way stop() does before binding the next one, so a system-started VpnService no longer outlives the switch. The package resolver drops its cache on package add, replace, and remove broadcasts and notifies Flutter, and the persistent notification can omit its stop action through an application setting synced over the shared state. The :core module takes the NDK clang directory and API level from the setup build hook input instead of ANDROID_NDK and a hardcoded API 21, and derives its ABI list from the target-platform property so a single-ABI build no longer links ABIs the hook did not produce. Changelog: Rework the Android VPN service and lifecycle handling
This commit is contained in:
@@ -81,6 +81,11 @@ android {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
// Unit tests live under android/tests/ instead of each module's src/test.
|
||||
getByName("test").java.setSrcDirs(listOf("../tests/app"))
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
@@ -105,4 +110,6 @@ dependencies {
|
||||
implementation(platform(libs.firebase.bom))
|
||||
implementation(libs.firebase.crashlytics.ndk)
|
||||
implementation(libs.firebase.analytics)
|
||||
testImplementation(libs.junit)
|
||||
testImplementation(libs.kotlinx.coroutines.test)
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
<uses-permission
|
||||
android:name="android.permission.QUERY_ALL_PACKAGES"
|
||||
tools:ignore="QueryAllPackagesPermission" />
|
||||
<uses-permission android:name="com.android.permission.GET_INSTALLED_APPS" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
||||
|
||||
@@ -6,21 +6,18 @@ import android.content.Intent
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import com.follow.clash.common.BroadcastAction
|
||||
import com.follow.clash.common.BroadcastLease
|
||||
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 lease = BroadcastLease { pendingResult.finish() }
|
||||
val timeout = Runnable {
|
||||
if (finished.compareAndSet(false, true)) {
|
||||
GlobalState.log("Broadcast handling timed out: $action")
|
||||
pendingResult.finish()
|
||||
}
|
||||
lease.release { GlobalState.log("Broadcast handling timed out: $action") }
|
||||
}
|
||||
mainHandler.postDelayed(timeout, BROADCAST_TIMEOUT_MILLIS)
|
||||
GlobalState.launch {
|
||||
@@ -30,9 +27,7 @@ class ServiceBroadcastReceiver : BroadcastReceiver() {
|
||||
GlobalState.log("Unable to handle service broadcast $action: $error")
|
||||
} finally {
|
||||
mainHandler.removeCallbacks(timeout)
|
||||
if (finished.compareAndSet(false, true)) {
|
||||
pendingResult.finish()
|
||||
}
|
||||
lease.release()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,7 +72,9 @@ object ServiceController {
|
||||
}
|
||||
|
||||
if (binding?.component != nextIntent.component) {
|
||||
clearBinding()
|
||||
if (binding != null) {
|
||||
tearDownServices()
|
||||
}
|
||||
lateinit var nextBinding: ManagedServiceBinding
|
||||
nextBinding = ManagedServiceBinding(nextIntent) { message ->
|
||||
handleServiceDisconnected(nextBinding, message)
|
||||
@@ -106,12 +108,29 @@ object ServiceController {
|
||||
}
|
||||
|
||||
suspend fun stop() = lock.withLock {
|
||||
tearDownServices()
|
||||
runTimeMillis = 0L
|
||||
}
|
||||
|
||||
private suspend fun tearDownServices() {
|
||||
binding?.useService { service -> service.stop() }
|
||||
?.onFailure { error ->
|
||||
GlobalState.log("Unable to stop background service: $error")
|
||||
}
|
||||
clearBinding()
|
||||
runTimeMillis = 0L
|
||||
stopServices()
|
||||
}
|
||||
|
||||
// A service the system started itself — always-on VPN, or the sticky restart
|
||||
// after the process was killed — outlives every binding this process holds, so
|
||||
// unbinding alone leaves the tunnel up while the app reports it stopped.
|
||||
private fun stopServices() {
|
||||
listOf(VpnService::class.intent, ProxyService::class.intent).forEach { intent ->
|
||||
runCatching { GlobalState.application.stopService(intent) }
|
||||
.onFailure { error ->
|
||||
GlobalState.log("Unable to stop ${intent.component?.className}: $error")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun isVpnServiceActive(): Boolean = lock.withLock {
|
||||
|
||||
@@ -1,323 +1,37 @@
|
||||
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))
|
||||
private val machine = ServiceStateMachine(AndroidServiceStateHost)
|
||||
|
||||
@Volatile
|
||||
private var sharedState = SharedState()
|
||||
val runState = machine.runState
|
||||
|
||||
@Volatile
|
||||
private var flutterEngine: FlutterEngine? = null
|
||||
fun attachFlutterEngine(engine: FlutterEngine) =
|
||||
AndroidServiceStateHost.attachFlutterEngine(engine)
|
||||
|
||||
val runState = mutableRunState.asStateFlow()
|
||||
fun detachFlutterEngine(engine: FlutterEngine) =
|
||||
AndroidServiceStateHost.detachFlutterEngine(engine)
|
||||
|
||||
private val runTimeMillis: Long
|
||||
get() = ServiceController.getRunTimeMillis()
|
||||
suspend fun handleToggleAction() = machine.handleToggleAction()
|
||||
|
||||
private val appPlugin: AppPlugin?
|
||||
get() = flutterEngine?.plugin<AppPlugin>()
|
||||
suspend fun handleStartAction() = machine.handleStartAction()
|
||||
|
||||
private val tilePlugin: TilePlugin?
|
||||
get() = flutterEngine?.plugin<TilePlugin>()
|
||||
suspend fun handleStopAction() = machine.handleStopAction()
|
||||
|
||||
fun attachFlutterEngine(engine: FlutterEngine) {
|
||||
flutterEngine = engine
|
||||
}
|
||||
suspend fun handleVpnRevokeAction() = machine.handleVpnRevokeAction()
|
||||
|
||||
fun detachFlutterEngine(engine: FlutterEngine) {
|
||||
if (flutterEngine === engine) {
|
||||
flutterEngine = null
|
||||
}
|
||||
}
|
||||
suspend fun refresh(): Long = machine.refresh()
|
||||
|
||||
suspend fun handleToggleAction() {
|
||||
if (isRunningRequested()) {
|
||||
handleStopAction()
|
||||
} else {
|
||||
handleStartAction()
|
||||
}
|
||||
}
|
||||
fun requestStart(): Deferred<Boolean> = machine.requestStart()
|
||||
|
||||
suspend fun refresh(): Long = transitionLock.withLock {
|
||||
val current = runTimeMillis
|
||||
mutableRunState.value = if (current == 0L) RunState.STOPPED else RunState.STARTED
|
||||
current
|
||||
}
|
||||
fun requestStop(): Deferred<Boolean> = machine.requestStop()
|
||||
|
||||
internal fun captureRequestToken(): RunRequest = latestRequest.get()
|
||||
fun syncSharedState(state: SharedState) = machine.syncSharedState(state)
|
||||
|
||||
/**
|
||||
* Settles the state after the bound service was lost. [token] is the request that was current
|
||||
* when the loss was observed, so a start that raced ahead of this callback keeps its intent.
|
||||
*/
|
||||
internal suspend fun handleServiceLost(token: RunRequest) = transitionLock.withLock {
|
||||
if (runTimeMillis != 0L) {
|
||||
return@withLock
|
||||
}
|
||||
if (!latestRequest.compareAndSet(token, RunRequest(running = false))) {
|
||||
return@withLock
|
||||
}
|
||||
mutableRunState.value = RunState.STOPPED
|
||||
}
|
||||
internal fun captureRequestToken(): RunRequest = machine.captureRequestToken()
|
||||
|
||||
suspend fun handleStartAction() {
|
||||
if (isRunningRequested()) {
|
||||
return
|
||||
}
|
||||
val plugin = tilePlugin
|
||||
if (plugin != null) {
|
||||
plugin.handleStart()
|
||||
return
|
||||
}
|
||||
loadPreferencesAndStart()
|
||||
}
|
||||
|
||||
suspend fun handleStopAction() {
|
||||
if (!isRunningRequested()) {
|
||||
return
|
||||
}
|
||||
val plugin = tilePlugin
|
||||
if (plugin != null) {
|
||||
plugin.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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
val plugin = appPlugin
|
||||
if (plugin != null) {
|
||||
plugin.requestNotificationPermission(launchRequest)
|
||||
} else {
|
||||
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
|
||||
val startedAtMillis = ServiceController.start(options)
|
||||
mutableRunState.value =
|
||||
if (startedAtMillis == 0L) RunState.STOPPED else RunState.STARTED
|
||||
if (startedAtMillis == 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
|
||||
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))
|
||||
}
|
||||
|
||||
internal class RunRequest(
|
||||
val running: Boolean,
|
||||
)
|
||||
internal suspend fun handleServiceLost(token: RunRequest) = machine.handleServiceLost(token)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
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 io.flutter.embedding.engine.FlutterEngine
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
|
||||
/**
|
||||
* The machine owns the arbitration and the transitions; this is only the part that cannot run on a
|
||||
* plain JVM, so unit tests can drive the state machine with an in-memory implementation.
|
||||
*/
|
||||
internal interface ServiceStateHost {
|
||||
val scope: CoroutineScope
|
||||
val runTimeMillis: Long
|
||||
val homeDirPath: String
|
||||
val sdkInt: Int
|
||||
|
||||
fun log(message: String)
|
||||
|
||||
fun showToast(message: String)
|
||||
|
||||
fun setCrashlytics(enabled: Boolean)
|
||||
|
||||
fun updateNotificationParams(params: NotificationParams)
|
||||
|
||||
fun loadSharedState(): SharedState
|
||||
|
||||
fun isVpnPermissionGranted(): Boolean
|
||||
|
||||
fun tile(): TileGateway?
|
||||
|
||||
fun app(): AppGateway?
|
||||
|
||||
suspend fun quickSetup(initParams: String, setupParams: String): Result<String>
|
||||
|
||||
suspend fun startService(options: VpnOptions): Long
|
||||
|
||||
suspend fun stopService()
|
||||
|
||||
suspend fun isVpnServiceActive(): Boolean
|
||||
}
|
||||
|
||||
internal interface TileGateway {
|
||||
fun handleStart()
|
||||
|
||||
fun handleStop()
|
||||
}
|
||||
|
||||
internal interface AppGateway {
|
||||
fun requestNotificationPermission(callback: (Boolean) -> Unit)
|
||||
|
||||
fun prepareVpn(enable: Boolean, callback: (Boolean) -> Unit)
|
||||
|
||||
fun cancelVpnPreparation(callback: (Boolean) -> Unit)
|
||||
}
|
||||
|
||||
internal object AndroidServiceStateHost : ServiceStateHost {
|
||||
@Volatile
|
||||
private var flutterEngine: FlutterEngine? = null
|
||||
|
||||
override val scope: CoroutineScope
|
||||
get() = GlobalState
|
||||
|
||||
override val runTimeMillis: Long
|
||||
get() = ServiceController.getRunTimeMillis()
|
||||
|
||||
override val homeDirPath: String
|
||||
get() = GlobalState.application.filesDir.path
|
||||
|
||||
override val sdkInt: Int
|
||||
get() = android.os.Build.VERSION.SDK_INT
|
||||
|
||||
fun attachFlutterEngine(engine: FlutterEngine) {
|
||||
flutterEngine = engine
|
||||
}
|
||||
|
||||
fun detachFlutterEngine(engine: FlutterEngine) {
|
||||
if (flutterEngine === engine) {
|
||||
flutterEngine = null
|
||||
}
|
||||
}
|
||||
|
||||
override fun log(message: String) = GlobalState.log(message)
|
||||
|
||||
override fun showToast(message: String) = GlobalState.application.showToast(message)
|
||||
|
||||
override fun setCrashlytics(enabled: Boolean) = GlobalState.setCrashlytics(enabled)
|
||||
|
||||
override fun updateNotificationParams(params: NotificationParams) =
|
||||
ServiceConfig.updateNotificationParams(params)
|
||||
|
||||
override fun loadSharedState(): SharedState = GlobalState.application.sharedState
|
||||
|
||||
override fun isVpnPermissionGranted(): Boolean =
|
||||
VpnService.prepare(GlobalState.application) == null
|
||||
|
||||
override fun tile(): TileGateway? = flutterEngine?.plugin<TilePlugin>()?.let { plugin ->
|
||||
object : TileGateway {
|
||||
override fun handleStart() = plugin.handleStart()
|
||||
|
||||
override fun handleStop() = plugin.handleStop()
|
||||
}
|
||||
}
|
||||
|
||||
override fun app(): AppGateway? = flutterEngine?.plugin<AppPlugin>()?.let { plugin ->
|
||||
object : AppGateway {
|
||||
override fun requestNotificationPermission(callback: (Boolean) -> Unit) =
|
||||
plugin.requestNotificationPermission(callback)
|
||||
|
||||
override fun prepareVpn(enable: Boolean, callback: (Boolean) -> Unit) =
|
||||
plugin.prepareVpn(enable, callback)
|
||||
|
||||
override fun cancelVpnPreparation(callback: (Boolean) -> Unit) =
|
||||
plugin.cancelVpnPreparation(callback)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun quickSetup(initParams: String, setupParams: String): Result<String> =
|
||||
ServiceController.quickSetup(initParams, setupParams)
|
||||
|
||||
override suspend fun startService(options: VpnOptions): Long =
|
||||
ServiceController.start(options)
|
||||
|
||||
override suspend fun stopService() = ServiceController.stop()
|
||||
|
||||
override suspend fun isVpnServiceActive(): Boolean = ServiceController.isVpnServiceActive()
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
package com.follow.clash
|
||||
|
||||
import com.follow.clash.common.RunIntentArbiter
|
||||
import com.follow.clash.models.SharedState
|
||||
import com.follow.clash.service.models.NotificationParams
|
||||
import com.follow.clash.service.models.VpnOptions
|
||||
import com.google.gson.Gson
|
||||
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,
|
||||
}
|
||||
|
||||
internal typealias RunRequest = RunIntentArbiter.Token
|
||||
|
||||
internal const val MISSING_CONFIG_MESSAGE = "No configuration found."
|
||||
internal const val INVALID_CONFIG_MESSAGE = "Invalid configuration."
|
||||
internal const val VPN_PERMISSION_MESSAGE = "VPN permission required."
|
||||
internal const val START_FAILED_MESSAGE = "Failed to start service."
|
||||
|
||||
/**
|
||||
* Callers request a transition; the newest request always wins. Every step that outlives its own
|
||||
* suspension point re-checks [isCurrent] before it publishes anything, so a start that was overtaken
|
||||
* by a stop cannot report itself as started.
|
||||
*/
|
||||
internal class ServiceStateMachine(private val host: ServiceStateHost) {
|
||||
private val transitionLock = Mutex()
|
||||
private val startPreparationLock = Mutex()
|
||||
private val mutableRunState = MutableStateFlow(RunState.STOPPED)
|
||||
private val arbiter = RunIntentArbiter()
|
||||
|
||||
@Volatile
|
||||
private var sharedState = SharedState()
|
||||
|
||||
@Volatile
|
||||
private var pendingVpnPreparation: (() -> Unit)? = null
|
||||
|
||||
val runState = mutableRunState.asStateFlow()
|
||||
|
||||
private val runTimeMillis: Long
|
||||
get() = host.runTimeMillis
|
||||
|
||||
suspend fun handleToggleAction() {
|
||||
if (isRunningRequested()) {
|
||||
handleStopAction()
|
||||
} else {
|
||||
handleStartAction()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun refresh(): Long = transitionLock.withLock {
|
||||
val current = runTimeMillis
|
||||
mutableRunState.value = if (current == 0L) RunState.STOPPED else RunState.STARTED
|
||||
current
|
||||
}
|
||||
|
||||
fun captureRequestToken(): RunRequest = arbiter.current()
|
||||
|
||||
/**
|
||||
* Settles the state after the bound service was lost. [token] is the request that was current
|
||||
* when the loss was observed, so a start that raced ahead of this callback keeps its intent.
|
||||
*/
|
||||
suspend fun handleServiceLost(token: RunRequest) = transitionLock.withLock {
|
||||
if (runTimeMillis != 0L) {
|
||||
return@withLock
|
||||
}
|
||||
if (!arbiter.resetToStopped(token)) {
|
||||
return@withLock
|
||||
}
|
||||
mutableRunState.value = RunState.STOPPED
|
||||
}
|
||||
|
||||
suspend fun handleStartAction() {
|
||||
if (isRunningRequested()) {
|
||||
return
|
||||
}
|
||||
val tile = host.tile()
|
||||
if (tile != null) {
|
||||
tile.handleStart()
|
||||
return
|
||||
}
|
||||
loadPreferencesAndStart()
|
||||
}
|
||||
|
||||
suspend fun handleStopAction() {
|
||||
if (!isRunningRequested()) {
|
||||
return
|
||||
}
|
||||
val tile = host.tile()
|
||||
if (tile != null) {
|
||||
tile.handleStop()
|
||||
return
|
||||
}
|
||||
host.showToast(sharedState.stopTip)
|
||||
requestStop().await()
|
||||
}
|
||||
|
||||
suspend fun handleVpnRevokeAction() {
|
||||
if (!host.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 {
|
||||
host.scope.launch {
|
||||
result.complete(
|
||||
runCatching { start(request) }
|
||||
.onFailure { error ->
|
||||
host.log("Unable to process service start request: $error")
|
||||
fail(request)
|
||||
reconcileStopped()
|
||||
}
|
||||
.getOrDefault(false),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
val app = host.app()
|
||||
if (app != null) {
|
||||
app.requestNotificationPermission(launchRequest)
|
||||
} else {
|
||||
launchRequest(true)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
fun requestStop(): Deferred<Boolean> {
|
||||
val request = createRequest(running = false)
|
||||
val result = CompletableDeferred<Boolean>()
|
||||
host.scope.launch {
|
||||
result.complete(
|
||||
runCatching { stop(request) }
|
||||
.onFailure { error ->
|
||||
host.log("Unable to process service stop request: $error")
|
||||
}
|
||||
.getOrDefault(false),
|
||||
)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
fun syncSharedState(state: SharedState) {
|
||||
sharedState = state
|
||||
applySharedState()
|
||||
}
|
||||
|
||||
private suspend fun loadPreferencesAndStart() {
|
||||
sharedState = host.loadSharedState()
|
||||
if (sharedState.setupParams == null || sharedState.vpnOptions == null) {
|
||||
host.showToast(MISSING_CONFIG_MESSAGE)
|
||||
return
|
||||
}
|
||||
if (setupCore()) {
|
||||
if (!requestStart().await()) {
|
||||
host.showToast(START_FAILED_MESSAGE)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun applySharedState() {
|
||||
host.setCrashlytics(sharedState.crashlytics)
|
||||
host.updateNotificationParams(notificationParams(sharedState))
|
||||
}
|
||||
|
||||
private suspend fun setupCore(): Boolean {
|
||||
applySharedState()
|
||||
host.showToast(sharedState.startTip)
|
||||
return host.quickSetup(
|
||||
initParams(host.homeDirPath, host.sdkInt),
|
||||
Gson().toJson(sharedState.setupParams),
|
||||
).fold(
|
||||
onSuccess = { message ->
|
||||
if (message.isEmpty()) {
|
||||
true
|
||||
} else {
|
||||
host.log("Unable to set up core: $message")
|
||||
showConfigError(message)
|
||||
false
|
||||
}
|
||||
},
|
||||
onFailure = { error ->
|
||||
host.log("Unable to set up core: $error")
|
||||
showConfigError(error.message)
|
||||
false
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun showConfigError(message: String?) {
|
||||
host.showToast(message?.takeIf { it.isNotBlank() } ?: INVALID_CONFIG_MESSAGE)
|
||||
}
|
||||
|
||||
private suspend fun start(request: RunRequest): Boolean = startPreparationLock.withLock {
|
||||
val started = runStart(request)
|
||||
if (!started) {
|
||||
reconcileStopped()
|
||||
}
|
||||
started
|
||||
}
|
||||
|
||||
private suspend fun runStart(request: RunRequest): Boolean {
|
||||
if (!isCurrent(request)) {
|
||||
return false
|
||||
}
|
||||
val options = sharedState.vpnOptions
|
||||
if (options == null) {
|
||||
fail(request)
|
||||
return false
|
||||
}
|
||||
if (!prepareVpn(options)) {
|
||||
if (host.app() == null && isCurrent(request)) {
|
||||
host.showToast(VPN_PERMISSION_MESSAGE)
|
||||
}
|
||||
fail(request)
|
||||
return false
|
||||
}
|
||||
if (!isCurrent(request)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return transitionLock.withLock transition@{
|
||||
if (!isCurrent(request)) {
|
||||
return@transition false
|
||||
}
|
||||
if (runTimeMillis != 0L && host.isVpnServiceActive() == options.enable) {
|
||||
mutableRunState.value = RunState.STARTED
|
||||
return@transition true
|
||||
}
|
||||
mutableRunState.value = RunState.STARTING
|
||||
val startedAtMillis = host.startService(options)
|
||||
if (startedAtMillis == 0L) {
|
||||
mutableRunState.value = RunState.STOPPED
|
||||
fail(request)
|
||||
return@transition false
|
||||
}
|
||||
if (!isCurrent(request)) {
|
||||
return@transition false
|
||||
}
|
||||
mutableRunState.value = RunState.STARTED
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun reconcileStopped() = transitionLock.withLock {
|
||||
if (isRunningRequested() || runTimeMillis == 0L) {
|
||||
return@withLock
|
||||
}
|
||||
mutableRunState.value = RunState.STOPPING
|
||||
host.stopService()
|
||||
mutableRunState.value = RunState.STOPPED
|
||||
}
|
||||
|
||||
private suspend fun stop(request: RunRequest): Boolean = transitionLock.withLock {
|
||||
if (!isCurrent(request)) {
|
||||
return@withLock false
|
||||
}
|
||||
abandonVpnPreparation()
|
||||
if (runState.value == RunState.STOPPED && runTimeMillis == 0L) {
|
||||
return@withLock true
|
||||
}
|
||||
mutableRunState.value = RunState.STOPPING
|
||||
host.stopService()
|
||||
mutableRunState.value = RunState.STOPPED
|
||||
isCurrent(request)
|
||||
}
|
||||
|
||||
private suspend fun prepareVpn(options: VpnOptions): Boolean {
|
||||
val app = host.app()
|
||||
?: return !options.enable || host.isVpnPermissionGranted()
|
||||
return suspendCancellableCoroutine { continuation ->
|
||||
val callback: (Boolean) -> Unit = { granted ->
|
||||
pendingVpnPreparation = null
|
||||
if (continuation.isActive) {
|
||||
continuation.resume(granted)
|
||||
}
|
||||
}
|
||||
pendingVpnPreparation = {
|
||||
app.cancelVpnPreparation(callback)
|
||||
callback(false)
|
||||
}
|
||||
continuation.invokeOnCancellation {
|
||||
pendingVpnPreparation = null
|
||||
app.cancelVpnPreparation(callback)
|
||||
}
|
||||
app.prepareVpn(options.enable, callback)
|
||||
}
|
||||
}
|
||||
|
||||
private fun abandonVpnPreparation() {
|
||||
val abandon = pendingVpnPreparation ?: return
|
||||
pendingVpnPreparation = null
|
||||
abandon()
|
||||
}
|
||||
|
||||
private fun createRequest(running: Boolean): RunRequest = arbiter.request(running)
|
||||
|
||||
private fun isRunningRequested(): Boolean = arbiter.isRunningRequested
|
||||
|
||||
private fun isCurrent(request: RunRequest): Boolean = arbiter.isCurrent(request)
|
||||
|
||||
private fun fail(request: RunRequest) {
|
||||
arbiter.resetToStopped(request)
|
||||
}
|
||||
|
||||
internal companion object {
|
||||
/**
|
||||
* The Core init payload. The key spelling is a cross-language contract with the Go wrapper,
|
||||
* not an implementation detail.
|
||||
*/
|
||||
fun initParams(homeDirPath: String, sdkInt: Int): String = Gson().toJson(
|
||||
mapOf(
|
||||
"home-dir" to homeDirPath,
|
||||
"version" to sdkInt,
|
||||
),
|
||||
)
|
||||
|
||||
fun notificationParams(state: SharedState): NotificationParams = NotificationParams(
|
||||
title = state.currentProfileName,
|
||||
stopText = state.stopText,
|
||||
onlyStatisticsProxy = state.onlyStatisticsProxy,
|
||||
showStopAction = state.showStopAction,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,9 @@ class TileService : android.service.quicksettings.TileService() {
|
||||
}
|
||||
}
|
||||
|
||||
// Always route through QuickActionActivity: startActivityAndCollapse is
|
||||
// what closes the shade, so toggling the service in place would leave
|
||||
// the panel open after a tap.
|
||||
override fun onClick() {
|
||||
super.onClick()
|
||||
openQuickAction()
|
||||
|
||||
@@ -10,6 +10,7 @@ data class SharedState(
|
||||
val currentProfileName: String = "FlClash",
|
||||
val stopText: String = "Stop",
|
||||
val onlyStatisticsProxy: Boolean = false,
|
||||
val showStopAction: Boolean = true,
|
||||
val vpnOptions: VpnOptions? = null,
|
||||
val setupParams: SetupParams? = null,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package com.follow.clash.packages
|
||||
|
||||
/**
|
||||
* The prefixes in [CHINA_PACKAGE_REGEX] are matched *without* a trailing dot
|
||||
* boundary, and that is deliberate: `com.qihoo` has to reach `com.qihoo360.*`
|
||||
* and `com.ali` has to reach `com.aliyun.*` and `com.alimama.*`. Requiring a
|
||||
* separator would turn those into misses.
|
||||
*
|
||||
* The price is that unrelated names starting with the same letters match too.
|
||||
* [SKIPPED_PREFIXES] is where those come back out, and it *does* apply a dot
|
||||
* boundary, because there the entries are whole package roots.
|
||||
*/
|
||||
internal object ChinaPackageMatcher {
|
||||
|
||||
fun isSkipped(packageName: String): Boolean = SKIPPED_PREFIXES.any {
|
||||
packageName == it || packageName.startsWith("$it.")
|
||||
}
|
||||
|
||||
fun matchesKnownPrefix(name: String): Boolean = name.matches(CHINA_PACKAGE_REGEX)
|
||||
|
||||
fun classNameOf(descriptor: String): String = descriptor
|
||||
.removeSurrounding("L", ";")
|
||||
.replace('/', '.')
|
||||
.replace('$', '.')
|
||||
|
||||
private val SKIPPED_PREFIXES = listOf(
|
||||
"com.google",
|
||||
"com.android.chrome",
|
||||
"com.android.vending",
|
||||
"com.microsoft",
|
||||
"com.apple",
|
||||
"com.zhiliaoapp.musically",
|
||||
// Caught by the loose "com.mx" prefix, which targets Maxthon's
|
||||
// com.mx.browser. MX Player is unrelated.
|
||||
"com.mxtech",
|
||||
// Caught by the loose "com.stub" prefix, which targets the
|
||||
// com.stub.StubApp packer. StubHub is unrelated.
|
||||
"com.stubhub",
|
||||
)
|
||||
|
||||
private 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()
|
||||
}
|
||||
@@ -14,8 +14,26 @@ internal class PackageResolver(
|
||||
private val packageManager: PackageManager,
|
||||
private val appPackageName: String,
|
||||
) {
|
||||
val installedPackages: List<InstalledPackage> by lazy(LazyThreadSafetyMode.SYNCHRONIZED) {
|
||||
loadPackages()
|
||||
private val cacheLock = Any()
|
||||
|
||||
@Volatile
|
||||
private var cachedPackages: List<InstalledPackage>? = null
|
||||
|
||||
val installedPackages: List<InstalledPackage>
|
||||
get() = cachedPackages ?: synchronized(cacheLock) {
|
||||
cachedPackages ?: loadPackages().also { cachedPackages = it }
|
||||
}
|
||||
|
||||
val isInstalledAppsPermissionSupported: Boolean by lazy {
|
||||
runCatching { packageManager.getPermissionInfo(GET_INSTALLED_APPS, 0) }.isSuccess
|
||||
}
|
||||
|
||||
fun hasInstalledAppsPermission(): Boolean = !isInstalledAppsPermissionSupported ||
|
||||
packageManager.checkPermission(GET_INSTALLED_APPS, appPackageName) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
|
||||
fun invalidate() {
|
||||
synchronized(cacheLock) { cachedPackages = null }
|
||||
}
|
||||
|
||||
fun getChinaPackageNames(): List<String> = installedPackages
|
||||
@@ -52,16 +70,16 @@ internal class PackageResolver(
|
||||
}
|
||||
|
||||
private fun isChinaPackage(packageName: String): Boolean {
|
||||
if (SKIPPED_PREFIXES.any { packageName == it || packageName.startsWith("$it.") }) {
|
||||
if (ChinaPackageMatcher.isSkipped(packageName)) {
|
||||
return false
|
||||
}
|
||||
if (packageName.matches(CHINA_PACKAGE_REGEX)) {
|
||||
if (ChinaPackageMatcher.matchesKnownPrefix(packageName)) {
|
||||
return true
|
||||
}
|
||||
|
||||
return runCatching {
|
||||
val packageInfo = getPackageInfo(packageName)
|
||||
packageInfo.componentNames().any { it.matches(CHINA_PACKAGE_REGEX) } ||
|
||||
packageInfo.componentNames().any(ChinaPackageMatcher::matchesKnownPrefix) ||
|
||||
packageInfo.applicationInfo?.publicSourceDir?.let(::scanArchive) == true
|
||||
}.getOrDefault(false)
|
||||
}
|
||||
@@ -100,75 +118,22 @@ internal class PackageResolver(
|
||||
DexBackedDexFile.fromInputStream(null, input)
|
||||
}
|
||||
dexFile.classes.any { clazz ->
|
||||
clazz.type
|
||||
.removeSurrounding("L", ";")
|
||||
.replace('/', '.')
|
||||
.replace('$', '.')
|
||||
.matches(CHINA_PACKAGE_REGEX)
|
||||
ChinaPackageMatcher.matchesKnownPrefix(
|
||||
ChinaPackageMatcher.classNameOf(clazz.type),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val ANDROID_PACKAGE_NAME = "android"
|
||||
const val MAX_DEX_SIZE_BYTES = 15_000_000L
|
||||
companion object {
|
||||
const val GET_INSTALLED_APPS = "com.android.permission.GET_INSTALLED_APPS"
|
||||
|
||||
val PACKAGE_INFO_FLAGS = PackageManager.GET_ACTIVITIES or
|
||||
private const val ANDROID_PACKAGE_NAME = "android"
|
||||
private const val MAX_DEX_SIZE_BYTES = 15_000_000L
|
||||
|
||||
private 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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,10 +4,15 @@ import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Activity
|
||||
import android.app.ActivityManager
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.VpnService
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.os.PowerManager
|
||||
import android.provider.Settings
|
||||
import androidx.core.app.ActivityCompat
|
||||
@@ -20,8 +25,10 @@ import androidx.core.net.toUri
|
||||
import com.follow.clash.R
|
||||
import com.follow.clash.common.Components
|
||||
import com.follow.clash.common.GlobalState
|
||||
import com.follow.clash.common.PendingCallback
|
||||
import com.follow.clash.common.QuickAction
|
||||
import com.follow.clash.common.quickIntent
|
||||
import com.follow.clash.common.registerReceiverCompat
|
||||
import com.follow.clash.getPackageIconPath
|
||||
import com.follow.clash.packages.PackageResolver
|
||||
import com.follow.clash.showToast
|
||||
@@ -32,6 +39,7 @@ import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import io.flutter.plugin.common.MethodChannel.Result
|
||||
import io.flutter.plugin.common.PluginRegistry
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
@@ -42,13 +50,23 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
|
||||
|
||||
private var activity: Activity? = null
|
||||
|
||||
private var activityBinding: ActivityPluginBinding? = null
|
||||
|
||||
private val activityResultListener =
|
||||
PluginRegistry.ActivityResultListener(::onActivityResult)
|
||||
|
||||
private val permissionsResultListener =
|
||||
PluginRegistry.RequestPermissionsResultListener(::onRequestPermissionsResultListener)
|
||||
|
||||
private lateinit var channel: MethodChannel
|
||||
|
||||
private lateinit var scope: CoroutineScope
|
||||
|
||||
private var vpnPrepareCallback: ((Boolean) -> Unit)? = null
|
||||
private val vpnPrepareCallback = PendingCallback<Boolean>()
|
||||
|
||||
private var requestNotificationCallback: ((Boolean) -> Unit)? = null
|
||||
private val requestNotificationCallback = PendingCallback<Boolean>()
|
||||
|
||||
private val requestInstalledAppsCallback = PendingCallback<Boolean>()
|
||||
|
||||
private var isRequestingNotificationPermission = false
|
||||
|
||||
@@ -61,9 +79,47 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
|
||||
)
|
||||
}
|
||||
|
||||
private var packageChangeContext: Context? = null
|
||||
|
||||
private val packageChangeReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
if (intent == null || intent.data?.schemeSpecificPart == GlobalState.application.packageName) {
|
||||
return
|
||||
}
|
||||
val addedByUpdate = intent.action == Intent.ACTION_PACKAGE_ADDED &&
|
||||
intent.getBooleanExtra(Intent.EXTRA_REPLACING, false)
|
||||
if (addedByUpdate) {
|
||||
return
|
||||
}
|
||||
packageResolver.invalidate()
|
||||
channel.invokeMethod("packagesChanged", null)
|
||||
}
|
||||
}
|
||||
|
||||
private var skipNotificationPermissionRequest = false
|
||||
|
||||
override fun onMethodCall(call: MethodCall, result: Result) {
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
|
||||
/**
|
||||
* The permission and consent hops below touch the Activity — starting an
|
||||
* activity for result, raising a permission prompt — and read the state that
|
||||
* tracks whether one is already up. Their callers are coroutines on
|
||||
* [Dispatchers.Default], while the answers come back on the main thread
|
||||
* through the ActivityAware listeners, so main is the one thread both ends
|
||||
* can agree on. A plain main-looper post rather than the plugin scope: the
|
||||
* scope is cancelled when the engine detaches, and a request dropped there
|
||||
* would leave its caller waiting for a callback that can no longer run.
|
||||
*/
|
||||
private fun onMainThread(block: () -> Unit) {
|
||||
if (Looper.myLooper() == Looper.getMainLooper()) {
|
||||
block()
|
||||
} else {
|
||||
mainHandler.post(block)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMethodCall(call: MethodCall, rawResult: Result) {
|
||||
val result = MainThreadResult(rawResult)
|
||||
when (call.method) {
|
||||
"moveTaskToBack" -> {
|
||||
activity?.moveTaskToBack(true)
|
||||
@@ -86,16 +142,20 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
|
||||
}
|
||||
}
|
||||
|
||||
"getPackages" -> {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
result.success(gson.toJson(packageResolver.installedPackages))
|
||||
}
|
||||
"getPackages" -> reply(result) {
|
||||
gson.toJson(packageResolver.installedPackages)
|
||||
}
|
||||
|
||||
"getChinaPackageNames" -> {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
result.success(gson.toJson(packageResolver.getChinaPackageNames()))
|
||||
}
|
||||
"getChinaPackageNames" -> reply(result) {
|
||||
gson.toJson(packageResolver.getChinaPackageNames())
|
||||
}
|
||||
|
||||
"isInstalledAppsPermissionGranted" -> reply(result) {
|
||||
packageResolver.hasInstalledAppsPermission()
|
||||
}
|
||||
|
||||
"requestInstalledAppsPermission" -> {
|
||||
requestInstalledAppsPermission { granted -> result.success(granted) }
|
||||
}
|
||||
|
||||
"getPackageIcon" -> {
|
||||
@@ -120,8 +180,12 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
|
||||
result.success(openAppSettings())
|
||||
}
|
||||
|
||||
"didCrashOnPreviousExecution" -> {
|
||||
result.success(GlobalState.didCrashOnPreviousExecution())
|
||||
"didCrashOnPreviousExecution" -> reply(result) {
|
||||
GlobalState.didCrashOnPreviousExecution()
|
||||
}
|
||||
|
||||
"getLastExitInfo" -> reply(result) {
|
||||
GlobalState.lastExitInfo()
|
||||
}
|
||||
|
||||
else -> {
|
||||
@@ -130,15 +194,21 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleGetPackageIcon(call: MethodCall, result: Result) {
|
||||
scope.launch {
|
||||
val packageName = call.argument<String>("packageName")
|
||||
if (packageName == null) {
|
||||
result.success("")
|
||||
return@launch
|
||||
private fun handleGetPackageIcon(call: MethodCall, result: Result) = reply(result) {
|
||||
val packageName = call.argument<String>("packageName") ?: ""
|
||||
GlobalState.application.packageManager.getPackageIconPath(packageName)
|
||||
}
|
||||
|
||||
// A throw inside the plugin scope has no handler and ends the process;
|
||||
// the Dart caller gets a PlatformException to handle instead.
|
||||
private fun reply(result: Result, block: suspend () -> Any?) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
result.success(block())
|
||||
} catch (error: Exception) {
|
||||
GlobalState.log("Platform call failed: $error")
|
||||
result.error("PLATFORM_ERROR", error.toString(), null)
|
||||
}
|
||||
val path = GlobalState.application.packageManager.getPackageIconPath(packageName)
|
||||
result.success(path)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -207,9 +277,8 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
|
||||
task?.setExcludeFromRecents(value ?: false)
|
||||
}
|
||||
|
||||
fun requestNotificationPermission(callback: (Boolean) -> Unit) {
|
||||
requestNotificationCallback?.invoke(false)
|
||||
requestNotificationCallback = callback
|
||||
fun requestNotificationPermission(callback: (Boolean) -> Unit) = onMainThread {
|
||||
requestNotificationCallback.replace(callback, supersededValue = false)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
val permission = ContextCompat.checkSelfPermission(
|
||||
GlobalState.application,
|
||||
@@ -217,10 +286,10 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
|
||||
)
|
||||
if (permission == PackageManager.PERMISSION_GRANTED || skipNotificationPermissionRequest) {
|
||||
invokeRequestNotificationCallback(true)
|
||||
return
|
||||
return@onMainThread
|
||||
}
|
||||
if (isRequestingNotificationPermission) {
|
||||
return
|
||||
return@onMainThread
|
||||
}
|
||||
isRequestingNotificationPermission = true
|
||||
activity?.let {
|
||||
@@ -230,23 +299,46 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
|
||||
NOTIFICATION_PERMISSION_REQUEST_CODE,
|
||||
)
|
||||
} ?: invokeRequestNotificationCallback(true)
|
||||
return
|
||||
return@onMainThread
|
||||
}
|
||||
invokeRequestNotificationCallback(true)
|
||||
}
|
||||
|
||||
private fun invokeRequestNotificationCallback(shouldStart: Boolean) {
|
||||
isRequestingNotificationPermission = false
|
||||
requestNotificationCallback?.invoke(shouldStart)
|
||||
requestNotificationCallback = null
|
||||
requestNotificationCallback.resolve(shouldStart)
|
||||
}
|
||||
|
||||
fun prepareVpn(needPrepare: Boolean, callback: (Boolean) -> Unit) {
|
||||
invokeVpnPrepareCallback(false)
|
||||
vpnPrepareCallback = callback
|
||||
private fun requestInstalledAppsPermission(callback: (Boolean) -> Unit) = onMainThread {
|
||||
requestInstalledAppsCallback.replace(callback, supersededValue = false)
|
||||
val activity = activity
|
||||
if (packageResolver.hasInstalledAppsPermission()) {
|
||||
invokeRequestInstalledAppsCallback(true)
|
||||
return@onMainThread
|
||||
}
|
||||
if (activity == null) {
|
||||
invokeRequestInstalledAppsCallback(false)
|
||||
return@onMainThread
|
||||
}
|
||||
ActivityCompat.requestPermissions(
|
||||
activity,
|
||||
arrayOf(PackageResolver.GET_INSTALLED_APPS),
|
||||
INSTALLED_APPS_PERMISSION_REQUEST_CODE,
|
||||
)
|
||||
}
|
||||
|
||||
private fun invokeRequestInstalledAppsCallback(granted: Boolean) {
|
||||
if (granted) {
|
||||
packageResolver.invalidate()
|
||||
}
|
||||
requestInstalledAppsCallback.resolve(granted)
|
||||
}
|
||||
|
||||
fun prepareVpn(needPrepare: Boolean, callback: (Boolean) -> Unit) = onMainThread {
|
||||
vpnPrepareCallback.replace(callback, supersededValue = false)
|
||||
if (!needPrepare) {
|
||||
invokeVpnPrepareCallback(true)
|
||||
return
|
||||
return@onMainThread
|
||||
}
|
||||
val intent = VpnService.prepare(GlobalState.application)
|
||||
if (intent != null) {
|
||||
@@ -257,20 +349,19 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
|
||||
@Suppress("DEPRECATION")
|
||||
activity.startActivityForResult(intent, VPN_PERMISSION_REQUEST_CODE)
|
||||
}
|
||||
return
|
||||
return@onMainThread
|
||||
}
|
||||
invokeVpnPrepareCallback(true)
|
||||
}
|
||||
|
||||
fun cancelVpnPreparation(callback: (Boolean) -> Unit) {
|
||||
if (vpnPrepareCallback === callback) {
|
||||
vpnPrepareCallback = null
|
||||
}
|
||||
// Posted rather than run where the cancellation lands, so it stays ordered
|
||||
// behind the prepareVpn that installed the callback it is cancelling.
|
||||
fun cancelVpnPreparation(callback: (Boolean) -> Unit) = onMainThread {
|
||||
vpnPrepareCallback.cancel(callback)
|
||||
}
|
||||
|
||||
private fun invokeVpnPrepareCallback(granted: Boolean) {
|
||||
vpnPrepareCallback?.invoke(granted)
|
||||
vpnPrepareCallback = null
|
||||
vpnPrepareCallback.resolve(granted)
|
||||
}
|
||||
|
||||
override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
|
||||
@@ -278,13 +369,28 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
|
||||
channel =
|
||||
MethodChannel(flutterPluginBinding.binaryMessenger, "${Components.PACKAGE_NAME}/app")
|
||||
channel.setMethodCallHandler(this)
|
||||
watchPackageChanges(flutterPluginBinding.applicationContext)
|
||||
}
|
||||
|
||||
private fun watchPackageChanges(context: Context) {
|
||||
val filter = IntentFilter().apply {
|
||||
addAction(Intent.ACTION_PACKAGE_ADDED)
|
||||
addAction(Intent.ACTION_PACKAGE_REPLACED)
|
||||
addAction(Intent.ACTION_PACKAGE_FULLY_REMOVED)
|
||||
addDataScheme("package")
|
||||
}
|
||||
context.registerReceiverCompat(packageChangeReceiver, filter)
|
||||
packageChangeContext = context
|
||||
}
|
||||
|
||||
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||
packageChangeContext?.unregisterReceiver(packageChangeReceiver)
|
||||
packageChangeContext = null
|
||||
channel.setMethodCallHandler(null)
|
||||
scope.cancel()
|
||||
invokeVpnPrepareCallback(false)
|
||||
invokeRequestNotificationCallback(false)
|
||||
invokeRequestInstalledAppsCallback(false)
|
||||
}
|
||||
|
||||
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
|
||||
@@ -292,13 +398,23 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
|
||||
}
|
||||
|
||||
private fun attachToActivity(binding: ActivityPluginBinding) {
|
||||
detachFromActivity()
|
||||
activityBinding = binding
|
||||
activity = binding.activity
|
||||
binding.addActivityResultListener(::onActivityResult)
|
||||
binding.addRequestPermissionsResultListener(::onRequestPermissionsResultListener)
|
||||
binding.addActivityResultListener(activityResultListener)
|
||||
binding.addRequestPermissionsResultListener(permissionsResultListener)
|
||||
}
|
||||
|
||||
private fun detachFromActivity() {
|
||||
activity = null
|
||||
val binding = activityBinding ?: return
|
||||
activityBinding = null
|
||||
binding.removeActivityResultListener(activityResultListener)
|
||||
binding.removeRequestPermissionsResultListener(permissionsResultListener)
|
||||
}
|
||||
|
||||
override fun onDetachedFromActivityForConfigChanges() {
|
||||
activity = null
|
||||
detachFromActivity()
|
||||
}
|
||||
|
||||
override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
|
||||
@@ -307,9 +423,10 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
|
||||
|
||||
override fun onDetachedFromActivity() {
|
||||
channel.invokeMethod("exit", null)
|
||||
activity = null
|
||||
detachFromActivity()
|
||||
invokeVpnPrepareCallback(false)
|
||||
invokeRequestNotificationCallback(false)
|
||||
invokeRequestInstalledAppsCallback(false)
|
||||
}
|
||||
|
||||
private fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean {
|
||||
@@ -324,17 +441,27 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
|
||||
requestCode: Int,
|
||||
permissions: Array<String>,
|
||||
grantResults: IntArray,
|
||||
): Boolean {
|
||||
if (requestCode != NOTIFICATION_PERMISSION_REQUEST_CODE) {
|
||||
return false
|
||||
): Boolean = when (requestCode) {
|
||||
NOTIFICATION_PERMISSION_REQUEST_CODE -> {
|
||||
skipNotificationPermissionRequest = true
|
||||
invokeRequestNotificationCallback(true)
|
||||
true
|
||||
}
|
||||
skipNotificationPermissionRequest = true
|
||||
invokeRequestNotificationCallback(true)
|
||||
return true
|
||||
|
||||
INSTALLED_APPS_PERMISSION_REQUEST_CODE -> {
|
||||
invokeRequestInstalledAppsCallback(
|
||||
grantResults.isNotEmpty() &&
|
||||
grantResults[0] == PackageManager.PERMISSION_GRANTED,
|
||||
)
|
||||
true
|
||||
}
|
||||
|
||||
else -> false
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val VPN_PERMISSION_REQUEST_CODE = 1001
|
||||
const val NOTIFICATION_PERMISSION_REQUEST_CODE = 1002
|
||||
const val INSTALLED_APPS_PERMISSION_REQUEST_CODE = 1003
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.follow.clash.plugins
|
||||
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
|
||||
/**
|
||||
* Delivers a [MethodChannel.Result] on the main thread, which is where Flutter
|
||||
* requires it, from handlers that finish on a background dispatcher.
|
||||
*
|
||||
* The hop goes through the main looper rather than the plugin's coroutine scope:
|
||||
* that scope is cancelled when the engine detaches, and a reply dropped by a
|
||||
* cancelled scope leaves the Dart future waiting forever. A reply posted after
|
||||
* the engine is gone is the messenger's problem to ignore, and it does.
|
||||
*/
|
||||
internal class MainThreadResult(
|
||||
private val delegate: MethodChannel.Result,
|
||||
) : MethodChannel.Result {
|
||||
override fun success(result: Any?) = post { delegate.success(result) }
|
||||
|
||||
override fun error(code: String, message: String?, details: Any?) =
|
||||
post { delegate.error(code, message, details) }
|
||||
|
||||
override fun notImplemented() = post { delegate.notImplemented() }
|
||||
|
||||
private fun post(block: () -> Unit) {
|
||||
if (Looper.myLooper() == Looper.getMainLooper()) {
|
||||
block()
|
||||
} else {
|
||||
mainHandler.post(block)
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val mainHandler = Handler(Looper.getMainLooper())
|
||||
}
|
||||
}
|
||||
@@ -31,7 +31,11 @@ class ServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
|
||||
ServiceController.setEventListener(null)
|
||||
}
|
||||
|
||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||
override fun onMethodCall(call: MethodCall, rawResult: MethodChannel.Result) {
|
||||
// Most handlers below reply from a scope worker on Dispatchers.Default,
|
||||
// but a MethodChannel.Result has to be answered on the platform thread.
|
||||
// Wrapping once here covers every branch, including notImplemented.
|
||||
val result = MainThreadResult(rawResult)
|
||||
when (call.method) {
|
||||
"init" -> initialize(result)
|
||||
"shutdown" -> shutdown(result)
|
||||
|
||||
@@ -14,6 +14,9 @@ rootProject.layout.buildDirectory.value(newBuildDir)
|
||||
subprojects {
|
||||
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
|
||||
project.layout.buildDirectory.value(newSubprojectBuildDir)
|
||||
}
|
||||
|
||||
subprojects {
|
||||
project.evaluationDependsOn(":app")
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,11 @@ android {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
// Unit tests live under android/tests/ instead of each module's src/test.
|
||||
getByName("test").java.setSrcDirs(listOf("../tests/common"))
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
@@ -30,4 +35,5 @@ dependencies {
|
||||
implementation(platform(libs.firebase.bom))
|
||||
implementation(libs.firebase.crashlytics.ndk)
|
||||
implementation(libs.firebase.analytics)
|
||||
testImplementation(libs.junit)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.follow.clash.common
|
||||
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
/**
|
||||
* A `BroadcastReceiver.goAsync()` path holds the broadcast open until its
|
||||
* `PendingResult` is finished, and finishing twice throws. Normal completion and
|
||||
* the timeout watchdog both race to release it, so the winner is decided here
|
||||
* rather than by whichever callback happens to run first.
|
||||
*
|
||||
* Releasing the lease says only that Android may stop waiting for this receiver.
|
||||
* It does not cancel, reverse, or otherwise redefine the work the broadcast
|
||||
* started, which keeps running under its own owner.
|
||||
*/
|
||||
class BroadcastLease(private val release: () -> Unit) {
|
||||
private val released = AtomicBoolean(false)
|
||||
|
||||
val isReleased: Boolean
|
||||
get() = released.get()
|
||||
|
||||
fun release(onRelease: () -> Unit = {}): Boolean {
|
||||
if (!released.compareAndSet(false, true)) {
|
||||
return false
|
||||
}
|
||||
try {
|
||||
onRelease()
|
||||
} finally {
|
||||
release.invoke()
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
package com.follow.clash.common
|
||||
|
||||
import android.app.ActivityManager
|
||||
import android.app.Application
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import com.google.firebase.FirebaseApp
|
||||
import com.google.firebase.crashlytics.FirebaseCrashlytics
|
||||
@@ -11,6 +13,8 @@ import kotlinx.coroutines.SupervisorJob
|
||||
object GlobalState : CoroutineScope by CoroutineScope(SupervisorJob() + Dispatchers.Default) {
|
||||
const val NOTIFICATION_CHANNEL = "FlClash"
|
||||
const val NOTIFICATION_ID = 1
|
||||
private const val ANY_PID = 0
|
||||
private const val EVERY_EXIT_RECORD = 0
|
||||
|
||||
val packageName: String
|
||||
get() = application.packageName
|
||||
@@ -44,4 +48,21 @@ object GlobalState : CoroutineScope by CoroutineScope(SupervisorJob() + Dispatch
|
||||
FirebaseApp.initializeApp(application)
|
||||
return FirebaseCrashlytics.getInstance().didCrashOnPreviousExecution()
|
||||
}
|
||||
|
||||
fun lastExitInfo(): Map<String, Any?>? {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return null
|
||||
val manager = application.getSystemService(ActivityManager::class.java) ?: return null
|
||||
val info = runCatching {
|
||||
manager.getHistoricalProcessExitReasons(
|
||||
application.packageName,
|
||||
ANY_PID,
|
||||
EVERY_EXIT_RECORD,
|
||||
)
|
||||
}.getOrNull()?.firstOrNull { it.processName == application.packageName } ?: return null
|
||||
return mapOf(
|
||||
"reason" to info.reason,
|
||||
"timestamp" to info.timestamp,
|
||||
"description" to info.description,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.follow.clash.common
|
||||
|
||||
/**
|
||||
* Only the newest request is ever pending: [replace] settles the request it
|
||||
* supersedes rather than dropping it, so no caller is left waiting forever. The
|
||||
* slot is cleared before the callback runs, so a callback that starts another
|
||||
* request cannot resolve itself twice.
|
||||
*
|
||||
* The two ends of such a hop live on different threads — the request is started
|
||||
* from whatever coroutine wants the permission, the answer arrives on the main
|
||||
* thread through `onActivityResult` — so every read and write of the slot is a
|
||||
* single atomic swap. Without that, a resolve that has already read the slot can
|
||||
* clear a callback a concurrent replace installed after it, and the request that
|
||||
* callback belonged to never completes: the VPN consent answer is lost and the
|
||||
* lock the start path holds is never released.
|
||||
*
|
||||
* Callbacks run outside the lock. They resume coroutines and can start the next
|
||||
* request, and holding the monitor across that would make the slot's lock part
|
||||
* of every caller's lock order.
|
||||
*/
|
||||
class PendingCallback<T> {
|
||||
private val lock = Any()
|
||||
private var callback: ((T) -> Unit)? = null
|
||||
|
||||
val isPending: Boolean
|
||||
get() = synchronized(lock) { callback != null }
|
||||
|
||||
fun replace(next: (T) -> Unit, supersededValue: T) {
|
||||
val superseded = synchronized(lock) {
|
||||
val current = callback
|
||||
callback = next
|
||||
current
|
||||
}
|
||||
superseded?.invoke(supersededValue)
|
||||
}
|
||||
|
||||
fun resolve(value: T) {
|
||||
val current = synchronized(lock) {
|
||||
val current = callback ?: return
|
||||
callback = null
|
||||
current
|
||||
}
|
||||
current(value)
|
||||
}
|
||||
|
||||
fun cancel(target: (T) -> Unit) {
|
||||
synchronized(lock) {
|
||||
if (callback === target) {
|
||||
callback = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.follow.clash.common
|
||||
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
|
||||
/**
|
||||
* Every request mints a fresh [Token]. Work that was started for an older token is obsolete once a
|
||||
* newer request arrives, so callers check [isCurrent] before applying a result and roll back with
|
||||
* [resetToStopped], which only wins while the token is still the latest one.
|
||||
*/
|
||||
class RunIntentArbiter(initialRunning: Boolean = false) {
|
||||
class Token internal constructor(
|
||||
val running: Boolean,
|
||||
)
|
||||
|
||||
private val latest = AtomicReference(Token(initialRunning))
|
||||
|
||||
val isRunningRequested: Boolean
|
||||
get() = latest.get().running
|
||||
|
||||
fun current(): Token = latest.get()
|
||||
|
||||
fun request(running: Boolean): Token = Token(running).also(latest::set)
|
||||
|
||||
fun isCurrent(token: Token): Boolean = latest.get() === token
|
||||
|
||||
fun resetToStopped(token: Token): Boolean =
|
||||
latest.compareAndSet(token, Token(running = false))
|
||||
}
|
||||
@@ -4,6 +4,15 @@ plugins {
|
||||
id("com.android.library")
|
||||
}
|
||||
|
||||
// Must equal Target.forPlatform("android") in setup_hooks, which writes libclash.so only for the platforms being built.
|
||||
val coreAbiByPlatform =
|
||||
linkedMapOf("android-arm" to "armeabi-v7a", "android-arm64" to "arm64-v8a", "android-x64" to "x86_64")
|
||||
val coreAbis =
|
||||
(rootProject.findProperty("target-platform") as String?)
|
||||
?.split(",")
|
||||
?.map { coreAbiByPlatform[it.trim()] ?: throw GradleException("No Core for Flutter target platform $it") }
|
||||
?: coreAbiByPlatform.values.toList()
|
||||
|
||||
android {
|
||||
namespace = "com.follow.clash.core"
|
||||
compileSdk = libs.versions.compileSdk.get().toInt()
|
||||
@@ -11,6 +20,10 @@ android {
|
||||
|
||||
defaultConfig {
|
||||
minSdk = libs.versions.minSdk.get().toInt()
|
||||
|
||||
ndk {
|
||||
abiFilters += coreAbis
|
||||
}
|
||||
}
|
||||
|
||||
externalNativeBuild {
|
||||
@@ -36,3 +49,39 @@ kotlin {
|
||||
dependencies {
|
||||
implementation(libs.annotation.jvm)
|
||||
}
|
||||
|
||||
// Why prefix matching and mustRunAfter: .agents/architecture.md, "Android Native Task Ordering".
|
||||
val nativeTaskPattern =
|
||||
Regex("^(configureCMake|buildCMake|externalNativeBuild|merge.*(NativeLibs|JniLibFolders)|copy.*JniLibs)")
|
||||
val flutterCompileTasks =
|
||||
rootProject.project(":app").tasks.matching { it.name.startsWith("compileFlutterBuild") }
|
||||
|
||||
tasks.matching { nativeTaskPattern.containsMatchIn(it.name) }.configureEach {
|
||||
mustRunAfter(flutterCompileTasks)
|
||||
}
|
||||
|
||||
gradle.taskGraph.whenReady {
|
||||
val nativeTask =
|
||||
allTasks.firstOrNull {
|
||||
it.path.startsWith("${project.path}:") && nativeTaskPattern.containsMatchIn(it.name)
|
||||
}
|
||||
if (nativeTask == null || allTasks.any { it.path.startsWith(":app:compileFlutterBuild") }) {
|
||||
return@whenReady
|
||||
}
|
||||
val missing =
|
||||
coreAbis
|
||||
.flatMap {
|
||||
listOf(file("src/main/jniLibs/$it/libclash.so"), file("src/main/cpp/includes/$it/libclash.h"))
|
||||
}.filterNot { it.isFile }
|
||||
if (missing.isNotEmpty()) {
|
||||
throw GradleException(
|
||||
"${nativeTask.name} consumes Core artifacts that the setup build hook writes into src/main, " +
|
||||
"and these are absent: ${missing.joinToString { it.relativeTo(projectDir).path }}. " +
|
||||
"Run `dart setup.dart android` once, or build through :app so the hook runs first.",
|
||||
)
|
||||
}
|
||||
logger.warn(
|
||||
":core: ${nativeTask.name} is running without an :app Flutter compile task, " +
|
||||
"so the Core artifacts under src/main are whatever the last hook run left behind",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -13,26 +13,16 @@ if (NOT "${CMAKE_BUILD_TYPE}" STREQUAL "Debug")
|
||||
add_link_options(-flto -Wl,--gc-sections,--strip-all)
|
||||
endif ()
|
||||
|
||||
set(LIB_CLASH_PATH "${CMAKE_SOURCE_DIR}/../jniLibs/${ANDROID_ABI}/libclash.so")
|
||||
set(LIB_CLASH_INCLUDE_DIR "${CMAKE_SOURCE_DIR}/../cpp/includes/${ANDROID_ABI}")
|
||||
set(LIB_CLASH_HEADER_PATH "${LIB_CLASH_INCLUDE_DIR}/libclash.h")
|
||||
set(BRIDE_HEADER_PATH "${LIB_CLASH_INCLUDE_DIR}/bride.h")
|
||||
|
||||
message("LIB_CLASH_PATH ${LIB_CLASH_PATH}")
|
||||
if (EXISTS ${LIB_CLASH_PATH} AND EXISTS ${LIB_CLASH_HEADER_PATH} AND EXISTS ${BRIDE_HEADER_PATH})
|
||||
message("Found libclash.so and headers for ABI ${ANDROID_ABI}")
|
||||
add_compile_definitions(LIBCLASH)
|
||||
include_directories(${LIB_CLASH_INCLUDE_DIR})
|
||||
link_directories(${CMAKE_SOURCE_DIR}/../jniLibs/${ANDROID_ABI})
|
||||
add_library(${CMAKE_PROJECT_NAME} SHARED
|
||||
jni_helper.cpp
|
||||
core.cpp)
|
||||
target_link_libraries(${CMAKE_PROJECT_NAME}
|
||||
clash)
|
||||
else ()
|
||||
message("Not found complete libclash output for ABI ${ANDROID_ABI}; building core without LIBCLASH")
|
||||
add_library(${CMAKE_PROJECT_NAME} SHARED
|
||||
jni_helper.cpp
|
||||
core.cpp)
|
||||
target_link_libraries(${CMAKE_PROJECT_NAME})
|
||||
endif ()
|
||||
# Never make linking libclash conditional on the file existing: AGP's configure
|
||||
# fingerprint tracks only CMakeLists.txt, so a configure that ran before the build
|
||||
# hook would stay cached and silently drop the Core. Failing at compile or link
|
||||
# time is what keeps that decision out of the configure step.
|
||||
include_directories(${LIB_CLASH_INCLUDE_DIR})
|
||||
link_directories(${CMAKE_SOURCE_DIR}/../jniLibs/${ANDROID_ABI})
|
||||
add_library(${CMAKE_PROJECT_NAME} SHARED
|
||||
jni_helper.cpp
|
||||
core.cpp)
|
||||
target_link_libraries(${CMAKE_PROJECT_NAME}
|
||||
clash)
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
#include <jni.h>
|
||||
|
||||
#ifdef LIBCLASH
|
||||
#include <cstring>
|
||||
|
||||
#include "jni_helper.h"
|
||||
#include "libclash.h"
|
||||
#include "bride.h"
|
||||
|
||||
extern "C"
|
||||
JNIEXPORT void JNICALL
|
||||
JNIEXPORT jboolean JNICALL
|
||||
Java_com_follow_clash_core_Core_startTun(JNIEnv *env, jobject thiz, jint fd, jobject cb,
|
||||
jstring stack, jstring address, jstring dns) {
|
||||
const auto interface = new_global(cb);
|
||||
startTUN(interface, fd, get_string(stack), get_string(address), get_string(dns));
|
||||
return startTUN(interface, fd, get_string(stack), get_string(address), get_string(dns))
|
||||
? JNI_TRUE
|
||||
: JNI_FALSE;
|
||||
}
|
||||
|
||||
extern "C"
|
||||
@@ -82,7 +84,8 @@ Java_com_follow_clash_core_Core_quickSetup(JNIEnv *env, jobject thiz, jstring in
|
||||
|
||||
|
||||
static jmethodID m_tun_interface_protect;
|
||||
static jmethodID m_tun_interface_resolve_process;
|
||||
static jmethodID m_tun_interface_resolve_uid;
|
||||
static jmethodID m_tun_interface_resolve_package;
|
||||
static jmethodID m_invoke_interface_result;
|
||||
|
||||
|
||||
@@ -95,30 +98,56 @@ static void free_string_impl(char *str) {
|
||||
free(str);
|
||||
}
|
||||
|
||||
static void call_tun_interface_protect_impl(void *tun_interface, const int fd) {
|
||||
static int call_tun_interface_protect_impl(void *tun_interface, const int fd) {
|
||||
// ART aborts the process on a call through a null object, so a callback
|
||||
// that was already released has to stop here rather than at the JNI call.
|
||||
if (tun_interface == nullptr) {
|
||||
return 0;
|
||||
}
|
||||
ATTACH_JNI();
|
||||
env->CallVoidMethod(static_cast<jobject>(tun_interface),
|
||||
m_tun_interface_protect,
|
||||
fd);
|
||||
const auto accepted = env->CallBooleanMethod(static_cast<jobject>(tun_interface),
|
||||
m_tun_interface_protect,
|
||||
fd);
|
||||
if (jni_clear_exception(env)) {
|
||||
return 0;
|
||||
}
|
||||
return accepted == JNI_TRUE ? 1 : 0;
|
||||
}
|
||||
|
||||
static char *
|
||||
call_tun_interface_resolve_process_impl(void *tun_interface, const int protocol,
|
||||
const char *source,
|
||||
const char *target,
|
||||
const int uid) {
|
||||
static int call_tun_interface_resolve_uid_impl(void *tun_interface, const int protocol,
|
||||
const char *source,
|
||||
const char *target) {
|
||||
if (tun_interface == nullptr) {
|
||||
return -1;
|
||||
}
|
||||
ATTACH_JNI();
|
||||
const auto source_string = new_string(source);
|
||||
const auto target_string = new_string(target);
|
||||
const auto uid = env->CallIntMethod(static_cast<jobject>(tun_interface),
|
||||
m_tun_interface_resolve_uid,
|
||||
protocol,
|
||||
source_string,
|
||||
target_string);
|
||||
const auto failed = jni_clear_exception(env);
|
||||
if (source_string != nullptr) {
|
||||
env->DeleteLocalRef(source_string);
|
||||
}
|
||||
if (target_string != nullptr) {
|
||||
env->DeleteLocalRef(target_string);
|
||||
}
|
||||
return failed ? -1 : uid;
|
||||
}
|
||||
|
||||
static char *call_tun_interface_resolve_package_impl(void *tun_interface, const int uid) {
|
||||
if (tun_interface == nullptr) {
|
||||
return strdup("");
|
||||
}
|
||||
ATTACH_JNI();
|
||||
const auto package_name = reinterpret_cast<jstring>(env->CallObjectMethod(
|
||||
static_cast<jobject>(tun_interface),
|
||||
m_tun_interface_resolve_process,
|
||||
protocol,
|
||||
source_string,
|
||||
target_string,
|
||||
m_tun_interface_resolve_package,
|
||||
uid));
|
||||
env->DeleteLocalRef(source_string);
|
||||
env->DeleteLocalRef(target_string);
|
||||
jni_clear_exception(env);
|
||||
const auto result = get_string(package_name);
|
||||
if (package_name != nullptr) {
|
||||
env->DeleteLocalRef(package_name);
|
||||
@@ -127,12 +156,18 @@ call_tun_interface_resolve_process_impl(void *tun_interface, const int protocol,
|
||||
}
|
||||
|
||||
static void call_invoke_interface_result_impl(void *invoke_interface, const char *data) {
|
||||
if (invoke_interface == nullptr) {
|
||||
return;
|
||||
}
|
||||
ATTACH_JNI();
|
||||
const auto value = new_string(data);
|
||||
env->CallVoidMethod(static_cast<jobject>(invoke_interface),
|
||||
m_invoke_interface_result,
|
||||
value);
|
||||
env->DeleteLocalRef(value);
|
||||
jni_clear_exception(env);
|
||||
if (value != nullptr) {
|
||||
env->DeleteLocalRef(value);
|
||||
}
|
||||
}
|
||||
|
||||
extern "C"
|
||||
@@ -149,74 +184,21 @@ JNI_OnLoad(JavaVM *vm, void *) {
|
||||
|
||||
const auto c_invoke_interface = find_class("com/follow/clash/core/InvokeInterface");
|
||||
|
||||
m_tun_interface_protect = find_method(c_tun_interface, "protect", "(I)V");
|
||||
m_tun_interface_resolve_process = find_method(c_tun_interface, "resolverProcess",
|
||||
"(ILjava/lang/String;Ljava/lang/String;I)Ljava/lang/String;");
|
||||
m_tun_interface_protect = find_method(c_tun_interface, "protect", "(I)Z");
|
||||
m_tun_interface_resolve_uid = find_method(c_tun_interface, "resolveUid",
|
||||
"(ILjava/lang/String;Ljava/lang/String;)I");
|
||||
m_tun_interface_resolve_package = find_method(c_tun_interface, "resolvePackage",
|
||||
"(I)Ljava/lang/String;");
|
||||
m_invoke_interface_result = find_method(c_invoke_interface, "onResult",
|
||||
"(Ljava/lang/String;)V");
|
||||
|
||||
|
||||
protect_func = &call_tun_interface_protect_impl;
|
||||
resolve_process_func = &call_tun_interface_resolve_process_impl;
|
||||
resolve_uid_func = &call_tun_interface_resolve_uid_impl;
|
||||
resolve_package_func = &call_tun_interface_resolve_package_impl;
|
||||
result_func = &call_invoke_interface_result_impl;
|
||||
release_object_func = &release_jni_object_impl;
|
||||
free_string_func = &free_string_impl;
|
||||
|
||||
return JNI_VERSION_1_6;
|
||||
}
|
||||
#else
|
||||
extern "C"
|
||||
JNIEXPORT void JNICALL
|
||||
Java_com_follow_clash_core_Core_startTun(JNIEnv *env, jobject thiz, jint fd, jobject cb,
|
||||
jstring stack, jstring address, jstring dns) {
|
||||
}
|
||||
|
||||
extern "C"
|
||||
JNIEXPORT void JNICALL
|
||||
Java_com_follow_clash_core_Core_stopTun(JNIEnv *env, jobject thiz) {
|
||||
}
|
||||
|
||||
extern "C"
|
||||
JNIEXPORT void JNICALL
|
||||
Java_com_follow_clash_core_Core_invokeMethod(JNIEnv *env, jobject thiz, jstring data, jobject cb) {
|
||||
}
|
||||
|
||||
extern "C"
|
||||
JNIEXPORT void JNICALL
|
||||
Java_com_follow_clash_core_Core_forceGC(JNIEnv *env, jobject thiz) {
|
||||
}
|
||||
|
||||
extern "C"
|
||||
JNIEXPORT void JNICALL
|
||||
Java_com_follow_clash_core_Core_updateDNS(JNIEnv *env, jobject thiz, jstring dns) {
|
||||
}
|
||||
|
||||
extern "C"
|
||||
JNIEXPORT void JNICALL
|
||||
Java_com_follow_clash_core_Core_setEventListener(JNIEnv *env, jobject thiz, jobject cb) {
|
||||
}
|
||||
|
||||
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"
|
||||
JNIEXPORT void JNICALL
|
||||
Java_com_follow_clash_core_Core_suspended(JNIEnv *env, jobject thiz, jboolean suspended) {
|
||||
}
|
||||
|
||||
extern "C"
|
||||
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
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <pthread.h>
|
||||
|
||||
static JavaVM *global_vm;
|
||||
|
||||
@@ -9,33 +10,86 @@ static jclass c_string;
|
||||
static jmethodID m_new_string;
|
||||
static jmethodID m_get_bytes;
|
||||
|
||||
static pthread_key_t detach_key;
|
||||
static bool detach_key_ready;
|
||||
|
||||
static void detach_current_thread(void *) {
|
||||
global_vm->DetachCurrentThread();
|
||||
}
|
||||
|
||||
void initialize_jni(JavaVM *vm, JNIEnv *env) {
|
||||
global_vm = vm;
|
||||
|
||||
detach_key_ready = pthread_key_create(&detach_key, detach_current_thread) == 0;
|
||||
|
||||
c_string = reinterpret_cast<jclass>(new_global(find_class("java/lang/String")));
|
||||
m_new_string = find_method(c_string, "<init>", "([B)V");
|
||||
m_get_bytes = find_method(c_string, "getBytes", "()[B");
|
||||
}
|
||||
|
||||
bool jni_clear_exception(JNIEnv *env) {
|
||||
if (env->ExceptionCheck() == JNI_FALSE) {
|
||||
return false;
|
||||
}
|
||||
env->ExceptionDescribe();
|
||||
env->ExceptionClear();
|
||||
return true;
|
||||
}
|
||||
|
||||
static char *empty_string() {
|
||||
return static_cast<char *>(calloc(1, 1));
|
||||
}
|
||||
|
||||
char *jni_get_string(JNIEnv *env, jstring str) {
|
||||
if (str == nullptr) {
|
||||
return static_cast<char *>(calloc(1, 1));
|
||||
return empty_string();
|
||||
}
|
||||
const auto array = reinterpret_cast<jbyteArray>(env->CallObjectMethod(str, m_get_bytes));
|
||||
if (jni_clear_exception(env) || array == nullptr) {
|
||||
return empty_string();
|
||||
}
|
||||
const int length = env->GetArrayLength(array);
|
||||
const auto content = static_cast<char *>(malloc(length + 1));
|
||||
if (content == nullptr) {
|
||||
env->DeleteLocalRef(array);
|
||||
return empty_string();
|
||||
}
|
||||
env->GetByteArrayRegion(array, 0, length, reinterpret_cast<jbyte *>(content));
|
||||
if (jni_clear_exception(env)) {
|
||||
// The copy did not happen, so `content` still holds whatever malloc
|
||||
// handed back. Returning it would pass that heap content on as the
|
||||
// string the caller asked for.
|
||||
free(content);
|
||||
env->DeleteLocalRef(array);
|
||||
return empty_string();
|
||||
}
|
||||
env->DeleteLocalRef(array);
|
||||
content[length] = 0;
|
||||
return content;
|
||||
}
|
||||
|
||||
jstring jni_new_string(JNIEnv *env, const char *str) {
|
||||
if (str == nullptr) {
|
||||
str = "";
|
||||
}
|
||||
const auto length = static_cast<int>(strlen(str));
|
||||
const auto array = env->NewByteArray(length);
|
||||
if (jni_clear_exception(env) || array == nullptr) {
|
||||
return env->NewStringUTF("");
|
||||
}
|
||||
env->SetByteArrayRegion(array, 0, length, reinterpret_cast<const jbyte *>(str));
|
||||
if (jni_clear_exception(env)) {
|
||||
// Calling NewObject with an exception still pending is undefined, and
|
||||
// the array it would read from was not filled in anyway.
|
||||
env->DeleteLocalRef(array);
|
||||
return env->NewStringUTF("");
|
||||
}
|
||||
const auto result = reinterpret_cast<jstring>(env->NewObject(c_string, m_new_string, array));
|
||||
jni_clear_exception(env);
|
||||
env->DeleteLocalRef(array);
|
||||
if (result == nullptr) {
|
||||
return env->NewStringUTF("");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -44,9 +98,13 @@ void jni_attach_thread(scoped_jni *jni) {
|
||||
jni->require_release = 0;
|
||||
return;
|
||||
}
|
||||
if (global_vm->AttachCurrentThread(&jni->env, nullptr) != JNI_OK) {
|
||||
if (global_vm->AttachCurrentThreadAsDaemon(&jni->env, nullptr) != JNI_OK) {
|
||||
abort();
|
||||
}
|
||||
if (detach_key_ready && pthread_setspecific(detach_key, jni->env) == 0) {
|
||||
jni->require_release = 0;
|
||||
return;
|
||||
}
|
||||
jni->require_release = 1;
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,8 @@ extern void jni_detach_thread(const scoped_jni *env);
|
||||
|
||||
extern void release_string(char **value);
|
||||
|
||||
extern bool jni_clear_exception(JNIEnv *env);
|
||||
|
||||
#define ATTACH_JNI() __attribute__((unused, cleanup(jni_detach_thread))) \
|
||||
scoped_jni _jni{}; \
|
||||
jni_attach_thread(&_jni); \
|
||||
|
||||
@@ -11,7 +11,7 @@ object Core {
|
||||
stack: String,
|
||||
address: String,
|
||||
dns: String,
|
||||
)
|
||||
): Boolean
|
||||
|
||||
external fun forceGC()
|
||||
|
||||
@@ -29,31 +29,30 @@ object Core {
|
||||
fun startTun(
|
||||
fd: Int,
|
||||
protect: (Int) -> Boolean,
|
||||
resolverProcess: (protocol: Int, source: InetSocketAddress, target: InetSocketAddress, uid: Int) -> String,
|
||||
resolveUid: (protocol: Int, source: InetSocketAddress, target: InetSocketAddress) -> Int,
|
||||
resolvePackage: (uid: Int) -> String,
|
||||
stack: String,
|
||||
address: String,
|
||||
dns: String,
|
||||
) {
|
||||
startTun(
|
||||
): Boolean {
|
||||
return startTun(
|
||||
fd,
|
||||
object : TunInterface {
|
||||
override fun protect(fd: Int) {
|
||||
protect(fd)
|
||||
}
|
||||
override fun protect(fd: Int): Boolean = protect(fd)
|
||||
|
||||
override fun resolverProcess(
|
||||
override fun resolveUid(
|
||||
protocol: Int,
|
||||
source: String,
|
||||
target: String,
|
||||
uid: Int,
|
||||
): String {
|
||||
return resolverProcess(
|
||||
): Int {
|
||||
return resolveUid(
|
||||
protocol,
|
||||
parseInetSocketAddress(source),
|
||||
parseInetSocketAddress(target),
|
||||
uid,
|
||||
)
|
||||
}
|
||||
|
||||
override fun resolvePackage(uid: Int): String = resolvePackage(uid)
|
||||
},
|
||||
stack,
|
||||
address,
|
||||
|
||||
@@ -4,7 +4,11 @@ import androidx.annotation.Keep
|
||||
|
||||
@Keep
|
||||
interface TunInterface {
|
||||
fun protect(fd: Int)
|
||||
/** Returns false when the socket could not be kept out of the tunnel. */
|
||||
fun protect(fd: Int): Boolean
|
||||
|
||||
fun resolverProcess(protocol: Int, source: String, target: String, uid: Int): String
|
||||
/** Returns the uid owning the connection, or -1 when the system will not name it. */
|
||||
fun resolveUid(protocol: Int, source: String, target: String): Int
|
||||
|
||||
fun resolvePackage(uid: Int): String
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ annotationJvm = "1.9.1"
|
||||
coreSplashscreen = "1.0.1"
|
||||
gson = "2.13.1"
|
||||
smaliDexlib2 = "3.0.9"
|
||||
junit = "4.13.2"
|
||||
kotlinxCoroutines = "1.10.2"
|
||||
|
||||
[libraries]
|
||||
androidx-core = { module = "androidx.core:core-ktx", version.ref = "coreKtx" }
|
||||
@@ -18,4 +20,6 @@ firebase-analytics = { module = "com.google.firebase:firebase-analytics" }
|
||||
firebase-bom = { module = "com.google.firebase:firebase-bom", version.ref = "firebaseBom" }
|
||||
firebase-crashlytics-ndk = { module = "com.google.firebase:firebase-crashlytics-ndk" }
|
||||
gson = { module = "com.google.code.gson:gson", version.ref = "gson" }
|
||||
junit = { module = "junit:junit", version.ref = "junit" }
|
||||
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinxCoroutines" }
|
||||
smali-dexlib2 = { module = "com.android.tools.smali:smali-dexlib2", version.ref = "smaliDexlib2" }
|
||||
|
||||
@@ -17,6 +17,10 @@ android {
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
// Unit tests live under android/tests/ instead of each module's src/test.
|
||||
getByName("test").java.setSrcDirs(listOf("../tests/service"))
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
@@ -30,4 +34,6 @@ dependencies {
|
||||
implementation(project(":common"))
|
||||
implementation(libs.gson)
|
||||
implementation(libs.androidx.core)
|
||||
testImplementation(libs.junit)
|
||||
testImplementation(libs.kotlinx.coroutines.test)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.follow.clash.service
|
||||
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.ProxyInfo
|
||||
import android.os.Binder
|
||||
@@ -41,26 +42,26 @@ class VpnService : SystemVpnService(), ManagedService {
|
||||
}
|
||||
private val uidPackageNameMap = ConcurrentHashMap<Int, String>()
|
||||
|
||||
private fun resolverProcess(
|
||||
private fun resolveUid(
|
||||
protocol: Int,
|
||||
source: InetSocketAddress,
|
||||
target: InetSocketAddress,
|
||||
uid: Int,
|
||||
): String {
|
||||
val nextUid = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
connectivity?.getConnectionOwnerUid(protocol, source, target) ?: -1
|
||||
} else {
|
||||
uid
|
||||
}
|
||||
if (nextUid == -1) {
|
||||
return ""
|
||||
}
|
||||
return uidPackageNameMap.getOrPut(nextUid) {
|
||||
packageManager
|
||||
.getPackagesForUid(nextUid)
|
||||
?.firstOrNull()
|
||||
.orEmpty()
|
||||
): Int {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
|
||||
return -1
|
||||
}
|
||||
return connectivity?.getConnectionOwnerUid(protocol, source, target) ?: -1
|
||||
}
|
||||
|
||||
private fun resolvePackage(uid: Int): String {
|
||||
val cached = uidPackageNameMap[uid]
|
||||
if (cached != null) return cached
|
||||
val packageName = packageManager
|
||||
.getPackagesForUid(uid)
|
||||
?.firstOrNull()
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
.orEmpty()
|
||||
return uidPackageNameMap.putIfAbsent(uid, packageName) ?: packageName
|
||||
}
|
||||
|
||||
private val VpnOptions.tunAddress
|
||||
@@ -145,14 +146,22 @@ class VpnService : SystemVpnService(), ManagedService {
|
||||
synchronized(tunLock) {
|
||||
tunRunning = true
|
||||
try {
|
||||
Core.startTun(
|
||||
fd = fd,
|
||||
protect = this::protect,
|
||||
resolverProcess = this::resolverProcess,
|
||||
stack = options.stack,
|
||||
address = options.tunAddress,
|
||||
dns = options.tunDns,
|
||||
)
|
||||
// A Core that fails to take the descriptor leaves the system
|
||||
// routes pointing at an interface nothing reads, and it no
|
||||
// longer keeps its own sockets out of them: every connection
|
||||
// then hangs until it times out. Tear the VPN down instead of
|
||||
// reporting a start that only looks successful.
|
||||
check(
|
||||
Core.startTun(
|
||||
fd = fd,
|
||||
protect = this::protect,
|
||||
resolveUid = this::resolveUid,
|
||||
resolvePackage = this::resolvePackage,
|
||||
stack = options.stack,
|
||||
address = options.tunAddress,
|
||||
dns = options.tunDns,
|
||||
),
|
||||
) { "Core rejected the tun file descriptor" }
|
||||
} catch (error: Exception) {
|
||||
stopTunLocked()
|
||||
throw error
|
||||
@@ -216,15 +225,28 @@ class VpnService : SystemVpnService(), ManagedService {
|
||||
if (!accessControl.enable) return
|
||||
when (accessControl.mode) {
|
||||
AccessControlMode.ACCEPT_SELECTED -> {
|
||||
(accessControl.acceptList + packageName).forEach(::addAllowedApplication)
|
||||
(accessControl.acceptList + packageName).forEach { name ->
|
||||
addApplication(name, ::addAllowedApplication)
|
||||
}
|
||||
}
|
||||
|
||||
AccessControlMode.REJECT_SELECTED -> {
|
||||
(accessControl.rejectList - packageName).forEach(::addDisallowedApplication)
|
||||
(accessControl.rejectList - packageName).forEach { name ->
|
||||
addApplication(name, ::addDisallowedApplication)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A selected package that was uninstalled since must not veto the whole tunnel.
|
||||
private fun addApplication(name: String, add: (String) -> Builder) {
|
||||
try {
|
||||
add(name)
|
||||
} catch (_: PackageManager.NameNotFoundException) {
|
||||
GlobalState.log("Access control skipped an uninstalled package: $name")
|
||||
}
|
||||
}
|
||||
|
||||
override fun start() {
|
||||
try {
|
||||
modules.start()
|
||||
|
||||
@@ -4,4 +4,5 @@ data class NotificationParams(
|
||||
val title: String = "FlClash",
|
||||
val stopText: String = "STOP",
|
||||
val onlyStatisticsProxy: Boolean = false,
|
||||
val showStopAction: Boolean = true,
|
||||
)
|
||||
|
||||
+13
-1
@@ -9,6 +9,8 @@ import android.net.NetworkCapabilities.TRANSPORT_SATELLITE
|
||||
import android.net.NetworkCapabilities.TRANSPORT_USB
|
||||
import android.net.NetworkRequest
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import androidx.core.content.getSystemService
|
||||
import com.follow.clash.core.Core
|
||||
import java.net.Inet4Address
|
||||
@@ -30,6 +32,7 @@ internal class NetworkObserveModule(private val service: Service) : ServiceModul
|
||||
private val connectivity by lazy {
|
||||
service.getSystemService<ConnectivityManager>()
|
||||
}
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
private var currentDnsList = listOf<String>()
|
||||
|
||||
private val request = NetworkRequest.Builder().apply {
|
||||
@@ -48,8 +51,16 @@ internal class NetworkObserveModule(private val service: Service) : ServiceModul
|
||||
}
|
||||
|
||||
override fun onLosing(network: Network, maxMsToLive: Int) {
|
||||
networkInfos[network]?.losingUntilMillis = System.currentTimeMillis() + maxMsToLive
|
||||
val info = networkInfos[network] ?: return
|
||||
info.losingUntilMillis = System.currentTimeMillis() + maxMsToLive
|
||||
updateDns()
|
||||
if (maxMsToLive > 0) {
|
||||
mainHandler.postDelayed({
|
||||
if (networkInfos.containsKey(network)) {
|
||||
updateDns()
|
||||
}
|
||||
}, maxMsToLive.toLong() + 50)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onLost(network: Network) {
|
||||
@@ -104,6 +115,7 @@ internal class NetworkObserveModule(private val service: Service) : ServiceModul
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
mainHandler.removeCallbacksAndMessages(null)
|
||||
try {
|
||||
connectivity?.unregisterNetworkCallback(callback)
|
||||
} finally {
|
||||
|
||||
+10
-5
@@ -33,6 +33,7 @@ import kotlinx.coroutines.launch
|
||||
private data class ExtendedNotificationParams(
|
||||
val title: String,
|
||||
val stopText: String,
|
||||
val showStopAction: Boolean,
|
||||
val contentText: String,
|
||||
)
|
||||
|
||||
@@ -40,6 +41,7 @@ private val NotificationParams.extended: ExtendedNotificationParams
|
||||
get() = ExtendedNotificationParams(
|
||||
title,
|
||||
stopText,
|
||||
showStopAction,
|
||||
Core.getSpeedTrafficText(onlyStatisticsProxy),
|
||||
)
|
||||
|
||||
@@ -106,11 +108,14 @@ internal class NotificationModule(
|
||||
setContentTitle(params.title)
|
||||
setContentText(params.contentText)
|
||||
clearActions()
|
||||
addAction(
|
||||
0,
|
||||
params.stopText,
|
||||
QuickAction.STOP.quickIntent.toPendingIntent,
|
||||
).build()
|
||||
if (params.showStopAction) {
|
||||
addAction(
|
||||
0,
|
||||
params.stopText,
|
||||
QuickAction.STOP.quickIntent.toPendingIntent,
|
||||
)
|
||||
}
|
||||
build()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
package com.follow.clash.packages
|
||||
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ChinaPackageMatcherTest {
|
||||
|
||||
@Test
|
||||
fun `known vendors and SDKs match`() {
|
||||
val names = listOf(
|
||||
"com.tencent.mm",
|
||||
"com.alipay.android.app",
|
||||
"com.taobao.taobao",
|
||||
"com.baidu.searchbox",
|
||||
"com.bytedance.sdk.openadsdk",
|
||||
"com.netease.cloudmusic",
|
||||
"com.unionpay.tsmservice",
|
||||
"cn.wps.moffice",
|
||||
"andes.oplus.internal",
|
||||
)
|
||||
for (name in names) {
|
||||
assertTrue(name, ChinaPackageMatcher.matchesKnownPrefix(name))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `packer signatures used as class names match`() {
|
||||
val classNames = listOf(
|
||||
"com.secneo.apkwrapper.H",
|
||||
"s.h.e.l.l.S",
|
||||
"com.stub.StubApp",
|
||||
"com.kiwisec.KiwiSecApplication",
|
||||
"com.secshell.shellwrapper.SecAppWrapper",
|
||||
"com.wrapper.proxyapplication.WrapperProxyApplication",
|
||||
"cn.securitystack.stack.StackApplication",
|
||||
)
|
||||
for (name in classNames) {
|
||||
assertTrue(name, ChinaPackageMatcher.matchesKnownPrefix(name))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The prefixes carry no dot boundary on purpose. Adding one would look
|
||||
* tidier and would silently stop detecting 360 and the Alibaba clouds,
|
||||
* whose packages extend the prefix without a separator.
|
||||
*/
|
||||
@Test
|
||||
fun `prefixes deliberately match without a separator`() {
|
||||
assertTrue(ChinaPackageMatcher.matchesKnownPrefix("com.qihoo360.mobilesafe"))
|
||||
assertTrue(ChinaPackageMatcher.matchesKnownPrefix("com.aliyun.linkcard"))
|
||||
assertTrue(ChinaPackageMatcher.matchesKnownPrefix("com.alimama.moon"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a bare prefix matches on its own`() {
|
||||
assertTrue(ChinaPackageMatcher.matchesKnownPrefix("com.tencent"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unrelated packages do not match`() {
|
||||
val names = listOf(
|
||||
"org.mozilla.firefox",
|
||||
"com.spotify.music",
|
||||
"de.telekom.mail",
|
||||
"com.whatsapp",
|
||||
)
|
||||
for (name in names) {
|
||||
assertFalse(name, ChinaPackageMatcher.matchesKnownPrefix(name))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* These two do match a prefix, which is exactly why they have to be skipped
|
||||
* explicitly: MX Player is caught by `com.mx` (meant for Maxthon) and
|
||||
* StubHub by `com.stub` (meant for the StubApp packer).
|
||||
*/
|
||||
@Test
|
||||
fun `loose prefixes drag in unrelated apps that the skip list removes`() {
|
||||
assertTrue(ChinaPackageMatcher.matchesKnownPrefix("com.mxtech.videoplayer.ad"))
|
||||
assertTrue(ChinaPackageMatcher.isSkipped("com.mxtech.videoplayer.ad"))
|
||||
|
||||
assertTrue(ChinaPackageMatcher.matchesKnownPrefix("com.stubhub"))
|
||||
assertTrue(ChinaPackageMatcher.isSkipped("com.stubhub"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the intended owners of those prefixes still match and are not skipped`() {
|
||||
assertTrue(ChinaPackageMatcher.matchesKnownPrefix("com.mx.browser"))
|
||||
assertFalse(ChinaPackageMatcher.isSkipped("com.mx.browser"))
|
||||
|
||||
assertTrue(ChinaPackageMatcher.matchesKnownPrefix("com.stub.StubApp"))
|
||||
assertFalse(ChinaPackageMatcher.isSkipped("com.stub.StubApp"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the skip list applies a dot boundary`() {
|
||||
assertTrue(ChinaPackageMatcher.isSkipped("com.google"))
|
||||
assertTrue(ChinaPackageMatcher.isSkipped("com.google.android.gms"))
|
||||
assertFalse(
|
||||
"com.googlefoo is a different vendor",
|
||||
ChinaPackageMatcher.isSkipped("com.googlefoo"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `skipping wins over a matching prefix`() {
|
||||
// TikTok ships domestic SDKs but must stay out of the domestic list.
|
||||
assertTrue(ChinaPackageMatcher.isSkipped("com.zhiliaoapp.musically"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dex descriptors are normalized before matching`() {
|
||||
assertEquals(
|
||||
"com.tencent.mm.Foo.Bar",
|
||||
ChinaPackageMatcher.classNameOf("Lcom/tencent/mm/Foo\$Bar;"),
|
||||
)
|
||||
assertTrue(
|
||||
ChinaPackageMatcher.matchesKnownPrefix(
|
||||
ChinaPackageMatcher.classNameOf("Lcom/qihoo360/replugin/Entry;"),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an already normalized name survives normalization`() {
|
||||
assertEquals("com.example.Foo", ChinaPackageMatcher.classNameOf("com.example.Foo"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,658 @@
|
||||
package com.follow.clash
|
||||
|
||||
import com.follow.clash.common.AccessControlMode
|
||||
import com.follow.clash.models.SetupParams
|
||||
import com.follow.clash.models.SharedState
|
||||
import com.follow.clash.service.models.AccessControlProps
|
||||
import com.follow.clash.service.models.NotificationParams
|
||||
import com.follow.clash.service.models.VpnOptions
|
||||
import com.google.gson.Gson
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.toList
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
private fun vpnOptions(enable: Boolean = true) = VpnOptions(
|
||||
enable = enable,
|
||||
port = 7890,
|
||||
ipv6 = false,
|
||||
dnsHijacking = false,
|
||||
accessControlProps = AccessControlProps(
|
||||
enable = false,
|
||||
mode = AccessControlMode.ACCEPT_SELECTED,
|
||||
acceptList = emptyList(),
|
||||
rejectList = emptyList(),
|
||||
),
|
||||
allowBypass = false,
|
||||
systemProxy = true,
|
||||
bypassDomain = emptyList(),
|
||||
stack = "gvisor",
|
||||
routeAddress = emptyList(),
|
||||
)
|
||||
|
||||
private fun configuredState(enable: Boolean = true) = SharedState(
|
||||
vpnOptions = vpnOptions(enable),
|
||||
setupParams = SetupParams(testUrl = "https://example.com", selectedMap = emptyMap()),
|
||||
)
|
||||
|
||||
private class FakeTile : TileGateway {
|
||||
var startCount = 0
|
||||
var stopCount = 0
|
||||
|
||||
override fun handleStart() {
|
||||
startCount++
|
||||
}
|
||||
|
||||
override fun handleStop() {
|
||||
stopCount++
|
||||
}
|
||||
}
|
||||
|
||||
private class FakeApp(
|
||||
private val notificationGranted: Boolean = true,
|
||||
private val vpnGranted: Boolean = true,
|
||||
private val holdVpnPreparation: Boolean = false,
|
||||
) : AppGateway {
|
||||
var beforeVpnPrepared: (() -> Unit)? = null
|
||||
var cancelledPreparations = 0
|
||||
|
||||
private var heldCallback: ((Boolean) -> Unit)? = null
|
||||
|
||||
override fun requestNotificationPermission(callback: (Boolean) -> Unit) =
|
||||
callback(notificationGranted)
|
||||
|
||||
override fun prepareVpn(enable: Boolean, callback: (Boolean) -> Unit) {
|
||||
beforeVpnPrepared?.invoke()
|
||||
if (holdVpnPreparation) {
|
||||
heldCallback = callback
|
||||
return
|
||||
}
|
||||
callback(vpnGranted)
|
||||
}
|
||||
|
||||
override fun cancelVpnPreparation(callback: (Boolean) -> Unit) {
|
||||
cancelledPreparations++
|
||||
if (heldCallback === callback) {
|
||||
heldCallback = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class FakeHost(override val scope: CoroutineScope) : ServiceStateHost {
|
||||
var storedSharedState = configuredState()
|
||||
var setupResult: Result<String> = Result.success("")
|
||||
var startResult = 1_700_000_000_000L
|
||||
var vpnPermissionGranted = true
|
||||
var vpnServiceActive = true
|
||||
var tile: TileGateway? = null
|
||||
var app: AppGateway? = null
|
||||
var beforeStartService: (() -> Unit)? = null
|
||||
|
||||
override var runTimeMillis = 0L
|
||||
override val homeDirPath = "/data/user/0/com.follow.clash/files"
|
||||
override val sdkInt = 34
|
||||
|
||||
val toasts = mutableListOf<String>()
|
||||
val logs = mutableListOf<String>()
|
||||
val notificationParams = mutableListOf<NotificationParams>()
|
||||
val crashlytics = mutableListOf<Boolean>()
|
||||
var setupCalls = 0
|
||||
var startCalls = 0
|
||||
var stopCalls = 0
|
||||
var lastInitParams: String? = null
|
||||
var lastSetupParams: String? = null
|
||||
|
||||
override fun log(message: String) {
|
||||
logs += message
|
||||
}
|
||||
|
||||
override fun showToast(message: String) {
|
||||
toasts += message
|
||||
}
|
||||
|
||||
override fun setCrashlytics(enabled: Boolean) {
|
||||
crashlytics += enabled
|
||||
}
|
||||
|
||||
override fun updateNotificationParams(params: NotificationParams) {
|
||||
notificationParams += params
|
||||
}
|
||||
|
||||
override fun loadSharedState(): SharedState = storedSharedState
|
||||
|
||||
override fun isVpnPermissionGranted(): Boolean = vpnPermissionGranted
|
||||
|
||||
override fun tile(): TileGateway? = tile
|
||||
|
||||
override fun app(): AppGateway? = app
|
||||
|
||||
override suspend fun quickSetup(initParams: String, setupParams: String): Result<String> {
|
||||
setupCalls++
|
||||
lastInitParams = initParams
|
||||
lastSetupParams = setupParams
|
||||
return setupResult
|
||||
}
|
||||
|
||||
override suspend fun startService(options: VpnOptions): Long {
|
||||
startCalls++
|
||||
beforeStartService?.invoke()
|
||||
runTimeMillis = startResult
|
||||
return startResult
|
||||
}
|
||||
|
||||
override suspend fun stopService() {
|
||||
stopCalls++
|
||||
runTimeMillis = 0L
|
||||
}
|
||||
|
||||
override suspend fun isVpnServiceActive(): Boolean = vpnServiceActive
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class ServiceStateMachineTest {
|
||||
|
||||
@Test
|
||||
fun `initParams spells the keys the Go wrapper expects`() {
|
||||
val json = ServiceStateMachine.initParams("/files", 34)
|
||||
|
||||
assertEquals("""{"home-dir":"/files","version":34}""", json)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `notification params come straight off the shared state`() {
|
||||
val params = ServiceStateMachine.notificationParams(
|
||||
SharedState(
|
||||
currentProfileName = "Work",
|
||||
stopText = "Disconnect",
|
||||
onlyStatisticsProxy = true,
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(NotificationParams("Work", "Disconnect", true), params)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `notification params carry the stop action switch`() {
|
||||
val params = ServiceStateMachine.notificationParams(
|
||||
SharedState(showStopAction = false),
|
||||
)
|
||||
|
||||
assertEquals(false, params.showStopAction)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `refresh reports STARTED only while the service has a run time`() = runTest {
|
||||
val host = FakeHost(backgroundScope)
|
||||
val machine = ServiceStateMachine(host)
|
||||
|
||||
assertEquals(0L, machine.refresh())
|
||||
assertEquals(RunState.STOPPED, machine.runState.value)
|
||||
|
||||
host.runTimeMillis = 42L
|
||||
assertEquals(42L, machine.refresh())
|
||||
assertEquals(RunState.STARTED, machine.runState.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a start request drives the service to STARTED`() = runTest {
|
||||
val host = FakeHost(backgroundScope)
|
||||
val machine = ServiceStateMachine(host)
|
||||
machine.syncSharedState(configuredState())
|
||||
|
||||
assertTrue(machine.requestStart().await())
|
||||
assertEquals(RunState.STARTED, machine.runState.value)
|
||||
assertEquals(1, host.startCalls)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a start that the service refuses settles back to STOPPED`() = runTest {
|
||||
val host = FakeHost(backgroundScope)
|
||||
host.startResult = 0L
|
||||
val machine = ServiceStateMachine(host)
|
||||
machine.syncSharedState(configuredState())
|
||||
|
||||
assertFalse(machine.requestStart().await())
|
||||
assertEquals(RunState.STOPPED, machine.runState.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a start without stored vpn options never reaches the service`() = runTest {
|
||||
val host = FakeHost(backgroundScope)
|
||||
val machine = ServiceStateMachine(host)
|
||||
|
||||
assertFalse(machine.requestStart().await())
|
||||
assertEquals(0, host.startCalls)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a denied notification permission cancels the start`() = runTest {
|
||||
val host = FakeHost(backgroundScope)
|
||||
host.app = FakeApp(notificationGranted = false)
|
||||
val machine = ServiceStateMachine(host)
|
||||
machine.syncSharedState(configuredState())
|
||||
|
||||
assertFalse(machine.requestStart().await())
|
||||
assertEquals(0, host.startCalls)
|
||||
assertEquals(RunState.STOPPED, machine.runState.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a denied vpn permission cancels the start`() = runTest {
|
||||
val host = FakeHost(backgroundScope)
|
||||
host.app = FakeApp(vpnGranted = false)
|
||||
val machine = ServiceStateMachine(host)
|
||||
machine.syncSharedState(configuredState())
|
||||
|
||||
assertFalse(machine.requestStart().await())
|
||||
assertEquals(0, host.startCalls)
|
||||
}
|
||||
|
||||
/**
|
||||
* Without a foreground app there is nobody to show the system consent dialog, so the machine
|
||||
* has to explain the refusal itself.
|
||||
*/
|
||||
@Test
|
||||
fun `a missing vpn permission is reported when no app is attached`() = runTest {
|
||||
val host = FakeHost(backgroundScope)
|
||||
host.vpnPermissionGranted = false
|
||||
val machine = ServiceStateMachine(host)
|
||||
machine.syncSharedState(configuredState())
|
||||
|
||||
assertFalse(machine.requestStart().await())
|
||||
assertTrue(host.toasts.contains(VPN_PERMISSION_MESSAGE))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a proxy-only start does not need the vpn permission`() = runTest {
|
||||
val host = FakeHost(backgroundScope)
|
||||
host.vpnPermissionGranted = false
|
||||
val machine = ServiceStateMachine(host)
|
||||
machine.syncSharedState(configuredState(enable = false))
|
||||
|
||||
assertTrue(machine.requestStart().await())
|
||||
assertEquals(1, host.startCalls)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a stop request drives the service to STOPPED`() = runTest {
|
||||
val host = FakeHost(backgroundScope)
|
||||
val machine = ServiceStateMachine(host)
|
||||
machine.syncSharedState(configuredState())
|
||||
machine.requestStart().await()
|
||||
|
||||
assertTrue(machine.requestStop().await())
|
||||
assertEquals(RunState.STOPPED, machine.runState.value)
|
||||
assertEquals(1, host.stopCalls)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `stopping an already stopped service touches nothing`() = runTest {
|
||||
val host = FakeHost(backgroundScope)
|
||||
val machine = ServiceStateMachine(host)
|
||||
|
||||
assertTrue(machine.requestStop().await())
|
||||
assertEquals(0, host.stopCalls)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a stop that lands mid-preparation keeps the service stopped`() = runTest {
|
||||
val host = FakeHost(backgroundScope)
|
||||
val machine = ServiceStateMachine(host)
|
||||
machine.syncSharedState(configuredState())
|
||||
val app = FakeApp()
|
||||
host.app = app
|
||||
app.beforeVpnPrepared = { machine.requestStop() }
|
||||
|
||||
assertFalse(machine.requestStart().await())
|
||||
assertEquals(0, host.startCalls)
|
||||
assertEquals(RunState.STOPPED, machine.runState.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a start reports failure when a stop overtakes the service call`() = runTest {
|
||||
val host = FakeHost(backgroundScope)
|
||||
val machine = ServiceStateMachine(host)
|
||||
machine.syncSharedState(configuredState())
|
||||
host.beforeStartService = { machine.requestStop() }
|
||||
|
||||
assertFalse(machine.requestStart().await())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handleServiceLost clears the state for the token that observed the loss`() = runTest {
|
||||
val host = FakeHost(backgroundScope)
|
||||
val machine = ServiceStateMachine(host)
|
||||
machine.syncSharedState(configuredState())
|
||||
machine.requestStart().await()
|
||||
|
||||
val token = machine.captureRequestToken()
|
||||
host.runTimeMillis = 0L
|
||||
machine.handleServiceLost(token)
|
||||
|
||||
assertEquals(RunState.STOPPED, machine.runState.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handleServiceLost keeps the intent of a start that raced ahead of it`() = runTest {
|
||||
val host = FakeHost(backgroundScope)
|
||||
val machine = ServiceStateMachine(host)
|
||||
machine.syncSharedState(configuredState())
|
||||
val staleToken = machine.captureRequestToken()
|
||||
|
||||
machine.requestStart().await()
|
||||
host.runTimeMillis = 0L
|
||||
machine.handleServiceLost(staleToken)
|
||||
|
||||
assertEquals(RunState.STARTED, machine.runState.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handleServiceLost ignores a service that is running again`() = runTest {
|
||||
val host = FakeHost(backgroundScope)
|
||||
val machine = ServiceStateMachine(host)
|
||||
machine.syncSharedState(configuredState())
|
||||
machine.requestStart().await()
|
||||
|
||||
machine.handleServiceLost(machine.captureRequestToken())
|
||||
|
||||
assertEquals(RunState.STARTED, machine.runState.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handleStartAction hands the start to the tile when one is attached`() = runTest {
|
||||
val host = FakeHost(backgroundScope)
|
||||
val tile = FakeTile()
|
||||
host.tile = tile
|
||||
val machine = ServiceStateMachine(host)
|
||||
|
||||
machine.handleStartAction()
|
||||
|
||||
assertEquals(1, tile.startCount)
|
||||
assertEquals(0, host.setupCalls)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handleStartAction is a no-op while a run is already requested`() = runTest {
|
||||
val host = FakeHost(backgroundScope)
|
||||
val machine = ServiceStateMachine(host)
|
||||
machine.syncSharedState(configuredState())
|
||||
machine.requestStart().await()
|
||||
val startsBefore = host.startCalls
|
||||
|
||||
machine.handleStartAction()
|
||||
|
||||
assertEquals(startsBefore, host.startCalls)
|
||||
assertEquals(0, host.setupCalls)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handleStartAction reports a missing configuration`() = runTest {
|
||||
val host = FakeHost(backgroundScope)
|
||||
host.storedSharedState = SharedState()
|
||||
val machine = ServiceStateMachine(host)
|
||||
|
||||
machine.handleStartAction()
|
||||
|
||||
assertEquals(listOf(MISSING_CONFIG_MESSAGE), host.toasts)
|
||||
assertEquals(0, host.setupCalls)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handleStartAction loads the stored configuration and sets the core up`() = runTest {
|
||||
val host = FakeHost(backgroundScope)
|
||||
val machine = ServiceStateMachine(host)
|
||||
|
||||
machine.handleStartAction()
|
||||
|
||||
assertEquals(1, host.setupCalls)
|
||||
assertEquals(1, host.startCalls)
|
||||
assertEquals(RunState.STARTED, machine.runState.value)
|
||||
assertEquals(
|
||||
ServiceStateMachine.initParams(host.homeDirPath, host.sdkInt),
|
||||
host.lastInitParams,
|
||||
)
|
||||
assertEquals(Gson().toJson(host.storedSharedState.setupParams), host.lastSetupParams)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a core that rejects the configuration reports its own message`() = runTest {
|
||||
val host = FakeHost(backgroundScope)
|
||||
host.setupResult = Result.success("proxy group not found")
|
||||
val machine = ServiceStateMachine(host)
|
||||
|
||||
machine.handleStartAction()
|
||||
|
||||
assertTrue(host.toasts.contains("proxy group not found"))
|
||||
assertEquals(0, host.startCalls)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a core setup that throws without a message falls back to the generic one`() = runTest {
|
||||
val host = FakeHost(backgroundScope)
|
||||
host.setupResult = Result.failure(RuntimeException(" "))
|
||||
val machine = ServiceStateMachine(host)
|
||||
|
||||
machine.handleStartAction()
|
||||
|
||||
assertTrue(host.toasts.contains(INVALID_CONFIG_MESSAGE))
|
||||
assertEquals(0, host.startCalls)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a service that refuses the start after a good setup is reported`() = runTest {
|
||||
val host = FakeHost(backgroundScope)
|
||||
host.startResult = 0L
|
||||
val machine = ServiceStateMachine(host)
|
||||
|
||||
machine.handleStartAction()
|
||||
|
||||
assertTrue(host.toasts.contains(START_FAILED_MESSAGE))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handleStopAction hands the stop to the tile when one is attached`() = runTest {
|
||||
val host = FakeHost(backgroundScope)
|
||||
val machine = ServiceStateMachine(host)
|
||||
machine.syncSharedState(configuredState())
|
||||
machine.requestStart().await()
|
||||
val tile = FakeTile()
|
||||
host.tile = tile
|
||||
|
||||
machine.handleStopAction()
|
||||
|
||||
assertEquals(1, tile.stopCount)
|
||||
assertEquals(0, host.stopCalls)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handleStopAction is a no-op while nothing is running`() = runTest {
|
||||
val host = FakeHost(backgroundScope)
|
||||
val machine = ServiceStateMachine(host)
|
||||
|
||||
machine.handleStopAction()
|
||||
|
||||
assertEquals(0, host.stopCalls)
|
||||
assertTrue(host.toasts.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handleStopAction announces itself before stopping`() = runTest {
|
||||
val host = FakeHost(backgroundScope)
|
||||
val machine = ServiceStateMachine(host)
|
||||
machine.syncSharedState(configuredState().copy(stopTip = "Stopping..."))
|
||||
machine.requestStart().await()
|
||||
|
||||
machine.handleStopAction()
|
||||
|
||||
assertEquals(listOf("Stopping..."), host.toasts)
|
||||
assertEquals(1, host.stopCalls)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handleToggleAction starts when stopped and stops when started`() = runTest {
|
||||
val host = FakeHost(backgroundScope)
|
||||
val machine = ServiceStateMachine(host)
|
||||
|
||||
machine.handleToggleAction()
|
||||
assertEquals(1, host.startCalls)
|
||||
|
||||
machine.handleToggleAction()
|
||||
assertEquals(1, host.stopCalls)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a revoke is ignored while no vpn service is active`() = runTest {
|
||||
val host = FakeHost(backgroundScope)
|
||||
host.vpnServiceActive = false
|
||||
val machine = ServiceStateMachine(host)
|
||||
machine.syncSharedState(configuredState())
|
||||
machine.requestStart().await()
|
||||
|
||||
machine.handleVpnRevokeAction()
|
||||
|
||||
assertEquals(0, host.stopCalls)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a revoke stops the active vpn service`() = runTest {
|
||||
val host = FakeHost(backgroundScope)
|
||||
val machine = ServiceStateMachine(host)
|
||||
machine.syncSharedState(configuredState())
|
||||
machine.requestStart().await()
|
||||
|
||||
machine.handleVpnRevokeAction()
|
||||
|
||||
assertEquals(1, host.stopCalls)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `syncSharedState pushes crashlytics and the notification straight through`() = runTest {
|
||||
val host = FakeHost(backgroundScope)
|
||||
val machine = ServiceStateMachine(host)
|
||||
|
||||
machine.syncSharedState(
|
||||
SharedState(
|
||||
crashlytics = false,
|
||||
currentProfileName = "Work",
|
||||
stopText = "Disconnect",
|
||||
onlyStatisticsProxy = true,
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(listOf(false), host.crashlytics)
|
||||
assertEquals(listOf(NotificationParams("Work", "Disconnect", true)), host.notificationParams)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a start that a stop overtakes never announces STARTED`() = runTest {
|
||||
val host = FakeHost(backgroundScope)
|
||||
val machine = ServiceStateMachine(host)
|
||||
machine.syncSharedState(configuredState())
|
||||
val states = mutableListOf<RunState>()
|
||||
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
|
||||
machine.runState.toList(states)
|
||||
}
|
||||
host.beforeStartService = { machine.requestStop() }
|
||||
|
||||
assertFalse(machine.requestStart().await())
|
||||
testScheduler.runCurrent()
|
||||
|
||||
assertFalse(states.contains(RunState.STARTED))
|
||||
assertEquals(RunState.STOPPED, machine.runState.value)
|
||||
assertEquals(0L, host.runTimeMillis)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a start rolled back after the service came up stops it again`() = runTest {
|
||||
val host = FakeHost(backgroundScope)
|
||||
val machine = ServiceStateMachine(host)
|
||||
machine.syncSharedState(configuredState())
|
||||
host.beforeStartService = {
|
||||
host.app = FakeApp(vpnGranted = false)
|
||||
machine.requestStart()
|
||||
}
|
||||
|
||||
assertFalse(machine.requestStart().await())
|
||||
testScheduler.runCurrent()
|
||||
|
||||
assertEquals(1, host.stopCalls)
|
||||
assertEquals(0L, host.runTimeMillis)
|
||||
assertEquals(RunState.STOPPED, machine.runState.value)
|
||||
assertFalse(machine.captureRequestToken().running)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a start that overtakes another inside the binding window adopts the running service`() =
|
||||
runTest {
|
||||
val host = FakeHost(backgroundScope)
|
||||
val machine = ServiceStateMachine(host)
|
||||
machine.syncSharedState(configuredState())
|
||||
host.beforeStartService = {
|
||||
host.beforeStartService = null
|
||||
machine.requestStart()
|
||||
}
|
||||
|
||||
machine.requestStart().await()
|
||||
testScheduler.runCurrent()
|
||||
|
||||
assertEquals(1, host.startCalls)
|
||||
assertEquals(0, host.stopCalls)
|
||||
assertEquals(RunState.STARTED, machine.runState.value)
|
||||
assertTrue(machine.captureRequestToken().running)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a start rebinds when the running service is not the one the options ask for`() = runTest {
|
||||
val host = FakeHost(backgroundScope)
|
||||
host.vpnServiceActive = false
|
||||
val machine = ServiceStateMachine(host)
|
||||
machine.syncSharedState(configuredState())
|
||||
host.beforeStartService = {
|
||||
host.beforeStartService = null
|
||||
machine.requestStart()
|
||||
}
|
||||
|
||||
machine.requestStart().await()
|
||||
testScheduler.runCurrent()
|
||||
|
||||
assertEquals(2, host.startCalls)
|
||||
assertEquals(RunState.STARTED, machine.runState.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a stop releases a start that is waiting on the vpn consent`() = runTest {
|
||||
val host = FakeHost(backgroundScope)
|
||||
val app = FakeApp(holdVpnPreparation = true)
|
||||
host.app = app
|
||||
val machine = ServiceStateMachine(host)
|
||||
machine.syncSharedState(configuredState())
|
||||
|
||||
val start = machine.requestStart()
|
||||
testScheduler.runCurrent()
|
||||
assertEquals(0, host.startCalls)
|
||||
|
||||
assertTrue(machine.requestStop().await())
|
||||
assertFalse(start.await())
|
||||
assertEquals(0, host.startCalls)
|
||||
assertEquals(1, app.cancelledPreparations)
|
||||
assertEquals(RunState.STOPPED, machine.runState.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a start request that throws is logged and rolled back`() = runTest {
|
||||
val host = FakeHost(backgroundScope)
|
||||
val machine = ServiceStateMachine(host)
|
||||
machine.syncSharedState(configuredState())
|
||||
host.beforeStartService = { throw IllegalStateException("binder died") }
|
||||
|
||||
assertFalse(machine.requestStart().await())
|
||||
assertTrue(host.logs.any { it.contains("binder died") })
|
||||
assertFalse(machine.captureRequestToken().running)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package com.follow.clash.models
|
||||
|
||||
import com.follow.clash.common.AccessControlMode
|
||||
import com.google.gson.Gson
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* `SharedState` is written by Flutter into `FlutterSharedPreferences` and read back here, so the
|
||||
* JSON spelling of every field is a cross-language contract rather than an implementation detail.
|
||||
*/
|
||||
class SharedStateTest {
|
||||
private val gson = Gson()
|
||||
|
||||
@Test
|
||||
fun `setup params use the kebab-case spelling Flutter writes`() {
|
||||
val json = """{"test-url":"https://example.test/204","selected-map":{"GLOBAL":"auto"}}"""
|
||||
|
||||
val params = gson.fromJson(json, SetupParams::class.java)
|
||||
|
||||
assertEquals("https://example.test/204", params.testUrl)
|
||||
assertEquals(mapOf("GLOBAL" to "auto"), params.selectedMap)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `setup params serialize back to the same spelling`() {
|
||||
val encoded = gson.toJson(
|
||||
SetupParams(testUrl = "https://example.test", selectedMap = emptyMap()),
|
||||
)
|
||||
|
||||
assertTrue(encoded.contains("\"test-url\""))
|
||||
assertTrue(encoded.contains("\"selected-map\""))
|
||||
assertTrue(!encoded.contains("testUrl"))
|
||||
assertTrue(!encoded.contains("selectedMap"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a full payload round-trips including nested vpn options`() {
|
||||
val json = """
|
||||
{
|
||||
"startTip": "Starting",
|
||||
"stopTip": "Stopping",
|
||||
"crashlytics": false,
|
||||
"currentProfileName": "Work",
|
||||
"stopText": "Halt",
|
||||
"onlyStatisticsProxy": true,
|
||||
"showStopAction": false,
|
||||
"vpnOptions": {
|
||||
"enable": true,
|
||||
"port": 7890,
|
||||
"ipv6": false,
|
||||
"dnsHijacking": true,
|
||||
"accessControlProps": {
|
||||
"enable": true,
|
||||
"mode": "rejectSelected",
|
||||
"acceptList": ["a.b"],
|
||||
"rejectList": ["c.d"]
|
||||
},
|
||||
"allowBypass": false,
|
||||
"systemProxy": true,
|
||||
"bypassDomain": ["example.test"],
|
||||
"stack": "gvisor",
|
||||
"routeAddress": ["0.0.0.0/0"]
|
||||
},
|
||||
"setupParams": {
|
||||
"test-url": "https://example.test/204",
|
||||
"selected-map": {"GLOBAL": "auto"}
|
||||
}
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val state = gson.fromJson(json, SharedState::class.java)
|
||||
|
||||
assertEquals("Starting", state.startTip)
|
||||
assertEquals("Work", state.currentProfileName)
|
||||
assertEquals(false, state.crashlytics)
|
||||
assertEquals(true, state.onlyStatisticsProxy)
|
||||
assertEquals(false, state.showStopAction)
|
||||
assertEquals(7890, state.vpnOptions?.port)
|
||||
assertEquals("gvisor", state.vpnOptions?.stack)
|
||||
assertEquals(
|
||||
AccessControlMode.REJECT_SELECTED,
|
||||
state.vpnOptions?.accessControlProps?.mode,
|
||||
)
|
||||
assertEquals(listOf("0.0.0.0/0"), state.vpnOptions?.routeAddress)
|
||||
assertEquals("https://example.test/204", state.setupParams?.testUrl)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the constructed default keeps every fallback Flutter relies on`() {
|
||||
val defaults = SharedState()
|
||||
|
||||
assertEquals("FlClash", defaults.currentProfileName)
|
||||
assertEquals("Stop", defaults.stopText)
|
||||
assertEquals(true, defaults.crashlytics)
|
||||
assertEquals(false, defaults.onlyStatisticsProxy)
|
||||
assertEquals(true, defaults.showStopAction)
|
||||
assertNull(defaults.vpnOptions)
|
||||
assertNull(defaults.setupParams)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an empty document still yields the declared defaults`() {
|
||||
// Every SharedState parameter has a default, so Kotlin emits a no-arg constructor
|
||||
// that Gson uses instead of its Unsafe fallback. Losing a default on any one
|
||||
// parameter would silently turn the absent fields below into nulls.
|
||||
val state = gson.fromJson("{}", SharedState::class.java)
|
||||
|
||||
assertNotNull(state)
|
||||
assertEquals("Starting VPN...", state.startTip)
|
||||
assertEquals("FlClash", state.currentProfileName)
|
||||
assertEquals(true, state.crashlytics)
|
||||
assertNull(state.vpnOptions)
|
||||
assertNull(state.setupParams)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a partial document keeps defaults for the fields it omits`() {
|
||||
val state = gson.fromJson("""{"currentProfileName":"Work"}""", SharedState::class.java)
|
||||
|
||||
assertEquals("Work", state.currentProfileName)
|
||||
assertEquals("Stop", state.stopText)
|
||||
assertEquals("Stopping VPN...", state.stopTip)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `setup params have no defaults so absent fields decode as null`() {
|
||||
// SetupParams declares no default values, so Gson builds it through Unsafe and
|
||||
// leaves missing fields null despite the non-nullable Kotlin types.
|
||||
val params = gson.fromJson("{}", SetupParams::class.java)
|
||||
|
||||
@Suppress("SENSELESS_COMPARISON")
|
||||
assertTrue(params.testUrl == null)
|
||||
|
||||
@Suppress("SENSELESS_COMPARISON")
|
||||
assertTrue(params.selectedMap == null)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a null json document decodes to null so the caller can fall back`() {
|
||||
assertNull(gson.fromJson("null", SharedState::class.java))
|
||||
assertNull(gson.fromJson(null as String?, SharedState::class.java))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an access control payload without a matching mode decodes to null`() {
|
||||
val json = """
|
||||
{"vpnOptions":{"accessControlProps":{"mode":"unknownMode"}}}
|
||||
""".trimIndent()
|
||||
|
||||
val state = gson.fromJson(json, SharedState::class.java)
|
||||
|
||||
assertNull(state.vpnOptions?.accessControlProps?.mode)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package com.follow.clash.common
|
||||
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class BroadcastLeaseTest {
|
||||
@Test
|
||||
fun `starts held`() {
|
||||
val lease = BroadcastLease { }
|
||||
|
||||
assertFalse(lease.isReleased)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the first release runs the release action and reports the win`() {
|
||||
var releases = 0
|
||||
val lease = BroadcastLease { releases++ }
|
||||
|
||||
assertTrue(lease.release())
|
||||
assertEquals(1, releases)
|
||||
assertTrue(lease.isReleased)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a second release neither runs nor claims the win`() {
|
||||
var releases = 0
|
||||
val lease = BroadcastLease { releases++ }
|
||||
lease.release()
|
||||
|
||||
assertFalse(lease.release())
|
||||
assertEquals(1, releases)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the reason runs only for the caller that wins`() {
|
||||
val reasons = mutableListOf<String>()
|
||||
val lease = BroadcastLease { }
|
||||
|
||||
lease.release { reasons.add("timeout") }
|
||||
lease.release { reasons.add("completion") }
|
||||
|
||||
assertEquals(listOf("timeout"), reasons)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the reason runs before the lease is released`() {
|
||||
val order = mutableListOf<String>()
|
||||
val lease = BroadcastLease { order.add("release") }
|
||||
|
||||
lease.release { order.add("reason") }
|
||||
|
||||
assertEquals(listOf("reason", "release"), order)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the lease is still released when the reason throws`() {
|
||||
var releases = 0
|
||||
val lease = BroadcastLease { releases++ }
|
||||
|
||||
try {
|
||||
lease.release { throw RuntimeException("boom") }
|
||||
} catch (_: RuntimeException) {
|
||||
// expected: propagated after the lease is released
|
||||
}
|
||||
|
||||
assertEquals(1, releases)
|
||||
assertTrue(lease.isReleased)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `only one of many concurrent releases wins`() {
|
||||
val threads = 16
|
||||
val releases = AtomicInteger(0)
|
||||
val wins = AtomicInteger(0)
|
||||
val lease = BroadcastLease { releases.incrementAndGet() }
|
||||
val start = CountDownLatch(1)
|
||||
val done = CountDownLatch(threads)
|
||||
val pool = Executors.newFixedThreadPool(threads)
|
||||
|
||||
repeat(threads) {
|
||||
pool.execute {
|
||||
start.await()
|
||||
if (lease.release()) {
|
||||
wins.incrementAndGet()
|
||||
}
|
||||
done.countDown()
|
||||
}
|
||||
}
|
||||
start.countDown()
|
||||
|
||||
assertTrue(done.await(5, TimeUnit.SECONDS))
|
||||
pool.shutdown()
|
||||
assertEquals(1, releases.get())
|
||||
assertEquals(1, wins.get())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.follow.clash.common
|
||||
|
||||
import com.google.gson.Gson
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The Flutter layer exchanges these enums as JSON, so the serialized spelling is a cross-language
|
||||
* contract rather than an implementation detail.
|
||||
*/
|
||||
class EnumsTest {
|
||||
private val gson = Gson()
|
||||
|
||||
@Test
|
||||
fun `access control mode serializes to the Dart spelling`() {
|
||||
assertEquals("\"acceptSelected\"", gson.toJson(AccessControlMode.ACCEPT_SELECTED))
|
||||
assertEquals("\"rejectSelected\"", gson.toJson(AccessControlMode.REJECT_SELECTED))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `access control mode deserializes from the Dart spelling`() {
|
||||
assertEquals(
|
||||
AccessControlMode.ACCEPT_SELECTED,
|
||||
gson.fromJson("\"acceptSelected\"", AccessControlMode::class.java),
|
||||
)
|
||||
assertEquals(
|
||||
AccessControlMode.REJECT_SELECTED,
|
||||
gson.fromJson("\"rejectSelected\"", AccessControlMode::class.java),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `quick action names back the intent action suffixes`() {
|
||||
assertEquals(
|
||||
listOf("STOP", "START", "TOGGLE"),
|
||||
QuickAction.entries.map { it.name },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `broadcast action names back the intent action suffixes`() {
|
||||
assertEquals(
|
||||
listOf("VPN_START_REQUESTED", "VPN_REVOKED"),
|
||||
BroadcastAction.entries.map { it.name },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package com.follow.clash.common
|
||||
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class PendingCallbackTest {
|
||||
@Test
|
||||
fun `starts empty`() {
|
||||
val slot = PendingCallback<Boolean>()
|
||||
|
||||
assertFalse(slot.isPending)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `replace leaves the new callback pending`() {
|
||||
val slot = PendingCallback<Boolean>()
|
||||
|
||||
slot.replace({ }, supersededValue = false)
|
||||
|
||||
assertTrue(slot.isPending)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `resolve delivers the value once and empties the slot`() {
|
||||
val slot = PendingCallback<Boolean>()
|
||||
val received = mutableListOf<Boolean>()
|
||||
slot.replace({ received.add(it) }, supersededValue = false)
|
||||
|
||||
slot.resolve(true)
|
||||
slot.resolve(true)
|
||||
|
||||
assertEquals(listOf(true), received)
|
||||
assertFalse(slot.isPending)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `replace settles the request it supersedes`() {
|
||||
val slot = PendingCallback<Boolean>()
|
||||
val first = mutableListOf<Boolean>()
|
||||
val second = mutableListOf<Boolean>()
|
||||
slot.replace({ first.add(it) }, supersededValue = false)
|
||||
|
||||
slot.replace({ second.add(it) }, supersededValue = false)
|
||||
|
||||
assertEquals(listOf(false), first)
|
||||
assertTrue(second.isEmpty())
|
||||
|
||||
slot.resolve(true)
|
||||
|
||||
assertEquals(listOf(false), first)
|
||||
assertEquals(listOf(true), second)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `resolve on an empty slot is inert`() {
|
||||
val slot = PendingCallback<Boolean>()
|
||||
|
||||
slot.resolve(true)
|
||||
|
||||
assertFalse(slot.isPending)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cancel drops the matching callback without invoking it`() {
|
||||
val slot = PendingCallback<Boolean>()
|
||||
val received = mutableListOf<Boolean>()
|
||||
val callback: (Boolean) -> Unit = { received.add(it) }
|
||||
slot.replace(callback, supersededValue = false)
|
||||
|
||||
slot.cancel(callback)
|
||||
|
||||
assertFalse(slot.isPending)
|
||||
assertTrue(received.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cancel keeps a callback that is no longer the pending one`() {
|
||||
val slot = PendingCallback<Boolean>()
|
||||
val stale = mutableListOf<Boolean>()
|
||||
val current = mutableListOf<Boolean>()
|
||||
val staleCallback: (Boolean) -> Unit = { stale.add(it) }
|
||||
slot.replace(staleCallback, supersededValue = false)
|
||||
slot.replace({ current.add(it) }, supersededValue = false)
|
||||
|
||||
slot.cancel(staleCallback)
|
||||
|
||||
assertTrue(slot.isPending)
|
||||
|
||||
slot.resolve(true)
|
||||
|
||||
assertEquals(listOf(false), stale)
|
||||
assertEquals(listOf(true), current)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a callback that starts a new request is not resolved twice`() {
|
||||
val slot = PendingCallback<Boolean>()
|
||||
val outer = mutableListOf<Boolean>()
|
||||
val inner = mutableListOf<Boolean>()
|
||||
slot.replace(
|
||||
{
|
||||
outer.add(it)
|
||||
slot.replace({ value -> inner.add(value) }, supersededValue = false)
|
||||
},
|
||||
supersededValue = false,
|
||||
)
|
||||
|
||||
slot.resolve(true)
|
||||
|
||||
assertEquals(listOf(true), outer)
|
||||
assertTrue(inner.isEmpty())
|
||||
assertTrue(slot.isPending)
|
||||
|
||||
slot.resolve(false)
|
||||
|
||||
assertEquals(listOf(true), outer)
|
||||
assertEquals(listOf(false), inner)
|
||||
}
|
||||
|
||||
/**
|
||||
* The request is started off the main thread and the answer arrives on it, so
|
||||
* a resolve that has already read the slot must not clear the callback a
|
||||
* concurrent replace installed after that read. Losing it strands the request
|
||||
* that callback belongs to, which on the VPN consent hop means the start path
|
||||
* never releases its lock.
|
||||
*/
|
||||
@Test
|
||||
fun `a callback installed while a resolve is in flight is never dropped`() {
|
||||
repeat(200) {
|
||||
val slot = PendingCallback<Boolean>()
|
||||
val settled = AtomicInteger()
|
||||
slot.replace({ settled.incrementAndGet() }, supersededValue = false)
|
||||
|
||||
val ready = CountDownLatch(2)
|
||||
val go = CountDownLatch(1)
|
||||
val replacer = Thread {
|
||||
ready.countDown()
|
||||
go.await()
|
||||
slot.replace({ settled.incrementAndGet() }, supersededValue = false)
|
||||
}
|
||||
val resolver = Thread {
|
||||
ready.countDown()
|
||||
go.await()
|
||||
slot.resolve(true)
|
||||
}
|
||||
replacer.start()
|
||||
resolver.start()
|
||||
ready.await()
|
||||
go.countDown()
|
||||
replacer.join(TimeUnit.SECONDS.toMillis(5))
|
||||
resolver.join(TimeUnit.SECONDS.toMillis(5))
|
||||
|
||||
// Two callbacks were installed and at most one of them can still be
|
||||
// pending, so every callback the slot let go of must have been called.
|
||||
val pending = if (slot.isPending) 1 else 0
|
||||
assertEquals(2 - pending, settled.get())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package com.follow.clash.common
|
||||
|
||||
import java.util.concurrent.CyclicBarrier
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.TimeUnit
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotSame
|
||||
import org.junit.Assert.assertSame
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class RunIntentArbiterTest {
|
||||
@Test
|
||||
fun `starts stopped by default`() {
|
||||
val arbiter = RunIntentArbiter()
|
||||
|
||||
assertFalse(arbiter.isRunningRequested)
|
||||
assertFalse(arbiter.current().running)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `request publishes the new intent`() {
|
||||
val arbiter = RunIntentArbiter()
|
||||
|
||||
val token = arbiter.request(running = true)
|
||||
|
||||
assertTrue(arbiter.isRunningRequested)
|
||||
assertTrue(arbiter.isCurrent(token))
|
||||
assertSame(token, arbiter.current())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `each request mints a distinct token`() {
|
||||
val arbiter = RunIntentArbiter()
|
||||
|
||||
val first = arbiter.request(running = true)
|
||||
val second = arbiter.request(running = true)
|
||||
|
||||
assertNotSame(first, second)
|
||||
assertFalse(arbiter.isCurrent(first))
|
||||
assertTrue(arbiter.isCurrent(second))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a newer request supersedes work started for an older token`() {
|
||||
val arbiter = RunIntentArbiter()
|
||||
val start = arbiter.request(running = true)
|
||||
|
||||
val stop = arbiter.request(running = false)
|
||||
|
||||
assertFalse(arbiter.isCurrent(start))
|
||||
assertTrue(arbiter.isCurrent(stop))
|
||||
assertFalse(arbiter.isRunningRequested)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `resetToStopped rolls back the current token`() {
|
||||
val arbiter = RunIntentArbiter()
|
||||
val token = arbiter.request(running = true)
|
||||
|
||||
assertTrue(arbiter.resetToStopped(token))
|
||||
|
||||
assertFalse(arbiter.isRunningRequested)
|
||||
assertFalse(arbiter.isCurrent(token))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `resetToStopped does not clobber a newer intent`() {
|
||||
val arbiter = RunIntentArbiter()
|
||||
val stale = arbiter.request(running = true)
|
||||
val latest = arbiter.request(running = true)
|
||||
|
||||
assertFalse(arbiter.resetToStopped(stale))
|
||||
|
||||
assertTrue(arbiter.isRunningRequested)
|
||||
assertTrue(arbiter.isCurrent(latest))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `resetToStopped is not idempotent for the same token`() {
|
||||
val arbiter = RunIntentArbiter()
|
||||
val token = arbiter.request(running = true)
|
||||
|
||||
assertTrue(arbiter.resetToStopped(token))
|
||||
assertFalse(arbiter.resetToStopped(token))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a start racing ahead of a service-lost callback keeps its intent`() {
|
||||
val arbiter = RunIntentArbiter()
|
||||
val observed = arbiter.request(running = true)
|
||||
|
||||
// The service-loss callback captured `observed`, but a new start won the race first.
|
||||
val restart = arbiter.request(running = true)
|
||||
val settled = arbiter.resetToStopped(observed)
|
||||
|
||||
assertFalse(settled)
|
||||
assertTrue(arbiter.isRunningRequested)
|
||||
assertTrue(arbiter.isCurrent(restart))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `initialRunning seeds the first intent`() {
|
||||
val arbiter = RunIntentArbiter(initialRunning = true)
|
||||
|
||||
assertTrue(arbiter.isRunningRequested)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `concurrent requests leave exactly one winning token`() {
|
||||
val arbiter = RunIntentArbiter()
|
||||
val threads = 8
|
||||
val executor = Executors.newFixedThreadPool(threads)
|
||||
val barrier = CyclicBarrier(threads)
|
||||
|
||||
try {
|
||||
val tokens = (0 until threads).map { index ->
|
||||
executor.submit<RunIntentArbiter.Token> {
|
||||
barrier.await(5, TimeUnit.SECONDS)
|
||||
arbiter.request(running = index % 2 == 0)
|
||||
}
|
||||
}.map { it.get(5, TimeUnit.SECONDS) }
|
||||
|
||||
val winners = tokens.filter(arbiter::isCurrent)
|
||||
assertEquals(1, winners.size)
|
||||
assertEquals(winners.single().running, arbiter.isRunningRequested)
|
||||
|
||||
tokens.filterNot(arbiter::isCurrent).forEach { stale ->
|
||||
assertFalse(arbiter.resetToStopped(stale))
|
||||
}
|
||||
assertTrue(arbiter.isCurrent(winners.single()))
|
||||
} finally {
|
||||
executor.shutdownNow()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package com.follow.clash.service
|
||||
|
||||
import com.follow.clash.common.AccessControlMode
|
||||
import com.follow.clash.service.models.AccessControlProps
|
||||
import com.follow.clash.service.models.NotificationParams
|
||||
import com.follow.clash.service.models.VpnOptions
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotSame
|
||||
import org.junit.Assert.assertSame
|
||||
import org.junit.Test
|
||||
|
||||
private fun vpnOptions(port: Int) = VpnOptions(
|
||||
enable = true,
|
||||
port = port,
|
||||
ipv6 = false,
|
||||
dnsHijacking = true,
|
||||
accessControlProps = AccessControlProps(
|
||||
enable = true,
|
||||
mode = AccessControlMode.REJECT_SELECTED,
|
||||
acceptList = listOf("com.example.accepted"),
|
||||
rejectList = listOf("com.example.rejected"),
|
||||
),
|
||||
allowBypass = true,
|
||||
systemProxy = false,
|
||||
bypassDomain = listOf("example.test"),
|
||||
stack = "system",
|
||||
routeAddress = listOf("0.0.0.0/0"),
|
||||
)
|
||||
|
||||
class ServiceConfigTest {
|
||||
@Test
|
||||
fun `notification params default to the app name and stop label`() {
|
||||
val defaults = NotificationParams()
|
||||
|
||||
assertEquals("FlClash", defaults.title)
|
||||
assertEquals("STOP", defaults.stopText)
|
||||
assertEquals(false, defaults.onlyStatisticsProxy)
|
||||
assertEquals(true, defaults.showStopAction)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `updateVpnOptions publishes the latest options`() {
|
||||
ServiceConfig.updateVpnOptions(vpnOptions(7890))
|
||||
assertEquals(7890, ServiceConfig.vpnOptions?.port)
|
||||
|
||||
val latest = vpnOptions(7891)
|
||||
ServiceConfig.updateVpnOptions(latest)
|
||||
|
||||
assertSame(latest, ServiceConfig.vpnOptions)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `updateNotificationParams emits through the state flow`() = runTest {
|
||||
val params = NotificationParams(
|
||||
title = "Profile",
|
||||
stopText = "Halt",
|
||||
onlyStatisticsProxy = true,
|
||||
)
|
||||
|
||||
ServiceConfig.updateNotificationParams(params)
|
||||
|
||||
assertSame(params, ServiceConfig.notificationParams.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `notification params state flow keeps the newest value`() = runTest {
|
||||
val first = NotificationParams(title = "first")
|
||||
val second = NotificationParams(title = "second")
|
||||
|
||||
ServiceConfig.updateNotificationParams(first)
|
||||
ServiceConfig.updateNotificationParams(second)
|
||||
|
||||
assertSame(second, ServiceConfig.notificationParams.value)
|
||||
assertNotSame(first, ServiceConfig.notificationParams.value)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package com.follow.clash.service.models
|
||||
|
||||
import java.util.Locale
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
class TrafficTest {
|
||||
private lateinit var defaultLocale: Locale
|
||||
|
||||
@Before
|
||||
fun pinLocale() {
|
||||
// speedText formats through String.format, which follows the default locale.
|
||||
defaultLocale = Locale.getDefault()
|
||||
Locale.setDefault(Locale.ROOT)
|
||||
}
|
||||
|
||||
@After
|
||||
fun restoreLocale() {
|
||||
Locale.setDefault(defaultLocale)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `renders whole bytes without a decimal part`() {
|
||||
assertEquals("0B/s↑ 0B/s↓", Traffic(up = 0, down = 0).speedText)
|
||||
assertEquals("1023B/s↑ 1B/s↓", Traffic(up = 1023, down = 1).speedText)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `promotes to the next unit at 1024`() {
|
||||
assertEquals("1.0KB/s↑ 1.0KB/s↓", Traffic(up = 1024, down = 1024).speedText)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `keeps one decimal place for fractional values`() {
|
||||
assertEquals("1.5KB/s↑ 2.5KB/s↓", Traffic(up = 1536, down = 2560).speedText)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `scales through every unit`() {
|
||||
assertTrue(Traffic(up = 1024L * 1024, down = 0).speedText.startsWith("1.0MB"))
|
||||
assertTrue(
|
||||
Traffic(up = 1024L * 1024 * 1024, down = 0).speedText.startsWith("1.0GB"),
|
||||
)
|
||||
assertTrue(
|
||||
Traffic(up = 1024L * 1024 * 1024 * 1024, down = 0).speedText
|
||||
.startsWith("1.0TB"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `stops promoting above terabytes`() {
|
||||
val text = Traffic(up = 1024L * 1024 * 1024 * 1024 * 1024, down = 0).speedText
|
||||
|
||||
assertTrue(text.startsWith("1024.0TB"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `labels upload before download`() {
|
||||
val text = Traffic(up = 1, down = 2).speedText
|
||||
|
||||
assertTrue(text.indexOf("↑") < text.indexOf("↓"))
|
||||
assertEquals("1B/s↑ 2B/s↓", text)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package com.follow.clash.service.models
|
||||
|
||||
import com.follow.clash.common.AccessControlMode
|
||||
import java.net.Inet4Address
|
||||
import java.net.Inet6Address
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertThrows
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
private fun optionsWithRoutes(routeAddress: List<String>) = VpnOptions(
|
||||
enable = true,
|
||||
port = 7890,
|
||||
ipv6 = true,
|
||||
dnsHijacking = false,
|
||||
accessControlProps = AccessControlProps(
|
||||
enable = false,
|
||||
mode = AccessControlMode.ACCEPT_SELECTED,
|
||||
acceptList = emptyList(),
|
||||
rejectList = emptyList(),
|
||||
),
|
||||
allowBypass = false,
|
||||
systemProxy = true,
|
||||
bypassDomain = emptyList(),
|
||||
stack = "gvisor",
|
||||
routeAddress = routeAddress,
|
||||
)
|
||||
|
||||
class ToCIDRTest {
|
||||
@Test
|
||||
fun `parses an IPv4 network`() {
|
||||
val cidr = "192.168.1.0/24".toCIDR()
|
||||
|
||||
assertTrue(cidr.address is Inet4Address)
|
||||
assertEquals("192.168.1.0", cidr.address.hostAddress)
|
||||
assertEquals(24, cidr.prefixLength)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parses an IPv6 network`() {
|
||||
val cidr = "fd00::/8".toCIDR()
|
||||
|
||||
assertTrue(cidr.address is Inet6Address)
|
||||
assertEquals(8, cidr.prefixLength)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `accepts the boundary prefix lengths`() {
|
||||
assertEquals(0, "0.0.0.0/0".toCIDR().prefixLength)
|
||||
assertEquals(32, "10.0.0.1/32".toCIDR().prefixLength)
|
||||
assertEquals(0, "::/0".toCIDR().prefixLength)
|
||||
assertEquals(128, "fd00::1/128".toCIDR().prefixLength)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rejects an address without a prefix`() {
|
||||
val error = assertThrows(IllegalArgumentException::class.java) {
|
||||
"192.168.1.0".toCIDR()
|
||||
}
|
||||
assertTrue(error.message!!.contains("Invalid CIDR format"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rejects an address with too many segments`() {
|
||||
assertThrows(IllegalArgumentException::class.java) {
|
||||
"192.168.1.0/24/8".toCIDR()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rejects a non-numeric prefix`() {
|
||||
val error = assertThrows(IllegalArgumentException::class.java) {
|
||||
"192.168.1.0/abc".toCIDR()
|
||||
}
|
||||
assertTrue(error.message!!.contains("Invalid prefix length"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rejects an IPv4 prefix above 32`() {
|
||||
assertThrows(IllegalArgumentException::class.java) {
|
||||
"192.168.1.0/33".toCIDR()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rejects an IPv6 prefix above 128`() {
|
||||
assertThrows(IllegalArgumentException::class.java) {
|
||||
"fd00::/129".toCIDR()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rejects a negative prefix`() {
|
||||
assertThrows(IllegalArgumentException::class.java) {
|
||||
"192.168.1.0/-1".toCIDR()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class RouteAddressTest {
|
||||
@Test
|
||||
fun `splits a mixed route list by address family`() {
|
||||
val options = optionsWithRoutes(
|
||||
listOf("192.168.1.0/24", "fd00::/8", "10.0.0.0/8", "2000::/3"),
|
||||
)
|
||||
|
||||
val ipv4 = options.getIpv4RouteAddress()
|
||||
val ipv6 = options.getIpv6RouteAddress()
|
||||
|
||||
assertEquals(2, ipv4.size)
|
||||
assertEquals(2, ipv6.size)
|
||||
assertTrue(ipv4.all { it.address is Inet4Address })
|
||||
assertTrue(ipv6.all { it.address is Inet6Address })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `returns empty lists for an empty route list`() {
|
||||
val options = optionsWithRoutes(emptyList())
|
||||
|
||||
assertTrue(options.getIpv4RouteAddress().isEmpty())
|
||||
assertTrue(options.getIpv6RouteAddress().isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `preserves the configured order within a family`() {
|
||||
val options = optionsWithRoutes(
|
||||
listOf("10.0.0.0/8", "fd00::/8", "192.168.0.0/16"),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
listOf(8, 16),
|
||||
options.getIpv4RouteAddress().map { it.prefixLength },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `propagates a malformed entry instead of silently dropping it`() {
|
||||
val options = optionsWithRoutes(listOf("192.168.1.0/24", "not-a-cidr"))
|
||||
|
||||
assertThrows(IllegalArgumentException::class.java) {
|
||||
options.getIpv4RouteAddress()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user