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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -27,6 +27,17 @@ internal object NativeInterfaceFactory {

/** Running interfaces keyed by config name. */
private val runningInterfaces = java.util.concurrent.ConcurrentHashMap<String, network.reticulum.interfaces.Interface>()

/**
* Columba mode string last applied to each running interface, keyed by name.
* [syncInterfaces] compares the desired mode against this to detect a saved
* mode edit on an already-running interface and restart it so the change
* takes effect. The diff-based sync otherwise only starts names that are not
* already running, so a persisted mode edit would never reach the running
* object (issue #1169, PR #1188 review P1). Tracking just the mode (not the
* full config) avoids spurious restarts from non-deterministic config fields.
*/
private val runningModes = java.util.concurrent.ConcurrentHashMap<String, String>()
private val rnodeRecoveryJobs = java.util.concurrent.ConcurrentHashMap<String, kotlinx.coroutines.Job>()

/**
Expand Down Expand Up @@ -86,6 +97,21 @@ internal object NativeInterfaceFactory {
stopInterface(name)
}

// Restart interfaces whose saved mode changed while they were running.
// The diff-based sync below only *starts* names not already running, so a
// persisted mode edit on a running interface would otherwise be dropped
// (the running object keeps the mode it was started with). Issue #1169,
// PR #1188 review P1.
for (name in runningNames intersect desiredNames) {
val config = configs.first { it.name == name }
val previousMode = runningModes[name]
val desiredMode = configModeOf(config)
if (previousMode != null && previousMode != desiredMode) {
Log.i(TAG, "Mode changed for running interface $name: $previousMode -> $desiredMode; restarting")
restartInterface(config)
Comment thread
greptile-apps[bot] marked this conversation as resolved.
}
}

// Start new interfaces
for (name in desiredNames - runningNames) {
val config = configs.first { it.name == name }
Expand Down Expand Up @@ -122,6 +148,17 @@ internal object NativeInterfaceFactory {
}
}

/** Extracts the Columba mode string from an [InterfaceConfig]. */
private fun configModeOf(config: InterfaceConfig): String =
when (config) {
is InterfaceConfig.AutoInterface -> config.mode
is InterfaceConfig.TCPClient -> config.mode
is InterfaceConfig.TCPServer -> config.mode
is InterfaceConfig.UDP -> config.mode
is InterfaceConfig.RNode -> config.mode
is InterfaceConfig.AndroidBLE -> config.mode
}

private fun startInterface(config: InterfaceConfig) {
// BLE requires async setup (scan + GATT + MTU negotiation)
if (config is InterfaceConfig.AndroidBLE) {
Expand All @@ -130,9 +167,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, config)
} catch (e: Exception) {
Log.e(TAG, "Failed to start interface ${config.name}: ${e.message}", e)
}
Expand All @@ -141,12 +177,14 @@ internal object NativeInterfaceFactory {
private fun registerAndTrack(
name: String,
iface: network.reticulum.interfaces.Interface,
config: InterfaceConfig,
) {
val ref =
network.reticulum.interfaces.InterfaceAdapter
.getOrCreate(iface)
Transport.registerInterface(ref)
runningInterfaces[name] = iface
runningModes[name] = configModeOf(config)

// Acquire multicast lock when AutoInterface starts (needed for multicast receive)
if (iface is network.reticulum.interfaces.auto.AutoInterface) {
Expand Down Expand Up @@ -253,7 +291,7 @@ internal object NativeInterfaceFactory {
name = config.name,
driver = driver,
transportIdentity = identityHash,
)
).withMode(config.name, config.mode)
iface.onPacketReceived = { data, fromInterface ->
Transport.inbound(
data,
Expand All @@ -262,7 +300,7 @@ internal object NativeInterfaceFactory {
)
}
iface.start()
registerAndTrack(config.name, iface)
registerAndTrack(config.name, iface, config)
} catch (e: Exception) {
Log.e(TAG, "Failed to start BLE interface ${config.name}: ${e.message}", e)
// AndroidBLEDriver launches event-aggregator coroutines from
Expand All @@ -281,7 +319,7 @@ internal object NativeInterfaceFactory {
appContext = appContext,
hostBridge = rnodeHostBridge,
scope = scope,
onRegisterAndTrack = ::registerAndTrack,
onRegisterAndTrack = { name, iface -> registerAndTrack(name, iface, config) },
onMonitorLifecycle = ::monitorRNodeLifecycle,
onEnsureRecovery = ::ensureRNodeRecovery,
)
Expand All @@ -294,6 +332,7 @@ internal object NativeInterfaceFactory {
// block sees the interface as unmanaged and bails out instead of
// leaving an orphaned collector behind.
val iface = runningInterfaces.remove(name)
runningModes.remove(name)
onlineObservers.remove(name)?.cancel()
if (iface == null) return
try {
Expand Down Expand Up @@ -356,7 +395,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 +412,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 +423,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 +432,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 +441,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
Loading
Loading