feat(android): add launcher shortcuts and one-step on-demand access
Changelog: Launcher shortcuts and one-step on-demand access
This commit is contained in:
@@ -46,6 +46,10 @@
|
||||
<meta-data
|
||||
android:name="io.flutter.embedding.android.NormalTheme"
|
||||
android:resource="@style/NormalTheme" />
|
||||
<!-- install-config links go through app_links; Flutter would also push them as routes. -->
|
||||
<meta-data
|
||||
android:name="flutter_deeplinking_enabled"
|
||||
android:value="false" />
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.follow.clash
|
||||
|
||||
import com.follow.clash.plugins.AppPlugin
|
||||
import com.follow.clash.plugins.BatteryPlugin
|
||||
import com.follow.clash.plugins.ServicePlugin
|
||||
import com.follow.clash.plugins.TilePlugin
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
@@ -10,6 +11,7 @@ class MainActivity : FlutterActivity() {
|
||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||
super.configureFlutterEngine(flutterEngine)
|
||||
flutterEngine.plugins.add(AppPlugin())
|
||||
flutterEngine.plugins.add(BatteryPlugin())
|
||||
flutterEngine.plugins.add(ServicePlugin())
|
||||
flutterEngine.plugins.add(TilePlugin())
|
||||
ServiceState.attachFlutterEngine(flutterEngine)
|
||||
|
||||
@@ -1,28 +1,37 @@
|
||||
package com.follow.clash
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.core.content.pm.ShortcutManagerCompat
|
||||
import com.follow.clash.common.GlobalState
|
||||
import com.follow.clash.common.QuickAction
|
||||
import com.follow.clash.common.action
|
||||
import com.follow.clash.common.shortcutId
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class QuickActionActivity : Activity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
when (intent.action) {
|
||||
QuickAction.START.action -> GlobalState.launch { ServiceState.handleStartAction() }
|
||||
QuickAction.STOP.action -> GlobalState.launch { ServiceState.handleStopAction() }
|
||||
QuickAction.TOGGLE.action -> {
|
||||
ShortcutManagerCompat.reportShortcutUsed(this, SHORTCUT_ID)
|
||||
GlobalState.launch { ServiceState.handleToggleAction() }
|
||||
}
|
||||
}
|
||||
handle(intent)
|
||||
finish()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val SHORTCUT_ID = "toggle"
|
||||
// A caller that repeats an action before this instance is gone reaches it here, not onCreate.
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
handle(intent)
|
||||
}
|
||||
|
||||
private fun handle(intent: Intent) {
|
||||
val action = QuickAction.entries.firstOrNull { it.action == intent.action } ?: return
|
||||
ShortcutManagerCompat.reportShortcutUsed(this, action.shortcutId)
|
||||
GlobalState.launch {
|
||||
when (action) {
|
||||
QuickAction.START -> ServiceState.handleStartAction()
|
||||
QuickAction.STOP -> ServiceState.handleStopAction()
|
||||
QuickAction.TOGGLE -> ServiceState.handleToggleAction()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,15 +34,9 @@ class ServiceBroadcastReceiver : BroadcastReceiver() {
|
||||
|
||||
private suspend fun handleAction(action: String) {
|
||||
when (action) {
|
||||
BroadcastAction.VPN_START_REQUESTED.action -> {
|
||||
GlobalState.log("System requested VPN service start")
|
||||
ServiceState.handleStartAction()
|
||||
}
|
||||
BroadcastAction.VPN_START_REQUESTED.action -> ServiceState.handleStartAction()
|
||||
|
||||
BroadcastAction.VPN_REVOKED.action -> {
|
||||
GlobalState.log("VPN permission revoked")
|
||||
ServiceState.handleVpnRevokeAction()
|
||||
}
|
||||
BroadcastAction.VPN_REVOKED.action -> ServiceState.handleVpnRevokeAction()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -30,18 +30,23 @@ import kotlin.coroutines.resume
|
||||
object ServiceController {
|
||||
private val lock = Mutex()
|
||||
private var binding: ManagedServiceBinding? = null
|
||||
@Volatile
|
||||
private var runTimeMillis = 0L
|
||||
|
||||
suspend fun unbind() = lock.withLock {
|
||||
clearBinding()
|
||||
}
|
||||
@Volatile
|
||||
internal var runtime: ServiceRuntime? = null
|
||||
private set
|
||||
|
||||
private fun clearBinding() {
|
||||
binding?.unbind()
|
||||
binding = null
|
||||
}
|
||||
|
||||
// The Core listens exactly while a run exists, so every path that ends one closes it.
|
||||
private fun release() {
|
||||
clearBinding()
|
||||
runtime = null
|
||||
Core.stopListener()
|
||||
}
|
||||
|
||||
fun invokeMethod(data: String, callback: (ByteArray) -> Unit): Result<Unit> = runCatching {
|
||||
Core.invokeMethod(data) { result ->
|
||||
callback(result ?: ByteArray(0))
|
||||
@@ -63,7 +68,7 @@ object ServiceController {
|
||||
Core.updateEventListener(callback)
|
||||
}
|
||||
|
||||
suspend fun start(options: VpnOptions): Long = lock.withLock {
|
||||
suspend fun start(options: VpnOptions): Boolean = lock.withLock {
|
||||
ServiceConfig.updateVpnOptions(options)
|
||||
val nextIntent = if (options.enable) {
|
||||
VpnService::class.intent
|
||||
@@ -82,13 +87,12 @@ object ServiceController {
|
||||
binding = nextBinding
|
||||
nextBinding.bind().onFailure { error ->
|
||||
GlobalState.log("Unable to bind background service: $error")
|
||||
clearBinding()
|
||||
runTimeMillis = 0L
|
||||
return@withLock runTimeMillis
|
||||
release()
|
||||
return@withLock false
|
||||
}
|
||||
}
|
||||
|
||||
val currentBinding = binding ?: return@withLock 0L
|
||||
val currentBinding = binding ?: return@withLock false
|
||||
val result = currentBinding.useService { service -> service.start() }
|
||||
if (result.isFailure) {
|
||||
GlobalState.log("Unable to start background service: ${result.exceptionOrNull()}")
|
||||
@@ -96,20 +100,21 @@ object ServiceController {
|
||||
.onFailure { error ->
|
||||
GlobalState.log("Unable to clean up failed background service start: $error")
|
||||
}
|
||||
clearBinding()
|
||||
runTimeMillis = 0L
|
||||
return@withLock runTimeMillis
|
||||
release()
|
||||
return@withLock false
|
||||
}
|
||||
|
||||
if (runTimeMillis == 0L) {
|
||||
runTimeMillis = System.currentTimeMillis()
|
||||
}
|
||||
runTimeMillis
|
||||
Core.startListener()
|
||||
runtime = ServiceRuntime(
|
||||
vpn = options.enable,
|
||||
startedAtMillis = runtime?.startedAtMillis ?: System.currentTimeMillis(),
|
||||
)
|
||||
true
|
||||
}
|
||||
|
||||
suspend fun stop() = lock.withLock {
|
||||
tearDownServices()
|
||||
runTimeMillis = 0L
|
||||
release()
|
||||
}
|
||||
|
||||
private suspend fun tearDownServices() {
|
||||
@@ -133,12 +138,6 @@ object ServiceController {
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun isVpnServiceActive(): Boolean = lock.withLock {
|
||||
runTimeMillis != 0L && binding?.component == VpnService::class.intent.component
|
||||
}
|
||||
|
||||
fun getRunTimeMillis(): Long = runTimeMillis
|
||||
|
||||
private fun handleServiceDisconnected(
|
||||
disconnectedBinding: ManagedServiceBinding,
|
||||
message: String,
|
||||
@@ -150,8 +149,7 @@ object ServiceController {
|
||||
return@withLock false
|
||||
}
|
||||
GlobalState.log("Background service disconnected: $message")
|
||||
clearBinding()
|
||||
runTimeMillis = 0L
|
||||
release()
|
||||
true
|
||||
}
|
||||
if (wasCurrent) {
|
||||
|
||||
@@ -9,6 +9,9 @@ object ServiceState {
|
||||
|
||||
val runState = machine.runState
|
||||
|
||||
val isRunRequested: Boolean
|
||||
get() = machine.isRunRequested
|
||||
|
||||
fun attachFlutterEngine(engine: FlutterEngine) =
|
||||
AndroidServiceStateHost.attachFlutterEngine(engine)
|
||||
|
||||
@@ -23,7 +26,7 @@ object ServiceState {
|
||||
|
||||
suspend fun handleVpnRevokeAction() = machine.handleVpnRevokeAction()
|
||||
|
||||
suspend fun refresh(): Long = machine.refresh()
|
||||
suspend fun awaitRunTime(): Long = machine.awaitRunTime()
|
||||
|
||||
fun requestStart(): Deferred<Boolean> = machine.requestStart()
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import androidx.core.content.ContextCompat
|
||||
import com.follow.clash.common.GlobalState
|
||||
import com.follow.clash.models.SharedState
|
||||
import com.follow.clash.plugins.AppPlugin
|
||||
import com.follow.clash.plugins.ServicePlugin
|
||||
import com.follow.clash.plugins.TilePlugin
|
||||
import com.follow.clash.service.ServiceConfig
|
||||
import com.follow.clash.service.models.NotificationParams
|
||||
@@ -15,13 +16,18 @@ import com.follow.clash.service.models.VpnOptions
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
|
||||
internal data class ServiceRuntime(
|
||||
val vpn: Boolean,
|
||||
val startedAtMillis: Long,
|
||||
)
|
||||
|
||||
/**
|
||||
* 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 runtime: ServiceRuntime?
|
||||
val homeDirPath: String
|
||||
val sdkInt: Int
|
||||
|
||||
@@ -43,19 +49,17 @@ internal interface ServiceStateHost {
|
||||
|
||||
fun app(): AppGateway?
|
||||
|
||||
fun notifyStopped()
|
||||
|
||||
suspend fun quickSetup(initParams: String, setupParams: String): Result<String>
|
||||
|
||||
suspend fun startService(options: VpnOptions): Long
|
||||
suspend fun startService(options: VpnOptions): Boolean
|
||||
|
||||
suspend fun stopService()
|
||||
|
||||
suspend fun isVpnServiceActive(): Boolean
|
||||
}
|
||||
|
||||
internal interface TileGateway {
|
||||
internal fun interface TileGateway {
|
||||
fun handleStart()
|
||||
|
||||
fun handleStop()
|
||||
}
|
||||
|
||||
internal interface AppGateway {
|
||||
@@ -75,8 +79,8 @@ internal object AndroidServiceStateHost : ServiceStateHost {
|
||||
override val scope: CoroutineScope
|
||||
get() = GlobalState
|
||||
|
||||
override val runTimeMillis: Long
|
||||
get() = ServiceController.getRunTimeMillis()
|
||||
override val runtime: ServiceRuntime?
|
||||
get() = ServiceController.runtime
|
||||
|
||||
override val homeDirPath: String
|
||||
get() = GlobalState.application.filesDir.path
|
||||
@@ -116,11 +120,7 @@ internal object AndroidServiceStateHost : ServiceStateHost {
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
|
||||
override fun tile(): TileGateway? = flutterEngine?.plugin<TilePlugin>()?.let { plugin ->
|
||||
object : TileGateway {
|
||||
override fun handleStart() = plugin.handleStart()
|
||||
|
||||
override fun handleStop() = plugin.handleStop()
|
||||
}
|
||||
TileGateway(plugin::handleStart)
|
||||
}
|
||||
|
||||
override fun app(): AppGateway? = flutterEngine?.plugin<AppPlugin>()?.let { plugin ->
|
||||
@@ -139,13 +139,15 @@ internal object AndroidServiceStateHost : ServiceStateHost {
|
||||
}
|
||||
}
|
||||
|
||||
override fun notifyStopped() {
|
||||
flutterEngine?.plugin<ServicePlugin>()?.notifyStopped()
|
||||
}
|
||||
|
||||
override suspend fun quickSetup(initParams: String, setupParams: String): Result<String> =
|
||||
ServiceController.quickSetup(initParams, setupParams)
|
||||
|
||||
override suspend fun startService(options: VpnOptions): Long =
|
||||
override suspend fun startService(options: VpnOptions): Boolean =
|
||||
ServiceController.start(options)
|
||||
|
||||
override suspend fun stopService() = ServiceController.stop()
|
||||
|
||||
override suspend fun isVpnServiceActive(): Boolean = ServiceController.isVpnServiceActive()
|
||||
}
|
||||
|
||||
@@ -5,8 +5,10 @@ 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.CancellationException
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.Deferred
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -29,10 +31,16 @@ 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."
|
||||
|
||||
private enum class StartOutcome {
|
||||
STARTED,
|
||||
FAILED,
|
||||
SUPERSEDED,
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Callers request a transition; the newest request always wins. No step assigns [runState]:
|
||||
* [publish] derives it from the latest intent, the transition in flight and what the host really
|
||||
* runs, so no path can leave it claiming a service that is not there.
|
||||
*/
|
||||
internal class ServiceStateMachine(private val host: ServiceStateHost) {
|
||||
private val transitionLock = Mutex()
|
||||
@@ -46,23 +54,24 @@ internal class ServiceStateMachine(private val host: ServiceStateHost) {
|
||||
@Volatile
|
||||
private var pendingVpnPreparation: (() -> Unit)? = null
|
||||
|
||||
@Volatile
|
||||
private var transition: RunState? = null
|
||||
|
||||
val runState = mutableRunState.asStateFlow()
|
||||
|
||||
private val runTimeMillis: Long
|
||||
get() = host.runTimeMillis
|
||||
val isRunRequested: Boolean
|
||||
get() = arbiter.isRunningRequested
|
||||
|
||||
suspend fun handleToggleAction() {
|
||||
if (isRunningRequested()) {
|
||||
if (isRunRequested) {
|
||||
handleStopAction()
|
||||
} else {
|
||||
handleStartAction()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun refresh(): Long = transitionLock.withLock {
|
||||
val current = runTimeMillis
|
||||
mutableRunState.value = if (current == 0L) RunState.STOPPED else RunState.STARTED
|
||||
current
|
||||
suspend fun awaitRunTime(): Long = transitionLock.withLock {
|
||||
host.runtime?.startedAtMillis?.takeIf { isRunRequested } ?: 0L
|
||||
}
|
||||
|
||||
fun captureRequestToken(): RunRequest = arbiter.current()
|
||||
@@ -71,18 +80,20 @@ internal class ServiceStateMachine(private val host: ServiceStateHost) {
|
||||
* 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
|
||||
suspend fun handleServiceLost(token: RunRequest) {
|
||||
val dropped = transitionLock.withLock {
|
||||
if (host.runtime != null) {
|
||||
return@withLock false
|
||||
}
|
||||
arbiter.resetToStopped(token).also { publish() }
|
||||
}
|
||||
if (!arbiter.resetToStopped(token)) {
|
||||
return@withLock
|
||||
if (dropped) {
|
||||
host.notifyStopped()
|
||||
}
|
||||
mutableRunState.value = RunState.STOPPED
|
||||
}
|
||||
|
||||
suspend fun handleStartAction() {
|
||||
if (isRunningRequested()) {
|
||||
if (isRunRequested) {
|
||||
return
|
||||
}
|
||||
val tile = host.tile()
|
||||
@@ -90,78 +101,36 @@ internal class ServiceStateMachine(private val host: ServiceStateHost) {
|
||||
tile.handleStart()
|
||||
return
|
||||
}
|
||||
loadPreferencesAndStart()
|
||||
startFromPreferences()
|
||||
}
|
||||
|
||||
suspend fun handleStopAction() {
|
||||
if (!isRunningRequested()) {
|
||||
return
|
||||
}
|
||||
val tile = host.tile()
|
||||
if (tile != null) {
|
||||
tile.handleStop()
|
||||
if (!isRunRequested) {
|
||||
return
|
||||
}
|
||||
host.showToast(sharedState.stopTip)
|
||||
requestStop().await()
|
||||
host.notifyStopped()
|
||||
}
|
||||
|
||||
suspend fun handleVpnRevokeAction() {
|
||||
if (!host.isVpnServiceActive()) {
|
||||
if (host.runtime?.vpn != true) {
|
||||
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),
|
||||
)
|
||||
}
|
||||
}
|
||||
if (isStartedAs(sharedState.vpnOptions)) {
|
||||
return CompletableDeferred(true)
|
||||
}
|
||||
val app = host.app()
|
||||
if (app != null) {
|
||||
app.requestNotificationPermission { shouldStart ->
|
||||
if (shouldStart) {
|
||||
app.requestLocalNetworkPermission { launchRequest(true) }
|
||||
} else {
|
||||
launchRequest(false)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
launchRequest(true)
|
||||
}
|
||||
return result
|
||||
val outcome = launchStart(createRequest(running = true))
|
||||
return host.scope.async { outcome.await() == StartOutcome.STARTED }
|
||||
}
|
||||
|
||||
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
|
||||
return host.scope.async { stop(request) }
|
||||
}
|
||||
|
||||
fun syncSharedState(state: SharedState) {
|
||||
@@ -169,17 +138,47 @@ internal class ServiceStateMachine(private val host: ServiceStateHost) {
|
||||
applySharedState()
|
||||
}
|
||||
|
||||
private suspend fun loadPreferencesAndStart() {
|
||||
// No new request for a settled run: one refused at a prompt would take the service down.
|
||||
private fun isStartedAs(options: VpnOptions?): Boolean =
|
||||
runState.value == RunState.STARTED && host.runtime?.vpn == options?.enable
|
||||
|
||||
// The intent is claimed before the Core is set up, so a second action that lands meanwhile
|
||||
// toggles this start off instead of racing a second setup against it.
|
||||
private suspend fun startFromPreferences() {
|
||||
val request = createRequest(running = true)
|
||||
sharedState = host.loadSharedState()
|
||||
if (sharedState.setupParams == null || sharedState.vpnOptions == null) {
|
||||
host.showToast(MISSING_CONFIG_MESSAGE)
|
||||
abandon(request)
|
||||
return
|
||||
}
|
||||
if (setupCore()) {
|
||||
if (!requestStart().await()) {
|
||||
host.showToast(START_FAILED_MESSAGE)
|
||||
if (!setupCore()) {
|
||||
abandon(request)
|
||||
} else if (launchStart(request).await() == StartOutcome.FAILED) {
|
||||
host.showToast(START_FAILED_MESSAGE)
|
||||
}
|
||||
}
|
||||
|
||||
private fun launchStart(request: RunRequest): Deferred<StartOutcome> {
|
||||
val result = CompletableDeferred<StartOutcome>()
|
||||
val launch: (Boolean) -> Unit = { permitted ->
|
||||
host.scope.launch {
|
||||
result.complete(if (permitted) start(request) else abandon(request))
|
||||
}
|
||||
}
|
||||
val app = host.app()
|
||||
if (app != null) {
|
||||
app.requestNotificationPermission { permitted ->
|
||||
if (permitted) {
|
||||
app.requestLocalNetworkPermission { launch(true) }
|
||||
} else {
|
||||
launch(false)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
launch(true)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private fun applySharedState() {
|
||||
@@ -215,54 +214,47 @@ internal class ServiceStateMachine(private val host: ServiceStateHost) {
|
||||
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()
|
||||
private suspend fun start(request: RunRequest): StartOutcome {
|
||||
val started = try {
|
||||
startPreparationLock.withLock { runStart(request) }
|
||||
} catch (error: CancellationException) {
|
||||
throw error
|
||||
} catch (error: Exception) {
|
||||
host.log("Unable to process service start request: $error")
|
||||
false
|
||||
}
|
||||
started
|
||||
return if (started) StartOutcome.STARTED else abandon(request)
|
||||
}
|
||||
|
||||
private suspend fun abandon(request: RunRequest): StartOutcome {
|
||||
val dropped = arbiter.resetToStopped(request)
|
||||
reconcileStopped()
|
||||
if (!dropped) {
|
||||
return StartOutcome.SUPERSEDED
|
||||
}
|
||||
host.notifyStopped()
|
||||
return StartOutcome.FAILED
|
||||
}
|
||||
|
||||
private suspend fun runStart(request: RunRequest): Boolean {
|
||||
if (!isCurrent(request)) {
|
||||
return false
|
||||
}
|
||||
val options = sharedState.vpnOptions
|
||||
if (options == null) {
|
||||
fail(request)
|
||||
return false
|
||||
}
|
||||
val options = sharedState.vpnOptions ?: 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
|
||||
return transitionLock.withLock {
|
||||
when {
|
||||
!isCurrent(request) -> false
|
||||
host.runtime?.vpn == options.enable -> true
|
||||
else -> transitioning(RunState.STARTING) {
|
||||
host.startService(withLocalNetworkFallback(options)) && isCurrent(request)
|
||||
}
|
||||
}
|
||||
if (runTimeMillis != 0L && host.isVpnServiceActive() == options.enable) {
|
||||
mutableRunState.value = RunState.STARTED
|
||||
return@transition true
|
||||
}
|
||||
mutableRunState.value = RunState.STARTING
|
||||
val startedAtMillis = host.startService(withLocalNetworkFallback(options))
|
||||
if (startedAtMillis == 0L) {
|
||||
mutableRunState.value = RunState.STOPPED
|
||||
fail(request)
|
||||
return@transition false
|
||||
}
|
||||
if (!isCurrent(request)) {
|
||||
return@transition false
|
||||
}
|
||||
mutableRunState.value = RunState.STARTED
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -277,13 +269,17 @@ internal class ServiceStateMachine(private val host: ServiceStateHost) {
|
||||
return options.copy(stack = FALLBACK_STACK)
|
||||
}
|
||||
|
||||
// Flutter's startListener and the native quickSetup both open the Core's listeners before any
|
||||
// service exists to answer for them, and Flutter may be gone before it hears of the failure.
|
||||
private suspend fun reconcileStopped() = transitionLock.withLock {
|
||||
if (isRunningRequested() || runTimeMillis == 0L) {
|
||||
return@withLock
|
||||
when {
|
||||
isRunRequested -> publish()
|
||||
host.runtime != null -> stopLocked()
|
||||
else -> {
|
||||
stopService()
|
||||
publish()
|
||||
}
|
||||
}
|
||||
mutableRunState.value = RunState.STOPPING
|
||||
host.stopService()
|
||||
mutableRunState.value = RunState.STOPPED
|
||||
}
|
||||
|
||||
private suspend fun stop(request: RunRequest): Boolean = transitionLock.withLock {
|
||||
@@ -291,15 +287,44 @@ internal class ServiceStateMachine(private val host: ServiceStateHost) {
|
||||
return@withLock false
|
||||
}
|
||||
abandonVpnPreparation()
|
||||
if (runState.value == RunState.STOPPED && runTimeMillis == 0L) {
|
||||
return@withLock true
|
||||
if (host.runtime != null) {
|
||||
stopLocked()
|
||||
}
|
||||
mutableRunState.value = RunState.STOPPING
|
||||
host.stopService()
|
||||
mutableRunState.value = RunState.STOPPED
|
||||
isCurrent(request)
|
||||
}
|
||||
|
||||
private suspend fun stopLocked() = transitioning(RunState.STOPPING) { stopService() }
|
||||
|
||||
private suspend fun stopService() {
|
||||
try {
|
||||
host.stopService()
|
||||
} catch (error: CancellationException) {
|
||||
throw error
|
||||
} catch (error: Exception) {
|
||||
host.log("Unable to stop service: $error")
|
||||
}
|
||||
}
|
||||
|
||||
private inline fun <T> transitioning(state: RunState, block: () -> T): T {
|
||||
transition = state
|
||||
publish()
|
||||
try {
|
||||
return block()
|
||||
} finally {
|
||||
transition = null
|
||||
publish()
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun publish() {
|
||||
mutableRunState.value = transition ?: when {
|
||||
host.runtime == null -> RunState.STOPPED
|
||||
isRunRequested -> RunState.STARTED
|
||||
else -> RunState.STOPPING
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun prepareVpn(options: VpnOptions): Boolean {
|
||||
val app = host.app()
|
||||
?: return !options.enable || host.isVpnPermissionGranted()
|
||||
@@ -328,16 +353,11 @@ internal class ServiceStateMachine(private val host: ServiceStateHost) {
|
||||
abandon()
|
||||
}
|
||||
|
||||
private fun createRequest(running: Boolean): RunRequest = arbiter.request(running)
|
||||
|
||||
private fun isRunningRequested(): Boolean = arbiter.isRunningRequested
|
||||
private fun createRequest(running: Boolean): RunRequest =
|
||||
arbiter.request(running).also { publish() }
|
||||
|
||||
private fun isCurrent(request: RunRequest): Boolean = arbiter.isCurrent(request)
|
||||
|
||||
private fun fail(request: RunRequest) {
|
||||
arbiter.resetToStopped(request)
|
||||
}
|
||||
|
||||
internal companion object {
|
||||
val KERNEL_TCP_STACKS = setOf("system", "mixed")
|
||||
const val FALLBACK_STACK = "gvisor"
|
||||
|
||||
@@ -20,7 +20,6 @@ class TileService : android.service.quicksettings.TileService() {
|
||||
scope?.cancel()
|
||||
scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate).also { scope ->
|
||||
scope.launch {
|
||||
ServiceState.refresh()
|
||||
ServiceState.runState.collect(::updateTile)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,4 +22,6 @@ data class SetupParams(
|
||||
val testUrl: String,
|
||||
@SerializedName("selected-map")
|
||||
val selectedMap: Map<String, String>,
|
||||
@SerializedName("skip-cert-verify")
|
||||
val skipCertVerify: Boolean = false,
|
||||
)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package com.follow.clash.plugins
|
||||
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Activity
|
||||
import android.app.ActivityManager
|
||||
import android.content.BroadcastReceiver
|
||||
@@ -13,7 +12,6 @@ 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
|
||||
import androidx.core.content.ContextCompat
|
||||
@@ -29,6 +27,7 @@ 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.common.shortcutId
|
||||
import com.follow.clash.getPackageIconPath
|
||||
import com.follow.clash.packages.PackageResolver
|
||||
import com.follow.clash.showToast
|
||||
@@ -139,11 +138,15 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
|
||||
}
|
||||
|
||||
"initShortcuts" -> {
|
||||
val label = call.arguments as? String
|
||||
if (label == null) {
|
||||
result.error("INVALID_ARGUMENT", "Shortcut label must be a string", null)
|
||||
val labels = shortcutLabels(call.arguments)
|
||||
if (labels == null) {
|
||||
result.error(
|
||||
"INVALID_ARGUMENT",
|
||||
"Shortcut labels must map every quick action to a string",
|
||||
null,
|
||||
)
|
||||
} else {
|
||||
initShortcuts(label)
|
||||
initShortcuts(labels)
|
||||
result.success(true)
|
||||
}
|
||||
}
|
||||
@@ -174,14 +177,6 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
|
||||
result.success(true)
|
||||
}
|
||||
|
||||
"isBatteryOptimizationDisabled" -> {
|
||||
result.success(isBatteryOptimizationDisabled())
|
||||
}
|
||||
|
||||
"openBatteryOptimizationSettings" -> {
|
||||
result.success(openBatteryOptimizationSettings())
|
||||
}
|
||||
|
||||
"openAppSettings" -> {
|
||||
result.success(openAppSettings())
|
||||
}
|
||||
@@ -218,45 +213,35 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
|
||||
}
|
||||
}
|
||||
|
||||
private fun initShortcuts(label: String) {
|
||||
val shortcut = with(ShortcutInfoCompat.Builder(GlobalState.application, "toggle")) {
|
||||
setShortLabel(label)
|
||||
setIcon(
|
||||
IconCompat.createWithResource(
|
||||
GlobalState.application,
|
||||
R.mipmap.ic_launcher_round,
|
||||
),
|
||||
)
|
||||
setIntent(QuickAction.TOGGLE.quickIntent)
|
||||
build()
|
||||
private fun shortcutLabels(arguments: Any?): Map<QuickAction, String>? {
|
||||
val map = arguments as? Map<*, *> ?: return null
|
||||
return QuickAction.entries.associateWith { action ->
|
||||
map[action.shortcutId] as? String ?: return null
|
||||
}
|
||||
ShortcutManagerCompat.setDynamicShortcuts(
|
||||
GlobalState.application,
|
||||
listOf(shortcut),
|
||||
)
|
||||
}
|
||||
|
||||
private fun isBatteryOptimizationDisabled(): Boolean {
|
||||
val powerManager = getSystemService(GlobalState.application, PowerManager::class.java)
|
||||
return powerManager?.isIgnoringBatteryOptimizations(GlobalState.application.packageName)
|
||||
?: false
|
||||
}
|
||||
|
||||
@SuppressLint("BatteryLife")
|
||||
private fun openBatteryOptimizationSettings(): Boolean {
|
||||
// VPN continuity is the user-requested core function, so the direct exemption is intentional.
|
||||
val activity = activity ?: return false
|
||||
return try {
|
||||
val intent = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
|
||||
data = "package:${GlobalState.application.packageName}".toUri()
|
||||
private fun initShortcuts(labels: Map<QuickAction, String>) {
|
||||
val shortcuts = SHORTCUT_ORDER.mapIndexed { rank, action ->
|
||||
with(ShortcutInfoCompat.Builder(GlobalState.application, action.shortcutId)) {
|
||||
setShortLabel(labels.getValue(action))
|
||||
setIcon(IconCompat.createWithResource(GlobalState.application, action.shortcutIcon))
|
||||
setIntent(action.quickIntent)
|
||||
setRank(rank)
|
||||
build()
|
||||
}
|
||||
activity.startActivity(intent)
|
||||
true
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
ShortcutManagerCompat.setDynamicShortcuts(GlobalState.application, shortcuts)
|
||||
}
|
||||
|
||||
// Plain circles, not adaptive icons: an adaptive shortcut icon takes the
|
||||
// launcher's own mask, which is a square on many devices.
|
||||
private val QuickAction.shortcutIcon: Int
|
||||
get() = when (this) {
|
||||
QuickAction.START -> R.drawable.ic_shortcut_start
|
||||
QuickAction.STOP -> R.drawable.ic_shortcut_stop
|
||||
QuickAction.TOGGLE -> R.drawable.ic_shortcut_toggle
|
||||
}
|
||||
|
||||
private fun openAppSettings(): Boolean {
|
||||
val activity = activity ?: return false
|
||||
return try {
|
||||
@@ -514,6 +499,7 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val SHORTCUT_ORDER = listOf(QuickAction.START, QuickAction.STOP, QuickAction.TOGGLE)
|
||||
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,241 @@
|
||||
package com.follow.clash.plugins
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Activity
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.database.ContentObserver
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.os.PowerManager
|
||||
import android.provider.Settings
|
||||
import androidx.core.net.toUri
|
||||
import com.follow.clash.common.Components
|
||||
import com.follow.clash.common.PendingCallback
|
||||
import com.follow.clash.common.registerReceiverCompat
|
||||
import io.flutter.embedding.engine.plugins.FlutterPlugin
|
||||
import io.flutter.embedding.engine.plugins.activity.ActivityAware
|
||||
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
|
||||
import io.flutter.plugin.common.EventChannel
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import io.flutter.plugin.common.PluginRegistry
|
||||
|
||||
class BatteryPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware,
|
||||
EventChannel.StreamHandler {
|
||||
|
||||
private lateinit var context: Context
|
||||
|
||||
private lateinit var methodChannel: MethodChannel
|
||||
|
||||
private lateinit var eventChannel: EventChannel
|
||||
|
||||
private var activityBinding: ActivityPluginBinding? = null
|
||||
|
||||
private val pendingRequest = PendingCallback<Unit>()
|
||||
|
||||
private var events: EventChannel.EventSink? = null
|
||||
|
||||
private var lastPublished: Boolean? = null
|
||||
|
||||
private val activityResultListener =
|
||||
PluginRegistry.ActivityResultListener { requestCode, _, _ ->
|
||||
if (requestCode != REQUEST_CODE) {
|
||||
return@ActivityResultListener false
|
||||
}
|
||||
resolve()
|
||||
true
|
||||
}
|
||||
|
||||
private val allowlistReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
publish()
|
||||
}
|
||||
}
|
||||
|
||||
private val saverObserver = object : ContentObserver(Handler(Looper.getMainLooper())) {
|
||||
override fun onChange(selfChange: Boolean) {
|
||||
publish()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||
context = binding.applicationContext
|
||||
methodChannel = MethodChannel(binding.binaryMessenger, "${Components.PACKAGE_NAME}/battery")
|
||||
methodChannel.setMethodCallHandler(this)
|
||||
eventChannel = EventChannel(
|
||||
binding.binaryMessenger,
|
||||
"${Components.PACKAGE_NAME}/battery/optimization",
|
||||
)
|
||||
eventChannel.setStreamHandler(this)
|
||||
}
|
||||
|
||||
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||
methodChannel.setMethodCallHandler(null)
|
||||
eventChannel.setStreamHandler(null)
|
||||
onCancel(null)
|
||||
resolve()
|
||||
}
|
||||
|
||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||
when (call.method) {
|
||||
"requestIgnoreOptimizations" -> requestIgnoreOptimizations { result.success(null) }
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onListen(arguments: Any?, sink: EventChannel.EventSink) {
|
||||
val ignoring = isIgnoringOptimizations()
|
||||
sink.success(ignoring)
|
||||
context.registerReceiverCompat(
|
||||
allowlistReceiver,
|
||||
IntentFilter(ACTION_POWER_SAVE_WHITELIST_CHANGED),
|
||||
)
|
||||
context.contentResolver.registerContentObserver(
|
||||
Settings.System.getUriFor(MILLET_NO_RESTRICT_APP),
|
||||
false,
|
||||
saverObserver,
|
||||
)
|
||||
events = sink
|
||||
lastPublished = ignoring
|
||||
}
|
||||
|
||||
override fun onCancel(arguments: Any?) {
|
||||
events ?: return
|
||||
events = null
|
||||
context.unregisterReceiver(allowlistReceiver)
|
||||
context.contentResolver.unregisterContentObserver(saverObserver)
|
||||
}
|
||||
|
||||
private fun publish() {
|
||||
val sink = events ?: return
|
||||
val ignoring = isIgnoringOptimizations()
|
||||
if (ignoring == lastPublished) {
|
||||
return
|
||||
}
|
||||
lastPublished = ignoring
|
||||
sink.success(ignoring)
|
||||
}
|
||||
|
||||
private fun resolve() {
|
||||
publish()
|
||||
pendingRequest.resolve(Unit)
|
||||
}
|
||||
|
||||
private fun requestIgnoreOptimizations(callback: (Unit) -> Unit) {
|
||||
val activity = activityBinding?.activity
|
||||
if (activity == null || isIgnoringOptimizations()) {
|
||||
callback(Unit)
|
||||
return
|
||||
}
|
||||
pendingRequest.replace(callback, supersededValue = Unit)
|
||||
val pages = if (isXiaomi) saverPages() else optimizationPages()
|
||||
if (pages.none { launch(activity, it) }) {
|
||||
resolve()
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("BatteryLife")
|
||||
private fun optimizationPages(): List<Intent> {
|
||||
// VPN continuity is the user-requested core function, so the direct exemption is intentional.
|
||||
val prompt = Intent(
|
||||
Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS,
|
||||
"package:${context.packageName}".toUri(),
|
||||
)
|
||||
// Some ROMs drop the direct prompt; the allowlist page still lets the user exempt the app.
|
||||
val allowlist = Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS)
|
||||
return listOf(prompt, allowlist)
|
||||
}
|
||||
|
||||
private fun saverPages(): List<Intent> {
|
||||
val policy = Intent()
|
||||
.setComponent(ComponentName(POWERKEEPER_PACKAGE, POWERKEEPER_POLICY_ACTIVITY))
|
||||
.putExtra("package_name", context.packageName)
|
||||
.putExtra(
|
||||
"package_label",
|
||||
context.applicationInfo.loadLabel(context.packageManager).toString(),
|
||||
)
|
||||
val details = Intent(
|
||||
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
|
||||
"package:${context.packageName}".toUri(),
|
||||
)
|
||||
return listOf(policy, details)
|
||||
}
|
||||
|
||||
private fun launch(activity: Activity, page: Intent): Boolean = try {
|
||||
@Suppress("DEPRECATION")
|
||||
activity.startActivityForResult(page, REQUEST_CODE)
|
||||
true
|
||||
} catch (_: ActivityNotFoundException) {
|
||||
false
|
||||
} catch (_: SecurityException) {
|
||||
false
|
||||
}
|
||||
|
||||
// On Xiaomi the battery saver is what counts: "No restrictions" also allowlists the
|
||||
// app, the allowlist alone does not stop PowerKeeper freezing it, and PowerKeeper
|
||||
// drops a running VPN from the allowlist while the list follows the user's policy.
|
||||
private fun isIgnoringOptimizations(): Boolean {
|
||||
if (isXiaomi) {
|
||||
return context.packageName in noRestrictionApps()
|
||||
}
|
||||
return context.getSystemService(PowerManager::class.java)
|
||||
?.isIgnoringBatteryOptimizations(context.packageName) ?: false
|
||||
}
|
||||
|
||||
// PowerKeeper's comma-separated "No restrictions" packages, kept for user 0 only.
|
||||
private fun noRestrictionApps(): List<String> {
|
||||
val packages = try {
|
||||
Settings.System.getString(context.contentResolver, MILLET_NO_RESTRICT_APP)
|
||||
} catch (_: SecurityException) {
|
||||
null
|
||||
}
|
||||
return packages?.split(',')?.map { it.trim() }.orEmpty()
|
||||
}
|
||||
|
||||
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
|
||||
activityBinding = binding
|
||||
binding.addActivityResultListener(activityResultListener)
|
||||
}
|
||||
|
||||
override fun onDetachedFromActivityForConfigChanges() {
|
||||
detachFromActivity()
|
||||
}
|
||||
|
||||
override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
|
||||
onAttachedToActivity(binding)
|
||||
}
|
||||
|
||||
override fun onDetachedFromActivity() {
|
||||
detachFromActivity()
|
||||
resolve()
|
||||
}
|
||||
|
||||
private fun detachFromActivity() {
|
||||
activityBinding?.removeActivityResultListener(activityResultListener)
|
||||
activityBinding = null
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val isXiaomi = Build.MANUFACTURER.equals("xiaomi", ignoreCase = true)
|
||||
|
||||
// Every plugin on the activity sees every result; AppPlugin holds 1001-1004.
|
||||
const val REQUEST_CODE = 1005
|
||||
|
||||
// PowerManager keeps this action @hide, yet the system broadcasts it to
|
||||
// registered receivers of the system user whenever the allowlist changes.
|
||||
const val ACTION_POWER_SAVE_WHITELIST_CHANGED =
|
||||
"android.os.action.POWER_SAVE_WHITELIST_CHANGED"
|
||||
|
||||
const val MILLET_NO_RESTRICT_APP = "MILLET_NO_RESTRICT_APP"
|
||||
|
||||
const val POWERKEEPER_PACKAGE = "com.miui.powerkeeper"
|
||||
|
||||
const val POWERKEEPER_POLICY_ACTIVITY = "com.miui.powerkeeper.ui.HiddenAppsConfigActivity"
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ class ServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
|
||||
private lateinit var channel: MethodChannel
|
||||
private lateinit var scope: CoroutineScope
|
||||
private val gson = Gson()
|
||||
private var commandRevision = 0
|
||||
|
||||
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||
scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
@@ -42,8 +43,8 @@ class ServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
|
||||
"invokeMethod" -> invokeMethod(call, result)
|
||||
"getRunTime" -> getRunTime(result)
|
||||
"syncState" -> syncState(call, result)
|
||||
"start" -> start(result)
|
||||
"stop" -> stop(result)
|
||||
"start" -> start(call, result)
|
||||
"stop" -> stop(call, result)
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
@@ -55,10 +56,8 @@ class ServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
|
||||
}
|
||||
|
||||
private fun shutdown(result: MethodChannel.Result) {
|
||||
scope.launch {
|
||||
ServiceController.unbind()
|
||||
result.success(true)
|
||||
}
|
||||
ServiceController.setEventListener(null)
|
||||
result.success(true)
|
||||
}
|
||||
|
||||
private fun invokeMethod(call: MethodCall, result: MethodChannel.Result) {
|
||||
@@ -79,7 +78,7 @@ class ServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
|
||||
|
||||
private fun getRunTime(result: MethodChannel.Result) {
|
||||
scope.launch {
|
||||
result.success(ServiceState.refresh())
|
||||
result.success(ServiceState.awaitRunTime())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,16 +97,27 @@ class ServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
|
||||
}
|
||||
}
|
||||
|
||||
private fun start(result: MethodChannel.Result) {
|
||||
private fun start(call: MethodCall, result: MethodChannel.Result) {
|
||||
commandRevision = call.arguments as? Int ?: commandRevision
|
||||
ServiceState.requestStart()
|
||||
result.success(true)
|
||||
}
|
||||
|
||||
private fun stop(result: MethodChannel.Result) {
|
||||
private fun stop(call: MethodCall, result: MethodChannel.Result) {
|
||||
commandRevision = call.arguments as? Int ?: commandRevision
|
||||
ServiceState.requestStop()
|
||||
result.success(true)
|
||||
}
|
||||
|
||||
// Read on the platform thread, where start and stop arrive: revision and intent are one instant.
|
||||
fun notifyStopped() {
|
||||
scope.launch(Dispatchers.Main) {
|
||||
if (!ServiceState.isRunRequested) {
|
||||
channel.invokeMethod("stopped", commandRevision)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun sendEvent(value: String?) {
|
||||
scope.launch(Dispatchers.Main) {
|
||||
channel.invokeMethod("event", value)
|
||||
|
||||
@@ -23,10 +23,6 @@ class TilePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
|
||||
channel.invokeMethodOnMainThread("start")
|
||||
}
|
||||
|
||||
fun handleStop() {
|
||||
channel.invokeMethodOnMainThread("stop")
|
||||
}
|
||||
|
||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||
result.notImplemented()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="48dp"
|
||||
android:height="48dp"
|
||||
android:viewportWidth="48"
|
||||
android:viewportHeight="48">
|
||||
<path
|
||||
android:fillColor="@color/shortcut_background"
|
||||
android:pathData="M24,2 A22,22 0 1 1 24,46 A22,22 0 1 1 24,2 Z" />
|
||||
<group
|
||||
android:scaleX="1.35"
|
||||
android:scaleY="1.35"
|
||||
android:translateX="7.8"
|
||||
android:translateY="7.8">
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M8.30,7.74 A1.2,1.2 0 0 1 10.12,6.71 L17.12,10.98 A1.2,1.2 0 0 1 17.12,13.02 L10.12,17.29 A1.2,1.2 0 0 1 8.30,16.26 Z"
|
||||
android:strokeColor="#FFFFFF"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:strokeWidth="1.8" />
|
||||
</group>
|
||||
</vector>
|
||||
@@ -0,0 +1,23 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="48dp"
|
||||
android:height="48dp"
|
||||
android:viewportWidth="48"
|
||||
android:viewportHeight="48">
|
||||
<path
|
||||
android:fillColor="@color/shortcut_background"
|
||||
android:pathData="M24,2 A22,22 0 1 1 24,46 A22,22 0 1 1 24,2 Z" />
|
||||
<group
|
||||
android:scaleX="1.35"
|
||||
android:scaleY="1.35"
|
||||
android:translateX="7.8"
|
||||
android:translateY="7.8">
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M14.90,7.00 A1.1,1.1 0 0 1 16.00,5.90 L16.00,5.90 A1.1,1.1 0 0 1 17.10,7.00 L17.10,17.00 A1.1,1.1 0 0 1 16.00,18.10 L16.00,18.10 A1.1,1.1 0 0 1 14.90,17.00 Z M6.90,7.00 A1.1,1.1 0 0 1 8.00,5.90 L8.00,5.90 A1.1,1.1 0 0 1 9.10,7.00 L9.10,17.00 A1.1,1.1 0 0 1 8.00,18.10 L8.00,18.10 A1.1,1.1 0 0 1 6.90,17.00 Z"
|
||||
android:strokeColor="#FFFFFF"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:strokeWidth="1.8" />
|
||||
</group>
|
||||
</vector>
|
||||
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="48dp"
|
||||
android:height="48dp"
|
||||
android:viewportWidth="48"
|
||||
android:viewportHeight="48">
|
||||
<path
|
||||
android:fillColor="@color/shortcut_background"
|
||||
android:pathData="M24,2 A22,22 0 1 1 24,46 A22,22 0 1 1 24,2 Z" />
|
||||
<group
|
||||
android:scaleX="1.35"
|
||||
android:scaleY="1.35"
|
||||
android:translateX="7.8"
|
||||
android:translateY="7.8">
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M2.90,12.00 A5.5,5.5 0 0 1 8.40,6.50 L15.60,6.50 A5.5,5.5 0 0 1 21.10,12.00 L21.10,12.00 A5.5,5.5 0 0 1 15.60,17.50 L8.40,17.50 A5.5,5.5 0 0 1 2.90,12.00 Z"
|
||||
android:strokeColor="#FFFFFF"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:strokeWidth="1.8" />
|
||||
<path
|
||||
android:fillColor="@color/shortcut_background"
|
||||
android:pathData="M8.4,9.2 A2.8,2.8 0 1 1 8.4,14.8 A2.8,2.8 0 1 1 8.4,9.2 Z" />
|
||||
</group>
|
||||
</vector>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="shortcut_background">#6666FB</color>
|
||||
</resources>
|
||||
@@ -9,6 +9,7 @@ import android.app.Service
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Context.RECEIVER_EXPORTED
|
||||
import android.content.Context.RECEIVER_NOT_EXPORTED
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
@@ -30,6 +31,9 @@ val ComponentName.intent: Intent
|
||||
val QuickAction.action: String
|
||||
get() = "${GlobalState.application.packageName}.action.${this.name}"
|
||||
|
||||
val QuickAction.shortcutId: String
|
||||
get() = name.lowercase()
|
||||
|
||||
val QuickAction.quickIntent: Intent
|
||||
get() = Components.quickActionActivity.intent.apply {
|
||||
action = this@quickIntent.action
|
||||
@@ -44,7 +48,6 @@ fun BroadcastAction.sendBroadcast() {
|
||||
val intent = Intent(broadcastAction).apply {
|
||||
component = Components.serviceBroadcastReceiver
|
||||
}
|
||||
GlobalState.log("Send broadcast: $broadcastAction")
|
||||
GlobalState.application.sendBroadcast(
|
||||
intent,
|
||||
GlobalState.receiveBroadcastPermission,
|
||||
@@ -92,13 +95,19 @@ fun Service.startForeground(notification: Notification) {
|
||||
fun Context.registerReceiverCompat(
|
||||
receiver: BroadcastReceiver,
|
||||
filter: IntentFilter,
|
||||
exported: Boolean = false,
|
||||
) = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
registerReceiver(receiver, filter, RECEIVER_NOT_EXPORTED)
|
||||
registerReceiver(
|
||||
receiver,
|
||||
filter,
|
||||
if (exported) RECEIVER_EXPORTED else RECEIVER_NOT_EXPORTED,
|
||||
)
|
||||
} else {
|
||||
registerReceiver(receiver, filter)
|
||||
}
|
||||
|
||||
fun Context.receiveBroadcastFlow(
|
||||
exported: Boolean = false,
|
||||
configure: IntentFilter.() -> Unit,
|
||||
): Flow<Intent> = callbackFlow {
|
||||
val filter = IntentFilter().apply(configure)
|
||||
@@ -108,6 +117,6 @@ fun Context.receiveBroadcastFlow(
|
||||
trySend(intent)
|
||||
}
|
||||
}
|
||||
registerReceiverCompat(receiver, filter)
|
||||
registerReceiverCompat(receiver, filter, exported)
|
||||
awaitClose { unregisterReceiver(receiver) }
|
||||
}
|
||||
|
||||
@@ -51,10 +51,25 @@ class FilesProvider : DocumentsProvider() {
|
||||
mode: String,
|
||||
signal: CancellationSignal?,
|
||||
): ParcelFileDescriptor {
|
||||
val accessMode = ParcelFileDescriptor.parseMode(mode)
|
||||
val accessMode = ParcelFileDescriptor.parseMode(canonicalMode(mode))
|
||||
return ParcelFileDescriptor.open(resolveFile(documentId), accessMode)
|
||||
}
|
||||
|
||||
// parseMode throws on a letter outside r, w, t and a, and before Android 10
|
||||
// on any spelling but these six; callers do not all stick to them.
|
||||
private fun canonicalMode(mode: String): String {
|
||||
val read = 'r' in mode
|
||||
val truncate = 't' in mode
|
||||
return when {
|
||||
'w' !in mode -> "r"
|
||||
read && truncate -> "rwt"
|
||||
read -> "rw"
|
||||
truncate -> "wt"
|
||||
'a' in mode -> "wa"
|
||||
else -> "w"
|
||||
}
|
||||
}
|
||||
|
||||
private fun includeFile(result: MatrixCursor, file: File) {
|
||||
result.newRow().apply {
|
||||
add(DocumentsContract.Document.COLUMN_DOCUMENT_ID, file.absolutePath)
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package com.follow.clash.service
|
||||
|
||||
import android.app.Service
|
||||
import android.content.ComponentCallbacks2
|
||||
import com.follow.clash.common.BroadcastAction
|
||||
import com.follow.clash.common.GlobalState
|
||||
import com.follow.clash.common.sendBroadcast
|
||||
import com.follow.clash.core.Core
|
||||
|
||||
interface ManagedService {
|
||||
fun start()
|
||||
@@ -20,3 +22,14 @@ internal fun Service.notifyVpnRevoked() {
|
||||
GlobalState.log("VPN permission revoked")
|
||||
BroadcastAction.VPN_REVOKED.sendBroadcast()
|
||||
}
|
||||
|
||||
// UI_HIDDEN reaches the services too, since they share the activity's process,
|
||||
// and Android 14 stopped delivering the running levels this answers to.
|
||||
@Suppress("DEPRECATION")
|
||||
internal fun trimCoreMemory(level: Int) {
|
||||
if (level == ComponentCallbacks2.TRIM_MEMORY_RUNNING_LOW ||
|
||||
level == ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL
|
||||
) {
|
||||
Core.forceGC()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,11 @@ class ProxyService : Service(), ManagedService {
|
||||
super.onLowMemory()
|
||||
}
|
||||
|
||||
override fun onTrimMemory(level: Int) {
|
||||
trimCoreMemory(level)
|
||||
super.onTrimMemory(level)
|
||||
}
|
||||
|
||||
inner class LocalBinder : Binder() {
|
||||
val service: ProxyService
|
||||
get() = this@ProxyService
|
||||
|
||||
@@ -7,7 +7,6 @@ import android.net.ProxyInfo
|
||||
import android.os.Binder
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import android.util.Log
|
||||
import androidx.core.content.getSystemService
|
||||
import com.follow.clash.common.AccessControlMode
|
||||
import com.follow.clash.common.GlobalState
|
||||
@@ -18,6 +17,8 @@ import com.follow.clash.service.models.VpnOptions
|
||||
import com.follow.clash.service.models.getIpv4RouteAddress
|
||||
import com.follow.clash.service.models.getIpv6RouteAddress
|
||||
import com.follow.clash.service.models.toCIDR
|
||||
import com.follow.clash.service.models.tunMtu
|
||||
import com.follow.clash.service.models.tunOptions
|
||||
import com.follow.clash.service.modules.ServiceModules
|
||||
import java.net.InetSocketAddress
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
@@ -92,6 +93,11 @@ class VpnService : SystemVpnService(), ManagedService {
|
||||
super.onLowMemory()
|
||||
}
|
||||
|
||||
override fun onTrimMemory(level: Int) {
|
||||
trimCoreMemory(level)
|
||||
super.onTrimMemory(level)
|
||||
}
|
||||
|
||||
inner class LocalBinder : Binder() {
|
||||
val service: VpnService
|
||||
get() = this@VpnService
|
||||
@@ -120,7 +126,7 @@ class VpnService : SystemVpnService(), ManagedService {
|
||||
val fd = with(Builder()) {
|
||||
addAddressAndRoutes(options)
|
||||
addDnsServers(options)
|
||||
setMtu(MTU)
|
||||
setMtu(options.tunMtu)
|
||||
configureAccessControl(options)
|
||||
setSession(getString(CommonR.string.app_name))
|
||||
setBlocking(false)
|
||||
@@ -160,6 +166,7 @@ class VpnService : SystemVpnService(), ManagedService {
|
||||
stack = options.stack,
|
||||
address = options.tunAddress,
|
||||
dns = options.tunDns,
|
||||
options = options.tunOptions,
|
||||
),
|
||||
) { "Core rejected the tun file descriptor" }
|
||||
} catch (error: Exception) {
|
||||
@@ -175,7 +182,7 @@ class VpnService : SystemVpnService(), ManagedService {
|
||||
addRoutes(
|
||||
routes = options::getIpv4RouteAddress,
|
||||
fallbackAddress = NET_ANY,
|
||||
logTag = "addRoute4",
|
||||
routeAll = options.routeAddress.isEmpty(),
|
||||
)
|
||||
|
||||
if (options.ipv6) {
|
||||
@@ -188,24 +195,25 @@ class VpnService : SystemVpnService(), ManagedService {
|
||||
addRoutes(
|
||||
routes = options::getIpv6RouteAddress,
|
||||
fallbackAddress = NET_ANY6,
|
||||
logTag = "addRoute6",
|
||||
routeAll = options.routeAddress.isEmpty(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Flutter lists every family it routes, so a family missing from a
|
||||
// non-empty list is one whose routes were all excluded.
|
||||
private fun Builder.addRoutes(
|
||||
routes: () -> List<CIDR>,
|
||||
fallbackAddress: String,
|
||||
logTag: String,
|
||||
routeAll: Boolean,
|
||||
) {
|
||||
val routeList = runCatching(routes).getOrDefault(emptyList())
|
||||
if (routeList.isEmpty()) {
|
||||
val routeList = runCatching(routes).getOrNull()
|
||||
if (routeAll || routeList == null) {
|
||||
addRoute(fallbackAddress, 0)
|
||||
return
|
||||
}
|
||||
try {
|
||||
routeList.forEach { route ->
|
||||
Log.d(logTag, "address: ${route.address} prefixLength: ${route.prefixLength}")
|
||||
addRoute(route.address, route.prefixLength)
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
@@ -285,13 +293,14 @@ class VpnService : SystemVpnService(), ManagedService {
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val IPV4_ADDRESS = "172.19.0.1/30"
|
||||
// mihomo's own TUN default: 198.18.0.0/15 is benchmarking space no LAN
|
||||
// uses, and its fake-ip pool starts at .4, past this /30.
|
||||
private const val IPV4_ADDRESS = "198.18.0.1/30"
|
||||
private const val IPV6_ADDRESS = "fdfe:dcba:9876::1/126"
|
||||
private const val DNS = "172.19.0.2"
|
||||
private const val DNS = "198.18.0.2"
|
||||
private const val DNS6 = "fdfe:dcba:9876::2"
|
||||
private const val NET_ANY = "0.0.0.0"
|
||||
private const val NET_ANY6 = "::"
|
||||
private const val LOCAL_HOST = "127.0.0.1"
|
||||
private const val MTU = 9000
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.follow.clash.service.models
|
||||
|
||||
import com.follow.clash.common.AccessControlMode
|
||||
import com.google.gson.Gson
|
||||
import java.net.Inet4Address
|
||||
import java.net.Inet6Address
|
||||
import java.net.InetAddress
|
||||
@@ -23,8 +24,29 @@ data class VpnOptions(
|
||||
val bypassDomain: List<String>,
|
||||
val stack: String,
|
||||
val routeAddress: List<String>,
|
||||
val mtu: Int = 0,
|
||||
val congestionController: String? = null,
|
||||
)
|
||||
|
||||
private const val DEFAULT_TUN_MTU = 9000
|
||||
|
||||
// establish() rejects an MTU above 65535, and below 1280 the kernel drops the
|
||||
// IPv6 address.
|
||||
private val TUN_MTU_RANGE = 1280..65535
|
||||
|
||||
private val gson = Gson()
|
||||
|
||||
val VpnOptions.tunMtu: Int
|
||||
get() = if (mtu > 0) mtu.coerceIn(TUN_MTU_RANGE) else DEFAULT_TUN_MTU
|
||||
|
||||
val VpnOptions.tunOptions: String
|
||||
get() = gson.toJson(
|
||||
mapOf(
|
||||
"mtu" to tunMtu,
|
||||
"congestion-controller" to congestionController.orEmpty(),
|
||||
),
|
||||
)
|
||||
|
||||
data class CIDR(
|
||||
val address: InetAddress,
|
||||
val prefixLength: Int,
|
||||
|
||||
+14
-6
@@ -34,6 +34,7 @@ internal class NetworkObserveModule(private val service: Service) : ServiceModul
|
||||
}
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
private var currentDnsList = listOf<String>()
|
||||
private var currentNetwork: Network? = null
|
||||
|
||||
private val request = NetworkRequest.Builder().apply {
|
||||
addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)
|
||||
@@ -109,18 +110,24 @@ internal class NetworkObserveModule(private val service: Service) : ServiceModul
|
||||
|
||||
@Synchronized
|
||||
private fun updateDns() {
|
||||
val dnsList = networkInfos.asSequence()
|
||||
.minByOrNull(::networkPriority)
|
||||
val preferred = networkInfos.entries.minByOrNull(::networkPriority)
|
||||
val dnsList = preferred
|
||||
?.value
|
||||
?.dnsList
|
||||
.orEmpty()
|
||||
.map { address -> address.asSocketAddressText(DNS_PORT) }
|
||||
.distinct()
|
||||
if (dnsList == currentDnsList) {
|
||||
return
|
||||
if (dnsList != currentDnsList) {
|
||||
currentDnsList = dnsList
|
||||
Core.updateDNS(dnsList.joinToString(","))
|
||||
}
|
||||
currentDnsList = dnsList
|
||||
Core.updateDNS(dnsList.joinToString(","))
|
||||
val network = preferred?.key ?: return
|
||||
// Sockets protected onto the network we left stay bound to it; closing
|
||||
// them makes apps reconnect now instead of after a TCP timeout.
|
||||
if (currentNetwork != null && currentNetwork != network) {
|
||||
Core.resetNetwork()
|
||||
}
|
||||
currentNetwork = network
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
@@ -130,6 +137,7 @@ internal class NetworkObserveModule(private val service: Service) : ServiceModul
|
||||
} finally {
|
||||
networkInfos.clear()
|
||||
updateDns()
|
||||
currentNetwork = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+8
-3
@@ -54,14 +54,19 @@ internal class NotificationModule(
|
||||
service.ensureNotificationChannel()
|
||||
update(ServiceConfig.notificationParams.value.extended)
|
||||
scope.launch {
|
||||
service.receiveBroadcastFlow {
|
||||
// Some ROMs, vivo's among them, drop updates posted behind the
|
||||
// keyguard, so unlocking restarts the loop to post once more.
|
||||
// SystemUI, an app, sends USER_PRESENT, which a receiver that is
|
||||
// not exported never gets; all three actions are protected.
|
||||
service.receiveBroadcastFlow(exported = true) {
|
||||
addAction(Intent.ACTION_SCREEN_ON)
|
||||
addAction(Intent.ACTION_SCREEN_OFF)
|
||||
addAction(Intent.ACTION_USER_PRESENT)
|
||||
}.map { intent ->
|
||||
intent.action == Intent.ACTION_SCREEN_ON
|
||||
intent.action != Intent.ACTION_SCREEN_OFF
|
||||
}.onStart {
|
||||
emit(isScreenOn())
|
||||
}.distinctUntilChanged().collectLatest { screenOn ->
|
||||
}.collectLatest { screenOn ->
|
||||
if (!screenOn) return@collectLatest
|
||||
combine(
|
||||
flow {
|
||||
|
||||
@@ -41,17 +41,14 @@ private fun configuredState(enable: Boolean = true, stack: String = "gvisor") =
|
||||
setupParams = SetupParams(testUrl = "https://example.com", selectedMap = emptyMap()),
|
||||
)
|
||||
|
||||
private const val STARTED_AT = 1_700_000_000_000L
|
||||
|
||||
private class FakeTile : TileGateway {
|
||||
var startCount = 0
|
||||
var stopCount = 0
|
||||
|
||||
override fun handleStart() {
|
||||
startCount++
|
||||
}
|
||||
|
||||
override fun handleStop() {
|
||||
stopCount++
|
||||
}
|
||||
}
|
||||
|
||||
private class FakeApp(
|
||||
@@ -94,16 +91,17 @@ private class FakeApp(
|
||||
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 startSucceeds = true
|
||||
var vpnPermissionGranted = true
|
||||
var localNetworkPermissionGranted = true
|
||||
var vpnServiceActive = true
|
||||
var runsAsVpn: Boolean? = null
|
||||
var tile: TileGateway? = null
|
||||
var app: AppGateway? = null
|
||||
var duringSetup: (suspend () -> Unit)? = null
|
||||
var beforeStartService: (() -> Unit)? = null
|
||||
var lastStartOptions: VpnOptions? = null
|
||||
|
||||
override var runTimeMillis = 0L
|
||||
override var runtime: ServiceRuntime? = null
|
||||
override val homeDirPath = "/data/user/0/com.follow.clash/files"
|
||||
override val sdkInt = 34
|
||||
|
||||
@@ -114,6 +112,7 @@ private class FakeHost(override val scope: CoroutineScope) : ServiceStateHost {
|
||||
var setupCalls = 0
|
||||
var startCalls = 0
|
||||
var stopCalls = 0
|
||||
var stoppedNotifications = 0
|
||||
var lastInitParams: String? = null
|
||||
var lastSetupParams: String? = null
|
||||
|
||||
@@ -143,27 +142,33 @@ private class FakeHost(override val scope: CoroutineScope) : ServiceStateHost {
|
||||
|
||||
override fun app(): AppGateway? = app
|
||||
|
||||
override fun notifyStopped() {
|
||||
stoppedNotifications++
|
||||
}
|
||||
|
||||
override suspend fun quickSetup(initParams: String, setupParams: String): Result<String> {
|
||||
setupCalls++
|
||||
lastInitParams = initParams
|
||||
lastSetupParams = setupParams
|
||||
duringSetup?.invoke()
|
||||
return setupResult
|
||||
}
|
||||
|
||||
override suspend fun startService(options: VpnOptions): Long {
|
||||
override suspend fun startService(options: VpnOptions): Boolean {
|
||||
startCalls++
|
||||
lastStartOptions = options
|
||||
beforeStartService?.invoke()
|
||||
runTimeMillis = startResult
|
||||
return startResult
|
||||
if (!startSucceeds) {
|
||||
return false
|
||||
}
|
||||
runtime = ServiceRuntime(vpn = runsAsVpn ?: options.enable, startedAtMillis = STARTED_AT)
|
||||
return true
|
||||
}
|
||||
|
||||
override suspend fun stopService() {
|
||||
stopCalls++
|
||||
runTimeMillis = 0L
|
||||
runtime = null
|
||||
}
|
||||
|
||||
override suspend fun isVpnServiceActive(): Boolean = vpnServiceActive
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@@ -199,16 +204,33 @@ class ServiceStateMachineTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `refresh reports STARTED only while the service has a run time`() = runTest {
|
||||
fun `the run time is reported only while a run is both requested and up`() = runTest {
|
||||
val host = FakeHost(backgroundScope)
|
||||
val machine = ServiceStateMachine(host)
|
||||
machine.syncSharedState(configuredState())
|
||||
|
||||
assertEquals(0L, machine.refresh())
|
||||
assertEquals(0L, machine.awaitRunTime())
|
||||
|
||||
machine.requestStart().await()
|
||||
assertEquals(STARTED_AT, machine.awaitRunTime())
|
||||
|
||||
machine.requestStop().await()
|
||||
assertEquals(0L, machine.awaitRunTime())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a stop request reads as STOPPING until the service is down`() = runTest {
|
||||
val host = FakeHost(backgroundScope)
|
||||
val machine = ServiceStateMachine(host)
|
||||
machine.syncSharedState(configuredState())
|
||||
machine.requestStart().await()
|
||||
|
||||
val stop = machine.requestStop()
|
||||
|
||||
assertEquals(RunState.STOPPING, machine.runState.value)
|
||||
assertEquals(0L, machine.awaitRunTime())
|
||||
assertTrue(stop.await())
|
||||
assertEquals(RunState.STOPPED, machine.runState.value)
|
||||
|
||||
host.runTimeMillis = 42L
|
||||
assertEquals(42L, machine.refresh())
|
||||
assertEquals(RunState.STARTED, machine.runState.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -225,12 +247,14 @@ class ServiceStateMachineTest {
|
||||
@Test
|
||||
fun `a start that the service refuses settles back to STOPPED`() = runTest {
|
||||
val host = FakeHost(backgroundScope)
|
||||
host.startResult = 0L
|
||||
host.startSucceeds = false
|
||||
val machine = ServiceStateMachine(host)
|
||||
machine.syncSharedState(configuredState())
|
||||
|
||||
assertFalse(machine.requestStart().await())
|
||||
assertEquals(RunState.STOPPED, machine.runState.value)
|
||||
assertFalse(machine.isRunRequested)
|
||||
assertEquals(1, host.stoppedNotifications)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -252,6 +276,42 @@ class ServiceStateMachineTest {
|
||||
assertFalse(machine.requestStart().await())
|
||||
assertEquals(0, host.startCalls)
|
||||
assertEquals(RunState.STOPPED, machine.runState.value)
|
||||
assertEquals(1, host.stoppedNotifications)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a start repeated on a settled run asks for nothing and keeps the service`() = runTest {
|
||||
val host = FakeHost(backgroundScope)
|
||||
val machine = ServiceStateMachine(host)
|
||||
machine.syncSharedState(configuredState())
|
||||
machine.requestStart().await()
|
||||
val token = machine.captureRequestToken()
|
||||
val app = FakeApp(notificationGranted = false)
|
||||
host.app = app
|
||||
|
||||
assertTrue(machine.requestStart().await())
|
||||
|
||||
assertTrue(machine.captureRequestToken() === token)
|
||||
assertEquals(0, app.localNetworkRequests)
|
||||
assertEquals(1, host.startCalls)
|
||||
assertEquals(0, host.stopCalls)
|
||||
assertEquals(RunState.STARTED, machine.runState.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a start that changes the service kind is refused down to STOPPED`() = runTest {
|
||||
val host = FakeHost(backgroundScope)
|
||||
val machine = ServiceStateMachine(host)
|
||||
machine.syncSharedState(configuredState(enable = false))
|
||||
machine.requestStart().await()
|
||||
machine.syncSharedState(configuredState(enable = true))
|
||||
host.app = FakeApp(vpnGranted = false)
|
||||
|
||||
assertFalse(machine.requestStart().await())
|
||||
|
||||
assertEquals(1, host.stopCalls)
|
||||
assertEquals(RunState.STOPPED, machine.runState.value)
|
||||
assertFalse(machine.isRunRequested)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -263,8 +323,30 @@ class ServiceStateMachineTest {
|
||||
|
||||
assertFalse(machine.requestStart().await())
|
||||
assertEquals(0, host.startCalls)
|
||||
assertEquals(1, host.stoppedNotifications)
|
||||
}
|
||||
|
||||
/** Flutter opened the Core's listeners before asking, and may be gone before it hears back. */
|
||||
@Test
|
||||
fun `a start refused before any service came up closes the core without passing STOPPING`() =
|
||||
runTest {
|
||||
val host = FakeHost(backgroundScope)
|
||||
host.app = FakeApp(vpnGranted = false)
|
||||
val machine = ServiceStateMachine(host)
|
||||
machine.syncSharedState(configuredState())
|
||||
val states = mutableListOf<RunState>()
|
||||
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
|
||||
machine.runState.toList(states)
|
||||
}
|
||||
|
||||
assertFalse(machine.requestStart().await())
|
||||
testScheduler.runCurrent()
|
||||
|
||||
assertEquals(1, host.stopCalls)
|
||||
assertFalse(states.contains(RunState.STOPPING))
|
||||
assertEquals(RunState.STOPPED, machine.runState.value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Without a foreground app there is nobody to show the system consent dialog, so the machine
|
||||
* has to explain the refusal itself.
|
||||
@@ -352,6 +434,7 @@ class ServiceStateMachineTest {
|
||||
assertTrue(machine.requestStop().await())
|
||||
assertEquals(RunState.STOPPED, machine.runState.value)
|
||||
assertEquals(1, host.stopCalls)
|
||||
assertEquals(0, host.stoppedNotifications)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -385,6 +468,7 @@ class ServiceStateMachineTest {
|
||||
host.beforeStartService = { machine.requestStop() }
|
||||
|
||||
assertFalse(machine.requestStart().await())
|
||||
assertEquals(0, host.stoppedNotifications)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -395,12 +479,15 @@ class ServiceStateMachineTest {
|
||||
machine.requestStart().await()
|
||||
|
||||
val token = machine.captureRequestToken()
|
||||
host.runTimeMillis = 0L
|
||||
host.runtime = null
|
||||
machine.handleServiceLost(token)
|
||||
|
||||
assertEquals(RunState.STOPPED, machine.runState.value)
|
||||
assertFalse(machine.isRunRequested)
|
||||
assertEquals(1, host.stoppedNotifications)
|
||||
}
|
||||
|
||||
/** The newer start still owns the intent, but nothing is running until it brings it up. */
|
||||
@Test
|
||||
fun `handleServiceLost keeps the intent of a start that raced ahead of it`() = runTest {
|
||||
val host = FakeHost(backgroundScope)
|
||||
@@ -409,10 +496,12 @@ class ServiceStateMachineTest {
|
||||
val staleToken = machine.captureRequestToken()
|
||||
|
||||
machine.requestStart().await()
|
||||
host.runTimeMillis = 0L
|
||||
host.runtime = null
|
||||
machine.handleServiceLost(staleToken)
|
||||
|
||||
assertEquals(RunState.STARTED, machine.runState.value)
|
||||
assertTrue(machine.isRunRequested)
|
||||
assertEquals(RunState.STOPPED, machine.runState.value)
|
||||
assertEquals(0, host.stoppedNotifications)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -464,6 +553,7 @@ class ServiceStateMachineTest {
|
||||
|
||||
assertEquals(listOf(MISSING_CONFIG_MESSAGE), host.toasts)
|
||||
assertEquals(0, host.setupCalls)
|
||||
assertFalse(machine.isRunRequested)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -510,16 +600,17 @@ class ServiceStateMachineTest {
|
||||
@Test
|
||||
fun `a service that refuses the start after a good setup is reported`() = runTest {
|
||||
val host = FakeHost(backgroundScope)
|
||||
host.startResult = 0L
|
||||
host.startSucceeds = false
|
||||
val machine = ServiceStateMachine(host)
|
||||
|
||||
machine.handleStartAction()
|
||||
|
||||
assertTrue(host.toasts.contains(START_FAILED_MESSAGE))
|
||||
assertFalse(machine.isRunRequested)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handleStopAction hands the stop to the tile when one is attached`() = runTest {
|
||||
fun `handleStopAction stops the service itself and reports it to Flutter`() = runTest {
|
||||
val host = FakeHost(backgroundScope)
|
||||
val machine = ServiceStateMachine(host)
|
||||
machine.syncSharedState(configuredState())
|
||||
@@ -529,8 +620,69 @@ class ServiceStateMachineTest {
|
||||
|
||||
machine.handleStopAction()
|
||||
|
||||
assertEquals(1, tile.stopCount)
|
||||
assertEquals(0, tile.startCount)
|
||||
assertEquals(1, host.stopCalls)
|
||||
assertEquals(RunState.STOPPED, machine.runState.value)
|
||||
assertEquals(1, host.stoppedNotifications)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a toggle during the native setup cancels the start without reporting a failure`() =
|
||||
runTest {
|
||||
val host = FakeHost(backgroundScope)
|
||||
val machine = ServiceStateMachine(host)
|
||||
host.duringSetup = { machine.handleToggleAction() }
|
||||
|
||||
machine.handleStartAction()
|
||||
|
||||
assertEquals(1, host.setupCalls)
|
||||
assertEquals(0, host.startCalls)
|
||||
assertFalse(host.toasts.contains(START_FAILED_MESSAGE))
|
||||
assertFalse(machine.isRunRequested)
|
||||
assertEquals(RunState.STOPPED, machine.runState.value)
|
||||
}
|
||||
|
||||
/** The setup already opened the Core's ports, so a start that ends there has to close them. */
|
||||
@Test
|
||||
fun `a native start that does not end up running tears the core setup down`() = runTest {
|
||||
val cancelled = FakeHost(backgroundScope)
|
||||
val cancelledMachine = ServiceStateMachine(cancelled)
|
||||
cancelled.duringSetup = { cancelledMachine.handleToggleAction() }
|
||||
cancelledMachine.handleStartAction()
|
||||
|
||||
val refused = FakeHost(backgroundScope)
|
||||
refused.vpnPermissionGranted = false
|
||||
ServiceStateMachine(refused).handleStartAction()
|
||||
|
||||
val rejected = FakeHost(backgroundScope)
|
||||
rejected.setupResult = Result.success("proxy group not found")
|
||||
ServiceStateMachine(rejected).handleStartAction()
|
||||
|
||||
assertEquals(listOf(1, 1, 1), listOf(cancelled, refused, rejected).map { it.stopCalls })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a native start that runs leaves the core setup alone`() = runTest {
|
||||
val host = FakeHost(backgroundScope)
|
||||
val machine = ServiceStateMachine(host)
|
||||
|
||||
machine.handleStartAction()
|
||||
|
||||
assertEquals(0, host.stopCalls)
|
||||
assertEquals(RunState.STARTED, machine.runState.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a second start action during the native setup is ignored`() = runTest {
|
||||
val host = FakeHost(backgroundScope)
|
||||
val machine = ServiceStateMachine(host)
|
||||
host.duringSetup = { machine.handleStartAction() }
|
||||
|
||||
machine.handleStartAction()
|
||||
|
||||
assertEquals(1, host.setupCalls)
|
||||
assertEquals(1, host.startCalls)
|
||||
assertEquals(RunState.STARTED, machine.runState.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -572,9 +724,8 @@ class ServiceStateMachineTest {
|
||||
@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.syncSharedState(configuredState(enable = false))
|
||||
machine.requestStart().await()
|
||||
|
||||
machine.handleVpnRevokeAction()
|
||||
@@ -582,6 +733,21 @@ class ServiceStateMachineTest {
|
||||
assertEquals(0, host.stopCalls)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a revoke stops the vpn service even with Flutter attached`() = runTest {
|
||||
val host = FakeHost(backgroundScope)
|
||||
val machine = ServiceStateMachine(host)
|
||||
machine.syncSharedState(configuredState())
|
||||
machine.requestStart().await()
|
||||
host.tile = FakeTile()
|
||||
|
||||
machine.handleVpnRevokeAction()
|
||||
|
||||
assertEquals(1, host.stopCalls)
|
||||
assertEquals(RunState.STOPPED, machine.runState.value)
|
||||
assertEquals(1, host.stoppedNotifications)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a revoke stops the active vpn service`() = runTest {
|
||||
val host = FakeHost(backgroundScope)
|
||||
@@ -628,7 +794,7 @@ class ServiceStateMachineTest {
|
||||
|
||||
assertFalse(states.contains(RunState.STARTED))
|
||||
assertEquals(RunState.STOPPED, machine.runState.value)
|
||||
assertEquals(0L, host.runTimeMillis)
|
||||
assertEquals(null, host.runtime)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -645,7 +811,7 @@ class ServiceStateMachineTest {
|
||||
testScheduler.runCurrent()
|
||||
|
||||
assertEquals(1, host.stopCalls)
|
||||
assertEquals(0L, host.runTimeMillis)
|
||||
assertEquals(null, host.runtime)
|
||||
assertEquals(RunState.STOPPED, machine.runState.value)
|
||||
assertFalse(machine.captureRequestToken().running)
|
||||
}
|
||||
@@ -673,7 +839,7 @@ class ServiceStateMachineTest {
|
||||
@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
|
||||
host.runsAsVpn = false
|
||||
val machine = ServiceStateMachine(host)
|
||||
machine.syncSharedState(configuredState())
|
||||
host.beforeStartService = {
|
||||
@@ -717,5 +883,6 @@ class ServiceStateMachineTest {
|
||||
assertFalse(machine.requestStart().await())
|
||||
assertTrue(host.logs.any { it.contains("binder died") })
|
||||
assertFalse(machine.captureRequestToken().running)
|
||||
assertEquals(RunState.STOPPED, machine.runState.value)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,12 +17,13 @@ class SharedStateTest {
|
||||
|
||||
@Test
|
||||
fun `setup params use the kebab-case spelling Flutter writes`() {
|
||||
val json = """{"test-url":"https://example.test/204","selected-map":{"GLOBAL":"auto"}}"""
|
||||
val json = """{"test-url":"https://example.test/204","selected-map":{"GLOBAL":"auto"},"skip-cert-verify":true}"""
|
||||
|
||||
val params = gson.fromJson(json, SetupParams::class.java)
|
||||
|
||||
assertEquals("https://example.test/204", params.testUrl)
|
||||
assertEquals(mapOf("GLOBAL" to "auto"), params.selectedMap)
|
||||
assertTrue(params.skipCertVerify)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -33,6 +34,7 @@ class SharedStateTest {
|
||||
|
||||
assertTrue(encoded.contains("\"test-url\""))
|
||||
assertTrue(encoded.contains("\"selected-map\""))
|
||||
assertTrue(encoded.contains("\"skip-cert-verify\""))
|
||||
assertTrue(!encoded.contains("testUrl"))
|
||||
assertTrue(!encoded.contains("selectedMap"))
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.follow.clash.service.models
|
||||
|
||||
import com.follow.clash.common.AccessControlMode
|
||||
import com.google.gson.JsonParser
|
||||
import java.net.Inet4Address
|
||||
import java.net.Inet6Address
|
||||
import org.junit.Assert.assertEquals
|
||||
@@ -142,3 +143,30 @@ class RouteAddressTest {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class TunOptionsTest {
|
||||
@Test
|
||||
fun `an unset mtu falls back to the core default`() {
|
||||
assertEquals(9000, optionsWithRoutes(emptyList()).tunMtu)
|
||||
assertEquals(1400, optionsWithRoutes(emptyList()).copy(mtu = 1400).tunMtu)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `hands the core the mtu the builder was given`() {
|
||||
val options = optionsWithRoutes(emptyList()).copy(congestionController = "bbr")
|
||||
|
||||
val json = JsonParser.parseString(options.tunOptions).asJsonObject
|
||||
|
||||
assertEquals(9000, json["mtu"].asInt)
|
||||
assertEquals("bbr", json["congestion-controller"].asString)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a state saved before the options existed still encodes`() {
|
||||
val options = optionsWithRoutes(emptyList()).copy(congestionController = null)
|
||||
|
||||
val json = JsonParser.parseString(options.tunOptions).asJsonObject
|
||||
|
||||
assertEquals("", json["congestion-controller"].asString)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user