Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ android {
versionName = "0.95"

testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
// Run each instrumented test in its own process with cleared app data/permissions, so that
// permission-state changes in one test can't kill the shared process during another.
testInstrumentationRunnerArguments["clearPackageData"] = "true"
vectorDrawables {
useSupportLibrary = true
}
Expand Down Expand Up @@ -77,6 +80,8 @@ android {
buildConfig = true
}
testOptions {
// Isolate each instrumented test in its own process; pairs with clearPackageData above.
execution = "ANDROIDX_TEST_ORCHESTRATOR"
unitTests {
isIncludeAndroidResources = true // required by Robolectric
isReturnDefaultValues = true // android.* stubs return defaults instead of throwing
Expand Down Expand Up @@ -139,6 +144,10 @@ dependencies {
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.ui.test.junit4)
androidTestImplementation(libs.androidx.test.core)
androidTestImplementation(libs.androidx.test.rules)
androidTestImplementation(libs.androidx.test.uiautomator)
androidTestUtil(libs.androidx.test.orchestrator)
androidTestUtil(libs.androidx.test.services)
debugImplementation(libs.androidx.ui.tooling)
debugImplementation(libs.androidx.ui.test.manifest)
implementation(libs.androidx.hilt.navigation.compose)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,9 @@ import org.junit.Assert.*
class ExampleInstrumentedTest {
@Test
fun useAppContext() {
// Context of the app under test.
// Context of the app under test. Debug builds carry a ".debug" applicationIdSuffix, so match
// the base package name as a prefix rather than exactly.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("org.distrinet.lanshield", appContext.packageName)
assertTrue(appContext.packageName.startsWith("org.distrinet.lanshield"))
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
package org.distrinet.lanshield

import android.content.Context
import android.content.Intent
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.Observer
import androidx.test.core.app.ActivityScenario
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.uiautomator.By
import androidx.test.uiautomator.UiDevice
import androidx.test.uiautomator.Until
import dagger.hilt.android.EntryPointAccessors
import org.distrinet.lanshield.vpnservice.VPNService
import org.junit.After
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertTrue
import org.junit.Assume.assumeTrue
import org.junit.Before
import org.junit.FixMethodOrder
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.MethodSorters
import java.io.FileInputStream
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.regex.Pattern

/**
* End-to-end test of the notification-permission gate on enable (MainActivity.startVPNService): with
* POST_NOTIFICATIONS denied, requesting enable must drive the real system permission dialog, and the
* VPN must start only if the user grants it.
*
* The enable request is posted through the same VPN_SERVICE_ACTION.START_VPN signal the Overview
* switch emits, rather than by tapping the Compose switch — the switch's StateFlow does not propagate
* reliably under the Compose test rule, and the switch->signal wiring is not what this test covers.
* From there everything is real: MainActivity's gate, the system permission dialog (driven by
* UiAutomator), and the resulting VPN service state. VPN consent is pre-granted via the ACTIVATE_VPN
* app-op so the only dialog in play is the notification one. Where the dialog can't be driven the
* test self-skips rather than failing, matching the project's choice to keep dialog automation out
* of mandatory CI.
*/
@RunWith(AndroidJUnit4::class)
@FixMethodOrder(MethodSorters.NAME_ASCENDING)
class NotificationPermissionGateTest {

private val context = ApplicationProvider.getApplicationContext<Context>()
private val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
private val entryPoint =
EntryPointAccessors.fromApplication(context, VpnStatusEntryPoint::class.java)
private val status: MutableLiveData<VPN_SERVICE_STATUS> = entryPoint.vpnServiceStatus()

@Before
fun setUp() {
// The orchestrator's clearPackageData runs each test in a fresh process with app data and
// permissions reset, so POST_NOTIFICATIONS starts denied-but-askable (the dialog appears).
// Pre-authorize the VPN so consent is not the dialog under test.
shell("appops set ${context.packageName} ACTIVATE_VPN allow")
}

@After
fun tearDown() {
try {
// Use startService (not startForegroundService) for STOP, as production does: it carries
// no "must call startForeground" promise, so the stop path won't crash the process.
context.startService(
Intent(context, VPNService::class.java).apply { action = VPNService.STOP_VPN_SERVICE }
)
} catch (_: Exception) {
}
}

@Test
fun test1_enableSucceeds_whenNotificationGrantedViaDialog() {
ActivityScenario.launch(MainActivity::class.java).use {
requestEnable()
assumeTrue(
"Notification permission dialog could not be driven on this image",
clickPermissionDialogButton(allow = true)
)
awaitStatus(VPN_SERVICE_STATUS.ENABLED)
}
}

@Test
fun test2_enableBlocked_whenNotificationDenied() {
ActivityScenario.launch(MainActivity::class.java).use {
requestEnable()
// The dialog must appear (proving the gate ran); deny it.
assumeTrue(
"Notification permission dialog could not be driven on this image",
clickPermissionDialogButton(allow = false)
)
// The VPN must never come up without notification permission.
assertStaysDisabled(seconds = 5)
}
}

/** Posts the same enable signal the Overview switch emits; MainActivity observes it and gates. */
private fun requestEnable() {
runOnMain { entryPoint.vpnServiceActionRequest().value = VPN_SERVICE_ACTION.START_VPN }
}

/** Returns true if a button was found and clicked. */
private fun clickPermissionDialogButton(allow: Boolean): Boolean {
// Match the resource-id by suffix so it works whether the dialog is served by
// com.android.permissioncontroller or com.google.android.permissioncontroller.
val resPattern = if (allow) {
Pattern.compile(".*:id/permission_allow_button")
} else {
Pattern.compile(".*:id/permission_deny_button")
}
var button = device.wait(Until.findObject(By.res(resPattern)), 5_000)
if (button == null) {
// Fallback by label; '.' matches either a straight or curly apostrophe in "Don't".
val text = if (allow) {
Pattern.compile("allow", Pattern.CASE_INSENSITIVE)
} else {
Pattern.compile("(don.?t allow|deny)", Pattern.CASE_INSENSITIVE)
}
button = device.wait(Until.findObject(By.text(text)), 3_000)
}
button?.click()
return button != null
}

private fun awaitStatus(expected: VPN_SERVICE_STATUS, timeoutSeconds: Long = 15) {
val latch = CountDownLatch(1)
val observer = Observer<VPN_SERVICE_STATUS> { if (it == expected) latch.countDown() }
runOnMain { status.observeForever(observer) }
try {
assertTrue(
"VPN status did not reach $expected within ${timeoutSeconds}s (was ${status.value})",
latch.await(timeoutSeconds, TimeUnit.SECONDS)
)
} finally {
runOnMain { status.removeObserver(observer) }
}
}

private fun assertStaysDisabled(seconds: Long) {
val deadline = System.currentTimeMillis() + seconds * 1000
while (System.currentTimeMillis() < deadline) {
assertNotEquals(
"VPN started despite notification permission being denied",
VPN_SERVICE_STATUS.ENABLED,
status.value
)
Thread.sleep(250)
}
}

private fun runOnMain(block: () -> Unit) {
InstrumentationRegistry.getInstrumentation().runOnMainSync(block)
}

private fun shell(command: String) {
val pfd = InstrumentationRegistry.getInstrumentation().uiAutomation
.executeShellCommand(command)
FileInputStream(pfd.fileDescriptor).use { it.readBytes() }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
package org.distrinet.lanshield.vpnservice

import android.content.Context
import android.content.Intent
import android.net.VpnService
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.Observer
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import dagger.hilt.android.EntryPointAccessors
import org.distrinet.lanshield.VPN_SERVICE_STATUS
import org.distrinet.lanshield.VpnStatusEntryPoint
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Assume.assumeTrue
import org.junit.Test
import org.junit.runner.RunWith
import java.io.FileInputStream
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit

/**
* On-device test for [VPNService] start/stop dispatch — the fix that keeps the VPN alive across
* OS-initiated restarts.
*
* Drives the real VpnService through start → restart → stop and asserts the VPN comes up, survives
* an action-less restart (the closest a test can get to the system's null re-delivery after a kill
* or via always-on VPN), and is torn down only on an explicit stop. Establishing a tunnel needs VPN
* consent, which it grants non-interactively via the ACTIVATE_VPN app-op (works on a debuggable
* emulator image). Where that grant is not permitted the test self-skips via [assumeTrue] instead
* of failing, matching the project's choice to keep VPN-consent automation out of mandatory CI.
*/
@RunWith(AndroidJUnit4::class)
class VpnServiceStartCommandTest {

@Test
fun endToEnd_restartReEstablishesVpn() {
val context = ApplicationProvider.getApplicationContext<Context>()
grantVpnConsent(context.packageName)

// If consent could not be granted on this image, there is nothing meaningful to assert.
assumeTrue("VPN consent unavailable on this device/image", VpnService.prepare(context) == null)

val status = EntryPointAccessors
.fromApplication(context, VpnStatusEntryPoint::class.java)
.vpnServiceStatus()

try {
// 1. A fresh start (no action) must bring the VPN up.
context.startForegroundService(Intent(context, VPNService::class.java))
awaitStatus(status, VPN_SERVICE_STATUS.ENABLED)

// 2. Simulate the OS restarting the still-running service with a bare, action-less
// intent (closest a test can get to the system's null re-delivery). It must remain
// ENABLED rather than being torn down.
context.startForegroundService(Intent(context, VPNService::class.java))
Thread.sleep(500)
assertEquals(VPN_SERVICE_STATUS.ENABLED, status.value)

// 3. An explicit stop must actually stop it (and stopSelf so it is not resurrected).
// Use startService for STOP, as production does: startForegroundService would create a
// "must call startForeground" promise that the stop path never fulfills (it stops),
// crashing the process.
context.startService(
Intent(context, VPNService::class.java).apply { action = VPNService.STOP_VPN_SERVICE }
)
awaitStatus(status, VPN_SERVICE_STATUS.DISABLED)
} finally {
context.startService(
Intent(context, VPNService::class.java).apply { action = VPNService.STOP_VPN_SERVICE }
)
}
}

/** Blocks until [status] reaches [expected], asserting it does so within the timeout. */
private fun awaitStatus(
status: MutableLiveData<VPN_SERVICE_STATUS>,
expected: VPN_SERVICE_STATUS,
timeoutSeconds: Long = 10
) {
val latch = CountDownLatch(1)
val observer = object : Observer<VPN_SERVICE_STATUS> {
override fun onChanged(value: VPN_SERVICE_STATUS) {
if (value == expected) latch.countDown()
}
}
val instrumentation = InstrumentationRegistry.getInstrumentation()
instrumentation.runOnMainSync { status.observeForever(observer) }
try {
assertTrue(
"VPN status did not reach $expected within ${timeoutSeconds}s (was ${status.value})",
latch.await(timeoutSeconds, TimeUnit.SECONDS)
)
} finally {
instrumentation.runOnMainSync { status.removeObserver(observer) }
}
}

/**
* Pre-authorizes this package as a VPN by flipping the ACTIVATE_VPN app-op, so
* [VpnService.prepare] returns null and no consent dialog is needed.
*/
private fun grantVpnConsent(packageName: String) {
executeShellCommand("appops set $packageName ACTIVATE_VPN allow")
}

private fun executeShellCommand(command: String) {
val automation = InstrumentationRegistry.getInstrumentation().uiAutomation
val pfd = automation.executeShellCommand(command)
// Drain so the command completes before we return.
FileInputStream(pfd.fileDescriptor).use { it.readBytes() }
}
}
11 changes: 11 additions & 0 deletions app/src/main/java/org/distrinet/lanshield/LANShieldApplication.kt
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import androidx.lifecycle.MutableLiveData
import androidx.work.Configuration
import dagger.Module
import dagger.Provides
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.android.HiltAndroidApp
import dagger.hilt.android.qualifiers.ApplicationContext
Expand Down Expand Up @@ -235,6 +236,16 @@ class LANShieldApplication : Application(), Configuration.Provider {
}


/**
* Exposes the VPN status for testing.
*/
@EntryPoint
@InstallIn(SingletonComponent::class)
interface VpnStatusEntryPoint {
fun vpnServiceStatus(): MutableLiveData<VPN_SERVICE_STATUS>
fun vpnServiceActionRequest(): MutableLiveData<VPN_SERVICE_ACTION>
}

@Module
@InstallIn(SingletonComponent::class)
object AppModule {
Expand Down
Loading
Loading