From 5d8912026f22bf80727732c9e48317b6608f4bae Mon Sep 17 00:00:00 2001 From: Jeroen Robben Date: Tue, 16 Jun 2026 15:30:08 +0200 Subject: [PATCH 1/5] fix: harden packet-forwarding + add tests --- .../vpnservice/VpnServiceObserverLeakTest.kt | 153 ++++++++++++++++++ .../lanshield/vpnservice/IPHeader.kt | 4 + .../lanshield/vpnservice/VPNService.kt | 57 ++++--- .../android/vpn/ClientPacketWriter.java | 19 ++- .../android/vpn/socket/DataConst.java | 5 + .../vpn/socket/SocketChannelReader.java | 23 +++ .../lanshield/vpnservice/IPHeaderTest.kt | 47 ++++++ .../vpn/ClientPacketWriterLargePacketTest.kt | 59 +++++++ .../vpn/UdpLargeDatagramForwardingTest.kt | 79 +++++++++ 9 files changed, 421 insertions(+), 25 deletions(-) create mode 100644 app/src/androidTest/java/org/distrinet/lanshield/vpnservice/VpnServiceObserverLeakTest.kt create mode 100644 app/src/test/java/tech/httptoolkit/android/vpn/ClientPacketWriterLargePacketTest.kt create mode 100644 app/src/test/java/tech/httptoolkit/android/vpn/UdpLargeDatagramForwardingTest.kt diff --git a/app/src/androidTest/java/org/distrinet/lanshield/vpnservice/VpnServiceObserverLeakTest.kt b/app/src/androidTest/java/org/distrinet/lanshield/vpnservice/VpnServiceObserverLeakTest.kt new file mode 100644 index 0000000..c58446b --- /dev/null +++ b/app/src/androidTest/java/org/distrinet/lanshield/vpnservice/VpnServiceObserverLeakTest.kt @@ -0,0 +1,153 @@ +package org.distrinet.lanshield.vpnservice + +import android.app.Service +import android.content.Context +import android.content.Intent +import android.net.VpnService +import androidx.lifecycle.LiveData +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.assertFalse +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 + +/** + * EXPOSES BUG (Finding 2): [VPNService.startVPNThread] registers seven `observeForever` observers but + * [VPNService.stopVPNThread] removes only three. The four it forgets — `allowMulticastLive`, + * `allowDnsLive`, `hideMulticastNotificationsLive`, `hideDnsNotificationsLive` — stay attached after + * the VPN stops, leaking the old [VPNRunnable] on every stop/restart cycle. + * + * The test drives the real service (start → stop) on a device/emulator, grabbing the running + * [VPNService] instance from `ActivityThread.mServices` so it can inspect the private LiveData fields. + * After the stop, every observer added at start should be gone; the four leaked sources still report + * observers, so `stop removes every observer it added` FAILS until the leak is fixed. + * + * Like [VpnServiceStartCommandTest], it self-skips where VPN consent cannot be granted, keeping + * consent automation out of mandatory CI. + */ +@RunWith(AndroidJUnit4::class) +class VpnServiceObserverLeakTest { + + /** All seven LiveData fields touched by start; stop must clear observers from every one. */ + private val observedFields = listOf( + // Correctly removed today (control — these should stay green). + "accessPolicies", + "defaultForwardPolicyLive", + "systemAppsForwardPolicyLive", + // Leaked today (these expose the bug). + "allowMulticastLive", + "allowDnsLive", + "hideMulticastNotificationsLive", + "hideDnsNotificationsLive", + ) + + @Test + fun `stop removes every observer it added`() { + val context = ApplicationProvider.getApplicationContext() + grantVpnConsent(context.packageName) + assumeTrue("VPN consent unavailable on this device/image", VpnService.prepare(context) == null) + + val status = EntryPointAccessors + .fromApplication(context, VpnStatusEntryPoint::class.java) + .vpnServiceStatus() + + var service: VPNService? = null + try { + // 1. Start the VPN and wait until it is up, then capture the live service instance. + context.startForegroundService(Intent(context, VPNService::class.java)) + awaitStatus(status, VPN_SERVICE_STATUS.ENABLED) + service = runOnMain { findRunningVpnService() } + assumeTrue("Could not locate the running VPNService instance", service != null) + + // 2. Stop it. stopVPNThread() should now remove every observer it registered at start. + context.startService( + Intent(context, VPNService::class.java).apply { action = VPNService.STOP_VPN_SERVICE } + ) + awaitStatus(status, VPN_SERVICE_STATUS.DISABLED) + + // 3. No LiveData touched at start may still have observers after the stop. + val stillObserved = runOnMain { + observedFields.filter { liveDataField(service!!, it).hasObservers() } + } + assertFalse( + "VPNService leaked observers after stop on: $stillObserved " + + "(stopVPNThread removes only 3 of the 7 observers added by startVPNThread)", + stillObserved.isNotEmpty() + ) + } finally { + context.startService( + Intent(context, VPNService::class.java).apply { action = VPNService.STOP_VPN_SERVICE } + ) + } + } + + private fun liveDataField(service: VPNService, name: String): LiveData<*> { + val field = VPNService::class.java.getDeclaredField(name).apply { isAccessible = true } + return field.get(service) as LiveData<*> + } + + /** Find the VPNService instance the framework is currently running, in this process. */ + private fun findRunningVpnService(): VPNService? { + val activityThreadClass = Class.forName("android.app.ActivityThread") + val activityThread = activityThreadClass.getMethod("currentActivityThread").invoke(null) + val servicesField = activityThreadClass.getDeclaredField("mServices").apply { isAccessible = true } + @Suppress("UNCHECKED_CAST") + val services = servicesField.get(activityThread) as Map<*, Service> + return services.values.filterIsInstance().firstOrNull() + } + + private fun runOnMain(block: () -> T): T { + var result: T? = null + var error: Throwable? = null + InstrumentationRegistry.getInstrumentation().runOnMainSync { + try { + result = block() + } catch (t: Throwable) { + error = t + } + } + error?.let { throw it } + @Suppress("UNCHECKED_CAST") + return result as T + } + + private fun awaitStatus( + status: MutableLiveData, + expected: VPN_SERVICE_STATUS, + timeoutSeconds: Long = 10 + ) { + val latch = CountDownLatch(1) + val observer = Observer { if (it == 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) } + } + } + + 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) + FileInputStream(pfd.fileDescriptor).use { it.readBytes() } + } +} diff --git a/app/src/main/java/org/distrinet/lanshield/vpnservice/IPHeader.kt b/app/src/main/java/org/distrinet/lanshield/vpnservice/IPHeader.kt index 94ec71a..45ddf4e 100644 --- a/app/src/main/java/org/distrinet/lanshield/vpnservice/IPHeader.kt +++ b/app/src/main/java/org/distrinet/lanshield/vpnservice/IPHeader.kt @@ -89,6 +89,10 @@ class IPHeader(packetBuffer: ByteBuffer) { } else { 40 } + // The TCP data-offset byte lives at ipHeaderLength + 12; the size checks above + // (>= 24 / >= 44) don't guarantee it, so reject truncated/crafted TCP packets with a + // controlled IllegalArgumentException instead of an IndexOutOfBoundsException. + require(rawPacket.limit() >= ipHeaderLength + 13) { "Truncated TCP packet" } val dataOffsetAndNs: Int = rawPacket.get(ipHeaderLength + 12).toInt() val tcpHeaderLength = dataOffsetAndNs.and(0xF0).shr(4) * 4 diff --git a/app/src/main/java/org/distrinet/lanshield/vpnservice/VPNService.kt b/app/src/main/java/org/distrinet/lanshield/vpnservice/VPNService.kt index b0698ef..24ea89f 100644 --- a/app/src/main/java/org/distrinet/lanshield/vpnservice/VPNService.kt +++ b/app/src/main/java/org/distrinet/lanshield/vpnservice/VPNService.kt @@ -92,35 +92,40 @@ class VPNService : VpnService(), IProtectSocket { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - defaultForwardPolicyLive = dataStore.data.map { - Policy.valueOf( - it[DEFAULT_POLICY_KEY] ?: Policy.DEFAULT.toString() - ) - }.distinctUntilChanged().asLiveData() - systemAppsForwardPolicyLive = dataStore.data.map { - Policy.valueOf( - it[SYSTEM_APPS_POLICY_KEY] ?: Policy.DEFAULT.toString() - ) - }.distinctUntilChanged().asLiveData() - - allowMulticastLive = dataStore.data.map { - it[ALLOW_MULTICAST] ?: false - }.distinctUntilChanged().asLiveData() + // Initialize once and keep the same LiveData instances for the service's lifetime. onStartCommand + // runs again on every restart (incl. the STOP path), and re-creating these would leave the + // observers added by startVPNThread() attached to orphaned instances that stopVPNThread() can no + // longer remove — leaking the VPNRunnable. + if (!this::defaultForwardPolicyLive.isInitialized) { + defaultForwardPolicyLive = dataStore.data.map { + Policy.valueOf( + it[DEFAULT_POLICY_KEY] ?: Policy.DEFAULT.toString() + ) + }.distinctUntilChanged().asLiveData() + systemAppsForwardPolicyLive = dataStore.data.map { + Policy.valueOf( + it[SYSTEM_APPS_POLICY_KEY] ?: Policy.DEFAULT.toString() + ) + }.distinctUntilChanged().asLiveData() - allowDnsLive = dataStore.data.map { - it[ALLOW_DNS] ?: false - }.distinctUntilChanged().asLiveData() + allowMulticastLive = dataStore.data.map { + it[ALLOW_MULTICAST] ?: false + }.distinctUntilChanged().asLiveData() - hideMulticastNotificationsLive = dataStore.data.map { - it[HIDE_MULTICAST_NOTIFICATIONS] ?: false - }.distinctUntilChanged().asLiveData() + allowDnsLive = dataStore.data.map { + it[ALLOW_DNS] ?: false + }.distinctUntilChanged().asLiveData() - hideDnsNotificationsLive = dataStore.data.map { - it[HIDE_DNS_NOTIFICATIONS] ?: false - }.distinctUntilChanged().asLiveData() + hideMulticastNotificationsLive = dataStore.data.map { + it[HIDE_MULTICAST_NOTIFICATIONS] ?: false + }.distinctUntilChanged().asLiveData() + hideDnsNotificationsLive = dataStore.data.map { + it[HIDE_DNS_NOTIFICATIONS] ?: false + }.distinctUntilChanged().asLiveData() - accessPolicies = lanAccessPolicyDao.getAllLive() + accessPolicies = lanAccessPolicyDao.getAllLive() + } updateAlwaysOnStatus() @@ -215,6 +220,10 @@ class VPNService : VpnService(), IProtectSocket { accessPolicies.removeObserver(it.accessPoliesObserver) defaultForwardPolicyLive.removeObserver(it.defaultPolicyObserver) systemAppsForwardPolicyLive.removeObserver(it.systemAppsPolicyObserver) + allowMulticastLive.removeObserver(it.allowMulticastObserver) + allowDnsLive.removeObserver(it.allowDnsObserver) + hideMulticastNotificationsLive.removeObserver(it.hideMulticastNotificationsObserver) + hideDnsNotificationsLive.removeObserver(it.hideDnsNotificationsObserver) it.stop() } vpnRunnable = null diff --git a/app/src/main/java/tech/httptoolkit/android/vpn/ClientPacketWriter.java b/app/src/main/java/tech/httptoolkit/android/vpn/ClientPacketWriter.java index d664487..49dc7d8 100644 --- a/app/src/main/java/tech/httptoolkit/android/vpn/ClientPacketWriter.java +++ b/app/src/main/java/tech/httptoolkit/android/vpn/ClientPacketWriter.java @@ -30,6 +30,7 @@ import java.util.concurrent.LinkedBlockingQueue; import tech.httptoolkit.android.TagKt; +import org.distrinet.lanshield.crashreport.CrashReporterKt; /** * write packet data back to VPN client stream. This class is thread safe. @@ -42,7 +43,13 @@ public class ClientPacketWriter implements Runnable { private final FileOutputStream clientWriter; + // Defensive upper bound on a single packet to the TUN. Real traffic is well under the MTU + // (TCP segments are capped at the MSS, oversized UDP is dropped upstream in SocketChannelReader), + // so this should never trigger; if it does it's a logic error we drop rather than crash on. + private static final int MAX_WRITE_PACKET_SIZE = 30000; + private volatile boolean shutdown = false; + private volatile boolean alreadyReportedOversize = false; private final BlockingDeque packetQueue = new LinkedBlockingDeque<>(); public ClientPacketWriter(FileOutputStream clientWriter) { @@ -50,7 +57,17 @@ public ClientPacketWriter(FileOutputStream clientWriter) { } public void write(byte[] data) { - if (data.length > 30000) throw new Error("Packet too large"); + if (data.length > MAX_WRITE_PACKET_SIZE) { + // Drop instead of throwing: this runs on the NIO thread, and an uncaught Error here + // would tear down the whole forwarding engine. + Log.w(TAG, "Dropping oversized packet (" + data.length + " bytes)"); + if (!alreadyReportedOversize) { + alreadyReportedOversize = true; + CrashReporterKt.getCrashReporter().recordException( + new RuntimeException("Dropped oversized packet to TUN: " + data.length + " bytes")); + } + return; + } packetQueue.addLast(data); } diff --git a/app/src/main/java/tech/httptoolkit/android/vpn/socket/DataConst.java b/app/src/main/java/tech/httptoolkit/android/vpn/socket/DataConst.java index 22813d7..decd163 100644 --- a/app/src/main/java/tech/httptoolkit/android/vpn/socket/DataConst.java +++ b/app/src/main/java/tech/httptoolkit/android/vpn/socket/DataConst.java @@ -21,4 +21,9 @@ */ public class DataConst { public static final int MAX_RECEIVE_BUFFER_SIZE = 65535; + + // The TUN interface MTU; mirrors VPNService.setMtu(MAX_PACKET_LEN). Packets emitted to the + // client must not exceed this, so oversized (e.g. large UDP) responses are dropped rather than + // forwarded as an un-deliverable jumbo packet. + public static final int MTU = 1500; } diff --git a/app/src/main/java/tech/httptoolkit/android/vpn/socket/SocketChannelReader.java b/app/src/main/java/tech/httptoolkit/android/vpn/socket/SocketChannelReader.java index 818cbd8..bea8b95 100644 --- a/app/src/main/java/tech/httptoolkit/android/vpn/socket/SocketChannelReader.java +++ b/app/src/main/java/tech/httptoolkit/android/vpn/socket/SocketChannelReader.java @@ -21,6 +21,7 @@ import java.nio.channels.spi.AbstractSelectableChannel; import tech.httptoolkit.android.TagKt; +import org.distrinet.lanshield.crashreport.CrashReporterKt; /** * Takes a session, and reads all available upstream data back into it. @@ -33,10 +34,22 @@ class SocketChannelReader { private final ClientPacketWriter writer; + // Report an oversized-UDP drop to Crashlytics at most once per reader, so a misbehaving peer + // flooding jumbo datagrams can't flood the crash reporter (each drop is still Log.w'd). + private volatile boolean alreadyReportedOversize = false; + public SocketChannelReader(ClientPacketWriter writer) { this.writer = writer; } + private void reportOversizeOnce(int packetLength) { + if (alreadyReportedOversize) return; + alreadyReportedOversize = true; + CrashReporterKt.getCrashReporter().recordException( + new RuntimeException("Dropped oversized UDP response packet: " + packetLength + + " bytes (MTU " + DataConst.MTU + ")")); + } + // When staged-but-unsent upstream bytes reach this, stop reading so the upstream TCP window // closes and the sender backs off, instead of pulling a multi-GB download into memory. private static final int STAGING_CAP = 2 * DataConst.MAX_RECEIVE_BUFFER_SIZE; @@ -227,6 +240,16 @@ private long readUDP(Session session){ byte[] packetData = UDPPacketFactory.createResponsePacket( session.getLastIpHeader(), session.getLastUdpHeader(), data); + if (packetData.length > DataConst.MTU) { + // Can't be delivered over the TUN (MTU 1500) without IP fragmentation, which + // the engine doesn't do. Drop it rather than hand the writer a jumbo packet. + Log.w(TAG, "Dropping oversized UDP response (" + packetData.length + + " bytes > MTU " + DataConst.MTU + ")"); + reportOversizeOnce(packetData.length); + buffer.clear(); + continue; + } + //write to client writer.write(packetData); diff --git a/app/src/test/java/org/distrinet/lanshield/vpnservice/IPHeaderTest.kt b/app/src/test/java/org/distrinet/lanshield/vpnservice/IPHeaderTest.kt index c0c1813..c38f49f 100644 --- a/app/src/test/java/org/distrinet/lanshield/vpnservice/IPHeaderTest.kt +++ b/app/src/test/java/org/distrinet/lanshield/vpnservice/IPHeaderTest.kt @@ -103,4 +103,51 @@ class IPHeaderTest { val bytes = IntArray(24) { 0 }.also { it[0] = 0x70 } IPHeader(buffer(*bytes)) } + + /** + * EXPOSES BUG (Finding 3): the constructor only requires `limit() >= 24` for IPv4, but the TCP + * branch then reads `rawPacket.get(ipHeaderLength + 12)` (IPHeader.kt:92) — absolute index 32 for + * a standard IHL=5 header. A truncated/crafted TCP packet of 24–32 bytes passes the size check yet + * reads out of bounds, throwing IndexOutOfBoundsException instead of the controlled + * IllegalArgumentException the parser raises for malformed input. + * + * This 28-byte packet passes `require(limit >= 24)` (ports live at offsets 20–23) but has no byte + * at index 32. Expected once fixed: a controlled IllegalArgumentException (consistent with the + * other too-short cases). Until then this FAILS with IndexOutOfBoundsException. + */ + @Test(expected = IllegalArgumentException::class) + fun `truncated ipv4 tcp packet does not read out of bounds`() { + IPHeader( + buffer( + 0x45, 0x00, 0x00, 0x1C, // IHL=5, total length = 28 + 0x00, 0x00, 0x00, 0x00, + 0x40, 0x06, 0x00, 0x00, // protocol = 6 (TCP) + 0xC0, 0xA8, 0x01, 0x02, // src 192.168.1.2 + 0x01, 0x01, 0x01, 0x01, // dst 1.1.1.1 + 0x9C, 0x40, 0x01, 0xBB, // src port 40000, dst port 443 (offsets 20-23) + 0x00, 0x00, 0x00, 0x00, // partial TCP header (seq) — buffer ends at index 27 + ) + ) + } + + /** + * EXPOSES BUG (Finding 3), IPv6 variant: the constructor requires `limit() >= 44`, but the TCP + * branch reads `rawPacket.get(40 + 12)` = absolute index 52. This 48-byte packet passes the size + * check (ports at offsets 40-43) yet has no byte at index 52. + */ + @Test(expected = IllegalArgumentException::class) + fun `truncated ipv6 tcp packet does not read out of bounds`() { + IPHeader( + buffer( + 0x60, 0x00, 0x00, 0x00, // version 6 + 0x00, 0x14, 0x06, 0x40, // payload length = 20, next header = 6 (TCP), hop limit + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, // src ::1 + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, // dst ::2 + 0x9C, 0x40, 0x01, 0xBB, // src port 40000, dst port 443 (offsets 40-43) + 0x00, 0x00, 0x00, 0x00, // partial TCP header — buffer ends at index 47 + ) + ) + } } diff --git a/app/src/test/java/tech/httptoolkit/android/vpn/ClientPacketWriterLargePacketTest.kt b/app/src/test/java/tech/httptoolkit/android/vpn/ClientPacketWriterLargePacketTest.kt new file mode 100644 index 0000000..976f674 --- /dev/null +++ b/app/src/test/java/tech/httptoolkit/android/vpn/ClientPacketWriterLargePacketTest.kt @@ -0,0 +1,59 @@ +package tech.httptoolkit.android.vpn + +import org.junit.After +import org.junit.Assert.fail +import org.junit.Before +import org.junit.Test +import java.io.File +import java.io.FileOutputStream + +/** + * EXPOSES BUG (Finding 1): [ClientPacketWriter.write] throws a raw [Error] for any packet larger + * than 30000 bytes (ClientPacketWriter.java:53). Because the writer is driven from the NIO thread + * — which catches only IOException/channel exceptions — a single oversized packet (e.g. a UDP reply + * up to MAX_RECEIVE_BUFFER_SIZE = 65535 bytes, see DataConst.java) propagates an uncaught Error that + * kills the forwarding engine. + * + * This is a pure-JVM reproducer with no threads: write() only enqueues, so the size guard fires (or + * doesn't) synchronously on the calling thread. + * + * Expected once fixed: oversized packets are handled gracefully (dropped/split/logged), never throw. + * Until then `writes a 30001-byte packet without crashing` FAILS. + */ +class ClientPacketWriterLargePacketTest { + + private lateinit var tempFile: File + private lateinit var writer: ClientPacketWriter + + @Before + fun setUp() { + tempFile = File.createTempFile("tun", ".bin").apply { deleteOnExit() } + writer = ClientPacketWriter(FileOutputStream(tempFile)) + } + + @After + fun tearDown() { + writer.shutdown() + tempFile.delete() + } + + @Test + fun `writes a 30000-byte packet without crashing`() { + // Control: the boundary value is accepted today (guard is strictly > 30000). + writer.write(ByteArray(30000)) + } + + @Test + fun `writes a 30001-byte packet without crashing`() { + // EXPOSES BUG: currently throws java.lang.Error("Packet too large"). A large UDP reply must + // not be able to crash the writer / NIO thread. + try { + writer.write(ByteArray(30001)) + } catch (e: Throwable) { + fail( + "write() threw $e for a 30001-byte packet; oversized packets should be handled " + + "gracefully, not crash the writer (and thereby the NIO forwarding thread)." + ) + } + } +} diff --git a/app/src/test/java/tech/httptoolkit/android/vpn/UdpLargeDatagramForwardingTest.kt b/app/src/test/java/tech/httptoolkit/android/vpn/UdpLargeDatagramForwardingTest.kt new file mode 100644 index 0000000..2266080 --- /dev/null +++ b/app/src/test/java/tech/httptoolkit/android/vpn/UdpLargeDatagramForwardingTest.kt @@ -0,0 +1,79 @@ +package tech.httptoolkit.android.vpn + +import android.app.Application +import com.google.common.truth.Truth.assertThat +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import tech.httptoolkit.android.vpn.socket.DataConst +import java.net.DatagramPacket +import java.net.DatagramSocket +import java.net.InetAddress + +/** + * Finding 1 (fixed): a UDP peer reply larger than the TUN MTU cannot be delivered as a single packet + * and used to crash the NIO thread (ClientPacketWriter threw an Error on >30000-byte packets). The fix + * drops oversized UDP responses in [SocketChannelReader.readUDP] (reporting once to Crashlytics) and + * never crashes the engine. + * + * This test feeds a flow, has the peer send a >MTU reply (must be dropped — never reaches the TUN) and + * then a small reply (must still be forwarded), proving the engine dropped the big one and stayed alive. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class) +class UdpLargeDatagramForwardingTest { + + private lateinit var harness: ForwardingTestHarness + private lateinit var peer: DatagramSocket + private var peerPort = 0 + + private val clientIp = "10.0.0.2" + private val clientPort = 50001 + private val peerIp = "127.0.0.1" + + @Before + fun setUp() { + harness = ForwardingTestHarness() + peer = DatagramSocket(0, InetAddress.getByName("127.0.0.1")).apply { soTimeout = 3000 } + peerPort = peer.localPort + } + + @After + fun tearDown() { + peer.close() + harness.close() + } + + @Test + fun `oversized udp reply is dropped and the engine stays alive`() { + // Kick off the flow so the engine has a UDP session and the peer learns the return address. + harness.feed(TestPackets.udpPacket(clientIp, clientPort, peerIp, peerPort, "hi".toByteArray())) + + val received = DatagramPacket(ByteArray(64), 64) + peer.receive(received) + + // (a) Peer replies with a > MTU datagram: it must be dropped, never forwarded to the TUN. + val bigPayload = ByteArray(40000) { 'x'.code.toByte() } + peer.send(DatagramPacket(bigPayload, bigPayload.size, received.socketAddress)) + + // (b) Peer then replies with a small datagram: the engine must still be alive and forward it. + peer.send(DatagramPacket("ok".toByteArray(), 2, received.socketAddress)) + + val small = harness.awaitTunPacketMatching { packet -> + val (_, udp, payload) = harness.parseUdp(packet) + udp.destinationPort == clientPort && String(payload) == "ok" + } + // The delivered packet is the small reply, and it is well under the MTU (the big one was dropped). + assertThat(small.size).isLessThan(DataConst.MTU) + + // Nothing oversized was emitted to the TUN. + var leftover = harness.pollTunPacket(200) + while (leftover != null) { + assertThat(leftover.size).isAtMost(DataConst.MTU) + leftover = harness.pollTunPacket(200) + } + } +} From 075cb8517a8c6e1071620169a5248b44c89a7242 Mon Sep 17 00:00:00 2001 From: Jeroen Robben Date: Tue, 16 Jun 2026 20:36:35 +0200 Subject: [PATCH 2/5] improve onboarding carousel --- .../ui/intro/ShareLanMetricsSlide.kt | 268 +++----- .../distrinet/lanshield/ui/LANShieldIcons.kt | 8 + .../distrinet/lanshield/ui/intro/IntroHero.kt | 318 ++++++++++ .../lanshield/ui/intro/IntroScreen.kt | 580 ++++++++++-------- .../lanshield/ui/intro/OnboardingSlide.kt | 189 ++++++ app/src/main/res/values/strings.xml | 17 +- .../ui/intro/ShareLanMetricsSlide.kt | 203 +++--- 7 files changed, 1011 insertions(+), 572 deletions(-) create mode 100644 app/src/main/java/org/distrinet/lanshield/ui/intro/IntroHero.kt create mode 100644 app/src/main/java/org/distrinet/lanshield/ui/intro/OnboardingSlide.kt diff --git a/app/src/foss/kotlin/org/distrinet/lanshield/ui/intro/ShareLanMetricsSlide.kt b/app/src/foss/kotlin/org/distrinet/lanshield/ui/intro/ShareLanMetricsSlide.kt index 753df1d..9e8b62c 100644 --- a/app/src/foss/kotlin/org/distrinet/lanshield/ui/intro/ShareLanMetricsSlide.kt +++ b/app/src/foss/kotlin/org/distrinet/lanshield/ui/intro/ShareLanMetricsSlide.kt @@ -1,150 +1,45 @@ package org.distrinet.lanshield.ui.intro import androidx.activity.compose.ManagedActivityResultLauncher -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.pager.PagerState -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Button import androidx.compose.material3.Card -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Switch -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp import kotlinx.coroutines.CoroutineScope import org.distrinet.lanshield.R -import org.distrinet.lanshield.STUDY_MORE_INFO_URL +import org.distrinet.lanshield.PRIVACY_POLICY_URL import org.distrinet.lanshield.ui.LANShieldIcons -import org.distrinet.lanshield.ui.intro.IntroSlides.INTRO_FINISHED -import org.distrinet.lanshield.ui.intro.IntroSlides.JOIN_USER_STUDY -import org.distrinet.lanshield.ui.intro.IntroSlides.NOTIFICATIONS -import org.distrinet.lanshield.ui.theme.LANShieldTheme +import org.distrinet.lanshield.ui.settings.SettingsSwitchComp @Composable fun ShareLanMetricsSlide( + page: Int, + pagerState: PagerState, onChangeShareLanMetrics: (Boolean) -> Unit, isShareLanMetricsEnabled: Boolean ) { - var showMoreInfoDialog by remember { mutableStateOf(false) } - - if (showMoreInfoDialog) { - AlertDialog( - icon = { - Icon( - LANShieldIcons.Info, - contentDescription = stringResource(id = R.string.info), - tint = getIconTint() - ) - }, - title = { Text(text = stringResource(id = R.string.more_info)) }, - text = { - Text(text = stringResource(R.string.intro_share_lan_metrics_more_info)) //TODO - }, - onDismissRequest = { showMoreInfoDialog = false }, - dismissButton = { - TextButton( - onClick = { showMoreInfoDialog = false } - ) { - Text(text = stringResource(id = R.string.privacy_policy)) - } - }, - confirmButton = { - TextButton( - onClick = { showMoreInfoDialog = false } - ) { - Text(text = stringResource(id = R.string.dismiss)) - } - }, - ) - } - - Column() { - Text( - text = stringResource(R.string.join_our_academic_study), - style = MaterialTheme.typography.titleLarge, - textAlign = TextAlign.Center, - modifier = Modifier - .align(Alignment.CenterHorizontally) - .padding(16.dp) - ) - Spacer(modifier = Modifier.size(40.dp)) - Icon( - imageVector = LANShieldIcons.Science, contentDescription = null, modifier = Modifier - .align(Alignment.CenterHorizontally) - .size(125.dp), - tint = getIconTint() - ) - Spacer(modifier = Modifier.size(40.dp)) - Text( - text = stringResource(R.string.intro_share_lan_metrics).trimIndent(), - style = MaterialTheme.typography.bodyLarge, - modifier = Modifier - .padding(24.dp) - .fillMaxWidth() - ) - - val uriHandler = LocalUriHandler.current - - Card( - modifier = Modifier - .padding(4.dp) - .align(Alignment.CenterHorizontally), - onClick = { onChangeShareLanMetrics(!isShareLanMetricsEnabled) }, - ) { - Column { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - text = "Join user study", - modifier = Modifier.padding(32.dp), - style = MaterialTheme.typography.bodyMedium, - textAlign = TextAlign.Start, - ) - } - Spacer(modifier = Modifier.padding(8.dp)) - Switch( - checked = isShareLanMetricsEnabled, - onCheckedChange = onChangeShareLanMetrics, - modifier = Modifier.padding(end = 16.dp), - thumbContent = { -// if (icon != null) { -// Icon(imageVector = icon, contentDescription = null) -// } - }) - } - } - } - Spacer(modifier = Modifier.weight(1f)) - Button( - onClick = { uriHandler.openUri(STUDY_MORE_INFO_URL) }, modifier = Modifier.align( - Alignment.CenterHorizontally + val uriHandler = LocalUriHandler.current + OnboardingSlide( + page = page, + pagerState = pagerState, + title = stringResource(R.string.join_our_academic_study), + icon = LANShieldIcons.Science, + body = stringResource(R.string.intro_share_lan_metrics).trimIndent(), + ) { + Card(modifier = Modifier.fillMaxWidth()) { + SettingsSwitchComp( + name = R.string.join_user_study, + isChecked = isShareLanMetricsEnabled, + onCheckedChange = onChangeShareLanMetrics, ) - ) { - Text(text = stringResource(id = R.string.more_info)) } - + OnboardingTextButton( + text = stringResource(id = R.string.more_info), + onClick = { uriHandler.openUri(PRIVACY_POLICY_URL) }, + ) } } @@ -156,25 +51,22 @@ fun IntroLeftButton( onChangeShareLanMetrics: (Boolean) -> Unit, isShareLanMetricsEnabled: Boolean ) { - - if (pagerState.currentPage == INTRO_FINISHED.ordinal && !isShareLanMetricsEnabled) { - IconButton( - onClick = { scrollToPage(pagerState, JOIN_USER_STUDY.ordinal, coroutineScope) }, - modifier = modifier - ) { - Icon( - imageVector = LANShieldIcons.ChevronLeft, - contentDescription = stringResource(R.string.previous) + when { + pagerState.currentPage == IntroSlides.INTRO_FINISHED.ordinal && !isShareLanMetricsEnabled -> { + OnboardingTextButton( + text = stringResource(R.string.back), + onClick = { + scrollToPage(pagerState, IntroSlides.JOIN_USER_STUDY.ordinal, coroutineScope) + }, + modifier = modifier, ) } - } else if (pagerState.currentPage != 0) { - IconButton( - onClick = { scrollToPreviousPage(pagerState, coroutineScope) }, - modifier = modifier - ) { - Icon( - imageVector = LANShieldIcons.ChevronLeft, - contentDescription = stringResource(R.string.previous) + + pagerState.currentPage != 0 -> { + OnboardingTextButton( + text = stringResource(R.string.back), + onClick = { scrollToPreviousPage(pagerState, coroutineScope) }, + modifier = modifier, ) } } @@ -189,65 +81,51 @@ fun IntroRightButton( isShareLanMetricsEnabled: Boolean, onChangeFinishAppIntro: (Boolean) -> Unit, navigateToOverview: () -> Unit, - requestNotificationPermissionLauncher: ManagedActivityResultLauncher + requestNotificationPermissionLauncher: ManagedActivityResultLauncher, + notificationsEnabled: Boolean, ) { - - if (pagerState.currentPage == IntroSlides.JOIN_USER_STUDY.ordinal) { - IconButton( - onClick = { - doShareLanMetricsDecision( - isShareLanMetricsEnabled, - onChangeShareLanMetrics, - coroutineScope, - pagerState - ) - }, - modifier = modifier - ) { - Icon( - imageVector = LANShieldIcons.ChevronRight, contentDescription = stringResource( - R.string.next - ) + when (pagerState.currentPage) { + IntroSlides.JOIN_USER_STUDY.ordinal -> { + OnboardingPrimaryButton( + text = stringResource(R.string.continue_label), + onClick = { + doShareLanMetricsDecision( + isShareLanMetricsEnabled, + onChangeShareLanMetrics, + coroutineScope, + pagerState + ) + }, + modifier = modifier, ) } - } else if (pagerState.currentPage == NOTIFICATIONS.ordinal) { - IconButton( - onClick = { requestNotificationPermissionLauncher.launch("android.permission.POST_NOTIFICATIONS") }, - modifier = modifier - ) { - Icon( - imageVector = LANShieldIcons.ChevronRight, contentDescription = stringResource( - R.string.next - ) + + IntroSlides.NOTIFICATIONS.ordinal -> { + OnboardingPrimaryButton( + text = stringResource(R.string.continue_label), + onClick = { scrollToNextPage(pagerState, coroutineScope) }, + enabled = notificationsEnabled, + modifier = modifier, ) } - } else if (pagerState.currentPage == INTRO_FINISHED.ordinal) { - TextButton(onClick = { - onChangeFinishAppIntro(true) - navigateToOverview() - }, modifier = modifier) { - Text(text = stringResource(R.string.finish), style = MaterialTheme.typography.bodyLarge) + + IntroSlides.INTRO_FINISHED.ordinal -> { + OnboardingPrimaryButton( + text = stringResource(R.string.finish), + onClick = { + onChangeFinishAppIntro(true) + navigateToOverview() + }, + modifier = modifier, + ) } - } else if (pagerState.currentPage != INTRO_FINISHED.ordinal) { - IconButton( - onClick = { scrollToNextPage(pagerState, coroutineScope) }, - modifier = modifier - ) { - Icon( - imageVector = LANShieldIcons.ChevronRight, contentDescription = stringResource( - R.string.next - ) + + else -> { + OnboardingPrimaryButton( + text = stringResource(R.string.continue_label), + onClick = { scrollToNextPage(pagerState, coroutineScope) }, + modifier = modifier, ) } } } - -@Preview -@Composable -fun ShareLanMetricsSlideFossPreview() { - LANShieldTheme(darkTheme = false) { - IntroScreen( - initialPage = JOIN_USER_STUDY.ordinal, - createNotificationChannels = { }) - } -} \ No newline at end of file diff --git a/app/src/main/java/org/distrinet/lanshield/ui/LANShieldIcons.kt b/app/src/main/java/org/distrinet/lanshield/ui/LANShieldIcons.kt index fc3cfff..b3a2c89 100644 --- a/app/src/main/java/org/distrinet/lanshield/ui/LANShieldIcons.kt +++ b/app/src/main/java/org/distrinet/lanshield/ui/LANShieldIcons.kt @@ -23,10 +23,14 @@ import androidx.compose.material.icons.rounded.Lan import androidx.compose.material.icons.rounded.Mood import androidx.compose.material.icons.rounded.MoreVert import androidx.compose.material.icons.rounded.NotificationsActive +import androidx.compose.material.icons.rounded.PhotoCamera import androidx.compose.material.icons.rounded.Policy +import androidx.compose.material.icons.rounded.Print import androidx.compose.material.icons.rounded.QuestionMark import androidx.compose.material.icons.rounded.RocketLaunch +import androidx.compose.material.icons.rounded.Router import androidx.compose.material.icons.rounded.Science +import androidx.compose.material.icons.rounded.Speaker import androidx.compose.material.icons.rounded.Search import androidx.compose.material.icons.rounded.SentimentVerySatisfied import androidx.compose.material.icons.rounded.Settings @@ -68,6 +72,10 @@ object LANShieldIcons { val ExpandCircleDown = Icons.Outlined.ExpandCircleDown val Policy = Icons.Rounded.Policy val PolicyOutlined = Icons.Outlined.Policy + val Printer = Icons.Rounded.Print + val Camera = Icons.Rounded.PhotoCamera + val Speaker = Icons.Rounded.Speaker + val Router = Icons.Rounded.Router } diff --git a/app/src/main/java/org/distrinet/lanshield/ui/intro/IntroHero.kt b/app/src/main/java/org/distrinet/lanshield/ui/intro/IntroHero.kt new file mode 100644 index 0000000..f1d69fa --- /dev/null +++ b/app/src/main/java/org/distrinet/lanshield/ui/intro/IntroHero.kt @@ -0,0 +1,318 @@ +package org.distrinet.lanshield.ui.intro + +import android.provider.Settings +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.withFrameNanos +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.blur +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.drawscope.translate +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import org.distrinet.lanshield.R +import org.distrinet.lanshield.ui.LANShieldIcons +import kotlin.math.abs +import kotlin.math.floor +import kotlin.math.max + +private data class App(val allowed: Boolean, val rowY: Float) + +private val Apps = listOf( + App(allowed = true, rowY = 0.2f), + App(allowed = false, rowY = 0.5f), + App(allowed = true, rowY = 0.8f), +) + +private val DeviceIcons = listOf( + LANShieldIcons.Printer, + LANShieldIcons.Camera, + LANShieldIcons.Speaker, +) +private val DeviceRows = listOf(0.2f, 0.5f, 0.8f) + +private val HeroHeight = 248.dp +private val HubSize = 184.dp // the LANShield logo +private val AppChip = 40.dp +private val DeviceChip = 44.dp + +private const val APP_X = 0.12f +private const val HUB_X = 0.5f +private const val DEVICE_X = 0.88f + +private const val CYCLE_MS = 6800f +private const val ARRIVE = 0.135f +private const val FWD_END = 0.21f +private const val GLOW_RISE = 0.035f +private const val GLOW_FALL = 0.10f + +private val AllowGreen = Color(0xFF34C759) +private val BlockRed = Color(0xFFFF453A) +private val AppColors = listOf( + Color(0xFF4F9DFF), // blue + Color(0xFFF5A623), // amber + Color(0xFFB36BFF), // violet +) +private fun forwardTarget(app: Int, journey: Int): Int { + var h = app * 374761393 + (journey + 1) * 668265263 + h = (h xor (h ushr 13)) * 1274126177 + return (h ushr 1) % DeviceRows.size +} + +@Composable +internal fun IntroHero(modifier: Modifier = Modifier) { + val context = LocalContext.current + val animationsEnabled = remember { + Settings.Global.getFloat( + context.contentResolver, + Settings.Global.ANIMATOR_DURATION_SCALE, + 1f, + ) != 0f + } + + val progress = remember { mutableFloatStateOf(0f) } + LaunchedEffect(animationsEnabled) { + if (!animationsEnabled) return@LaunchedEffect + val start = withFrameNanos { it } + while (true) { + val now = withFrameNanos { it } + progress.floatValue = (now - start) / 1_000_000f / CYCLE_MS + } + } + + val appColors = AppColors + val spokeColor = MaterialTheme.colorScheme.outlineVariant + + BoxWithConstraints( + modifier = modifier + .fillMaxWidth() + .height(HeroHeight), + ) { + val w = maxWidth + val h = maxHeight + + Canvas(modifier = Modifier.size(w, h)) { + val hub = Offset(size.width * HUB_X, size.height * HUB_X) + + Apps.forEach { app -> + val appPt = Offset(size.width * APP_X, size.height * app.rowY) + drawLine(spokeColor.copy(alpha = 0.5f), appPt, hub, strokeWidth = 1.5.dp.toPx()) + } + DeviceRows.forEach { row -> + val devPt = Offset(size.width * DEVICE_X, size.height * row) + drawLine(spokeColor.copy(alpha = 0.5f), hub, devPt, strokeWidth = 1.5.dp.toPx()) + } + + if (!animationsEnabled) { + Apps.forEachIndexed { i, app -> + val appPt = Offset(size.width * APP_X, size.height * app.rowY) + drawCircle(appColors[i], 5.dp.toPx(), lerp(appPt, hub, 0.5f)) + } + } else { + val prog = progress.floatValue + Apps.forEachIndexed { i, app -> + val appPt = Offset(size.width * APP_X, size.height * app.rowY) + val laneProg = prog + i.toFloat() / Apps.size + val p = laneProg % 1f + val journey = floor(laneProg).toInt() + + if (p < ARRIVE) { + val seg = p / ARRIVE + val fadeIn = (p / 0.03f).coerceIn(0f, 1f) + drawCircle( + color = appColors[i].copy(alpha = fadeIn), + radius = 5.dp.toPx(), + center = lerp(appPt, hub, seg), + ) + } + + if (app.allowed && p >= ARRIVE && p <= FWD_END) { + val targetRow = DeviceRows[forwardTarget(i, journey)] + val devPt = Offset(size.width * DEVICE_X, size.height * targetRow) + val seg = ((p - ARRIVE) / (FWD_END - ARRIVE)).coerceIn(0f, 1f) + drawCircle( + color = appColors[i], + radius = 5.dp.toPx(), + center = lerp(hub, devPt, seg), + ) + } + } + } + } + + if (animationsEnabled) { + val glowSize = HubSize * 1.08f + Apps.forEachIndexed { i, app -> + val verdictColor = if (app.allowed) AllowGreen else BlockRed + Image( + painter = painterResource(id = R.mipmap.logo_foreground), + contentDescription = null, + contentScale = ContentScale.Fit, + colorFilter = ColorFilter.tint(verdictColor), + modifier = Modifier + .offset(x = w * HUB_X - glowSize / 2, y = h * HUB_X - glowSize / 2) + .size(glowSize) + .blur(28.dp) + .graphicsLayer { + val p = (progress.floatValue + i.toFloat() / Apps.size) % 1f + // Grow monotonically across the whole glow so it only ever radiates out. + val grow = ((p - (ARRIVE - GLOW_RISE)) / (GLOW_RISE + GLOW_FALL)) + .coerceIn(0f, 1f) + // Gentle peak — a soft, calm glow rather than a hard flash. + alpha = radiance(p) * 0.45f + val s = 1f + 0.28f * grow + scaleX = s + scaleY = s + }, + ) + } + } + + Image( + painter = painterResource(id = R.mipmap.logo_foreground), + contentDescription = stringResource(R.string.lanshield_logo), + contentScale = ContentScale.Fit, + modifier = Modifier + .offset(x = w * HUB_X - HubSize / 2, y = h * HUB_X - HubSize / 2) + .size(HubSize), + ) + + Apps.forEachIndexed { i, app -> + Chip( + centerX = w * APP_X, + centerY = h * app.rowY, + chipSize = AppChip, + icon = LANShieldIcons.Android, + iconSize = 24.dp, + tint = appColors[i], + horns = !app.allowed, + ) + } + + DeviceRows.forEachIndexed { j, row -> + Chip( + centerX = w * DEVICE_X, + centerY = h * row, + chipSize = DeviceChip, + icon = DeviceIcons[j], + iconSize = 24.dp, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + scaleProvider = if (animationsEnabled) { + { + val prog = progress.floatValue + var pulse = 0f + Apps.forEachIndexed { i, app -> + if (app.allowed) { + val laneProg = prog + i.toFloat() / Apps.size + val journey = floor(laneProg).toInt() + if (forwardTarget(i, journey) == j) { + val p = laneProg % 1f + pulse = max( + pulse, + (1f - abs(p - FWD_END) / 0.06f).coerceIn(0f, 1f), + ) + } + } + } + 1f + 0.12f * pulse + } + } else null, + ) + } + } +} + +@Composable +private fun Chip( + centerX: Dp, + centerY: Dp, + chipSize: Dp, + icon: ImageVector, + iconSize: Dp, + tint: Color, + scaleProvider: (() -> Float)? = null, + horns: Boolean = false, + containerColor: Color = MaterialTheme.colorScheme.surface, + tonalElevation: Dp = 3.dp, +) { + Surface( + modifier = Modifier + .offset(x = centerX - chipSize / 2, y = centerY - chipSize / 2) + .size(chipSize) + .graphicsLayer { + val s = scaleProvider?.invoke() ?: 1f + scaleX = s + scaleY = s + }, + shape = CircleShape, + color = containerColor, + tonalElevation = tonalElevation, + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + imageVector = icon, + contentDescription = null, + tint = tint, + modifier = Modifier.size(iconSize), + ) + if (horns) { + Canvas(modifier = Modifier.size(iconSize)) { + val s = size.minDimension + val left = Path().apply { + moveTo(0.30f * s, 0.35f * s) + quadraticTo(0.13f * s, 0.27f * s, 0.18f * s, 0.06f * s) + quadraticTo(0.33f * s, 0.17f * s, 0.43f * s, 0.33f * s) + close() + } + val right = Path().apply { + moveTo(0.70f * s, 0.35f * s) + quadraticTo(0.87f * s, 0.27f * s, 0.82f * s, 0.06f * s) + quadraticTo(0.67f * s, 0.17f * s, 0.57f * s, 0.33f * s) + close() + } + // Nudge the horns down a touch so their bases cover the robot's antennae. + translate(top = 0.03f * s) { + drawPath(left, BlockRed) + translate(left = 0.015f * s) { + drawPath(right, BlockRed) + } + } + } + } + } + } +} + +private fun lerp(a: Offset, b: Offset, t: Float): Offset = + Offset(a.x + (b.x - a.x) * t, a.y + (b.y - a.y) * t) + +private fun radiance(p: Float): Float = when { + p < ARRIVE - GLOW_RISE -> 0f + p < ARRIVE -> (p - (ARRIVE - GLOW_RISE)) / GLOW_RISE + p < ARRIVE + GLOW_FALL -> 1f - (p - ARRIVE) / GLOW_FALL + else -> 0f +} diff --git a/app/src/main/java/org/distrinet/lanshield/ui/intro/IntroScreen.kt b/app/src/main/java/org/distrinet/lanshield/ui/intro/IntroScreen.kt index ebd180f..8f396b4 100644 --- a/app/src/main/java/org/distrinet/lanshield/ui/intro/IntroScreen.kt +++ b/app/src/main/java/org/distrinet/lanshield/ui/intro/IntroScreen.kt @@ -1,29 +1,33 @@ package org.distrinet.lanshield.ui.intro +import android.app.Activity import android.content.Context +import android.content.ContextWrapper import android.content.Intent +import android.os.Build import android.provider.Settings import androidx.activity.compose.BackHandler import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.core.animateDpAsState import androidx.compose.foundation.Image import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material3.Button import androidx.compose.material3.Card -import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.SegmentedButton @@ -31,25 +35,38 @@ import androidx.compose.material3.SegmentedButtonDefaults import androidx.compose.material3.SingleChoiceSegmentedButtonRow import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.scale +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color -import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Velocity import androidx.compose.ui.unit.dp +import androidx.core.app.NotificationManagerCompat +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch +import org.distrinet.lanshield.ABOUT_LANSHIELD_URL +import org.distrinet.lanshield.PRIVACY_POLICY_URL import org.distrinet.lanshield.Policy import org.distrinet.lanshield.R import org.distrinet.lanshield.isAppUsageAccessGranted @@ -87,7 +104,7 @@ internal fun IntroRoute(viewModel: IntroViewModel, navigateToOverview: () -> Uni ) - Scaffold(topBar = { Spacer(modifier = Modifier.size(50.dp)) }) { innerPadding -> + Scaffold { innerPadding -> IntroScreen( modifier = Modifier.padding(innerPadding), defaultPolicy = defaultPolicy, onChangeDefaultPolicy = { viewModel.onChangeDefaultPolicy(it) }, @@ -113,6 +130,16 @@ internal fun IntroStartPreview() { } } +@Preview +@Composable +internal fun IntroStartLightPreview() { + LANShieldTheme(darkTheme = false) { + IntroScreen( + initialPage = INTRO_START.ordinal, + createNotificationChannels = { }) + } +} + @Composable internal fun IntroScreen( modifier: Modifier = Modifier, @@ -127,34 +154,79 @@ internal fun IntroScreen( onChangeFinishAppIntro: (Boolean) -> Unit = {}, createNotificationChannels: () -> Unit = {} ) { - val pageCount = - IntroSlides.entries.size //if(isShareLanMetricsEnabled) IntroSlides.entries.size else IntroSlides.entries.size - 1 + val pageCount = IntroSlides.entries.size val pagerState = rememberPagerState(pageCount = { pageCount }, initialPage = initialPage) val coroutineScope = rememberCoroutineScope() - var notificationPermissionDialogLaunched by remember { mutableStateOf(false) } + val context = LocalContext.current var showGrantAppUsageDialog by remember { mutableStateOf(false) } + var notificationsEnabled by remember { mutableStateOf(areNotificationsEnabled(context)) } + var notificationRequested by remember { mutableStateOf(false) } + val lifecycleOwner = LocalLifecycleOwner.current + DisposableEffect(lifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + notificationsEnabled = areNotificationsEnabled(context) + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } + } val requestNotificationPermissionLauncher = rememberLauncherForActivityResult( ActivityResultContracts.RequestPermission() - ) { isGranted -> - notificationPermissionDialogLaunched = true + ) { _ -> + notificationRequested = true createNotificationChannels() - scrollToNextPage(pagerState, coroutineScope) + notificationsEnabled = areNotificationsEnabled(context) + } + + val activity = context.findActivity() + val canRequestRuntime = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU + val notificationsPermanentlyDenied = !notificationsEnabled && ( + !canRequestRuntime || + (notificationRequested && activity != null && + !activity.shouldShowRequestPermissionRationale(POST_NOTIFICATIONS_PERMISSION)) + ) + val onAllowNotifications: () -> Unit = { + if (canRequestRuntime && !notificationsPermanentlyDenied) { + requestNotificationPermissionLauncher.launch(POST_NOTIFICATIONS_PERMISSION) + } else { + openNotificationSettings(context) + } } - val scrollEnabled = - (pagerState.currentPage != NOTIFICATIONS.ordinal || notificationPermissionDialogLaunched) && + val canSwipeForward = + (pagerState.currentPage != NOTIFICATIONS.ordinal || notificationsEnabled) && pagerState.currentPage != JOIN_USER_STUDY.ordinal && (pagerState.currentPage != INTRO_FINISHED.ordinal || isShareLanMetricsEnabled) + val canSwipeBack = + pagerState.currentPage != INTRO_FINISHED.ordinal || isShareLanMetricsEnabled + + val canSwipeForwardState = rememberUpdatedState(canSwipeForward) + val canSwipeBackState = rememberUpdatedState(canSwipeBack) + val blockSwipe = remember { + object : NestedScrollConnection { + private fun blocked(deltaX: Float): Boolean = + (deltaX < 0f && !canSwipeForwardState.value) || + (deltaX > 0f && !canSwipeBackState.value) + + override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset = + if (source == NestedScrollSource.UserInput && blocked(available.x)) + available.copy(y = 0f) else Offset.Zero + + override suspend fun onPreFling(available: Velocity): Velocity = + if (blocked(available.x)) available.copy(y = 0f) else Velocity.Zero + } + } + BackHandler(enabled = pagerState.currentPage != 0) { scrollToPreviousPage(pagerState, coroutineScope) } - val context = LocalContext.current if (showGrantAppUsageDialog) { GrantAppUsagePermissionDialog( @@ -166,85 +238,152 @@ internal fun IntroScreen( ) } - Column(modifier = modifier) { - HorizontalPager( - state = pagerState, userScrollEnabled = scrollEnabled, modifier = Modifier - .weight(1F) - .fillMaxHeight() - ) { page -> - when (page) { - INTRO_START.ordinal -> IntroSlide() - DEFAULT_POLICY.ordinal -> DefaultPolicySlide( - defaultPolicy = defaultPolicy, - onChangeDefaultPolicy = onChangeDefaultPolicy - ) - - NOTIFICATIONS.ordinal -> NotificationSlide(doRequestNotificationPermission = { - requestNotificationPermissionLauncher.launch("android.permission.POST_NOTIFICATIONS") - } - ) + val backgroundBrush = Brush.verticalGradient( + listOf( + MaterialTheme.colorScheme.surface, + MaterialTheme.colorScheme.surface, + MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.45f), + ) + ) - JOIN_USER_STUDY.ordinal -> ShareLanMetricsSlide( - onChangeShareLanMetrics = onChangeShareLanMetrics, - isShareLanMetricsEnabled = isShareLanMetricsEnabled - ) + Box(modifier = modifier.fillMaxSize().background(backgroundBrush)) { + Column(modifier = Modifier.fillMaxSize()) { + HorizontalPager( + state = pagerState, modifier = Modifier + .weight(1F) + .fillMaxHeight() + .nestedScroll(blockSwipe) + ) { page -> + when (page) { + INTRO_START.ordinal -> IntroSlide(page, pagerState) + DEFAULT_POLICY.ordinal -> DefaultPolicySlide( + page = page, + pagerState = pagerState, + defaultPolicy = defaultPolicy, + onChangeDefaultPolicy = onChangeDefaultPolicy + ) - SHARE_APP_USAGE.ordinal -> ShareAppUsageSlide( - isShareAppUsageEnabled - ) { isEnabled -> - onChangeShareAppUsageWithPermission( - isEnabled, - onChangeShareAppUsage, { showGrantAppUsageDialog = true }, - context + NOTIFICATIONS.ordinal -> NotificationSlide( + page = page, + pagerState = pagerState, + notificationsEnabled = notificationsEnabled, + permanentlyDenied = notificationsPermanentlyDenied, + onAllowNotifications = onAllowNotifications, ) - } - INTRO_FINISHED.ordinal -> IntroFinishedSlide() - } - } - Box( - Modifier - .fillMaxWidth() - .padding(bottom = 4.dp) - ) { - IntroLeftButton( - modifier = Modifier - .padding(8.dp) - .align(Alignment.CenterStart), - coroutineScope = coroutineScope, - pagerState = pagerState, - onChangeShareLanMetrics = onChangeShareLanMetrics, - isShareLanMetricsEnabled = isShareLanMetricsEnabled - ) - Row( - Modifier - .align(Alignment.Center) - .padding(horizontal = 8.dp, vertical = 12.dp) - ) { - repeat(pageCount) { iteration -> - val color = - if (pagerState.currentPage == iteration) Color.DarkGray else Color.LightGray - Box( - modifier = Modifier - .padding(8.dp) - .background(color, CircleShape) - .size(10.dp) + JOIN_USER_STUDY.ordinal -> ShareLanMetricsSlide( + page = page, + pagerState = pagerState, + onChangeShareLanMetrics = onChangeShareLanMetrics, + isShareLanMetricsEnabled = isShareLanMetricsEnabled ) + + SHARE_APP_USAGE.ordinal -> ShareAppUsageSlide( + page = page, + pagerState = pagerState, + isShareAppUsageEnabled = isShareAppUsageEnabled, + ) { isEnabled -> + onChangeShareAppUsageWithPermission( + isEnabled, + onChangeShareAppUsage, { showGrantAppUsageDialog = true }, + context + ) + } + + INTRO_FINISHED.ordinal -> IntroFinishedSlide(page, pagerState) } } - IntroRightButton( - modifier = Modifier - .padding(8.dp) - .align(Alignment.CenterEnd), - coroutineScope = coroutineScope, + IntroBottomBar( pagerState = pagerState, + pageCount = pageCount, + coroutineScope = coroutineScope, onChangeShareLanMetrics = onChangeShareLanMetrics, isShareLanMetricsEnabled = isShareLanMetricsEnabled, navigateToOverview = navigateToOverview, onChangeFinishAppIntro = onChangeFinishAppIntro, - requestNotificationPermissionLauncher = requestNotificationPermissionLauncher + requestNotificationPermissionLauncher = requestNotificationPermissionLauncher, + notificationsEnabled = notificationsEnabled, ) + } + } +} +@Composable +private fun IntroBottomBar( + pagerState: PagerState, + pageCount: Int, + coroutineScope: CoroutineScope, + onChangeShareLanMetrics: (Boolean) -> Unit, + isShareLanMetricsEnabled: Boolean, + navigateToOverview: () -> Unit, + onChangeFinishAppIntro: (Boolean) -> Unit, + requestNotificationPermissionLauncher: androidx.activity.compose.ManagedActivityResultLauncher, + notificationsEnabled: Boolean, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + PageIndicator(pagerState = pagerState, pageCount = pageCount) + Spacer(Modifier.height(8.dp)) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Box { + IntroLeftButton( + coroutineScope = coroutineScope, + pagerState = pagerState, + onChangeShareLanMetrics = onChangeShareLanMetrics, + isShareLanMetricsEnabled = isShareLanMetricsEnabled + ) + } + Box { + IntroRightButton( + coroutineScope = coroutineScope, + pagerState = pagerState, + onChangeShareLanMetrics = onChangeShareLanMetrics, + isShareLanMetricsEnabled = isShareLanMetricsEnabled, + navigateToOverview = navigateToOverview, + onChangeFinishAppIntro = onChangeFinishAppIntro, + requestNotificationPermissionLauncher = requestNotificationPermissionLauncher, + notificationsEnabled = notificationsEnabled, + ) + } + } + } +} + +/** Theme-aware page indicator; the active page grows into a brand-colored pill. */ +@Composable +private fun PageIndicator(pagerState: PagerState, pageCount: Int) { + Row( + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + repeat(pageCount) { iteration -> + val selected = pagerState.currentPage == iteration + val dotWidth by animateDpAsState( + targetValue = if (selected) 26.dp else 8.dp, + label = "dotWidth" + ) + val color = if (selected) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.outlineVariant + } + Box( + modifier = Modifier + .padding(horizontal = 4.dp) + .height(8.dp) + .width(dotWidth) + .background(color, CircleShape) + ) } } } @@ -284,29 +423,21 @@ fun scrollToPreviousPage(pagerState: PagerState, coroutineScope: CoroutineScope) } @Composable -internal fun IntroSlide(modifier: Modifier = Modifier) { - Column(modifier = modifier.fillMaxWidth()) { - Text( - text = "LANShield", - style = MaterialTheme.typography.titleLarge, - textAlign = TextAlign.Center, - modifier = Modifier - .align(Alignment.CenterHorizontally) - .padding(16.dp) - ) - Image( - painter = painterResource(id = R.mipmap.logo_foreground), - contentDescription = stringResource(R.string.lanshield_logo), - modifier = Modifier - .size(200.dp) - .align(Alignment.CenterHorizontally), - contentScale = ContentScale.Crop - ) - Text( - text = stringResource(R.string.intro_welcome).trimIndent(), - style = MaterialTheme.typography.bodyLarge, - modifier = Modifier.padding(16.dp), - textAlign = TextAlign.Center +internal fun IntroSlide(page: Int, pagerState: PagerState, modifier: Modifier = Modifier) { + val uriHandler = LocalUriHandler.current + + OnboardingSlide( + page = page, + pagerState = pagerState, + modifier = modifier, + title = stringResource(R.string.app_name), + titleFontWeight = FontWeight.Bold, + body = stringResource(R.string.intro_welcome).trimIndent(), + hero = { IntroHero() }, + ) { + OnboardingTextButton( + text = stringResource(R.string.learn_more_on_lanshield_eu), + onClick = { uriHandler.openUri(ABOUT_LANSHIELD_URL) }, ) } } @@ -322,59 +453,35 @@ internal fun DefaultPolicySlidePreview() { } @Composable -internal fun DefaultPolicySlide(defaultPolicy: Policy, onChangeDefaultPolicy: (Policy) -> Unit) { - Column(modifier = Modifier.fillMaxWidth()) { - Text( - text = stringResource(R.string.set_a_default_policy), - style = MaterialTheme.typography.titleLarge, - textAlign = TextAlign.Center, - modifier = Modifier - .align(Alignment.CenterHorizontally) - .padding(16.dp) - ) - Spacer(modifier = Modifier.size(40.dp)) - Icon( - imageVector = LANShieldIcons.Block, contentDescription = null, modifier = Modifier - .align(Alignment.CenterHorizontally) - .size(125.dp), - tint = getIconTint() - ) - Spacer(modifier = Modifier.size(40.dp)) - Text( - text = stringResource(R.string.intro_default_policy).trimIndent(), - style = MaterialTheme.typography.bodyLarge, - modifier = Modifier.padding(24.dp) - ) - - SingleChoiceSegmentedButtonRow( - modifier = Modifier - .scale(0.9F) - .padding(end = 0.dp) - .align(Alignment.CenterHorizontally) - ) { +internal fun DefaultPolicySlide( + page: Int, + pagerState: PagerState, + defaultPolicy: Policy, + onChangeDefaultPolicy: (Policy) -> Unit +) { + OnboardingSlide( + page = page, + pagerState = pagerState, + title = stringResource(R.string.set_a_default_policy), + icon = LANShieldIcons.Policy, + body = stringResource(R.string.intro_default_policy).trimIndent(), + ) { + SingleChoiceSegmentedButtonRow { SegmentedButton( selected = defaultPolicy == Policy.BLOCK, onClick = { onChangeDefaultPolicy(Policy.BLOCK) }, - shape = SegmentedButtonDefaults.itemShape( - index = 0, - count = 2 - ) + shape = SegmentedButtonDefaults.itemShape(index = 0, count = 2) ) { Text(text = stringResource(R.string.block)) } SegmentedButton( selected = defaultPolicy == Policy.ALLOW, onClick = { onChangeDefaultPolicy(Policy.ALLOW) }, - shape = SegmentedButtonDefaults.itemShape( - index = 1, - count = 2 - ) + shape = SegmentedButtonDefaults.itemShape(index = 1, count = 2) ) { Text(text = stringResource(R.string.allow)) } } - - } } @@ -390,47 +497,42 @@ internal fun NotificationSlidePreview() { @Composable -internal fun NotificationSlide(doRequestNotificationPermission: () -> Unit) { - Column(modifier = Modifier.fillMaxWidth()) { - Text( - text = stringResource(R.string.get_notified), - style = MaterialTheme.typography.titleLarge, - textAlign = TextAlign.Center, - modifier = Modifier - .align(Alignment.CenterHorizontally) - .padding(16.dp) - ) - Spacer(modifier = Modifier.size(40.dp)) - Card(modifier = Modifier.align(Alignment.CenterHorizontally)) { - Image( - painterResource(id = R.drawable.lanshield_notification), - contentDescription = null, - modifier = Modifier - .align(Alignment.CenterHorizontally) - .width(350.dp) - .padding(8.dp) +internal fun NotificationSlide( + page: Int, + pagerState: PagerState, + notificationsEnabled: Boolean = false, + permanentlyDenied: Boolean = false, + onAllowNotifications: () -> Unit = {}, +) { + OnboardingSlide( + page = page, + pagerState = pagerState, + title = stringResource(R.string.get_notified), + body = stringResource(R.string.intro_notification).trimIndent(), + hero = { + Card { + Image( + painterResource(id = R.drawable.lanshield_notification), + contentDescription = null, + modifier = Modifier + .width(340.dp) + .padding(8.dp) + ) + } + }, + action = { + val label = when { + notificationsEnabled -> stringResource(R.string.notifications_enabled) + permanentlyDenied -> stringResource(R.string.open_notification_settings) + else -> stringResource(R.string.allow_notifications) + } + OnboardingPrimaryButton( + text = label, + onClick = onAllowNotifications, + enabled = !notificationsEnabled, ) - } -// Icon(imageVector = LANShieldIcons.NotificationsActive, contentDescription = null, modifier = Modifier -// .align(Alignment.CenterHorizontally) -// .size(125.dp), -// tint = getIconTint() -// ) - Spacer(modifier = Modifier.size(40.dp)) - Text( - text = stringResource(R.string.intro_notification).trimIndent(), - style = MaterialTheme.typography.bodyLarge, - modifier = Modifier - .padding(24.dp), - ) - Spacer(modifier = Modifier.size(40.dp)) - Button( - onClick = doRequestNotificationPermission, - modifier = Modifier.align(Alignment.CenterHorizontally) - ) { - Text(text = "Allow notifications") - } - } + }, + ) } @@ -453,6 +555,26 @@ internal fun ShareAppUsageSlidePreview() { } } +private const val POST_NOTIFICATIONS_PERMISSION = "android.permission.POST_NOTIFICATIONS" + +internal fun areNotificationsEnabled(context: Context): Boolean = + NotificationManagerCompat.from(context).areNotificationsEnabled() + +private fun openNotificationSettings(context: Context) { + val intent = Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS) + .putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName) + context.startActivity(intent) +} + +private fun Context.findActivity(): Activity? { + var ctx: Context? = this + while (ctx is ContextWrapper) { + if (ctx is Activity) return ctx + ctx = ctx.baseContext + } + return null +} + private fun onChangeShareAppUsageWithPermission( isEnabled: Boolean, setShareAppUsage: (Boolean) -> Unit, @@ -469,41 +591,27 @@ private fun onChangeShareAppUsageWithPermission( @Composable internal fun ShareAppUsageSlide( + page: Int, + pagerState: PagerState, isShareAppUsageEnabled: Boolean, onChangeShareAppUsage: (Boolean) -> Unit ) { - Column() { - Text( - text = stringResource(R.string.intro_share_app_usage_title), - style = MaterialTheme.typography.titleLarge, - textAlign = TextAlign.Center, - modifier = Modifier - .align(Alignment.CenterHorizontally) - .padding(16.dp) - ) - Spacer(modifier = Modifier.size(40.dp)) - Icon( - imageVector = LANShieldIcons.QuestionMark, - contentDescription = null, - modifier = Modifier - .align(Alignment.CenterHorizontally) - .size(125.dp), - tint = getIconTint() - ) - Spacer(modifier = Modifier.size(40.dp)) - Text( - text = stringResource(R.string.intro_share_app_usage_content).trimIndent(), - style = MaterialTheme.typography.bodyLarge, - modifier = Modifier - .padding(24.dp) - .fillMaxWidth(), - textAlign = TextAlign.Center - ) + val uriHandler = LocalUriHandler.current + OnboardingSlide( + page = page, + pagerState = pagerState, + title = stringResource(R.string.intro_share_app_usage_title), + icon = LANShieldIcons.QuestionMark, + body = stringResource(R.string.intro_share_app_usage_content).trimIndent(), + ) { SettingsSwitchComp( name = R.string.share_anonymous_app_usage, isChecked = isShareAppUsageEnabled, onCheckedChange = onChangeShareAppUsage, - modifier = Modifier.padding(32.dp) + ) + OnboardingTextButton( + text = stringResource(id = R.string.more_info), + onClick = { uriHandler.openUri(PRIVACY_POLICY_URL) }, ) } } @@ -524,45 +632,17 @@ internal fun getIconTint(): Color { } @Composable -internal fun IntroFinishedSlide() { - Column() { - Text( - text = stringResource(R.string.you_re_all_set), - style = MaterialTheme.typography.titleLarge, - textAlign = TextAlign.Center, - modifier = Modifier - .align(Alignment.CenterHorizontally) - .padding(16.dp) - ) - Spacer(modifier = Modifier.size(40.dp)) - Icon( - imageVector = LANShieldIcons.RocketLaunch, - contentDescription = null, - modifier = Modifier - .align(Alignment.CenterHorizontally) - .size(125.dp), - tint = getIconTint() - ) - Spacer(modifier = Modifier.size(40.dp)) - Text( - text = stringResource(R.string.enjoy_using_lanshield).trimIndent(), - style = MaterialTheme.typography.titleMedium, - modifier = Modifier - .padding(16.dp) - .fillMaxWidth(), - textAlign = TextAlign.Center - ) - Text( - text = stringResource(R.string.intro_finished_feedback).trimIndent(), - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier - .padding(16.dp) - .fillMaxWidth(), - textAlign = TextAlign.Center - ) - } +internal fun IntroFinishedSlide(page: Int, pagerState: PagerState) { + OnboardingSlide( + page = page, + pagerState = pagerState, + title = stringResource(R.string.you_re_all_set), + icon = LANShieldIcons.RocketLaunch, + body = stringResource(R.string.enjoy_using_lanshield).trimIndent(), + caption = stringResource(R.string.intro_finished_feedback).trimIndent(), + ) } private fun startUsageAccessSettingsActivity(context: Context) { context.startActivity(Intent(Settings.ACTION_USAGE_ACCESS_SETTINGS)) -} \ No newline at end of file +} diff --git a/app/src/main/java/org/distrinet/lanshield/ui/intro/OnboardingSlide.kt b/app/src/main/java/org/distrinet/lanshield/ui/intro/OnboardingSlide.kt new file mode 100644 index 0000000..b0f71e9 --- /dev/null +++ b/app/src/main/java/org/distrinet/lanshield/ui/intro/OnboardingSlide.kt @@ -0,0 +1,189 @@ +package org.distrinet.lanshield.ui.intro + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.MutableTransitionState +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.pager.PagerState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp + +/** + * Shared spacing tokens for the onboarding carousel so every slide breathes the same way + * instead of carrying its own ad-hoc padding values. + */ +internal object OnboardingSpacing { + val Screen = 28.dp + val HeroBadge = 132.dp + val HeroIcon = 60.dp + val AfterHero = 36.dp + val AfterTitle = 16.dp + val BeforeAction = 28.dp +} + +@Composable +internal fun OnboardingSlide( + page: Int, + pagerState: PagerState, + title: String, + modifier: Modifier = Modifier, + icon: ImageVector? = null, + body: String? = null, + caption: String? = null, + titleFontWeight: FontWeight? = null, + hero: (@Composable () -> Unit)? = null, + action: (@Composable ColumnScope.() -> Unit)? = null, +) { + val settled = pagerState.settledPage == page + + Column( + modifier = modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = OnboardingSpacing.Screen), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(Modifier.weight(1f)) + + StaggeredItem(settled, order = 0) { + when { + hero != null -> hero() + icon != null -> HeroBadge(icon) + } + } + + Spacer(Modifier.height(OnboardingSpacing.AfterHero)) + StaggeredItem(settled, order = 1) { + Text( + text = title, + style = MaterialTheme.typography.headlineMedium, + fontWeight = titleFontWeight, + color = MaterialTheme.colorScheme.onBackground, + textAlign = TextAlign.Center, + ) + } + + if (body != null) { + Spacer(Modifier.height(OnboardingSpacing.AfterTitle)) + StaggeredItem(settled, order = 2) { + Text( + text = body, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } + } + + if (caption != null) { + Spacer(Modifier.height(OnboardingSpacing.AfterTitle)) + StaggeredItem(settled, order = 3) { + Text( + text = caption, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } + } + + if (action != null) { + Spacer(Modifier.height(OnboardingSpacing.BeforeAction)) + StaggeredItem(settled, order = 3) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp), + content = action, + ) + } + } + + Spacer(Modifier.weight(1.4f)) + } +} + +@Composable +internal fun HeroBadge(icon: ImageVector) { + Box( + modifier = Modifier + .size(OnboardingSpacing.HeroBadge) + .background(MaterialTheme.colorScheme.primaryContainer, CircleShape), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.size(OnboardingSpacing.HeroIcon), + ) + } +} + +@Composable +internal fun StaggeredItem( + visible: Boolean, + order: Int, + content: @Composable () -> Unit, +) { + val state = remember { MutableTransitionState(false) } + state.targetState = visible + val delay = order * 90 + AnimatedVisibility( + visibleState = state, + enter = fadeIn(tween(durationMillis = 380, delayMillis = delay)) + + slideInVertically(tween(durationMillis = 380, delayMillis = delay)) { it / 5 }, + exit = fadeOut(tween(durationMillis = 120)), + ) { + content() + } +} + +@Composable +internal fun OnboardingPrimaryButton( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, +) { + Button(onClick = onClick, modifier = modifier, enabled = enabled) { + Text(text = text, style = MaterialTheme.typography.labelLarge) + } +} + +@Composable +internal fun OnboardingTextButton( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + TextButton(onClick = onClick, modifier = modifier) { + Text(text = text, style = MaterialTheme.typography.labelLarge) + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 728b89e..5ed7b96 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -6,6 +6,9 @@ All Allow Allowed + Allow notifications + Open notification settings + Notifications enabled Alter blocking policy on a per app basis Anonymous sharing Any detected LAN traffic \nwill be shown here @@ -36,7 +39,7 @@ Exceptionsanonymous Feedback Finish - Get notified + Notifications Give feedback In: %1$s Individual LAN traffic @@ -44,13 +47,14 @@ Info "First, you need to choose whether you want to block or allow LAN access by default. You can override this later for individual apps." "Got any feedback? You can share your remarks in the Settings tab!" - "LANShield will notify you when an app tries to access the LAN for the first time, letting you allow or block it. In the next step you'll be asked to enable push notifications to receive these alerts. Don't worry, we won't spam you." + "LANShield sends a notification the first time an app tries to reach your local network, so you can allow or block it. Notifications are only used for these local network access events." "To let us better understand app behaviour, we'd like to know which apps didn't access the LAN while being used by you. In order to do so, you can share your app usage with us." - Help us understand which apps don\'t access the LAN + Share app usage "LANShield is part of an academic research project at the university of KU Leuven. You can help us understand why apps access a local network, by sharing information about how the apps on your device interact with your local network. All data is fully anonymized and will never be shared." - Todo intro_share_lan_metrics_more_info - "Welcome! LANShield allows you to control which apps have access to your local network." + "The local networks you connect to are home to printers, cameras, speakers and other devices. LANShield lets you decide which apps can reach them, and which can\'t." + LANShield works through a local VPN so it can filter LAN traffic on-device. You\'ll see a VPN icon, but nothing is ever sent to the internet. Join our academic study + Join user study Join our academic study by KU Leuven LAN Traffic LAN traffic blocking @@ -95,6 +99,8 @@ Share Set a default policy Settings + Skip + Continue Sharing app usage helps us identify which apps do not attempt to access the local network. To proceed, please find the LANShield app on the next screen and select \'Allow\'. Share anonymous app usage Share anonymous LAN metrics @@ -107,6 +113,7 @@ Total: %1$s Unknown VPN notification + Why the VPN icon? VPN permission request LANShield needs notification permission to run. Enable notifications to turn it on. You\'re all set! diff --git a/app/src/playStore/kotlin/org/distrinet/lanshield/ui/intro/ShareLanMetricsSlide.kt b/app/src/playStore/kotlin/org/distrinet/lanshield/ui/intro/ShareLanMetricsSlide.kt index 354fd3a..4cf8a10 100644 --- a/app/src/playStore/kotlin/org/distrinet/lanshield/ui/intro/ShareLanMetricsSlide.kt +++ b/app/src/playStore/kotlin/org/distrinet/lanshield/ui/intro/ShareLanMetricsSlide.kt @@ -1,91 +1,36 @@ package org.distrinet.lanshield.ui.intro import androidx.activity.compose.ManagedActivityResultLauncher -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.pager.PagerState -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Button -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp import kotlinx.coroutines.CoroutineScope import org.distrinet.lanshield.R -import org.distrinet.lanshield.STUDY_MORE_INFO_URL +import org.distrinet.lanshield.PRIVACY_POLICY_URL import org.distrinet.lanshield.ui.LANShieldIcons @Composable -fun ShareLanMetricsSlide( onChangeShareLanMetrics: (Boolean) -> Unit, - isShareLanMetricsEnabled: Boolean) { - var showMoreInfoDialog by remember { mutableStateOf(false) } - - if(showMoreInfoDialog) { - AlertDialog( - icon = { - Icon(LANShieldIcons.Info, contentDescription = stringResource(id = R.string.info), tint = getIconTint()) - }, - title = { Text(text = stringResource(id = R.string.more_info)) }, - text = { - Text(text = stringResource(R.string.intro_share_lan_metrics_more_info)) //TODO - }, - onDismissRequest = { showMoreInfoDialog = false}, - dismissButton = { - TextButton( - onClick = { showMoreInfoDialog = false} - ) { - Text(text = stringResource(id = R.string.privacy_policy)) - } - }, - confirmButton = { - TextButton( - onClick = { showMoreInfoDialog = false} - ) { - Text(text = stringResource(id = R.string.dismiss)) - } - }, +fun ShareLanMetricsSlide( + page: Int, + pagerState: PagerState, + onChangeShareLanMetrics: (Boolean) -> Unit, + isShareLanMetricsEnabled: Boolean +) { + val uriHandler = LocalUriHandler.current + OnboardingSlide( + page = page, + pagerState = pagerState, + title = stringResource(R.string.join_our_academic_study), + icon = LANShieldIcons.Science, + body = stringResource(R.string.intro_share_lan_metrics).trimIndent(), + ) { + OnboardingTextButton( + text = stringResource(id = R.string.more_info), + onClick = { uriHandler.openUri(PRIVACY_POLICY_URL) }, ) } - - Column() { - Text(text = stringResource(R.string.join_our_academic_study), style = MaterialTheme.typography.titleLarge, textAlign = TextAlign.Center, modifier = Modifier - .align(Alignment.CenterHorizontally) - .padding(16.dp)) - Spacer(modifier = Modifier.size(40.dp)) - Icon(imageVector = LANShieldIcons.Science, contentDescription = null, modifier = Modifier - .align(Alignment.CenterHorizontally) - .size(125.dp), - tint = getIconTint() - ) - Spacer(modifier = Modifier.size(40.dp)) - Text(text= stringResource(R.string.intro_share_lan_metrics).trimIndent(), style = MaterialTheme.typography.bodyLarge, modifier = Modifier - .padding(24.dp) - .fillMaxWidth()) - -// Button(onClick = {showMoreInfoDialog = true}, modifier = Modifier.align(Alignment.CenterHorizontally)) { -// Text(text = stringResource(id = R.string.more_info)) -// } - val uriHandler = LocalUriHandler.current - Button(onClick = {uriHandler.openUri(STUDY_MORE_INFO_URL)}, modifier = Modifier.align( - Alignment.CenterHorizontally)) { - Text(text = stringResource(id = R.string.more_info)) - } - } } @Composable @@ -94,26 +39,35 @@ fun IntroLeftButton( pagerState: PagerState, coroutineScope: CoroutineScope, onChangeShareLanMetrics: (Boolean) -> Unit, - isShareLanMetricsEnabled: Boolean) { - - if (pagerState.currentPage == IntroSlides.JOIN_USER_STUDY.ordinal) { - TextButton(onClick = { doShareLanMetricsDecision(false, onChangeShareLanMetrics, coroutineScope, pagerState) }, - modifier = modifier) { - Text(text = stringResource(R.string.disagree), style = MaterialTheme.typography.bodyLarge) + isShareLanMetricsEnabled: Boolean +) { + when { + pagerState.currentPage == IntroSlides.JOIN_USER_STUDY.ordinal -> { + OnboardingTextButton( + text = stringResource(R.string.disagree), + onClick = { + doShareLanMetricsDecision(false, onChangeShareLanMetrics, coroutineScope, pagerState) + }, + modifier = modifier, + ) } - } - else if (pagerState.currentPage == IntroSlides.INTRO_FINISHED.ordinal && !isShareLanMetricsEnabled) { - IconButton( - onClick = { scrollToPage(pagerState, IntroSlides.JOIN_USER_STUDY.ordinal, coroutineScope) }, - modifier = modifier) { - Icon(imageVector = LANShieldIcons.ChevronLeft, contentDescription = stringResource(R.string.previous)) + + pagerState.currentPage == IntroSlides.INTRO_FINISHED.ordinal && !isShareLanMetricsEnabled -> { + OnboardingTextButton( + text = stringResource(R.string.back), + onClick = { + scrollToPage(pagerState, IntroSlides.JOIN_USER_STUDY.ordinal, coroutineScope) + }, + modifier = modifier, + ) } - } - else if (pagerState.currentPage != 0) { - IconButton( - onClick = { scrollToPreviousPage(pagerState, coroutineScope) }, - modifier = modifier) { - Icon(imageVector = LANShieldIcons.ChevronLeft, contentDescription = stringResource(R.string.previous)) + + pagerState.currentPage != 0 -> { + OnboardingTextButton( + text = stringResource(R.string.back), + onClick = { scrollToPreviousPage(pagerState, coroutineScope) }, + modifier = modifier, + ) } } } @@ -127,41 +81,46 @@ fun IntroRightButton( isShareLanMetricsEnabled: Boolean, onChangeFinishAppIntro: (Boolean) -> Unit, navigateToOverview: () -> Unit, - requestNotificationPermissionLauncher: ManagedActivityResultLauncher + requestNotificationPermissionLauncher: ManagedActivityResultLauncher, + notificationsEnabled: Boolean, ) { - if (pagerState.currentPage == IntroSlides.JOIN_USER_STUDY.ordinal) { - TextButton( - onClick = { doShareLanMetricsDecision(true, onChangeShareLanMetrics, coroutineScope, pagerState) }, - modifier = modifier) { - Text(text = stringResource(R.string.agree), style = MaterialTheme.typography.bodyLarge) - } - } - else if (pagerState.currentPage == IntroSlides.NOTIFICATIONS.ordinal) { - IconButton( - onClick = { requestNotificationPermissionLauncher.launch("android.permission.POST_NOTIFICATIONS") }, - modifier = modifier) { - Icon(imageVector = LANShieldIcons.ChevronRight, contentDescription = stringResource( - R.string.next + when (pagerState.currentPage) { + IntroSlides.JOIN_USER_STUDY.ordinal -> { + OnboardingPrimaryButton( + text = stringResource(R.string.agree), + onClick = { + doShareLanMetricsDecision(true, onChangeShareLanMetrics, coroutineScope, pagerState) + }, + modifier = modifier, ) + } + + IntroSlides.NOTIFICATIONS.ordinal -> { + OnboardingPrimaryButton( + text = stringResource(R.string.continue_label), + onClick = { scrollToNextPage(pagerState, coroutineScope) }, + enabled = notificationsEnabled, + modifier = modifier, ) } - } - else if (pagerState.currentPage == IntroSlides.INTRO_FINISHED.ordinal) { - TextButton(onClick = { - onChangeFinishAppIntro(true) - navigateToOverview() - }, modifier = modifier) { - Text(text = stringResource(R.string.finish), style = MaterialTheme.typography.bodyLarge) + + IntroSlides.INTRO_FINISHED.ordinal -> { + OnboardingPrimaryButton( + text = stringResource(R.string.finish), + onClick = { + onChangeFinishAppIntro(true) + navigateToOverview() + }, + modifier = modifier, + ) } - } - else if (pagerState.currentPage != IntroSlides.INTRO_FINISHED.ordinal) { - IconButton( - onClick = { scrollToNextPage(pagerState, coroutineScope) }, - modifier = modifier - ) { - Icon(imageVector = LANShieldIcons.ChevronRight, contentDescription = stringResource( - R.string.next - )) + + else -> { + OnboardingPrimaryButton( + text = stringResource(R.string.continue_label), + onClick = { scrollToNextPage(pagerState, coroutineScope) }, + modifier = modifier, + ) } } -} \ No newline at end of file +} From afd3e670ab04534677d427b53551b6639a162d78 Mon Sep 17 00:00:00 2001 From: Jeroen Robben Date: Tue, 16 Jun 2026 21:17:14 +0200 Subject: [PATCH 3/5] add tests --- .../ConnectionOwnerUidUnconnectedUdpTest.kt | 287 ++++++++++++++++++ .../vpnservice/VpnServiceObserverLeakTest.kt | 2 +- .../lanshield/vpnservice/VPNService.kt | 7 +- 3 files changed, 292 insertions(+), 4 deletions(-) create mode 100644 app/src/androidTest/java/org/distrinet/lanshield/vpnservice/ConnectionOwnerUidUnconnectedUdpTest.kt diff --git a/app/src/androidTest/java/org/distrinet/lanshield/vpnservice/ConnectionOwnerUidUnconnectedUdpTest.kt b/app/src/androidTest/java/org/distrinet/lanshield/vpnservice/ConnectionOwnerUidUnconnectedUdpTest.kt new file mode 100644 index 0000000..af70b96 --- /dev/null +++ b/app/src/androidTest/java/org/distrinet/lanshield/vpnservice/ConnectionOwnerUidUnconnectedUdpTest.kt @@ -0,0 +1,287 @@ +package org.distrinet.lanshield.vpnservice + +import android.content.Context +import android.content.Intent +import android.net.ConnectivityManager +import android.net.VpnService +import android.os.ParcelFileDescriptor +import android.os.Process +import android.system.OsConstants +import android.util.Log +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.AfterClass +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Assume.assumeTrue +import org.junit.BeforeClass +import org.junit.Test +import org.junit.runner.RunWith +import java.io.FileInputStream +import java.net.InetAddress +import java.net.InetSocketAddress +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit + +/** + * On-device characterization of [ConnectivityManager.getConnectionOwnerUid] for **unconnected UDP + * sockets** — the basis for attributing LAN UDP discovery traffic (mDNS/SSDP/IoT) to an app, the way + * [org.distrinet.lanshield.backendsync.OpenPortsFinder] already does (wildcard-remote query) and the + * way a future [VPNRunnable.getPacketOwnerUid] fallback might. + * + * Important platform facts this test is built around (traced through AOSP + verified on an API 36 + * AVD; see project memory `getconnectionowneruid-quirks`): + * - The active VPN gets `INVALID_UID` for sockets owned by its **own** UID — `ConnectivityService` + * filters the result through `NetworkCapabilities.appliesToUid`, and `addDisallowedApplication` + * removes LANShield's own UID from the tunnel. So the socket under test must be owned by a + * **different** UID. We use a UDP listener spawned via the instrumentation shell (UID `shell`), + * which the tunnel does cover. + * - For UDP the framework swaps src/dst for the exact lookup (kernel bug workaround, aosp/755889) + * and, on miss, does a wildcard-remote DUMP reading only the first netlink record. The lookup is + * therefore **non-deterministic** (per-CPU scoring, SO_REUSEPORT, dump ordering). Every assertion + * here resolves with retries, mirroring production's `repeat(5)`. + * + * `getConnectionOwnerUid` needs the caller to be the active VpnService, so we bring up the real + * LANShield [VPNService] once for the class and grant consent via the `ACTIVATE_VPN` app-op. Where + * that isn't honored, or the shell UID isn't resolvable on the image, the class self-skips. + * + * Run with: `./gradlew connectedFossDebugAndroidTest` against a debuggable emulator. + */ +@RunWith(AndroidJUnit4::class) +class ConnectionOwnerUidUnconnectedUdpTest { + + /** + * Core claim: an unconnected UDP socket bound to the wildcard address resolves to its owner via a + * wildcard-remote query (the OpenPortsFinder / proposed-fallback technique). + */ + @Test + fun unconnected_wildcardBound_wildcardRemote_resolvesOwnerUid() { + val port = 47781 + spawnListener(port, "0.0.0.0", v6 = false).use { + val uid = resolveToUid(InetSocketAddress(anyV4, port), InetSocketAddress(anyV4, 0)) + assertEquals( + "wildcard-bound unconnected UDP should resolve to the shell uid via wildcard-remote " + + "(local=0.0.0.0:$port, remote=0.0.0.0:0)", + shellUid, uid + ) + } + } + + /** + * Characterization (corrects the original premise that a full 5-tuple query "misses" unconnected + * sockets): because an unconnected socket has no peer, the kernel's score check skips the remote, + * so a full-tuple query with an arbitrary remote still resolves it. + */ + @Test + fun unconnected_wildcardBound_fullTupleArbitraryRemote_characterize() { + val port = 47782 + spawnListener(port, "0.0.0.0", v6 = false).use { + val uid = resolveToUid(InetSocketAddress(anyV4, port), InetSocketAddress(InetAddress.getByName("8.8.8.8"), 53)) + Log.i( + TAG, + "CHARACTERIZE unconnected + full-tuple(8.8.8.8:53): uid=$uid -> " + + if (uid == shellUid) "RESOLVES (peer ignored for unconnected sockets)" else "MISSES" + ) + } + } + + /** Specific-bound unconnected socket resolves via wildcard-remote when queried with its real local. */ + @Test + fun unconnected_specificBound_wildcardRemote_resolvesOwnerUid() { + val ip = emulatorIpv4OrSkip() + val port = 47783 + spawnListener(port, ip.hostAddress!!, v6 = false).use { + val uid = resolveToUid(InetSocketAddress(ip, port), InetSocketAddress(anyV4, 0)) + assertEquals( + "specific-bound unconnected UDP should resolve via wildcard-remote with its real local " + + "(local=${ip.hostAddress}:$port)", + shellUid, uid + ) + } + } + + /** + * Key measurement: querying a *specific*-bound socket with a *wildcard* local (0.0.0.0). The kernel + * hashes on the local address, so a 0.0.0.0 query lands in a different bucket and misses. Confirms + * a fallback for specifically-bound sockets must use the packet's real source IP, not 0.0.0.0. + */ + @Test + fun unconnected_specificBound_wildcardLocalAndRemote_characterize() { + val ip = emulatorIpv4OrSkip() + val port = 47784 + spawnListener(port, ip.hostAddress!!, v6 = false).use { + val uid = resolveToUid(InetSocketAddress(anyV4, port), InetSocketAddress(anyV4, 0)) + Log.i( + TAG, + "CHARACTERIZE specific-bound(${ip.hostAddress}:$port) + wildcard-local query: uid=$uid -> " + + if (uid == shellUid) "MATCHES (0.0.0.0 query finds specific-bound socket)" + else "MISSES (fallback must use the packet's real source IP)" + ) + } + } + + /** Same core claim over IPv6; skipped where the image can't bind a v6 UDP listener. */ + @Test + fun ipv6_unconnected_wildcardBound_wildcardRemote_resolvesOwnerUid() { + val port = 47785 + spawnListener(port, "::", v6 = true).use { + assumeTrue("could not bind an IPv6 UDP listener on this image", isPortBound(port, v6 = true)) + val uid = resolveToUid(InetSocketAddress(anyV6, port), InetSocketAddress(anyV6, 0)) + assertEquals( + "wildcard-bound unconnected IPv6 UDP should resolve to the shell uid via wildcard-remote " + + "(local=[::]:$port, remote=[::]:0)", + shellUid, uid + ) + } + } + + // --- per-test helpers ------------------------------------------------------------------------- + + private val anyV4: InetAddress get() = InetAddress.getByName("0.0.0.0") + private val anyV6: InetAddress get() = InetAddress.getByName("::") + + private fun resolveToUid(local: InetSocketAddress, remote: InetSocketAddress, attempts: Int = 15): Int { + repeat(attempts) { + val u = try { + cm.getConnectionOwnerUid(OsConstants.IPPROTO_UDP, local, remote) + } catch (e: Exception) { Process.INVALID_UID } + if (u != Process.INVALID_UID) return u + } + return Process.INVALID_UID + } + + private fun emulatorIpv4OrSkip(): InetAddress { + val addr = runCatching { + java.net.NetworkInterface.getNetworkInterfaces().asSequence() + .filter { runCatching { it.isUp && !it.isLoopback }.getOrDefault(false) } + .flatMap { it.inetAddresses.asSequence() } + .firstOrNull { !it.isLoopbackAddress && !it.isAnyLocalAddress && it is java.net.Inet4Address } + }.getOrNull() + assumeTrue("no non-loopback IPv4 interface address on this device", addr != null) + return addr!! + } + + companion object { + private const val TAG = "ConnOwnerUidTest" + + private lateinit var context: Context + private lateinit var cm: ConnectivityManager + private var vpnStarted = false + private var shellUid = 2000 + + @BeforeClass + @JvmStatic + fun startRealVpn() { + context = ApplicationProvider.getApplicationContext() + cm = context.getSystemService(VpnService.CONNECTIVITY_SERVICE) as ConnectivityManager + shellUid = readShell("id -u").trim().toIntOrNull() ?: 2000 + + grantVpnConsent(context.packageName) + assumeTrue("VPN consent unavailable on this device/image", VpnService.prepare(context) == null) + + val status = EntryPointAccessors + .fromApplication(context, VpnStatusEntryPoint::class.java) + .vpnServiceStatus() + context.startForegroundService(Intent(context, VPNService::class.java)) + awaitStatus(status, VPN_SERVICE_STATUS.ENABLED) + vpnStarted = true + + // Precondition: confirm the active VPN can resolve a different-UID socket on this image at + // all (it cannot resolve its own UID — see class KDoc). If not, skip the whole class. + val probePort = 47780 + spawnListener(probePort, "0.0.0.0", v6 = false).use { + var resolved = Process.INVALID_UID + repeat(15) { + val u = cm.getConnectionOwnerUid( + OsConstants.IPPROTO_UDP, + InetSocketAddress(InetAddress.getByName("0.0.0.0"), probePort), + InetSocketAddress(InetAddress.getByName("0.0.0.0"), 0), + ) + if (u != Process.INVALID_UID) { resolved = u; return@repeat } + } + assumeTrue( + "getConnectionOwnerUid does not resolve the shell-UID listener on this image " + + "(got $resolved, shellUid=$shellUid) — cannot exercise the API here", + resolved == shellUid + ) + } + } + + @AfterClass + @JvmStatic + fun stopRealVpn() { + runCatching { execShell("pkill -f 'nc -u -l'") } + if (!vpnStarted) return + runCatching { + context.startService( + Intent(context, VPNService::class.java).apply { action = VPNService.STOP_VPN_SERVICE } + ) + } + } + + /** + * Spawn a UDP listener owned by the shell UID, bound to [bind]:[port]. Returns an + * [AutoCloseable] that tears the listener down. nc stays bound (waiting for a datagram we never + * send) for as long as the returned handle is open. + */ + private fun spawnListener(port: Int, bind: String, v6: Boolean): AutoCloseable { + val fam = if (v6) "-6" else "-4" + val automation = InstrumentationRegistry.getInstrumentation().uiAutomation + val pfd: ParcelFileDescriptor = + automation.executeShellCommand("toybox nc -u -l $fam -s $bind -p $port") + Thread.sleep(500) // let it bind + return AutoCloseable { + runCatching { pfd.close() } + runCatching { execShell("pkill -f 'nc -u -l $fam -s $bind -p $port'") } + } + } + + private fun isPortBound(port: Int, v6: Boolean): Boolean { + val hex = port.toString(16).uppercase().padStart(4, '0') + val file = if (v6) "/proc/net/udp6" else "/proc/net/udp" + return readShell("cat $file").lineSequence().any { + val t = it.trim().split(Regex("\\s+")) + t.size > 1 && t[1].endsWith(":$hex") + } + } + + private fun awaitStatus( + status: MutableLiveData, + expected: VPN_SERVICE_STATUS, + timeoutSeconds: Long = 10 + ) { + val latch = CountDownLatch(1) + val observer = Observer { if (it == 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) } + } + } + + private fun grantVpnConsent(packageName: String) { + execShell("appops set $packageName ACTIVATE_VPN allow") + } + + private fun execShell(command: String) { + readShell(command) + } + + private fun readShell(command: String): String { + val automation = InstrumentationRegistry.getInstrumentation().uiAutomation + val pfd = automation.executeShellCommand(command) + return FileInputStream(pfd.fileDescriptor).use { String(it.readBytes()) } + } + } +} diff --git a/app/src/androidTest/java/org/distrinet/lanshield/vpnservice/VpnServiceObserverLeakTest.kt b/app/src/androidTest/java/org/distrinet/lanshield/vpnservice/VpnServiceObserverLeakTest.kt index c58446b..8659dd3 100644 --- a/app/src/androidTest/java/org/distrinet/lanshield/vpnservice/VpnServiceObserverLeakTest.kt +++ b/app/src/androidTest/java/org/distrinet/lanshield/vpnservice/VpnServiceObserverLeakTest.kt @@ -53,7 +53,7 @@ class VpnServiceObserverLeakTest { ) @Test - fun `stop removes every observer it added`() { + fun stop_removesEveryObserverItAdded() { val context = ApplicationProvider.getApplicationContext() grantVpnConsent(context.packageName) assumeTrue("VPN consent unavailable on this device/image", VpnService.prepare(context) == null) diff --git a/app/src/main/java/org/distrinet/lanshield/vpnservice/VPNService.kt b/app/src/main/java/org/distrinet/lanshield/vpnservice/VPNService.kt index 24ea89f..f314472 100644 --- a/app/src/main/java/org/distrinet/lanshield/vpnservice/VPNService.kt +++ b/app/src/main/java/org/distrinet/lanshield/vpnservice/VPNService.kt @@ -395,9 +395,10 @@ class VPNService : VpnService(), IProtectSocket { vpnThread = Thread(vpnRunnable, "VPN thread") stopLanShieldSession() - lanShieldSession = LANShieldSession.createLANShieldSession() + val session = LANShieldSession.createLANShieldSession() + lanShieldSession = session CoroutineScope(Dispatchers.IO).launch { - lanShieldSessionDao.insert(lanShieldSession!!) + lanShieldSessionDao.insert(session) } vpnThread!!.start() setVPNRunning(true) @@ -406,4 +407,4 @@ class VPNService : VpnService(), IProtectSocket { private fun updateAlwaysOnStatus() { vpnAlwaysOnStatus.postValue(if (isAlwaysOn) VPN_ALWAYS_ON_STATUS.ENABLED else VPN_ALWAYS_ON_STATUS.DISABLED) } -} \ No newline at end of file +} From b5c97e67f8143418a8d6fc1eb5c332e0448e030b Mon Sep 17 00:00:00 2001 From: Jeroen Robben Date: Wed, 17 Jun 2026 00:45:00 +0200 Subject: [PATCH 4/5] add background glow to lanshield logo on overview screen --- .../lanshield/ui/overview/OverviewScreen.kt | 88 +++++++++++++++++-- 1 file changed, 81 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/org/distrinet/lanshield/ui/overview/OverviewScreen.kt b/app/src/main/java/org/distrinet/lanshield/ui/overview/OverviewScreen.kt index 00a9ea9..4d30089 100644 --- a/app/src/main/java/org/distrinet/lanshield/ui/overview/OverviewScreen.kt +++ b/app/src/main/java/org/distrinet/lanshield/ui/overview/OverviewScreen.kt @@ -52,6 +52,14 @@ import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.withFrameNanos +import androidx.compose.ui.draw.blur +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.graphicsLayer import androidx.lifecycle.compose.collectAsStateWithLifecycle import org.distrinet.lanshield.Policy import org.distrinet.lanshield.R @@ -60,6 +68,8 @@ import org.distrinet.lanshield.VPN_ALWAYS_ON_STATUS import org.distrinet.lanshield.VPN_SERVICE_STATUS import org.distrinet.lanshield.ui.LANShieldIcons import org.distrinet.lanshield.ui.theme.LANShieldTheme +import kotlin.math.PI +import kotlin.math.cos @Composable internal fun OverviewRoute( @@ -172,13 +182,9 @@ internal fun OverviewScreen( } } - Image( - painter = painterResource(id = R.mipmap.logo_foreground), // replace 'logo' with the actual name of your logo file - contentDescription = stringResource(id = R.string.lanshield_logo), - modifier = Modifier - .size(150.dp) - .align(Alignment.CenterHorizontally), // adjust the size as needed - contentScale = ContentScale.Crop + ShieldHero( + enabled = isSwitchChecked, + modifier = Modifier.align(Alignment.CenterHorizontally), ) Text( modifier = Modifier @@ -291,6 +297,74 @@ internal fun TopBarOverview(modifier: Modifier = Modifier) { ) } +@Composable +private fun ShieldHero( + enabled: Boolean, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + val animationsEnabled = remember { + Settings.Global.getFloat( + context.contentResolver, + Settings.Global.ANIMATOR_DURATION_SCALE, + 1f, + ) != 0f + } + + val pulse = remember { mutableFloatStateOf(0f) } + LaunchedEffect(enabled, animationsEnabled) { + if (!enabled) { + pulse.floatValue = 0f + return@LaunchedEffect + } + if (!animationsEnabled) { + pulse.floatValue = 1f + return@LaunchedEffect + } + val start = withFrameNanos { it } + while (true) { + val now = withFrameNanos { it } + val t = (now - start) / 1_000_000f / BREATHE_MS + pulse.floatValue = (1f - cos(t * 2f * PI.toFloat())) / 2f + } + } + + val glowColor = if (isSystemInDarkTheme()) GlowGreenDark else GlowGreenLight + val logoSize = 150.dp + + Box(modifier = modifier.size(232.dp), contentAlignment = Alignment.Center) { + if (enabled) { + Image( + painter = painterResource(R.mipmap.logo_foreground), + contentDescription = null, + contentScale = ContentScale.Fit, + colorFilter = ColorFilter.tint(glowColor), + modifier = Modifier + .size(logoSize * 1.5f) + .blur(36.dp) + .graphicsLayer { + val p = pulse.floatValue + alpha = 0.22f + 0.30f * p + val s = 1f + 0.06f * p + scaleX = s + scaleY = s + }, + ) + } + Image( + painter = painterResource(R.mipmap.logo_foreground), + contentDescription = stringResource(R.string.lanshield_logo), + contentScale = ContentScale.Fit, + modifier = Modifier.size(logoSize), + ) + } +} + +private const val BREATHE_MS = 3200f + +private val GlowGreenLight = Color(0xFF146C2E) +private val GlowGreenDark = Color(0xFF8BD89B) + @Composable fun OverviewStatus(defaultPolicy: Policy, amountBlockedApps: Int, amountAllowedApps: Int) { From 6fb22e82c4df7196d312a2ca5f864549d782b5cd Mon Sep 17 00:00:00 2001 From: Jeroen Robben Date: Wed, 17 Jun 2026 00:56:14 +0200 Subject: [PATCH 5/5] cleanup tests --- .../lanshield/ExampleInstrumentedTest.kt | 5 --- .../NotificationPermissionGateTest.kt | 14 ------ .../ConnectionOwnerUidUnconnectedUdpTest.kt | 43 ------------------- .../VpnForwardingInstrumentedTest.kt | 18 -------- .../vpnservice/VpnServiceObserverLeakTest.kt | 14 ------ .../vpnservice/VpnServiceStartCommandTest.kt | 15 ------- .../vpn/ConcurrentDownloadInstrumentedTest.kt | 9 ---- .../vpn/SlowClientDownloadInstrumentedTest.kt | 14 ------ .../lanshield/PackageResolutionTest.kt | 4 -- .../database/model/LANFlowJsonTest.kt | 5 --- .../lanshield/database/model/LANFlowTest.kt | 5 --- .../lanshield/testutil/LiveDataTestUtil.kt | 6 --- .../ui/settings/SettingsViewModelTest.kt | 5 --- .../lanshield/vpnservice/IPHeaderTest.kt | 16 ------- .../lanshield/vpnservice/PolicyEngineTest.kt | 6 --- .../vpn/ClientPacketWriterLargePacketTest.kt | 13 ------ .../vpn/ConnectionTrackingForwardingTest.kt | 9 ---- .../android/vpn/ForwardingTestHarness.kt | 20 --------- .../android/vpn/IcmpForwardingTest.kt | 5 --- .../vpn/MixedProtocolConcurrencyTest.kt | 6 --- .../android/vpn/PacketFactoryRoundTripTest.kt | 4 -- .../vpn/SourceAddressForwardingTest.kt | 17 -------- .../android/vpn/TcpConcurrentWindowsTest.kt | 7 --- .../android/vpn/TcpDownloadFlowControlTest.kt | 9 ---- .../android/vpn/TcpEdgeCaseForwardingTest.kt | 6 --- .../android/vpn/TcpForwardingTest.kt | 5 --- .../android/vpn/TcpWindowedDownloadTest.kt | 7 --- .../httptoolkit/android/vpn/TestPackets.kt | 9 ---- .../android/vpn/UdpConcurrentFlowsTest.kt | 6 --- .../android/vpn/UdpForwardingTest.kt | 5 --- .../vpn/UdpLargeDatagramForwardingTest.kt | 9 ---- 31 files changed, 316 deletions(-) diff --git a/app/src/androidTest/java/org/distrinet/lanshield/ExampleInstrumentedTest.kt b/app/src/androidTest/java/org/distrinet/lanshield/ExampleInstrumentedTest.kt index d60aac1..a577a03 100644 --- a/app/src/androidTest/java/org/distrinet/lanshield/ExampleInstrumentedTest.kt +++ b/app/src/androidTest/java/org/distrinet/lanshield/ExampleInstrumentedTest.kt @@ -8,11 +8,6 @@ import org.junit.runner.RunWith import org.junit.Assert.* -/** - * Instrumented test, which will execute on an Android device. - * - * See [testing documentation](http://d.android.com/tools/testing). - */ @RunWith(AndroidJUnit4::class) class ExampleInstrumentedTest { @Test diff --git a/app/src/androidTest/java/org/distrinet/lanshield/NotificationPermissionGateTest.kt b/app/src/androidTest/java/org/distrinet/lanshield/NotificationPermissionGateTest.kt index 7e0b2c8..7a312a7 100644 --- a/app/src/androidTest/java/org/distrinet/lanshield/NotificationPermissionGateTest.kt +++ b/app/src/androidTest/java/org/distrinet/lanshield/NotificationPermissionGateTest.kt @@ -27,20 +27,6 @@ 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 { diff --git a/app/src/androidTest/java/org/distrinet/lanshield/vpnservice/ConnectionOwnerUidUnconnectedUdpTest.kt b/app/src/androidTest/java/org/distrinet/lanshield/vpnservice/ConnectionOwnerUidUnconnectedUdpTest.kt index af70b96..61d095a 100644 --- a/app/src/androidTest/java/org/distrinet/lanshield/vpnservice/ConnectionOwnerUidUnconnectedUdpTest.kt +++ b/app/src/androidTest/java/org/distrinet/lanshield/vpnservice/ConnectionOwnerUidUnconnectedUdpTest.kt @@ -29,37 +29,9 @@ import java.net.InetSocketAddress import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit -/** - * On-device characterization of [ConnectivityManager.getConnectionOwnerUid] for **unconnected UDP - * sockets** — the basis for attributing LAN UDP discovery traffic (mDNS/SSDP/IoT) to an app, the way - * [org.distrinet.lanshield.backendsync.OpenPortsFinder] already does (wildcard-remote query) and the - * way a future [VPNRunnable.getPacketOwnerUid] fallback might. - * - * Important platform facts this test is built around (traced through AOSP + verified on an API 36 - * AVD; see project memory `getconnectionowneruid-quirks`): - * - The active VPN gets `INVALID_UID` for sockets owned by its **own** UID — `ConnectivityService` - * filters the result through `NetworkCapabilities.appliesToUid`, and `addDisallowedApplication` - * removes LANShield's own UID from the tunnel. So the socket under test must be owned by a - * **different** UID. We use a UDP listener spawned via the instrumentation shell (UID `shell`), - * which the tunnel does cover. - * - For UDP the framework swaps src/dst for the exact lookup (kernel bug workaround, aosp/755889) - * and, on miss, does a wildcard-remote DUMP reading only the first netlink record. The lookup is - * therefore **non-deterministic** (per-CPU scoring, SO_REUSEPORT, dump ordering). Every assertion - * here resolves with retries, mirroring production's `repeat(5)`. - * - * `getConnectionOwnerUid` needs the caller to be the active VpnService, so we bring up the real - * LANShield [VPNService] once for the class and grant consent via the `ACTIVATE_VPN` app-op. Where - * that isn't honored, or the shell UID isn't resolvable on the image, the class self-skips. - * - * Run with: `./gradlew connectedFossDebugAndroidTest` against a debuggable emulator. - */ @RunWith(AndroidJUnit4::class) class ConnectionOwnerUidUnconnectedUdpTest { - /** - * Core claim: an unconnected UDP socket bound to the wildcard address resolves to its owner via a - * wildcard-remote query (the OpenPortsFinder / proposed-fallback technique). - */ @Test fun unconnected_wildcardBound_wildcardRemote_resolvesOwnerUid() { val port = 47781 @@ -73,11 +45,6 @@ class ConnectionOwnerUidUnconnectedUdpTest { } } - /** - * Characterization (corrects the original premise that a full 5-tuple query "misses" unconnected - * sockets): because an unconnected socket has no peer, the kernel's score check skips the remote, - * so a full-tuple query with an arbitrary remote still resolves it. - */ @Test fun unconnected_wildcardBound_fullTupleArbitraryRemote_characterize() { val port = 47782 @@ -106,11 +73,6 @@ class ConnectionOwnerUidUnconnectedUdpTest { } } - /** - * Key measurement: querying a *specific*-bound socket with a *wildcard* local (0.0.0.0). The kernel - * hashes on the local address, so a 0.0.0.0 query lands in a different bucket and misses. Confirms - * a fallback for specifically-bound sockets must use the packet's real source IP, not 0.0.0.0. - */ @Test fun unconnected_specificBound_wildcardLocalAndRemote_characterize() { val ip = emulatorIpv4OrSkip() @@ -225,11 +187,6 @@ class ConnectionOwnerUidUnconnectedUdpTest { } } - /** - * Spawn a UDP listener owned by the shell UID, bound to [bind]:[port]. Returns an - * [AutoCloseable] that tears the listener down. nc stays bound (waiting for a datagram we never - * send) for as long as the returned handle is open. - */ private fun spawnListener(port: Int, bind: String, v6: Boolean): AutoCloseable { val fam = if (v6) "-6" else "-4" val automation = InstrumentationRegistry.getInstrumentation().uiAutomation diff --git a/app/src/androidTest/java/org/distrinet/lanshield/vpnservice/VpnForwardingInstrumentedTest.kt b/app/src/androidTest/java/org/distrinet/lanshield/vpnservice/VpnForwardingInstrumentedTest.kt index 554d73c..f093f45 100644 --- a/app/src/androidTest/java/org/distrinet/lanshield/vpnservice/VpnForwardingInstrumentedTest.kt +++ b/app/src/androidTest/java/org/distrinet/lanshield/vpnservice/VpnForwardingInstrumentedTest.kt @@ -8,24 +8,6 @@ import org.junit.Ignore import org.junit.Test import org.junit.runner.RunWith -/** - * Opt-in, on-device end-to-end test of the real VpnService path. This is NOT part of CI: - * establishing a VPN requires the system consent dialog (returned by [VpnService.prepare]), - * which cannot be granted programmatically. The deterministic forward/receive behaviour is - * already covered locally by UdpForwardingTest / TcpForwardingTest against loopback sockets. - * - * To run manually on a connected device/emulator (and grant the consent dialog by hand, or - * drive it with UiAutomator): - * - * ./gradlew connectedFossDebugAndroidTest \ - * -Pandroid.testInstrumentationRunnerArguments.notAnnotation=org.junit.Ignore - * - * A full implementation would: call VpnService.prepare(); accept the consent intent (manual - * or UiAutomator By.text("OK")/Allow — locale-fragile); start LANShield's VpnService; drive - * an outbound connection to a known LAN/loopback peer; and assert a LANFlow row is recorded - * in the database. Kept as a documented skeleton so it stays compilable without pulling in - * UiAutomator or wiring CI for VPN consent. - */ @RunWith(AndroidJUnit4::class) class VpnForwardingInstrumentedTest { diff --git a/app/src/androidTest/java/org/distrinet/lanshield/vpnservice/VpnServiceObserverLeakTest.kt b/app/src/androidTest/java/org/distrinet/lanshield/vpnservice/VpnServiceObserverLeakTest.kt index 8659dd3..fc58a6a 100644 --- a/app/src/androidTest/java/org/distrinet/lanshield/vpnservice/VpnServiceObserverLeakTest.kt +++ b/app/src/androidTest/java/org/distrinet/lanshield/vpnservice/VpnServiceObserverLeakTest.kt @@ -22,20 +22,6 @@ import java.io.FileInputStream import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit -/** - * EXPOSES BUG (Finding 2): [VPNService.startVPNThread] registers seven `observeForever` observers but - * [VPNService.stopVPNThread] removes only three. The four it forgets — `allowMulticastLive`, - * `allowDnsLive`, `hideMulticastNotificationsLive`, `hideDnsNotificationsLive` — stay attached after - * the VPN stops, leaking the old [VPNRunnable] on every stop/restart cycle. - * - * The test drives the real service (start → stop) on a device/emulator, grabbing the running - * [VPNService] instance from `ActivityThread.mServices` so it can inspect the private LiveData fields. - * After the stop, every observer added at start should be gone; the four leaked sources still report - * observers, so `stop removes every observer it added` FAILS until the leak is fixed. - * - * Like [VpnServiceStartCommandTest], it self-skips where VPN consent cannot be granted, keeping - * consent automation out of mandatory CI. - */ @RunWith(AndroidJUnit4::class) class VpnServiceObserverLeakTest { diff --git a/app/src/androidTest/java/org/distrinet/lanshield/vpnservice/VpnServiceStartCommandTest.kt b/app/src/androidTest/java/org/distrinet/lanshield/vpnservice/VpnServiceStartCommandTest.kt index 564cd89..d726bf2 100644 --- a/app/src/androidTest/java/org/distrinet/lanshield/vpnservice/VpnServiceStartCommandTest.kt +++ b/app/src/androidTest/java/org/distrinet/lanshield/vpnservice/VpnServiceStartCommandTest.kt @@ -20,17 +20,6 @@ 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 { @@ -97,10 +86,6 @@ class VpnServiceStartCommandTest { } } - /** - * 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") } diff --git a/app/src/androidTest/java/tech/httptoolkit/android/vpn/ConcurrentDownloadInstrumentedTest.kt b/app/src/androidTest/java/tech/httptoolkit/android/vpn/ConcurrentDownloadInstrumentedTest.kt index 506e269..582ff49 100644 --- a/app/src/androidTest/java/tech/httptoolkit/android/vpn/ConcurrentDownloadInstrumentedTest.kt +++ b/app/src/androidTest/java/tech/httptoolkit/android/vpn/ConcurrentDownloadInstrumentedTest.kt @@ -28,15 +28,6 @@ import java.util.concurrent.Future import java.util.concurrent.LinkedBlockingQueue import java.util.concurrent.TimeUnit -/** - * On-device end-to-end test running several windowed TCP downloads through the real engine at - * once, each with a different receive window. Proves on a real Android TCP stack + NIO selector - * that concurrent flows are tracked and flow-controlled independently: each delivered in order, - * in full, with unacked data never exceeding its own window, and no bytes leaking across ports. - * - * Self-contained (the unit-test harness isn't visible to androidTest): it inlines a capturing - * writer and minimal TCP packet builders. - */ @RunWith(AndroidJUnit4::class) class ConcurrentDownloadInstrumentedTest { diff --git a/app/src/androidTest/java/tech/httptoolkit/android/vpn/SlowClientDownloadInstrumentedTest.kt b/app/src/androidTest/java/tech/httptoolkit/android/vpn/SlowClientDownloadInstrumentedTest.kt index 5f71d32..f6d169a 100644 --- a/app/src/androidTest/java/tech/httptoolkit/android/vpn/SlowClientDownloadInstrumentedTest.kt +++ b/app/src/androidTest/java/tech/httptoolkit/android/vpn/SlowClientDownloadInstrumentedTest.kt @@ -27,20 +27,6 @@ import java.util.concurrent.Executors import java.util.concurrent.LinkedBlockingQueue import java.util.concurrent.TimeUnit -/** - * On-device (instrumented) end-to-end test of the TCP download flow-control fix, running the - * REAL forwarding engine (SessionHandler / SessionManager / SocketNIODataService) against a - * real loopback server on the device. - * - * It models a slow-draining client (like Firefox) by acting as the client's TCP stack: it - * advertises a small receive window and acks the contiguous prefix as it consumes each - * segment. Because the engine now respects that window, it never over-sends, so nothing is - * dropped and the whole file is delivered in order — the on-device proof that the download - * completes instead of stalling. - * - * Self-contained (the unit-test ForwardingTestHarness/TestPackets are not visible to - * androidTest), so it inlines a minimal capturing writer and TCP packet builder. - */ @RunWith(AndroidJUnit4::class) class SlowClientDownloadInstrumentedTest { diff --git a/app/src/test/java/org/distrinet/lanshield/PackageResolutionTest.kt b/app/src/test/java/org/distrinet/lanshield/PackageResolutionTest.kt index 733bbeb..df1095a 100644 --- a/app/src/test/java/org/distrinet/lanshield/PackageResolutionTest.kt +++ b/app/src/test/java/org/distrinet/lanshield/PackageResolutionTest.kt @@ -11,10 +11,6 @@ import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config -/** - * Tests the app-id / package resolution logic in LANShieldApplication.kt. Runs under - * Robolectric so a real [ApplicationInfo] can be constructed; [PackageManager] is mocked. - */ @RunWith(RobolectricTestRunner::class) @Config(sdk = [34], application = Application::class) class PackageResolutionTest { diff --git a/app/src/test/java/org/distrinet/lanshield/database/model/LANFlowJsonTest.kt b/app/src/test/java/org/distrinet/lanshield/database/model/LANFlowJsonTest.kt index 83a8507..863cc1e 100644 --- a/app/src/test/java/org/distrinet/lanshield/database/model/LANFlowJsonTest.kt +++ b/app/src/test/java/org/distrinet/lanshield/database/model/LANFlowJsonTest.kt @@ -13,11 +13,6 @@ import java.io.StringWriter import java.net.InetAddress import java.net.InetSocketAddress -/** - * Robolectric is required here because [LANFlow.toJSON]/[LANFlow.writeJson] use - * `org.json.JSONObject` and `android.util.JsonWriter`, which are stubbed (and throw) - * under plain unit tests. - */ @RunWith(RobolectricTestRunner::class) @Config(sdk = [34], application = Application::class) class LANFlowJsonTest { diff --git a/app/src/test/java/org/distrinet/lanshield/database/model/LANFlowTest.kt b/app/src/test/java/org/distrinet/lanshield/database/model/LANFlowTest.kt index e408f70..13d26be 100644 --- a/app/src/test/java/org/distrinet/lanshield/database/model/LANFlowTest.kt +++ b/app/src/test/java/org/distrinet/lanshield/database/model/LANFlowTest.kt @@ -9,11 +9,6 @@ import java.time.Instant import java.time.OffsetDateTime import java.time.format.DateTimeFormatter -/** - * Pure-JVM tests for the side-effect-free parts of [LANFlow]. JSON serialization - * (which relies on `org.json` / `android.util.JsonWriter`) is covered separately - * under Robolectric in [LANFlowJsonTest]. - */ class LANFlowTest { private val remote = InetSocketAddress(InetAddress.getByName("8.8.8.8"), 443) diff --git a/app/src/test/java/org/distrinet/lanshield/testutil/LiveDataTestUtil.kt b/app/src/test/java/org/distrinet/lanshield/testutil/LiveDataTestUtil.kt index d87ac2b..93bde87 100644 --- a/app/src/test/java/org/distrinet/lanshield/testutil/LiveDataTestUtil.kt +++ b/app/src/test/java/org/distrinet/lanshield/testutil/LiveDataTestUtil.kt @@ -6,12 +6,6 @@ import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit import java.util.concurrent.TimeoutException -/** - * Observes a [LiveData] until it emits a value (or times out) and returns it. - * - * Pair with `InstantTaskExecutorRule` so LiveData work runs synchronously. Adapted - * from the common Android architecture-components testing recipe. - */ fun LiveData.getOrAwaitValue( time: Long = 2, timeUnit: TimeUnit = TimeUnit.SECONDS, diff --git a/app/src/test/java/org/distrinet/lanshield/ui/settings/SettingsViewModelTest.kt b/app/src/test/java/org/distrinet/lanshield/ui/settings/SettingsViewModelTest.kt index 2cefb07..453deb6 100644 --- a/app/src/test/java/org/distrinet/lanshield/ui/settings/SettingsViewModelTest.kt +++ b/app/src/test/java/org/distrinet/lanshield/ui/settings/SettingsViewModelTest.kt @@ -21,11 +21,6 @@ import org.junit.Test import org.junit.rules.TemporaryFolder import java.io.File -/** - * Exercises [SettingsViewModel] against a real (file-backed) Preferences DataStore. - * DataStore-core is pure JVM, so no Robolectric is needed; the view-model's - * `viewModelScope` is driven by overriding the Main dispatcher. - */ @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) class SettingsViewModelTest { diff --git a/app/src/test/java/org/distrinet/lanshield/vpnservice/IPHeaderTest.kt b/app/src/test/java/org/distrinet/lanshield/vpnservice/IPHeaderTest.kt index c38f49f..e323f19 100644 --- a/app/src/test/java/org/distrinet/lanshield/vpnservice/IPHeaderTest.kt +++ b/app/src/test/java/org/distrinet/lanshield/vpnservice/IPHeaderTest.kt @@ -104,17 +104,6 @@ class IPHeaderTest { IPHeader(buffer(*bytes)) } - /** - * EXPOSES BUG (Finding 3): the constructor only requires `limit() >= 24` for IPv4, but the TCP - * branch then reads `rawPacket.get(ipHeaderLength + 12)` (IPHeader.kt:92) — absolute index 32 for - * a standard IHL=5 header. A truncated/crafted TCP packet of 24–32 bytes passes the size check yet - * reads out of bounds, throwing IndexOutOfBoundsException instead of the controlled - * IllegalArgumentException the parser raises for malformed input. - * - * This 28-byte packet passes `require(limit >= 24)` (ports live at offsets 20–23) but has no byte - * at index 32. Expected once fixed: a controlled IllegalArgumentException (consistent with the - * other too-short cases). Until then this FAILS with IndexOutOfBoundsException. - */ @Test(expected = IllegalArgumentException::class) fun `truncated ipv4 tcp packet does not read out of bounds`() { IPHeader( @@ -130,11 +119,6 @@ class IPHeaderTest { ) } - /** - * EXPOSES BUG (Finding 3), IPv6 variant: the constructor requires `limit() >= 44`, but the TCP - * branch reads `rawPacket.get(40 + 12)` = absolute index 52. This 48-byte packet passes the size - * check (ports at offsets 40-43) yet has no byte at index 52. - */ @Test(expected = IllegalArgumentException::class) fun `truncated ipv6 tcp packet does not read out of bounds`() { IPHeader( diff --git a/app/src/test/java/org/distrinet/lanshield/vpnservice/PolicyEngineTest.kt b/app/src/test/java/org/distrinet/lanshield/vpnservice/PolicyEngineTest.kt index dc7be7e..4fb86c1 100644 --- a/app/src/test/java/org/distrinet/lanshield/vpnservice/PolicyEngineTest.kt +++ b/app/src/test/java/org/distrinet/lanshield/vpnservice/PolicyEngineTest.kt @@ -8,12 +8,6 @@ import org.distrinet.lanshield.Policy.DEFAULT import org.junit.Test import java.net.InetAddress -/** - * Pure-JVM tests for the packet forwarding decision engine. This is a - * behaviour-preserving extraction of the logic previously inline in - * [VPNRunnable.shouldForwardPacket]; the cases below double as the executable - * specification for that decision matrix. - */ class PolicyEngineTest { private val unicast: InetAddress = InetAddress.getByName("8.8.8.8") diff --git a/app/src/test/java/tech/httptoolkit/android/vpn/ClientPacketWriterLargePacketTest.kt b/app/src/test/java/tech/httptoolkit/android/vpn/ClientPacketWriterLargePacketTest.kt index 976f674..3013717 100644 --- a/app/src/test/java/tech/httptoolkit/android/vpn/ClientPacketWriterLargePacketTest.kt +++ b/app/src/test/java/tech/httptoolkit/android/vpn/ClientPacketWriterLargePacketTest.kt @@ -7,19 +7,6 @@ import org.junit.Test import java.io.File import java.io.FileOutputStream -/** - * EXPOSES BUG (Finding 1): [ClientPacketWriter.write] throws a raw [Error] for any packet larger - * than 30000 bytes (ClientPacketWriter.java:53). Because the writer is driven from the NIO thread - * — which catches only IOException/channel exceptions — a single oversized packet (e.g. a UDP reply - * up to MAX_RECEIVE_BUFFER_SIZE = 65535 bytes, see DataConst.java) propagates an uncaught Error that - * kills the forwarding engine. - * - * This is a pure-JVM reproducer with no threads: write() only enqueues, so the size guard fires (or - * doesn't) synchronously on the calling thread. - * - * Expected once fixed: oversized packets are handled gracefully (dropped/split/logged), never throw. - * Until then `writes a 30001-byte packet without crashing` FAILS. - */ class ClientPacketWriterLargePacketTest { private lateinit var tempFile: File diff --git a/app/src/test/java/tech/httptoolkit/android/vpn/ConnectionTrackingForwardingTest.kt b/app/src/test/java/tech/httptoolkit/android/vpn/ConnectionTrackingForwardingTest.kt index a782c19..1bac941 100644 --- a/app/src/test/java/tech/httptoolkit/android/vpn/ConnectionTrackingForwardingTest.kt +++ b/app/src/test/java/tech/httptoolkit/android/vpn/ConnectionTrackingForwardingTest.kt @@ -19,15 +19,6 @@ import java.net.Socket import java.util.concurrent.Executors import java.util.concurrent.TimeUnit -/** - * End-to-end correctness checks on the engine's connection tracking, through the real - * forwarding pipeline against loopback peers. Exercises both directions: - * - * - egress (packet intercepted from a local app): the engine creates/reuses the right - * session per 5-tuple, and demultiplexes concurrent connections without crossing streams; - * - ingress (data coming back from the LAN peer): each reply is routed back to the exact - * client connection that originated it, and traffic for an unknown connection is rejected. - */ @RunWith(RobolectricTestRunner::class) @Config(sdk = [34], application = Application::class) class ConnectionTrackingForwardingTest { diff --git a/app/src/test/java/tech/httptoolkit/android/vpn/ForwardingTestHarness.kt b/app/src/test/java/tech/httptoolkit/android/vpn/ForwardingTestHarness.kt index f38b9a9..b762637 100644 --- a/app/src/test/java/tech/httptoolkit/android/vpn/ForwardingTestHarness.kt +++ b/app/src/test/java/tech/httptoolkit/android/vpn/ForwardingTestHarness.kt @@ -22,11 +22,6 @@ import java.nio.ByteBuffer import java.util.concurrent.LinkedBlockingQueue import java.util.concurrent.TimeUnit -/** - * Captures packets the engine would write back to the TUN interface, instead of writing - * them to a real file descriptor. [write] is overridable on the base class, so this never - * touches the (throwaway) FileOutputStream and never needs the drain thread. - */ class CapturingClientPacketWriter(out: FileOutputStream) : ClientPacketWriter(out) { val queue = LinkedBlockingQueue() override fun write(data: ByteArray) { @@ -34,16 +29,6 @@ class CapturingClientPacketWriter(out: FileOutputStream) : ClientPacketWriter(ou } } -/** - * Reusable harness that wires up the real forwarding engine against an in-memory database - * and a capturing packet writer, with socket protection stubbed out so the engine uses - * ordinary OS sockets. Lets tests feed crafted packets and observe both the TUN-bound - * output and the recorded flows. Lives in package `tech.httptoolkit.android.vpn` so it can - * use the engine's package-visible API. - * - * Place `@RunWith(RobolectricTestRunner)` `@Config(sdk=[34], application=Application)` on the - * test classes that use it; create one per test and `close()` it in `@After`. - */ class ForwardingTestHarness : AutoCloseable { val db: AppDatabase @@ -86,11 +71,6 @@ class ForwardingTestHarness : AutoCloseable { fun pollTunPacket(timeoutMs: Long = 2000): ByteArray? = writer.queue.poll(timeoutMs, TimeUnit.MILLISECONDS) - /** - * Await the first captured TUN packet matching [predicate], discarding non-matching - * packets along the way (the engine emits several packets per flow, e.g. an ACK before - * a data segment, so filter rather than assume order). - */ fun awaitTunPacketMatching(timeoutMs: Long = 2000, predicate: (ByteArray) -> Boolean): ByteArray { val deadline = System.nanoTime() + timeoutMs * 1_000_000 while (System.nanoTime() < deadline) { diff --git a/app/src/test/java/tech/httptoolkit/android/vpn/IcmpForwardingTest.kt b/app/src/test/java/tech/httptoolkit/android/vpn/IcmpForwardingTest.kt index a29f7be..b672b06 100644 --- a/app/src/test/java/tech/httptoolkit/android/vpn/IcmpForwardingTest.kt +++ b/app/src/test/java/tech/httptoolkit/android/vpn/IcmpForwardingTest.kt @@ -15,11 +15,6 @@ import tech.httptoolkit.android.vpn.transport.icmp.ICMPPacketFactory import tech.httptoolkit.android.vpn.transport.ip.IPPacketFactory import java.nio.ByteBuffer -/** - * Tests the engine's ICMP handling: echo requests proxied to a reachable host and the reply - * returned to the client (live, against loopback), reply construction (factory round-trip), - * types we can't proxy dropped/rejected, and that ICMP is never connection-tracked. - */ @RunWith(RobolectricTestRunner::class) @Config(sdk = [34], application = Application::class) class IcmpForwardingTest { diff --git a/app/src/test/java/tech/httptoolkit/android/vpn/MixedProtocolConcurrencyTest.kt b/app/src/test/java/tech/httptoolkit/android/vpn/MixedProtocolConcurrencyTest.kt index 736f6ac..f401980 100644 --- a/app/src/test/java/tech/httptoolkit/android/vpn/MixedProtocolConcurrencyTest.kt +++ b/app/src/test/java/tech/httptoolkit/android/vpn/MixedProtocolConcurrencyTest.kt @@ -17,12 +17,6 @@ import java.net.Socket import java.util.concurrent.Executors import java.util.concurrent.TimeUnit -/** - * Stress / consistency check: TCP downloads, UDP echo flows and ICMP pings run through the one - * engine at the same time, exercising the shared NIO thread and session table under mixed load. - * It asserts the protocols never bleed into each other — each TCP download in order on its own - * port, each UDP reply on its own port, ICMP answered but never connection-tracked. - */ @RunWith(RobolectricTestRunner::class) @Config(sdk = [34], application = Application::class) class MixedProtocolConcurrencyTest { diff --git a/app/src/test/java/tech/httptoolkit/android/vpn/PacketFactoryRoundTripTest.kt b/app/src/test/java/tech/httptoolkit/android/vpn/PacketFactoryRoundTripTest.kt index 8df002c..fd73d56 100644 --- a/app/src/test/java/tech/httptoolkit/android/vpn/PacketFactoryRoundTripTest.kt +++ b/app/src/test/java/tech/httptoolkit/android/vpn/PacketFactoryRoundTripTest.kt @@ -10,10 +10,6 @@ import tech.httptoolkit.android.vpn.transport.udp.UDPHeader import tech.httptoolkit.android.vpn.transport.udp.UDPPacketFactory import java.nio.ByteBuffer -/** - * Pure-JVM round-trip tests for the packet factories: parse a crafted packet, feed the - * parsed headers into a response builder, and re-parse to confirm fields/flags/payload. - */ class PacketFactoryRoundTripTest { private fun parseIpTcp(packet: ByteArray): Pair { diff --git a/app/src/test/java/tech/httptoolkit/android/vpn/SourceAddressForwardingTest.kt b/app/src/test/java/tech/httptoolkit/android/vpn/SourceAddressForwardingTest.kt index e1bbfa7..2437171 100644 --- a/app/src/test/java/tech/httptoolkit/android/vpn/SourceAddressForwardingTest.kt +++ b/app/src/test/java/tech/httptoolkit/android/vpn/SourceAddressForwardingTest.kt @@ -18,23 +18,6 @@ import java.net.Socket import java.util.concurrent.Executors import java.util.concurrent.TimeUnit -/** - * The tun interface is assigned a synthetic VPN address (10.215.173.1), but because the - * kernel picks a packet's source address from the destination, intercepted packets arrive - * with two different source IPs: - * - * - the VPN-tun IP (10.215.173.1) for destinations outside the device's own subnet, and - * - the device's real wlan IP (e.g. 192.168.1.100) for destinations on the local subnet. - * - * The forwarding engine handles the source IP opaquely, so both forms must round-trip - * symmetrically: the peer's reply must return to the TUN addressed to whatever source the - * client used, and the recorded [LANFlow.localEndpoint] must carry that same source. These - * parameterized tests lock that in for both source-IP forms, for UDP and TCP. - * - * Only the client source IP is varied; the peer stays on loopback (127.0.0.1) since a JVM - * test can't bind a peer on a real 192.168.x subnet and the engine connects to the - * destination regardless of source. - */ @RunWith(ParameterizedRobolectricTestRunner::class) @Config(sdk = [34], application = Application::class) class SourceAddressForwardingTest( diff --git a/app/src/test/java/tech/httptoolkit/android/vpn/TcpConcurrentWindowsTest.kt b/app/src/test/java/tech/httptoolkit/android/vpn/TcpConcurrentWindowsTest.kt index c0d8699..f38e202 100644 --- a/app/src/test/java/tech/httptoolkit/android/vpn/TcpConcurrentWindowsTest.kt +++ b/app/src/test/java/tech/httptoolkit/android/vpn/TcpConcurrentWindowsTest.kt @@ -15,13 +15,6 @@ import java.net.Socket import java.util.concurrent.Executors import java.util.concurrent.TimeUnit -/** - * Drives many concurrent TCP downloads through the one engine, each advertising a *different* - * receive window. All flows share one capture queue and are demultiplexed by destination port, - * so the test also proves connection tracking keeps the streams separate: each flow is - * delivered strictly in order, in full, with unacked data never exceeding its own window, and - * a client port can be reused once the previous connection is torn down. - */ @RunWith(RobolectricTestRunner::class) @Config(sdk = [34], application = Application::class) class TcpConcurrentWindowsTest { diff --git a/app/src/test/java/tech/httptoolkit/android/vpn/TcpDownloadFlowControlTest.kt b/app/src/test/java/tech/httptoolkit/android/vpn/TcpDownloadFlowControlTest.kt index 0588116..63f6900 100644 --- a/app/src/test/java/tech/httptoolkit/android/vpn/TcpDownloadFlowControlTest.kt +++ b/app/src/test/java/tech/httptoolkit/android/vpn/TcpDownloadFlowControlTest.kt @@ -14,15 +14,6 @@ import java.net.Socket import java.util.concurrent.Executors import java.util.concurrent.TimeUnit -/** - * Verifies TCP receive-window flow control on the download (server -> app) path: the engine - * must never have more unacknowledged data in flight than the client's advertised window. - * - * Previously the engine ignored the window and pushed the whole response unacknowledged, - * which is why large downloads failed in slow-draining clients (Firefox while Chrome/curl - * succeeded). This drives a download where the client advertises a tiny window and sends NO - * further ACKs, and asserts the engine stops after ~one window instead of over-sending. - */ @RunWith(RobolectricTestRunner::class) @Config(sdk = [34], application = Application::class) class TcpDownloadFlowControlTest { diff --git a/app/src/test/java/tech/httptoolkit/android/vpn/TcpEdgeCaseForwardingTest.kt b/app/src/test/java/tech/httptoolkit/android/vpn/TcpEdgeCaseForwardingTest.kt index bbe7cb9..b9b38fd 100644 --- a/app/src/test/java/tech/httptoolkit/android/vpn/TcpEdgeCaseForwardingTest.kt +++ b/app/src/test/java/tech/httptoolkit/android/vpn/TcpEdgeCaseForwardingTest.kt @@ -16,12 +16,6 @@ import java.net.SocketTimeoutException import java.util.concurrent.Executors import java.util.concurrent.TimeUnit -/** - * Adversarial / edge-case checks on the TCP path: 32-bit sequence wraparound in the ACK - * accounting, a zero receive window that later reopens, RST teardown, FIN acknowledgement, - * out-of-order (duplicate) client data, and a retransmitted SYN. These are the corners where - * the sequence arithmetic and connection-tracking are most likely to misbehave. - */ @RunWith(RobolectricTestRunner::class) @Config(sdk = [34], application = Application::class) class TcpEdgeCaseForwardingTest { diff --git a/app/src/test/java/tech/httptoolkit/android/vpn/TcpForwardingTest.kt b/app/src/test/java/tech/httptoolkit/android/vpn/TcpForwardingTest.kt index f78b1ae..e5465ad 100644 --- a/app/src/test/java/tech/httptoolkit/android/vpn/TcpForwardingTest.kt +++ b/app/src/test/java/tech/httptoolkit/android/vpn/TcpForwardingTest.kt @@ -15,11 +15,6 @@ import java.net.Socket import java.util.concurrent.Executors import java.util.concurrent.TimeUnit -/** - * End-to-end TCP forwarding against a real loopback server: drive the handshake, forward - * client data to the peer, and relay the peer's response back to the TUN. The test maintains - * its own seq/ack bookkeeping and reads the (random) server ISN from the captured SYN-ACK. - */ @RunWith(RobolectricTestRunner::class) @Config(sdk = [34], application = Application::class) class TcpForwardingTest { diff --git a/app/src/test/java/tech/httptoolkit/android/vpn/TcpWindowedDownloadTest.kt b/app/src/test/java/tech/httptoolkit/android/vpn/TcpWindowedDownloadTest.kt index 5a19bd8..3465361 100644 --- a/app/src/test/java/tech/httptoolkit/android/vpn/TcpWindowedDownloadTest.kt +++ b/app/src/test/java/tech/httptoolkit/android/vpn/TcpWindowedDownloadTest.kt @@ -16,13 +16,6 @@ import java.util.concurrent.Executors import java.util.concurrent.Future import java.util.concurrent.TimeUnit -/** - * End-to-end tests of the windowed download path against a real loopback server: the engine - * must never have more than the client's advertised window of unacknowledged data in flight, - * must resume sending when an ACK reopens the window, must backpressure the upstream so a - * large file isn't pulled into memory, and must deliver the whole file in order to a - * conformant (windowed) client. - */ @RunWith(RobolectricTestRunner::class) @Config(sdk = [34], application = Application::class) class TcpWindowedDownloadTest { diff --git a/app/src/test/java/tech/httptoolkit/android/vpn/TestPackets.kt b/app/src/test/java/tech/httptoolkit/android/vpn/TestPackets.kt index ef39904..a241905 100644 --- a/app/src/test/java/tech/httptoolkit/android/vpn/TestPackets.kt +++ b/app/src/test/java/tech/httptoolkit/android/vpn/TestPackets.kt @@ -4,11 +4,6 @@ import tech.httptoolkit.android.vpn.util.PacketUtil import java.net.InetAddress import java.nio.ByteBuffer -/** - * Builders for raw IPv4/IPv6 TCP/UDP/ICMP packets used to drive the forwarding engine in - * tests. Checksums aren't verified by the engine so TCP/UDP leave them zero; length fields - * must be correct. - */ object TestPackets { // TCP flag bits @@ -80,10 +75,6 @@ object TestPackets { return buf } - /** - * IPv4 TCP packet. When [mss] is non-null an MSS option is added (data offset 6), - * otherwise a plain 20-byte TCP header (data offset 5) is used. - */ fun tcpPacket( srcIp: String, srcPort: Int, dstIp: String, dstPort: Int, seq: Long, ack: Long, flags: Int, payload: ByteArray = ByteArray(0), mss: Int? = null, diff --git a/app/src/test/java/tech/httptoolkit/android/vpn/UdpConcurrentFlowsTest.kt b/app/src/test/java/tech/httptoolkit/android/vpn/UdpConcurrentFlowsTest.kt index 800ee32..8206c94 100644 --- a/app/src/test/java/tech/httptoolkit/android/vpn/UdpConcurrentFlowsTest.kt +++ b/app/src/test/java/tech/httptoolkit/android/vpn/UdpConcurrentFlowsTest.kt @@ -13,12 +13,6 @@ import java.net.DatagramPacket import java.net.DatagramSocket import java.net.InetAddress -/** - * Connection-tracking and forwarding checks for UDP under concurrency: many simultaneous - * flows demultiplex back to the right client, the same source port to different destinations - * is tracked as separate connections, and a burst of datagrams on one connection keeps its - * message boundaries and ordering. Egress counters accumulate per connection. - */ @RunWith(RobolectricTestRunner::class) @Config(sdk = [34], application = Application::class) class UdpConcurrentFlowsTest { diff --git a/app/src/test/java/tech/httptoolkit/android/vpn/UdpForwardingTest.kt b/app/src/test/java/tech/httptoolkit/android/vpn/UdpForwardingTest.kt index a139ec1..a796824 100644 --- a/app/src/test/java/tech/httptoolkit/android/vpn/UdpForwardingTest.kt +++ b/app/src/test/java/tech/httptoolkit/android/vpn/UdpForwardingTest.kt @@ -14,11 +14,6 @@ import java.net.DatagramPacket import java.net.DatagramSocket import java.net.InetAddress -/** - * End-to-end UDP forwarding against a real loopback peer: feed a UDP packet into the - * engine, assert the peer receives it (egress), the peer's reply is emitted back to the - * TUN (ingress), and a LANFlow is recorded with updated counters. - */ @RunWith(RobolectricTestRunner::class) @Config(sdk = [34], application = Application::class) class UdpForwardingTest { diff --git a/app/src/test/java/tech/httptoolkit/android/vpn/UdpLargeDatagramForwardingTest.kt b/app/src/test/java/tech/httptoolkit/android/vpn/UdpLargeDatagramForwardingTest.kt index 2266080..3b13bb7 100644 --- a/app/src/test/java/tech/httptoolkit/android/vpn/UdpLargeDatagramForwardingTest.kt +++ b/app/src/test/java/tech/httptoolkit/android/vpn/UdpLargeDatagramForwardingTest.kt @@ -13,15 +13,6 @@ import java.net.DatagramPacket import java.net.DatagramSocket import java.net.InetAddress -/** - * Finding 1 (fixed): a UDP peer reply larger than the TUN MTU cannot be delivered as a single packet - * and used to crash the NIO thread (ClientPacketWriter threw an Error on >30000-byte packets). The fix - * drops oversized UDP responses in [SocketChannelReader.readUDP] (reporting once to Crashlytics) and - * never crashes the engine. - * - * This test feeds a flow, has the peer send a >MTU reply (must be dropped — never reaches the TUN) and - * then a small reply (must still be forwarded), proving the engine dropped the big one and stayed alive. - */ @RunWith(RobolectricTestRunner::class) @Config(sdk = [34], application = Application::class) class UdpLargeDatagramForwardingTest {