feat: voice chat, video chat, screen share

This commit is contained in:
infi 2026-07-18 03:07:28 +02:00
parent 97dff3a50e
commit 6a4b5d21e9
26 changed files with 1294 additions and 186 deletions

View File

@ -244,9 +244,9 @@ dependencies {
implementation(libs.jetbrains.markdown)
implementation(libs.highlights)
/*implementation(libs.livekit.android)
implementation(libs.livekit.android)
implementation(libs.livekit.android.camerax)
implementation(libs.livekit.android.compose)*/
implementation(libs.livekit.android.compose)
implementation(platform(libs.firebase.bom))
implementation(libs.firebase.messaging)

View File

@ -5,7 +5,13 @@
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.POST_PROMOTED_NOTIFICATIONS" />
<uses-permission android:name="android.permission.MANAGE_OWN_CALLS" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_PHONE_CALL" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<!-- Up to Android 10, we need the following to take photos from the camera. -->
<uses-permission
@ -13,13 +19,6 @@
android:maxSdkVersion="29"
tools:ignore="ScopedStorage" />
<!--
* FIXME LiveKit is temporarily not included, hence the following is commented out.
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.CAMERA" />
-->
<uses-feature
android:name="android.hardware.camera"
android:required="false" />
@ -66,6 +65,11 @@
</intent-filter>
</service>
<service
android:name=".services.OngoingCallService"
android:exported="false"
android:foregroundServiceType="phoneCall|microphone" />
<receiver
android:name=".c2dm.ReplyReceiver"
android:exported="false" />

View File

@ -10,6 +10,8 @@ import coil3.ImageLoader
import coil3.SingletonImageLoader
import coil3.request.crossfade
import com.google.android.material.color.DynamicColors
import io.livekit.android.LiveKit
import io.livekit.android.util.LoggingLevel
import logcat.AndroidLogcatLogger
import logcat.LogPriority
import org.koin.android.ext.koin.androidContext
@ -25,6 +27,10 @@ class StoatApplication : Application(), SingletonImageLoader.Factory {
super.onCreate()
AndroidLogcatLogger.installOnDebuggableApp(this, minPriority = LogPriority.VERBOSE)
if (BuildConfig.DEBUG) {
LiveKit.loggingLevel = LoggingLevel.DEBUG
}
startKoin {
androidContext(this@StoatApplication)
androidLogger()

View File

@ -88,6 +88,7 @@ import chat.stoat.c2dm.NotificationDeepLink
import chat.stoat.composables.generic.HealthAlert
import chat.stoat.composables.voice.VoicePermissionSwitch
import chat.stoat.composables.voice.VoiceSheet
import chat.stoat.voice.VoiceCallManager
import chat.stoat.core.model.schemas.HealthNotice
import chat.stoat.material.EasingTokens
import chat.stoat.persistence.KVStorage
@ -448,8 +449,9 @@ fun AppEntrypoint(
onRetryConnection: () -> Unit,
onUpdateNextDestination: (String) -> Unit = {}
) {
var showVoiceUI by rememberSaveable { mutableStateOf(false) }
var voiceChannelId by rememberSaveable { mutableStateOf<String?>(null) }
val showVoiceUI = VoiceCallManager.isSheetVisible
val hideVoiceUI = { VoiceCallManager.isSheetVisible = false }
val disconnectVoice = { VoiceCallManager.leave() }
val chatUIScale by animateFloatAsState(
if (showVoiceUI) 0.8f else 1.0f,
@ -467,7 +469,7 @@ fun AppEntrypoint(
)
BackHandler(showVoiceUI) {
showVoiceUI = false
hideVoiceUI()
}
val keyboardController = LocalSoftwareKeyboardController.current
@ -636,8 +638,7 @@ fun AppEntrypoint(
navController.navigate("default")
},
onEnterVoiceUI = { channelId ->
showVoiceUI = true
voiceChannelId = channelId
VoiceCallManager.openSheet(channelId)
},
)
}
@ -760,7 +761,7 @@ fun AppEntrypoint(
indication = null,
interactionSource = remember { MutableInteractionSource() }
) {
showVoiceUI = false
hideVoiceUI()
}
)
}
@ -793,17 +794,14 @@ fun AppEntrypoint(
.padding(8.dp)
) {
VoicePermissionSwitch(
onCancel = {
showVoiceUI = false
}
onCancel = disconnectVoice
) {
voiceChannelId?.let {
VoiceCallManager.requestedChannelId?.let { channelId ->
LaunchedEffect(channelId) {
VoiceCallManager.join(channelId)
}
VoiceSheet(
it,
onDisconnect = {
showVoiceUI = false
voiceChannelId = null
}
onDisconnect = disconnectVoice
)
}
}

View File

@ -57,8 +57,11 @@ import io.ktor.websocket.WebSocketSession
import io.ktor.websocket.close
import io.ktor.websocket.readText
import io.ktor.websocket.send
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.channels.consumeEach
import kotlinx.serialization.SerializationException
import logcat.LogPriority
import logcat.asLog
import logcat.logcat
enum class DisconnectionState {
@ -120,10 +123,18 @@ object RealtimeSocket {
incoming.consumeEach { frame ->
if (frame is Frame.Text) {
val frameString = frame.readText()
val frameType =
StoatJson.decodeFromString(AnyFrame.serializer(), frameString).type
try {
val frameType =
StoatJson.decodeFromString(AnyFrame.serializer(), frameString).type
handleFrame(frameType, frameString)
handleFrame(frameType, frameString)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
logcat(LogPriority.ERROR) {
"Failed to handle frame: $frameString\n" + e.asLog()
}
}
}
}
}
@ -886,10 +897,10 @@ object RealtimeSocket {
val userVoiceStateUpdateFrame =
StoatJson.decodeFromString(UserVoiceStateUpdateFrame.serializer(), rawFrame)
logcat { "Received user voice state update frame for user ${userVoiceStateUpdateFrame.id} in channel ${userVoiceStateUpdateFrame.id}." }
logcat { "Received user voice state update frame for user ${userVoiceStateUpdateFrame.id} in channel ${userVoiceStateUpdateFrame.channelId}." }
val existingChannelState =
StoatAPI.voiceStateCache[userVoiceStateUpdateFrame.id] ?: return
StoatAPI.voiceStateCache[userVoiceStateUpdateFrame.channelId] ?: return
val newParticipants = existingChannelState.participants.map {
if (it.id == userVoiceStateUpdateFrame.id) {
@ -899,8 +910,8 @@ object RealtimeSocket {
}
}
StoatAPI.voiceStateCache[userVoiceStateUpdateFrame.id] =
ChannelVoiceState(userVoiceStateUpdateFrame.id, newParticipants)
StoatAPI.voiceStateCache[userVoiceStateUpdateFrame.channelId] =
ChannelVoiceState(userVoiceStateUpdateFrame.channelId, newParticipants)
}
"UserMoveVoiceChannel" -> {

View File

@ -271,6 +271,7 @@ data class VoiceChannelMoveFrame(
data class UserVoiceStateUpdateFrame(
val type: String = "UserVoiceStateUpdate",
val id: String,
@SerialName("channel_id") val channelId: String,
val data: PartialUserVoiceState
)

View File

@ -21,6 +21,9 @@ class ChannelRegistrator(val context: Context) {
const val CHANNEL_ID_GROUP_SOCIAL = "chat.stoat.c2dm.social"
const val CHANNEL_ID_GROUP_SOCIAL_FRIENDREQUESTS = "chat.stoat.c2dm.social.friendrequests"
const val CHANNEL_ID_GROUP_VOICE = "chat.stoat.voice"
const val CHANNEL_ID_GROUP_VOICE_ONGOING = "chat.stoat.voice.ongoing"
}
private val notificationManager =
@ -39,6 +42,12 @@ class ChannelRegistrator(val context: Context) {
context.getString(R.string.notification_channel_group_social)
)
)
notificationManager.createNotificationChannelGroup(
NotificationChannelGroup(
CHANNEL_ID_GROUP_VOICE,
context.getString(R.string.notification_channel_group_voice)
)
)
}
private fun registerChannels() {
@ -64,6 +73,19 @@ class ChannelRegistrator(val context: Context) {
context.getString(R.string.notification_channel_messages_description)
}
)
notificationManager.createNotificationChannel(
NotificationChannel(
CHANNEL_ID_GROUP_VOICE_ONGOING,
context.getString(R.string.notification_channel_ongoing_call),
NotificationManager.IMPORTANCE_DEFAULT
).apply {
group = CHANNEL_ID_GROUP_VOICE
description =
context.getString(R.string.notification_channel_ongoing_call_description)
setSound(null, null)
enableVibration(false)
}
)
}
fun register() {

View File

@ -45,6 +45,7 @@ enum class SystemMessageType(val type: String) {
USER_JOINED("user_joined"),
MESSAGE_PINNED("message_pinned"),
MESSAGE_UNPINNED("message_unpinned"),
CALL_STARTED("call_started"),
TEXT("text")
}
@ -206,6 +207,16 @@ fun SystemMessage(message: Message) {
)
}
SystemMessageType.CALL_STARTED -> {
ChatMarkdown(
stringResource(
R.string.system_message_call_started,
message.system!!.by.mention()
),
serverId = serverId
)
}
SystemMessageType.TEXT -> {
message.system!!.content?.let { ChatMarkdown(it) }
}
@ -329,6 +340,15 @@ fun SystemMessageIcon(type: SystemMessageType, modifier: Modifier = Modifier, si
)
}
SystemMessageType.CALL_STARTED -> {
Icon(
painter = painterResource(R.drawable.ic_call_24dp__fill),
contentDescription = stringResource(R.string.system_message_call_started_alt),
tint = LocalContentColor.current,
modifier = modifier.size(size)
)
}
SystemMessageType.TEXT -> {
Icon(
painter = painterResource(R.drawable.ic_info_24dp),
@ -356,6 +376,7 @@ private fun shapeForType(type: SystemMessageType): Shape {
SystemMessageType.USER_JOINED -> MaterialShapes.Cookie9Sided
SystemMessageType.MESSAGE_PINNED -> MaterialShapes.Clover4Leaf
SystemMessageType.MESSAGE_UNPINNED -> MaterialShapes.Clover8Leaf
SystemMessageType.CALL_STARTED -> MaterialShapes.Fan
SystemMessageType.TEXT -> MaterialShapes.Square
}.toShape()
}

View File

@ -0,0 +1,87 @@
package chat.stoat.composables.voice
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import chat.stoat.R
import chat.stoat.api.StoatAPI
import chat.stoat.api.internals.ChannelUtils
import chat.stoat.voice.VoiceCallManager
@Composable
fun VoiceCallBanner(modifier: Modifier = Modifier) {
val channelId = VoiceCallManager.activeChannelId
AnimatedVisibility(
visible = channelId != null && !VoiceCallManager.isSheetVisible,
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically(),
modifier = modifier
) {
Surface(
color = MaterialTheme.colorScheme.surfaceContainer,
modifier = Modifier
.fillMaxWidth()
.clickable { VoiceCallManager.isSheetVisible = true }
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.padding(start = 16.dp, end = 4.dp)
) {
Icon(
painter = painterResource(R.drawable.ic_voice_chat_24dp),
contentDescription = null,
tint = MaterialTheme.colorScheme.primary
)
Column(
modifier = Modifier
.weight(1f)
.padding(vertical = 8.dp)
) {
Text(
text = channelId
?.let { StoatAPI.channelCache[it] }
?.let(ChannelUtils::resolveName)
?: stringResource(R.string.voice_notification_fallback_channel_name),
style = MaterialTheme.typography.titleSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = stringResource(R.string.voice_banner_tap_to_return),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
IconButton(onClick = { VoiceCallManager.leave() }) {
Icon(
painter = painterResource(R.drawable.ic_call_end_24dp__fill),
contentDescription = stringResource(R.string.voice_action_disconnect),
tint = MaterialTheme.colorScheme.error
)
}
}
}
}
}

View File

@ -1,51 +1,55 @@
package chat.stoat.composables.voice
import androidx.compose.runtime.Composable
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewmodel.compose.viewModel
class VoiceSheetViewModel(private val state: SavedStateHandle) : ViewModel() {}
@Composable
fun VoiceSheet(
channelId: String,
onDisconnect: () -> Unit,
viewModel: VoiceSheetViewModel = viewModel()
) {
}
/*
import android.app.Activity
import android.media.projection.MediaProjectionManager
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.LinearOutSlowInEasing
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.CornerSize
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.HorizontalFloatingToolbar
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
@ -55,153 +59,170 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewmodel.compose.viewModel
import chat.stoat.R
import chat.stoat.api.StoatAPI
import chat.stoat.api.routes.misc.Root
import chat.stoat.api.routes.misc.getRootRoute
import chat.stoat.api.routes.voice.joinCall
import chat.stoat.composables.chat.displayNameInChannel
import chat.stoat.core.model.util.UserVoiceState
import chat.stoat.voice.VoiceCallManager
import com.twilio.audioswitch.AudioDevice
import com.twilio.audioswitch.AudioDeviceChangeListener
import io.livekit.android.compose.local.RoomLocal
import io.livekit.android.compose.local.RoomScope
import io.livekit.android.compose.state.rememberParticipants
import io.livekit.android.compose.state.rememberTracks
import io.livekit.android.compose.ui.ScaleType
import io.livekit.android.compose.ui.VideoTrackView
import io.livekit.android.room.Room
import io.livekit.android.room.track.LocalVideoTrack
import io.livekit.android.room.track.Track
import io.livekit.android.room.track.screencapture.ScreenCaptureParams
import io.livekit.android.util.flow
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import logcat.LogPriority
import logcat.asLog
import logcat.logcat
class VoiceSheetViewModel(private val state: SavedStateHandle) : ViewModel() {
private val _channelId = mutableStateOf(state.get<String>("channelId") ?: "")
var channelId: String
get() = _channelId.value
set(value) {
_channelId.value = value
state["channelId"] = value
}
var voiceLkNode by mutableStateOf("")
private val _voiceToken = mutableStateOf(state.get<String>("voiceToken") ?: "")
var voiceToken: String
get() = _voiceToken.value
private set(value) {
_voiceToken.value = value
state["voiceToken"] = value
}
var errorResource by mutableStateOf<Int?>(null)
private set
suspend fun getVoiceToken() {
errorResource = null
val root: Root
try {
root = getRootRoute()
} catch (e: Exception) {
logcat(LogPriority.ERROR) { "Could not get root route\n" + e.asLog() }
errorResource = R.string.voice_error_generic
return
}
val lk = root.features.livekit
if (lk == null) {
logcat(LogPriority.ERROR) {
IllegalStateException("LiveKit is not supported by this API version!").asLog()
}
errorResource = R.string.voice_error_not_supported
return
}
if (lk.nodes.isEmpty()) {
logcat(LogPriority.ERROR) { IllegalStateException("No LiveKit nodes available!").asLog() }
errorResource = R.string.voice_error_no_nodes
return
}
val node = lk.nodes.random()
try {
val joined = joinCall(channelId, node.name)
voiceLkNode = joined.url
voiceToken = joined.token
} catch (e: Exception) {
logcat(LogPriority.ERROR) { "Could not get LiveKit token\n" + e.asLog() }
errorResource = R.string.voice_error_generic
return
}
}
}
import kotlin.time.Duration.Companion.seconds
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun VoiceSheet(
channelId: String,
onDisconnect: () -> Unit,
viewModel: VoiceSheetViewModel = viewModel()
) {
LaunchedEffect(channelId) {
viewModel.channelId = channelId
viewModel.getVoiceToken()
}
fun VoiceSheet(onDisconnect: () -> Unit) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
RoomScope(
url = viewModel.voiceLkNode,
token = viewModel.voiceToken,
audio = true,
video = false,
connect = true,
) {
val room = RoomLocal.current
val room = VoiceCallManager.room
val channelId = VoiceCallManager.activeChannelId
if (room == null || channelId == null) return
CompositionLocalProvider(RoomLocal provides room) {
val roomState by room::state.flow.collectAsState()
val isMicOn by room.localParticipant::isMicrophoneEnabled.flow.collectAsState()
val isCameraOn by room.localParticipant::isCameraEnabled.flow.collectAsState()
val isScreenShared by room.localParticipant::isScreenShareEnabled.flow.collectAsState()
val activeSpeakers by room::activeSpeakers.flow.collectAsState()
val participants by rememberParticipants(room)
val trackRefs by rememberTracks(passedRoom = room)
val isDeafened = VoiceCallManager.isDeafened
val audioHandler = room.audioSwitchHandler
var audioDevices by remember {
mutableStateOf(audioHandler?.availableAudioDevices ?: emptyList())
}
var selectedAudioDevice by remember { mutableStateOf(audioHandler?.selectedAudioDevice) }
DisposableEffect(audioHandler) {
val listener: AudioDeviceChangeListener = { devices, selected ->
audioDevices = devices
selectedAudioDevice = selected
}
audioHandler?.registerAudioDeviceChangeListener(listener)
onDispose {
audioHandler?.unregisterAudioDeviceChangeListener(listener)
}
}
Column {
LazyColumn(
modifier = Modifier.animateContentSize(
animationSpec = tween(
durationMillis = 300,
easing = LinearOutSlowInEasing
modifier = Modifier
// don't push the toolbar off-screen
.weight(1f, fill = false)
.animateContentSize(
animationSpec = tween(
durationMillis = 300,
easing = LinearOutSlowInEasing
)
)
)
) {
val voiceStates = StoatAPI.voiceStateCache[viewModel.channelId]
items(voiceStates?.participants?.size ?: 0) { index ->
val participantState = voiceStates?.participants[index]
participantState?.let {
val voiceStates = StoatAPI.voiceStateCache[channelId]
items(participants.size) { index ->
val participant = participants[index]
val userId = participant.identity?.value
if (userId != null) {
val micEnabled by participant::isMicrophoneEnabled.flow.collectAsState()
val cameraEnabled by participant::isCameraEnabled.flow.collectAsState()
val screenShareEnabled by participant::isScreenShareEnabled.flow.collectAsState()
val cachedState = voiceStates?.participants?.find { it.id == userId }
VoiceParticipant(
state = participantState,
channelId = viewModel.channelId,
speaking = activeSpeakers.any { it.identity?.value == participantState.id }
state = UserVoiceState(
id = userId,
isReceiving = cachedState?.isReceiving ?: true,
isPublishing = micEnabled,
screensharing = screenShareEnabled,
camera = cameraEnabled,
joinedAt = cachedState?.joinedAt
),
channelId = channelId,
speaking = activeSpeakers.any { it.identity == participant.identity }
)
}
}
items(trackRefs.size) { index ->
VideoTrackView(
trackReference = trackRefs[index],
room = room,
modifier = Modifier.fillParentMaxHeight(0.5f)
)
val trackRef = trackRefs[index]
val publication = trackRef.publication
if (publication != null) {
val isTrackMuted by publication::muted.flow.collectAsState()
if (!isTrackMuted) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 4.dp)
.aspectRatio(16f / 9f)
.clip(MaterialTheme.shapes.large)
.background(MaterialTheme.colorScheme.surfaceContainerLowest)
) {
VideoTrackView(
trackReference = trackRef,
room = room,
// Letterbox vertical feeds
scaleType = ScaleType.FitInside,
modifier = Modifier.fillMaxSize()
)
Surface(
color = MaterialTheme.colorScheme.surfaceContainer.copy(
alpha = 0.85f
),
shape = MaterialTheme.shapes.small,
modifier = Modifier
.align(Alignment.BottomStart)
.padding(8.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.padding(
horizontal = 8.dp,
vertical = 4.dp
)
) {
if (trackRef.source == Track.Source.SCREEN_SHARE) {
Icon(
painter = painterResource(R.drawable.ic_screen_share_24dp),
contentDescription = stringResource(R.string.voice_screen_sharing),
modifier = Modifier.size(16.dp)
)
}
Text(
text = trackRef.participant.identity?.value
?.let { displayNameInChannel(it, channelId) }
?: stringResource(R.string.unknown),
style = MaterialTheme.typography.labelMedium
)
}
}
}
}
}
}
item(key = "status") {
var showStatus by remember { mutableStateOf(true) }
LaunchedEffect(roomState) {
if (roomState == Room.State.CONNECTED) {
delay(1000)
delay(1.seconds)
showStatus = false
} else {
showStatus = true
@ -290,8 +311,8 @@ fun VoiceSheet(
}
}
AnimatedVisibility(viewModel.errorResource != null) {
viewModel.errorResource?.let { resId ->
AnimatedVisibility(VoiceCallManager.errorResource != null) {
VoiceCallManager.errorResource?.let { resId ->
Text(
text = stringResource(resId),
color = MaterialTheme.colorScheme.error,
@ -301,8 +322,211 @@ fun VoiceSheet(
}
}
// the prompt where you select whether you want to share a single app or the whole screen
val screenCaptureLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { result ->
val data = result.data
if (result.resultCode == Activity.RESULT_OK && data != null) {
scope.launch {
try {
val published = room.localParticipant.setScreenShareEnabled(
true,
ScreenCaptureParams(data)
)
logcat { "Screen share enable result: $published" }
} catch (e: Exception) {
logcat(LogPriority.ERROR) {
"Could not start screen share\n" + e.asLog()
}
}
}
} else {
logcat(LogPriority.WARN) {
"Screen capture consent denied or empty " +
"(resultCode ${result.resultCode}, data $data)"
}
}
}
var toolbarExpanded by remember { mutableStateOf(false) }
var outputMenuOpen by remember { mutableStateOf(false) }
val chevronRotation by animateFloatAsState(
targetValue = if (toolbarExpanded) 90f else 270f,
label = "voice toolbar chevron"
)
val firstOptionShape = MaterialTheme.shapes.extraSmall.copy(
topStart = MaterialTheme.shapes.large.topStart,
topEnd = MaterialTheme.shapes.large.topEnd
)
val toolbarTopCornerRadius by animateDpAsState(
targetValue = if (toolbarExpanded) 4.dp else 48.dp,
label = "voice toolbar corners"
)
AnimatedVisibility(
visible = toolbarExpanded,
enter = fadeIn() + expandVertically(expandFrom = Alignment.Bottom),
exit = fadeOut() + shrinkVertically(shrinkTowards = Alignment.Bottom)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(
start = 8.dp,
end = 8.dp,
bottom = 2.dp,
),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
ListItem(
colors = ListItemDefaults.colors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
headlineColor = if (isCameraOn) {
MaterialTheme.colorScheme.primary
} else {
ListItemDefaults.colors().contentColor
},
leadingIconColor = if (isCameraOn) {
MaterialTheme.colorScheme.primary
} else {
ListItemDefaults.colors().leadingContentColor
}
),
headlineContent = { Text(stringResource(R.string.voice_action_video)) },
leadingContent = {
Icon(
painter = if (isCameraOn) painterResource(R.drawable.ic_videocam_24dp) else painterResource(
R.drawable.ic_videocam_off_24dp
),
contentDescription = null
)
},
modifier = Modifier
.clip(firstOptionShape)
.clickable {
scope.launch {
room.localParticipant.setCameraEnabled(!isCameraOn)
}
}
)
AnimatedVisibility(visible = isCameraOn) {
ListItem(
colors = ListItemDefaults.colors(
containerColor = MaterialTheme.colorScheme.surfaceContainer
),
headlineContent = {
Text(stringResource(R.string.voice_action_flip_camera))
},
leadingContent = {
Icon(
painter = painterResource(R.drawable.ic_cameraswitch_24dp),
contentDescription = null
)
},
modifier = Modifier
.clip(MaterialTheme.shapes.extraSmall)
.clickable {
(room.localParticipant
.getTrackPublication(Track.Source.CAMERA)
?.track as? LocalVideoTrack)
?.switchCamera()
}
)
}
ListItem(
colors = ListItemDefaults.colors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
headlineColor = if (isScreenShared) {
MaterialTheme.colorScheme.primary
} else {
ListItemDefaults.colors().contentColor
},
leadingIconColor = if (isScreenShared) {
MaterialTheme.colorScheme.primary
} else {
ListItemDefaults.colors().leadingContentColor
}
),
headlineContent = { Text(stringResource(R.string.voice_action_screen_share)) },
leadingContent = {
Icon(
painter = painterResource(R.drawable.ic_mobile_share_24px),
contentDescription = null
)
},
modifier = Modifier
.clip(MaterialTheme.shapes.extraSmall)
.clickable {
if (isScreenShared) {
scope.launch {
room.localParticipant.setScreenShareEnabled(false)
}
} else {
context.getSystemService(MediaProjectionManager::class.java)
?.let { manager ->
screenCaptureLauncher.launch(
manager.createScreenCaptureIntent()
)
}
}
}
)
if (audioHandler != null) {
Box(modifier = Modifier.fillMaxWidth()) {
ListItem(
colors = ListItemDefaults.colors(
containerColor = MaterialTheme.colorScheme.surfaceContainer
),
headlineContent = {
Text(stringResource(R.string.voice_audio_output))
},
supportingContent = {
selectedAudioDevice?.let { Text(audioDeviceLabel(it)) }
},
leadingContent = {
Icon(
painter = painterResource(R.drawable.ic_volume_up_24dp),
contentDescription = null
)
},
modifier = Modifier
.clip(MaterialTheme.shapes.extraSmall)
.clickable { outputMenuOpen = true }
)
DropdownMenu(
expanded = outputMenuOpen,
onDismissRequest = { outputMenuOpen = false }
) {
audioDevices.forEach { device ->
DropdownMenuItem(
text = { Text(audioDeviceLabel(device)) },
trailingIcon = {
if (device == selectedAudioDevice) {
Icon(
painter = painterResource(R.drawable.ic_check_24dp),
contentDescription = null
)
}
},
onClick = {
audioHandler.selectDevice(device)
outputMenuOpen = false
}
)
}
}
}
}
}
}
HorizontalFloatingToolbar(
expanded = true,
shape = RoundedCornerShape(
topStart = CornerSize(toolbarTopCornerRadius),
topEnd = CornerSize(toolbarTopCornerRadius),
bottomStart = CornerSize(50),
bottomEnd = CornerSize(50)
),
modifier = Modifier.padding(
start = 8.dp,
end = 8.dp,
@ -316,11 +540,7 @@ fun VoiceSheet(
)
) {
Button(
onClick = {
scope.launch {
room.localParticipant.setMicrophoneEnabled(!isMicOn)
}
},
onClick = { VoiceCallManager.toggleMicrophone() },
colors = ButtonDefaults.buttonColors(
containerColor = if (isMicOn) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.secondaryContainer,
contentColor = if (isMicOn) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSecondaryContainer,
@ -334,20 +554,17 @@ fun VoiceSheet(
painter = if (isMicOn) painterResource(R.drawable.ic_mic_24dp) else painterResource(
R.drawable.ic_mic_off_24dp
),
contentDescription = "TODO change this string to res"
contentDescription = stringResource(
if (isMicOn) R.string.voice_action_mute else R.string.voice_action_unmute
)
)
}
Spacer(Modifier.width(4.dp))
Button(
onClick = {
scope.launch {
room.localParticipant.setCameraEnabled(!isMicOn)
}
},
enabled = false,
onClick = { VoiceCallManager.toggleDeafen() },
colors = ButtonDefaults.buttonColors(
containerColor = if (isCameraOn) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.secondaryContainer,
contentColor = if (isCameraOn) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSecondaryContainer,
containerColor = if (!isDeafened) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.secondaryContainer,
contentColor = if (!isDeafened) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSecondaryContainer,
),
shapes = ButtonDefaults.shapes(),
modifier = Modifier
@ -355,20 +572,17 @@ fun VoiceSheet(
.height(64.dp)
) {
Icon(
painter = if (isCameraOn) painterResource(R.drawable.ic_videocam_24dp) else painterResource(
R.drawable.ic_videocam_off_24dp
painter = if (isDeafened) painterResource(R.drawable.ic_headset_off_24dp) else painterResource(
R.drawable.ic_headset_24dp
),
contentDescription = "TODO change this string to res"
contentDescription = stringResource(
if (isDeafened) R.string.voice_action_undeafen else R.string.voice_action_deafen
)
)
}
Spacer(Modifier.width(4.dp))
Button(
onClick = {
scope.launch {
room.localParticipant.setScreenShareEnabled(!isScreenShared)
}
},
enabled = false,
onClick = { toolbarExpanded = !toolbarExpanded },
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer,
contentColor = MaterialTheme.colorScheme.onSecondaryContainer
@ -379,15 +593,14 @@ fun VoiceSheet(
.height(64.dp)
) {
Icon(
painter = painterResource(R.drawable.ic_mobile_share_24px),
contentDescription = "TODO change this string to res"
painter = painterResource(R.drawable.ic_chevron_forward_24dp),
contentDescription = null,
modifier = Modifier.graphicsLayer { rotationZ = chevronRotation }
)
}
Spacer(Modifier.width(4.dp))
Button(
onClick = {
onDisconnect()
},
onClick = onDisconnect,
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.errorContainer,
contentColor = MaterialTheme.colorScheme.onErrorContainer
@ -406,4 +619,11 @@ fun VoiceSheet(
}
}
}
*/
@Composable
private fun audioDeviceLabel(device: AudioDevice): String = when (device) {
is AudioDevice.BluetoothHeadset -> device.name
is AudioDevice.WiredHeadset -> stringResource(R.string.voice_audio_output_wired_headset)
is AudioDevice.Earpiece -> stringResource(R.string.voice_audio_output_earpiece)
is AudioDevice.Speakerphone -> stringResource(R.string.voice_audio_output_speaker)
}

View File

@ -0,0 +1,82 @@
package chat.stoat.composables.voice
import android.content.Context
import android.media.AudioAttributes
import android.media.SoundPool
import android.os.Handler
import android.os.Looper
import androidx.annotation.RawRes
import chat.stoat.R
import logcat.LogPriority
import logcat.logcat
internal enum class VoiceSound(@param:RawRes val resourceId: Int) {
MUTE(R.raw.sfx_mute),
UNMUTE(R.raw.sfx_unmute),
DEAFEN(R.raw.sfx_deafen),
UNDEAFEN(R.raw.sfx_undeafen),
USER_JOIN(R.raw.sfx_user_join_voice),
USER_LEAVE(R.raw.sfx_user_leave_voice),
STREAM_START(R.raw.sfx_stream_start),
STREAM_END(R.raw.sfx_stream_end),
}
internal class VoiceSoundPlayer(context: Context) {
private val soundPool = SoundPool.Builder()
.setMaxStreams(4)
.setAudioAttributes(
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION_SIGNALLING)
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.build()
)
.build()
private val soundIds = mutableMapOf<VoiceSound, Int>()
private val loadedSoundIds = mutableSetOf<Int>()
private val pendingSounds = mutableListOf<VoiceSound>()
init {
soundPool.setOnLoadCompleteListener { _, sampleId, status ->
if (status != 0) {
logcat(LogPriority.ERROR) {
"Failed to load voice sound sample $sampleId (status $status)"
}
return@setOnLoadCompleteListener
}
loadedSoundIds += sampleId
pendingSounds
.filter { soundIds[it] == sampleId }
.forEach(::playLoaded)
pendingSounds.removeAll { soundIds[it] == sampleId }
}
VoiceSound.entries.forEach { sound ->
soundIds[sound] =
soundPool.load(context.applicationContext, sound.resourceId, 1)
}
}
fun play(sound: VoiceSound) {
val soundId = soundIds.getValue(sound)
if (soundId in loadedSoundIds) {
playLoaded(sound)
} else {
pendingSounds += sound
}
}
fun release() {
pendingSounds.clear()
// let a leave sound finish
Handler(Looper.getMainLooper()).postDelayed(soundPool::release, 1_000)
}
private fun playLoaded(sound: VoiceSound) {
val streamId = soundPool.play(soundIds.getValue(sound), 1f, 1f, 1, 0, 1f)
logcat { "Playing voice sound $sound (stream $streamId)" }
if (streamId == 0) {
logcat(LogPriority.ERROR) { "SoundPool could not play $sound" }
}
}
}

View File

@ -124,6 +124,7 @@ import chat.stoat.composables.chat.Message
import chat.stoat.composables.chat.MessageField
import chat.stoat.composables.chat.SystemMessage
import chat.stoat.composables.emoji.EmojiPicker
import chat.stoat.composables.voice.VoiceCallBanner
import chat.stoat.composables.generic.GroupIcon
import chat.stoat.composables.generic.PresenceBadge
import chat.stoat.composables.generic.UserAvatar
@ -641,6 +642,28 @@ fun ChannelScreen(
}
},
actions = {
val isDmLike =
viewModel.channel?.channelType == ChannelType.DirectMessage ||
viewModel.channel?.channelType == ChannelType.Group
if (isDmLike &&
viewModel.channel?.voice == null &&
StoatAPI.voiceStateCache[channelId]?.participants.isNullOrEmpty() &&
channelPermissions has PermissionBit.Connect &&
Experiments.useVoiceChats2p0.isEnabled
) {
IconButton(onClick = {
scope.launch {
ActionChannel.send(
Action.OpenVoiceChannelOverlay(channelId)
)
}
}) {
Icon(
painter = painterResource(R.drawable.ic_call_24dp__fill),
contentDescription = stringResource(id = R.string.voice_start_call)
)
}
}
IconButton(onClick = {
scope.launch {
ActionChannel.send(
@ -655,6 +678,7 @@ fun ChannelScreen(
}
}
)
VoiceCallBanner()
}
}
) { pv ->
@ -961,7 +985,15 @@ fun ChannelScreen(
}
}
if ((viewModel.channel?.channelType == ChannelType.VoiceChannel || viewModel.channel?.voice != null) &&
val isDmLikeWithOngoingCall =
(viewModel.channel?.channelType == ChannelType.DirectMessage ||
viewModel.channel?.channelType == ChannelType.Group) &&
StoatAPI.voiceStateCache[channelId]
?.participants
?.isNotEmpty() == true
if ((viewModel.channel?.channelType == ChannelType.VoiceChannel ||
viewModel.channel?.voice != null ||
isDmLikeWithOngoingCall) &&
channelPermissions has PermissionBit.Connect &&
Experiments.useVoiceChats2p0.isEnabled
) {

View File

@ -0,0 +1,302 @@
package chat.stoat.services
import android.app.Notification
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.IBinder
import android.telecom.DisconnectCause
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.Person
import androidx.core.app.ServiceCompat
import androidx.core.content.ContextCompat
import androidx.core.net.toUri
import androidx.core.telecom.CallAttributesCompat
import androidx.core.telecom.CallControlScope
import androidx.core.telecom.CallsManager
import chat.stoat.R
import chat.stoat.activities.MainActivity
import chat.stoat.c2dm.ChannelRegistrator
import chat.stoat.services.OngoingCallService.Companion.hangupEvents
import chat.stoat.voice.VoiceCallManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import logcat.LogPriority
import logcat.asLog
import logcat.logcat
import kotlin.time.Duration.Companion.seconds
/**
* Foreground service that runs while the user is connected to a voice channel.
*
* Posts a persistent [NotificationCompat.CallStyle] notification with a chronometer (the fancy chip!)
* and registers the call with the Telecom framework via [CallsManager] so the system treats it like
* any other ongoing call.
*
* The service does not own the call itself, the LiveKit room lives in the voice UIs composition.
* Hangup requests from the notification or Telecom are relayed through [hangupEvents].
*/
class OngoingCallService : Service() {
companion object {
private const val ACTION_START = "chat.stoat.services.OngoingCallService.START"
private const val ACTION_HANGUP = "chat.stoat.services.OngoingCallService.HANGUP"
private const val ACTION_STOP = "chat.stoat.services.OngoingCallService.STOP"
private const val ACTION_TOGGLE_MUTE = "chat.stoat.services.OngoingCallService.TOGGLE_MUTE"
private const val ACTION_UPDATE_STATE =
"chat.stoat.services.OngoingCallService.UPDATE_STATE"
private const val EXTRA_CHANNEL_ID = "channelId"
private const val EXTRA_CHANNEL_NAME = "channelName"
private const val EXTRA_MIC_MUTED = "micMuted"
private const val NOTIFICATION_ID = 67
/**
* Emits when the user hangs up from outside the app (notification action or Telecom,
* e.g. through a smartwatch).
*/
val hangupEvents = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
fun start(context: Context, channelId: String, channelName: String?) {
ContextCompat.startForegroundService(
context,
Intent(context, OngoingCallService::class.java)
.setAction(ACTION_START)
.putExtra(EXTRA_CHANNEL_ID, channelId)
.putExtra(EXTRA_CHANNEL_NAME, channelName)
)
}
fun updateMicMuted(context: Context, muted: Boolean) {
try {
context.startService(
Intent(context, OngoingCallService::class.java)
.setAction(ACTION_UPDATE_STATE)
.putExtra(EXTRA_MIC_MUTED, muted)
)
} catch (e: IllegalStateException) {
logcat(LogPriority.WARN) {
"Could not deliver mic state to call service\n" + e.asLog()
}
}
}
fun stop(context: Context) {
try {
context.startService(
Intent(context, OngoingCallService::class.java).setAction(ACTION_STOP)
)
} catch (e: IllegalStateException) {
logcat(LogPriority.WARN) { "Could not deliver stop to call service\n" + e.asLog() }
}
}
}
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
private var telecomJob: Job? = null
private var callControl: CallControlScope? = null
private var registeredChannelId: String? = null
private var currentChannelName: String? = null
private var callStartedAt = 0L
private var isMicMuted = false
override fun onBind(intent: Intent?): IBinder? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
ACTION_START -> {
val channelId = intent.getStringExtra(EXTRA_CHANNEL_ID) ?: return START_NOT_STICKY
val channelName = intent.getStringExtra(EXTRA_CHANNEL_NAME)
?: getString(R.string.voice_notification_fallback_channel_name)
currentChannelName = channelName
if (registeredChannelId != channelId) {
callStartedAt = System.currentTimeMillis()
}
ChannelRegistrator(this).register()
ServiceCompat.startForeground(
this,
NOTIFICATION_ID,
buildNotification(channelName),
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
ServiceInfo.FOREGROUND_SERVICE_TYPE_PHONE_CALL or
ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE
} else {
0
}
)
if (registeredChannelId != channelId) {
registeredChannelId = channelId
registerCallWithTelecom(channelId, channelName)
}
}
ACTION_HANGUP -> hangupEvents.tryEmit(Unit)
ACTION_TOGGLE_MUTE -> VoiceCallManager.toggleMicrophone()
ACTION_UPDATE_STATE -> {
val muted = intent.getBooleanExtra(EXTRA_MIC_MUTED, false)
if (muted != isMicMuted) {
isMicMuted = muted
currentChannelName?.let { channelName ->
try {
NotificationManagerCompat.from(this)
.notify(NOTIFICATION_ID, buildNotification(channelName))
} catch (e: SecurityException) {
logcat(LogPriority.WARN) {
"Could not update call notification\n" + e.asLog()
}
}
}
}
}
ACTION_STOP -> endCall(startId)
}
return START_NOT_STICKY
}
override fun onTaskRemoved(rootIntent: Intent?) {
hangupEvents.tryEmit(Unit)
endCall(startId = -1)
super.onTaskRemoved(rootIntent)
}
override fun onDestroy() {
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
scope.cancel()
super.onDestroy()
}
private fun buildNotification(channelName: String): Notification {
val caller = Person.Builder()
.setName(channelName)
.setImportant(true)
.build()
val hangupIntent = PendingIntent.getService(
this,
0,
Intent(this, OngoingCallService::class.java).setAction(ACTION_HANGUP),
PendingIntent.FLAG_IMMUTABLE
)
val toggleMuteIntent = PendingIntent.getService(
this,
1,
Intent(this, OngoingCallService::class.java).setAction(ACTION_TOGGLE_MUTE),
PendingIntent.FLAG_IMMUTABLE
)
val contentIntent = PendingIntent.getActivity(
this,
0,
Intent(this, MainActivity::class.java)
.setAction(Intent.ACTION_MAIN)
.addCategory(Intent.CATEGORY_LAUNCHER)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
PendingIntent.FLAG_IMMUTABLE
)
return NotificationCompat.Builder(this, ChannelRegistrator.CHANNEL_ID_GROUP_VOICE_ONGOING)
.setSmallIcon(R.drawable.ic_notification_monochrome)
.setContentTitle(channelName)
.setContentText(getString(R.string.voice_notification_ongoing_call))
.setStyle(NotificationCompat.CallStyle.forOngoingCall(caller, hangupIntent))
.addAction(
if (isMicMuted) R.drawable.ic_mic_24dp else R.drawable.ic_mic_off_24dp,
getString(
if (isMicMuted) R.string.voice_action_unmute else R.string.voice_action_mute
),
toggleMuteIntent
)
.addPerson(caller)
.setContentIntent(contentIntent)
.setCategory(NotificationCompat.CATEGORY_CALL)
.setOngoing(true)
.setSilent(true)
.setShowWhen(true)
.setWhen(callStartedAt)
.setUsesChronometer(true)
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
.apply {
if (Build.VERSION.SDK_INT >= 36) {
// TODO update sdk and dont use the literal string value here (lazy solution prevailed)
extras.putBoolean("android.requestPromotedOngoing", true)
}
}
.build()
}
private fun registerCallWithTelecom(channelId: String, channelName: String) {
telecomJob?.cancel()
callControl = null
val callsManager = CallsManager(this)
try {
callsManager.registerAppWithTelecom(CallsManager.CAPABILITY_BASELINE)
} catch (e: Exception) {
logcat(LogPriority.ERROR) { "Could not register app with Telecom\n" + e.asLog() }
return
}
telecomJob = scope.launch {
try {
callsManager.addCall(
CallAttributesCompat(
displayName = channelName,
address = "stoat:$channelId".toUri(),
direction = CallAttributesCompat.DIRECTION_OUTGOING,
callType = CallAttributesCompat.CALL_TYPE_AUDIO_CALL,
callCapabilities = 0
),
onAnswer = {},
onDisconnect = {
if (callControl != null) hangupEvents.tryEmit(Unit)
},
onSetActive = {},
onSetInactive = {}
) {
callControl = this
launch { setActive() }
}
} catch (e: Exception) {
logcat(LogPriority.ERROR) { "Could not add call to Telecom\n" + e.asLog() }
}
}
}
private fun endCall(startId: Int) {
val control = callControl
callControl = null
registeredChannelId = null
if (control == null) {
telecomJob?.cancel()
stopSelf(startId)
return
}
scope.launch {
try {
withTimeoutOrNull(1.seconds) {
control.disconnect(DisconnectCause(DisconnectCause.LOCAL))
}
} catch (e: Exception) {
logcat(LogPriority.ERROR) { "Could not disconnect Telecom call\n" + e.asLog() }
}
stopSelf(startId)
}
}
}

View File

@ -0,0 +1,279 @@
package chat.stoat.voice
import android.content.Context
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import chat.stoat.R
import chat.stoat.StoatApplication
import chat.stoat.api.StoatAPI
import chat.stoat.api.internals.ChannelUtils
import chat.stoat.api.routes.misc.Root
import chat.stoat.api.routes.misc.getRootRoute
import chat.stoat.api.routes.voice.JoinCallResponse
import chat.stoat.api.routes.voice.joinCall
import chat.stoat.composables.voice.VoiceSound
import chat.stoat.composables.voice.VoiceSoundPlayer
import chat.stoat.services.OngoingCallService
import io.livekit.android.LiveKit
import io.livekit.android.events.RoomEvent
import io.livekit.android.room.Room
import io.livekit.android.room.track.RemoteAudioTrack
import io.livekit.android.room.track.Track
import io.livekit.android.util.flow
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import logcat.LogPriority
import logcat.asLog
import logcat.logcat
/**
* Owns the lifecycle of the current voice call.
* The LiveKit [Room] lives here
*/
object VoiceCallManager {
private val context: Context get() = StoatApplication.instance
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
var activeChannelId: String? by mutableStateOf(null)
private set
var room: Room? by mutableStateOf(null)
private set
var errorResource: Int? by mutableStateOf<Int?>(null)
private set
var isDeafened: Boolean by mutableStateOf(false)
private set
var isSheetVisible: Boolean by mutableStateOf(false)
var requestedChannelId: String? by mutableStateOf(null)
private set
fun openSheet(channelId: String) {
requestedChannelId = channelId
isSheetVisible = true
}
private var micWasOnBeforeDeafen = false
private var soundPlayer: VoiceSoundPlayer? = null
private var sessionJob: Job? = null
private var hasPlayedJoinSound = false
private var hasPlayedLeaveSound = false
init {
scope.launch {
OngoingCallService.hangupEvents.collect { leave() }
}
}
fun join(channelId: String) {
if (activeChannelId == channelId && room != null) return
endSession()
activeChannelId = channelId
errorResource = null
hasPlayedJoinSound = false
hasPlayedLeaveSound = false
soundPlayer = VoiceSoundPlayer(context)
val newRoom = LiveKit.create(context.applicationContext)
room = newRoom
sessionJob = scope.launch {
launch { watchRoomState(newRoom, channelId) }
launch { watchRoomEvents(newRoom) }
launch { watchLocalScreenShare(newRoom) }
launch { watchLocalMicrophone(newRoom) }
val connection = fetchVoiceToken(channelId) ?: return@launch
try {
newRoom.connect(connection.url, connection.token)
newRoom.localParticipant.setMicrophoneEnabled(true)
} catch (e: Exception) {
logcat(LogPriority.ERROR) { "Could not connect to LiveKit room\n" + e.asLog() }
errorResource = R.string.voice_error_generic
}
}
}
fun leave() {
isSheetVisible = false
requestedChannelId = null
endSession()
}
private fun endSession() {
sessionJob?.cancel()
sessionJob = null
val leftRoom = room
room = null
activeChannelId = null
errorResource = null
isDeafened = false
micWasOnBeforeDeafen = false
if (leftRoom != null) {
if (hasPlayedJoinSound && !hasPlayedLeaveSound) {
hasPlayedLeaveSound = true
soundPlayer?.play(VoiceSound.USER_LEAVE)
}
leftRoom.disconnect()
leftRoom.release()
}
soundPlayer?.release()
soundPlayer = null
OngoingCallService.stop(context)
}
fun toggleMicrophone() {
val room = room ?: return
val isMicOn = room.localParticipant.isMicrophoneEnabled
soundPlayer?.play(if (isMicOn) VoiceSound.MUTE else VoiceSound.UNMUTE)
scope.launch {
room.localParticipant.setMicrophoneEnabled(!isMicOn)
}
}
fun toggleDeafen() {
val room = room ?: return
if (!isDeafened) {
soundPlayer?.play(VoiceSound.DEAFEN)
micWasOnBeforeDeafen = room.localParticipant.isMicrophoneEnabled
isDeafened = true
applyDeafenState(room)
scope.launch {
room.localParticipant.setMicrophoneEnabled(false)
}
} else {
soundPlayer?.play(VoiceSound.UNDEAFEN)
isDeafened = false
applyDeafenState(room)
if (micWasOnBeforeDeafen) {
scope.launch {
room.localParticipant.setMicrophoneEnabled(true)
}
}
}
}
private fun applyDeafenState(room: Room) {
room.remoteParticipants.values.forEach { participant ->
participant.audioTrackPublications.forEach { (_, track) ->
(track as? RemoteAudioTrack)?.setVolume(if (isDeafened) 0.0 else 1.0)
}
}
}
private suspend fun watchRoomState(room: Room, channelId: String) {
room::state.flow.collect { state ->
when (state) {
Room.State.CONNECTED -> {
if (!hasPlayedJoinSound) {
hasPlayedJoinSound = true
soundPlayer?.play(VoiceSound.USER_JOIN)
OngoingCallService.start(
context,
channelId,
StoatAPI.channelCache[channelId]?.let(ChannelUtils::resolveName)
)
}
}
Room.State.DISCONNECTED -> {
if (hasPlayedJoinSound) leave()
}
Room.State.CONNECTING, Room.State.RECONNECTING -> Unit
}
}
}
private suspend fun watchRoomEvents(room: Room) {
room.events.events.collect { event ->
when (event) {
is RoomEvent.TrackSubscribed -> applyDeafenState(room)
is RoomEvent.ParticipantConnected -> soundPlayer?.play(VoiceSound.USER_JOIN)
is RoomEvent.ParticipantDisconnected -> soundPlayer?.play(VoiceSound.USER_LEAVE)
is RoomEvent.TrackPublished -> {
if (event.participant != room.localParticipant &&
event.publication.source == Track.Source.SCREEN_SHARE
) {
soundPlayer?.play(VoiceSound.STREAM_START)
}
}
is RoomEvent.TrackUnpublished -> {
if (event.participant != room.localParticipant &&
event.publication.source == Track.Source.SCREEN_SHARE
) {
soundPlayer?.play(VoiceSound.STREAM_END)
}
}
else -> Unit
}
}
}
private suspend fun watchLocalMicrophone(room: Room) {
room.localParticipant::isMicrophoneEnabled.flow.collect { enabled ->
OngoingCallService.updateMicMuted(context, muted = !enabled)
}
}
private suspend fun watchLocalScreenShare(room: Room) {
var wasSharing: Boolean? = null
room.localParticipant::isScreenShareEnabled.flow.collect { isSharing ->
wasSharing?.let { previous ->
if (isSharing && !previous) soundPlayer?.play(VoiceSound.STREAM_START)
if (!isSharing && previous) soundPlayer?.play(VoiceSound.STREAM_END)
}
wasSharing = isSharing
}
}
private suspend fun fetchVoiceToken(channelId: String): JoinCallResponse? {
val root: Root
try {
root = getRootRoute()
} catch (e: Exception) {
logcat(LogPriority.ERROR) { "Could not get root route\n" + e.asLog() }
errorResource = R.string.voice_error_generic
return null
}
val lk = root.features.livekit
if (lk == null) {
logcat(LogPriority.ERROR) {
IllegalStateException("LiveKit is not supported by this API version!").asLog()
}
errorResource = R.string.voice_error_not_supported
return null
}
if (lk.nodes.isEmpty()) {
logcat(LogPriority.ERROR) { IllegalStateException("No LiveKit nodes available!").asLog() }
errorResource = R.string.voice_error_no_nodes
return null
}
val node = lk.nodes.random()
return try {
joinCall(channelId, node.name)
} catch (e: Exception) {
logcat(LogPriority.ERROR) { "Could not get LiveKit token\n" + e.asLog() }
errorResource = R.string.voice_error_generic
null
}
}
}

View File

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M320,680Q287,680 263.5,656.5Q240,633 240,600L240,360Q240,327 263.5,303.5Q287,280 320,280L360,280L400,240L560,240L600,280L640,280Q673,280 696.5,303.5Q720,327 720,360L720,600Q720,633 696.5,656.5Q673,680 640,680L320,680ZM320,600L640,600Q640,600 640,600Q640,600 640,600L640,360Q640,360 640,360Q640,360 640,360L320,360Q320,360 320,360Q320,360 320,360L320,600Q320,600 320,600Q320,600 320,600ZM480,560Q513,560 536.5,536.5Q560,513 560,480Q560,447 536.5,423.5Q513,400 480,400Q447,400 423.5,423.5Q400,447 400,480Q400,513 423.5,536.5Q447,560 480,560ZM342,20Q376,9 410.5,4.5Q445,0 480,0Q574,0 657.5,33.5Q741,67 805.5,126.5Q870,186 911,266.5Q952,347 960,440L880,440Q873,368 842,305.5Q811,243 762.5,195.5Q714,148 651,118Q588,88 516,82L578,144L522,200L342,20ZM618,940Q584,951 549.5,955.5Q515,960 480,960Q386,960 302.5,926.5Q219,893 154.5,833.5Q90,774 49,693.5Q8,613 0,520L80,520Q88,592 118.5,654.5Q149,717 197.5,764.5Q246,812 309,842Q372,872 444,878L382,816L438,760L618,940ZM480,480Q480,480 480,480Q480,480 480,480L480,480Q480,480 480,480Q480,480 480,480L480,480Q480,480 480,480Q480,480 480,480L480,480Q480,480 480,480Q480,480 480,480Z"/>
</vector>

View File

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M360,840L200,840Q167,840 143.5,816.5Q120,793 120,760L120,480Q120,405 148.5,339.5Q177,274 226,225Q275,176 340.5,148Q406,120 480,120Q554,120 619.5,148Q685,176 734,225Q783,274 811.5,339.5Q840,405 840,480L840,760Q840,793 816.5,816.5Q793,840 760,840L600,840L600,520L760,520L760,480Q760,363 678.5,281.5Q597,200 480,200Q363,200 281.5,281.5Q200,363 200,480L200,520L360,520L360,840ZM280,600L200,600L200,760Q200,760 200,760Q200,760 200,760L280,760L280,600ZM680,600L680,760L760,760Q760,760 760,760Q760,760 760,760L760,600L680,600ZM280,600L200,600L280,600L280,600ZM680,600L760,600L680,600L680,600Z"/>
</vector>

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -296,6 +296,7 @@
<string name="system_message_user_joined_alt">User joined</string>
<string name="system_message_message_pinned_alt">Message pinned</string>
<string name="system_message_message_unpinned_alt">Message unpinned</string>
<string name="system_message_call_started_alt">Call started</string>
<string name="system_message_text_alt">System message</string>
<string name="system_message_ownership_changed">%1$s transferred ownership to %2$s</string>
@ -310,6 +311,7 @@
<string name="system_message_user_joined">%1$s joined</string>
<string name="system_message_message_pinned">%1$s pinned a message to this channel</string>
<string name="system_message_message_unpinned">%1$s unpinned a message from this channel</string>
<string name="system_message_call_started">%1$s started a call</string>
<string name="today">Today</string>
<string name="yesterday">Yesterday</string>
@ -677,11 +679,29 @@
<string name="voice_action_disconnect">Disconnect</string>
<string name="voice_action_mute">Mute</string>
<string name="voice_action_unmute">Unmute</string>
<string name="voice_action_deafen">Deafen</string>
<string name="voice_action_undeafen">Undeafen</string>
<string name="voice_action_video">Video</string>
<string name="voice_action_screen_share">Share screen</string>
<string name="voice_action_flip_camera">Flip camera</string>
<string name="voice_audio_output">Audio output</string>
<string name="voice_audio_output_speaker">Speaker</string>
<string name="voice_audio_output_earpiece">Earpiece</string>
<string name="voice_audio_output_wired_headset">Wired headset</string>
<string name="voice_status_connecting">Connecting…</string>
<string name="voice_status_reconnecting">Reconnecting…</string>
<string name="voice_status_connected">Connected</string>
<string name="voice_status_disconnected">No connection</string>
<string name="voice_notification_ongoing_call">Connected to voice</string>
<string name="voice_banner_tap_to_return">Tap to return to the call</string>
<string name="voice_start_call">Start a call</string>
<string name="voice_notification_fallback_channel_name">Voice channel</string>
<string name="spark_notifications_rationale">Stay in the loop</string>
<string name="spark_notifications_rationale_description">Enable notifications to be kept up to date with messages and mentions.</string>
<string name="spark_notifications_rationale_cta">Enable notifications</string>
@ -921,6 +941,9 @@
<string name="notification_channel_friend_requests_description">Incoming friend requests, as well as accepted requests you\'ve sent.</string>
<string name="notification_channel_messages">Messages</string>
<string name="notification_channel_messages_description">Unread messages from your conversations and channels.</string>
<string name="notification_channel_group_voice">Voice</string>
<string name="notification_channel_ongoing_call">Ongoing calls</string>
<string name="notification_channel_ongoing_call_description">Shown persistently while you are connected to a voice channel.</string>
<string name="keyboard_shortcut_messaging">Messaging</string>
<string name="keyboard_shortcut_messaging_new_line">New Line</string>

View File

@ -22,8 +22,8 @@ telephoto = "1.0.0-alpha02"
haze = "1.7.2"
chucker = "4.3.1"
androidx-test = "1.6.1"
livekit = "2.24.1"
livekit-compose = "2.3.0"
livekit = "2.26.1"
livekit-compose = "2.4.0"
koin = "4.2.1"
multiplatform-markdown = "0.41.0-b01"
coil = "3.4.0"