videochat integriert
All checks were successful
Deploy SingleChat / deploy (push) Successful in 27s

This commit is contained in:
Torsten Schulz (local)
2026-06-17 12:53:03 +02:00
parent 8c9a600645
commit 10e6e7a80a
22 changed files with 4443 additions and 510 deletions

View File

@@ -1,6 +1,6 @@
# YPChat Android
Native Android-App fuer den bestehenden SingleChat/YPChat-Server.
Native Android-App fuer den bestehenden YPChat-Server.
## Stack

View File

@@ -113,6 +113,7 @@ dependencies {
}
implementation("io.coil-kt.coil3:coil-compose:3.4.0")
implementation("io.coil-kt.coil3:coil-network-okhttp:3.4.0")
implementation("io.github.webrtc-sdk:android:125.6422.07")
debugImplementation("androidx.compose.ui:ui-tooling")
}

View File

@@ -1,6 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<application
android:name=".YpChatApp"

View File

@@ -4,6 +4,7 @@ import android.content.Context
import de.ypchat.android.data.api.RestApi
import de.ypchat.android.data.api.SocketClient
import de.ypchat.android.data.repository.ChatRepository
import de.ypchat.android.media.AndroidVideoCallManager
import okhttp3.OkHttpClient
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
@@ -28,5 +29,6 @@ class AppContainer(context: Context) {
val restApi: RestApi = retrofit.create(RestApi::class.java)
val socketClient = SocketClient(AppConfig.baseUrl, okHttpClient)
val chatRepository = ChatRepository(restApi, socketClient, cookieJar, profileStore)
val videoCallManager = AndroidVideoCallManager(context, socketClient)
val chatRepository = ChatRepository(restApi, socketClient, cookieJar, profileStore, videoCallManager)
}

View File

@@ -15,6 +15,14 @@ import de.ypchat.android.data.model.HistoryItemDto
import de.ypchat.android.data.model.InboxItemDto
import de.ypchat.android.data.model.SocketEvent
import de.ypchat.android.data.model.UserDto
import de.ypchat.android.data.model.VideoCallDto
import de.ypchat.android.data.model.VideoCapacityDto
import de.ypchat.android.data.model.VideoConsentDto
import de.ypchat.android.data.model.VideoIceCandidateDto
import de.ypchat.android.data.model.VideoIceServerDto
import de.ypchat.android.data.model.VideoMediaDto
import de.ypchat.android.data.model.VideoSessionDescriptionDto
import de.ypchat.android.data.model.VideoSignalDto
import okhttp3.OkHttpClient
import org.json.JSONArray
import org.json.JSONObject
@@ -113,6 +121,51 @@ class SocketClient(
s.on("unreadChats") { args ->
args.firstJson()?.let { emit(SocketEvent.UnreadChats(it.optInt("count", 0))) }
}
s.on("videoConsent:update") { args ->
args.firstJson()?.let { emit(SocketEvent.VideoConsentUpdate(it.toVideoConsentDto())) }
}
s.on("videoCall:invite") { args ->
args.firstJson()?.let { emit(SocketEvent.VideoCallInvite(it.toVideoCallDto())) }
}
s.on("videoCall:incoming") { args ->
args.firstJson()?.let { emit(SocketEvent.VideoCallIncoming(it.toVideoCallDto())) }
}
s.on("videoCall:start") { args ->
args.firstJson()?.let { emit(SocketEvent.VideoCallStart(it.toVideoCallDto())) }
}
s.on("videoCall:update") { args ->
args.firstJson()?.let { emit(SocketEvent.VideoCallUpdate(it.toVideoCallDto())) }
}
s.on("videoCall:reject") { args ->
args.firstJson()?.let { emit(SocketEvent.VideoCallReject(it.toVideoCallDto())) }
}
s.on("videoCall:cancel") { args ->
args.firstJson()?.let { emit(SocketEvent.VideoCallCancel(it.toVideoCallDto())) }
}
s.on("videoCall:end") { args ->
args.firstJson()?.let { emit(SocketEvent.VideoCallEnd(it.toVideoCallDto())) }
}
s.on("videoCall:muteState") { args ->
args.firstJson()?.let { emit(SocketEvent.VideoCallMuteState(it.toVideoCallDto())) }
}
s.on("videoCall:capacity") { args ->
args.firstJson()?.let { emit(SocketEvent.VideoCallCapacity(it.toVideoCapacityDto())) }
}
s.on("videoCall:signal") { args ->
args.firstJson()?.let { emit(SocketEvent.VideoCallSignal(it.toVideoSignalDto())) }
}
s.on("videoCall:error") { args ->
args.firstJson()?.let { json ->
emit(
SocketEvent.VideoCallError(
code = json.optStringOrNull("code"),
message = json.optString("message", "Video-Fehler"),
withUserName = json.optStringOrNull("withUserName"),
callId = json.optStringOrNull("callId")
)
)
}
}
s.on("userBlocked") { args ->
args.firstJson()?.let { emit(SocketEvent.UserBlocked(it.optString("userName"))) }
}
@@ -210,6 +263,55 @@ class SocketClient(
fun requestOpenConversations() = socket?.emit("requestOpenConversations")
fun blockUser(userName: String) = socket?.emit("blockUser", JSONObject().put("userName", userName))
fun unblockUser(userName: String) = socket?.emit("unblockUser", JSONObject().put("userName", userName))
fun setVideoConsent(withUserName: String, allowed: Boolean) =
socket?.emit("videoConsent:set", JSONObject().put("withUserName", withUserName).put("allowed", allowed))
fun inviteVideoCall(withUserName: String) =
socket?.emit("videoCall:invite", JSONObject().put("withUserName", withUserName))
fun acceptVideoCall(callId: String) =
socket?.emit("videoCall:accept", JSONObject().put("callId", callId))
fun rejectVideoCall(callId: String) =
socket?.emit("videoCall:reject", JSONObject().put("callId", callId))
fun cancelVideoCall(callId: String) =
socket?.emit("videoCall:cancel", JSONObject().put("callId", callId))
fun endVideoCall(callId: String) =
socket?.emit("videoCall:end", JSONObject().put("callId", callId))
fun setVideoMuteState(callId: String, muted: Boolean) =
socket?.emit("videoCall:muteState", JSONObject().put("callId", callId).put("muted", muted))
fun sendVideoSignal(signal: VideoSignalDto) {
val payload = JSONObject()
.put("callId", signal.callId)
.put("signalType", signal.signalType)
signal.description?.let {
payload.put(
"description",
JSONObject()
.put("type", it.type)
.put("sdp", it.sdp)
)
}
signal.candidate?.let {
payload.put(
"candidate",
JSONObject()
.put("candidate", it.candidate)
.put("sdpMid", it.sdpMid)
.put("sdpMLineIndex", it.sdpMLineIndex)
.put("usernameFragment", it.usernameFragment)
.put("type", it.type)
)
}
socket?.emit("videoCall:signal", payload)
}
fun setVideoConnectionState(callId: String, connectionState: String) =
socket?.emit(
"videoCall:connectionState",
JSONObject()
.put("callId", callId)
.put("connectionState", connectionState)
)
private fun emit(event: SocketEvent) {
scope.launch { _events.emit(event) }
@@ -252,6 +354,79 @@ private fun JSONObject.toInboxItemDto(): InboxItemDto = InboxItemDto(
unreadCount = optInt("unreadCount", 0)
)
private fun JSONObject.toVideoConsentDto(): VideoConsentDto = VideoConsentDto(
withUserName = optStringOrNull("withUserName"),
localConsent = optBoolean("localConsent", false),
remoteConsent = optBoolean("remoteConsent", false),
videoVisible = optBoolean("videoVisible", false)
)
private fun JSONObject.toVideoCallDto(): VideoCallDto = VideoCallDto(
callId = optString("callId"),
roomId = optStringOrNull("roomId"),
withUserName = optStringOrNull("withUserName"),
initiatedBy = optStringOrNull("initiatedBy"),
status = optString("status"),
createdAt = optString("createdAt"),
updatedAt = optString("updatedAt"),
endedAt = optStringOrNull("endedAt"),
reason = optStringOrNull("reason"),
localMuted = optBoolean("localMuted", false),
remoteMuted = optBoolean("remoteMuted", false),
connectionState = optString("connectionState", "new"),
remoteConnectionState = optString("remoteConnectionState", "new"),
media = optJSONObject("media")?.toVideoMediaDto()
)
private fun JSONObject.toVideoCapacityDto(): VideoCapacityDto = VideoCapacityDto(
activeConnections = optInt("activeConnections", 0),
maxConnections = optInt("maxConnections", 3),
reachedMax = optBoolean("reachedMax", false)
)
private fun JSONObject.toVideoMediaDto(): VideoMediaDto = VideoMediaDto(
mode = optString("mode"),
relayOnly = optBoolean("relayOnly", false),
iceTransportPolicy = optString("iceTransportPolicy", "relay"),
iceServers = optJSONArray("iceServers")?.toObjectList { it.toVideoIceServerDto() }.orEmpty(),
isCaller = optBoolean("isCaller", false)
)
private fun JSONObject.toVideoIceServerDto(): VideoIceServerDto {
val urlsValue = opt("urls")
val urls = when (urlsValue) {
is JSONArray -> urlsValue.toStringList()
is String -> listOf(urlsValue)
else -> emptyList()
}
return VideoIceServerDto(
urls = urls,
username = optStringOrNull("username"),
credential = optStringOrNull("credential")
)
}
private fun JSONObject.toVideoSignalDto(): VideoSignalDto = VideoSignalDto(
callId = optString("callId"),
fromUserName = optStringOrNull("fromUserName"),
signalType = optString("signalType"),
description = optJSONObject("description")?.toVideoSessionDescriptionDto(),
candidate = optJSONObject("candidate")?.toVideoIceCandidateDto()
)
private fun JSONObject.toVideoSessionDescriptionDto(): VideoSessionDescriptionDto = VideoSessionDescriptionDto(
type = optString("type"),
sdp = optString("sdp")
)
private fun JSONObject.toVideoIceCandidateDto(): VideoIceCandidateDto = VideoIceCandidateDto(
candidate = optString("candidate"),
sdpMid = optStringOrNull("sdpMid"),
sdpMLineIndex = optInt("sdpMLineIndex", 0),
usernameFragment = optStringOrNull("usernameFragment"),
type = optStringOrNull("type")
)
private fun JSONArray?.toStringList(): List<String> {
if (this == null) return emptyList()
return List(length()) { index -> opt(index)?.toString().orEmpty() }

View File

@@ -34,6 +34,71 @@ data class InboxItemDto(
val unreadCount: Int = 0
)
data class VideoConsentDto(
val withUserName: String? = null,
val localConsent: Boolean = false,
val remoteConsent: Boolean = false,
val videoVisible: Boolean = false
)
data class VideoCallDto(
val callId: String = "",
val roomId: String? = null,
val withUserName: String? = null,
val initiatedBy: String? = null,
val status: String = "",
val createdAt: String = "",
val updatedAt: String = "",
val endedAt: String? = null,
val reason: String? = null,
val localMuted: Boolean = false,
val remoteMuted: Boolean = false,
val connectionState: String = "new",
val remoteConnectionState: String = "new",
val media: VideoMediaDto? = null
)
data class VideoCapacityDto(
val activeConnections: Int = 0,
val maxConnections: Int = 3,
val reachedMax: Boolean = false
)
data class VideoMediaDto(
val mode: String = "",
val relayOnly: Boolean = false,
val iceTransportPolicy: String = "relay",
val iceServers: List<VideoIceServerDto> = emptyList(),
val isCaller: Boolean = false
)
data class VideoIceServerDto(
val urls: List<String> = emptyList(),
val username: String? = null,
val credential: String? = null
)
data class VideoSessionDescriptionDto(
val type: String = "",
val sdp: String = ""
)
data class VideoIceCandidateDto(
val candidate: String = "",
val sdpMid: String? = null,
val sdpMLineIndex: Int = 0,
val usernameFragment: String? = null,
val type: String? = null
)
data class VideoSignalDto(
val callId: String = "",
val fromUserName: String? = null,
val signalType: String = "",
val description: VideoSessionDescriptionDto? = null,
val candidate: VideoIceCandidateDto? = null
)
data class CountryOption(
val englishName: String,
val displayName: String,

View File

@@ -11,6 +11,18 @@ sealed interface SocketEvent {
data class HistoryResults(val results: List<HistoryItemDto>) : SocketEvent
data class InboxResults(val results: List<InboxItemDto>) : SocketEvent
data class UnreadChats(val count: Int) : SocketEvent
data class VideoConsentUpdate(val consent: VideoConsentDto) : SocketEvent
data class VideoCallInvite(val call: VideoCallDto) : SocketEvent
data class VideoCallIncoming(val call: VideoCallDto) : SocketEvent
data class VideoCallStart(val call: VideoCallDto) : SocketEvent
data class VideoCallUpdate(val call: VideoCallDto) : SocketEvent
data class VideoCallReject(val call: VideoCallDto) : SocketEvent
data class VideoCallCancel(val call: VideoCallDto) : SocketEvent
data class VideoCallEnd(val call: VideoCallDto) : SocketEvent
data class VideoCallMuteState(val call: VideoCallDto) : SocketEvent
data class VideoCallCapacity(val capacity: VideoCapacityDto) : SocketEvent
data class VideoCallSignal(val signal: VideoSignalDto) : SocketEvent
data class VideoCallError(val code: String?, val message: String, val withUserName: String? = null, val callId: String? = null) : SocketEvent
data class UserBlocked(val userName: String) : SocketEvent
data class UserUnblocked(val userName: String) : SocketEvent
data class CommandResult(val lines: List<String>, val kind: String) : SocketEvent

View File

@@ -25,18 +25,27 @@ import de.ypchat.android.data.model.InboxItemDto
import de.ypchat.android.data.model.PartnerLinkDto
import de.ypchat.android.data.model.SocketEvent
import de.ypchat.android.data.model.UserDto
import de.ypchat.android.data.model.VideoCallDto
import de.ypchat.android.data.model.VideoCapacityDto
import de.ypchat.android.data.model.VideoConsentDto
import de.ypchat.android.media.AndroidVideoCallManager
import de.ypchat.android.media.VideoMediaState
import okhttp3.MultipartBody
import org.webrtc.EglBase
import java.util.Locale
class ChatRepository(
private val restApi: RestApi,
private val socketClient: SocketClient,
private val cookieJar: SessionCookieJar,
private val profileStore: ProfileStore
private val profileStore: ProfileStore,
private val videoCallManager: AndroidVideoCallManager
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val _state = MutableStateFlow(ChatState())
val state: StateFlow<ChatState> = _state.asStateFlow()
val videoMediaState: StateFlow<VideoMediaState> = videoCallManager.state
val videoEglBaseContext: EglBase.Context = videoCallManager.eglBaseContext()
private var timeoutTickerStarted = false
init {
@@ -105,6 +114,7 @@ class ChatRepository(
runCatching { restApi.logout() }
socketClient.disconnect()
cookieJar.clear()
videoCallManager.releaseAll()
_state.value = ChatState(savedProfile = profileStore.read(), countries = _state.value.countries)
}
@@ -114,13 +124,21 @@ class ChatRepository(
}
fun openConversation(userName: String) {
_state.value = _state.value.copy(currentConversation = userName, messages = emptyList())
_state.value = _state.value.copy(
currentConversation = userName,
messages = emptyList(),
videoConsent = VideoConsentDto(withUserName = userName)
)
socketClient.requestConversation(userName)
resetTimeout()
}
fun closeConversation() {
_state.value = _state.value.copy(currentConversation = null, messages = emptyList())
_state.value = _state.value.copy(
currentConversation = null,
messages = emptyList(),
videoConsent = VideoConsentDto()
)
}
fun sendMessage(text: String) {
@@ -287,6 +305,75 @@ class ChatRepository(
resetTimeout()
}
fun setVideoConsent(allowed: Boolean) {
val target = _state.value.currentConversation ?: return
socketClient.setVideoConsent(target, allowed)
resetTimeout()
}
fun inviteVideoCall() {
val target = _state.value.currentConversation ?: return
socketClient.inviteVideoCall(target)
resetTimeout()
}
fun acceptVideoCall(callId: String) {
socketClient.acceptVideoCall(callId)
resetTimeout()
}
fun rejectVideoCall(callId: String) {
socketClient.rejectVideoCall(callId)
resetTimeout()
}
fun cancelVideoCall(callId: String) {
socketClient.cancelVideoCall(callId)
resetTimeout()
}
fun endVideoCall(callId: String) {
socketClient.endVideoCall(callId)
videoCallManager.endCall(callId)
resetTimeout()
}
fun bringVideoToFront(callId: String) {
_state.value = _state.value.copy(foregroundVideoSessionId = callId)
}
fun minimizeForegroundVideo() {
_state.value = _state.value.copy(foregroundVideoSessionId = null)
}
fun updateFloatingVideoPosition(x: Float, y: Float) {
_state.value = _state.value.copy(
floatingVideoOffsetX = x.coerceAtLeast(0f),
floatingVideoOffsetY = y.coerceAtLeast(0f)
)
}
fun toggleSelfMuted() {
val nextMuted = !_state.value.selfMuted
_state.value = _state.value.copy(selfMuted = nextMuted)
videoCallManager.updateSelfMuted(nextMuted)
_state.value.videoDockSessions.forEach { session ->
socketClient.setVideoMuteState(session.callId, nextMuted)
}
resetTimeout()
}
fun toggleSelfCameraEnabled() {
val nextEnabled = !_state.value.selfCameraEnabled
_state.value = _state.value.copy(selfCameraEnabled = nextEnabled)
videoCallManager.updateSelfCameraEnabled(nextEnabled)
resetTimeout()
}
fun setRuntimeError(message: String) {
_state.value = _state.value.copy(errorMessage = message)
}
private fun startTimeoutTicker() {
if (timeoutTickerStarted) return
timeoutTickerStarted = true
@@ -344,6 +431,7 @@ class ChatRepository(
is SocketEvent.Conversation -> current.copy(
currentConversation = event.withUserName,
messages = event.messages,
videoConsent = current.videoConsent.takeIf { it.withUserName == event.withUserName } ?: VideoConsentDto(withUserName = event.withUserName),
unreadChatsCount = maxOf(0, current.unreadChatsCount - 1),
remainingSecondsToTimeout = 1800
)
@@ -351,6 +439,56 @@ class ChatRepository(
is SocketEvent.HistoryResults -> current.copy(historyResults = event.results)
is SocketEvent.InboxResults -> current.copy(inboxResults = event.results)
is SocketEvent.UnreadChats -> current.copy(unreadChatsCount = event.count)
is SocketEvent.VideoConsentUpdate -> current.copy(
videoConsent = event.consent.takeIf { it.withUserName == current.currentConversation } ?: current.videoConsent
)
is SocketEvent.VideoCallInvite -> current.withVideoCall(event.call)
is SocketEvent.VideoCallIncoming -> current.withVideoCall(event.call)
is SocketEvent.VideoCallStart -> current.withVideoCall(event.call).also {
scope.launch {
runCatching {
videoCallManager.ensureCall(event.call, it.selfMuted, it.selfCameraEnabled)
}.onFailure { error ->
_state.value = _state.value.copy(errorMessage = error.message)
}
}
}
is SocketEvent.VideoCallUpdate -> current.withVideoCall(event.call).also {
if ((event.call.status == "connecting" || event.call.status == "active") && event.call.media != null) {
scope.launch {
runCatching {
videoCallManager.ensureCall(event.call, it.selfMuted, it.selfCameraEnabled)
}.onFailure { error ->
_state.value = _state.value.copy(errorMessage = error.message)
}
}
}
}
is SocketEvent.VideoCallReject -> current.withVideoCall(event.call).also {
videoCallManager.endCall(event.call.callId)
}
is SocketEvent.VideoCallCancel -> current.withVideoCall(event.call).also {
videoCallManager.endCall(event.call.callId)
}
is SocketEvent.VideoCallEnd -> current.withVideoCall(event.call).also {
videoCallManager.endCall(event.call.callId)
}
is SocketEvent.VideoCallMuteState -> current.withVideoCall(event.call)
is SocketEvent.VideoCallCapacity -> current.copy(
activeVideoConnectionCount = event.capacity.activeConnections,
maxVideoConnections = event.capacity.maxConnections,
maxVideoConnectionsReached = event.capacity.reachedMax
)
is SocketEvent.VideoCallSignal -> current.also {
scope.launch {
runCatching {
videoCallManager.handleSignal(event.signal)
}.onFailure { error ->
_state.value = _state.value.copy(errorMessage = error.message)
}
}
}
is SocketEvent.VideoCallError -> current.copy(errorMessage = event.message)
is SocketEvent.UserBlocked -> current.copy(errorMessage = "${event.userName} blocked")
is SocketEvent.UserUnblocked -> current.copy(errorMessage = "${event.userName} unblocked")
is SocketEvent.CommandResult -> current.copy(
@@ -371,6 +509,40 @@ class ChatRepository(
}
}
private fun ChatState.withVideoCall(call: VideoCallDto): ChatState {
val normalized = VideoSessionState.fromDto(call)
val updated = videoDockSessions.toMutableList()
val index = updated.indexOfFirst { it.callId == normalized.callId }
if (index >= 0) {
updated[index] = updated[index].copy(
roomId = normalized.roomId,
withUserName = normalized.withUserName,
initiatedBy = normalized.initiatedBy,
status = normalized.status,
createdAt = normalized.createdAt,
updatedAt = normalized.updatedAt,
endedAt = normalized.endedAt,
reason = normalized.reason,
localMuted = normalized.localMuted,
remoteMuted = normalized.remoteMuted,
connectionState = normalized.connectionState,
remoteConnectionState = normalized.remoteConnectionState,
media = normalized.media
)
} else {
updated += normalized
}
val visibleSessions = updated.filterNot { it.status in setOf("rejected", "cancelled", "ended", "failed") }
return copy(
videoDockSessions = updated.sortedByDescending { it.updatedAt }.take(3),
foregroundVideoSessionId = when {
foregroundVideoSessionId == null && normalized.status !in setOf("rejected", "cancelled", "ended", "failed") -> normalized.callId
foregroundVideoSessionId == normalized.callId && normalized.status in setOf("rejected", "cancelled", "ended", "failed") -> visibleSessions.firstOrNull()?.callId
else -> foregroundVideoSessionId
}
)
}
data class CommandTableState(
val title: String,
val columns: List<String>,
@@ -406,5 +578,51 @@ data class ChatState(
val isUploadingImage: Boolean = false,
val imageUploadMessage: String? = null,
val unreadChatsCount: Int = 0,
val errorMessage: String? = null
val errorMessage: String? = null,
val videoConsent: VideoConsentDto = VideoConsentDto(),
val videoDockSessions: List<VideoSessionState> = emptyList(),
val foregroundVideoSessionId: String? = null,
val floatingVideoOffsetX: Float = 24f,
val floatingVideoOffsetY: Float = 24f,
val activeVideoConnectionCount: Int = 0,
val maxVideoConnections: Int = 3,
val maxVideoConnectionsReached: Boolean = false,
val selfMuted: Boolean = false,
val selfCameraEnabled: Boolean = true
)
data class VideoSessionState(
val callId: String,
val roomId: String? = null,
val withUserName: String? = null,
val initiatedBy: String? = null,
val status: String = "",
val createdAt: String = "",
val updatedAt: String = "",
val endedAt: String? = null,
val reason: String? = null,
val localMuted: Boolean = false,
val remoteMuted: Boolean = false,
val connectionState: String = "new",
val remoteConnectionState: String = "new",
val media: de.ypchat.android.data.model.VideoMediaDto? = null
) {
companion object {
fun fromDto(dto: VideoCallDto) = VideoSessionState(
callId = dto.callId,
roomId = dto.roomId,
withUserName = dto.withUserName,
initiatedBy = dto.initiatedBy,
status = dto.status,
createdAt = dto.createdAt,
updatedAt = dto.updatedAt,
endedAt = dto.endedAt,
reason = dto.reason,
localMuted = dto.localMuted,
remoteMuted = dto.remoteMuted,
connectionState = dto.connectionState,
remoteConnectionState = dto.remoteConnectionState,
media = dto.media
)
}
}

View File

@@ -0,0 +1,391 @@
package de.ypchat.android.media
import android.content.Context
import de.ypchat.android.data.api.SocketClient
import de.ypchat.android.data.model.VideoCallDto
import de.ypchat.android.data.model.VideoIceCandidateDto
import de.ypchat.android.data.model.VideoIceServerDto
import de.ypchat.android.data.model.VideoSessionDescriptionDto
import de.ypchat.android.data.model.VideoSignalDto
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import org.webrtc.AudioSource
import org.webrtc.AudioTrack
import org.webrtc.Camera1Enumerator
import org.webrtc.CameraEnumerator
import org.webrtc.Camera2Enumerator
import org.webrtc.CameraVideoCapturer
import org.webrtc.CandidatePairChangeEvent
import org.webrtc.DataChannel
import org.webrtc.DefaultVideoDecoderFactory
import org.webrtc.DefaultVideoEncoderFactory
import org.webrtc.EglBase
import org.webrtc.IceCandidate
import org.webrtc.Logging
import org.webrtc.MediaConstraints
import org.webrtc.MediaStream
import org.webrtc.PeerConnection
import org.webrtc.PeerConnectionFactory
import org.webrtc.RtpReceiver
import org.webrtc.RtpTransceiver
import org.webrtc.SdpObserver
import org.webrtc.SessionDescription
import org.webrtc.SurfaceTextureHelper
import org.webrtc.VideoCapturer
import org.webrtc.VideoSource
import org.webrtc.VideoTrack
import java.util.concurrent.ConcurrentHashMap
import java.util.regex.Pattern
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlin.coroutines.suspendCoroutine
data class VideoMediaState(
val localVideoTrack: VideoTrack? = null,
val remoteVideoTracks: Map<String, VideoTrack> = emptyMap(),
val lastError: String? = null
)
class AndroidVideoCallManager(
context: Context,
private val socketClient: SocketClient
) {
private val appContext = context.applicationContext
private val eglBase: EglBase = EglBase.create()
private val peerConnectionFactory: PeerConnectionFactory
private val peerConnections = ConcurrentHashMap<String, PeerConnection>()
private val pendingIceCandidates = ConcurrentHashMap<String, MutableList<IceCandidate>>()
private var audioSource: AudioSource? = null
private var audioTrack: AudioTrack? = null
private var videoSource: VideoSource? = null
private var videoTrack: VideoTrack? = null
private var videoCapturer: CameraVideoCapturer? = null
private var videoCapturerInitialized = false
private var surfaceTextureHelper: SurfaceTextureHelper? = null
private val _state = MutableStateFlow(VideoMediaState())
val state: StateFlow<VideoMediaState> = _state.asStateFlow()
init {
PeerConnectionFactory.initialize(
PeerConnectionFactory.InitializationOptions.builder(appContext)
.setEnableInternalTracer(false)
.createInitializationOptions()
)
val options = PeerConnectionFactory.Options()
peerConnectionFactory = PeerConnectionFactory.builder()
.setOptions(options)
.setVideoEncoderFactory(DefaultVideoEncoderFactory(eglBase.eglBaseContext, true, true))
.setVideoDecoderFactory(DefaultVideoDecoderFactory(eglBase.eglBaseContext))
.createPeerConnectionFactory()
}
fun eglBaseContext() = eglBase.eglBaseContext
suspend fun ensureCall(call: VideoCallDto, selfMuted: Boolean, selfCameraEnabled: Boolean) {
val media = call.media ?: return
ensureLocalMedia(selfMuted, selfCameraEnabled)
val existing = peerConnections[call.callId]
if (existing != null) {
if (media.isCaller && existing.localDescription == null) {
createOffer(call.callId, existing)
}
return
}
val rtcConfig = PeerConnection.RTCConfiguration(media.iceServers.toNativeIceServers()).apply {
iceTransportsType = PeerConnection.IceTransportsType.RELAY
sdpSemantics = PeerConnection.SdpSemantics.UNIFIED_PLAN
bundlePolicy = PeerConnection.BundlePolicy.MAXBUNDLE
rtcpMuxPolicy = PeerConnection.RtcpMuxPolicy.REQUIRE
continualGatheringPolicy = PeerConnection.ContinualGatheringPolicy.GATHER_CONTINUALLY
}
val peerConnection = peerConnectionFactory.createPeerConnection(
rtcConfig,
createPeerConnectionObserver(call.callId)
) ?: throw IllegalStateException("PeerConnection konnte nicht erstellt werden.")
audioTrack?.let { peerConnection.addTrack(it) }
videoTrack?.let { peerConnection.addTrack(it) }
peerConnections[call.callId] = peerConnection
if (media.isCaller) {
createOffer(call.callId, peerConnection)
}
}
suspend fun handleSignal(signal: VideoSignalDto) {
val peerConnection = peerConnections[signal.callId] ?: return
when (signal.signalType) {
"description" -> {
val description = signal.description ?: return
peerConnection.setRemoteDescriptionAwait(
SessionDescription(SessionDescription.Type.fromCanonicalForm(description.type), description.sdp)
)
flushPendingCandidates(signal.callId, peerConnection)
if (description.type == "offer") {
createAnswer(signal.callId, peerConnection)
}
}
"candidate" -> {
val candidate = signal.candidate ?: return
val nativeCandidate = candidate.toNativeIceCandidate()
if (peerConnection.remoteDescription == null) {
pendingIceCandidates.getOrPut(signal.callId) { mutableListOf() }.add(nativeCandidate)
} else {
peerConnection.addIceCandidate(nativeCandidate)
}
}
}
}
fun updateSelfMuted(muted: Boolean) {
audioTrack?.setEnabled(!muted)
}
fun updateSelfCameraEnabled(enabled: Boolean) {
videoTrack?.setEnabled(enabled)
}
fun endCall(callId: String) {
peerConnections.remove(callId)?.let { connection ->
connection.close()
}
pendingIceCandidates.remove(callId)
val nextTracks = _state.value.remoteVideoTracks.toMutableMap().apply { remove(callId) }
_state.value = _state.value.copy(remoteVideoTracks = nextTracks)
}
fun releaseAll() {
peerConnections.keys.toList().forEach(::endCall)
runCatching { videoCapturer?.stopCapture() }
runCatching { videoCapturer?.dispose() }
runCatching { surfaceTextureHelper?.dispose() }
runCatching { videoSource?.dispose() }
runCatching { audioSource?.dispose() }
runCatching { videoTrack?.dispose() }
runCatching { audioTrack?.dispose() }
surfaceTextureHelper = null
videoCapturer = null
videoSource = null
audioSource = null
videoTrack = null
audioTrack = null
videoCapturerInitialized = false
_state.value = VideoMediaState()
}
private suspend fun ensureLocalMedia(selfMuted: Boolean, selfCameraEnabled: Boolean) {
if (audioTrack == null) {
audioSource = peerConnectionFactory.createAudioSource(MediaConstraints())
audioTrack = peerConnectionFactory.createAudioTrack("YPCHAT_AUDIO", audioSource).apply {
setEnabled(!selfMuted)
}
}
if (videoTrack == null) {
val capturer = createVideoCapturer()
?: throw IllegalStateException("Keine geeignete Kamera für Videochat gefunden.")
videoCapturer = capturer
surfaceTextureHelper = SurfaceTextureHelper.create("YPChatVideoCapture", eglBase.eglBaseContext)
videoSource = peerConnectionFactory.createVideoSource(capturer.isScreencast)
capturer.initialize(surfaceTextureHelper, appContext, videoSource?.capturerObserver)
capturer.startCapture(960, 720, 24)
videoCapturerInitialized = true
videoTrack = peerConnectionFactory.createVideoTrack("YPCHAT_VIDEO", videoSource).apply {
setEnabled(selfCameraEnabled)
}
_state.value = _state.value.copy(localVideoTrack = videoTrack)
} else if (videoCapturerInitialized) {
videoTrack?.setEnabled(selfCameraEnabled)
}
}
private fun createPeerConnectionObserver(callId: String) = object : PeerConnection.Observer {
override fun onSignalingChange(newState: PeerConnection.SignalingState?) = Unit
override fun onIceConnectionChange(newState: PeerConnection.IceConnectionState?) {
when (newState) {
PeerConnection.IceConnectionState.CONNECTED,
PeerConnection.IceConnectionState.COMPLETED -> socketClient.setVideoConnectionState(callId, "connected")
PeerConnection.IceConnectionState.DISCONNECTED -> socketClient.setVideoConnectionState(callId, "disconnected")
PeerConnection.IceConnectionState.FAILED -> socketClient.setVideoConnectionState(callId, "failed")
PeerConnection.IceConnectionState.CLOSED -> socketClient.setVideoConnectionState(callId, "closed")
PeerConnection.IceConnectionState.CHECKING -> socketClient.setVideoConnectionState(callId, "connecting")
else -> Unit
}
}
override fun onStandardizedIceConnectionChange(newState: PeerConnection.IceConnectionState?) = Unit
override fun onConnectionChange(newState: PeerConnection.PeerConnectionState?) {
when (newState) {
PeerConnection.PeerConnectionState.CONNECTING -> socketClient.setVideoConnectionState(callId, "connecting")
PeerConnection.PeerConnectionState.CONNECTED -> socketClient.setVideoConnectionState(callId, "connected")
PeerConnection.PeerConnectionState.DISCONNECTED -> socketClient.setVideoConnectionState(callId, "disconnected")
PeerConnection.PeerConnectionState.FAILED -> socketClient.setVideoConnectionState(callId, "failed")
PeerConnection.PeerConnectionState.CLOSED -> socketClient.setVideoConnectionState(callId, "closed")
else -> Unit
}
}
override fun onIceConnectionReceivingChange(receiving: Boolean) = Unit
override fun onIceGatheringChange(newState: PeerConnection.IceGatheringState?) = Unit
override fun onIceCandidate(candidate: IceCandidate?) {
candidate ?: return
socketClient.sendVideoSignal(
VideoSignalDto(
callId = callId,
signalType = "candidate",
candidate = candidate.toDto()
)
)
}
override fun onIceCandidatesRemoved(candidates: Array<out IceCandidate>?) = Unit
override fun onAddStream(stream: MediaStream?) {
val remoteTrack = stream?.videoTracks?.firstOrNull() ?: return
val nextTracks = _state.value.remoteVideoTracks.toMutableMap().apply { put(callId, remoteTrack) }
_state.value = _state.value.copy(remoteVideoTracks = nextTracks)
}
override fun onRemoveStream(stream: MediaStream?) {
val nextTracks = _state.value.remoteVideoTracks.toMutableMap().apply { remove(callId) }
_state.value = _state.value.copy(remoteVideoTracks = nextTracks)
}
override fun onDataChannel(dataChannel: DataChannel?) = Unit
override fun onRenegotiationNeeded() = Unit
override fun onAddTrack(receiver: RtpReceiver?, mediaStreams: Array<out MediaStream>?) {
val remoteTrack = receiver?.track() as? VideoTrack ?: return
val nextTracks = _state.value.remoteVideoTracks.toMutableMap().apply { put(callId, remoteTrack) }
_state.value = _state.value.copy(remoteVideoTracks = nextTracks)
}
override fun onTrack(transceiver: RtpTransceiver?) {
val remoteTrack = transceiver?.receiver?.track() as? VideoTrack ?: return
val nextTracks = _state.value.remoteVideoTracks.toMutableMap().apply { put(callId, remoteTrack) }
_state.value = _state.value.copy(remoteVideoTracks = nextTracks)
}
override fun onSelectedCandidatePairChanged(event: CandidatePairChangeEvent?) = Unit
}
private suspend fun createOffer(callId: String, peerConnection: PeerConnection) {
val offer = peerConnection.createOfferAwait(
MediaConstraints().apply {
mandatory.add(MediaConstraints.KeyValuePair("OfferToReceiveAudio", "true"))
mandatory.add(MediaConstraints.KeyValuePair("OfferToReceiveVideo", "true"))
}
)
peerConnection.setLocalDescriptionAwait(offer)
socketClient.sendVideoSignal(
VideoSignalDto(
callId = callId,
signalType = "description",
description = VideoSessionDescriptionDto(
type = offer.type.canonicalForm(),
sdp = offer.description
)
)
)
}
private suspend fun createAnswer(callId: String, peerConnection: PeerConnection) {
val answer = peerConnection.createAnswerAwait(MediaConstraints())
peerConnection.setLocalDescriptionAwait(answer)
socketClient.sendVideoSignal(
VideoSignalDto(
callId = callId,
signalType = "description",
description = VideoSessionDescriptionDto(
type = answer.type.canonicalForm(),
sdp = answer.description
)
)
)
}
private suspend fun flushPendingCandidates(callId: String, peerConnection: PeerConnection) {
val candidates = pendingIceCandidates.remove(callId).orEmpty()
candidates.forEach { peerConnection.addIceCandidate(it) }
}
private fun createVideoCapturer(): CameraVideoCapturer? {
if (Camera2Enumerator.isSupported(appContext)) {
createCameraCapturer(Camera2Enumerator(appContext))?.let { return it }
}
return createCameraCapturer(Camera1Enumerator(false))
}
private fun createCameraCapturer(enumerator: CameraEnumerator): CameraVideoCapturer? {
enumerator.deviceNames.firstOrNull(enumerator::isFrontFacing)?.let { deviceName ->
enumerator.createCapturer(deviceName, null)?.let { return it }
}
enumerator.deviceNames.firstOrNull()?.let { deviceName ->
enumerator.createCapturer(deviceName, null)?.let { return it }
}
return null
}
}
private suspend fun PeerConnection.createOfferAwait(constraints: MediaConstraints): SessionDescription = suspendCoroutine { continuation ->
createOffer(object : SdpObserver {
override fun onCreateSuccess(desc: SessionDescription?) {
if (desc != null) continuation.resume(desc) else continuation.resumeWithException(IllegalStateException("Offer leer"))
}
override fun onSetSuccess() = Unit
override fun onCreateFailure(error: String?) = continuation.resumeWithException(IllegalStateException(error ?: "Offer fehlgeschlagen"))
override fun onSetFailure(error: String?) = Unit
}, constraints)
}
private suspend fun PeerConnection.createAnswerAwait(constraints: MediaConstraints): SessionDescription = suspendCoroutine { continuation ->
createAnswer(object : SdpObserver {
override fun onCreateSuccess(desc: SessionDescription?) {
if (desc != null) continuation.resume(desc) else continuation.resumeWithException(IllegalStateException("Answer leer"))
}
override fun onSetSuccess() = Unit
override fun onCreateFailure(error: String?) = continuation.resumeWithException(IllegalStateException(error ?: "Answer fehlgeschlagen"))
override fun onSetFailure(error: String?) = Unit
}, constraints)
}
private suspend fun PeerConnection.setLocalDescriptionAwait(description: SessionDescription): Unit = suspendCoroutine { continuation ->
setLocalDescription(object : SdpObserver {
override fun onCreateSuccess(desc: SessionDescription?) = Unit
override fun onSetSuccess() = continuation.resume(Unit)
override fun onCreateFailure(error: String?) = Unit
override fun onSetFailure(error: String?) = continuation.resumeWithException(IllegalStateException(error ?: "setLocalDescription fehlgeschlagen"))
}, description)
}
private suspend fun PeerConnection.setRemoteDescriptionAwait(description: SessionDescription): Unit = suspendCoroutine { continuation ->
setRemoteDescription(object : SdpObserver {
override fun onCreateSuccess(desc: SessionDescription?) = Unit
override fun onSetSuccess() = continuation.resume(Unit)
override fun onCreateFailure(error: String?) = Unit
override fun onSetFailure(error: String?) = continuation.resumeWithException(IllegalStateException(error ?: "setRemoteDescription fehlgeschlagen"))
}, description)
}
private fun List<VideoIceServerDto>.toNativeIceServers(): List<PeerConnection.IceServer> = mapNotNull { dto ->
if (dto.urls.isEmpty()) return@mapNotNull null
PeerConnection.IceServer.builder(dto.urls)
.apply {
dto.username?.let(::setUsername)
dto.credential?.let(::setPassword)
}
.createIceServer()
}
private val relayPattern = Pattern.compile("\\btyp\\s+(\\w+)\\b", Pattern.CASE_INSENSITIVE)
private fun IceCandidate.toDto(): VideoIceCandidateDto {
val type = relayPattern.matcher(sdp).let { matcher ->
if (matcher.find()) matcher.group(1) else null
}
return VideoIceCandidateDto(
candidate = sdp,
sdpMid = sdpMid,
sdpMLineIndex = sdpMLineIndex,
usernameFragment = serverUrl,
type = type
)
}
private fun VideoIceCandidateDto.toNativeIceCandidate(): IceCandidate =
IceCandidate(sdpMid, sdpMLineIndex, candidate)

View File

@@ -10,12 +10,16 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import de.ypchat.android.data.repository.ChatRepository
import de.ypchat.android.data.repository.ChatState
import de.ypchat.android.media.VideoMediaState
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody
import okhttp3.RequestBody.Companion.toRequestBody
import org.webrtc.EglBase
class ChatViewModel(private val repository: ChatRepository) : ViewModel() {
val state: StateFlow<ChatState> = repository.state
val videoMediaState: StateFlow<VideoMediaState> = repository.videoMediaState
val videoEglBaseContext: EglBase.Context = repository.videoEglBaseContext
init {
viewModelScope.launch { repository.restoreSession() }
@@ -111,6 +115,18 @@ class ChatViewModel(private val repository: ChatRepository) : ViewModel() {
fun blockCurrentUser() = state.value.currentConversation?.let(repository::blockUser)
fun unblockCurrentUser() = state.value.currentConversation?.let(repository::unblockUser)
fun setVideoConsent(allowed: Boolean) = repository.setVideoConsent(allowed)
fun inviteVideoCall() = repository.inviteVideoCall()
fun acceptVideoCall(callId: String) = repository.acceptVideoCall(callId)
fun rejectVideoCall(callId: String) = repository.rejectVideoCall(callId)
fun cancelVideoCall(callId: String) = repository.cancelVideoCall(callId)
fun endVideoCall(callId: String) = repository.endVideoCall(callId)
fun bringVideoToFront(callId: String) = repository.bringVideoToFront(callId)
fun minimizeForegroundVideo() = repository.minimizeForegroundVideo()
fun updateFloatingVideoPosition(x: Float, y: Float) = repository.updateFloatingVideoPosition(x, y)
fun toggleSelfMuted() = repository.toggleSelfMuted()
fun toggleSelfCameraEnabled() = repository.toggleSelfCameraEnabled()
fun setRuntimeError(message: String) = repository.setRuntimeError(message)
private companion object {
const val MAX_IMAGE_BYTES = 5 * 1024 * 1024

View File

@@ -10,16 +10,21 @@ import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.absoluteOffset
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxHeight
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.lazy.LazyRow
@@ -54,6 +59,7 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
@@ -64,10 +70,13 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import coil3.compose.AsyncImage
import de.ypchat.android.R
import de.ypchat.android.data.model.ChatMessageDto
@@ -77,12 +86,18 @@ import de.ypchat.android.data.model.PartnerLinkDto
import de.ypchat.android.data.model.UserDto
import de.ypchat.android.data.repository.ChatState
import de.ypchat.android.data.repository.CommandTableState
import de.ypchat.android.data.repository.VideoSessionState
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import java.io.File
import java.io.FileOutputStream
import kotlin.math.roundToInt
import org.webrtc.EglBase
import org.webrtc.RendererCommon
import org.webrtc.SurfaceViewRenderer
import org.webrtc.VideoTrack
enum class AppTab(val labelRes: Int) {
Online(R.string.tab_online),
@@ -773,6 +788,20 @@ private fun ChatScreen(state: ChatState, viewModel: ChatViewModel) {
var draft by remember { mutableStateOf("") }
var showSmileys by remember { mutableStateOf(false) }
val context = LocalContext.current
val videoMediaState by viewModel.videoMediaState.collectAsState()
val liveVideoSessions = remember(state.videoDockSessions) {
state.videoDockSessions.filter { it.status == "ringing" || it.status == "connecting" || it.status == "active" }
}
val currentVideoSession = remember(state.currentConversation, liveVideoSessions) {
liveVideoSessions.firstOrNull { it.withUserName == state.currentConversation }
}
val foregroundSession = remember(state.foregroundVideoSessionId, liveVideoSessions) {
liveVideoSessions.firstOrNull { it.callId == state.foregroundVideoSessionId }
}
val canStartVideo = state.currentConversation != null &&
state.videoConsent.videoVisible &&
currentVideoSession == null &&
!state.maxVideoConnectionsReached
val imagePicker = rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri ->
if (uri != null) viewModel.sendImage(context, uri)
}
@@ -795,95 +824,623 @@ private fun ChatScreen(state: ChatState, viewModel: ChatViewModel) {
viewModel.setImageUploadMessage("Camera permission denied")
}
}
var pendingVideoAction by remember { mutableStateOf<(() -> Unit)?>(null) }
val videoPermissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { grants ->
val allGranted = grants[Manifest.permission.CAMERA] == true && grants[Manifest.permission.RECORD_AUDIO] == true
val action = pendingVideoAction
pendingVideoAction = null
if (allGranted) {
action?.invoke()
} else {
viewModel.setRuntimeError("Camera and microphone permission are required for video chat")
}
}
Column(modifier = Modifier.fillMaxSize()) {
Row(modifier = Modifier.fillMaxWidth().padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
TextButton(onClick = viewModel::closeConversation) { Text(stringResource(R.string.back)) }
Text(state.currentConversation.orEmpty(), fontWeight = FontWeight.Bold, modifier = Modifier.weight(1f))
TextButton(onClick = viewModel::blockCurrentUser) { Text(stringResource(R.string.block)) }
TextButton(onClick = viewModel::unblockCurrentUser) { Text(stringResource(R.string.unblock)) }
fun runVideoAction(action: () -> Unit) {
val hasCamera = context.checkSelfPermission(Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED
val hasAudio = context.checkSelfPermission(Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED
if (hasCamera && hasAudio) {
action()
} else {
pendingVideoAction = action
videoPermissionLauncher.launch(arrayOf(Manifest.permission.CAMERA, Manifest.permission.RECORD_AUDIO))
}
HorizontalDivider()
LazyColumn(modifier = Modifier.weight(1f).padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
items(state.messages) { message -> MessageBubble(message, state.currentUser?.userName.orEmpty()) }
}
if (state.isUploadingImage || !state.imageUploadMessage.isNullOrBlank()) {
UploadStatusBanner(state)
}
Row(modifier = Modifier.fillMaxWidth().padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
OutlinedTextField(
draft,
{ draft = it },
modifier = Modifier.weight(1f),
placeholder = { Text(stringResource(R.string.message_placeholder)) },
colors = appTextFieldColors(),
enabled = !state.isUploadingImage
)
Spacer(Modifier.width(8.dp))
IconImageButton(
iconRes = R.drawable.smileys_button,
contentDescription = stringResource(R.string.button_smileys),
onClick = { showSmileys = !showSmileys },
enabled = !state.isUploadingImage
)
Spacer(Modifier.width(8.dp))
IconImageButton(
iconRes = R.drawable.image_button,
contentDescription = stringResource(R.string.button_image),
onClick = { imagePicker.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) },
enabled = !state.isUploadingImage
)
Spacer(Modifier.width(8.dp))
TextButton(
onClick = {
if (context.checkSelfPermission(Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) {
cameraLauncher.launch(null)
} else {
cameraPermissionLauncher.launch(Manifest.permission.CAMERA)
}
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
val showRightDock = liveVideoSessions.isNotEmpty() && maxWidth >= 900.dp
Box(modifier = Modifier.fillMaxSize()) {
Row(modifier = Modifier.fillMaxSize()) {
Column(modifier = Modifier.weight(1f).fillMaxHeight()) {
Row(modifier = Modifier.fillMaxWidth().padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
TextButton(onClick = viewModel::closeConversation) { Text(stringResource(R.string.back)) }
Text(state.currentConversation.orEmpty(), fontWeight = FontWeight.Bold, modifier = Modifier.weight(1f))
TextButton(onClick = viewModel::blockCurrentUser) { Text(stringResource(R.string.block)) }
TextButton(onClick = viewModel::unblockCurrentUser) { Text(stringResource(R.string.unblock)) }
}
},
enabled = !state.isUploadingImage,
colors = ButtonDefaults.textButtonColors(
containerColor = Primary100,
contentColor = Primary700,
disabledContainerColor = SurfaceSubtle,
disabledContentColor = TextMuted
)
) {
Text(stringResource(R.string.button_camera), fontWeight = FontWeight.Bold)
}
Spacer(Modifier.width(8.dp))
Button(
onClick = { viewModel.sendMessage(draft); draft = "" },
colors = ButtonDefaults.buttonColors(containerColor = Primary600),
enabled = draft.isNotBlank() && !state.isUploadingImage
) {
Text(stringResource(R.string.button_send))
}
}
if (showSmileys) {
LazyRow(modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
items(SmileyItems) { smiley ->
Text(
text = smileyEmoji(smiley.hexCode),
modifier = Modifier
.background(Primary100, RoundedCornerShape(999.dp))
.clickable {
draft += smiley.token
showSmileys = false
HorizontalDivider()
if (state.currentConversation != null) {
VideoConversationHeader(
state = state,
currentVideoSession = currentVideoSession,
canStartVideo = canStartVideo,
onToggleConsent = { viewModel.setVideoConsent(!state.videoConsent.localConsent) },
onInvite = { runVideoAction(viewModel::inviteVideoCall) }
)
}
if (currentVideoSession != null) {
VideoSessionBanner(
session = currentVideoSession,
currentUserName = state.currentUser?.userName.orEmpty(),
onAccept = { callId -> runVideoAction { viewModel.acceptVideoCall(callId) } },
onReject = viewModel::rejectVideoCall,
onCancel = viewModel::cancelVideoCall,
onBringToFront = viewModel::bringVideoToFront
)
}
LazyColumn(modifier = Modifier.weight(1f).padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
items(state.messages) { message -> MessageBubble(message, state.currentUser?.userName.orEmpty()) }
}
if (!showRightDock && liveVideoSessions.isNotEmpty()) {
VideoDockRow(
state = state,
sessions = liveVideoSessions,
viewModel = viewModel,
videoMediaState = videoMediaState,
eglBaseContext = viewModel.videoEglBaseContext,
onAccept = { callId -> runVideoAction { viewModel.acceptVideoCall(callId) } }
)
}
if (state.isUploadingImage || !state.imageUploadMessage.isNullOrBlank()) {
UploadStatusBanner(state)
}
Row(modifier = Modifier.fillMaxWidth().padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
OutlinedTextField(
draft,
{ draft = it },
modifier = Modifier.weight(1f),
placeholder = { Text(stringResource(R.string.message_placeholder)) },
colors = appTextFieldColors(),
enabled = !state.isUploadingImage
)
Spacer(Modifier.width(8.dp))
IconImageButton(
iconRes = R.drawable.smileys_button,
contentDescription = stringResource(R.string.button_smileys),
onClick = { showSmileys = !showSmileys },
enabled = !state.isUploadingImage
)
Spacer(Modifier.width(8.dp))
IconImageButton(
iconRes = R.drawable.image_button,
contentDescription = stringResource(R.string.button_image),
onClick = { imagePicker.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) },
enabled = !state.isUploadingImage
)
Spacer(Modifier.width(8.dp))
TextButton(
onClick = {
if (context.checkSelfPermission(Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) {
cameraLauncher.launch(null)
} else {
cameraPermissionLauncher.launch(Manifest.permission.CAMERA)
}
},
enabled = !state.isUploadingImage,
colors = ButtonDefaults.textButtonColors(
containerColor = Primary100,
contentColor = Primary700,
disabledContainerColor = SurfaceSubtle,
disabledContentColor = TextMuted
)
) {
Text(stringResource(R.string.button_camera), fontWeight = FontWeight.Bold)
}
Spacer(Modifier.width(8.dp))
Button(
onClick = { viewModel.sendMessage(draft); draft = "" },
colors = ButtonDefaults.buttonColors(containerColor = Primary600),
enabled = draft.isNotBlank() && !state.isUploadingImage
) {
Text(stringResource(R.string.button_send))
}
}
if (showSmileys) {
LazyRow(modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
items(SmileyItems) { smiley ->
Text(
text = smileyEmoji(smiley.hexCode),
modifier = Modifier
.background(Primary100, RoundedCornerShape(999.dp))
.clickable {
draft += smiley.token
showSmileys = false
}
.padding(horizontal = 12.dp, vertical = 8.dp),
color = Primary700,
fontWeight = FontWeight.Bold
)
}
.padding(horizontal = 12.dp, vertical = 8.dp),
color = Primary700,
fontWeight = FontWeight.Bold
}
}
state.errorMessage?.let { Text(localizeRuntimeMessage(it), modifier = Modifier.padding(horizontal = 12.dp), color = Danger) }
Spacer(Modifier.height(8.dp))
}
if (showRightDock) {
VideoDockSidebar(
state = state,
sessions = liveVideoSessions,
viewModel = viewModel,
videoMediaState = videoMediaState,
eglBaseContext = viewModel.videoEglBaseContext,
onAccept = { callId -> runVideoAction { viewModel.acceptVideoCall(callId) } }
)
}
}
if (foregroundSession != null) {
FloatingVideoWindow(
state = state,
session = foregroundSession,
viewModel = viewModel,
videoMediaState = videoMediaState,
eglBaseContext = viewModel.videoEglBaseContext,
modifier = Modifier.absoluteOffset {
IntOffset(state.floatingVideoOffsetX.roundToInt(), state.floatingVideoOffsetY.roundToInt())
}
)
}
}
state.errorMessage?.let { Text(localizeRuntimeMessage(it), modifier = Modifier.padding(horizontal = 12.dp), color = Danger) }
Spacer(Modifier.height(8.dp))
}
}
@Composable
private fun VideoConversationHeader(
state: ChatState,
currentVideoSession: VideoSessionState?,
canStartVideo: Boolean,
onToggleConsent: () -> Unit,
onInvite: () -> Unit
) {
Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 10.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
TextButton(
onClick = onToggleConsent,
colors = ButtonDefaults.textButtonColors(
containerColor = if (state.videoConsent.localConsent) SurfaceSoftGreen else Primary100,
contentColor = Primary700
)
) {
Text(
stringResource(
if (state.videoConsent.localConsent) R.string.button_video_allowed else R.string.button_video_allow
),
fontWeight = FontWeight.Bold
)
}
if (state.videoConsent.videoVisible) {
Button(
onClick = onInvite,
enabled = canStartVideo,
colors = ButtonDefaults.buttonColors(containerColor = Primary600)
) {
Text(stringResource(R.string.button_video_open))
}
}
}
Card(
colors = CardDefaults.cardColors(containerColor = SurfaceSubtle),
shape = RoundedCornerShape(12.dp)
) {
Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 10.dp)) {
Text(
text = if (state.videoConsent.remoteConsent) {
stringResource(R.string.video_status_partner_allowed)
} else {
stringResource(R.string.video_status_partner_pending)
},
color = TextMuted
)
if (state.maxVideoConnectionsReached) {
Text(stringResource(R.string.video_status_capacity_reached), color = Danger, fontWeight = FontWeight.Bold)
} else if (currentVideoSession != null) {
Text(videoStatusLabel(currentVideoSession.status), color = Primary700, fontWeight = FontWeight.Bold)
}
}
}
}
}
@Composable
private fun VideoSessionBanner(
session: VideoSessionState,
currentUserName: String,
onAccept: (String) -> Unit,
onReject: (String) -> Unit,
onCancel: (String) -> Unit,
onBringToFront: (String) -> Unit
) {
Card(
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp),
colors = CardDefaults.cardColors(containerColor = SurfaceSubtle),
shape = RoundedCornerShape(12.dp)
) {
Column(modifier = Modifier.fillMaxWidth().padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(session.withUserName.orEmpty(), color = TextStrong, fontWeight = FontWeight.Bold)
Text(
"${videoStatusLabel(session.status)} · ${
if (session.remoteMuted) stringResource(R.string.video_mic_off) else stringResource(R.string.video_mic_on)
}",
color = TextMuted
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
if (session.status == "ringing" && session.initiatedBy != currentUserName) {
Button(onClick = { onAccept(session.callId) }, colors = ButtonDefaults.buttonColors(containerColor = Primary600)) {
Text(stringResource(R.string.button_video_accept))
}
TextButton(onClick = { onReject(session.callId) }, colors = ButtonDefaults.textButtonColors(containerColor = SurfaceSoftRed, contentColor = Danger)) {
Text(stringResource(R.string.button_video_reject), fontWeight = FontWeight.Bold)
}
} else if (session.status == "ringing" && session.initiatedBy == currentUserName) {
TextButton(onClick = { onCancel(session.callId) }, colors = ButtonDefaults.textButtonColors(containerColor = Primary100, contentColor = Primary700)) {
Text(stringResource(R.string.button_video_cancel), fontWeight = FontWeight.Bold)
}
} else if (session.status == "connecting" || session.status == "active") {
TextButton(onClick = { onBringToFront(session.callId) }, colors = ButtonDefaults.textButtonColors(containerColor = Primary100, contentColor = Primary700)) {
Text(stringResource(R.string.button_video_foreground), fontWeight = FontWeight.Bold)
}
}
}
}
}
}
@Composable
private fun VideoDockSidebar(
state: ChatState,
sessions: List<VideoSessionState>,
viewModel: ChatViewModel,
videoMediaState: de.ypchat.android.media.VideoMediaState,
eglBaseContext: EglBase.Context,
onAccept: (String) -> Unit
) {
Column(
modifier = Modifier
.width(220.dp)
.fillMaxHeight()
.background(Color(0xFFF0F4F1))
.padding(10.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
VideoSelfPreviewCard(state, videoMediaState.localVideoTrack, eglBaseContext)
sessions.take(3).forEach { session ->
VideoPartnerPreviewCard(
session = session,
currentUserName = state.currentUser?.userName.orEmpty(),
viewModel = viewModel,
remoteVideoTrack = videoMediaState.remoteVideoTracks[session.callId],
eglBaseContext = eglBaseContext,
onAccept = onAccept
)
}
}
}
@Composable
private fun VideoDockRow(
state: ChatState,
sessions: List<VideoSessionState>,
viewModel: ChatViewModel,
videoMediaState: de.ypchat.android.media.VideoMediaState,
eglBaseContext: EglBase.Context,
onAccept: (String) -> Unit
) {
LazyRow(
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
item { VideoSelfPreviewCard(state, videoMediaState.localVideoTrack, eglBaseContext, modifier = Modifier.width(170.dp)) }
items(sessions.take(3)) { session ->
VideoPartnerPreviewCard(
session = session,
currentUserName = state.currentUser?.userName.orEmpty(),
viewModel = viewModel,
remoteVideoTrack = videoMediaState.remoteVideoTracks[session.callId],
eglBaseContext = eglBaseContext,
onAccept = onAccept,
modifier = Modifier.width(190.dp)
)
}
}
}
@Composable
private fun VideoSelfPreviewCard(
state: ChatState,
localVideoTrack: VideoTrack?,
eglBaseContext: EglBase.Context,
modifier: Modifier = Modifier
) {
Card(modifier = modifier, shape = RoundedCornerShape(14.dp), colors = CardDefaults.cardColors(containerColor = SurfaceColor)) {
Column {
Box(
modifier = Modifier
.fillMaxWidth()
.height(120.dp)
.background(Color(0xFF111916)),
contentAlignment = Alignment.Center
) {
if (localVideoTrack != null && state.selfCameraEnabled) {
WebRtcVideoSurface(
videoTrack = localVideoTrack,
eglBaseContext = eglBaseContext,
mirror = true,
modifier = Modifier.fillMaxSize()
)
} else {
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(stringResource(R.string.video_self_preview), color = Color.White, fontWeight = FontWeight.Bold)
Text(
if (state.selfCameraEnabled) stringResource(R.string.video_mic_on) else stringResource(R.string.video_self_preview_camera_off),
color = Color(0xFFD6E2DA)
)
}
}
}
Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp)) {
Text(stringResource(R.string.video_self_preview), color = TextStrong, fontWeight = FontWeight.Bold)
Text(
if (state.selfMuted) stringResource(R.string.video_mic_off) else stringResource(R.string.video_mic_on),
color = TextMuted
)
}
}
}
}
@Composable
private fun VideoPartnerPreviewCard(
session: VideoSessionState,
currentUserName: String,
viewModel: ChatViewModel,
remoteVideoTrack: VideoTrack?,
eglBaseContext: EglBase.Context,
onAccept: (String) -> Unit,
modifier: Modifier = Modifier
) {
Card(modifier = modifier, shape = RoundedCornerShape(14.dp), colors = CardDefaults.cardColors(containerColor = SurfaceColor)) {
Column {
Box(
modifier = Modifier
.fillMaxWidth()
.height(120.dp)
.background(Color(0xFF101712)),
contentAlignment = Alignment.Center
) {
if (remoteVideoTrack != null) {
WebRtcVideoSurface(
videoTrack = remoteVideoTrack,
eglBaseContext = eglBaseContext,
mirror = false,
modifier = Modifier.fillMaxSize()
)
} else {
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(session.withUserName.orEmpty(), color = Color.White, fontWeight = FontWeight.Bold)
Text(videoStatusLabel(session.status), color = Color(0xFFD6E2DA))
}
}
}
Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(session.withUserName.orEmpty(), color = TextStrong, fontWeight = FontWeight.Bold)
Text(
if (session.remoteMuted) stringResource(R.string.video_mic_off) else stringResource(R.string.video_mic_on),
color = TextMuted
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
TextButton(
onClick = { viewModel.bringVideoToFront(session.callId) },
colors = ButtonDefaults.textButtonColors(containerColor = Primary100, contentColor = Primary700)
) {
Text(stringResource(R.string.button_video_foreground), fontWeight = FontWeight.Bold)
}
when {
session.status == "ringing" && session.initiatedBy != currentUserName -> {
TextButton(
onClick = { onAccept(session.callId) },
colors = ButtonDefaults.textButtonColors(containerColor = SurfaceSoftGreen, contentColor = Primary700)
) {
Text(stringResource(R.string.button_video_accept), fontWeight = FontWeight.Bold)
}
}
session.status == "ringing" && session.initiatedBy == currentUserName -> {
TextButton(
onClick = { viewModel.cancelVideoCall(session.callId) },
colors = ButtonDefaults.textButtonColors(containerColor = Primary100, contentColor = Primary700)
) {
Text(stringResource(R.string.button_video_cancel), fontWeight = FontWeight.Bold)
}
}
session.status == "connecting" || session.status == "active" -> {
TextButton(
onClick = { viewModel.endVideoCall(session.callId) },
colors = ButtonDefaults.textButtonColors(containerColor = SurfaceSoftRed, contentColor = Danger)
) {
Text(stringResource(R.string.button_video_end), fontWeight = FontWeight.Bold)
}
}
}
}
}
}
}
}
@Composable
private fun FloatingVideoWindow(
state: ChatState,
session: VideoSessionState,
viewModel: ChatViewModel,
videoMediaState: de.ypchat.android.media.VideoMediaState,
eglBaseContext: EglBase.Context,
modifier: Modifier = Modifier
) {
Card(
modifier = modifier.width(300.dp),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = SurfaceColor)
) {
Column {
Row(
modifier = Modifier
.fillMaxWidth()
.background(Color(0xFF19211D))
.pointerInput(state.floatingVideoOffsetX, state.floatingVideoOffsetY) {
detectDragGestures { change, dragAmount ->
change.consume()
viewModel.updateFloatingVideoPosition(
state.floatingVideoOffsetX + dragAmount.x,
state.floatingVideoOffsetY + dragAmount.y
)
}
}
.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(session.withUserName.orEmpty(), color = Color.White, fontWeight = FontWeight.Bold)
Text(
if (session.remoteMuted) stringResource(R.string.video_mic_off) else stringResource(R.string.video_mic_on),
color = Color(0xFFD1DED6)
)
}
TextButton(onClick = viewModel::minimizeForegroundVideo) { Text(stringResource(R.string.button_video_minimize), color = Color.White) }
}
Box(
modifier = Modifier
.fillMaxWidth()
.height(190.dp)
.background(Color(0xFF0A110D))
) {
val remoteTrack = videoMediaState.remoteVideoTracks[session.callId]
if (remoteTrack != null) {
WebRtcVideoSurface(
videoTrack = remoteTrack,
eglBaseContext = eglBaseContext,
mirror = false,
modifier = Modifier.fillMaxSize()
)
} else {
Column(
modifier = Modifier.align(Alignment.Center),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
Text(session.withUserName.orEmpty(), color = Color.White, fontWeight = FontWeight.Bold)
Text(videoStatusLabel(session.status), color = Color(0xFFD6E2DA))
}
}
Card(
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(12.dp)
.size(width = 110.dp, height = 82.dp),
shape = RoundedCornerShape(12.dp),
colors = CardDefaults.cardColors(containerColor = Color(0xFF1B2721))
) {
val localTrack = videoMediaState.localVideoTrack
if (localTrack != null && state.selfCameraEnabled) {
WebRtcVideoSurface(
videoTrack = localTrack,
eglBaseContext = eglBaseContext,
mirror = true,
modifier = Modifier.fillMaxSize()
)
} else {
Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) {
Text(stringResource(R.string.video_self_preview), color = Color.White, fontWeight = FontWeight.Bold)
}
}
}
}
Column(modifier = Modifier.fillMaxWidth().padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
if (session.remoteMuted) stringResource(R.string.video_partner_mic_off) else stringResource(R.string.video_partner_mic_on),
color = TextMuted
)
Text(
if (state.selfMuted) stringResource(R.string.video_self_mic_off) else stringResource(R.string.video_self_mic_on),
color = TextMuted
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
TextButton(
onClick = viewModel::toggleSelfMuted,
colors = ButtonDefaults.textButtonColors(containerColor = Primary100, contentColor = Primary700)
) {
Text(
stringResource(if (state.selfMuted) R.string.button_video_unmute else R.string.button_video_mute),
fontWeight = FontWeight.Bold
)
}
TextButton(
onClick = viewModel::toggleSelfCameraEnabled,
colors = ButtonDefaults.textButtonColors(containerColor = Primary100, contentColor = Primary700)
) {
Text(
stringResource(if (state.selfCameraEnabled) R.string.button_video_camera_off else R.string.button_video_camera_on),
fontWeight = FontWeight.Bold
)
}
TextButton(
onClick = { viewModel.endVideoCall(session.callId) },
colors = ButtonDefaults.textButtonColors(containerColor = SurfaceSoftRed, contentColor = Danger)
) {
Text(stringResource(R.string.button_video_end), fontWeight = FontWeight.Bold)
}
}
}
}
}
}
@Composable
private fun WebRtcVideoSurface(
videoTrack: VideoTrack,
eglBaseContext: EglBase.Context,
mirror: Boolean,
modifier: Modifier = Modifier
) {
AndroidView(
modifier = modifier,
factory = { context ->
SurfaceViewRenderer(context).apply {
init(eglBaseContext, null)
setEnableHardwareScaler(true)
setMirror(mirror)
setScalingType(RendererCommon.ScalingType.SCALE_ASPECT_FILL)
videoTrack.addSink(this)
}
},
update = { renderer ->
renderer.setMirror(mirror)
}
)
DisposableEffect(videoTrack) {
onDispose {}
}
}
@Composable
private fun videoStatusLabel(status: String): String = when (status) {
"ringing" -> stringResource(R.string.video_status_ringing)
"connecting" -> stringResource(R.string.video_status_connecting)
"active" -> stringResource(R.string.video_status_active)
else -> status
}
private fun saveCameraBitmap(context: Context, bitmap: Bitmap): Uri? {
return runCatching {
val file = File(context.cacheDir, "singlechat-photo-${System.currentTimeMillis()}.jpg")
@@ -1092,6 +1649,14 @@ private fun localizeRuntimeMessage(message: String): String {
stringResource(R.string.camera_permission_denied)
message == "Camera capture failed" ->
stringResource(R.string.camera_capture_failed)
message == "Videochat ist erst nach beidseitiger Freigabe sichtbar." ->
stringResource(R.string.video_consent_required)
message == "Maximal drei Videoverbindungen gleichzeitig erlaubt." ->
stringResource(R.string.video_capacity_reached)
message == "Der Gesprächspartner hat bereits die maximale Anzahl an Videoverbindungen erreicht." ->
stringResource(R.string.video_partner_capacity_reached)
message == "Für diesen Gesprächspartner existiert bereits ein laufender Videochat." ->
stringResource(R.string.video_call_exists)
message.endsWith(" blocked") ->
stringResource(R.string.user_blocked, message.removeSuffix(" blocked"))
message.endsWith(" unblocked") ->

View File

@@ -52,6 +52,33 @@
<string name="button_camera">Photo</string>
<string name="button_send">Send</string>
<string name="button_smileys">Smileys</string>
<string name="button_video_allow">Allow video</string>
<string name="button_video_allowed">Video allowed</string>
<string name="button_video_open">Open video chat</string>
<string name="button_video_foreground">Bring forward</string>
<string name="button_video_accept">Accept</string>
<string name="button_video_reject">Reject</string>
<string name="button_video_cancel">Cancel</string>
<string name="button_video_end">End</string>
<string name="button_video_minimize">Minimize</string>
<string name="button_video_mute">Mute microphone</string>
<string name="button_video_unmute">Unmute microphone</string>
<string name="button_video_camera_off">Camera off</string>
<string name="button_video_camera_on">Camera on</string>
<string name="video_status_partner_allowed">Partner allowed video</string>
<string name="video_status_partner_pending">Partner has not allowed video yet</string>
<string name="video_status_capacity_reached">Maximum of three video connections allowed</string>
<string name="video_self_preview">You</string>
<string name="video_self_preview_camera_off">Camera inactive</string>
<string name="video_mic_on">Microphone on</string>
<string name="video_mic_off">Microphone off</string>
<string name="video_partner_mic_on">Partner: microphone on</string>
<string name="video_partner_mic_off">Partner: microphone off</string>
<string name="video_self_mic_on">You: microphone on</string>
<string name="video_self_mic_off">You: microphone off</string>
<string name="video_status_ringing">Ringing</string>
<string name="video_status_connecting">Connecting</string>
<string name="video_status_active">Active</string>
<string name="image_message">Image message</string>
<string name="image_upload_in_progress">Uploading image...</string>
<string name="image_upload_success">Image uploaded.</string>
@@ -65,6 +92,10 @@
<string name="countries_load_error">Country list could not be loaded: %1$s</string>
<string name="user_blocked">%1$s has been blocked</string>
<string name="user_unblocked">%1$s has been unblocked</string>
<string name="video_consent_required">Video chat becomes visible after both users allow it.</string>
<string name="video_capacity_reached">Maximum of three video connections allowed.</string>
<string name="video_partner_capacity_reached">The partner already reached the maximum number of video connections.</string>
<string name="video_call_exists">A video chat with this partner already exists.</string>
<string name="feedback_title">Feedback</string>
<string name="feedback_comment">Comment</string>
<string name="feedback_send">Send feedback</string>