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:
chen08209
2026-10-08 02:49:08 +08:00
parent 94a25f4827
commit d3fa2252b0
53 changed files with 2089 additions and 913 deletions
+4
View File
@@ -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,
@@ -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
}
}
}
@@ -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 {
+199 -32
View File
@@ -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)
}
}
+3 -1
View File
@@ -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"))
}
+28
View File
@@ -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)
}
}