Skip to content

Commit 84d62f8

Browse files
authored
Merge pull request #10 from element-hq/kegan/tun
Add VpnService in developer settings
2 parents b997a6d + ed788fa commit 84d62f8

18 files changed

Lines changed: 673 additions & 10 deletions

File tree

AGENTS.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88

99
You are running in a sandbox unless told otherwise.
1010
Builds/tests Android projects OFFLINE. Host SDK + Gradle caches are mounted read-only. Use './gradlew --offline testDebugUnitTest' for unit tests, './gradlew --offline assembleDebug' to build, './gradlew --offline lintDebug' for lint. Instrumented tests (connectedAndroidTest) are NOT supported — no device/KVM.
11+
You may be unable to write cache entries (chmod EPERM) in which case `--no-configuration-cache`.
12+
Run ktlint, detekt, lintGplayDebug, lintFdroidDebug and fix any issues before declaring work is done.
1113

1214
## Strong Conventions
1315

app/build.gradle.kts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,9 @@ plugins {
3838
// 26.05.2-r1234+neutrino.0.3.0. The build number is the git commit count; the Neutrino
3939
// version comes from the version catalog. Used for both versionName and the APK file name.
4040
val neutrinoVersion = libs.versions.neutrino.get()
41-
val buildNumber = providers.of(GitCommitCountValueSource::class.java) {}.get()
41+
val buildNumber = providers.of(GitCommitCountValueSource::class.java) {
42+
parameters.workingDir.set(rootProject.layout.projectDirectory)
43+
}.get()
4244
val fullVersionName = "${Versions.VERSION_NAME}-r$buildNumber+neutrino.$neutrinoVersion"
4345

4446
base {
@@ -255,8 +257,12 @@ dependencies {
255257

256258
tasks.withType<GenerateBuildConfig>().configureEach {
257259
outputs.upToDateWhen { false }
258-
val gitRevision = providers.of(GitRevisionValueSource::class.java) {}.get()
259-
val gitBranchName = providers.of(GitBranchNameValueSource::class.java) {}.get()
260+
val gitRevision = providers.of(GitRevisionValueSource::class.java) {
261+
parameters.workingDir.set(rootProject.layout.projectDirectory)
262+
}.get()
263+
val gitBranchName = providers.of(GitBranchNameValueSource::class.java) {
264+
parameters.workingDir.set(rootProject.layout.projectDirectory)
265+
}.get()
260266
android.defaultConfig.buildConfigFieldStr("GIT_REVISION", gitRevision)
261267
android.defaultConfig.buildConfigFieldStr("GIT_BRANCH_NAME", gitBranchName)
262268
}

features/preferences/impl/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ dependencies {
8484
implementation(projects.services.analytics.api)
8585
implementation(projects.services.analytics.compose)
8686
implementation(projects.services.appnavstate.api)
87+
implementation(projects.services.neutrino.api)
8788
implementation(projects.services.toolbox.api)
8889
implementation(libs.datetime)
8990
implementation(libs.coil.compose)

features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsEvents.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,6 @@ sealed interface DeveloperSettingsEvents {
1515
data class ChangeBrandColor(val color: Color?) : DeveloperSettingsEvents
1616
data object ClearCache : DeveloperSettingsEvents
1717
data object VacuumStores : DeveloperSettingsEvents
18+
data class SetPacketTunnelEnabled(val enabled: Boolean) : DeveloperSettingsEvents
19+
data class OnPacketTunnelConsentResult(val granted: Boolean) : DeveloperSettingsEvents
1820
}

features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenter.kt

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
package io.element.android.features.preferences.impl.developer
1010

11+
import android.content.Intent
1112
import androidx.compose.runtime.Composable
1213
import androidx.compose.runtime.LaunchedEffect
1314
import androidx.compose.runtime.MutableState
@@ -31,6 +32,7 @@ import io.element.android.libraries.architecture.runCatchingUpdatingState
3132
import io.element.android.libraries.core.data.ByteUnit
3233
import io.element.android.libraries.matrix.api.analytics.GetDatabaseSizesUseCase
3334
import io.element.android.libraries.matrix.api.core.SessionId
35+
import io.element.android.services.neutrino.api.NeutrinoTunnel
3436
import kotlinx.collections.immutable.ImmutableMap
3537
import kotlinx.collections.immutable.toImmutableMap
3638
import kotlinx.coroutines.CoroutineScope
@@ -46,6 +48,7 @@ class DeveloperSettingsPresenter(
4648
private val vacuumStoresUseCase: VacuumStoresUseCase,
4749
private val databaseSizesUseCase: GetDatabaseSizesUseCase,
4850
private val fileSizeFormatter: FileSizeFormatter,
51+
private val neutrinoTunnel: NeutrinoTunnel,
4952
) : Presenter<DeveloperSettingsState> {
5053
@Composable
5154
override fun present(): DeveloperSettingsState {
@@ -61,6 +64,14 @@ class DeveloperSettingsPresenter(
6164
var showColorPicker by remember {
6265
mutableStateOf(false)
6366
}
67+
// Initialise from the actual tunnel state so the toggle reflects reality on
68+
// (re)entry, rather than a presenter-local flag that resets to false.
69+
var packetTunnelEnabled by remember {
70+
mutableStateOf(neutrinoTunnel.isRunning())
71+
}
72+
var packetTunnelConsentIntent by remember {
73+
mutableStateOf<Intent?>(null)
74+
}
6475
LaunchedEffect(Unit) {
6576
computeDatabaseSizes(databaseSizes)
6677
}
@@ -88,6 +99,29 @@ class DeveloperSettingsPresenter(
8899
DeveloperSettingsEvents.VacuumStores -> coroutineScope.launch {
89100
vacuumStoresUseCase()
90101
}
102+
is DeveloperSettingsEvents.SetPacketTunnelEnabled -> {
103+
if (event.enabled) {
104+
val consentIntent = neutrinoTunnel.consentIntent()
105+
if (consentIntent == null) {
106+
// Consent already granted: start immediately.
107+
neutrinoTunnel.start()
108+
packetTunnelEnabled = true
109+
} else {
110+
// Ask the View to launch the system VPN consent dialog.
111+
packetTunnelConsentIntent = consentIntent
112+
}
113+
} else {
114+
neutrinoTunnel.stop()
115+
packetTunnelEnabled = false
116+
}
117+
}
118+
is DeveloperSettingsEvents.OnPacketTunnelConsentResult -> {
119+
packetTunnelConsentIntent = null
120+
if (event.granted) {
121+
neutrinoTunnel.start()
122+
packetTunnelEnabled = true
123+
}
124+
}
91125
}
92126
}
93127

@@ -99,6 +133,8 @@ class DeveloperSettingsPresenter(
99133
clearCacheAction = clearCacheAction.value,
100134
isEnterpriseBuild = enterpriseService.isEnterpriseBuild,
101135
showColorPicker = showColorPicker,
136+
packetTunnelEnabled = packetTunnelEnabled,
137+
packetTunnelConsentIntent = packetTunnelConsentIntent,
102138
eventSink = ::handleEvent,
103139
)
104140
}

features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsState.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
package io.element.android.features.preferences.impl.developer
1010

11+
import android.content.Intent
1112
import io.element.android.features.preferences.impl.developer.appsettings.AppDeveloperSettingsState
1213
import io.element.android.libraries.architecture.AsyncAction
1314
import io.element.android.libraries.architecture.AsyncData
@@ -20,6 +21,10 @@ data class DeveloperSettingsState(
2021
val clearCacheAction: AsyncAction<Unit>,
2122
val isEnterpriseBuild: Boolean,
2223
val showColorPicker: Boolean,
24+
val packetTunnelEnabled: Boolean,
25+
// When non-null, the View must launch this VPN consent Intent and report the
26+
// result back via [DeveloperSettingsEvents.OnPacketTunnelConsentResult].
27+
val packetTunnelConsentIntent: Intent?,
2328
val eventSink: (DeveloperSettingsEvents) -> Unit
2429
) {
2530
val showLoader = clearCacheAction is AsyncAction.Loading

features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsStateProvider.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ fun aDeveloperSettingsState(
3939
clearCacheAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
4040
isEnterpriseBuild: Boolean = false,
4141
showColorPicker: Boolean = false,
42+
packetTunnelEnabled: Boolean = false,
4243
eventSink: (DeveloperSettingsEvents) -> Unit = {},
4344
) = DeveloperSettingsState(
4445
appDeveloperSettingsState = appDeveloperSettingsState,
@@ -47,5 +48,7 @@ fun aDeveloperSettingsState(
4748
clearCacheAction = clearCacheAction,
4849
isEnterpriseBuild = isEnterpriseBuild,
4950
showColorPicker = showColorPicker,
51+
packetTunnelEnabled = packetTunnelEnabled,
52+
packetTunnelConsentIntent = null,
5053
eventSink = eventSink,
5154
)

features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsView.kt

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,15 @@
88

99
package io.element.android.features.preferences.impl.developer
1010

11+
import android.app.Activity
1112
import androidx.activity.compose.BackHandler
13+
import androidx.activity.compose.rememberLauncherForActivityResult
14+
import androidx.activity.result.contract.ActivityResultContracts
1215
import androidx.compose.foundation.layout.Column
1316
import androidx.compose.foundation.layout.size
1417
import androidx.compose.foundation.progressSemantics
1518
import androidx.compose.runtime.Composable
19+
import androidx.compose.runtime.LaunchedEffect
1620
import androidx.compose.ui.ExperimentalComposeUiApi
1721
import androidx.compose.ui.Modifier
1822
import androidx.compose.ui.res.stringResource
@@ -45,6 +49,16 @@ fun DeveloperSettingsView(
4549
if (state.showLoader) {
4650
ProgressDialog()
4751
}
52+
// The packet tunnel needs system VPN consent before it can start. When the
53+
// presenter surfaces a consent Intent, launch it and report the result back.
54+
val packetTunnelConsentLauncher = rememberLauncherForActivityResult(
55+
ActivityResultContracts.StartActivityForResult()
56+
) { result ->
57+
state.eventSink(DeveloperSettingsEvents.OnPacketTunnelConsentResult(result.resultCode == Activity.RESULT_OK))
58+
}
59+
LaunchedEffect(state.packetTunnelConsentIntent) {
60+
state.packetTunnelConsentIntent?.let { packetTunnelConsentLauncher.launch(it) }
61+
}
4862
BackHandler(
4963
enabled = !state.showLoader,
5064
onBack = onBackClick,
@@ -65,6 +79,23 @@ fun DeveloperSettingsView(
6579
)
6680
NotificationCategory(onPushHistoryClick)
6781

82+
PreferenceCategory(title = "Neutrino") {
83+
ListItem(
84+
headlineContent = {
85+
Text("Packet tunnel")
86+
},
87+
supportingContent = {
88+
Text("Capture this app's traffic to the virtual subnet over a TUN interface (logs packets only)")
89+
},
90+
trailingContent = ListItemContent.Switch(
91+
checked = state.packetTunnelEnabled,
92+
),
93+
onClick = {
94+
state.eventSink(DeveloperSettingsEvents.SetPacketTunnelEnabled(!state.packetTunnelEnabled))
95+
}
96+
)
97+
}
98+
6899
if (state.isEnterpriseBuild) {
69100
PreferenceCategory(title = "Theme") {
70101
ListItem(

features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenterTest.kt

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import io.element.android.libraries.matrix.api.analytics.SdkStoreSizes
2727
import io.element.android.libraries.matrix.api.core.SessionId
2828
import io.element.android.libraries.matrix.test.A_SESSION_ID
2929
import io.element.android.tests.testutils.WarmUpRule
30+
import io.element.android.tests.testutils.consumeItemsUntilPredicate
3031
import io.element.android.tests.testutils.lambda.lambdaRecorder
3132
import io.element.android.tests.testutils.lambda.value
3233
import io.element.android.tests.testutils.test
@@ -144,13 +145,56 @@ class DeveloperSettingsPresenterTest {
144145
}
145146
}
146147

148+
@Test
149+
fun `present - enabling packet tunnel with consent already granted starts it`() = runTest {
150+
val neutrinoTunnel = FakeNeutrinoTunnel(consentIntentResult = null)
151+
val presenter = createDeveloperSettingsPresenter(neutrinoTunnel = neutrinoTunnel)
152+
presenter.test {
153+
val initialState = consumeItemsUntilPredicate { !it.packetTunnelEnabled }.last()
154+
initialState.eventSink(DeveloperSettingsEvents.SetPacketTunnelEnabled(true))
155+
val enabledState = consumeItemsUntilPredicate { it.packetTunnelEnabled }.last()
156+
assertThat(enabledState.packetTunnelEnabled).isTrue()
157+
assertThat(enabledState.packetTunnelConsentIntent).isNull()
158+
assertThat(neutrinoTunnel.startCount).isEqualTo(1)
159+
cancelAndIgnoreRemainingEvents()
160+
}
161+
}
162+
163+
@Test
164+
fun `present - packet tunnel toggle reflects the already-running tunnel on entry`() = runTest {
165+
val neutrinoTunnel = FakeNeutrinoTunnel(initiallyRunning = true)
166+
val presenter = createDeveloperSettingsPresenter(neutrinoTunnel = neutrinoTunnel)
167+
presenter.test {
168+
assertThat(awaitItem().packetTunnelEnabled).isTrue()
169+
cancelAndIgnoreRemainingEvents()
170+
}
171+
}
172+
173+
@Test
174+
fun `present - disabling packet tunnel stops it`() = runTest {
175+
val neutrinoTunnel = FakeNeutrinoTunnel(consentIntentResult = null)
176+
val presenter = createDeveloperSettingsPresenter(neutrinoTunnel = neutrinoTunnel)
177+
presenter.test {
178+
val initialState = consumeItemsUntilPredicate { !it.packetTunnelEnabled }.last()
179+
initialState.eventSink(DeveloperSettingsEvents.SetPacketTunnelEnabled(true))
180+
consumeItemsUntilPredicate { it.packetTunnelEnabled }
181+
.last()
182+
.eventSink(DeveloperSettingsEvents.SetPacketTunnelEnabled(false))
183+
val disabledState = consumeItemsUntilPredicate { !it.packetTunnelEnabled }.last()
184+
assertThat(disabledState.packetTunnelEnabled).isFalse()
185+
assertThat(neutrinoTunnel.stopCount).isEqualTo(1)
186+
cancelAndIgnoreRemainingEvents()
187+
}
188+
}
189+
147190
private fun createDeveloperSettingsPresenter(
148191
sessionId: SessionId = A_SESSION_ID,
149192
cacheSizeUseCase: FakeComputeCacheSizeUseCase = FakeComputeCacheSizeUseCase(),
150193
clearCacheUseCase: FakeClearCacheUseCase = FakeClearCacheUseCase(),
151194
enterpriseService: EnterpriseService = FakeEnterpriseService(),
152195
vacuumStoresUseCase: VacuumStoresUseCase = VacuumStoresUseCase {},
153196
databaseSizesUseCase: GetDatabaseSizesUseCase = GetDatabaseSizesUseCase { Result.success(SdkStoreSizes(null, null, null, null)) },
197+
neutrinoTunnel: FakeNeutrinoTunnel = FakeNeutrinoTunnel(),
154198
): DeveloperSettingsPresenter {
155199
return DeveloperSettingsPresenter(
156200
appDeveloperSettingsPresenter = { anAppDeveloperSettingsState() },
@@ -161,6 +205,7 @@ class DeveloperSettingsPresenterTest {
161205
vacuumStoresUseCase = vacuumStoresUseCase,
162206
databaseSizesUseCase = databaseSizesUseCase,
163207
fileSizeFormatter = FakeFileSizeFormatter(),
208+
neutrinoTunnel = neutrinoTunnel,
164209
)
165210
}
166211
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
/*
2+
* Copyright (c) 2026 Element Creations Ltd.
3+
*
4+
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
5+
* Please see LICENSE files in the repository root for full details.
6+
*/
7+
8+
package io.element.android.features.preferences.impl.developer
9+
10+
import android.content.Intent
11+
import io.element.android.services.neutrino.api.NeutrinoTunnel
12+
13+
class FakeNeutrinoTunnel(
14+
private val consentIntentResult: Intent? = null,
15+
initiallyRunning: Boolean = false,
16+
) : NeutrinoTunnel {
17+
var startCount = 0
18+
private set
19+
var stopCount = 0
20+
private set
21+
private var running = initiallyRunning
22+
23+
override fun consentIntent(): Intent? = consentIntentResult
24+
25+
override fun start() {
26+
startCount++
27+
running = true
28+
}
29+
30+
override fun stop() {
31+
stopCount++
32+
running = false
33+
}
34+
35+
override fun isRunning(): Boolean = running
36+
}

0 commit comments

Comments
 (0)