Merge branch 'master' into nd/android-attach-sheet-keyboard

This commit is contained in:
Narasimha-sc
2026-09-30 15:45:18 +00:00
306 changed files with 39394 additions and 1193 deletions
@@ -0,0 +1,6 @@
package chat.simplex.common.platform
import androidx.compose.ui.graphics.drawscope.DrawScope
// Android blurs the bars with a RenderEffect on the layer itself, so this is never called there.
actual fun DrawScope.drawBarsBlurred(radiusPx: Float, barWidth: Float, barHeight: Float, drawBar: DrawScope.() -> Unit) = drawBar()
@@ -132,7 +132,7 @@ actual fun ActiveCallView() {
}
Box(Modifier.fillMaxSize()) {
WebRTCView(chatModel.callCommand) { apiMsg ->
Log.d(TAG, "received from WebRTCView: $apiMsg")
Log.d(TAG, "received from WebRTCView: ${apiMsg.resp.javaClass.simpleName}")
val call = chatModel.activeCall.value
if (call != null) {
val callState = call.androidCallState as ActiveCallState
@@ -711,7 +711,7 @@ fun WebRTCView(callCommand: SnapshotStateList<WCallCommand>, onResponse: (WVAPIM
.collect {
while (callCommand.isNotEmpty()) {
val cmd = callCommand.removeFirstOrNull()
Log.d(TAG, "WebRTCView LaunchedEffect executing $cmd")
Log.d(TAG, "WebRTCView LaunchedEffect executing ${cmd?.javaClass?.simpleName}")
if (cmd != null) {
processCommand(wv, cmd)
}
@@ -785,7 +785,7 @@ class WebRTCInterface(private val onResponse: (WVAPIMessage) -> Unit) {
// onResponse(message)
onResponse(json.decodeFromString(message))
} catch (e: Exception) {
Log.e(TAG, "failed parsing WebView message: $message")
Log.e(TAG, "failed parsing WebView message")
}
}
}
@@ -269,6 +269,7 @@ object AppLock {
fun elapsedRealtime(): Long = System.nanoTime() / 1_000_000
fun recheckAuthState() {
if (ChatModel.showCallView.value) return
val enteredBackgroundVal = enteredBackground.value
val delay = ChatController.appPrefs.laLockDelay.get()
if (enteredBackgroundVal == null || elapsedRealtime() - enteredBackgroundVal >= delay * 1000) {
@@ -1931,7 +1931,7 @@ data class Contact(
}
val isContactCard: Boolean get() =
(activeConn == null || activeConn.connStatus == ConnStatus.Prepared) && profile.contactLink != null && active && preparedContact == null && contactRequestId == null
(activeConn == null || activeConn.connStatus == ConnStatus.Prepared) && profile.contactLink != null && active && preparedContact == null && contactRequestId == null && groupDirectInv == null
val isBot: Boolean get() = profile.peerType == ChatPeerType.Bot
@@ -3555,6 +3555,7 @@ data class ChatItem (
is RcvGroupEvent.MemberCreatedContact -> false
is RcvGroupEvent.MemberProfileUpdated -> false
is RcvGroupEvent.NewMemberPendingReview -> true
is RcvGroupEvent.MsgBadSignature -> false
}
is CIContent.SndGroupEventContent -> false
is CIContent.RcvConnEventContent -> false
@@ -5461,6 +5462,7 @@ sealed class RcvGroupEvent() {
@Serializable @SerialName("memberCreatedContact") class MemberCreatedContact(): RcvGroupEvent()
@Serializable @SerialName("memberProfileUpdated") class MemberProfileUpdated(val fromProfile: Profile, val toProfile: Profile): RcvGroupEvent()
@Serializable @SerialName("newMemberPendingReview") class NewMemberPendingReview(): RcvGroupEvent()
@Serializable @SerialName("msgBadSignature") class MsgBadSignature(): RcvGroupEvent()
val text: String get() = text(isChannel = false)
@@ -5485,6 +5487,7 @@ sealed class RcvGroupEvent() {
is MemberCreatedContact -> generalGetString(MR.strings.rcv_group_event_member_created_contact)
is MemberProfileUpdated -> profileUpdatedText(fromProfile, toProfile)
is NewMemberPendingReview -> generalGetString(MR.strings.rcv_group_event_new_member_pending_review)
is MsgBadSignature -> generalGetString(MR.strings.rcv_group_event_msg_bad_signature)
}
private fun profileUpdatedText(from: Profile, to: Profile): String =
@@ -941,7 +941,7 @@ object ChatController {
val r = json.decodeFromString<API>(rStr)
if (log) {
Log.d(TAG, "sendCmd response type ${r.responseType}")
if (r is API.Result && (r.res is CR.Response || r.res is CR.Invalid)) {
if (r is API.Result && ((r.res is CR.Response && !r.res.type.startsWith("call")) || r.res is CR.Invalid)) {
Log.d(TAG, "sendCmd response json $rStr")
}
chatModel.addTerminalItem(TerminalItem.resp(rhId, r))
@@ -958,7 +958,7 @@ object ChatController {
} else {
val r = json.decodeFromString<API>(rStr)
Log.d(TAG, "chatRecvMsg: ${r.responseType}")
if (r is API.Result && (r.res is CR.Response || r.res is CR.Invalid)) Log.d(TAG, "chatRecvMsg json: $rStr")
if (r is API.Result && ((r.res is CR.Response && !r.res.type.startsWith("call")) || r.res is CR.Invalid)) Log.d(TAG, "chatRecvMsg json: $rStr")
r
}
}
@@ -3367,10 +3367,9 @@ object ChatController {
// TODO askConfirmation?
// TODO check encryption is compatible
withCall(r, r.contact) { call ->
chatModel.activeCall.value = call.copy(callState = CallState.OfferReceived, sharedKey = r.sharedKey)
chatModel.activeCall.value = call.copy(callState = CallState.OfferReceived, hasSharedKey = r.sharedKey != null)
val useRelay = appPrefs.webrtcPolicyRelay.get()
val iceServers = getIceServers()
Log.d(TAG, ".callOffer iceServers $iceServers")
chatModel.callCommand.add(WCallCommand.Offer(
offer = r.offer.rtcSession,
iceCandidates = r.offer.rtcIceCandidates,
@@ -7215,9 +7214,9 @@ sealed class CR {
is SndStandaloneFileComplete -> withUser(user, rcvURIs.size.toString())
is SndFileError -> withUser(user, "errorMessage: ${json.encodeToString(errorMessage)}\nchatItem: ${json.encodeToString(chatItem_)}")
is SndFileWarning -> withUser(user, "errorMessage: ${json.encodeToString(errorMessage)}\nchatItem: ${json.encodeToString(chatItem_)}")
is CallInvitations -> "callInvitations: ${json.encodeToString(callInvitations)}"
is CallInvitation -> "contact: ${callInvitation.contact.id}\ncallType: $callInvitation.callType\nsharedKey: ${callInvitation.sharedKey ?: ""}"
is CallOffer -> withUser(user, "contact: ${contact.id}\ncallType: $callType\nsharedKey: ${sharedKey ?: ""}\naskConfirmation: $askConfirmation\noffer: ${json.encodeToString(offer)}")
is CallInvitations -> "callInvitations: ${json.encodeToString(callInvitations.map { it.copy(sharedKey = null) })}"
is CallInvitation -> "contact: ${callInvitation.contact.id}\ncallType: ${callInvitation.callType}"
is CallOffer -> withUser(user, "contact: ${contact.id}\ncallType: $callType\naskConfirmation: $askConfirmation\noffer: ${json.encodeToString(offer)}")
is CallAnswer -> withUser(user, "contact: ${contact.id}\nanswer: ${json.encodeToString(answer)}")
is CallExtraInfo -> withUser(user, "contact: ${contact.id}\nextraInfo: ${json.encodeToString(extraInfo)}")
is CallEnded -> withUser(user, "contact: ${contact.id}")
@@ -0,0 +1,6 @@
package chat.simplex.common.platform
import androidx.compose.ui.graphics.drawscope.DrawScope
// Draws the bars' background blurred. The drawing to blur is given in bar coordinates, whatever size the blur runs at.
expect fun DrawScope.drawBarsBlurred(radiusPx: Float, barWidth: Float, barHeight: Float, drawBar: DrawScope.() -> Unit)
@@ -1,18 +1,18 @@
package chat.simplex.common.platform
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.BlurredEdgeTreatment
import androidx.compose.ui.draw.blur
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.IntSize
import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.views.helpers.KeyChangeEffect
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.filter
import java.io.File
import kotlin.math.roundToInt
@Composable
expect fun Modifier.desktopOnExternalDrag(
@@ -58,20 +58,23 @@ fun Modifier.desktopModifyBlurredState(enabled: Boolean, blurred: MutableState<B
}
}
@Composable
fun blurHidesMedia(enabled: Boolean, blurred: State<Boolean>): Boolean =
enabled && blurred.value && remember { appPrefs.privacyMediaBlurRadius.state }.value > 0
@Composable
fun Modifier.privacyBlur(
enabled: Boolean,
preview: ImageBitmap,
blurred: MutableState<Boolean> = remember { mutableStateOf(appPrefs.privacyMediaBlurRadius.get() > 0) },
scrollState: State<Boolean>,
onLongClick: () -> Unit = {}
): Modifier {
val blurRadius = remember { appPrefs.privacyMediaBlurRadius.state }
return if (enabled && blurred.value) {
this then Modifier.blur(
radiusX = remember { appPrefs.privacyMediaBlurRadius.state }.value.dp,
radiusY = remember { appPrefs.privacyMediaBlurRadius.state }.value.dp,
edgeTreatment = BlurredEdgeTreatment(RoundedCornerShape(0.dp))
)
return if (blurHidesMedia(enabled, blurred)) {
val blurredPreview = remember(preview, blurRadius.value) { preview.blurredBy(blurRadius.value) }
this then Modifier
.drawWithContent { drawImage(blurredPreview, dstSize = IntSize(size.width.roundToInt(), size.height.roundToInt())) }
.combinedClickable(
onLongClick = onLongClick,
onClick = {
@@ -90,3 +93,25 @@ fun Modifier.privacyBlur(
this
}
}
// Calibrated so the resample blurs as much as Modifier.blur did at each radius; 360 read 5-75% stronger.
private const val BLURRED_MEDIA_WIDTH_DP = 400
// Bounds the first step: nothing bounds a decoded video frame, and reading every pixel of a 4K one would stall.
private const val RESAMPLE_MEDIA_FROM_SIDE = 512
private fun ImageBitmap.blurredBy(radius: Int): ImageBitmap {
if (width <= 0 || height <= 0) return this
val w = (BLURRED_MEDIA_WIDTH_DP / radius).coerceIn(1, width)
val h = (w * height / width).coerceIn(1, BLURRED_MEDIA_WIDTH_DP)
val longest = maxOf(width, height)
var image = if (longest > RESAMPLE_MEDIA_FROM_SIDE) {
val step = RESAMPLE_MEDIA_FROM_SIDE.toFloat() / longest
scale((width * step).roundToInt().coerceAtLeast(1), (height * step).roundToInt().coerceAtLeast(1))
} else this
// One bilinear step from a large image undersamples it.
while (image.width / 2 > w) image = image.scale(image.width / 2, (image.height / 2).coerceAtLeast(1))
image = image.scale(w, h)
// Stretching w px straight to the screen shows their grid; each doubling smooths it, so the last stretch is small.
while (maxOf(image.width, image.height) < BLURRED_MEDIA_WIDTH_DP / 2) image = image.scale(image.width * 2, image.height * 2)
return image
}
@@ -1,5 +1,6 @@
package chat.simplex.common.views.call
import chat.simplex.common.AppLock
import chat.simplex.common.model.*
import chat.simplex.common.platform.*
import chat.simplex.common.views.helpers.withBGApi
@@ -51,13 +52,12 @@ class CallManager(val chatModel: ChatModel) {
callUUID = invitation.callUUID,
callState = CallState.InvitationAccepted,
initialCallType = invitation.callType.media,
sharedKey = invitation.sharedKey,
hasSharedKey = invitation.sharedKey != null,
androidCallState = platform.androidCreateActiveCallState()
)
showCallView.value = true
val useRelay = controller.appPrefs.webrtcPolicyRelay.get()
val iceServers = getIceServers()
Log.d(TAG, "answerIncomingCall iceServers: $iceServers")
callCommand.add(WCallCommand.Start(
media = invitation.callType.media,
aesKey = invitation.sharedKey,
@@ -79,6 +79,7 @@ class CallManager(val chatModel: ChatModel) {
// Don't destroy WebView if you plan to accept next call right after this one
if (!switchingCall.value) {
AppLock.appWasHidden()
showCallView.value = false
activeCall.value?.androidCallState?.close()
activeCall.value = null
@@ -21,7 +21,7 @@ data class Call(
val localMediaSources: CallMediaSources = CallMediaSources(mic = true, camera = initialCallType == CallMediaType.Video),
val localCapabilities: CallCapabilities? = null,
val peerMediaSources: CallMediaSources = CallMediaSources(),
val sharedKey: String? = null,
val hasSharedKey: Boolean = false,
var localCamera: VideoCamera = VideoCamera.User,
val connectionInfo: ConnectionInfo? = null,
var connectedAt: Instant? = null,
@@ -32,14 +32,14 @@ data class Call(
val androidCallState: Closeable
) {
val encrypted: Boolean get() = localEncrypted && sharedKey != null
val encrypted: Boolean get() = localEncrypted && hasSharedKey
private val localEncrypted: Boolean get() = localCapabilities?.encryption ?: false
val encryptionStatus: String get() = when(callState) {
CallState.WaitCapabilities -> ""
CallState.InvitationSent -> generalGetString(if (localEncrypted) MR.strings.status_e2e_encrypted else MR.strings.status_no_e2e_encryption)
CallState.InvitationAccepted -> generalGetString(if (sharedKey == null) MR.strings.status_contact_has_no_e2e_encryption else MR.strings.status_contact_has_e2e_encryption)
else -> generalGetString(if (!localEncrypted) MR.strings.status_no_e2e_encryption else if (sharedKey == null) MR.strings.status_contact_has_no_e2e_encryption else MR.strings.status_e2e_encrypted)
CallState.InvitationAccepted -> generalGetString(if (!hasSharedKey) MR.strings.status_contact_has_no_e2e_encryption else MR.strings.status_contact_has_e2e_encryption)
else -> generalGetString(if (!localEncrypted) MR.strings.status_no_e2e_encryption else if (!hasSharedKey) MR.strings.status_contact_has_no_e2e_encryption else MR.strings.status_e2e_encrypted)
}
val hasVideo: Boolean
@@ -1205,12 +1205,13 @@ fun SynchronizeConnectionButtonForce(syncConnectionForce: () -> Unit) {
}
@Composable
fun VerifyCodeButton(contactVerified: Boolean, onClick: () -> Unit) {
fun VerifyCodeButton(contactVerified: Boolean, onClick: () -> Unit, disabled: Boolean = false) {
SettingsActionItem(
if (contactVerified) painterResource(MR.images.ic_verified_user) else painterResource(MR.images.ic_shield),
stringResource(if (contactVerified) MR.strings.view_security_code else MR.strings.verify_security_code),
click = onClick,
iconColor = MaterialTheme.colors.secondary,
disabled = disabled,
)
}
@@ -499,6 +499,15 @@ fun ChatView(
showMemberInfo = { groupInfo: GroupInfo, member: GroupMember ->
hideKeyboard(view)
groupMembersJob.cancel()
val connStats = mutableStateOf<ConnectionStats?>(null)
val connectionCode = mutableStateOf<String?>(null)
val connectionLoaded = mutableStateOf(false)
if (chatsCtx.secondaryContextFilter == null) {
ModalManager.end.closeModals()
}
ModalManager.end.showModalCloseable(showClose = true, cardScreen = true) { close ->
GroupMemberInfoView(chatRh, groupInfo, member, scrollToItemId, connStats, connectionCode, connectionLoaded, chatModel, openedFromSupportChat = false, close = close, closeAll = close)
}
groupMembersJob = scope.launch(Dispatchers.Default) {
val r = chatModel.controller.apiGroupMemberInfo(chatRh, groupInfo.groupId, member.groupMemberId)
val stats = r?.second
@@ -513,15 +522,9 @@ fun ChatView(
withContext(Dispatchers.Main) {
chatModel.chatsContext.upsertGroupMember(chatRh, groupInfo, updatedMember)
}
if (chatsCtx.secondaryContextFilter == null) {
ModalManager.end.closeModals()
}
ModalManager.end.showModalCloseable(showClose = true, cardScreen = true) { close ->
remember { derivedStateOf { chatModel.getGroupMember(member.groupMemberId) } }.value?.let { mem ->
GroupMemberInfoView(chatRh, groupInfo, mem, scrollToItemId, stats, code, chatModel, openedFromSupportChat = false, close = close, closeAll = close)
}
}
connStats.value = stats
connectionCode.value = code
connectionLoaded.value = true
}
},
loadMessages = { chatId, pagination, visibleItemIndexes ->
@@ -123,6 +123,15 @@ fun ModalData.GroupChatInfoView(
}
},
showMemberInfo = { member, groupRelay ->
val connStats = mutableStateOf<ConnectionStats?>(null)
val connectionCode = mutableStateOf<String?>(null)
val connectionLoaded = mutableStateOf(false)
ModalManager.end.showModalCloseable(showClose = true, cardScreen = true) { closeCurrent ->
GroupMemberInfoView(rhId, groupInfo, member, scrollToItemId, connStats, connectionCode, connectionLoaded, chatModel, openedFromSupportChat = false, groupRelay = groupRelay, close = closeCurrent) {
closeCurrent()
close()
}
}
withBGApi {
val r = chatModel.controller.apiGroupMemberInfo(rhId, groupInfo.groupId, member.groupMemberId)
val stats = r?.second
@@ -132,14 +141,9 @@ fun ModalData.GroupChatInfoView(
} else {
member to null
}
ModalManager.end.showModalCloseable(showClose = true, cardScreen = true) { closeCurrent ->
remember { derivedStateOf { chatModel.getGroupMember(member.groupMemberId) } }.value?.let { mem ->
GroupMemberInfoView(rhId, groupInfo, mem, scrollToItemId, stats, code, chatModel, openedFromSupportChat = false, groupRelay = groupRelay, close = closeCurrent) {
closeCurrent()
close()
}
}
}
connStats.value = stats
connectionCode.value = code
connectionLoaded.value = true
}
},
editGroupProfile = {
@@ -45,10 +45,11 @@ import kotlinx.coroutines.*
fun GroupMemberInfoView(
rhId: Long?,
groupInfo: GroupInfo,
member: GroupMember,
groupMember: GroupMember,
scrollToItemId: MutableState<Long?>,
connectionStats: ConnectionStats?,
connectionCode: String?,
connStats: MutableState<ConnectionStats?>,
connectionCode: State<String?>,
connectionLoaded: State<Boolean>,
chatModel: ChatModel,
openedFromSupportChat: Boolean,
groupRelay: GroupRelay? = null,
@@ -60,7 +61,10 @@ fun GroupMemberInfoView(
}
BackHandler(onBack = close)
val chat = chatModel.chats.value.firstOrNull { ch -> ch.id == chatModel.chatId.value && ch.remoteHostId == rhId }
val connStats = remember { mutableStateOf(connectionStats) }
// the passed member is shown until the loaded one is added to the model, so that the profile opens without waiting for the core
val member = remember(groupMember.groupMemberId) {
derivedStateOf { chatModel.getGroupMember(groupMember.groupMemberId) ?: groupMember }
}.value
val developerTools = chatModel.controller.appPrefs.developerTools.get()
var progressIndicator by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
@@ -91,7 +95,8 @@ fun GroupMemberInfoView(
connStats,
newRole,
developerTools,
connectionCode,
connectionCode.value,
connectionLoaded.value,
groupRelay = groupRelay,
getContactChat = { chatModel.getContactChat(it) },
openDirectChat = { contactId ->
@@ -101,6 +106,7 @@ fun GroupMemberInfoView(
}
},
createMemberContact = {
val connectionStats = connStats.value
if (member.sendMsgEnabled) {
withBGApi {
progressIndicator = true
@@ -209,7 +215,7 @@ fun GroupMemberInfoView(
remember { derivedStateOf { chatModel.getGroupMember(member.groupMemberId) } }.value?.let { mem ->
VerifyCodeView(
mem.displayName,
connectionCode,
connectionCode.value,
mem.verified,
verify = { code ->
chatModel.controller.apiVerifyGroupMember(rhId, mem.groupId, mem.groupMemberId, code)?.let { r ->
@@ -368,6 +374,7 @@ fun GroupMemberInfoLayout(
newRole: MutableState<GroupMemberRole>,
developerTools: Boolean,
connectionCode: String?,
connectionLoaded: Boolean,
groupRelay: GroupRelay? = null,
getContactChat: (Long) -> Chat?,
openDirectChat: (Long) -> Unit,
@@ -538,22 +545,23 @@ fun GroupMemberInfoLayout(
SectionDividerSpaced()
}
val memberConnected = member.memberActive || (groupInfo.useRelays && member.memberCurrent)
val showMemberSupportChat = !openedFromSupportChat &&
groupInfo.membership.memberRole >= GroupMemberRole.Moderator &&
member.memberRole != GroupMemberRole.Relay &&
((groupInfo.fullGroupPreferences.support.on && member.memberRole < GroupMemberRole.Moderator)
|| member.supportChat != null)
val canVerifyCode = connectionCode != null && member.memberRole != GroupMemberRole.Relay
// the same condition decides whether the code is requested, so the row is shown disabled until it arrives
val canVerifyCode = member.memberRole != GroupMemberRole.Relay && (connectionCode != null || !connectionLoaded)
val canSyncConn = cStats != null && cStats.ratchetSyncAllowed
if ((member.memberActive || (groupInfo.useRelays && member.memberCurrent))
&& (showMemberSupportChat || canVerifyCode || canSyncConn)) {
if (memberConnected && (showMemberSupportChat || canVerifyCode || canSyncConn)) {
SectionView {
if (showMemberSupportChat) {
SupportChatButton()
}
if (canVerifyCode) {
VerifyCodeButton(member.verified, verifyClicked)
VerifyCodeButton(member.verified, verifyClicked, disabled = connectionCode == null)
}
if (canSyncConn) {
SynchronizeConnectionButton(syncMemberConnection)
@@ -655,6 +663,20 @@ fun GroupMemberInfoLayout(
SimplexServers(stringResource(MR.strings.sending_via), sndServers)
}
}
} else if (!connectionLoaded && memberConnected) {
SectionDividerSpaced()
SectionView(title = stringResource(MR.strings.conn_stats_section_title_servers)) {
// the status is set from the same receiving queues as the servers below, so it is reserved with them
SectionItemView {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
Text(stringResource(MR.strings.network_status))
Icon(painterResource(MR.images.ic_info), stringResource(MR.strings.network_status), tint = MaterialTheme.colors.secondary)
}
}
SwitchAddressButton(disabled = true, switchAddress = {})
SimplexServers(stringResource(MR.strings.receiving_via), emptyList())
SimplexServers(stringResource(MR.strings.sending_via), emptyList())
}
}
val connFailedErr = member.activeConn?.connFailedErr
@@ -1117,6 +1139,7 @@ fun PreviewGroupMemberInfoLayout() {
newRole = remember { mutableStateOf(GroupMemberRole.Member) },
developerTools = false,
connectionCode = "123",
connectionLoaded = true,
getContactChat = { Chat.sampleData },
openDirectChat = {},
createMemberContact = {},
@@ -76,6 +76,15 @@ fun MemberSupportChatAppBar(
navigationButton = { NavigationButtonBack(onBackClicked) },
title = { MemberSupportChatToolbarTitle(scopeMember_) },
onTitleClick = {
val connStats = mutableStateOf<ConnectionStats?>(null)
val connectionCode = mutableStateOf<String?>(null)
val connectionLoaded = mutableStateOf(false)
ModalManager.end.showModalCloseable(showClose = true, cardScreen = true) { closeCurrent ->
GroupMemberInfoView(rhId, groupInfo, scopeMember_, scrollToItemId, connStats, connectionCode, connectionLoaded, chatModel, openedFromSupportChat = true, close = closeCurrent) {
closeCurrent()
close()
}
}
withBGApi {
val r = chatModel.controller.apiGroupMemberInfo(rhId, groupInfo.groupId, scopeMember_.groupMemberId)
val stats = r?.second
@@ -85,14 +94,9 @@ fun MemberSupportChatAppBar(
} else {
null
}
ModalManager.end.showModalCloseable(showClose = true, cardScreen = true) { closeCurrent ->
remember { derivedStateOf { chatModel.getGroupMember(scopeMember_.groupMemberId) } }.value?.let { mem ->
GroupMemberInfoView(rhId, groupInfo, mem, scrollToItemId, stats, code, chatModel, openedFromSupportChat = true, close = closeCurrent) {
closeCurrent()
close()
}
}
}
connStats.value = stats
connectionCode.value = code
connectionLoaded.value = true
}
},
onTop = !oneHandUI.value || !chatBottomBar.value,
@@ -114,7 +114,7 @@ fun CIImageView(
onClick = onClick
)
.onRightClick { showMenu.value = true }
.privacyBlur(!smallView, blurred, scrollState = chatViewScrollState.collectAsState(), onLongClick = { showMenu.value = true }),
.privacyBlur(!smallView, imageBitmap, blurred, scrollState = chatViewScrollState.collectAsState(), onLongClick = { showMenu.value = true }),
contentScale = if (smallView) ContentScale.Crop else ContentScale.FillWidth,
)
}
@@ -138,7 +138,7 @@ fun CIImageView(
onClick = onClick
)
.onRightClick { showMenu.value = true }
.privacyBlur(!smallView, blurred, scrollState = chatViewScrollState.collectAsState(), onLongClick = { showMenu.value = true }),
.privacyBlur(!smallView, previewBitmap, blurred, scrollState = chatViewScrollState.collectAsState(), onLongClick = { showMenu.value = true }),
contentScale = if (smallView) ContentScale.Crop else ContentScale.FillWidth,
)
} else {
@@ -149,7 +149,7 @@ fun CIImageView(
onClick = {}
)
.onRightClick { showMenu.value = true }
.privacyBlur(!smallView, blurred, scrollState = chatViewScrollState.collectAsState(), onLongClick = { showMenu.value = true }),
.privacyBlur(!smallView, previewBitmap, blurred, scrollState = chatViewScrollState.collectAsState(), onLongClick = { showMenu.value = true }),
contentAlignment = Alignment.Center
) {
imageView(previewBitmap, onClick = {
@@ -195,22 +195,26 @@ fun CIImageView(
contentAlignment = Alignment.TopEnd
) {
val res: MutableState<Triple<ImageBitmap, ByteArray, String>?> = remember { mutableStateOf(null) }
if (chatModel.connectedToRemote()) {
LaunchedEffect(file, CIFile.cachedRemoteFileRequests.toList()) {
withBGApi {
// Hidden media is not worth reading, decoding at full size and caching.
val revealed = !blurHidesMedia(!smallView, blurred)
if (revealed) {
if (chatModel.connectedToRemote()) {
LaunchedEffect(file, CIFile.cachedRemoteFileRequests.toList()) {
withBGApi {
if (res.value == null || res.value!!.third != getLoadedFilePath(file)) {
res.value = imageAndFilePath(file)
}
}
}
} else {
LaunchedEffect(file) {
if (res.value == null || res.value!!.third != getLoadedFilePath(file)) {
res.value = imageAndFilePath(file)
res.value = withContext(Dispatchers.IO) { imageAndFilePath(file) }
}
}
}
} else {
LaunchedEffect(file) {
if (res.value == null || res.value!!.third != getLoadedFilePath(file)) {
res.value = withContext(Dispatchers.IO) { imageAndFilePath(file) }
}
}
}
val loaded = res.value
val loaded = if (revealed) res.value else null
if (loaded != null && file != null) {
val (imageBitmap, data, _) = loaded
SimpleAndAnimatedImageView(data, imageBitmap, file, imageProvider, smallView, blurred, @Composable { painter, onClick -> ImageView(painter, image, file.fileSource, onClick) })
@@ -421,7 +421,7 @@ fun VideoPreviewImageView(
onClick = onClick
)
.onRightClick(onLongClick)
.privacyBlur(!smallView, blurred, scrollState = chatViewScrollState.collectAsState(), onLongClick = onLongClick),
.privacyBlur(!smallView, preview, blurred, scrollState = chatViewScrollState.collectAsState(), onLongClick = onLongClick),
contentScale = if (smallView) ContentScale.Crop else ContentScale.FillWidth,
)
}
@@ -779,7 +779,7 @@ fun connectIfOpenedViaUri(rhId: Long?, uri: String, chatModel: ChatModel) {
} else {
withBGApi {
chatModel.appOpenUrlConnecting.value = true
planAndConnect(rhId, uri, close = null, cleanup = { chatModel.appOpenUrlConnecting.value = false })
planAndConnect(rhId, uri, close = { ModalManager.closeAllModalsEverywhere() }, cleanup = { chatModel.appOpenUrlConnecting.value = false })
}
}
}
@@ -556,12 +556,16 @@ suspend fun deleteChatAsync(m: ChatModel) {
fun deleteChatDatabaseFilesAndState() {
val chat = File(dataDir, chatDatabaseFileName)
val chatBak = File(dataDir, "$chatDatabaseFileName.bak")
val chatExported = File(dataDir, "$chatDatabaseFileName.exported")
val agent = File(dataDir, agentDatabaseFileName)
val agentBak = File(dataDir, "$agentDatabaseFileName.bak")
val agentExported = File(dataDir, "$agentDatabaseFileName.exported")
chat.delete()
chatBak.delete()
chatExported.delete()
agent.delete()
agentBak.delete()
agentExported.delete()
filesDir.deleteRecursively()
filesDir.mkdir()
remoteHostsDir.deleteRecursively()
@@ -12,6 +12,7 @@ import androidx.compose.ui.graphics.layer.GraphicsLayer
import androidx.compose.ui.graphics.layer.drawLayer
import androidx.compose.ui.unit.*
import chat.simplex.common.platform.appPlatform
import chat.simplex.common.platform.drawBarsBlurred
import chat.simplex.common.ui.theme.CurrentColors
fun Modifier.blurredBackgroundModifier(
@@ -32,7 +33,7 @@ fun Modifier.blurredBackgroundModifier(
return if (appPlatform.isAndroid) {
this.androidBlurredModifier(keyboardInset, blurRadius.value, keyboardCoversBar, onTop, graphicsLayer, backgroundGraphicsLayer, backgroundGraphicsLayerSize, density)
} else {
this.desktopBlurredModifier(keyboardInset, blurRadius, keyboardCoversBar, onTop, graphicsLayer, backgroundGraphicsLayer, backgroundGraphicsLayerSize, density)
this.desktopBlurredModifier(keyboardInset, blurRadius, keyboardCoversBar, onTop, handler, graphicsLayer, backgroundGraphicsLayer, backgroundGraphicsLayerSize, density)
}
}
@@ -100,40 +101,44 @@ private fun Modifier.desktopBlurredModifier(
blurRadius: State<Int>,
keyboardCoversBar: Boolean,
onTop: Boolean,
handler: AppBarHandler,
graphicsLayer: GraphicsLayer,
backgroundGraphicsLayer: GraphicsLayer,
backgroundGraphicsLayerSize: State<IntSize>,
density: Density
): Modifier = this
.graphicsLayer {
renderEffect = if (blurRadius.value > 0) BlurEffect(blurRadius.value.dp.toPx(), blurRadius.value.dp.toPx()) else null
clip = blurRadius.value > 0
}
.drawBehind {
drawRect(CurrentColors.value.colors.background)
if (onTop) {
clipRect {
if (backgroundGraphicsLayer.size != IntSize.Zero) {
drawLayer(backgroundGraphicsLayer)
} else {
drawRect(CurrentColors.value.colors.background, size = Size(graphicsLayer.size.width.toFloat(), graphicsLayer.size.height.toFloat()))
val barWidth = size.width
val barHeight = size.height
// The blur is taken from a copy of the scrolled content rather than drawn from that layer, so unlike a layer's own
// filter it does not follow the content on its own: the bar is redrawn when the container reports the copy moved.
handler.contentVersion.value
drawBarsBlurred(blurRadius.value.dp.toPx(), barWidth, barHeight) {
drawRect(CurrentColors.value.colors.background, size = Size(barWidth, barHeight))
if (onTop) {
clipRect(0f, 0f, barWidth, barHeight) {
if (backgroundGraphicsLayer.size != IntSize.Zero) {
drawLayer(backgroundGraphicsLayer)
} else {
drawRect(CurrentColors.value.colors.background, size = Size(graphicsLayer.size.width.toFloat(), graphicsLayer.size.height.toFloat()))
}
drawLayer(graphicsLayer)
}
drawLayer(graphicsLayer)
}
} else {
val bgSize = when {
backgroundGraphicsLayerSize.value.height == 0 && backgroundGraphicsLayer.size.height != 0 -> backgroundGraphicsLayer.size.height
backgroundGraphicsLayerSize.value.height == 0 -> graphicsLayer.size.height
else -> backgroundGraphicsLayerSize.value.height
}
val keyboardHeightCovered = if (!keyboardCoversBar) keyboardInset.getBottom(density) else 0
translate(top = -bgSize + size.height + keyboardHeightCovered) {
if (backgroundGraphicsLayer.size != IntSize.Zero) {
drawLayer(backgroundGraphicsLayer)
} else {
drawRect(CurrentColors.value.colors.background, size = Size(graphicsLayer.size.width.toFloat(), graphicsLayer.size.height.toFloat()))
} else {
val bgSize = when {
backgroundGraphicsLayerSize.value.height == 0 && backgroundGraphicsLayer.size.height != 0 -> backgroundGraphicsLayer.size.height
backgroundGraphicsLayerSize.value.height == 0 -> graphicsLayer.size.height
else -> backgroundGraphicsLayerSize.value.height
}
val keyboardHeightCovered = if (!keyboardCoversBar) keyboardInset.getBottom(density) else 0
translate(top = -bgSize + barHeight + keyboardHeightCovered) {
if (backgroundGraphicsLayer.size != IntSize.Zero) {
drawLayer(backgroundGraphicsLayer)
} else {
drawRect(CurrentColors.value.colors.background, size = Size(graphicsLayer.size.width.toFloat(), graphicsLayer.size.height.toFloat()))
}
drawLayer(graphicsLayer)
}
drawLayer(graphicsLayer)
}
}
}
@@ -78,6 +78,10 @@ class AppBarHandler(
val backgroundGraphicsLayerSize: MutableState<IntSize> = mutableStateOf(IntSize.Zero)
// Bars that blur a copy of the scrolled content depend on this to know the copy moved. It is bumped from a collector
// rather than while drawing: a write made during the draw phase invalidates the bars every frame and never settles.
val contentVersion: MutableState<Int> = mutableStateOf(0)
companion object {
var appBarMaxHeightPx: Int = 0
}
@@ -194,7 +194,7 @@ fun ComposeLinkView(linkPreview: LinkPreview?, cancelPreview: () -> Unit, cancel
@Composable
fun ChatItemLinkView(linkPreview: LinkPreview, showMenu: State<Boolean>, onLongClick: () -> Unit) {
val image = base64ToBitmap(linkPreview.image)
val image = remember(linkPreview.image) { base64ToBitmap(linkPreview.image) }
Column(
Modifier
.layoutId(CHAT_IMAGE_LAYOUT_ID)
@@ -207,7 +207,7 @@ fun ChatItemLinkView(linkPreview: LinkPreview, showMenu: State<Boolean>, onLongC
modifier = Modifier
.fillMaxWidth()
.desktopModifyBlurredState(true, blurred, showMenu)
.privacyBlur(true, blurred, chatViewScrollState.collectAsState(), onLongClick = onLongClick),
.privacyBlur(true, image, blurred, chatViewScrollState.collectAsState(), onLongClick = onLongClick),
contentScale = ContentScale.FillWidth,
)
Column(Modifier.padding(top = 6.dp).padding(horizontal = 12.dp)) {
@@ -1033,7 +1033,7 @@ fun shouldShowWhatsNew(m: ChatModel): Boolean {
return v != lastVersion
}
private const val WEFUNDER_URL = "https://wefunder.com/simplex.chat"
private const val WEFUNDER_URL = "https://wefunder.com/simplex.chat?utm_source=app"
private const val CROWDFUNDING_CONTACT_URI = "simplex:/a#JxGcOA1_QhlmVFzYYabloMbvMZk5Y9d9iS3ITDnhzYo?h=smp11.simplex.im"
@@ -1197,7 +1197,7 @@ fun GetStakeView(showFirstImage: Boolean, inCenterOfWindow: Boolean = false, clo
ColumnWithScrollBar(Modifier.pinchZoom().padding(horizontal = DEFAULT_PADDING)) {
// in the center of the window the page is wide enough for the title to fit on one line
val title = "Get a stake in\nSimpleX Chat"
val title = "Invest in\nSimpleX Chat"
AppBarTitle(if (inCenterOfWindow) title.replace("\n", " ") else title, withPadding = false)
// What's new already shows the image of the first slide, above the link that opens this page
if (showFirstImage) {
@@ -74,6 +74,9 @@ fun SettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, close: (
val simplexTeamUri =
"simplex:/a#lrdvu2d8A1GumSmoKb2krQmtKhWXq-tyGpHuM7aMwsw?h=smp6.simplex.im"
val simplexNewsUri =
"simplex:/c#grcfG3ulVI4Sh6ow33qBsmSk7uEy3gRSl2KkJ5ER6tA?h=smp18.simplex.im"
@Composable
fun SettingsLayout(
stopped: Boolean,
@@ -161,6 +164,7 @@ fun HelpAndSupportView(
SectionView(stringResource(MR.strings.settings_section_title_contact)) {
if (!chatModel.desktopNoUserNoRemote) {
SettingsActionItem(painterResource(MR.images.ic_bigtop_updates), stringResource(MR.strings.follow_simplex_network_news), { uriHandler.openVerifiedSimplexUri(simplexNewsUri) }, textColor = MaterialTheme.colors.primary, disabled = stopped)
SettingsActionItem(painterResource(MR.images.ic_tag), stringResource(MR.strings.chat_with_the_founder), { uriHandler.openVerifiedSimplexUri(simplexTeamUri) }, textColor = MaterialTheme.colors.primary, disabled = stopped)
}
SettingsActionItem(painterResource(MR.images.ic_mail), stringResource(MR.strings.send_us_an_email), { uriHandler.openUriCatching("mailto:chat@simplex.chat") }, textColor = MaterialTheme.colors.primary)
@@ -1015,6 +1015,7 @@
<string name="markdown_in_messages">Markdown in messages</string>
<string name="chat_with_the_founder">Send questions and ideas</string>
<string name="send_us_an_email">Send us email</string>
<string name="follow_simplex_network_news">Follow SimpleX Network News</string>
<string name="chat_lock">SimpleX Lock</string>
<string name="chat_console">Chat console</string>
<string name="message_servers">Message servers</string>
@@ -1783,6 +1784,7 @@
<string name="rcv_group_event_invited_via_your_group_link">invited via your group link</string>
<string name="rcv_group_event_member_created_contact">requested connection</string>
<string name="rcv_group_event_new_member_pending_review">New member wants to join the group.</string>
<string name="rcv_group_event_msg_bad_signature">message rejected: bad signature</string>
<string name="snd_group_event_changed_member_role">you changed role of %s to %s</string>
<string name="snd_group_event_changed_role_for_yourself">you changed role for yourself to %s</string>
<string name="snd_group_event_member_blocked">you blocked %s</string>
@@ -2678,8 +2680,8 @@
<string name="v7_0_invest_descr" translatable="false">Crowdfunding on Wefunder.</string>
<string name="v7_0_crowdfunding" translatable="false">Crowdfunding on Wefunder</string>
<string name="v7_0_invest_learn_more" translatable="false">Learn more on Wefunder</string>
<string name="invest_banner_title" translatable="false">Get a stake in SimpleX Chat!</string>
<string name="invest_banner_subtitle" translatable="false">Invest on Wefunder from $100</string>
<string name="invest_banner_title" translatable="false">Invest in SimpleX Chat!</string>
<string name="invest_banner_subtitle" translatable="false">Crowdfunding on Wefunder</string>
<string name="v7_0_simplex_names">SimpleX public names (BETA)</string>
<string name="v7_0_simplex_names_descr">Public names for your channel or business.</string>
<string name="v7_0_channels">Better channels 📢</string>
@@ -182,9 +182,10 @@ private fun ApplicationScope.AppWindow(closedByError: MutableState<Boolean>) {
}
}
var windowFocused by remember { simplexWindowState.windowFocused }
LaunchedEffect(windowFocused) {
val showCallView = ChatModel.showCallView.value
LaunchedEffect(windowFocused, showCallView) {
val delay = ChatController.appPrefs.laLockDelay.get()
if (!windowFocused && ChatModel.showAuthScreen.value && delay > 0) {
if (!windowFocused && !showCallView && ChatModel.showAuthScreen.value && delay > 0) {
delay(delay * 1000L)
// Trigger auth state check when delay ends (and if it ends)
AppLock.recheckAuthState()
@@ -76,12 +76,11 @@ internal class SkiaBitmapVideoSurface : VideoSurface(VideoSurfaceAdapters.getVid
override fun getBufferFormat(sourceWidth: Int, sourceHeight: Int): BufferFormat {
// libvlc passes the size the decoder padded the picture to, not the size of the picture (dav1d
// pads to a multiple of 128, so 1920x1080 arrives as 1920x1152), and vlc stretches the picture to
// fill whatever size is returned. Ask for the size of the track being played instead. The format
// is negotiated more than once, and vlc has not selected the track yet on the first calls
val player = mediaPlayer
val tracks = player?.media()?.info()?.videoTracks()
val playingTrack = player?.video()?.track()
val track = tracks?.firstOrNull { it.id() == playingTrack } ?: tracks?.singleOrNull()
// fill whatever size is returned. Ask for the size of the video track instead. Not the selected
// track: video().track() takes the input lock, which stop() holds while it waits for this thread
// to finish, so asking here deadlocks both, and every caller of the player after them
val tracks = mediaPlayer?.media()?.info()?.videoTracks()
val track = tracks?.singleOrNull()
// Both track sides or neither: one side from the track and the other from the padded size libvlc
// passed never described the same picture, and transposing such a pair compounds the mismatch
val trackW = track?.width() ?: 0
@@ -0,0 +1,95 @@
package chat.simplex.common.platform
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.BlurEffect
import androidx.compose.ui.graphics.asComposeCanvas
import androidx.compose.ui.graphics.drawscope.CanvasDrawScope
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
import androidx.compose.ui.graphics.drawscope.scale
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.unit.Density
import org.jetbrains.skia.*
import kotlin.math.ceil
// Skia's raster blur costs width x (bar + 6 sigma) pixels every frame, which is what makes the bars lag. The blur cannot be
// made cheaper by scaling the layer it runs on, because a layer's filter is evaluated in device space: any scale applied to
// the layer scales the sigma straight back up, which weakens the blur without saving anything. So the bar is drawn into a
// narrower offscreen surface and blurred there, at its own resolution, and only the finished blur is scaled back out.
// Only the width is reduced: a bar is just AppBarHeight tall, and taking rows away from it visibly weakens the blur.
private const val MAX_BLUR_NARROWING = 8
private const val MIN_BLUR_WIDTH = 128
// How small the blur's own sigma is allowed to get. Below about this the narrowed copy is barely blurred, and stretching it
// back out shows the steps of the narrowing instead of a blur.
private const val MIN_NARROWED_SIGMA = 1.5f
// The rows are halved as well, with the vertical sigma halved to match, which is only sound because the copy is drawn at
// that size rather than scaled afterwards. A bar has few rows to begin with, so it is not reduced past this.
private const val MIN_BLUR_HEIGHT = 24
// The panes of the desktop window have different widths, so bars of several sizes are drawn in the same frame and a single
// pair of surfaces would be reallocated for each of them. Keeping one pair per size, with the least recently drawn size
// released once there are more than the window can show at once, allocates each pair once instead.
private const val MAX_BAR_SURFACES = 4
private class BarBlurSurface(width: Int, height: Int) {
val surface: Surface = Surface.makeRaster(ImageInfo.makeN32Premul(width, height))
fun close() = surface.close()
}
private val barBlurSurfaces = ThreadLocal.withInitial { LinkedHashMap<Long, BarBlurSurface>() }
private fun surfaceFor(width: Int, height: Int): BarBlurSurface {
val cached = barBlurSurfaces.get()
val key = (width.toLong() shl 32) or height.toLong()
val surface = cached.remove(key) ?: BarBlurSurface(width, height)
cached[key] = surface
while (cached.size > MAX_BAR_SURFACES) {
cached.remove(cached.keys.first())?.close()
}
return surface
}
private fun narrowing(sigma: Float, barWidth: Float): Int {
var scale = 1
while (sigma / (scale * 2) >= MIN_NARROWED_SIGMA && scale < MAX_BLUR_NARROWING && barWidth / (scale * 2) >= MIN_BLUR_WIDTH) scale *= 2
return scale
}
private fun shortening(sigma: Float, barHeight: Float): Int =
if (sigma / 2 >= MIN_NARROWED_SIGMA && barHeight / 2 >= MIN_BLUR_HEIGHT) 2 else 1
actual fun DrawScope.drawBarsBlurred(radiusPx: Float, barWidth: Float, barHeight: Float, drawBar: DrawScope.() -> Unit) {
val sigma = BlurEffect.convertRadiusToSigma(radiusPx)
val scale = narrowing(sigma, barWidth)
val rows = shortening(sigma, barHeight)
val width = ceil(barWidth / scale).toInt()
val height = ceil(barHeight / rows).toInt()
if (width <= 0 || height <= 0) return
val surface = surfaceFor(width, height).surface
surface.canvas.clear(Color.TRANSPARENT)
// the blur is the layer's own paint, so it runs at this surface's resolution rather than at the canvas the bar draws to
surface.canvas.saveLayer(Rect.makeWH(width.toFloat(), height.toFloat()), Paint().apply {
imageFilter = ImageFilter.makeBlur(sigma / scale, sigma / rows, FilterTileMode.CLAMP)
})
CanvasDrawScope().draw(Density(density, fontScale), layoutDirection, surface.canvas.asComposeCanvas(), Size(width.toFloat(), height.toFloat())) {
scale(1f / scale, 1f / rows, Offset.Zero) { drawBar() }
}
surface.canvas.restore()
// The surface is whole pixels while the bar is not, so only the bar's own area is taken from it.
drawIntoCanvas {
it.nativeCanvas.drawImageRect(
surface.makeImageSnapshot(),
Rect.makeWH(barWidth / scale, barHeight / rows),
Rect.makeWH(barWidth, barHeight),
SamplingMode.LINEAR,
null,
true
)
}
}
@@ -64,6 +64,9 @@ actual fun LazyColumnWithScrollBar(
}
val state = state ?: handler.listState
val connection = handler.connection
LaunchedEffect(state, handler) {
snapshotFlow { state.firstVisibleItemIndex to state.firstVisibleItemScrollOffset }.collect { handler.contentVersion.value++ }
}
// When scroll bar is dragging, there is no scroll event in nested scroll modifier. So, listen for changes on lazy column state
// (only first visible row is useful because LazyColumn doesn't have absolute scroll position, only relative to row)
val scrollBarDraggingState = remember { mutableStateOf(false) }
@@ -202,6 +205,9 @@ actual fun ColumnWithScrollBar(
}
}
val state = state ?: handler.scrollState
LaunchedEffect(state, handler) {
snapshotFlow { state.value }.collect { handler.contentVersion.value++ }
}
val connection = handler.connection
// When scroll bar is dragging, there is no scroll event in nested scroll modifier. So, listen for changes on column state
// (exact scroll position is available but in Int, not Float)
@@ -31,7 +31,7 @@ val connections = ArrayList<WebSocket>()
actual fun ActiveCallView() {
val scope = rememberCoroutineScope()
WebRTCController(chatModel.callCommand) { apiMsg ->
Log.d(TAG, "received from WebRTCController: $apiMsg")
Log.d(TAG, "received from WebRTCController: ${apiMsg.resp.javaClass.simpleName}")
val call = chatModel.activeCall.value
if (call != null) {
Log.d(TAG, "has active call $call")
@@ -202,7 +202,7 @@ fun WebRTCController(callCommand: SnapshotStateList<WCallCommand>, onResponse: (
}
while (callCommand.isNotEmpty()) {
val cmd = callCommand.removeFirstOrNull()
Log.d(TAG, "WebRTCController LaunchedEffect executing $cmd")
Log.d(TAG, "WebRTCController LaunchedEffect executing ${cmd?.javaClass?.simpleName}")
if (cmd != null) {
processCommand(cmd)
}
@@ -289,7 +289,7 @@ class MyWebSocket(val onResponse: (WVAPIMessage) -> Unit, handshakeRequest: IHTT
// onResponse(message.textPayload)
onResponse(json.decodeFromString(message.textPayload))
} catch (e: Exception) {
Log.e(TAG, "failed parsing browser message: $message")
Log.e(TAG, "failed parsing browser message")
}
}