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"