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