This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# YPChat Android
|
||||
|
||||
Native Android-App fuer den bestehenden SingleChat/YPChat-Server.
|
||||
Native Android-App fuer den bestehenden YPChat-Server.
|
||||
|
||||
## Stack
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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() }
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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") ->
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user