From 6a4b5d21e9cab91b56891f1ee13dbd47c6ad6fdd Mon Sep 17 00:00:00 2001 From: infi Date: Sat, 18 Jul 2026 03:07:28 +0200 Subject: [PATCH] feat: voice chat, video chat, screen share --- app/build.gradle.kts | 4 +- app/src/main/AndroidManifest.xml | 18 +- .../main/java/chat/stoat/StoatApplication.kt | 6 + .../chat/stoat/activities/MainActivity.kt | 28 +- .../chat/stoat/api/realtime/RealtimeSocket.kt | 25 +- .../frames/receivable/ReceivableFrames.kt | 1 + .../chat/stoat/c2dm/ChannelRegistrator.kt | 22 + .../stoat/composables/chat/SystemMessage.kt | 21 + .../composables/voice/VoiceCallBanner.kt | 87 +++ .../stoat/composables/voice/VoiceSheet.kt | 524 +++++++++++++----- .../composables/voice/VoiceSoundPlayer.kt | 82 +++ .../chat/views/channel/ChannelScreen.kt | 34 +- .../chat/stoat/services/OngoingCallService.kt | 302 ++++++++++ .../java/chat/stoat/voice/VoiceCallManager.kt | 279 ++++++++++ .../res/drawable/ic_cameraswitch_24dp.xml | 10 + app/src/main/res/drawable/ic_headset_24dp.xml | 10 + app/src/main/res/raw/sfx_deafen.ogg | Bin 0 -> 8211 bytes app/src/main/res/raw/sfx_mute.ogg | Bin 0 -> 8192 bytes app/src/main/res/raw/sfx_stream_end.ogg | Bin 0 -> 17826 bytes app/src/main/res/raw/sfx_stream_start.ogg | Bin 0 -> 22854 bytes app/src/main/res/raw/sfx_undeafen.ogg | Bin 0 -> 9354 bytes app/src/main/res/raw/sfx_unmute.ogg | Bin 0 -> 8400 bytes app/src/main/res/raw/sfx_user_join_voice.ogg | Bin 0 -> 6898 bytes app/src/main/res/raw/sfx_user_leave_voice.ogg | Bin 0 -> 7517 bytes app/src/main/res/values/strings.xml | 23 + gradle/libs.versions.toml | 4 +- 26 files changed, 1294 insertions(+), 186 deletions(-) create mode 100644 app/src/main/java/chat/stoat/composables/voice/VoiceCallBanner.kt create mode 100644 app/src/main/java/chat/stoat/composables/voice/VoiceSoundPlayer.kt create mode 100644 app/src/main/java/chat/stoat/services/OngoingCallService.kt create mode 100644 app/src/main/java/chat/stoat/voice/VoiceCallManager.kt create mode 100644 app/src/main/res/drawable/ic_cameraswitch_24dp.xml create mode 100644 app/src/main/res/drawable/ic_headset_24dp.xml create mode 100644 app/src/main/res/raw/sfx_deafen.ogg create mode 100644 app/src/main/res/raw/sfx_mute.ogg create mode 100644 app/src/main/res/raw/sfx_stream_end.ogg create mode 100644 app/src/main/res/raw/sfx_stream_start.ogg create mode 100644 app/src/main/res/raw/sfx_undeafen.ogg create mode 100644 app/src/main/res/raw/sfx_unmute.ogg create mode 100644 app/src/main/res/raw/sfx_user_join_voice.ogg create mode 100644 app/src/main/res/raw/sfx_user_leave_voice.ogg diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ab9dd7a6..d989ff89 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index b2904249..27e304d2 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -5,7 +5,13 @@ + + + + + + - - @@ -66,6 +65,11 @@ + + diff --git a/app/src/main/java/chat/stoat/StoatApplication.kt b/app/src/main/java/chat/stoat/StoatApplication.kt index d0c5a059..c899b001 100644 --- a/app/src/main/java/chat/stoat/StoatApplication.kt +++ b/app/src/main/java/chat/stoat/StoatApplication.kt @@ -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() diff --git a/app/src/main/java/chat/stoat/activities/MainActivity.kt b/app/src/main/java/chat/stoat/activities/MainActivity.kt index 280cf886..20291498 100644 --- a/app/src/main/java/chat/stoat/activities/MainActivity.kt +++ b/app/src/main/java/chat/stoat/activities/MainActivity.kt @@ -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(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 ) } } diff --git a/app/src/main/java/chat/stoat/api/realtime/RealtimeSocket.kt b/app/src/main/java/chat/stoat/api/realtime/RealtimeSocket.kt index e31fd9d8..c49ebca0 100644 --- a/app/src/main/java/chat/stoat/api/realtime/RealtimeSocket.kt +++ b/app/src/main/java/chat/stoat/api/realtime/RealtimeSocket.kt @@ -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" -> { diff --git a/app/src/main/java/chat/stoat/api/realtime/frames/receivable/ReceivableFrames.kt b/app/src/main/java/chat/stoat/api/realtime/frames/receivable/ReceivableFrames.kt index c3bc0a77..f8d7ad36 100644 --- a/app/src/main/java/chat/stoat/api/realtime/frames/receivable/ReceivableFrames.kt +++ b/app/src/main/java/chat/stoat/api/realtime/frames/receivable/ReceivableFrames.kt @@ -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 ) diff --git a/app/src/main/java/chat/stoat/c2dm/ChannelRegistrator.kt b/app/src/main/java/chat/stoat/c2dm/ChannelRegistrator.kt index 298ee88f..eed38acb 100644 --- a/app/src/main/java/chat/stoat/c2dm/ChannelRegistrator.kt +++ b/app/src/main/java/chat/stoat/c2dm/ChannelRegistrator.kt @@ -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() { diff --git a/app/src/main/java/chat/stoat/composables/chat/SystemMessage.kt b/app/src/main/java/chat/stoat/composables/chat/SystemMessage.kt index c5fbc1c6..c531f6cd 100644 --- a/app/src/main/java/chat/stoat/composables/chat/SystemMessage.kt +++ b/app/src/main/java/chat/stoat/composables/chat/SystemMessage.kt @@ -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() } diff --git a/app/src/main/java/chat/stoat/composables/voice/VoiceCallBanner.kt b/app/src/main/java/chat/stoat/composables/voice/VoiceCallBanner.kt new file mode 100644 index 00000000..35824663 --- /dev/null +++ b/app/src/main/java/chat/stoat/composables/voice/VoiceCallBanner.kt @@ -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 + ) + } + } + } + } +} diff --git a/app/src/main/java/chat/stoat/composables/voice/VoiceSheet.kt b/app/src/main/java/chat/stoat/composables/voice/VoiceSheet.kt index 18c7ea0e..a979a809 100644 --- a/app/src/main/java/chat/stoat/composables/voice/VoiceSheet.kt +++ b/app/src/main/java/chat/stoat/composables/voice/VoiceSheet.kt @@ -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("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("voiceToken") ?: "") - var voiceToken: String - get() = _voiceToken.value - private set(value) { - _voiceToken.value = value - state["voiceToken"] = value - } - - var errorResource by mutableStateOf(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) +} diff --git a/app/src/main/java/chat/stoat/composables/voice/VoiceSoundPlayer.kt b/app/src/main/java/chat/stoat/composables/voice/VoiceSoundPlayer.kt new file mode 100644 index 00000000..69978921 --- /dev/null +++ b/app/src/main/java/chat/stoat/composables/voice/VoiceSoundPlayer.kt @@ -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() + private val loadedSoundIds = mutableSetOf() + private val pendingSounds = mutableListOf() + + 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" } + } + } +} diff --git a/app/src/main/java/chat/stoat/screens/chat/views/channel/ChannelScreen.kt b/app/src/main/java/chat/stoat/screens/chat/views/channel/ChannelScreen.kt index 4653bb79..68aee4c3 100644 --- a/app/src/main/java/chat/stoat/screens/chat/views/channel/ChannelScreen.kt +++ b/app/src/main/java/chat/stoat/screens/chat/views/channel/ChannelScreen.kt @@ -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 ) { diff --git a/app/src/main/java/chat/stoat/services/OngoingCallService.kt b/app/src/main/java/chat/stoat/services/OngoingCallService.kt new file mode 100644 index 00000000..28c102e5 --- /dev/null +++ b/app/src/main/java/chat/stoat/services/OngoingCallService.kt @@ -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(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) + } + } +} diff --git a/app/src/main/java/chat/stoat/voice/VoiceCallManager.kt b/app/src/main/java/chat/stoat/voice/VoiceCallManager.kt new file mode 100644 index 00000000..de25a28f --- /dev/null +++ b/app/src/main/java/chat/stoat/voice/VoiceCallManager.kt @@ -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(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 + } + } +} diff --git a/app/src/main/res/drawable/ic_cameraswitch_24dp.xml b/app/src/main/res/drawable/ic_cameraswitch_24dp.xml new file mode 100644 index 00000000..62f2207a --- /dev/null +++ b/app/src/main/res/drawable/ic_cameraswitch_24dp.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_headset_24dp.xml b/app/src/main/res/drawable/ic_headset_24dp.xml new file mode 100644 index 00000000..f4fbf096 --- /dev/null +++ b/app/src/main/res/drawable/ic_headset_24dp.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/raw/sfx_deafen.ogg b/app/src/main/res/raw/sfx_deafen.ogg new file mode 100644 index 0000000000000000000000000000000000000000..d5f73116373f9655ee5725ea0e4e496eb7ad42de GIT binary patch literal 8211 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 literal 0 HcmV?d00001 diff --git a/app/src/main/res/raw/sfx_stream_start.ogg b/app/src/main/res/raw/sfx_stream_start.ogg new file mode 100644 index 0000000000000000000000000000000000000000..23c7bed76f9b31fbea0d51f4dd9d6d9161aa8468 GIT binary patch literal 22854 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 L6-k&4NM-zAGv~2o literal 0 HcmV?d00001 diff --git a/app/src/main/res/raw/sfx_unmute.ogg b/app/src/main/res/raw/sfx_unmute.ogg new file mode 100644 index 0000000000000000000000000000000000000000..0c34db51c23c5222e198bd9f06760169b23fca9e GIT binary patch literal 8400 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%0Dci*8#{;& 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 WT5sOOe^b0@W$mQe-K9WL@xK7|x@fWh literal 0 HcmV?d00001 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0dea5c26..27c4311b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -296,6 +296,7 @@ User joined Message pinned Message unpinned + Call started System message %1$s transferred ownership to %2$s @@ -310,6 +311,7 @@ %1$s joined %1$s pinned a message to this channel %1$s unpinned a message from this channel + %1$s started a call Today Yesterday @@ -677,11 +679,29 @@ Disconnect + Mute + Unmute + Deafen + Undeafen + Video + Share screen + Flip camera + + Audio output + Speaker + Earpiece + Wired headset + Connecting… Reconnecting… Connected No connection + Connected to voice + Tap to return to the call + Start a call + Voice channel + Stay in the loop Enable notifications to be kept up to date with messages and mentions. Enable notifications @@ -921,6 +941,9 @@ Incoming friend requests, as well as accepted requests you\'ve sent. Messages Unread messages from your conversations and channels. + Voice + Ongoing calls + Shown persistently while you are connected to a voice channel. Messaging New Line diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 4dcd20bf..d7b18611 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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"