feat(android): add launcher shortcuts and one-step on-demand access, and follow the real service

The VPN moves onto 198.18.0.0/15.

Changelog: Launcher shortcuts, one-step on-demand access and a tile that agrees with the VPN
This commit is contained in:
chen08209
2026-10-08 02:49:08 +08:00
parent 7bd9b18a5d
commit 11f2dd8ba3
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)
}
}
-125
View File
@@ -1,125 +0,0 @@
import 'dart:async';
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/plugins/app.dart';
import 'package:fl_clash/providers/providers.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:wifi_ssid/wifi_ssid_manager.dart';
enum LocationPermissionFollowUp { none, showDeniedMessage, openSettings }
LocationPermissionFollowUp getLocationPermissionFollowUp(
WifiSsidPermission permission,
) {
return switch (permission) {
WifiSsidPermission.granted => LocationPermissionFollowUp.none,
WifiSsidPermission.denied => LocationPermissionFollowUp.showDeniedMessage,
WifiSsidPermission.permanentlyDenied =>
LocationPermissionFollowUp.openSettings,
};
}
class Permissions {
static Permissions? _instance;
Permissions._internal({bool Function()? supportsLocationPermissions})
: _supportsLocationPermissions =
supportsLocationPermissions ??
(() => system.isAndroid || system.isMacOS);
factory Permissions() {
_instance ??= Permissions._internal();
return _instance!;
}
@visibleForTesting
factory Permissions.test({required bool supportsLocationPermissions}) {
return Permissions._internal(
supportsLocationPermissions: () => supportsLocationPermissions,
);
}
final bool Function() _supportsLocationPermissions;
bool _isRequestingLocation = false;
bool _autoRequestedLocation = false;
bool _hadExcludeSSIDs = false;
bool needWaitingBatteryOptimizationSettings = false;
void check(ProviderReader read) {
checkLocationPermissions(read);
checkBatteryOptimizationDisable(read);
}
Future<void> checkBatteryOptimizationDisable(ProviderReader read) async {
await _checkBatteryOptimizationDisable(read);
}
Future<void> _checkBatteryOptimizationDisable(ProviderReader read) async {
const tag = LoadingTag.batteryOptimization;
try {
if (needWaitingBatteryOptimizationSettings) {
read(loadingProvider(tag).notifier).value = true;
}
read(
batteryOptimizationDisableProvider.notifier,
).value = await retry<bool>(
task: () async {
return await app?.isBatteryOptimizationDisabled() ?? false;
},
retryIf: (res) => res == false,
delay: const Duration(milliseconds: 500),
maxAttempts: needWaitingBatteryOptimizationSettings ? 5 : 1,
);
} finally {
read(loadingProvider(tag).notifier).value = false;
needWaitingBatteryOptimizationSettings = false;
}
}
Future<void> checkLocationPermissions(ProviderReader read) async {
if (!_supportsLocationPermissions() || read(safeModeProvider)) {
return;
}
final res = await WifiSsidManager.instance.checkPermission();
final current = read(locationPermissionsProvider);
if (res == WifiSsidPermission.granted ||
current != WifiSsidPermission.permanentlyDenied) {
read(locationPermissionsProvider.notifier).value = res;
}
final needRequestPermission = read(
excludeSSIDsProvider.select((state) => state.isNotEmpty),
);
if (needRequestPermission && !_hadExcludeSSIDs) {
_autoRequestedLocation = false;
}
_hadExcludeSSIDs = needRequestPermission;
if (res == WifiSsidPermission.denied &&
needRequestPermission &&
!_autoRequestedLocation &&
!_isRequestingLocation) {
_isRequestingLocation = true;
try {
final res = await WifiSsidManager.instance.requestPermission();
_autoRequestedLocation = true;
read(locationPermissionsProvider.notifier).value = res;
if (res == WifiSsidPermission.granted) {
final ssid = await WifiSsidManager.instance.getSsid();
read(currentSSIDProvider.notifier).value = ssid;
}
} on PlatformException catch (e) {
commonPrint.log(
'requestPermission error ${e.toString()}',
logLevel: LogLevel.warning,
);
} finally {
_isRequestingLocation = false;
}
}
}
}
final permissions = Permissions();
+8
View File
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/core/core.dart';
import 'package:fl_clash/enum/enum.dart';
@@ -76,6 +78,12 @@ class _AndroidContainerState extends ConsumerState<AndroidManager>
super.onServiceEvent(event);
}
@override
void onServiceStopped() {
unawaited(ref.read(setupActionProvider.notifier).setRunning(false));
super.onServiceStopped();
}
@override
Widget build(BuildContext context) {
return widget.child;
+11
View File
@@ -5,6 +5,7 @@ import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/providers/app.dart';
import 'package:fl_clash/providers/config.dart';
import 'package:fl_clash/providers/on_demand.dart';
import 'package:material_ui/material_ui.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:wifi_ssid/wifi_ssid.dart';
@@ -52,6 +53,16 @@ class _ConnectivityManagerState extends ConsumerState<ConnectivityManager> {
unawaited(_updateSsid());
}
});
ref.listenManual(
locationPermissionsProvider.select(
(permission) => permission == WifiSsidPermission.granted,
),
(previous, next) {
if (previous != next) {
unawaited(_updateSsid());
}
},
);
}
void _handleResults(List<ConnectivityResult> results) {
-10
View File
@@ -35,16 +35,6 @@ class _TileContainerState extends ConsumerState<TileManager> with TileListener {
super.onStart();
}
@override
Future<void> onStop() async {
if (!isStart) {
return;
}
unawaited(ref.read(setupActionProvider.notifier).setRunning(false));
unawaited(app?.tip(currentAppLocalizations.stopVpn));
super.onStop();
}
@override
void initState() {
super.initState();
+18 -26
View File
@@ -2,8 +2,8 @@ import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/models/models.dart';
import 'package:fl_clash/providers/action.dart';
import 'package:fl_clash/providers/app.dart';
import 'package:fl_clash/providers/state.dart';
import 'package:fl_clash/state.dart';
import 'package:material_ui/material_ui.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -20,35 +20,27 @@ class _VpnContainerState extends ConsumerState<VpnManager> {
@override
void initState() {
super.initState();
ref.listenManual(vpnStateProvider, (prev, next) {
if (prev != next) {
showTip(next);
ref.listenManual(vpnOptionsProvider.select((state) => state.effective), (
prev,
next,
) {
final running = ref.read(runningVpnOptionsProvider)?.effective;
if (prev != next && running != null && running != next) {
_showRestartTip();
}
});
}
void showTip(VpnState state) {
throttler.call(
FunctionTag.vpnTip,
() {
if (!ref.read(isStartProvider) || state == globalState.lastVpnState) {
return;
}
dialogs.showNotifier(
currentAppLocalizations.vpnConfigChangeDetected,
level: MessageLevel.warning,
actionState: MessageActionState(
actionText: currentAppLocalizations.restart,
action: () async {
final setupAction = ref.read(setupActionProvider.notifier);
await setupAction.setRunning(false);
await setupAction.setRunning(true);
},
),
);
},
duration: const Duration(seconds: 10),
fire: true,
void _showRestartTip() {
dialogs.showNotifier(
currentAppLocalizations.vpnConfigChangeDetected,
level: MessageLevel.warning,
actionState: MessageActionState(
actionText: currentAppLocalizations.restart,
action: () async {
await ref.read(setupActionProvider.notifier).restartVpn();
},
),
);
}
+11 -17
View File
@@ -84,6 +84,7 @@ class App {
final Map<String, ImageProvider?> _packageIcons = {};
final Map<String, Future<ImageProvider?>> _packageIconTasks = {};
final TaskPool _packageIconPool = TaskPool(maxConcurrentIconLoads);
bool hasPackageIcon(String packageName) {
return _packageIcons.containsKey(packageName);
@@ -112,9 +113,11 @@ class App {
Future<ImageProvider?> _requestPackageIcon(String packageName) async {
try {
final path = await methodChannel.invokeMethod<String>('getPackageIcon', {
'packageName': packageName,
});
final path = await _packageIconPool.run(
() => methodChannel.invokeMethod<String>('getPackageIcon', {
'packageName': packageName,
}),
);
if (path == null || path.isEmpty) {
return null;
}
@@ -136,10 +139,11 @@ class App {
}
Future<bool?> initShortcuts() async {
return methodChannel.invokeMethod<bool>(
'initShortcuts',
currentAppLocalizations.toggle,
);
return methodChannel.invokeMethod<bool>('initShortcuts', {
'start': currentAppLocalizations.start,
'stop': currentAppLocalizations.stop,
'toggle': currentAppLocalizations.toggle,
});
}
Future<bool?> updateExcludeFromRecents(bool value) async {
@@ -148,16 +152,6 @@ class App {
});
}
Future<bool?> isBatteryOptimizationDisabled() async {
if (!Platform.isAndroid) return true;
return methodChannel.invokeMethod<bool>('isBatteryOptimizationDisabled');
}
Future<bool?> openBatteryOptimizationSettings() async {
if (!Platform.isAndroid) return false;
return methodChannel.invokeMethod<bool>('openBatteryOptimizationSettings');
}
Future<bool?> openAppSettings() async {
if (!Platform.isAndroid) return false;
return methodChannel.invokeMethod<bool>('openAppSettings');
+29
View File
@@ -0,0 +1,29 @@
import 'package:fl_clash/common/common.dart';
import 'package:flutter/services.dart';
class BatteryOptimization {
BatteryOptimization._();
static final instance = BatteryOptimization._();
static const _methodChannel = MethodChannel('$packageName/battery');
static const _eventChannel = EventChannel(
'$packageName/battery/optimization',
);
/// The current answer on listen, then one per change. On Xiaomi it follows
/// the battery saver's "No restrictions" instead of the allowlist.
late final Stream<bool> onChanged = _eventChannel
.receiveBroadcastStream()
.map((event) => event as bool);
/// Completes once the user leaves the settings page; the answer itself
/// arrives through [onChanged].
Future<void> requestIgnore() {
return _methodChannel.invokeMethod<void>('requestIgnoreOptimizations');
}
}
final batteryOptimization = system.isAndroid
? BatteryOptimization.instance
: null;
+39 -13
View File
@@ -22,6 +22,8 @@ Future<Object?> _decodeResponse(Uint8List data) async {
abstract mixin class ServiceListener {
void onServiceEvent(CoreEvent event) {}
void onServiceStopped() {}
}
class Service {
@@ -31,6 +33,9 @@ class Service {
final ObserverList<ServiceListener> _listeners =
ObserverList<ServiceListener>();
int _commandRevision = 0;
bool _startRequested = false;
factory Service() {
_instance ??= Service._internal();
return _instance!;
@@ -46,19 +51,20 @@ class Service {
Map<String, Object?>.from(json.decode(data) as Map),
);
for (final event in coreEventsFromData(methodCall.arguments)) {
for (final listener in List.of(_listeners)) {
try {
listener.onServiceEvent(event);
} catch (error) {
commonPrint.log(
'Unable to dispatch Android Core event '
'${event.type.name}: $error',
logLevel: LogLevel.error,
);
}
}
_dispatch(
'Core event ${event.type.name}',
(listener) => listener.onServiceEvent(event),
);
}
break;
case 'stopped':
// A report stamped before the newest command no longer describes it.
if (call.arguments != _commandRevision || !_startRequested) {
break;
}
_startRequested = false;
_dispatch('stop report', (listener) => listener.onServiceStopped());
break;
default:
throw MissingPluginException();
}
@@ -77,12 +83,32 @@ class Service {
return CoreMethodResponse.fromJson(dataJson as Map<String, dynamic>);
}
void _dispatch(String label, void Function(ServiceListener) deliver) {
for (final listener in List.of(_listeners)) {
try {
deliver(listener);
} catch (error) {
commonPrint.log(
'Unable to dispatch Android $label: $error',
logLevel: LogLevel.error,
);
}
}
}
Future<bool> start() async {
return await methodChannel.invokeMethod<bool>('start') ?? false;
_startRequested = true;
return await methodChannel.invokeMethod<bool>(
'start',
++_commandRevision,
) ??
false;
}
Future<bool> stop() async {
return await methodChannel.invokeMethod<bool>('stop') ?? false;
_startRequested = false;
return await methodChannel.invokeMethod<bool>('stop', ++_commandRevision) ??
false;
}
Future<String> init() async {
-5
View File
@@ -8,8 +8,6 @@ import 'package:flutter/services.dart';
abstract mixin class TileListener {
void onStart() {}
void onStop() {}
void onDetached() {}
}
@@ -31,9 +29,6 @@ class Tile {
case 'start':
listener.onStart();
break;
case 'stop':
listener.onStop();
break;
case 'detached':
listener.onDetached();
break;
+109
View File
@@ -0,0 +1,109 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of '../on_demand.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(LocationPermissions)
final locationPermissionsProvider = LocationPermissionsProvider._();
final class LocationPermissionsProvider
extends $NotifierProvider<LocationPermissions, WifiSsidPermission> {
LocationPermissionsProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'locationPermissionsProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$locationPermissionsHash();
@$internal
@override
LocationPermissions create() => LocationPermissions();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(WifiSsidPermission value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<WifiSsidPermission>(value),
);
}
}
String _$locationPermissionsHash() =>
r'e6e5d8a46af321983b65e81759facb07eee5f94c';
abstract class _$LocationPermissions extends $Notifier<WifiSsidPermission> {
WifiSsidPermission build();
@$mustCallSuper
@override
WhenComplete runBuild() {
final ref = this.ref as $Ref<WifiSsidPermission, WifiSsidPermission>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<WifiSsidPermission, WifiSsidPermission>,
WifiSsidPermission,
Object?,
Object?
>;
return element.handleCreate(ref, build);
}
}
@ProviderFor(BatteryOptimizationIgnored)
final batteryOptimizationIgnoredProvider =
BatteryOptimizationIgnoredProvider._();
final class BatteryOptimizationIgnoredProvider
extends $StreamNotifierProvider<BatteryOptimizationIgnored, bool> {
BatteryOptimizationIgnoredProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'batteryOptimizationIgnoredProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$batteryOptimizationIgnoredHash();
@$internal
@override
BatteryOptimizationIgnored create() => BatteryOptimizationIgnored();
}
String _$batteryOptimizationIgnoredHash() =>
r'57dec1c4aa0a54c5af1a6934a3d4f78407df8013';
abstract class _$BatteryOptimizationIgnored extends $StreamNotifier<bool> {
Stream<bool> build();
@$mustCallSuper
@override
WhenComplete runBuild() {
final ref = this.ref as $Ref<AsyncValue<bool>, bool>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<AsyncValue<bool>, bool>,
AsyncValue<bool>,
Object?,
Object?
>;
return element.handleCreate(ref, build);
}
}
+105
View File
@@ -0,0 +1,105 @@
import 'dart:async';
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/plugins/battery.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:wifi_ssid/wifi_ssid.dart';
import 'app.dart';
import 'config.dart';
part 'generated/on_demand.g.dart';
@Riverpod(keepAlive: true)
class LocationPermissions extends _$LocationPermissions {
bool _requesting = false;
bool _autoRequested = false;
@override
WifiSsidPermission build() => WifiSsidPermission.denied;
/// Only a request can tell a permanent denial apart, so a check leaves that
/// answer in place until it sees the grant.
Future<void> refresh() async {
if (ref.read(safeModeProvider)) {
return;
}
final WifiSsidPermission permission;
try {
permission = await wifiSsidManager.checkPermission();
} catch (error) {
commonPrint.log(
'checkPermission error $error',
logLevel: LogLevel.warning,
);
return;
}
if (permission == WifiSsidPermission.granted ||
state != WifiSsidPermission.permanentlyDenied) {
state = permission;
}
if (ref.read(excludeSSIDsProvider).isEmpty) {
_autoRequested = false;
return;
}
if (permission != WifiSsidPermission.denied || _autoRequested) {
return;
}
try {
_autoRequested = await request() != null;
} on PlatformException catch (error) {
commonPrint.log(
'requestPermission error $error',
logLevel: LogLevel.warning,
);
}
}
/// Null while another request is showing, so only the caller that raised
/// the prompt follows up on the answer.
Future<WifiSsidPermission?> request() async {
if (_requesting) {
return null;
}
_requesting = true;
try {
final permission = await wifiSsidManager.requestPermission();
state = permission;
return permission;
} finally {
_requesting = false;
}
}
}
@riverpod
class BatteryOptimizationIgnored extends _$BatteryOptimizationIgnored {
bool _requesting = false;
@protected
BatteryOptimization? get plugin => batteryOptimization;
@override
Stream<bool> build() => plugin?.onChanged ?? Stream.value(true);
Future<void> request() async {
final plugin = this.plugin;
if (plugin == null || _requesting) {
return;
}
_requesting = true;
try {
await plugin.requestIgnore();
} on PlatformException catch (error) {
commonPrint.log(
'requestIgnoreOptimizations error $error',
logLevel: LogLevel.warning,
);
} finally {
_requesting = false;
}
}
}
+51 -75
View File
@@ -1,9 +1,7 @@
import 'dart:async';
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/common/permission.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/features/overwrite/overwrite.dart';
import 'package:fl_clash/icons/icons.dart';
import 'package:fl_clash/plugins/app.dart';
import 'package:fl_clash/providers/providers.dart';
@@ -28,8 +26,6 @@ class _OnDemandViewState extends ConsumerState<OnDemandView>
static const _authorizeButtonPadding = 12.0;
static const _minAuthorizeButtonWidth = 80.0;
bool _requestingLocation = false;
bool get _isAndroid => widget.isAndroid ?? system.isAndroid;
bool get _isMacOS => widget.isMacOS ?? system.isMacOS;
@@ -51,9 +47,6 @@ class _OnDemandViewState extends ConsumerState<OnDemandView>
}
Future<void> _handleRequestLocationPermission() async {
if (_requestingLocation) {
return;
}
final appLocalizations = context.appLocalizations;
final permission = ref.read(locationPermissionsProvider);
if (permission == WifiSsidPermission.granted) {
@@ -63,28 +56,23 @@ class _OnDemandViewState extends ConsumerState<OnDemandView>
_handlePermanentlyDeniedLocationPermission();
return;
}
final permissionsNotifier = ref.read(locationPermissionsProvider.notifier);
final WifiSsidPermission res;
_requestingLocation = true;
final WifiSsidPermission? res;
try {
res = await wifiSsidManager.requestPermission();
res = await ref.read(locationPermissionsProvider.notifier).request();
} on PlatformException catch (e) {
commonPrint.log('requestPermission error $e', logLevel: LogLevel.warning);
return;
} finally {
_requestingLocation = false;
}
permissionsNotifier.value = res;
if (!mounted) {
if (res == null || !mounted) {
return;
}
switch (getLocationPermissionFollowUp(res)) {
case LocationPermissionFollowUp.none:
switch (res) {
case WifiSsidPermission.granted:
return;
case LocationPermissionFollowUp.openSettings:
case WifiSsidPermission.permanentlyDenied:
_handlePermanentlyDeniedLocationPermission();
return;
case LocationPermissionFollowUp.showDeniedMessage:
case WifiSsidPermission.denied:
break;
}
final needGo = await dialogs.showMessage(
@@ -98,13 +86,11 @@ class _OnDemandViewState extends ConsumerState<OnDemandView>
unawaited(app?.openAppSettings());
}
void _handleOpenBatteryOptimizationSettings() {
final isDisabled = ref.read(batteryOptimizationDisableProvider);
if (isDisabled) {
void _handleRequestIgnoreBatteryOptimization() {
if (ref.read(batteryOptimizationIgnoredProvider).value == true) {
return;
}
permissions.needWaitingBatteryOptimizationSettings = true;
app?.openBatteryOptimizationSettings();
unawaited(ref.read(batteryOptimizationIgnoredProvider.notifier).request());
}
Future<void> _handleAddOrUpdate([String? ssid]) async {
@@ -163,7 +149,6 @@ class _OnDemandViewState extends ConsumerState<OnDemandView>
position: position,
child: SelectedDecorationListItem(
isEditing: isEditing,
minVerticalPadding: 8,
title: TooltipText(
text: Text(ssid, maxLines: 2, overflow: TextOverflow.ellipsis),
),
@@ -243,59 +228,31 @@ class _OnDemandViewState extends ConsumerState<OnDemandView>
Widget _buildPrerequisiteItem({
required String title,
required String desc,
required Widget action,
}) {
return DecorationListItem(
minVerticalPadding: 0,
title: Padding(
padding: const EdgeInsets.only(top: 12),
child: TooltipLabel(title),
),
subtitle: Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
spacing: 8,
children: [
Text(desc),
Align(alignment: Alignment.centerRight, child: action),
],
),
),
);
return DecorationListItem(title: TooltipLabel(title), trailing: action);
}
Widget _buildBatteryOptimizationItem() {
final appLocalizations = context.appLocalizations;
// HyperOS takes a running VPN app off the battery optimization whitelist,
// so the status reads as not authorized until the VPN stops.
final isStart = ref.watch(isStartProvider);
final isLoading = ref.watch(
loadingProvider(LoadingTag.batteryOptimization),
);
final disabled = ref.watch(batteryOptimizationDisableProvider);
final ignored = ref.watch(batteryOptimizationIgnoredProvider);
final isLoading = ignored.isLoading;
return _buildPrerequisiteItem(
title: appLocalizations.ignoreBatteryOptimization,
desc: appLocalizations.batteryOptimizationDesc,
action: Stack(
alignment: Alignment.centerRight,
children: [
Visibility(
visible: !isLoading && !isStart,
visible: !isLoading,
maintainSize: true,
maintainAnimation: true,
maintainState: true,
child: _buildAuthorizeButton(
authorized: disabled,
onPressed: _handleOpenBatteryOptimizationSettings,
authorized: ignored.value ?? false,
onPressed: _handleRequestIgnoreBatteryOptimization,
),
),
if (isStart)
InfoMessageButton(
message: appLocalizations.batteryOptimizationStatusTip,
),
if (!isStart && isLoading)
if (isLoading)
const SizedBox.square(dimension: 32, child: CommonCircleLoading()),
],
),
@@ -311,7 +268,6 @@ class _OnDemandViewState extends ConsumerState<OnDemandView>
);
return _buildPrerequisiteItem(
title: appLocalizations.locationPermission,
desc: appLocalizations.locationPermissionDesc,
action: _buildAuthorizeButton(
authorized: granted,
onPressed: _handleRequestLocationPermission,
@@ -320,12 +276,17 @@ class _OnDemandViewState extends ConsumerState<OnDemandView>
}
Widget _buildPrerequisites() {
final appLocalizations = context.appLocalizations;
return generateSectionV3(
title: context.appLocalizations.prerequisites,
title: appLocalizations.prerequisites,
items: [
if (_isAndroid) _buildBatteryOptimizationItem(),
if (_isAndroid || _isMacOS) _buildLocationPermissionItem(),
],
footer: [
if (_isAndroid) appLocalizations.batteryOptimizationDesc,
if (_isAndroid || _isMacOS) appLocalizations.locationPermissionDesc,
].join('\n'),
);
}
@@ -334,7 +295,6 @@ class _OnDemandViewState extends ConsumerState<OnDemandView>
final hasSelection = ref.watch(itemsProvider(key)).isNotEmpty;
return ListHeader(
title: appLocalizations.excludeSsids,
subTitle: appLocalizations.excludeSsidsDesc,
actions: [
if (hasSelection)
CommonMinIconButtonTheme(
@@ -346,19 +306,25 @@ class _OnDemandViewState extends ConsumerState<OnDemandView>
),
),
),
CommonMinFilledButtonTheme(
child: ElasticButton(
child: hasSelection
? FilledButton(
onPressed: _handleSelectAll,
child: Text(appLocalizations.selectAll),
)
: FilledButton.tonal(
onPressed: _handleAddOrUpdate,
child: Text(appLocalizations.add),
),
if (hasSelection)
CommonMinFilledButtonTheme(
child: ElasticButton(
child: FilledButton(
onPressed: _handleSelectAll,
child: Text(appLocalizations.selectAll),
),
),
)
else
CommonMinIconButtonTheme(
child: ElasticButton(
child: IconButton.filledTonal(
tooltip: appLocalizations.add,
onPressed: _handleAddOrUpdate,
icon: const GlyphIcon(AppGlyphs.addCircle, fill: 1),
),
),
),
),
],
);
}
@@ -418,6 +384,16 @@ class _OnDemandViewState extends ConsumerState<OnDemandView>
sliver: SliverToBoxAdapter(child: _buildExcludeSsidsHeader()),
),
_buildExcludeSsidsList(excludeSSIDs, selectedItems),
SliverPadding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
).copyWith(bottom: 16),
sliver: SliverToBoxAdapter(
child: ListFooter(
text: context.appLocalizations.excludeSsidsDesc,
),
),
),
],
),
title: context.appLocalizations.onDemand,
@@ -15,6 +15,7 @@ import android.os.Handler
import android.os.Looper
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.core.content.edit
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.embedding.engine.plugins.activity.ActivityAware
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
@@ -33,6 +34,7 @@ class WifiSsidPlugin : FlutterPlugin, MethodCallHandler, ActivityAware {
private var connectivityManager: ConnectivityManager? = null
private var activityBinding: ActivityPluginBinding? = null
private var pendingPermissionResult: Result? = null
private var rationaleBeforeRequest: Map<String, Boolean> = emptyMap()
private var wifiNetworkCallback: ConnectivityManager.NetworkCallback? = null
private val wifiInfoByNetwork = ConcurrentHashMap<Network, WifiInfo>()
private val pendingSsidResults = mutableListOf<Result>()
@@ -53,23 +55,18 @@ class WifiSsidPlugin : FlutterPlugin, MethodCallHandler, ActivityAware {
result.error(ERROR_UNAVAILABLE, "Activity not available", null)
return@RequestPermissionsResultListener true
}
// R+ dialogs never offer "Allow all the time"; asking for it alone opens Settings.
if (
requestCode == REQUEST_CODE_LOCATION &&
Build.VERSION.SDK_INT >= Build.VERSION_CODES.R &&
hasForegroundLocation(currentActivity) &&
!hasBackgroundLocation(currentActivity) &&
Build.VERSION.SDK_INT < Build.VERSION_CODES.R
!hasBackgroundLocation(currentActivity)
) {
requestBackgroundLocation(currentActivity)
return@RequestPermissionsResultListener true
}
pendingPermissionResult = null
val state = permissionState(currentActivity, afterRequest = true)
if (hasForegroundLocation(currentActivity)) {
refreshWifiNetworkCallback()
} else {
unregisterWifiNetworkCallback()
}
result.success(state)
result.success(settleRequest(currentActivity))
true
}
@@ -80,8 +77,10 @@ class WifiSsidPlugin : FlutterPlugin, MethodCallHandler, ActivityAware {
private const val METHOD_REQUEST_PERMISSION = "requestPermission"
private const val ERROR_UNAVAILABLE = "UNAVAILABLE"
private const val ERROR_IN_PROGRESS = "IN_PROGRESS"
private const val REQUEST_CODE_LOCATION = 1001
private const val REQUEST_CODE_BACKGROUND_LOCATION = 1002
// Every plugin on the activity sees every result; stay clear of the app's codes.
private const val REQUEST_CODE_LOCATION = 2101
private const val REQUEST_CODE_BACKGROUND_LOCATION = 2102
private const val DENIED_PERMISSIONS_PREFERENCES = "wifi_ssid_denied_permissions"
private const val SSID_TIMEOUT_MILLIS = 3_000L
// Values must match WifiSsidPermission enum index in Dart
@@ -148,12 +147,11 @@ class WifiSsidPlugin : FlutterPlugin, MethodCallHandler, ActivityAware {
result.error(ERROR_UNAVAILABLE, "Context not available", null)
return
}
if (hasForegroundLocation(ctx)) {
refreshWifiNetworkCallback()
} else {
unregisterWifiNetworkCallback()
}
result.success(permissionState(ctx))
refreshWifiNetworkCallback()
forgetGrantedDenials(ctx)
result.success(
if (missingPermission(ctx) == null) PERMISSION_GRANTED else PERMISSION_DENIED,
)
}
private fun requestPermission(result: Result) {
@@ -165,8 +163,9 @@ class WifiSsidPlugin : FlutterPlugin, MethodCallHandler, ActivityAware {
result.error(ERROR_UNAVAILABLE, "Context not available", null)
return
}
if (permissionState(ctx) == PERMISSION_GRANTED) {
if (missingPermission(ctx) == null) {
refreshWifiNetworkCallback()
forgetGrantedDenials(ctx)
result.success(PERMISSION_GRANTED)
return
}
@@ -174,17 +173,15 @@ class WifiSsidPlugin : FlutterPlugin, MethodCallHandler, ActivityAware {
result.error(ERROR_IN_PROGRESS, "A permission request is already active", null)
return
}
// R+ can't grant background location from a runtime dialog once foreground is held.
if (hasForegroundLocation(ctx) && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
refreshWifiNetworkCallback()
result.success(permissionState(ctx, afterRequest = true))
return
}
pendingPermissionResult = result
if (hasForegroundLocation(ctx)) {
requestBackgroundLocation(act)
return
} else {
requestForegroundLocation(act)
}
}
private fun requestForegroundLocation(activity: Activity) {
// Q accepts the background permission in the same dialog; R+ rejects the
// combined request and needs it asked separately once foreground is granted.
val permissions = if (Build.VERSION.SDK_INT == Build.VERSION_CODES.Q) {
@@ -199,17 +196,68 @@ class WifiSsidPlugin : FlutterPlugin, MethodCallHandler, ActivityAware {
Manifest.permission.ACCESS_COARSE_LOCATION,
)
}
ActivityCompat.requestPermissions(act, permissions, REQUEST_CODE_LOCATION)
launchPermissionRequest(activity, permissions, REQUEST_CODE_LOCATION)
}
private fun requestBackgroundLocation(activity: Activity) {
ActivityCompat.requestPermissions(
launchPermissionRequest(
activity,
arrayOf(Manifest.permission.ACCESS_BACKGROUND_LOCATION),
REQUEST_CODE_BACKGROUND_LOCATION,
)
}
private fun launchPermissionRequest(
activity: Activity,
permissions: Array<String>,
requestCode: Int,
) {
rationaleBeforeRequest = permissions.associateWith {
ActivityCompat.shouldShowRequestPermissionRationale(activity, it)
}
ActivityCompat.requestPermissions(activity, permissions, requestCode)
}
private fun settleRequest(activity: Activity): Int {
val rationaleBefore = rationaleBeforeRequest
rationaleBeforeRequest = emptyMap()
refreshWifiNetworkCallback()
forgetGrantedDenials(activity)
val missing = missingPermission(activity) ?: return PERMISSION_GRANTED
return resolveDenial(activity, missing, rationaleBefore[missing] == true)
}
// Rationale is false both before the first prompt and after a permanent denial;
// how it moved across the request and a persisted earlier denial tell them apart.
private fun resolveDenial(
activity: Activity,
permission: String,
rationaleBefore: Boolean,
): Int {
val preferences = deniedPermissions(activity)
val deniedBefore = preferences.getBoolean(permission, false)
if (ActivityCompat.shouldShowRequestPermissionRationale(activity, permission)) {
preferences.edit { putBoolean(permission, true) }
return PERMISSION_DENIED
}
if (rationaleBefore) {
preferences.edit { putBoolean(permission, true) }
return PERMISSION_PERMANENTLY_DENIED
}
return if (deniedBefore) PERMISSION_PERMANENTLY_DENIED else PERMISSION_DENIED
}
private fun forgetGrantedDenials(context: Context) {
val preferences = deniedPermissions(context)
val granted = preferences.all.keys.filter { isGranted(context, it) }
if (granted.isNotEmpty()) {
preferences.edit { granted.forEach(::remove) }
}
}
private fun deniedPermissions(context: Context) =
context.getSharedPreferences(DENIED_PERMISSIONS_PREFERENCES, Context.MODE_PRIVATE)
private fun isGranted(context: Context, permission: String): Boolean =
ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
@@ -222,19 +270,10 @@ class WifiSsidPlugin : FlutterPlugin, MethodCallHandler, ActivityAware {
Build.VERSION.SDK_INT < Build.VERSION_CODES.Q ||
isGranted(context, Manifest.permission.ACCESS_BACKGROUND_LOCATION)
private fun permissionState(context: Context, afterRequest: Boolean = false): Int {
val missing = when {
!hasForegroundLocation(context) -> Manifest.permission.ACCESS_FINE_LOCATION
!hasBackgroundLocation(context) -> Manifest.permission.ACCESS_BACKGROUND_LOCATION
else -> return PERMISSION_GRANTED
}
if (!afterRequest) return PERMISSION_DENIED
val activity = activity ?: return PERMISSION_DENIED
return if (ActivityCompat.shouldShowRequestPermissionRationale(activity, missing)) {
PERMISSION_DENIED
} else {
PERMISSION_PERMANENTLY_DENIED
}
private fun missingPermission(context: Context): String? = when {
!hasForegroundLocation(context) -> Manifest.permission.ACCESS_FINE_LOCATION
!hasBackgroundLocation(context) -> Manifest.permission.ACCESS_BACKGROUND_LOCATION
else -> null
}
private fun getSsid(result: Result) {
@@ -388,6 +427,7 @@ class WifiSsidPlugin : FlutterPlugin, MethodCallHandler, ActivityAware {
activityBinding = null
activity = null
if (cancelPermissionRequest) {
rationaleBeforeRequest = emptyMap()
completePendingPermissionRequest(
"Activity detached before permission request completed",
)
@@ -3,7 +3,6 @@ import 'package:flutter/services.dart';
/// The platform permission state required to read the current Wi-Fi SSID.
enum WifiSsidPermission { granted, denied, permanentlyDenied }
/// Provides access to the current Wi-Fi SSID and its required permission.
class WifiSsidManager {
WifiSsidManager._();
@@ -19,13 +18,11 @@ class WifiSsidManager {
return _channel.invokeMethod<String>(_getSsidMethod);
}
/// Checks whether the required platform permission has been granted.
Future<WifiSsidPermission> checkPermission() async {
final result = await _channel.invokeMethod<int>(_checkPermissionMethod);
return _decodePermission(result);
}
/// Requests the required platform permission from the user.
Future<WifiSsidPermission> requestPermission() async {
final result = await _channel.invokeMethod<int>(_requestPermissionMethod);
return _decodePermission(result);
+32 -1
View File
@@ -2,6 +2,8 @@ import 'dart:convert';
import 'package:fl_clash/manager/android_manager.dart';
import 'package:fl_clash/models/models.dart';
import 'package:fl_clash/plugins/service.dart';
import 'package:fl_clash/providers/action.dart';
import 'package:fl_clash/providers/config.dart';
import 'package:fl_clash/providers/database.dart';
import 'package:fl_clash/state.dart';
@@ -13,6 +15,16 @@ import 'package:shared_preferences/shared_preferences.dart';
import '../helpers/test_app.dart';
import '../helpers/test_profiles.dart';
class _RecordingSetupAction extends SetupAction {
static final requests = <bool>[];
@override
Future<bool> setRunning(bool running, {bool initialize = false}) async {
requests.add(running);
return true;
}
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
SharedPreferences.setMockInitialValues({});
@@ -23,8 +35,12 @@ void main() {
setUp(() async {
store = await SharedPreferences.getInstance();
await store.clear();
_RecordingSetupAction.requests.clear();
container = ProviderContainer(
overrides: [profilesProvider.overrideWith(TestProfiles.new)],
overrides: [
profilesProvider.overrideWith(TestProfiles.new),
setupActionProvider.overrideWith(_RecordingSetupAction.new),
],
);
globalState.container = container;
});
@@ -52,6 +68,21 @@ void main() {
await tester.pumpWidget(const SizedBox.shrink());
});
testWidgets('a stop the service reports turns the running state off', (
tester,
) async {
await pumpAndroidManager(tester);
final listener =
tester.state(find.byType(AndroidManager)) as ServiceListener;
listener.onServiceStopped();
await tester.pump();
expect(_RecordingSetupAction.requests, [false]);
await tester.pumpWidget(const SizedBox.shrink());
});
testWidgets('persists shared state after the debounce window', (
tester,
) async {
@@ -4,10 +4,13 @@ import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:fl_clash/manager/connectivity_manager.dart';
import 'package:fl_clash/providers/app.dart';
import 'package:fl_clash/providers/config.dart';
import 'package:fl_clash/providers/on_demand.dart';
import 'package:fl_clash/state.dart';
import 'package:material_ui/material_ui.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:wifi_ssid/wifi_ssid.dart';
void main() {
late ProviderContainer container;
@@ -126,6 +129,31 @@ void main() {
expect(currentSsid(), 'Home');
});
testWidgets('reads the SSID again once location permission is granted', (
tester,
) async {
const channel = MethodChannel('wifi_ssid');
final messenger = tester.binding.defaultBinaryMessenger;
messenger.setMockMethodCallHandler(channel, (_) async => 0);
addTearDown(() => messenger.setMockMethodCallHandler(channel, null));
var permitted = false;
await pumpManager(tester, readSsid: () async => permitted ? 'Home' : null);
connectivity.add([ConnectivityResult.wifi]);
await tester.pumpAndSettle();
expect(currentSsid(), isNull);
permitted = true;
await container.read(locationPermissionsProvider.notifier).request();
await tester.pumpAndSettle();
expect(
container.read(locationPermissionsProvider),
WifiSsidPermission.granted,
);
expect(currentSsid(), 'Home');
});
testWidgets('drops the SSID when the last excluded network is removed', (
tester,
) async {
-22
View File
@@ -99,26 +99,4 @@ void main() {
expect(_RecordingSetupAction.requests, [true]);
});
testWidgets('a tile stop request stops a running core', (tester) async {
final listener = await pumpTileManager(tester);
markStarted();
await tester.pump();
listener.onStop();
await tester.pumpAndSettle();
expect(_RecordingSetupAction.requests, [false]);
});
testWidgets('a tile stop request is ignored when nothing is running', (
tester,
) async {
final listener = await pumpTileManager(tester);
listener.onStop();
await tester.pumpAndSettle();
expect(_RecordingSetupAction.requests, isEmpty);
});
}
+117 -57
View File
@@ -1,8 +1,10 @@
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/l10n/l10n.dart';
import 'package:fl_clash/manager/status_manager.dart';
import 'package:fl_clash/manager/vpn_manager.dart';
import 'package:fl_clash/models/models.dart';
import 'package:fl_clash/providers/action.dart';
import 'package:fl_clash/providers/app.dart';
import 'package:fl_clash/providers/config.dart';
import 'package:fl_clash/providers/state.dart';
@@ -11,19 +13,28 @@ import 'package:material_ui/material_ui.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
class _RestartRecordingSetupAction extends SetupAction {
int restarts = 0;
@override
Future<void> restartVpn() async {
restarts++;
}
}
void main() {
late ProviderContainer container;
late _RestartRecordingSetupAction setupAction;
setUp(() {
container = ProviderContainer();
setupAction = _RestartRecordingSetupAction();
container = ProviderContainer(
overrides: [setupActionProvider.overrideWith(() => setupAction)],
);
globalState.container = container;
globalState.lastVpnState = null;
});
tearDown(() {
container.dispose();
globalState.lastVpnState = null;
});
tearDown(() => container.dispose());
Future<void> pumpVpnManager(WidgetTester tester) async {
await tester.pumpWidget(
@@ -46,73 +57,122 @@ void main() {
await tester.pump();
}
Future<void> outlastTip(WidgetTester tester) async {
await tester.pump(const Duration(seconds: 7));
await tester.pump(const Duration(milliseconds: 500));
}
Future<void> drainTimers(WidgetTester tester) async {
await tester.pump(const Duration(seconds: 11));
await outlastTip(tester);
await tester.pumpWidget(const SizedBox.shrink());
}
testWidgets('shows a tip when the vpn state changes while started', (
void startService() {
container.read(runningVpnOptionsProvider.notifier).value = container.read(
vpnOptionsProvider,
);
}
void updateVpn(VpnProps Function(VpnProps state) update) {
container.read(vpnSettingProvider.notifier).update(update);
}
Finder tip() => find.text(currentAppLocalizations.vpnConfigChangeDetected);
testWidgets('a change the running service reads asks for a restart', (
tester,
) async {
await pumpVpnManager(tester);
container.read(runTimeProvider.notifier).value = 1;
startService();
container
.read(vpnSettingProvider.notifier)
.update((_) => const VpnProps(enable: false));
updateVpn((state) => state.copyWith(ipv6: !state.ipv6));
await tester.pump();
expect(tip(), findsOneWidget);
expect(
find.text(currentAppLocalizations.vpnConfigChangeDetected),
findsOneWidget,
);
await drainTimers(tester);
});
testWidgets('does not show a tip when not started', (tester) async {
await pumpVpnManager(tester);
container
.read(vpnSettingProvider.notifier)
.update((_) => const VpnProps(enable: false));
await tester.pump();
expect(
find.text(currentAppLocalizations.vpnConfigChangeDetected),
findsNothing,
);
await drainTimers(tester);
});
testWidgets('does not repeat the tip for the same vpn state', (tester) async {
await pumpVpnManager(tester);
container.read(runTimeProvider.notifier).value = 1;
container
.read(vpnSettingProvider.notifier)
.update((_) => const VpnProps(enable: false));
await tester.pump();
expect(
find.text(currentAppLocalizations.vpnConfigChangeDetected),
findsOneWidget,
);
await tester.pump(const Duration(seconds: 7));
await tester.pump(const Duration(milliseconds: 500));
expect(
find.text(currentAppLocalizations.vpnConfigChangeDetected),
findsNothing,
);
await tester.tap(find.text(currentAppLocalizations.restart));
await tester.pump();
expect(setupAction.restarts, 1);
await drainTimers(tester);
});
testWidgets('a mixed port change asks for a restart under a system proxy', (
tester,
) async {
await pumpVpnManager(tester);
startService();
globalState.lastVpnState = container.read(vpnStateProvider);
container
.read(vpnSettingProvider.notifier)
.update((_) => const VpnProps(enable: true));
.read(patchClashConfigProvider.notifier)
.update((state) => state.copyWith(mixedPort: state.mixedPort + 1));
await tester.pump();
expect(
find.text(currentAppLocalizations.vpnConfigChangeDetected),
findsNothing,
expect(tip(), findsOneWidget);
await drainTimers(tester);
});
testWidgets('no tip while no service is running', (tester) async {
await pumpVpnManager(tester);
updateVpn((state) => state.copyWith(ipv6: !state.ipv6));
await tester.pump();
expect(tip(), findsNothing);
await drainTimers(tester);
});
testWidgets('no tip for what the service never reads', (tester) async {
await pumpVpnManager(tester);
startService();
updateVpn(
(state) => state.copyWith(
accessControlProps: state.accessControlProps.copyWith(
sort: AccessSortType.name,
isFilterSystemApp: !state.accessControlProps.isFilterSystemApp,
),
),
);
await tester.pump();
expect(tip(), findsNothing);
await drainTimers(tester);
});
testWidgets('changing back to what the service runs asks for nothing', (
tester,
) async {
await pumpVpnManager(tester);
startService();
updateVpn((state) => state.copyWith(ipv6: !state.ipv6));
await tester.pump();
expect(tip(), findsOneWidget);
await outlastTip(tester);
expect(tip(), findsNothing);
updateVpn((state) => state.copyWith(ipv6: !state.ipv6));
await tester.pump();
expect(tip(), findsNothing);
await drainTimers(tester);
});
testWidgets('a restart measures later changes against the new options', (
tester,
) async {
await pumpVpnManager(tester);
startService();
updateVpn((state) => state.copyWith(ipv6: !state.ipv6));
await tester.pump();
await outlastTip(tester);
expect(tip(), findsNothing);
startService();
updateVpn((state) => state.copyWith(ipv6: !state.ipv6));
await tester.pump();
expect(tip(), findsOneWidget);
await drainTimers(tester);
});
}
+34
View File
@@ -1,3 +1,6 @@
import 'dart:async';
import 'dart:math';
import 'package:fl_clash/common/boot_record.dart';
import 'package:fl_clash/common/constant.dart';
import 'package:fl_clash/plugins/app.dart';
@@ -92,6 +95,37 @@ void main() {
},
);
test(
'asks Android for a bounded number of package icons at a time',
() async {
var inFlight = 0;
var peak = 0;
final release = Completer<void>();
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, (call) async {
inFlight++;
peak = max(peak, inFlight);
await release.future;
inFlight--;
return '/icons/${call.arguments['packageName']}.png';
});
final app = App();
final icons = Future.wait([
for (var i = 0; i < maxConcurrentIconLoads * 2; i++)
app.getPackageIcon('com.$i'),
]);
await pumpEventQueue();
expect(inFlight, maxConcurrentIconLoads);
release.complete();
expect(await icons, everyElement(isNotNull));
expect(peak, maxConcurrentIconLoads);
},
);
test('caches a failed package icon lookup', () async {
var iconCallCount = 0;
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
+91
View File
@@ -15,6 +15,13 @@ class _RecordingListener with ServiceListener {
void onServiceEvent(CoreEvent event) => events.add(event);
}
class _RecordingStopListener with ServiceListener {
var stops = 0;
@override
void onServiceStopped() => stops++;
}
class _ThrowingListener with ServiceListener {
var called = false;
@@ -23,6 +30,11 @@ class _ThrowingListener with ServiceListener {
called = true;
throw StateError('listener boom');
}
@override
void onServiceStopped() {
throw StateError('listener boom');
}
}
class _MutatingListener with ServiceListener {
@@ -146,6 +158,85 @@ void main() {
});
});
group('stop reports', () {
late _RecordingStopListener listener;
Future<void> reportStopped(Object? revision) async {
await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.handlePlatformMessage(
channelName,
codec.encodeMethodCall(MethodCall('stopped', revision)),
null,
);
}
int lastRevision() => calls.last.arguments as int;
setUp(() {
mockChannel((_) async => true);
listener = _RecordingStopListener();
Service().addListener(listener);
});
tearDown(() => Service().removeListener(listener));
test('every command carries a revision newer than the last', () async {
await Service().start();
final first = lastRevision();
await Service().stop();
expect(lastRevision(), greaterThan(first));
});
test(
'a report for the start in force reaches the listeners once',
() async {
await Service().start();
await reportStopped(lastRevision());
await reportStopped(lastRevision());
expect(listener.stops, 1);
},
);
test('a report older than the newest command is dropped', () async {
await Service().start();
final stale = lastRevision();
await Service().stop();
await Service().start();
await reportStopped(stale);
expect(listener.stops, 0);
});
test(
'a report is dropped while Flutter itself asked for the stop',
() async {
await Service().start();
await Service().stop();
await reportStopped(lastRevision());
expect(listener.stops, 0);
},
);
test('a throwing listener does not starve the next one', () async {
final throwing = _ThrowingListener();
Service().removeListener(listener);
Service().addListener(throwing);
Service().addListener(listener);
addTearDown(() => Service().removeListener(throwing));
await Service().start();
await reportStopped(lastRevision());
expect(listener.stops, 1);
});
});
group('getRunTime', () {
test('maps a millisecond stamp to a DateTime', () async {
final stamp = DateTime.utc(2026, 8, 16).millisecondsSinceEpoch;
+1 -5
View File
@@ -9,9 +9,6 @@ class _RecordingListener with TileListener {
@override
void onStart() => calls.add('start');
@override
void onStop() => calls.add('stop');
@override
void onDetached() => calls.add('detached');
}
@@ -58,10 +55,9 @@ void main() {
addTearDown(() => Tile.instance.removeListener(listener));
await emitFromPlatform('start');
await emitFromPlatform('stop');
await emitFromPlatform('detached');
expect(listener.calls, ['start', 'stop', 'detached']);
expect(listener.calls, ['start', 'detached']);
});
test('delivers one action to every registered listener', () async {
+303
View File
@@ -0,0 +1,303 @@
import 'dart:async';
import 'package:fl_clash/common/constant.dart';
import 'package:fl_clash/plugins/battery.dart';
import 'package:fl_clash/providers/app.dart';
import 'package:fl_clash/providers/config.dart';
import 'package:fl_clash/providers/on_demand.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:riverpod/riverpod.dart';
import 'package:wifi_ssid/wifi_ssid.dart';
class _AndroidBatteryOptimizationIgnored extends BatteryOptimizationIgnored {
@override
BatteryOptimization? get plugin => BatteryOptimization.instance;
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
final messenger =
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
group('location permission', () {
const channel = MethodChannel('wifi_ssid');
late ProviderContainer container;
late List<String> calls;
void answer(Future<Object?> Function(MethodCall call) handler) {
messenger.setMockMethodCallHandler(channel, (call) {
calls.add(call.method);
return handler(call);
});
}
ProviderContainer excluding(List<String> ssids) {
return ProviderContainer(
overrides: [excludeSSIDsProvider.overrideWithValue(ssids)],
);
}
LocationPermissions notifier() {
return container.read(locationPermissionsProvider.notifier);
}
setUp(() {
calls = [];
container = excluding(const ['Office Wi-Fi']);
});
tearDown(() {
messenger.setMockMethodCallHandler(channel, null);
container.dispose();
});
test('asks for the permission once a network is excluded', () async {
answer(
(call) async => switch (call.method) {
'checkPermission' => 1,
_ => 0,
},
);
await notifier().refresh();
expect(calls, ['checkPermission', 'requestPermission']);
expect(
container.read(locationPermissionsProvider),
WifiSsidPermission.granted,
);
});
test('a safe mode build neither checks nor requests location', () async {
container.dispose();
container = ProviderContainer(
overrides: [
excludeSSIDsProvider.overrideWithValue(const ['Office Wi-Fi']),
safeModeProvider.overrideWithValue(true),
],
);
answer((_) async => 0);
await notifier().refresh();
expect(calls, isEmpty);
expect(
container.read(locationPermissionsProvider),
WifiSsidPermission.denied,
);
});
test('nothing is asked while no network is excluded', () async {
container.dispose();
container = excluding(const []);
answer((_) async => 1);
await notifier().refresh();
expect(calls, ['checkPermission']);
});
test('auto-requests at most once per session while denied', () async {
answer((_) async => 1);
await notifier().refresh();
await notifier().refresh();
expect(calls.where((call) => call == 'requestPermission').length, 1);
});
test('an empty excluded list re-arms the auto-request', () async {
container.dispose();
container = ProviderContainer();
answer((_) async => 1);
await notifier().refresh();
container.read(excludeSSIDsProvider.notifier).value = const ['Office'];
await notifier().refresh();
await notifier().refresh();
container.read(excludeSSIDsProvider.notifier).value = const [];
await notifier().refresh();
container.read(excludeSSIDsProvider.notifier).value = const ['Office'];
await notifier().refresh();
expect(calls.where((call) => call == 'requestPermission').length, 2);
});
test('a failed auto-request is retried on the next refresh', () async {
answer((call) async {
if (call.method == 'requestPermission') {
throw PlatformException(code: 'IN_PROGRESS');
}
return 1;
});
await expectLater(notifier().refresh(), completes);
await notifier().refresh();
expect(calls.where((call) => call == 'requestPermission').length, 2);
});
test('a check keeps a permanent denial until it sees the grant', () async {
container.dispose();
container = excluding(const []);
var checked = 1;
answer(
(call) async => switch (call.method) {
'requestPermission' => 2,
_ => checked,
},
);
await notifier().request();
await notifier().refresh();
expect(
container.read(locationPermissionsProvider),
WifiSsidPermission.permanentlyDenied,
);
checked = 0;
await notifier().refresh();
expect(
container.read(locationPermissionsProvider),
WifiSsidPermission.granted,
);
});
test('a request made while one is showing answers null', () async {
final prompt = Completer<Object?>();
answer((_) => prompt.future);
final first = notifier().request();
final second = await notifier().request();
prompt.complete(0);
expect(second, isNull);
expect(await first, WifiSsidPermission.granted);
expect(calls, ['requestPermission']);
});
});
group('battery optimization', () {
const methodChannel = MethodChannel('$packageName/battery');
const eventChannel = EventChannel('$packageName/battery/optimization');
late ProviderContainer container;
late List<String> calls;
late MockStreamHandlerEventSink events;
late bool listening;
void answer(Future<Object?> Function(MethodCall call) handler) {
messenger.setMockMethodCallHandler(methodChannel, (call) {
calls.add(call.method);
return handler(call);
});
}
Future<void> watch() async {
container.listen(batteryOptimizationIgnoredProvider, (_, _) {});
await pumpEventQueue();
}
bool? ignored() => container.read(batteryOptimizationIgnoredProvider).value;
setUp(() {
calls = [];
listening = false;
messenger.setMockStreamHandler(
eventChannel,
MockStreamHandler.inline(
onListen: (_, sink) {
listening = true;
events = sink;
sink.success(false);
},
onCancel: (_) => listening = false,
),
);
container = ProviderContainer(
overrides: [
batteryOptimizationIgnoredProvider.overrideWith(
_AndroidBatteryOptimizationIgnored.new,
),
],
);
});
tearDown(() {
messenger.setMockMethodCallHandler(methodChannel, null);
messenger.setMockStreamHandler(eventChannel, null);
container.dispose();
});
test('follows the allowlist while watched and stops after', () async {
await watch();
expect(ignored(), isFalse);
events.success(true);
await pumpEventQueue();
expect(ignored(), isTrue);
container.dispose();
await pumpEventQueue();
expect(listening, isFalse);
container = ProviderContainer();
});
test(
'a request waits for the user and the answer comes from the stream',
() async {
answer((_) async {
events.success(true);
return null;
});
await watch();
await container
.read(batteryOptimizationIgnoredProvider.notifier)
.request();
await pumpEventQueue();
expect(calls, ['requestIgnoreOptimizations']);
expect(ignored(), isTrue);
},
);
test('a second tap while the prompt is up asks once', () async {
final prompt = Completer<Object?>();
answer((_) => prompt.future);
await watch();
final notifier = container.read(
batteryOptimizationIgnoredProvider.notifier,
);
final first = notifier.request();
await notifier.request();
prompt.complete();
await first;
expect(calls, ['requestIgnoreOptimizations']);
});
test('coming back to the app leaves the state to the stream', () async {
answer((_) async => null);
await watch();
container.read(appVisibleProvider.notifier).value = false;
container.read(appVisibleProvider.notifier).value = true;
await pumpEventQueue();
expect(calls, isEmpty);
expect(ignored(), isFalse);
});
test('a platform without the prompt counts as exempt', () async {
container.dispose();
container = ProviderContainer();
await watch();
expect(ignored(), isTrue);
expect(listening, isFalse);
});
});
}
-181
View File
@@ -1,181 +0,0 @@
import 'package:fl_clash/common/permission.dart';
import 'package:fl_clash/providers/app.dart';
import 'package:fl_clash/providers/config.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:riverpod/riverpod.dart';
import 'package:wifi_ssid/wifi_ssid.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
const channel = MethodChannel('wifi_ssid');
late ProviderContainer container;
setUp(() {
container = ProviderContainer(
overrides: [
excludeSSIDsProvider.overrideWithValue(const ['Office Wi-Fi']),
],
);
});
tearDown(() async {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, null);
container.dispose();
});
test('maps permission results to the correct UI follow-up', () {
expect(
getLocationPermissionFollowUp(WifiSsidPermission.granted),
LocationPermissionFollowUp.none,
);
expect(
getLocationPermissionFollowUp(WifiSsidPermission.denied),
LocationPermissionFollowUp.showDeniedMessage,
);
expect(
getLocationPermissionFollowUp(WifiSsidPermission.permanentlyDenied),
LocationPermissionFollowUp.openSettings,
);
});
test(
'refreshes current SSID immediately after permission is granted',
() async {
final calls = <String>[];
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, (call) async {
calls.add(call.method);
return switch (call.method) {
'checkPermission' => 1,
'requestPermission' => 0,
'getSsid' => 'Office Wi-Fi',
_ => null,
};
});
await Permissions.test(
supportsLocationPermissions: true,
).checkLocationPermissions(container.read);
expect(calls, ['checkPermission', 'requestPermission', 'getSsid']);
expect(
container.read(locationPermissionsProvider),
WifiSsidPermission.granted,
);
expect(container.read(currentSSIDProvider), 'Office Wi-Fi');
},
);
test('a safe mode build neither checks nor requests location', () async {
container.dispose();
container = ProviderContainer(
overrides: [
excludeSSIDsProvider.overrideWithValue(const ['Office Wi-Fi']),
safeModeProvider.overrideWithValue(true),
],
);
final calls = <String>[];
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, (call) async {
calls.add(call.method);
return 0;
});
await Permissions.test(
supportsLocationPermissions: true,
).checkLocationPermissions(container.read);
expect(calls, isEmpty);
expect(
container.read(locationPermissionsProvider),
WifiSsidPermission.denied,
);
expect(container.read(currentSSIDProvider), isNull);
});
test('does not read SSID when permission remains denied', () async {
final calls = <String>[];
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, (call) async {
calls.add(call.method);
return 1;
});
await Permissions.test(
supportsLocationPermissions: true,
).checkLocationPermissions(container.read);
expect(calls, ['checkPermission', 'requestPermission']);
expect(
container.read(locationPermissionsProvider),
WifiSsidPermission.denied,
);
expect(container.read(currentSSIDProvider), isNull);
});
test('auto-requests at most once per session while still denied', () async {
final calls = <String>[];
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, (call) async {
calls.add(call.method);
return 1;
});
final permissions = Permissions.test(supportsLocationPermissions: true);
await permissions.checkLocationPermissions(container.read);
await permissions.checkLocationPermissions(container.read);
expect(calls.where((call) => call == 'requestPermission').length, 1);
});
test(
're-arms the auto-request when excludeSSIDs goes from empty to non-empty',
() async {
container = ProviderContainer();
final calls = <String>[];
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, (call) async {
calls.add(call.method);
return 1;
});
final permissions = Permissions.test(supportsLocationPermissions: true);
await permissions.checkLocationPermissions(container.read);
expect(calls.where((call) => call == 'requestPermission'), isEmpty);
container.read(excludeSSIDsProvider.notifier).value = const [
'Office Wi-Fi',
];
await permissions.checkLocationPermissions(container.read);
await permissions.checkLocationPermissions(container.read);
expect(calls.where((call) => call == 'requestPermission').length, 1);
},
);
test('re-arms the auto-request and does not throw when requestPermission '
'fails', () async {
var requestPermissionCalls = 0;
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, (call) async {
if (call.method == 'requestPermission') {
requestPermissionCalls++;
throw PlatformException(code: 'IN_PROGRESS');
}
return 1;
});
final permissions = Permissions.test(supportsLocationPermissions: true);
await expectLater(
permissions.checkLocationPermissions(container.read),
completes,
);
await permissions.checkLocationPermissions(container.read);
expect(requestPermissionCalls, 2);
});
}
+95 -31
View File
@@ -3,6 +3,7 @@ import 'dart:async';
import 'package:fl_clash/icons/icons.dart';
import 'package:fl_clash/providers/app.dart';
import 'package:fl_clash/providers/config.dart';
import 'package:fl_clash/providers/on_demand.dart';
import 'package:fl_clash/providers/state.dart';
import 'package:fl_clash/l10n/l10n.dart';
import 'package:fl_clash/state.dart';
@@ -35,6 +36,22 @@ class _TestLocationPermissions extends LocationPermissions {
WifiSsidPermission build() => _initial;
}
class _TestBatteryOptimizationIgnored extends BatteryOptimizationIgnored {
_TestBatteryOptimizationIgnored(this._initial);
final bool _initial;
int requests = 0;
@override
Stream<bool> build() => Stream.value(_initial);
@override
Future<void> request() async {
requests++;
state = const AsyncData(true);
}
}
void main() {
late ProviderContainer container;
@@ -42,6 +59,7 @@ void main() {
WidgetTester tester, {
List<String> ssids = const [],
WifiSsidPermission permission = WifiSsidPermission.denied,
bool batteryIgnored = false,
bool isAndroid = false,
bool isMacOS = false,
bool isStart = false,
@@ -59,6 +77,9 @@ void main() {
locationPermissionsProvider.overrideWith(
() => _TestLocationPermissions(permission),
),
batteryOptimizationIgnoredProvider.overrideWith(
() => _TestBatteryOptimizationIgnored(batteryIgnored),
),
isStartProvider.overrideWithValue(isStart),
],
);
@@ -83,7 +104,7 @@ void main() {
await pumpView(tester);
expect(find.text('SSIDs are empty'), findsOneWidget);
expect(find.text('Add'), findsOneWidget);
expect(find.byTooltip('Add'), findsOneWidget);
expect(find.text('Select all'), findsNothing);
expect(find.byGlyph(AppGlyphs.delete), findsNothing);
});
@@ -107,7 +128,7 @@ void main() {
expect(find.byGlyph(AppGlyphs.delete), findsOneWidget);
expect(find.text('Select all'), findsOneWidget);
expect(find.text('Add'), findsNothing);
expect(find.byTooltip('Add'), findsNothing);
});
testWidgets('select all takes every SSID, and pressing it again clears', (
@@ -123,7 +144,11 @@ void main() {
await tester.tap(find.text('Select all'));
await tester.pumpAndSettle();
expect(find.text('Add'), findsOneWidget, reason: 'selection was cleared');
expect(
find.byTooltip('Add'),
findsOneWidget,
reason: 'selection was cleared',
);
});
testWidgets('deleting removes the selected SSIDs and clears the selection', (
@@ -138,7 +163,11 @@ void main() {
expect(container.read(excludeSSIDsProvider), ['Office']);
expect(find.text('Home'), findsNothing);
expect(find.text('Add'), findsOneWidget, reason: 'selection was cleared');
expect(
find.byTooltip('Add'),
findsOneWidget,
reason: 'selection was cleared',
);
});
testWidgets('the location prerequisite reflects a denied permission', (
@@ -224,27 +253,67 @@ void main() {
expect(find.text('Location permission'), findsOneWidget);
});
testWidgets('while running the battery item explains instead of asking', (
testWidgets('authorizing the battery item asks and shows the answer', (
tester,
) async {
await pumpView(tester, isAndroid: true, isStart: true);
container.read(viewSizeProvider.notifier).value = const Size(1400, 1000);
final appLocalizations = AppLocalizations.of(
tester.element(find.byType(OnDemandView)),
await pumpView(tester, isAndroid: true);
final batteryItem = find.ancestor(
of: find.text('Ignore battery optimization'),
matching: find.byType(DecorationListItem),
);
expect(find.bySemanticsLabel(appLocalizations.tapToAuthorize), findsOne);
await tester.tap(find.byTooltip(appLocalizations.tip));
await tester.tap(
find.descendant(of: batteryItem, matching: find.text('Tap to authorize')),
);
await tester.pumpAndSettle();
final notifier =
container.read(batteryOptimizationIgnoredProvider.notifier)
as _TestBatteryOptimizationIgnored;
expect(notifier.requests, 1);
expect(
find.text(appLocalizations.batteryOptimizationStatusTip),
find.descendant(
of: batteryItem,
matching: find.bySemanticsLabel('Authorized'),
),
findsOneWidget,
);
});
testWidgets('the authorize action sits on its own line under the text', (
testWidgets('an exempt app does not ask again', (tester) async {
await pumpView(tester, isAndroid: true, batteryIgnored: true);
final batteryItem = find.ancestor(
of: find.text('Ignore battery optimization'),
matching: find.byType(DecorationListItem),
);
await tester.tap(
find.descendant(of: batteryItem, matching: find.text('Authorized')),
);
await tester.pumpAndSettle();
final notifier =
container.read(batteryOptimizationIgnoredProvider.notifier)
as _TestBatteryOptimizationIgnored;
expect(notifier.requests, 0);
});
testWidgets('while running the battery item still shows and asks', (
tester,
) async {
await pumpView(tester, isAndroid: true, isStart: true);
await tester.tap(find.text('Tap to authorize').first);
await tester.pumpAndSettle();
final notifier =
container.read(batteryOptimizationIgnoredProvider.notifier)
as _TestBatteryOptimizationIgnored;
expect(notifier.requests, 1);
expect(find.byTooltip('Tip'), findsNothing);
});
testWidgets('the authorize action trails its row, the reason the group', (
tester,
) async {
await pumpView(tester, isMacOS: true, locale: const Locale('ru'));
@@ -252,25 +321,20 @@ void main() {
final appLocalizations = AppLocalizations.of(
tester.element(find.byType(OnDemandView)),
);
final row = tester.getRect(find.byType(DecorationListItem).first);
final desc = find.text(appLocalizations.locationPermissionDesc);
final button = find
.ancestor(
of: find.text(appLocalizations.tapToAuthorize),
matching: find.byType(FilledButton),
)
.first;
final button = tester.getRect(
find
.ancestor(
of: find.text(appLocalizations.tapToAuthorize),
matching: find.byType(FilledButton),
)
.first,
);
expect(
tester.getTopLeft(button).dy,
greaterThanOrEqualTo(tester.getBottomLeft(desc).dy),
);
expect(
tester.getRect(button).right,
closeTo(
tester.getRect(find.byType(DecorationListItem).first).right - 16,
1,
),
);
expect(button.center.dy, closeTo(row.center.dy, 1));
expect(button.right, closeTo(row.right - 16, 1));
expect(tester.getTopLeft(desc).dy, greaterThanOrEqualTo(row.bottom));
});
testWidgets('a desktop that is not macOS asks for no prerequisite', (