Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package network.columba.app.rns.backend.kt

import android.util.Log
import network.columba.app.rns.api.model.InterfaceMode as ColumbaMode
import network.reticulum.common.InterfaceMode as ReticulumMode

private const val TAG = "InterfaceModeMapper"

/**
* Maps a Columba interface-mode string to the reticulum-kt [ReticulumMode] that the
* constructed interface object should report.
*
* Returns `null` for modes that the pinned reticulum-kt enum cannot faithfully
* represent. A `null` result means "leave the interface at its declared default
* and log a warning" - the caller must NOT coerce such a mode onto the object,
* because coercing would silently change runtime behavior to unverified semantics
* (see issue #1169 and its dependency on reticulum-kt #91).
*
* INTERNAL is currently unrepresentable: the pinned reticulum-kt [ReticulumMode]
* has no INTERNAL value, and its stray [ReticulumMode.POINT_TO_POINT] corresponds
* to no RNS config mode string. Per #1169 it must stay a no-op until #91
* reconciles the enum with the Python reference vocabulary - mapping it to
* POINT_TO_POINT (or FULL) would change announce re-broadcast behavior without
* conformance proof.
*/
internal fun mapInterfaceMode(configName: String, modeString: String): ReticulumMode? {
val mapped =
when (ColumbaMode.fromValue(modeString)) {
ColumbaMode.FULL -> ReticulumMode.FULL
ColumbaMode.GATEWAY -> ReticulumMode.GATEWAY
ColumbaMode.ACCESS_POINT -> ReticulumMode.ACCESS_POINT
ColumbaMode.ROAMING -> ReticulumMode.ROAMING
ColumbaMode.BOUNDARY -> ReticulumMode.BOUNDARY
ColumbaMode.INTERNAL -> null // unrepresentable until reticulum-kt #91
null -> {
Log.w(TAG, "Interface $configName: unknown mode '$modeString', leaving default")
null
}
}
if (mapped == null && ColumbaMode.fromValue(modeString) == ColumbaMode.INTERNAL) {
Log.w(
TAG,
"Interface $configName: mode '$modeString' not supported by pinned reticulum-kt " +
"(no INTERNAL in InterfaceMode, pending #91); leaving interface at default mode",
)
}
return mapped
}

/**
* Returns [iface] with its [network.reticulum.interfaces.Interface.modeOverride] set to the
* mapped reticulum-kt mode for [mode]. When [mapInterfaceMode] returns `null`
* (INTERNAL on the pinned enum, or an unknown string) the interface is left at its
* declared default - the mode is intentionally NOT coerced.
*
* A generic extension so every interface-construction site applies the mode as a
* single `.withMode(config.name, config.mode)` call, keeping
* [NativeInterfaceFactory.createInterface] within detekt's length/complexity
* budgets. [name] and [mode] are taken explicitly (not the config object) because
* [network.columba.app.rns.api.model.InterfaceConfig] declares `mode` only on its
* concrete subclasses, not the base type.
*/
internal fun <T : network.reticulum.interfaces.Interface> T.withMode(name: String, mode: String): T =
apply {
mapInterfaceMode(name, mode)?.let { modeOverride = it }
}
Original file line number Diff line number Diff line change
Expand Up @@ -130,9 +130,8 @@ internal object NativeInterfaceFactory {
}
try {
val iface = createInterface(config) ?: return
val rnsInterface = iface as network.reticulum.interfaces.Interface
rnsInterface.start()
registerAndTrack(config.name, rnsInterface)
iface.start()
registerAndTrack(config.name, iface)
} catch (e: Exception) {
Log.e(TAG, "Failed to start interface ${config.name}: ${e.message}", e)
}
Expand Down Expand Up @@ -253,7 +252,7 @@ internal object NativeInterfaceFactory {
name = config.name,
driver = driver,
transportIdentity = identityHash,
)
).withMode(config.name, config.mode)
iface.onPacketReceived = { data, fromInterface ->
Transport.inbound(
data,
Expand Down Expand Up @@ -356,7 +355,8 @@ internal object NativeInterfaceFactory {
)
}

private fun createInterface(config: InterfaceConfig): Any? {
@androidx.annotation.VisibleForTesting
internal fun createInterface(config: InterfaceConfig): network.reticulum.interfaces.Interface? {
fun mapScopeToHex(scopeName: String): String =
when (scopeName.lowercase()) {
"link" -> "2"
Expand All @@ -372,7 +372,7 @@ internal object NativeInterfaceFactory {
AutoInterface(
name = config.name,
discoveryScope = mapScopeToHex(config.discoveryScope),
)
).withMode(config.name, config.mode)

is InterfaceConfig.TCPClient ->
TCPClientInterface(
Expand All @@ -383,7 +383,7 @@ internal object NativeInterfaceFactory {
keepAlive = false, // Disable for mobile battery
ifacNetname = config.networkName,
ifacNetkey = config.passphrase,
)
).withMode(config.name, config.mode)
Comment thread
greptile-apps[bot] marked this conversation as resolved.

is InterfaceConfig.UDP ->
UDPInterface(
Expand All @@ -392,7 +392,7 @@ internal object NativeInterfaceFactory {
bindPort = config.listenPort,
forwardIp = config.forwardIp,
forwardPort = config.forwardPort,
)
).withMode(config.name, config.mode)

is InterfaceConfig.TCPServer ->
TCPServerInterface(
Expand All @@ -401,7 +401,7 @@ internal object NativeInterfaceFactory {
bindPort = config.listenPort,
ifacNetname = config.networkName,
ifacNetkey = config.passphrase,
).apply {
).withMode(config.name, config.mode).apply {
// Register each spawned child interface with Transport BEFORE
// start() opens the accept loop, so the first incoming
// connection can't race us into a silent-drop: Python RNS
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ internal object RNodeConnectionHelper {
// this in a dedicated child scope the way startBleInterface does.
parentScope = scope,
displayImageData = if (config.enableFramebuffer) hostBridge.rnodeFramebufferData() else null,
)
).withMode(config.name, config.mode)
iface.onPacketReceived = { data, fromInterface ->
Transport.inbound(
data,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
package network.columba.app.rns.backend.kt

import network.columba.app.rns.api.model.InterfaceConfig
import network.reticulum.common.InterfaceMode
import network.reticulum.interfaces.InterfaceAdapter
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertNotNull
import org.junit.Test

/**
* Reproduces [issue #1169](https://github.com/torlando-tech/columba/issues/1169):
* "Kotlin backend: mode is silently dropped for all interface types (TCP, RNode/SPP, UDP)."
*
* The user-selected interface mode is persisted to the DB and written to the RNS
* config file (which the Python flavor honors), but `NativeInterfaceFactory`
* builds every reticulum-kt interface object WITHOUT passing the mode, so the
* constructed object silently reports the default [InterfaceMode.FULL] no matter
* what the user chose.
*
* This test drives the real production wiring: it invokes the private
* `NativeInterfaceFactory.createInterface(config)` via reflection for the
* socket-free interface types (AutoInterface, TCPClient, UDP) and asserts the
* resulting reticulum-kt object reports the mode the user configured.
*
* It is RED today for two independent reasons, both of which the fix must close:
* 1. There is no Columba -> reticulum-kt mode mapping in `rns-backend-kt`
* (the factory never references [InterfaceMode] at all).
* 2. The factory never applies the mapped mode to the constructed object, so
* every object stays at the default FULL.
*
* INTERNAL is handled as a "not-yet-mappable" case: the pinned reticulum-kt
* [InterfaceMode] enum has no INTERNAL value (reticulum-kt #91, still open), so
* the factory must NOT silently coerce internal to FULL - it must leave the
* interface un-mapped (modeOverride == null, declared mode == FULL) and log a
* warning. That is the only honest representation until the enum lands INTERNAL.
*/
class InterfaceModeWiringTest {

/**
* Maps a Columba interface-mode string to the reticulum-kt [InterfaceMode]
* the fix must apply. Returns null for values reticulum-kt cannot represent
* yet (INTERNAL) or for unknown strings, which the factory must treat as
* "leave default + warn" rather than silently coercing to FULL.
*
* Kept in the test as the reference vocabulary; the implementation fix
* should mirror this mapping.
*/
private fun expectedReticulumMode(columbaMode: String): InterfaceMode? =
when (columbaMode) {
"full" -> InterfaceMode.FULL
"gateway" -> InterfaceMode.GATEWAY
"access_point" -> InterfaceMode.ACCESS_POINT
"roaming" -> InterfaceMode.ROAMING
"boundary" -> InterfaceMode.BOUNDARY
"internal" -> null // reticulum-kt #91: no INTERNAL value yet
else -> null
}

/** Effective mode exactly as Transport/InterfaceAdapter surface it. */
private fun effectiveMode(iface: network.reticulum.interfaces.Interface): InterfaceMode =
InterfaceAdapter.getOrCreate(iface).mode

/** Build the interface via the production factory wiring (no start(), no sockets). */
private fun buildInterface(config: InterfaceConfig): network.reticulum.interfaces.Interface {
val iface = NativeInterfaceFactory.createInterface(config)
Comment on lines +75 to +77

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Three interface types lack tests

The new tests cover only AutoInterface, TCPClient, and UDP. None checks the new mode calls for TCPServer, RNode, or BLE. Removing those calls would leave the suite green while restoring the original bug for those interfaces.

Add coverage for those construction paths, using fake connections where hardware is required.

Prompt To Fix With AI
This is a comment left during a code review.
Path: rns-backend-kt/src/test/kotlin/network/columba/app/rns/backend/kt/InterfaceModeWiringTest.kt
Line: 64-66

Comment:
**Three interface types lack tests**

The new tests cover only `AutoInterface`, `TCPClient`, and `UDP`. None checks the new mode calls for `TCPServer`, `RNode`, or BLE. Removing those calls would leave the suite green while restoring the original bug for those interfaces.

Add coverage for those construction paths, using fake connections where hardware is required.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Claude Code

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed on the new head: added TCPServer (via the real factory), RNode (driving the real RNodeConnectionHelper with a mocked host bridge), and BLE (real BLEInterface + the same .withMode call from startBleInterface). 8/8 green.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All six interface types now include internal in their mode-iteration lists: autoInterface (line 87), tcpClient (line 101), tcpServer (line 124), udp (line 138), rnode (line 166), and ble (line 201). Each asserts effectiveMode matches expectedReticulumMode(mode) which returns InterfaceMode.INTERNAL for the internal string. Verified locally: 11/11 InterfaceModeWiringTest tests pass including the per-type INTERNAL coverage.

assertNotNull(
"createInterface returned null for ${config.typeName}; expected a concrete interface object",
iface,
)
return iface!!
}

@Test
fun autoInterface_reportsConfiguredMode() {
for (mode in listOf("full", "gateway", "access_point", "roaming", "boundary")) {
val config = InterfaceConfig.AutoInterface(name = "auto-$mode", enabled = true, mode = mode)
val obj = buildInterface(config)
val expected = expectedReticulumMode(mode)!!
assertEquals(
"AutoInterface mode='$mode' should report $expected",
expected,
effectiveMode(obj),
)
}
}

@Test
fun tcpClient_reportsConfiguredMode() {
for (mode in listOf("gateway", "access_point", "roaming", "boundary")) {
val config =
InterfaceConfig.TCPClient(
name = "tcp-$mode",
enabled = true,
targetHost = "127.0.0.1",
targetPort = 4242,
mode = mode,
)
val obj = buildInterface(config)
val expected = expectedReticulumMode(mode)!!
assertEquals(
"TCPClient mode='$mode' should report $expected",
expected,
effectiveMode(obj),
)
}
}

@Test
fun udp_reportsConfiguredMode() {
for (mode in listOf("full", "gateway", "access_point", "roaming", "boundary")) {
val config = InterfaceConfig.UDP(name = "udp-$mode", enabled = true, mode = mode)
val obj = buildInterface(config)
val expected = expectedReticulumMode(mode)!!
assertEquals(
"UDP mode='$mode' should report $expected",
expected,
effectiveMode(obj),
)
}
}

@Test
fun modeIsActuallyConsumed_notSilentlyDropped() {
// Core regression: the factory must CHANGE the constructed mode based on
// the config. If it ignores the field (today's bug), both configs yield
// the default FULL and this assert fails.
val tcpGateway =
buildInterface(
InterfaceConfig.TCPClient(
name = "tcp-gateway",
enabled = true,
targetHost = "127.0.0.1",
targetPort = 4242,
mode = "gateway",
),
)
val tcpFull =
buildInterface(
InterfaceConfig.TCPClient(
name = "tcp-full",
enabled = true,
targetHost = "127.0.0.1",
targetPort = 4242,
mode = "full",
),
)
assertEquals(
"TCPClient(mode=gateway) must report GATEWAY",
InterfaceMode.GATEWAY,
effectiveMode(tcpGateway),
)
assertNotEquals(
"mode field is being ignored: gateway and full both report the same " +
"(${effectiveMode(tcpGateway)})",
effectiveMode(tcpFull),
effectiveMode(tcpGateway),
)
}

@Test
fun internalMode_isNotSilentlyCoercedToFull() {
// reticulum-kt #91: the pinned InterfaceMode enum has no INTERNAL.
// The factory must not silently coerce "internal" to FULL (that would
// be a behavior change the user never asked for and the wrong semantics).
// Acceptable behavior: leave the interface at its declared default with
// modeOverride == null (the "no mapping, log a warning" path).
// What must NOT happen: a fabricated INTERNAL, or a hard-wired FULL that
// makes internal indistinguishable from a deliberate full selection.
val config =
InterfaceConfig.TCPClient(
name = "tcp-internal",
enabled = true,
targetHost = "127.0.0.1",
targetPort = 4242,
mode = "internal",
)
val iface = buildInterface(config)
// The pinned enum cannot express INTERNAL, so the object must not pretend
// to be a mappable mode via modeOverride.
assertEquals(
"internal must not be coerced to a mappable modeOverride on pinned reticulum-kt",
null,
iface.modeOverride,
)
// And it must be left at the declared default, not silently coerced to FULL.
assertEquals(
"internal must be left at the declared default mode (FULL), not coerced",
InterfaceMode.FULL,
effectiveMode(iface),
)
}
}
Loading