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 00000000..d5f73116 Binary files /dev/null and b/app/src/main/res/raw/sfx_deafen.ogg differ diff --git a/app/src/main/res/raw/sfx_mute.ogg b/app/src/main/res/raw/sfx_mute.ogg new file mode 100644 index 00000000..0f776777 Binary files /dev/null and b/app/src/main/res/raw/sfx_mute.ogg differ diff --git a/app/src/main/res/raw/sfx_stream_end.ogg b/app/src/main/res/raw/sfx_stream_end.ogg new file mode 100644 index 00000000..e14ffc2c Binary files /dev/null and b/app/src/main/res/raw/sfx_stream_end.ogg differ 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 00000000..23c7bed7 Binary files /dev/null and b/app/src/main/res/raw/sfx_stream_start.ogg differ diff --git a/app/src/main/res/raw/sfx_undeafen.ogg b/app/src/main/res/raw/sfx_undeafen.ogg new file mode 100644 index 00000000..23958101 Binary files /dev/null and b/app/src/main/res/raw/sfx_undeafen.ogg differ 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 00000000..0c34db51 Binary files /dev/null and b/app/src/main/res/raw/sfx_unmute.ogg differ diff --git a/app/src/main/res/raw/sfx_user_join_voice.ogg b/app/src/main/res/raw/sfx_user_join_voice.ogg new file mode 100644 index 00000000..6e7c1ae7 Binary files /dev/null and b/app/src/main/res/raw/sfx_user_join_voice.ogg differ diff --git a/app/src/main/res/raw/sfx_user_leave_voice.ogg b/app/src/main/res/raw/sfx_user_leave_voice.ogg new file mode 100644 index 00000000..48b344c9 Binary files /dev/null and b/app/src/main/res/raw/sfx_user_leave_voice.ogg differ 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"