feat: add SdkRadioControllerImpl and SdkStateBridge for SDK hard cutover

Replace the legacy AIDL-based AndroidRadioControllerImpl with an SDK-backed
implementation that delegates all operations through RadioClient:

SdkRadioControllerImpl:
- Full RadioController interface impl (~35 methods)
- Local admin ops → SDK AdminApi/TelemetryApi/RoutingApi
- Remote admin ops → raw MeshPacket via RadioClient.send()
- Registered as @Single(binds = [RadioController::class]) in Koin

SdkStateBridge:
- Bridges SDK reactive flows (connection, nodes, packets, events) into
  ServiceRepository and NodeManager for legacy UI compatibility
- Connection state mapping (SDK states → app ConnectionState enum)
- Node snapshot/added/updated/removed → NodeManager
- Inbound telemetry/position/user packets → NodeManager handlers
- Events (reboot, security warnings, drops) → notification layer

AndroidRadioControllerImpl:
- @Single annotation disabled (commented out) to prevent Koin conflict
- Class retained for reference/fallback

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
James Rich
2026-05-04 18:36:46 -05:00
parent 384746cc75
commit f5e2aabc00
4 changed files with 658 additions and 3 deletions

View File

@@ -46,7 +46,10 @@ import org.meshtastic.sdk.transport.tcp.TcpTransport
* service can react to connection changes with `flatMapLatest`.
*/
@Single(binds = [SdkClientLifecycle::class])
class RadioClientProvider(private val context: Context, private val radioPrefs: RadioPrefs) : SdkClientLifecycle {
class RadioClientProvider(
private val context: Context,
private val radioPrefs: RadioPrefs,
) : SdkClientLifecycle {
private val _client = MutableStateFlow<RadioClient?>(null)
/** Active [RadioClient], or `null` when disconnected or between connections. */

View File

@@ -0,0 +1,475 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.radio
import co.touchlab.kermit.Logger
import kotlinx.coroutines.flow.StateFlow
import okio.ByteString
import okio.ByteString.Companion.toByteString
import org.koin.core.annotation.Single
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.Position
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.repository.MeshLocationManager
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.proto.AdminMessage
import org.meshtastic.proto.Channel
import org.meshtastic.proto.ClientNotification
import org.meshtastic.proto.Config
import org.meshtastic.proto.Data
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.ModuleConfig
import org.meshtastic.proto.PortNum
import org.meshtastic.proto.SharedContact
import org.meshtastic.proto.User
import org.meshtastic.sdk.AdminResult
import org.meshtastic.sdk.ChannelIndex
import org.meshtastic.sdk.NodeId
import org.meshtastic.sdk.RadioClient
import java.util.concurrent.atomic.AtomicInteger
/**
* [RadioController] implementation that delegates all operations through the meshtastic-sdk.
*
* This replaces [org.meshtastic.core.service.AndroidRadioControllerImpl] in the hard-cutover POC. Feature modules
* continue injecting [RadioController] and get SDK-backed behavior without code changes.
*
* **Command dispatch:** All admin, telemetry, and routing operations go through [RadioClient.admin],
* [RadioClient.telemetry], and [RadioClient.routing] respectively.
*
* **State distribution:** Handled separately by [SdkStateBridge], which feeds SDK flows back into
* [ServiceRepository] and [org.meshtastic.core.repository.NodeManager].
*/
@Single(binds = [RadioController::class])
@Suppress("TooManyFunctions", "LongParameterList")
class SdkRadioControllerImpl(
private val provider: RadioClientProvider,
private val serviceRepository: ServiceRepository,
private val nodeRepository: NodeRepository,
private val locationManager: MeshLocationManager,
) : RadioController {
private val packetIdCounter = AtomicInteger(1)
private val client: RadioClient?
get() = provider.client.value
private fun requireClient(): RadioClient {
return client ?: run {
Logger.w { "SdkRadioControllerImpl: no active RadioClient" }
throw IllegalStateException("RadioClient not connected")
}
}
// ── Observable state ────────────────────────────────────────────────────
override val connectionState: StateFlow<ConnectionState>
get() = serviceRepository.connectionState
override val clientNotification: StateFlow<ClientNotification?>
get() = serviceRepository.clientNotification
override fun clearClientNotification() {
serviceRepository.clearClientNotification()
}
// ── Messaging ───────────────────────────────────────────────────────────
override suspend fun sendMessage(packet: DataPacket) {
val c = client ?: run {
Logger.w { "sendMessage: no client, dropping packet" }
return
}
val destNum = when (packet.to) {
null, DataPacket.ID_BROADCAST -> DataPacket.NODENUM_BROADCAST
else -> DataPacket.idToDefaultNodeNum(packet.to?.removePrefix("!")) ?: DataPacket.NODENUM_BROADCAST
}
val meshPacket = MeshPacket(
to = destNum,
channel = packet.channel,
decoded = Data(
portnum = PortNum.fromValue(packet.dataType) ?: PortNum.UNKNOWN_APP,
payload = packet.bytes ?: ByteString.EMPTY,
want_response = false,
),
)
c.send(meshPacket)
}
// ── Node operations ─────────────────────────────────────────────────────
override suspend fun favoriteNode(nodeNum: Int) {
val c = requireClient()
val node = nodeRepository.getNode(DataPacket.nodeNumToDefaultId(nodeNum))
val currentlyFavorite = node.isFavorite
c.admin.setFavorite(NodeId(nodeNum), !currentlyFavorite)
}
override suspend fun sendSharedContact(nodeNum: Int): Boolean {
val c = client ?: return false
val node = nodeRepository.getNode(DataPacket.nodeNumToDefaultId(nodeNum))
val contact = SharedContact(
node_num = node.num,
user = node.user,
manually_verified = node.manuallyVerified,
)
return when (c.admin.addContact(contact)) {
is AdminResult.Success -> true
else -> false
}
}
// ── Local config ────────────────────────────────────────────────────────
override suspend fun setLocalConfig(config: Config) {
val c = requireClient()
c.admin.setConfig(config)
}
override suspend fun setLocalChannel(channel: Channel) {
val c = requireClient()
c.admin.setChannel(channel)
}
// ── Remote admin (config/owner/channel) ─────────────────────────────────
override suspend fun setOwner(destNum: Int, user: User, packetId: Int) {
val c = requireClient()
if (isLocalNode(destNum)) {
c.admin.setOwner(user)
} else {
sendRemoteAdmin(c, destNum, AdminMessage(set_owner = user))
}
}
override suspend fun setConfig(destNum: Int, config: Config, packetId: Int) {
val c = requireClient()
if (isLocalNode(destNum)) {
c.admin.setConfig(config)
} else {
sendRemoteAdmin(c, destNum, AdminMessage(set_config = config))
}
}
override suspend fun setModuleConfig(destNum: Int, config: ModuleConfig, packetId: Int) {
val c = requireClient()
if (isLocalNode(destNum)) {
c.admin.setModuleConfig(config)
} else {
sendRemoteAdmin(c, destNum, AdminMessage(set_module_config = config))
}
}
override suspend fun setRemoteChannel(destNum: Int, channel: Channel, packetId: Int) {
val c = requireClient()
if (isLocalNode(destNum)) {
c.admin.setChannel(channel)
} else {
sendRemoteAdmin(c, destNum, AdminMessage(set_channel = channel))
}
}
override suspend fun setFixedPosition(destNum: Int, position: Position) {
val c = requireClient()
val protoPos = org.meshtastic.proto.Position(
latitude_i = Position.degI(position.latitude),
longitude_i = Position.degI(position.longitude),
altitude = position.altitude,
time = position.time,
)
if (isLocalNode(destNum)) {
c.admin.setFixedPosition(protoPos)
} else {
sendRemoteAdmin(c, destNum, AdminMessage(set_fixed_position = protoPos))
}
}
override suspend fun setRingtone(destNum: Int, ringtone: String) {
val c = requireClient()
if (isLocalNode(destNum)) {
c.admin.setRingtone(ringtone)
} else {
sendRemoteAdmin(c, destNum, AdminMessage(set_ringtone_message = ringtone))
}
}
override suspend fun setCannedMessages(destNum: Int, messages: String) {
val c = requireClient()
if (isLocalNode(destNum)) {
c.admin.setCannedMessages(messages)
} else {
sendRemoteAdmin(c, destNum, AdminMessage(set_canned_message_module_messages = messages))
}
}
// ── Remote admin (getters) ──────────────────────────────────────────────
override suspend fun getOwner(destNum: Int, packetId: Int) {
val c = requireClient()
if (isLocalNode(destNum)) {
c.admin.getOwner()
} else {
sendRemoteAdmin(c, destNum, AdminMessage(get_owner_request = true), wantResponse = true)
}
}
override suspend fun getConfig(destNum: Int, configType: Int, packetId: Int) {
val c = requireClient()
val type = AdminMessage.ConfigType.fromValue(configType) ?: return
if (isLocalNode(destNum)) {
c.admin.getConfig(type)
} else {
sendRemoteAdmin(c, destNum, AdminMessage(get_config_request = type), wantResponse = true)
}
}
override suspend fun getModuleConfig(destNum: Int, moduleConfigType: Int, packetId: Int) {
val c = requireClient()
val type = AdminMessage.ModuleConfigType.fromValue(moduleConfigType) ?: return
if (isLocalNode(destNum)) {
c.admin.getModuleConfig(type)
} else {
sendRemoteAdmin(c, destNum, AdminMessage(get_module_config_request = type), wantResponse = true)
}
}
override suspend fun getChannel(destNum: Int, index: Int, packetId: Int) {
val c = requireClient()
if (isLocalNode(destNum)) {
c.admin.getChannel(ChannelIndex(index))
} else {
sendRemoteAdmin(c, destNum, AdminMessage(get_channel_request = index + 1), wantResponse = true)
}
}
override suspend fun getRingtone(destNum: Int, packetId: Int) {
val c = requireClient()
if (isLocalNode(destNum)) {
c.admin.getRingtone()
} else {
sendRemoteAdmin(c, destNum, AdminMessage(get_ringtone_request = true), wantResponse = true)
}
}
override suspend fun getCannedMessages(destNum: Int, packetId: Int) {
val c = requireClient()
if (isLocalNode(destNum)) {
c.admin.getCannedMessages()
} else {
sendRemoteAdmin(c, destNum, AdminMessage(get_canned_message_module_messages_request = true), wantResponse = true)
}
}
override suspend fun getDeviceConnectionStatus(destNum: Int, packetId: Int) {
val c = requireClient()
if (isLocalNode(destNum)) {
c.admin.getDeviceConnectionStatus()
} else {
sendRemoteAdmin(c, destNum, AdminMessage(get_device_connection_status_request = true), wantResponse = true)
}
}
// ── Lifecycle commands ───────────────────────────────────────────────────
override suspend fun reboot(destNum: Int, packetId: Int) {
val c = requireClient()
if (isLocalNode(destNum)) {
c.admin.reboot()
} else {
sendRemoteAdmin(c, destNum, AdminMessage(reboot_seconds = 0))
}
}
override suspend fun rebootToDfu(nodeNum: Int) {
val c = requireClient()
if (isLocalNode(nodeNum)) {
c.admin.enterDfuMode()
} else {
sendRemoteAdmin(c, nodeNum, AdminMessage(enter_dfu_mode_request = true))
}
}
override suspend fun requestRebootOta(requestId: Int, destNum: Int, mode: Int, hash: ByteArray?) {
val c = requireClient()
if (isLocalNode(destNum)) {
c.admin.rebootOta()
} else {
sendRemoteAdmin(c, destNum, AdminMessage(reboot_ota_seconds = 0))
}
}
override suspend fun shutdown(destNum: Int, packetId: Int) {
val c = requireClient()
if (isLocalNode(destNum)) {
c.admin.shutdown()
} else {
sendRemoteAdmin(c, destNum, AdminMessage(shutdown_seconds = 0))
}
}
override suspend fun factoryReset(destNum: Int, packetId: Int) {
val c = requireClient()
if (isLocalNode(destNum)) {
c.admin.factoryReset()
} else {
sendRemoteAdmin(c, destNum, AdminMessage(factory_reset_config = 1))
}
}
override suspend fun nodedbReset(destNum: Int, packetId: Int, preserveFavorites: Boolean) {
val c = requireClient()
if (isLocalNode(destNum)) {
c.admin.nodeDbReset()
} else {
sendRemoteAdmin(c, destNum, AdminMessage(nodedb_reset = true))
}
}
override suspend fun removeByNodenum(packetId: Int, nodeNum: Int) {
val c = requireClient()
c.admin.removeNode(NodeId(nodeNum))
}
// ── Data requests ───────────────────────────────────────────────────────
override suspend fun requestPosition(destNum: Int, currentPosition: Position) {
val c = client ?: return
val posBytes = org.meshtastic.proto.Position(
latitude_i = Position.degI(currentPosition.latitude),
longitude_i = Position.degI(currentPosition.longitude),
altitude = currentPosition.altitude,
time = currentPosition.time,
).encode()
c.send(
portnum = PortNum.POSITION_APP,
payload = posBytes,
to = NodeId(destNum),
wantAck = true,
)
}
override suspend fun requestUserInfo(destNum: Int) {
val c = client ?: return
// Send an empty NODEINFO_APP packet with want_response to request user info
c.send(
MeshPacket(
to = destNum,
want_ack = true,
decoded = Data(
portnum = PortNum.NODEINFO_APP,
payload = byteArrayOf().toByteString(),
want_response = true,
),
),
)
}
override suspend fun requestTraceroute(requestId: Int, destNum: Int) {
val c = requireClient()
c.routing.traceRoute(NodeId(destNum))
}
override suspend fun requestTelemetry(requestId: Int, destNum: Int, typeValue: Int) {
val c = requireClient()
val node = NodeId(destNum)
// TelemetryType enum values: 0=DEVICE, 1=ENVIRONMENT, 2=AIR_QUALITY, 3=POWER, 4=LOCAL_STATS, 5=HEALTH
when (typeValue) {
0 -> c.telemetry.requestDevice(node)
1 -> c.telemetry.requestEnvironment(node)
2 -> c.telemetry.requestAirQuality(node)
3 -> c.telemetry.requestPower(node)
4 -> c.telemetry.requestLocalStats()
5 -> c.telemetry.requestHealth(node)
6 -> c.telemetry.requestHost(node)
7 -> c.telemetry.requestTrafficManagement(node)
else -> Logger.w { "Unknown telemetry type: $typeValue" }
}
}
override suspend fun requestNeighborInfo(requestId: Int, destNum: Int) {
val c = requireClient()
c.routing.requestNeighborInfo(NodeId(destNum))
}
// ── Edit settings (transactional) ───────────────────────────────────────
override suspend fun beginEditSettings(destNum: Int) {
val c = client ?: return
// Send raw begin_edit_settings admin message for compatibility with the split begin/commit pattern
val msg = AdminMessage(begin_edit_settings = true)
val target = if (isLocalNode(destNum)) NodeId(c.ownNode.value?.num ?: 0) else NodeId(destNum)
sendRemoteAdmin(c, target.raw, msg)
}
override suspend fun commitEditSettings(destNum: Int) {
val c = client ?: return
val msg = AdminMessage(commit_edit_settings = true)
val target = if (isLocalNode(destNum)) NodeId(c.ownNode.value?.num ?: 0) else NodeId(destNum)
sendRemoteAdmin(c, target.raw, msg)
}
// ── Utility ─────────────────────────────────────────────────────────────
override fun getPacketId(): Int = packetIdCounter.getAndIncrement()
override fun startProvideLocation() {
// Location provision is managed at the app level; no-op until bridge wires it
}
override fun stopProvideLocation() {
locationManager.stop()
}
override fun setDeviceAddress(address: String) {
// Changing device address requires rebuilding the SDK client connection
provider.rebuildAndConnectAsync()
}
// ── Private helpers ─────────────────────────────────────────────────────
private fun isLocalNode(destNum: Int): Boolean {
if (destNum == 0) return true
val ownNum = client?.ownNode?.value?.num ?: return true
return destNum == ownNum
}
/**
* Sends a raw admin message to a remote node via the SDK's send path.
* Used for remote-admin operations where destNum != local node.
*/
private suspend fun sendRemoteAdmin(
c: RadioClient,
destNum: Int,
adminMsg: AdminMessage,
wantResponse: Boolean = false,
) {
val payload = AdminMessage.ADAPTER.encode(adminMsg).toByteString()
c.send(
MeshPacket(
to = destNum,
want_ack = true,
decoded = Data(
portnum = PortNum.ADMIN_APP,
payload = payload,
want_response = wantResponse,
),
),
)
}
}

View File

@@ -0,0 +1,178 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.radio
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import org.koin.core.annotation.Single
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.ConnectionState as AppConnectionState
import org.meshtastic.core.repository.NodeManager
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.proto.ClientNotification
import org.meshtastic.proto.PortNum
import org.meshtastic.proto.Telemetry
import org.meshtastic.proto.User
import org.meshtastic.proto.Position as ProtoPosition
import org.meshtastic.sdk.ConnectionState as SdkConnectionState
import org.meshtastic.sdk.MeshEvent
import org.meshtastic.sdk.NodeChange
/**
* Bridges SDK reactive flows into the legacy repository layer.
*
* The SDK owns the transport and all state; this bridge maps SDK emissions into [ServiceRepository]
* and [NodeManager] so that existing feature-module UI code (which observes those repositories)
* continues to work without modification.
*
* **Lifecycle:** Created as a Koin `@Single`. Automatically subscribes to [RadioClientProvider.client]
* and starts/stops collection as clients come and go. No explicit lifecycle management needed.
*
* **Mapping policy:**
* - SDK `Connected` → app `Connected`
* - SDK `Connecting` / `Configuring` → app `Connecting`
* - SDK `Reconnecting` → app `DeviceSleep` (firmware went to sleep or transport dropped)
* - SDK `Disconnected` → app `Disconnected`
*/
@Single
class SdkStateBridge(
private val provider: RadioClientProvider,
private val serviceRepository: ServiceRepository,
private val nodeManager: NodeManager,
private val dispatchers: CoroutineDispatchers,
) {
private val scope = CoroutineScope(SupervisorJob() + dispatchers.default)
init {
startBridge()
}
private fun startBridge() {
// ── Connection state bridge ─────────────────────────────────────────
provider.client
.flatMapLatest { client -> client?.connection ?: flowOf(SdkConnectionState.Disconnected) }
.onEach { sdkState -> serviceRepository.setConnectionState(mapConnectionState(sdkState)) }
.launchIn(scope)
// ── Node updates bridge ─────────────────────────────────────────────
provider.client
.flatMapLatest { client -> client?.nodes ?: flowOf() }
.onEach { change ->
when (change) {
is NodeChange.Snapshot -> {
nodeManager.clear()
change.nodes.forEach { (_, nodeInfo) ->
nodeManager.installNodeInfo(nodeInfo, withBroadcast = false)
}
nodeManager.setNodeDbReady(true)
}
is NodeChange.Added -> {
nodeManager.installNodeInfo(change.node, withBroadcast = true)
}
is NodeChange.Updated -> {
nodeManager.installNodeInfo(change.node, withBroadcast = true)
}
is NodeChange.Removed -> {
nodeManager.removeByNodenum(change.nodeId.raw)
}
}
}
.launchIn(scope)
// ── Own node identity bridge ────────────────────────────────────────
provider.client
.flatMapLatest { client -> client?.ownNode ?: flowOf(null) }
.onEach { ownNode ->
if (ownNode != null) {
nodeManager.setMyNodeNum(ownNode.num)
}
}
.launchIn(scope)
// ── Inbound packet bridge (telemetry, position, user updates) ───────
provider.client
.flatMapLatest { client -> client?.packets ?: flowOf() }
.onEach { packet ->
val decoded = packet.decoded ?: return@onEach
val fromNum = packet.from
val myNodeNum = nodeManager.myNodeNum.value ?: 0
val payloadBytes = decoded.payload?.toByteArray() ?: return@onEach
when (decoded.portnum) {
PortNum.TELEMETRY_APP -> {
runCatching { Telemetry.ADAPTER.decode(payloadBytes) }
.onSuccess { nodeManager.handleReceivedTelemetry(fromNum, it) }
}
PortNum.POSITION_APP -> {
runCatching { ProtoPosition.ADAPTER.decode(payloadBytes) }
.onSuccess { nodeManager.handleReceivedPosition(fromNum, myNodeNum, it, packet.rx_time.toLong()) }
}
PortNum.NODEINFO_APP -> {
runCatching { User.ADAPTER.decode(payloadBytes) }
.onSuccess { nodeManager.handleReceivedUser(fromNum, it, packet.channel) }
}
else -> {
// Other port types are handled directly by feature VMs via SDK flows
}
}
}
.launchIn(scope)
// ── Events bridge (notifications, warnings) ─────────────────────────
provider.client
.flatMapLatest { client -> client?.events ?: flowOf() }
.onEach { event ->
when (event) {
is MeshEvent.DeviceRebooted -> {
Logger.i { "[SdkBridge] Device rebooted" }
serviceRepository.setClientNotification(
ClientNotification(message = "Device rebooted"),
)
}
is MeshEvent.SecurityWarning -> {
Logger.w { "[SdkBridge] Security warning: $event" }
}
is MeshEvent.PacketsDropped -> {
Logger.w { "[SdkBridge] Packets dropped: ${event.count} from ${event.flow}" }
}
else -> {
Logger.d { "[SdkBridge] Event: $event" }
}
}
}
.launchIn(scope)
Logger.i { "SdkStateBridge started" }
}
companion object {
/** Map SDK connection states to the app's legacy connection states. */
fun mapConnectionState(sdkState: SdkConnectionState): AppConnectionState = when (sdkState) {
is SdkConnectionState.Disconnected -> AppConnectionState.Disconnected
is SdkConnectionState.Connecting -> AppConnectionState.Connecting
is SdkConnectionState.Configuring -> AppConnectionState.Connecting
is SdkConnectionState.Connected -> AppConnectionState.Connected
is SdkConnectionState.Reconnecting -> AppConnectionState.DeviceSleep
}
}
}

View File

@@ -20,7 +20,6 @@ import android.content.Context
import android.content.Intent
import co.touchlab.kermit.Logger
import kotlinx.coroutines.flow.StateFlow
import org.koin.core.annotation.Single
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.Position
@@ -40,7 +39,7 @@ import org.meshtastic.proto.User
* All radio commands are forwarded through [AndroidServiceRepository.meshService]. If the service is not yet bound,
* commands are silently dropped with a warning log.
*/
@Single
// @Single — disabled for SDK hard-cutover POC; SdkRadioControllerImpl provides RadioController instead
@Suppress("TooManyFunctions")
class AndroidRadioControllerImpl(
private val context: Context,