Skip to content

Commit a4a19ed

Browse files
authored
Merge pull request #16 from element-hq/kegan/pcap
Add pcap support in settings
2 parents 819e624 + 42881fc commit a4a19ed

11 files changed

Lines changed: 257 additions & 1 deletion

File tree

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,7 @@ sealed interface DeveloperSettingsEvents {
1515
data class ChangeBrandColor(val color: Color?) : DeveloperSettingsEvents
1616
data object ClearCache : DeveloperSettingsEvents
1717
data object VacuumStores : DeveloperSettingsEvents
18+
19+
/** Start the federation pcap capture if idle, or stop it if running. */
20+
data object ToggleNeutrinoCapture : DeveloperSettingsEvents
1821
}

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

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import io.element.android.libraries.architecture.runCatchingUpdatingState
3131
import io.element.android.libraries.core.data.ByteUnit
3232
import io.element.android.libraries.matrix.api.analytics.GetDatabaseSizesUseCase
3333
import io.element.android.libraries.matrix.api.core.SessionId
34+
import io.element.android.services.neutrino.api.CaptureResult
3435
import io.element.android.services.neutrino.api.NeutrinoService
3536
import kotlinx.collections.immutable.ImmutableMap
3637
import kotlinx.collections.immutable.toImmutableMap
@@ -63,6 +64,14 @@ class DeveloperSettingsPresenter(
6364
var showColorPicker by remember {
6465
mutableStateOf(false)
6566
}
67+
// Seed the capture toggle from the running server so the switch reflects
68+
// reality when the screen (re)opens.
69+
var neutrinoCapturing by remember {
70+
mutableStateOf(neutrinoService.isCapturing())
71+
}
72+
var neutrinoCaptureStatus by remember {
73+
mutableStateOf<String?>(null)
74+
}
6675
LaunchedEffect(Unit) {
6776
computeDatabaseSizes(databaseSizes)
6877
}
@@ -90,6 +99,27 @@ class DeveloperSettingsPresenter(
9099
DeveloperSettingsEvents.VacuumStores -> coroutineScope.launch {
91100
vacuumStoresUseCase()
92101
}
102+
DeveloperSettingsEvents.ToggleNeutrinoCapture -> {
103+
if (neutrinoCapturing) {
104+
val path = neutrinoService.stopCapture()
105+
neutrinoCapturing = false
106+
// The service reports the short Downloads location on stop.
107+
neutrinoCaptureStatus = path?.let { "Saved to $it" } ?: "Not capturing"
108+
} else {
109+
when (val result = neutrinoService.startCapture()) {
110+
is CaptureResult.Started -> {
111+
neutrinoCapturing = true
112+
// Don't surface the long working path — the file
113+
// lands in Downloads on stop.
114+
neutrinoCaptureStatus = "Capturing… saved to Downloads on stop"
115+
}
116+
is CaptureResult.Failed -> {
117+
neutrinoCapturing = false
118+
neutrinoCaptureStatus = "Failed: ${result.reason}"
119+
}
120+
}
121+
}
122+
}
93123
}
94124
}
95125

@@ -102,6 +132,8 @@ class DeveloperSettingsPresenter(
102132
isEnterpriseBuild = enterpriseService.isEnterpriseBuild,
103133
showColorPicker = showColorPicker,
104134
neutrinoServerName = neutrinoService.serverName(),
135+
neutrinoCapturing = neutrinoCapturing,
136+
neutrinoCaptureStatus = neutrinoCaptureStatus,
105137
eventSink = ::handleEvent,
106138
)
107139
}

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,12 @@ data class DeveloperSettingsState(
2323
// The embedded Neutrino homeserver's federation server_name (its node id), or
2424
// null until the server has started and resolved its identity.
2525
val neutrinoServerName: String?,
26+
// Whether a federation pcap capture is currently running (drives the toggle).
27+
val neutrinoCapturing: Boolean,
28+
// A human-readable status line for the capture toggle: where it is writing
29+
// while on, where the file was saved after stopping, or the failure reason.
30+
// Null before the toggle has been used this session.
31+
val neutrinoCaptureStatus: String?,
2632
val eventSink: (DeveloperSettingsEvents) -> Unit
2733
) {
2834
val showLoader = clearCacheAction is AsyncAction.Loading

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,10 @@ open class DeveloperSettingsStateProvider : PreviewParameterProvider<DeveloperSe
3131
// at io.mhssn.colorpicker.pickers.ClassicColorPickerKt$ClassicColorPicker$1$1.invokeSuspend(ClassicColorPicker.kt:53)
3232
showColorPicker = false,
3333
),
34+
aDeveloperSettingsState(
35+
neutrinoCapturing = true,
36+
neutrinoCaptureStatus = "Capturing… saved to Downloads on stop",
37+
),
3438
)
3539
}
3640

@@ -40,6 +44,8 @@ fun aDeveloperSettingsState(
4044
isEnterpriseBuild: Boolean = false,
4145
showColorPicker: Boolean = false,
4246
neutrinoServerName: String? = "a1b2c3d4e5f6",
47+
neutrinoCapturing: Boolean = false,
48+
neutrinoCaptureStatus: String? = null,
4349
eventSink: (DeveloperSettingsEvents) -> Unit = {},
4450
) = DeveloperSettingsState(
4551
appDeveloperSettingsState = appDeveloperSettingsState,
@@ -49,5 +55,7 @@ fun aDeveloperSettingsState(
4955
isEnterpriseBuild = isEnterpriseBuild,
5056
showColorPicker = showColorPicker,
5157
neutrinoServerName = neutrinoServerName,
58+
neutrinoCapturing = neutrinoCapturing,
59+
neutrinoCaptureStatus = neutrinoCaptureStatus,
5260
eventSink = eventSink,
5361
)

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,18 @@ fun DeveloperSettingsView(
8181
},
8282
onClick = onNeutrinoPeersClick,
8383
)
84+
ListItem(
85+
headlineContent = {
86+
Text("Capture federation traffic")
87+
},
88+
supportingContent = state.neutrinoCaptureStatus?.let { status ->
89+
{ Text(status) }
90+
},
91+
trailingContent = ListItemContent.Switch(
92+
checked = state.neutrinoCapturing,
93+
),
94+
onClick = { state.eventSink(DeveloperSettingsEvents.ToggleNeutrinoCapture) },
95+
)
8496
}
8597

8698
if (state.isEnterpriseBuild) {

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

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import io.element.android.libraries.matrix.api.analytics.GetDatabaseSizesUseCase
2626
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
29+
import io.element.android.services.neutrino.api.CaptureResult
2930
import io.element.android.services.neutrino.api.NeutrinoService
3031
import io.element.android.tests.testutils.WarmUpRule
3132
import io.element.android.tests.testutils.lambda.lambdaRecorder
@@ -156,6 +157,49 @@ class DeveloperSettingsPresenterTest {
156157
}
157158
}
158159

160+
@Test
161+
fun `present - toggling Neutrino capture starts then stops it`() = runTest {
162+
val presenter = createDeveloperSettingsPresenter(
163+
neutrinoService = FakeNeutrinoService(
164+
startCaptureResult = { CaptureResult.Started("/sdcard/neutrino.pcap") },
165+
stopCaptureResult = "Download/neutrino-fed.pcap",
166+
),
167+
)
168+
presenter.test {
169+
val initialState = awaitItem()
170+
assertThat(initialState.neutrinoCapturing).isFalse()
171+
assertThat(initialState.neutrinoCaptureStatus).isNull()
172+
173+
initialState.eventSink(DeveloperSettingsEvents.ToggleNeutrinoCapture)
174+
val capturing = awaitItem()
175+
assertThat(capturing.neutrinoCapturing).isTrue()
176+
assertThat(capturing.neutrinoCaptureStatus).isEqualTo("Capturing… saved to Downloads on stop")
177+
178+
capturing.eventSink(DeveloperSettingsEvents.ToggleNeutrinoCapture)
179+
val stopped = awaitItem()
180+
assertThat(stopped.neutrinoCapturing).isFalse()
181+
assertThat(stopped.neutrinoCaptureStatus).isEqualTo("Saved to Download/neutrino-fed.pcap")
182+
cancelAndIgnoreRemainingEvents()
183+
}
184+
}
185+
186+
@Test
187+
fun `present - a failed capture start surfaces the reason`() = runTest {
188+
val presenter = createDeveloperSettingsPresenter(
189+
neutrinoService = FakeNeutrinoService(
190+
startCaptureResult = { CaptureResult.Failed("permission denied") },
191+
),
192+
)
193+
presenter.test {
194+
val initialState = awaitItem()
195+
initialState.eventSink(DeveloperSettingsEvents.ToggleNeutrinoCapture)
196+
val failed = awaitItem()
197+
assertThat(failed.neutrinoCapturing).isFalse()
198+
assertThat(failed.neutrinoCaptureStatus).isEqualTo("Failed: permission denied")
199+
cancelAndIgnoreRemainingEvents()
200+
}
201+
}
202+
159203
private fun createDeveloperSettingsPresenter(
160204
sessionId: SessionId = A_SESSION_ID,
161205
cacheSizeUseCase: FakeComputeCacheSizeUseCase = FakeComputeCacheSizeUseCase(),

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

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,20 @@
77

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

10+
import io.element.android.services.neutrino.api.CaptureResult
1011
import io.element.android.services.neutrino.api.DiscoveredPeer
1112
import io.element.android.services.neutrino.api.NeutrinoService
1213

1314
class FakeNeutrinoService(
1415
private val serverNameResult: String? = "a1b2c3d4",
1516
private val discoveredPeersResult: () -> List<DiscoveredPeer> = { emptyList() },
17+
private val startCaptureResult: () -> CaptureResult = { CaptureResult.Started(A_CAPTURE_PATH) },
18+
// The location stopCapture reports (the real service copies to Downloads, so
19+
// this need not match the started path).
20+
private val stopCaptureResult: String? = "Download/$A_CAPTURE_NAME",
1621
) : NeutrinoService {
22+
private var capturing = false
23+
1724
override fun start() = Unit
1825

1926
override suspend fun awaitReady(timeoutMs: Long) = Unit
@@ -23,4 +30,21 @@ class FakeNeutrinoService(
2330
override fun serverName(): String? = serverNameResult
2431

2532
override fun discoveredPeers(): List<DiscoveredPeer> = discoveredPeersResult()
33+
34+
override fun startCapture(): CaptureResult = startCaptureResult().also { result ->
35+
if (result is CaptureResult.Started) {
36+
capturing = true
37+
}
38+
}
39+
40+
override fun stopCapture(): String? {
41+
if (!capturing) return null
42+
capturing = false
43+
return stopCaptureResult
44+
}
45+
46+
override fun isCapturing(): Boolean = capturing
2647
}
48+
49+
private const val A_CAPTURE_NAME = "neutrino-fed.pcap"
50+
private const val A_CAPTURE_PATH = "/storage/emulated/0/Android/data/pkg/files/$A_CAPTURE_NAME"

gradle/libs.versions.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ androidx-test-ext-junit = "1.3.0"
6666
kover = "0.9.8"
6767

6868
# Neutrino
69-
neutrino = "0.6.1"
69+
neutrino = "0.6.3"
7070

7171
[libraries]
7272
# Project
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
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.services.neutrino.api
9+
10+
/**
11+
* Outcome of arming a federation pcap capture (see [NeutrinoService.startCapture]).
12+
*/
13+
sealed interface CaptureResult {
14+
/** Capture is running, writing to [filePath] (an absolute, adb-pullable path). */
15+
data class Started(val filePath: String) : CaptureResult
16+
17+
/** Capture could not be started; [reason] is a human-readable explanation. */
18+
data class Failed(val reason: String) : CaptureResult
19+
}

services/neutrino/api/src/main/kotlin/io/element/android/services/neutrino/api/NeutrinoService.kt

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,4 +47,25 @@ interface NeutrinoService {
4747
* server has started.
4848
*/
4949
fun discoveredPeers(): List<DiscoveredPeer>
50+
51+
/**
52+
* Start mirroring federation datagrams into a Wireshark-readable pcap file in
53+
* host-owned external storage (so it can be pulled with `adb pull` without
54+
* root). The service owns the path. Re-arming while already capturing rotates
55+
* to a fresh file. Returns where it is writing, or why it could not start.
56+
*/
57+
fun startCapture(): CaptureResult
58+
59+
/**
60+
* Stop capturing and flush + close the file, which is ready to `adb pull` the
61+
* moment this returns. Returns the finalized file path, or `null` if no
62+
* capture was running.
63+
*/
64+
fun stopCapture(): String?
65+
66+
/**
67+
* Whether a federation pcap capture is currently running. Drives the toggle
68+
* state in developer settings.
69+
*/
70+
fun isCapturing(): Boolean
5071
}

0 commit comments

Comments
 (0)