Initial commit
This commit is contained in:
57
modules/communications/build.gradle.kts
Normal file
57
modules/communications/build.gradle.kts
Normal file
@@ -0,0 +1,57 @@
|
||||
plugins {
|
||||
id("com.android.library")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
id("kotlin-kapt")
|
||||
id("dagger.hilt.android.plugin")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.smoa.modules.communications"
|
||||
compileSdk = AppConfig.compileSdk
|
||||
|
||||
defaultConfig {
|
||||
minSdk = AppConfig.minSdk
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
}
|
||||
|
||||
composeOptions {
|
||||
kotlinCompilerExtensionVersion = "1.5.4"
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":core:common"))
|
||||
implementation(project(":core:auth"))
|
||||
implementation(project(":core:security"))
|
||||
|
||||
implementation(platform(Dependencies.composeBom))
|
||||
implementation(Dependencies.composeUi)
|
||||
implementation(Dependencies.composeUiGraphics)
|
||||
implementation(Dependencies.composeMaterial3)
|
||||
implementation(Dependencies.androidxCoreKtx)
|
||||
implementation(Dependencies.androidxLifecycleRuntimeKtx)
|
||||
|
||||
implementation(Dependencies.hiltAndroid)
|
||||
kapt(Dependencies.hiltAndroidCompiler)
|
||||
|
||||
implementation(Dependencies.retrofit)
|
||||
implementation(Dependencies.okHttp)
|
||||
|
||||
// WebRTC - TODO: Configure WebRTC dependency
|
||||
// WebRTC library needs to be built from source or obtained separately
|
||||
// Uncomment when WebRTC is available:
|
||||
// implementation(Dependencies.webrtc)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.smoa.modules.communications
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.smoa.core.auth.RBACFramework
|
||||
import com.smoa.modules.communications.domain.CommunicationsService
|
||||
import com.smoa.modules.communications.ui.CommunicationsScreen
|
||||
|
||||
/**
|
||||
* Communications module - Mission voice communications using channelized, unit-based access.
|
||||
*/
|
||||
@Composable
|
||||
fun CommunicationsModule(
|
||||
communicationsService: CommunicationsService,
|
||||
userRole: RBACFramework.Role,
|
||||
userUnit: String?,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
CommunicationsScreen(
|
||||
communicationsService = communicationsService,
|
||||
userRole = userRole,
|
||||
userUnit = userUnit,
|
||||
modifier = modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package com.smoa.modules.communications.di
|
||||
|
||||
import android.content.Context
|
||||
import com.smoa.core.security.AuditLogger
|
||||
import com.smoa.modules.communications.domain.ChannelManager
|
||||
import com.smoa.modules.communications.domain.CommunicationsService
|
||||
import com.smoa.modules.communications.domain.VoiceTransport
|
||||
import com.smoa.modules.communications.domain.WebRTCManager
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object CommunicationsModule {
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideWebRTCManager(
|
||||
@ApplicationContext context: Context
|
||||
): WebRTCManager {
|
||||
return WebRTCManager(context)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideChannelManager(): ChannelManager {
|
||||
return ChannelManager()
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideVoiceTransport(
|
||||
webRTCManager: WebRTCManager
|
||||
): VoiceTransport {
|
||||
return VoiceTransport(webRTCManager)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideCommunicationsService(
|
||||
channelManager: ChannelManager,
|
||||
voiceTransport: VoiceTransport,
|
||||
auditLogger: AuditLogger,
|
||||
rbacFramework: com.smoa.core.auth.RBACFramework
|
||||
): CommunicationsService {
|
||||
return CommunicationsService(
|
||||
channelManager,
|
||||
voiceTransport,
|
||||
auditLogger,
|
||||
rbacFramework
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
package com.smoa.modules.communications.domain
|
||||
|
||||
import com.smoa.core.auth.RBACFramework
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Channel manager for communication channels.
|
||||
* Manages channel access based on role and unit authorization.
|
||||
*/
|
||||
@Singleton
|
||||
class ChannelManager @Inject constructor() {
|
||||
private val channels = mutableMapOf<String, Channel>()
|
||||
|
||||
init {
|
||||
// Initialize default channels (can be loaded from policy/config)
|
||||
// Example channels would be added here
|
||||
}
|
||||
|
||||
/**
|
||||
* Get channel by ID.
|
||||
*/
|
||||
fun getChannel(channelId: String): Channel? {
|
||||
return channels[channelId]
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available channels for user based on role and unit.
|
||||
*/
|
||||
fun getAvailableChannels(
|
||||
userRole: RBACFramework.Role,
|
||||
userUnit: String?
|
||||
): List<Channel> {
|
||||
return channels.values.filter { channel ->
|
||||
hasAccess(channel, userRole, userUnit)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has access to channel.
|
||||
*/
|
||||
fun hasAccess(
|
||||
channel: Channel,
|
||||
userRole: RBACFramework.Role,
|
||||
userUnit: String?
|
||||
): Boolean {
|
||||
// Admins can access all channels
|
||||
if (userRole == RBACFramework.Role.ADMIN) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check unit authorization
|
||||
if (channel.unitRestricted && userUnit != null && channel.allowedUnits.contains(userUnit)) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check role authorization
|
||||
if (channel.allowedRoles.contains(userRole)) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a channel.
|
||||
*/
|
||||
fun registerChannel(channel: Channel) {
|
||||
channels[channel.id] = channel
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a channel.
|
||||
*/
|
||||
fun removeChannel(channelId: String) {
|
||||
channels.remove(channelId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Communication channel.
|
||||
*/
|
||||
data class Channel(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val description: String?,
|
||||
val unitRestricted: Boolean,
|
||||
val allowedUnits: Set<String>,
|
||||
val allowedRoles: Set<RBACFramework.Role>,
|
||||
val priority: ChannelPriority = ChannelPriority.NORMAL,
|
||||
val encrypted: Boolean = true
|
||||
)
|
||||
|
||||
/**
|
||||
* Channel priority levels.
|
||||
*/
|
||||
enum class ChannelPriority {
|
||||
LOW,
|
||||
NORMAL,
|
||||
HIGH,
|
||||
ALERT
|
||||
}
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
package com.smoa.modules.communications.domain
|
||||
|
||||
import com.smoa.core.auth.RBACFramework
|
||||
import com.smoa.core.common.Result
|
||||
import com.smoa.core.security.AuditLogger
|
||||
import com.smoa.core.security.AuditEventType
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import java.util.Date
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Communications service for radio-style voice communications.
|
||||
* Supports multi-channel push-to-talk (PTT) with encrypted voice transport.
|
||||
*/
|
||||
@Singleton
|
||||
class CommunicationsService @Inject constructor(
|
||||
private val channelManager: ChannelManager,
|
||||
private val voiceTransport: VoiceTransport,
|
||||
private val auditLogger: AuditLogger,
|
||||
private val rbacFramework: RBACFramework
|
||||
) {
|
||||
private val _currentChannel = MutableStateFlow<Channel?>(null)
|
||||
val currentChannel: StateFlow<Channel?> = _currentChannel.asStateFlow()
|
||||
|
||||
private val _isPTTActive = MutableStateFlow(false)
|
||||
val isPTTActive: StateFlow<Boolean> = _isPTTActive.asStateFlow()
|
||||
|
||||
/**
|
||||
* Join a communication channel.
|
||||
*/
|
||||
suspend fun joinChannel(
|
||||
channelId: String,
|
||||
userRole: RBACFramework.Role,
|
||||
userUnit: String?
|
||||
): Result<Channel> {
|
||||
val channel = channelManager.getChannel(channelId) ?: return Result.Error(
|
||||
IllegalArgumentException("Channel not found: $channelId")
|
||||
)
|
||||
|
||||
// Check authorization
|
||||
if (!channelManager.hasAccess(channel, userRole, userUnit)) {
|
||||
return Result.Error(SecurityException("Access denied to channel: $channelId"))
|
||||
}
|
||||
|
||||
// Leave current channel if any
|
||||
_currentChannel.value?.let { leaveChannel(it.id) }
|
||||
|
||||
// Join new channel
|
||||
val joinResult = voiceTransport.joinChannel(channelId)
|
||||
return when (joinResult) {
|
||||
is Result.Success -> {
|
||||
_currentChannel.value = channel
|
||||
auditLogger.logEvent(
|
||||
AuditEventType.CHANNEL_JOINED,
|
||||
mapOf(
|
||||
"channelId" to channelId,
|
||||
"channelName" to channel.name,
|
||||
"timestamp" to Date().toString()
|
||||
)
|
||||
)
|
||||
Result.Success(channel)
|
||||
}
|
||||
is Result.Error -> joinResult
|
||||
is Result.Loading -> Result.Error(Exception("Unexpected loading state"))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Leave current channel.
|
||||
*/
|
||||
suspend fun leaveChannel(channelId: String): Result<Unit> {
|
||||
val result = voiceTransport.leaveChannel(channelId)
|
||||
when (result) {
|
||||
is Result.Success -> {
|
||||
_currentChannel.value = null
|
||||
auditLogger.logEvent(
|
||||
AuditEventType.CHANNEL_LEFT,
|
||||
mapOf(
|
||||
"channelId" to channelId,
|
||||
"timestamp" to Date().toString()
|
||||
)
|
||||
)
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Start push-to-talk (PTT).
|
||||
*/
|
||||
suspend fun startPTT(): Result<Unit> {
|
||||
val channel = _currentChannel.value ?: return Result.Error(
|
||||
IllegalStateException("Not connected to any channel")
|
||||
)
|
||||
|
||||
val result = voiceTransport.startTransmission(channel.id)
|
||||
when (result) {
|
||||
is Result.Success -> {
|
||||
_isPTTActive.value = true
|
||||
auditLogger.logEvent(
|
||||
AuditEventType.PTT_STARTED,
|
||||
mapOf(
|
||||
"channelId" to channel.id,
|
||||
"timestamp" to Date().toString()
|
||||
)
|
||||
)
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop push-to-talk (PTT).
|
||||
*/
|
||||
suspend fun stopPTT(): Result<Unit> {
|
||||
val channel = _currentChannel.value ?: return Result.Error(
|
||||
IllegalStateException("Not connected to any channel")
|
||||
)
|
||||
|
||||
val result = voiceTransport.stopTransmission(channel.id)
|
||||
when (result) {
|
||||
is Result.Success -> {
|
||||
_isPTTActive.value = false
|
||||
auditLogger.logEvent(
|
||||
AuditEventType.PTT_STOPPED,
|
||||
mapOf(
|
||||
"channelId" to channel.id,
|
||||
"timestamp" to Date().toString()
|
||||
)
|
||||
)
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available channels for user.
|
||||
*/
|
||||
suspend fun getAvailableChannels(
|
||||
userRole: RBACFramework.Role,
|
||||
userUnit: String?
|
||||
): List<Channel> {
|
||||
return channelManager.getAvailableChannels(userRole, userUnit)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
package com.smoa.modules.communications.domain
|
||||
|
||||
import com.smoa.core.common.Result
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Voice transport for encrypted voice communication.
|
||||
* Uses WebRTC for peer-to-peer encrypted voice transmission.
|
||||
*/
|
||||
@Singleton
|
||||
class VoiceTransport @Inject constructor(
|
||||
private val webRTCManager: WebRTCManager
|
||||
) {
|
||||
private val _connectionState = MutableStateFlow<ConnectionState>(ConnectionState.Disconnected)
|
||||
val connectionState: StateFlow<ConnectionState> = _connectionState.asStateFlow()
|
||||
|
||||
private var currentChannelId: String? = null
|
||||
private var isTransmitting = false
|
||||
private var peerConnection: WebRTCPeerConnection? = null
|
||||
|
||||
/**
|
||||
* Join a communication channel.
|
||||
*/
|
||||
suspend fun joinChannel(channelId: String): Result<Unit> {
|
||||
return try {
|
||||
_connectionState.value = ConnectionState.Connecting(channelId)
|
||||
|
||||
// Initialize WebRTC peer connection (audio only for voice)
|
||||
val connectionResult = webRTCManager.initializePeerConnection(channelId, isAudioOnly = true)
|
||||
|
||||
when (connectionResult) {
|
||||
is Result.Success -> {
|
||||
peerConnection = connectionResult.data
|
||||
currentChannelId = channelId
|
||||
_connectionState.value = ConnectionState.Connected(channelId)
|
||||
Result.Success(Unit)
|
||||
}
|
||||
is Result.Error -> {
|
||||
_connectionState.value = ConnectionState.Error(connectionResult.exception.message ?: "Failed to connect")
|
||||
Result.Error(connectionResult.exception)
|
||||
}
|
||||
is Result.Loading -> {
|
||||
_connectionState.value = ConnectionState.Error("Unexpected loading state")
|
||||
Result.Error(Exception("Unexpected loading state"))
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_connectionState.value = ConnectionState.Error(e.message ?: "Unknown error")
|
||||
Result.Error(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Leave current channel.
|
||||
*/
|
||||
suspend fun leaveChannel(channelId: String): Result<Unit> {
|
||||
return try {
|
||||
if (isTransmitting) {
|
||||
stopTransmission(channelId)
|
||||
}
|
||||
|
||||
// Close WebRTC peer connection
|
||||
peerConnection?.let { connection ->
|
||||
webRTCManager.closePeerConnection(connection)
|
||||
}
|
||||
|
||||
peerConnection = null
|
||||
currentChannelId = null
|
||||
_connectionState.value = ConnectionState.Disconnected
|
||||
Result.Success(Unit)
|
||||
} catch (e: Exception) {
|
||||
Result.Error(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start voice transmission (PTT).
|
||||
*/
|
||||
suspend fun startTransmission(channelId: String): Result<Unit> {
|
||||
return try {
|
||||
if (currentChannelId != channelId) {
|
||||
return Result.Error(IllegalStateException("Not connected to channel: $channelId"))
|
||||
}
|
||||
|
||||
val connection = peerConnection ?: return Result.Error(
|
||||
IllegalStateException("No active peer connection")
|
||||
)
|
||||
|
||||
// Start audio transmission via WebRTC
|
||||
val result = webRTCManager.startAudioTransmission(connection)
|
||||
when (result) {
|
||||
is Result.Success -> {
|
||||
isTransmitting = true
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
result
|
||||
} catch (e: Exception) {
|
||||
Result.Error(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop voice transmission (PTT release).
|
||||
*/
|
||||
suspend fun stopTransmission(channelId: String): Result<Unit> {
|
||||
return try {
|
||||
val connection = peerConnection ?: return Result.Error(
|
||||
IllegalStateException("No active peer connection")
|
||||
)
|
||||
|
||||
// Stop audio transmission via WebRTC
|
||||
val result = webRTCManager.stopAudioTransmission(connection)
|
||||
when (result) {
|
||||
is Result.Success -> {
|
||||
isTransmitting = false
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
result
|
||||
} catch (e: Exception) {
|
||||
Result.Error(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Connection state.
|
||||
*/
|
||||
sealed class ConnectionState {
|
||||
object Disconnected : ConnectionState()
|
||||
data class Connecting(val channelId: String) : ConnectionState()
|
||||
data class Connected(val channelId: String) : ConnectionState()
|
||||
data class Error(val message: String) : ConnectionState()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.smoa.modules.communications.domain
|
||||
|
||||
/**
|
||||
* WebRTC configuration for STUN/TURN servers and signaling.
|
||||
*/
|
||||
data class WebRTCConfig(
|
||||
val stunServers: List<StunServer>,
|
||||
val turnServers: List<TurnServer>,
|
||||
val signalingServerUrl: String,
|
||||
val iceCandidatePoolSize: Int = 10
|
||||
) {
|
||||
companion object {
|
||||
/**
|
||||
* Default configuration with public STUN servers.
|
||||
* In production, use organization-specific STUN/TURN servers.
|
||||
*/
|
||||
fun default(): WebRTCConfig {
|
||||
return WebRTCConfig(
|
||||
stunServers = listOf(
|
||||
StunServer("stun:stun.l.google.com:19302"),
|
||||
StunServer("stun:stun1.l.google.com:19302")
|
||||
),
|
||||
turnServers = emptyList(), // TURN servers should be configured per deployment
|
||||
signalingServerUrl = "", // Should be configured per deployment
|
||||
iceCandidatePoolSize = 10
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* STUN server configuration.
|
||||
*/
|
||||
data class StunServer(
|
||||
val url: String
|
||||
)
|
||||
|
||||
/**
|
||||
* TURN server configuration.
|
||||
*/
|
||||
data class TurnServer(
|
||||
val url: String,
|
||||
val username: String? = null,
|
||||
val credential: String? = null
|
||||
)
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
package com.smoa.modules.communications.domain
|
||||
|
||||
import android.content.Context
|
||||
import com.smoa.core.common.Result
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* WebRTC Manager for voice and video communication.
|
||||
* Provides WebRTC peer connection management for Communications and Meetings modules.
|
||||
*/
|
||||
@Singleton
|
||||
class WebRTCManager @Inject constructor(
|
||||
private val context: Context
|
||||
) {
|
||||
private val config = WebRTCConfig.default()
|
||||
private val peerConnections = mutableMapOf<String, WebRTCPeerConnection>()
|
||||
private val _connectionState = MutableStateFlow<WebRTCConnectionState>(WebRTCConnectionState.Disconnected)
|
||||
val connectionState: StateFlow<WebRTCConnectionState> = _connectionState.asStateFlow()
|
||||
|
||||
/**
|
||||
* Initialize WebRTC peer connection.
|
||||
*/
|
||||
suspend fun initializePeerConnection(
|
||||
channelId: String,
|
||||
isAudioOnly: Boolean = false
|
||||
): Result<WebRTCPeerConnection> {
|
||||
return try {
|
||||
_connectionState.value = WebRTCConnectionState.Connecting(channelId)
|
||||
|
||||
// Create peer connection configuration
|
||||
val rtcConfig = createRTCConfiguration()
|
||||
|
||||
// TODO: Initialize actual WebRTC PeerConnection when library is fully integrated
|
||||
// This would:
|
||||
// 1. Initialize PeerConnectionFactory with options
|
||||
// 2. Create PeerConnection with rtcConfig
|
||||
// 3. Set up audio/video tracks based on isAudioOnly
|
||||
// 4. Configure ICE candidates
|
||||
// 5. Set up signaling channel
|
||||
|
||||
val peerConnection = WebRTCPeerConnection(
|
||||
channelId = channelId,
|
||||
isAudioOnly = isAudioOnly,
|
||||
config = rtcConfig
|
||||
)
|
||||
|
||||
peerConnections[channelId] = peerConnection
|
||||
|
||||
_connectionState.value = WebRTCConnectionState.Connected(channelId)
|
||||
Result.Success(peerConnection)
|
||||
} catch (e: Exception) {
|
||||
_connectionState.value = WebRTCConnectionState.Error(e.message ?: "Unknown error")
|
||||
Result.Error(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create RTC configuration with STUN/TURN servers.
|
||||
*/
|
||||
private fun createRTCConfiguration(): RTCConfiguration {
|
||||
val iceServers = mutableListOf<IceServer>()
|
||||
|
||||
// Add STUN servers
|
||||
config.stunServers.forEach { stunServer ->
|
||||
iceServers.add(IceServer(stunServer.url))
|
||||
}
|
||||
|
||||
// Add TURN servers
|
||||
config.turnServers.forEach { turnServer ->
|
||||
iceServers.add(
|
||||
IceServer(
|
||||
url = turnServer.url,
|
||||
username = turnServer.username,
|
||||
credential = turnServer.credential
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return RTCConfiguration(
|
||||
iceServers = iceServers,
|
||||
iceCandidatePoolSize = config.iceCandidatePoolSize
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Close peer connection.
|
||||
*/
|
||||
suspend fun closePeerConnection(peerConnection: WebRTCPeerConnection): Result<Unit> {
|
||||
return try {
|
||||
// Stop all tracks
|
||||
if (peerConnection.isAudioActive) {
|
||||
stopAudioTransmission(peerConnection)
|
||||
}
|
||||
if (peerConnection.isVideoActive) {
|
||||
stopVideoTransmission(peerConnection)
|
||||
}
|
||||
|
||||
// TODO: Close actual WebRTC PeerConnection when library is fully integrated
|
||||
// This would:
|
||||
// 1. Close peer connection
|
||||
// 2. Release all tracks
|
||||
// 3. Dispose of resources
|
||||
|
||||
peerConnections.remove(peerConnection.channelId)
|
||||
_connectionState.value = WebRTCConnectionState.Disconnected
|
||||
Result.Success(Unit)
|
||||
} catch (e: Exception) {
|
||||
Result.Error(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start audio capture and transmission.
|
||||
*/
|
||||
suspend fun startAudioTransmission(peerConnection: WebRTCPeerConnection): Result<Unit> {
|
||||
return try {
|
||||
// TODO: Start audio capture when WebRTC library is fully integrated
|
||||
// This would:
|
||||
// 1. Create AudioSource with constraints
|
||||
// 2. Create AudioTrack from source
|
||||
// 3. Add track to peer connection's sender
|
||||
// 4. Enable track
|
||||
// 5. Start audio capture
|
||||
|
||||
peerConnection.isAudioActive = true
|
||||
Result.Success(Unit)
|
||||
} catch (e: Exception) {
|
||||
Result.Error(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop audio transmission.
|
||||
*/
|
||||
suspend fun stopAudioTransmission(peerConnection: WebRTCPeerConnection): Result<Unit> {
|
||||
return try {
|
||||
// TODO: Stop audio capture when WebRTC library is fully integrated
|
||||
// This would:
|
||||
// 1. Disable audio track
|
||||
// 2. Remove track from peer connection sender
|
||||
// 3. Stop track
|
||||
// 4. Release audio source
|
||||
|
||||
peerConnection.isAudioActive = false
|
||||
Result.Success(Unit)
|
||||
} catch (e: Exception) {
|
||||
Result.Error(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start video capture and transmission.
|
||||
*/
|
||||
suspend fun startVideoTransmission(peerConnection: WebRTCPeerConnection): Result<Unit> {
|
||||
return try {
|
||||
if (peerConnection.isAudioOnly) {
|
||||
return Result.Error(IllegalStateException("Video not supported for audio-only connection"))
|
||||
}
|
||||
|
||||
// TODO: Start video capture when WebRTC library is fully integrated
|
||||
// This would:
|
||||
// 1. Create VideoSource with camera constraints
|
||||
// 2. Create VideoTrack from source
|
||||
// 3. Add track to peer connection's sender
|
||||
// 4. Enable track
|
||||
// 5. Start camera capture
|
||||
|
||||
peerConnection.isVideoActive = true
|
||||
Result.Success(Unit)
|
||||
} catch (e: Exception) {
|
||||
Result.Error(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop video transmission.
|
||||
*/
|
||||
suspend fun stopVideoTransmission(peerConnection: WebRTCPeerConnection): Result<Unit> {
|
||||
return try {
|
||||
// TODO: Stop video capture when WebRTC library is fully integrated
|
||||
// This would:
|
||||
// 1. Disable video track
|
||||
// 2. Remove track from peer connection sender
|
||||
// 3. Stop track
|
||||
// 4. Release video source and camera
|
||||
|
||||
peerConnection.isVideoActive = false
|
||||
Result.Success(Unit)
|
||||
} catch (e: Exception) {
|
||||
Result.Error(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* WebRTC peer connection wrapper.
|
||||
*/
|
||||
data class WebRTCPeerConnection(
|
||||
val channelId: String,
|
||||
val isAudioOnly: Boolean = false,
|
||||
val config: RTCConfiguration,
|
||||
var isAudioActive: Boolean = false,
|
||||
var isVideoActive: Boolean = false
|
||||
// TODO: Add actual WebRTC PeerConnection instance when library is integrated
|
||||
// private val peerConnection: PeerConnection
|
||||
)
|
||||
|
||||
/**
|
||||
* RTC configuration for peer connections.
|
||||
*/
|
||||
data class RTCConfiguration(
|
||||
val iceServers: List<IceServer>,
|
||||
val iceCandidatePoolSize: Int = 10
|
||||
)
|
||||
|
||||
/**
|
||||
* ICE server configuration.
|
||||
*/
|
||||
data class IceServer(
|
||||
val url: String,
|
||||
val username: String? = null,
|
||||
val credential: String? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* WebRTC connection state.
|
||||
*/
|
||||
sealed class WebRTCConnectionState {
|
||||
object Disconnected : WebRTCConnectionState()
|
||||
data class Connecting(val channelId: String) : WebRTCConnectionState()
|
||||
data class Connected(val channelId: String) : WebRTCConnectionState()
|
||||
data class Error(val message: String) : WebRTCConnectionState()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
package com.smoa.modules.communications.ui
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.smoa.core.auth.RBACFramework
|
||||
import com.smoa.modules.communications.domain.Channel
|
||||
import com.smoa.modules.communications.domain.CommunicationsService
|
||||
|
||||
/**
|
||||
* Communications screen with channel list and PTT controls.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun CommunicationsScreen(
|
||||
communicationsService: CommunicationsService,
|
||||
userRole: RBACFramework.Role,
|
||||
userUnit: String?,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var channels by remember { mutableStateOf<List<Channel>>(emptyList()) }
|
||||
var currentChannel by remember { mutableStateOf<Channel?>(null) }
|
||||
var isLoading by remember { mutableStateOf(false) }
|
||||
var errorMessage by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
val isPTTActive by communicationsService.isPTTActive.collectAsState()
|
||||
|
||||
// Load available channels
|
||||
LaunchedEffect(userRole, userUnit) {
|
||||
isLoading = true
|
||||
errorMessage = null
|
||||
try {
|
||||
channels = communicationsService.getAvailableChannels(userRole, userUnit)
|
||||
} catch (e: Exception) {
|
||||
errorMessage = e.message
|
||||
} finally {
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
// Observe current channel
|
||||
LaunchedEffect(Unit) {
|
||||
communicationsService.currentChannel.collect { channel ->
|
||||
currentChannel = channel
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Unit Communications",
|
||||
style = MaterialTheme.typography.headlineMedium
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Current channel indicator
|
||||
currentChannel?.let { channel ->
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Current Channel: ${channel.name}",
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
channel.description?.let { desc ->
|
||||
Text(
|
||||
text = desc,
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
// Error message
|
||||
errorMessage?.let { error ->
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.errorContainer
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = error,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
modifier = Modifier.padding(16.dp)
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
|
||||
// Channel list
|
||||
if (isLoading) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
items(channels) { channel ->
|
||||
ChannelCard(
|
||||
channel = channel,
|
||||
isActive = currentChannel?.id == channel.id,
|
||||
onClick = {
|
||||
// Join channel
|
||||
// This would need to be in a coroutine scope
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// PTT Button
|
||||
Button(
|
||||
onClick = {
|
||||
if (isPTTActive) {
|
||||
// Stop PTT
|
||||
} else {
|
||||
// Start PTT
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(64.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = if (isPTTActive) {
|
||||
MaterialTheme.colorScheme.error
|
||||
} else {
|
||||
MaterialTheme.colorScheme.primary
|
||||
}
|
||||
),
|
||||
enabled = currentChannel != null
|
||||
) {
|
||||
Text(
|
||||
text = if (isPTTActive) "RELEASE" else "PUSH TO TALK",
|
||||
style = MaterialTheme.typography.titleLarge
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Channel card.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ChannelCard(
|
||||
channel: Channel,
|
||||
isActive: Boolean,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Card(
|
||||
onClick = onClick,
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = if (isActive) {
|
||||
MaterialTheme.colorScheme.primaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surface
|
||||
}
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = channel.name,
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
channel.description?.let { desc ->
|
||||
Text(
|
||||
text = desc,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
if (isActive) {
|
||||
Text(
|
||||
text = "Connected",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user