12403b063a
Moves to AGP 9.4.1 and Gradle 9.8.0; Rust 1.98.1 lets macOS 27 load rust_api. Android 17 treats the tun subnet as local network, so starting the VPN asks for that permission and falls back to gvisor without it. Signed release builds upload native symbols to Crashlytics.
722 lines
24 KiB
Kotlin
722 lines
24 KiB
Kotlin
package com.follow.clash
|
|
|
|
import com.follow.clash.common.AccessControlMode
|
|
import com.follow.clash.models.SetupParams
|
|
import com.follow.clash.models.SharedState
|
|
import com.follow.clash.service.models.AccessControlProps
|
|
import com.follow.clash.service.models.NotificationParams
|
|
import com.follow.clash.service.models.VpnOptions
|
|
import com.google.gson.Gson
|
|
import kotlinx.coroutines.CoroutineScope
|
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
|
import kotlinx.coroutines.flow.toList
|
|
import kotlinx.coroutines.launch
|
|
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
|
import kotlinx.coroutines.test.runTest
|
|
import org.junit.Assert.assertEquals
|
|
import org.junit.Assert.assertFalse
|
|
import org.junit.Assert.assertTrue
|
|
import org.junit.Test
|
|
|
|
private fun vpnOptions(enable: Boolean = true, stack: String = "gvisor") = VpnOptions(
|
|
enable = enable,
|
|
port = 7890,
|
|
ipv6 = false,
|
|
dnsHijacking = false,
|
|
accessControlProps = AccessControlProps(
|
|
enable = false,
|
|
mode = AccessControlMode.ACCEPT_SELECTED,
|
|
acceptList = emptyList(),
|
|
rejectList = emptyList(),
|
|
),
|
|
allowBypass = false,
|
|
systemProxy = true,
|
|
bypassDomain = emptyList(),
|
|
stack = stack,
|
|
routeAddress = emptyList(),
|
|
)
|
|
|
|
private fun configuredState(enable: Boolean = true, stack: String = "gvisor") = SharedState(
|
|
vpnOptions = vpnOptions(enable, stack),
|
|
setupParams = SetupParams(testUrl = "https://example.com", selectedMap = emptyMap()),
|
|
)
|
|
|
|
private class FakeTile : TileGateway {
|
|
var startCount = 0
|
|
var stopCount = 0
|
|
|
|
override fun handleStart() {
|
|
startCount++
|
|
}
|
|
|
|
override fun handleStop() {
|
|
stopCount++
|
|
}
|
|
}
|
|
|
|
private class FakeApp(
|
|
private val notificationGranted: Boolean = true,
|
|
private val localNetworkGranted: Boolean = true,
|
|
private val vpnGranted: Boolean = true,
|
|
private val holdVpnPreparation: Boolean = false,
|
|
) : AppGateway {
|
|
var beforeVpnPrepared: (() -> Unit)? = null
|
|
var cancelledPreparations = 0
|
|
var localNetworkRequests = 0
|
|
|
|
private var heldCallback: ((Boolean) -> Unit)? = null
|
|
|
|
override fun requestNotificationPermission(callback: (Boolean) -> Unit) =
|
|
callback(notificationGranted)
|
|
|
|
override fun requestLocalNetworkPermission(callback: (Boolean) -> Unit) {
|
|
localNetworkRequests++
|
|
callback(localNetworkGranted)
|
|
}
|
|
|
|
override fun prepareVpn(enable: Boolean, callback: (Boolean) -> Unit) {
|
|
beforeVpnPrepared?.invoke()
|
|
if (holdVpnPreparation) {
|
|
heldCallback = callback
|
|
return
|
|
}
|
|
callback(vpnGranted)
|
|
}
|
|
|
|
override fun cancelVpnPreparation(callback: (Boolean) -> Unit) {
|
|
cancelledPreparations++
|
|
if (heldCallback === callback) {
|
|
heldCallback = null
|
|
}
|
|
}
|
|
}
|
|
|
|
private class FakeHost(override val scope: CoroutineScope) : ServiceStateHost {
|
|
var storedSharedState = configuredState()
|
|
var setupResult: Result<String> = Result.success("")
|
|
var startResult = 1_700_000_000_000L
|
|
var vpnPermissionGranted = true
|
|
var localNetworkPermissionGranted = true
|
|
var vpnServiceActive = true
|
|
var tile: TileGateway? = null
|
|
var app: AppGateway? = null
|
|
var beforeStartService: (() -> Unit)? = null
|
|
var lastStartOptions: VpnOptions? = null
|
|
|
|
override var runTimeMillis = 0L
|
|
override val homeDirPath = "/data/user/0/com.follow.clash/files"
|
|
override val sdkInt = 34
|
|
|
|
val toasts = mutableListOf<String>()
|
|
val logs = mutableListOf<String>()
|
|
val notificationParams = mutableListOf<NotificationParams>()
|
|
val crashlytics = mutableListOf<Boolean>()
|
|
var setupCalls = 0
|
|
var startCalls = 0
|
|
var stopCalls = 0
|
|
var lastInitParams: String? = null
|
|
var lastSetupParams: String? = null
|
|
|
|
override fun log(message: String) {
|
|
logs += message
|
|
}
|
|
|
|
override fun showToast(message: String) {
|
|
toasts += message
|
|
}
|
|
|
|
override fun setCrashlytics(enabled: Boolean) {
|
|
crashlytics += enabled
|
|
}
|
|
|
|
override fun updateNotificationParams(params: NotificationParams) {
|
|
notificationParams += params
|
|
}
|
|
|
|
override fun loadSharedState(): SharedState = storedSharedState
|
|
|
|
override fun isVpnPermissionGranted(): Boolean = vpnPermissionGranted
|
|
|
|
override fun isLocalNetworkPermissionGranted(): Boolean = localNetworkPermissionGranted
|
|
|
|
override fun tile(): TileGateway? = tile
|
|
|
|
override fun app(): AppGateway? = app
|
|
|
|
override suspend fun quickSetup(initParams: String, setupParams: String): Result<String> {
|
|
setupCalls++
|
|
lastInitParams = initParams
|
|
lastSetupParams = setupParams
|
|
return setupResult
|
|
}
|
|
|
|
override suspend fun startService(options: VpnOptions): Long {
|
|
startCalls++
|
|
lastStartOptions = options
|
|
beforeStartService?.invoke()
|
|
runTimeMillis = startResult
|
|
return startResult
|
|
}
|
|
|
|
override suspend fun stopService() {
|
|
stopCalls++
|
|
runTimeMillis = 0L
|
|
}
|
|
|
|
override suspend fun isVpnServiceActive(): Boolean = vpnServiceActive
|
|
}
|
|
|
|
@OptIn(ExperimentalCoroutinesApi::class)
|
|
class ServiceStateMachineTest {
|
|
|
|
@Test
|
|
fun `initParams spells the keys the Go wrapper expects`() {
|
|
val json = ServiceStateMachine.initParams("/files", 34)
|
|
|
|
assertEquals("""{"home-dir":"/files","version":34}""", json)
|
|
}
|
|
|
|
@Test
|
|
fun `notification params come straight off the shared state`() {
|
|
val params = ServiceStateMachine.notificationParams(
|
|
SharedState(
|
|
currentProfileName = "Work",
|
|
stopText = "Disconnect",
|
|
onlyStatisticsProxy = true,
|
|
),
|
|
)
|
|
|
|
assertEquals(NotificationParams("Work", "Disconnect", true), params)
|
|
}
|
|
|
|
@Test
|
|
fun `notification params carry the stop action switch`() {
|
|
val params = ServiceStateMachine.notificationParams(
|
|
SharedState(showStopAction = false),
|
|
)
|
|
|
|
assertEquals(false, params.showStopAction)
|
|
}
|
|
|
|
@Test
|
|
fun `refresh reports STARTED only while the service has a run time`() = runTest {
|
|
val host = FakeHost(backgroundScope)
|
|
val machine = ServiceStateMachine(host)
|
|
|
|
assertEquals(0L, machine.refresh())
|
|
assertEquals(RunState.STOPPED, machine.runState.value)
|
|
|
|
host.runTimeMillis = 42L
|
|
assertEquals(42L, machine.refresh())
|
|
assertEquals(RunState.STARTED, machine.runState.value)
|
|
}
|
|
|
|
@Test
|
|
fun `a start request drives the service to STARTED`() = runTest {
|
|
val host = FakeHost(backgroundScope)
|
|
val machine = ServiceStateMachine(host)
|
|
machine.syncSharedState(configuredState())
|
|
|
|
assertTrue(machine.requestStart().await())
|
|
assertEquals(RunState.STARTED, machine.runState.value)
|
|
assertEquals(1, host.startCalls)
|
|
}
|
|
|
|
@Test
|
|
fun `a start that the service refuses settles back to STOPPED`() = runTest {
|
|
val host = FakeHost(backgroundScope)
|
|
host.startResult = 0L
|
|
val machine = ServiceStateMachine(host)
|
|
machine.syncSharedState(configuredState())
|
|
|
|
assertFalse(machine.requestStart().await())
|
|
assertEquals(RunState.STOPPED, machine.runState.value)
|
|
}
|
|
|
|
@Test
|
|
fun `a start without stored vpn options never reaches the service`() = runTest {
|
|
val host = FakeHost(backgroundScope)
|
|
val machine = ServiceStateMachine(host)
|
|
|
|
assertFalse(machine.requestStart().await())
|
|
assertEquals(0, host.startCalls)
|
|
}
|
|
|
|
@Test
|
|
fun `a denied notification permission cancels the start`() = runTest {
|
|
val host = FakeHost(backgroundScope)
|
|
host.app = FakeApp(notificationGranted = false)
|
|
val machine = ServiceStateMachine(host)
|
|
machine.syncSharedState(configuredState())
|
|
|
|
assertFalse(machine.requestStart().await())
|
|
assertEquals(0, host.startCalls)
|
|
assertEquals(RunState.STOPPED, machine.runState.value)
|
|
}
|
|
|
|
@Test
|
|
fun `a denied vpn permission cancels the start`() = runTest {
|
|
val host = FakeHost(backgroundScope)
|
|
host.app = FakeApp(vpnGranted = false)
|
|
val machine = ServiceStateMachine(host)
|
|
machine.syncSharedState(configuredState())
|
|
|
|
assertFalse(machine.requestStart().await())
|
|
assertEquals(0, host.startCalls)
|
|
}
|
|
|
|
/**
|
|
* Without a foreground app there is nobody to show the system consent dialog, so the machine
|
|
* has to explain the refusal itself.
|
|
*/
|
|
@Test
|
|
fun `a missing vpn permission is reported when no app is attached`() = runTest {
|
|
val host = FakeHost(backgroundScope)
|
|
host.vpnPermissionGranted = false
|
|
val machine = ServiceStateMachine(host)
|
|
machine.syncSharedState(configuredState())
|
|
|
|
assertFalse(machine.requestStart().await())
|
|
assertTrue(host.toasts.contains(VPN_PERMISSION_MESSAGE))
|
|
}
|
|
|
|
@Test
|
|
fun `a proxy-only start does not need the vpn permission`() = runTest {
|
|
val host = FakeHost(backgroundScope)
|
|
host.vpnPermissionGranted = false
|
|
val machine = ServiceStateMachine(host)
|
|
machine.syncSharedState(configuredState(enable = false))
|
|
|
|
assertTrue(machine.requestStart().await())
|
|
assertEquals(1, host.startCalls)
|
|
}
|
|
|
|
/** Android 17 drops the kernel hop system/mixed rely on until the permission is granted. */
|
|
@Test
|
|
fun `a denied local network permission falls back to the gvisor stack`() = runTest {
|
|
val host = FakeHost(backgroundScope)
|
|
host.localNetworkPermissionGranted = false
|
|
val machine = ServiceStateMachine(host)
|
|
machine.syncSharedState(configuredState(stack = "system"))
|
|
|
|
assertTrue(machine.requestStart().await())
|
|
assertEquals("gvisor", host.lastStartOptions?.stack)
|
|
assertTrue(host.toasts.contains(configuredState().localNetworkTip))
|
|
}
|
|
|
|
@Test
|
|
fun `a granted local network permission keeps the chosen stack`() = runTest {
|
|
val host = FakeHost(backgroundScope)
|
|
val machine = ServiceStateMachine(host)
|
|
machine.syncSharedState(configuredState(stack = "mixed"))
|
|
|
|
assertTrue(machine.requestStart().await())
|
|
assertEquals("mixed", host.lastStartOptions?.stack)
|
|
assertFalse(host.toasts.contains(configuredState().localNetworkTip))
|
|
}
|
|
|
|
@Test
|
|
fun `a proxy-only start keeps its stack without the local network permission`() = runTest {
|
|
val host = FakeHost(backgroundScope)
|
|
host.localNetworkPermissionGranted = false
|
|
val machine = ServiceStateMachine(host)
|
|
machine.syncSharedState(configuredState(enable = false, stack = "system"))
|
|
|
|
assertTrue(machine.requestStart().await())
|
|
assertEquals("system", host.lastStartOptions?.stack)
|
|
assertFalse(host.toasts.contains(configuredState().localNetworkTip))
|
|
}
|
|
|
|
@Test
|
|
fun `the local network prompt is asked once and never blocks the start`() = runTest {
|
|
val host = FakeHost(backgroundScope)
|
|
val app = FakeApp(localNetworkGranted = false)
|
|
host.app = app
|
|
host.localNetworkPermissionGranted = false
|
|
val machine = ServiceStateMachine(host)
|
|
machine.syncSharedState(configuredState(stack = "system"))
|
|
|
|
assertTrue(machine.requestStart().await())
|
|
assertEquals(1, app.localNetworkRequests)
|
|
assertEquals(1, host.startCalls)
|
|
assertEquals("gvisor", host.lastStartOptions?.stack)
|
|
}
|
|
|
|
@Test
|
|
fun `a stop request drives the service to STOPPED`() = runTest {
|
|
val host = FakeHost(backgroundScope)
|
|
val machine = ServiceStateMachine(host)
|
|
machine.syncSharedState(configuredState())
|
|
machine.requestStart().await()
|
|
|
|
assertTrue(machine.requestStop().await())
|
|
assertEquals(RunState.STOPPED, machine.runState.value)
|
|
assertEquals(1, host.stopCalls)
|
|
}
|
|
|
|
@Test
|
|
fun `stopping an already stopped service touches nothing`() = runTest {
|
|
val host = FakeHost(backgroundScope)
|
|
val machine = ServiceStateMachine(host)
|
|
|
|
assertTrue(machine.requestStop().await())
|
|
assertEquals(0, host.stopCalls)
|
|
}
|
|
|
|
@Test
|
|
fun `a stop that lands mid-preparation keeps the service stopped`() = runTest {
|
|
val host = FakeHost(backgroundScope)
|
|
val machine = ServiceStateMachine(host)
|
|
machine.syncSharedState(configuredState())
|
|
val app = FakeApp()
|
|
host.app = app
|
|
app.beforeVpnPrepared = { machine.requestStop() }
|
|
|
|
assertFalse(machine.requestStart().await())
|
|
assertEquals(0, host.startCalls)
|
|
assertEquals(RunState.STOPPED, machine.runState.value)
|
|
}
|
|
|
|
@Test
|
|
fun `a start reports failure when a stop overtakes the service call`() = runTest {
|
|
val host = FakeHost(backgroundScope)
|
|
val machine = ServiceStateMachine(host)
|
|
machine.syncSharedState(configuredState())
|
|
host.beforeStartService = { machine.requestStop() }
|
|
|
|
assertFalse(machine.requestStart().await())
|
|
}
|
|
|
|
@Test
|
|
fun `handleServiceLost clears the state for the token that observed the loss`() = runTest {
|
|
val host = FakeHost(backgroundScope)
|
|
val machine = ServiceStateMachine(host)
|
|
machine.syncSharedState(configuredState())
|
|
machine.requestStart().await()
|
|
|
|
val token = machine.captureRequestToken()
|
|
host.runTimeMillis = 0L
|
|
machine.handleServiceLost(token)
|
|
|
|
assertEquals(RunState.STOPPED, machine.runState.value)
|
|
}
|
|
|
|
@Test
|
|
fun `handleServiceLost keeps the intent of a start that raced ahead of it`() = runTest {
|
|
val host = FakeHost(backgroundScope)
|
|
val machine = ServiceStateMachine(host)
|
|
machine.syncSharedState(configuredState())
|
|
val staleToken = machine.captureRequestToken()
|
|
|
|
machine.requestStart().await()
|
|
host.runTimeMillis = 0L
|
|
machine.handleServiceLost(staleToken)
|
|
|
|
assertEquals(RunState.STARTED, machine.runState.value)
|
|
}
|
|
|
|
@Test
|
|
fun `handleServiceLost ignores a service that is running again`() = runTest {
|
|
val host = FakeHost(backgroundScope)
|
|
val machine = ServiceStateMachine(host)
|
|
machine.syncSharedState(configuredState())
|
|
machine.requestStart().await()
|
|
|
|
machine.handleServiceLost(machine.captureRequestToken())
|
|
|
|
assertEquals(RunState.STARTED, machine.runState.value)
|
|
}
|
|
|
|
@Test
|
|
fun `handleStartAction hands the start to the tile when one is attached`() = runTest {
|
|
val host = FakeHost(backgroundScope)
|
|
val tile = FakeTile()
|
|
host.tile = tile
|
|
val machine = ServiceStateMachine(host)
|
|
|
|
machine.handleStartAction()
|
|
|
|
assertEquals(1, tile.startCount)
|
|
assertEquals(0, host.setupCalls)
|
|
}
|
|
|
|
@Test
|
|
fun `handleStartAction is a no-op while a run is already requested`() = runTest {
|
|
val host = FakeHost(backgroundScope)
|
|
val machine = ServiceStateMachine(host)
|
|
machine.syncSharedState(configuredState())
|
|
machine.requestStart().await()
|
|
val startsBefore = host.startCalls
|
|
|
|
machine.handleStartAction()
|
|
|
|
assertEquals(startsBefore, host.startCalls)
|
|
assertEquals(0, host.setupCalls)
|
|
}
|
|
|
|
@Test
|
|
fun `handleStartAction reports a missing configuration`() = runTest {
|
|
val host = FakeHost(backgroundScope)
|
|
host.storedSharedState = SharedState()
|
|
val machine = ServiceStateMachine(host)
|
|
|
|
machine.handleStartAction()
|
|
|
|
assertEquals(listOf(MISSING_CONFIG_MESSAGE), host.toasts)
|
|
assertEquals(0, host.setupCalls)
|
|
}
|
|
|
|
@Test
|
|
fun `handleStartAction loads the stored configuration and sets the core up`() = runTest {
|
|
val host = FakeHost(backgroundScope)
|
|
val machine = ServiceStateMachine(host)
|
|
|
|
machine.handleStartAction()
|
|
|
|
assertEquals(1, host.setupCalls)
|
|
assertEquals(1, host.startCalls)
|
|
assertEquals(RunState.STARTED, machine.runState.value)
|
|
assertEquals(
|
|
ServiceStateMachine.initParams(host.homeDirPath, host.sdkInt),
|
|
host.lastInitParams,
|
|
)
|
|
assertEquals(Gson().toJson(host.storedSharedState.setupParams), host.lastSetupParams)
|
|
}
|
|
|
|
@Test
|
|
fun `a core that rejects the configuration reports its own message`() = runTest {
|
|
val host = FakeHost(backgroundScope)
|
|
host.setupResult = Result.success("proxy group not found")
|
|
val machine = ServiceStateMachine(host)
|
|
|
|
machine.handleStartAction()
|
|
|
|
assertTrue(host.toasts.contains("proxy group not found"))
|
|
assertEquals(0, host.startCalls)
|
|
}
|
|
|
|
@Test
|
|
fun `a core setup that throws without a message falls back to the generic one`() = runTest {
|
|
val host = FakeHost(backgroundScope)
|
|
host.setupResult = Result.failure(RuntimeException(" "))
|
|
val machine = ServiceStateMachine(host)
|
|
|
|
machine.handleStartAction()
|
|
|
|
assertTrue(host.toasts.contains(INVALID_CONFIG_MESSAGE))
|
|
assertEquals(0, host.startCalls)
|
|
}
|
|
|
|
@Test
|
|
fun `a service that refuses the start after a good setup is reported`() = runTest {
|
|
val host = FakeHost(backgroundScope)
|
|
host.startResult = 0L
|
|
val machine = ServiceStateMachine(host)
|
|
|
|
machine.handleStartAction()
|
|
|
|
assertTrue(host.toasts.contains(START_FAILED_MESSAGE))
|
|
}
|
|
|
|
@Test
|
|
fun `handleStopAction hands the stop to the tile when one is attached`() = runTest {
|
|
val host = FakeHost(backgroundScope)
|
|
val machine = ServiceStateMachine(host)
|
|
machine.syncSharedState(configuredState())
|
|
machine.requestStart().await()
|
|
val tile = FakeTile()
|
|
host.tile = tile
|
|
|
|
machine.handleStopAction()
|
|
|
|
assertEquals(1, tile.stopCount)
|
|
assertEquals(0, host.stopCalls)
|
|
}
|
|
|
|
@Test
|
|
fun `handleStopAction is a no-op while nothing is running`() = runTest {
|
|
val host = FakeHost(backgroundScope)
|
|
val machine = ServiceStateMachine(host)
|
|
|
|
machine.handleStopAction()
|
|
|
|
assertEquals(0, host.stopCalls)
|
|
assertTrue(host.toasts.isEmpty())
|
|
}
|
|
|
|
@Test
|
|
fun `handleStopAction announces itself before stopping`() = runTest {
|
|
val host = FakeHost(backgroundScope)
|
|
val machine = ServiceStateMachine(host)
|
|
machine.syncSharedState(configuredState().copy(stopTip = "Stopping..."))
|
|
machine.requestStart().await()
|
|
|
|
machine.handleStopAction()
|
|
|
|
assertEquals(listOf("Stopping..."), host.toasts)
|
|
assertEquals(1, host.stopCalls)
|
|
}
|
|
|
|
@Test
|
|
fun `handleToggleAction starts when stopped and stops when started`() = runTest {
|
|
val host = FakeHost(backgroundScope)
|
|
val machine = ServiceStateMachine(host)
|
|
|
|
machine.handleToggleAction()
|
|
assertEquals(1, host.startCalls)
|
|
|
|
machine.handleToggleAction()
|
|
assertEquals(1, host.stopCalls)
|
|
}
|
|
|
|
@Test
|
|
fun `a revoke is ignored while no vpn service is active`() = runTest {
|
|
val host = FakeHost(backgroundScope)
|
|
host.vpnServiceActive = false
|
|
val machine = ServiceStateMachine(host)
|
|
machine.syncSharedState(configuredState())
|
|
machine.requestStart().await()
|
|
|
|
machine.handleVpnRevokeAction()
|
|
|
|
assertEquals(0, host.stopCalls)
|
|
}
|
|
|
|
@Test
|
|
fun `a revoke stops the active vpn service`() = runTest {
|
|
val host = FakeHost(backgroundScope)
|
|
val machine = ServiceStateMachine(host)
|
|
machine.syncSharedState(configuredState())
|
|
machine.requestStart().await()
|
|
|
|
machine.handleVpnRevokeAction()
|
|
|
|
assertEquals(1, host.stopCalls)
|
|
}
|
|
|
|
@Test
|
|
fun `syncSharedState pushes crashlytics and the notification straight through`() = runTest {
|
|
val host = FakeHost(backgroundScope)
|
|
val machine = ServiceStateMachine(host)
|
|
|
|
machine.syncSharedState(
|
|
SharedState(
|
|
crashlytics = false,
|
|
currentProfileName = "Work",
|
|
stopText = "Disconnect",
|
|
onlyStatisticsProxy = true,
|
|
),
|
|
)
|
|
|
|
assertEquals(listOf(false), host.crashlytics)
|
|
assertEquals(listOf(NotificationParams("Work", "Disconnect", true)), host.notificationParams)
|
|
}
|
|
|
|
@Test
|
|
fun `a start that a stop overtakes never announces STARTED`() = runTest {
|
|
val host = FakeHost(backgroundScope)
|
|
val machine = ServiceStateMachine(host)
|
|
machine.syncSharedState(configuredState())
|
|
val states = mutableListOf<RunState>()
|
|
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
|
|
machine.runState.toList(states)
|
|
}
|
|
host.beforeStartService = { machine.requestStop() }
|
|
|
|
assertFalse(machine.requestStart().await())
|
|
testScheduler.runCurrent()
|
|
|
|
assertFalse(states.contains(RunState.STARTED))
|
|
assertEquals(RunState.STOPPED, machine.runState.value)
|
|
assertEquals(0L, host.runTimeMillis)
|
|
}
|
|
|
|
@Test
|
|
fun `a start rolled back after the service came up stops it again`() = runTest {
|
|
val host = FakeHost(backgroundScope)
|
|
val machine = ServiceStateMachine(host)
|
|
machine.syncSharedState(configuredState())
|
|
host.beforeStartService = {
|
|
host.app = FakeApp(vpnGranted = false)
|
|
machine.requestStart()
|
|
}
|
|
|
|
assertFalse(machine.requestStart().await())
|
|
testScheduler.runCurrent()
|
|
|
|
assertEquals(1, host.stopCalls)
|
|
assertEquals(0L, host.runTimeMillis)
|
|
assertEquals(RunState.STOPPED, machine.runState.value)
|
|
assertFalse(machine.captureRequestToken().running)
|
|
}
|
|
|
|
@Test
|
|
fun `a start that overtakes another inside the binding window adopts the running service`() =
|
|
runTest {
|
|
val host = FakeHost(backgroundScope)
|
|
val machine = ServiceStateMachine(host)
|
|
machine.syncSharedState(configuredState())
|
|
host.beforeStartService = {
|
|
host.beforeStartService = null
|
|
machine.requestStart()
|
|
}
|
|
|
|
machine.requestStart().await()
|
|
testScheduler.runCurrent()
|
|
|
|
assertEquals(1, host.startCalls)
|
|
assertEquals(0, host.stopCalls)
|
|
assertEquals(RunState.STARTED, machine.runState.value)
|
|
assertTrue(machine.captureRequestToken().running)
|
|
}
|
|
|
|
@Test
|
|
fun `a start rebinds when the running service is not the one the options ask for`() = runTest {
|
|
val host = FakeHost(backgroundScope)
|
|
host.vpnServiceActive = false
|
|
val machine = ServiceStateMachine(host)
|
|
machine.syncSharedState(configuredState())
|
|
host.beforeStartService = {
|
|
host.beforeStartService = null
|
|
machine.requestStart()
|
|
}
|
|
|
|
machine.requestStart().await()
|
|
testScheduler.runCurrent()
|
|
|
|
assertEquals(2, host.startCalls)
|
|
assertEquals(RunState.STARTED, machine.runState.value)
|
|
}
|
|
|
|
@Test
|
|
fun `a stop releases a start that is waiting on the vpn consent`() = runTest {
|
|
val host = FakeHost(backgroundScope)
|
|
val app = FakeApp(holdVpnPreparation = true)
|
|
host.app = app
|
|
val machine = ServiceStateMachine(host)
|
|
machine.syncSharedState(configuredState())
|
|
|
|
val start = machine.requestStart()
|
|
testScheduler.runCurrent()
|
|
assertEquals(0, host.startCalls)
|
|
|
|
assertTrue(machine.requestStop().await())
|
|
assertFalse(start.await())
|
|
assertEquals(0, host.startCalls)
|
|
assertEquals(1, app.cancelledPreparations)
|
|
assertEquals(RunState.STOPPED, machine.runState.value)
|
|
}
|
|
|
|
@Test
|
|
fun `a start request that throws is logged and rolled back`() = runTest {
|
|
val host = FakeHost(backgroundScope)
|
|
val machine = ServiceStateMachine(host)
|
|
machine.syncSharedState(configuredState())
|
|
host.beforeStartService = { throw IllegalStateException("binder died") }
|
|
|
|
assertFalse(machine.requestStart().await())
|
|
assertTrue(host.logs.any { it.contains("binder died") })
|
|
assertFalse(machine.captureRequestToken().running)
|
|
}
|
|
}
|