mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-10-23 19:23:36 +00:00
Merge branch 'master' into master-android
This commit is contained in:
19 files changed
+251
-49
No files matched your search
@@ -178,7 +178,6 @@ object NtfManager {
|
||||
val fullScreenPendingIntent = PendingIntent.getActivity(context, 0, fullScreenIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
|
||||
NotificationCompat.Builder(context, CallChannel)
|
||||
.setFullScreenIntent(fullScreenPendingIntent, true)
|
||||
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
||||
} else {
|
||||
val soundUri = Uri.parse(ContentResolver.SCHEME_ANDROID_RESOURCE + "://" + context.packageName + "/raw/ring_once")
|
||||
val fullScreenPendingIntent = PendingIntent.getActivity(context, 0, Intent(), PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
|
||||
|
||||
+6
-2
@@ -376,8 +376,12 @@ fun IncomingCallLockScreenAlertLayout(
|
||||
IncomingCallInfo(invitation, chatModel)
|
||||
Spacer(Modifier.fillMaxHeight().weight(1f))
|
||||
if (callOnLockScreen == CallOnLockScreen.ACCEPT) {
|
||||
ProfileImage(size = 192.dp, image = invitation.contact.profile.image)
|
||||
Text(invitation.contact.chatViewName, style = MaterialTheme.typography.h2)
|
||||
if (chatModel.controller.appPrefs.notificationPreviewMode.get() == NotificationPreviewMode.HIDDEN.name) {
|
||||
ProfileImage(size = 192.dp)
|
||||
} else {
|
||||
ProfileImage(size = 192.dp, image = invitation.contact.profile.image)
|
||||
Text(invitation.contact.chatViewName, style = MaterialTheme.typography.h2)
|
||||
}
|
||||
Spacer(Modifier.fillMaxHeight().weight(1f))
|
||||
Row {
|
||||
LockScreenCallButton(stringResource(MR.strings.reject), painterResource(R.drawable.ic_call_end_filled), Color.Red, rejectCall)
|
||||
|
||||
+23
-11
@@ -3,9 +3,12 @@ package chat.simplex.common.platform
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawWithCache
|
||||
import androidx.compose.ui.draw.drawWithContent
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.drawscope.clipRect
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import chat.simplex.common.model.ChatController.appPrefs
|
||||
import chat.simplex.common.views.helpers.KeyChangeEffect
|
||||
@@ -29,14 +32,14 @@ expect fun Modifier.desktopPointerHoverIconHand(): Modifier
|
||||
expect fun Modifier.desktopOnHovered(action: (Boolean) -> Unit): Modifier
|
||||
|
||||
@Composable
|
||||
fun Modifier.desktopModifyBlurredState(enabled: Boolean, blurred: MutableState<Boolean>, showMenu: State<Boolean>,): Modifier {
|
||||
fun Modifier.desktopModifyBlurredState(blurred: MutableState<Boolean>, showMenu: State<Boolean>,): Modifier {
|
||||
val blurRadius = remember { appPrefs.privacyMediaBlurRadius.state }
|
||||
if (appPlatform.isDesktop) {
|
||||
KeyChangeEffect(blurRadius.value) {
|
||||
blurred.value = enabled && blurRadius.value > 0
|
||||
blurred.value = blurRadius.value > 0
|
||||
}
|
||||
}
|
||||
return if (appPlatform.isDesktop && enabled && blurRadius.value > 0 && !showMenu.value) {
|
||||
return if (appPlatform.isDesktop && blurRadius.value > 0 && !showMenu.value) {
|
||||
var job: Job = remember { Job() }
|
||||
LaunchedEffect(Unit) {
|
||||
// The approach here is to allow menu to show up and to not blur the view. When menu is shown and mouse is hovering,
|
||||
@@ -64,24 +67,31 @@ fun blurHidesMedia(enabled: Boolean, blurred: State<Boolean>): Boolean =
|
||||
|
||||
@Composable
|
||||
fun Modifier.privacyBlur(
|
||||
enabled: Boolean,
|
||||
fullSize: 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 (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())) }
|
||||
return if (blurHidesMedia(true, blurred)) {
|
||||
this then (if (fullSize) {
|
||||
val blurredPreview = remember(preview, blurRadius.value) { preview.blurredBy(blurRadius.value) }
|
||||
Modifier.drawWithContent { drawImage(blurredPreview, dstSize = IntSize(size.width.roundToInt(), size.height.roundToInt())) }
|
||||
} else Modifier.drawWithCache {
|
||||
val cropScale = maxOf(size.width / preview.width, size.height / preview.height)
|
||||
val croppedSize = IntSize((preview.width * cropScale).roundToInt(), (preview.height * cropScale).roundToInt())
|
||||
val blurredCropped = preview.blurredBy(blurRadius.value, croppedSize.width.toDp().value)
|
||||
val offset = IntOffset(((size.width - croppedSize.width) / 2).roundToInt(), ((size.height - croppedSize.height) / 2).roundToInt())
|
||||
onDrawWithContent { clipRect { drawImage(blurredCropped, dstOffset = offset, dstSize = croppedSize) } }
|
||||
})
|
||||
.combinedClickable(
|
||||
onLongClick = onLongClick,
|
||||
onClick = {
|
||||
blurred.value = false
|
||||
}
|
||||
)
|
||||
} else if (enabled && blurRadius.value > 0 && appPlatform.isAndroid) {
|
||||
} else if (blurRadius.value > 0 && appPlatform.isAndroid) {
|
||||
LaunchedEffect(Unit) {
|
||||
snapshotFlow { scrollState.value }
|
||||
.filter { it }
|
||||
@@ -96,12 +106,14 @@ fun Modifier.privacyBlur(
|
||||
|
||||
// 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
|
||||
// The width in-chat media is assumed to be drawn at; small views are blurred as much on screen.
|
||||
private const val CHAT_MEDIA_WIDTH_DP = 360
|
||||
// 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 {
|
||||
private fun ImageBitmap.blurredBy(radius: Int, drawnWidthDp: Float = CHAT_MEDIA_WIDTH_DP.toFloat()): ImageBitmap {
|
||||
if (width <= 0 || height <= 0) return this
|
||||
val w = (BLURRED_MEDIA_WIDTH_DP / radius).coerceIn(1, width)
|
||||
val w = (BLURRED_MEDIA_WIDTH_DP * drawnWidthDp / CHAT_MEDIA_WIDTH_DP / radius).toInt().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) {
|
||||
|
||||
+1
@@ -2470,6 +2470,7 @@ fun BoxScope.ChatItemsList(
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
snapshotFlow { listState.value.isScrollInProgress }
|
||||
.onCompletion { chatViewScrollState.value = false }
|
||||
.collect {
|
||||
chatViewScrollState.value = it
|
||||
}
|
||||
|
||||
+4
-8
@@ -120,7 +120,7 @@ fun CIImageView(
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ImageView(painter: Painter, image: String, fileSource: CryptoFile?, onClick: () -> Unit) {
|
||||
fun ImageView(painter: Painter, image: String, onClick: () -> Unit) {
|
||||
// On my Android device Compose fails to display 6000x6000 px WebP image with exception:
|
||||
// IllegalStateException: Recording currently in progress - missing #endRecording() call?
|
||||
// but can display 5000px image. Using even lower value here just to feel safer.
|
||||
@@ -152,11 +152,7 @@ fun CIImageView(
|
||||
.privacyBlur(!smallView, previewBitmap, blurred, scrollState = chatViewScrollState.collectAsState(), onLongClick = { showMenu.value = true }),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
imageView(previewBitmap, onClick = {
|
||||
if (fileSource != null) {
|
||||
openFile(fileSource)
|
||||
}
|
||||
})
|
||||
imageView(previewBitmap, onClick = onClick)
|
||||
Icon(
|
||||
painterResource(MR.images.ic_open_in_new),
|
||||
contentDescription = stringResource(MR.strings.image_descr),
|
||||
@@ -191,7 +187,7 @@ fun CIImageView(
|
||||
}
|
||||
} else Modifier
|
||||
)
|
||||
.desktopModifyBlurredState(!smallView, blurred, showMenu),
|
||||
.desktopModifyBlurredState(blurred, showMenu),
|
||||
contentAlignment = Alignment.TopEnd
|
||||
) {
|
||||
val res: MutableState<Triple<ImageBitmap, ByteArray, String>?> = remember { mutableStateOf(null) }
|
||||
@@ -217,7 +213,7 @@ fun CIImageView(
|
||||
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) })
|
||||
SimpleAndAnimatedImageView(data, imageBitmap, file, imageProvider, smallView, blurred, @Composable { painter, onClick -> ImageView(painter, image, onClick) })
|
||||
} else {
|
||||
imageView(previewBitmap, onClick = {
|
||||
if (file != null) {
|
||||
|
||||
+9
-7
@@ -54,7 +54,7 @@ fun CIVideoView(
|
||||
}
|
||||
} else Modifier
|
||||
)
|
||||
.desktopModifyBlurredState(!smallView, blurred, showMenu),
|
||||
.desktopModifyBlurredState(blurred, showMenu),
|
||||
contentAlignment = Alignment.TopEnd
|
||||
) {
|
||||
val filePath = remember(file, CIFile.cachedRemoteFileRequests.toList()) { mutableStateOf(getLoadedFilePath(file)) }
|
||||
@@ -84,11 +84,11 @@ fun CIVideoView(
|
||||
val uriDecrypted = remember(filePath) { mutableStateOf(if (file.fileSource?.cryptoArgs == null) uri else file.fileSource.decryptedGet()) }
|
||||
val decrypted = uriDecrypted.value
|
||||
if (decrypted != null && smallView) {
|
||||
SmallVideoView(decrypted, file, preview, duration * 1000L, autoPlay, sizeMultiplier, openFullscreen = openFullscreen)
|
||||
SmallVideoView(decrypted, file, preview, duration * 1000L, autoPlay, blurred, sizeMultiplier, openFullscreen = openFullscreen)
|
||||
} else if (decrypted != null) {
|
||||
VideoView(decrypted, file, preview, duration * 1000L, autoPlay, showMenu, blurred, openFullscreen = openFullscreen)
|
||||
} else if (smallView) {
|
||||
SmallVideoViewEncrypted(uriDecrypted, file, preview, autoPlay, showMenu, sizeMultiplier, openFullscreen = openFullscreen)
|
||||
SmallVideoViewEncrypted(uriDecrypted, file, preview, autoPlay, showMenu, blurred, sizeMultiplier, openFullscreen = openFullscreen)
|
||||
} else {
|
||||
VideoViewEncrypted(uriDecrypted, file, preview, duration * 1000L, autoPlay, showMenu, blurred, openFullscreen = openFullscreen)
|
||||
}
|
||||
@@ -184,16 +184,17 @@ private fun SmallVideoViewEncrypted(
|
||||
defaultPreview: ImageBitmap,
|
||||
autoPlay: MutableState<Boolean>,
|
||||
showMenu: MutableState<Boolean>,
|
||||
blurred: MutableState<Boolean>,
|
||||
sizeMultiplier: Float,
|
||||
openFullscreen: () -> Unit,
|
||||
) {
|
||||
var decryptionInProgress by rememberSaveable(file.fileName) { mutableStateOf(false) }
|
||||
val onLongClick = { showMenu.value = true }
|
||||
Box {
|
||||
VideoPreviewImageView(defaultPreview, smallView = true, blurred = remember { mutableStateOf(false) }, onClick = if (decryptionInProgress) {{}} else openFullscreen, onLongClick = onLongClick)
|
||||
VideoPreviewImageView(defaultPreview, smallView = true, blurred = blurred, onClick = if (decryptionInProgress) {{}} else openFullscreen, onLongClick = onLongClick)
|
||||
if (decryptionInProgress) {
|
||||
VideoDecryptionProgress(sizeMultiplier, onLongClick = onLongClick)
|
||||
} else if (!file.showStatusIconInSmallView) {
|
||||
} else if (!file.showStatusIconInSmallView && !blurHidesMedia(true, blurred)) {
|
||||
PlayButton(false, sizeMultiplier, onLongClick = onLongClick) {
|
||||
decryptionInProgress = true
|
||||
withBGApi {
|
||||
@@ -216,6 +217,7 @@ private fun SmallVideoView(
|
||||
defaultPreview: ImageBitmap,
|
||||
defaultDuration: Long,
|
||||
autoPlay: MutableState<Boolean>,
|
||||
blurred: MutableState<Boolean>,
|
||||
sizeMultiplier: Float,
|
||||
openFullscreen: () -> Unit
|
||||
) {
|
||||
@@ -233,8 +235,8 @@ private fun SmallVideoView(
|
||||
onLongClick = {},
|
||||
{}
|
||||
)
|
||||
VideoPreviewImageView(preview, smallView = true, blurred = remember { mutableStateOf(false) }, onClick = openFullscreen, onLongClick = {})
|
||||
if (!file.showStatusIconInSmallView) {
|
||||
VideoPreviewImageView(preview, smallView = true, blurred = blurred, onClick = openFullscreen, onLongClick = {})
|
||||
if (!file.showStatusIconInSmallView && !blurHidesMedia(true, blurred)) {
|
||||
PlayButton(brokenVideo, sizeMultiplier, onLongClick = {}, onClick = openFullscreen)
|
||||
}
|
||||
}
|
||||
|
||||
+3
-2
@@ -153,6 +153,7 @@ fun FramedItemView(
|
||||
|
||||
@Composable
|
||||
fun ciQuoteView(qi: CIQuote) {
|
||||
val blurred = remember { mutableStateOf(appPreferences.privacyMediaBlurRadius.get() > 0) }
|
||||
val sentColor = MaterialTheme.appColors.sentQuote
|
||||
val receivedColor = MaterialTheme.appColors.receivedQuote
|
||||
Row(
|
||||
@@ -170,7 +171,7 @@ fun FramedItemView(
|
||||
imageBitmap,
|
||||
contentDescription = stringResource(MR.strings.image_descr),
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.size(68.dp).clipToBounds()
|
||||
modifier = Modifier.size(68.dp).clipToBounds().desktopModifyBlurredState(blurred, showMenu).privacyBlur(fullSize = false, imageBitmap, blurred, chatViewScrollState.collectAsState(), onLongClick = { showMenu.value = true })
|
||||
)
|
||||
}
|
||||
is MsgContent.MCVideo -> {
|
||||
@@ -182,7 +183,7 @@ fun FramedItemView(
|
||||
imageBitmap,
|
||||
contentDescription = stringResource(MR.strings.video_descr),
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.size(68.dp).clipToBounds()
|
||||
modifier = Modifier.size(68.dp).clipToBounds().desktopModifyBlurredState(blurred, showMenu).privacyBlur(fullSize = false, imageBitmap, blurred, chatViewScrollState.collectAsState(), onLongClick = { showMenu.value = true })
|
||||
)
|
||||
}
|
||||
is MsgContent.MCFile, is MsgContent.MCVoice -> {
|
||||
|
||||
+9
-4
@@ -311,26 +311,31 @@ fun ChatPreviewView(
|
||||
mutableStateOf({ providerForGallery(chat.chatItems, ci?.id ?: 0) {} })
|
||||
}
|
||||
val uriHandler = LocalUriHandler.current
|
||||
// Media in the chat list has no menu, so a menu opened from it must close at once, or the media stays revealed.
|
||||
val noMenu = remember { mutableStateOf(false) }
|
||||
LaunchedEffect(noMenu.value) { noMenu.value = false }
|
||||
when (mc) {
|
||||
is MsgContent.MCLink -> SmallContentPreview {
|
||||
val image = remember(mc.preview.image) { base64ToBitmap(mc.preview.image) }
|
||||
val blurred = remember { mutableStateOf(appPrefs.privacyMediaBlurRadius.get() > 0) }
|
||||
IconButton(
|
||||
{ openBrowserAlert(mc.preview.uri, uriHandler) },
|
||||
Modifier.desktopPointerHoverIconHand(),
|
||||
) {
|
||||
Image(base64ToBitmap(mc.preview.image), null, contentScale = ContentScale.Crop)
|
||||
Image(image, null, Modifier.desktopModifyBlurredState(blurred, noMenu).privacyBlur(fullSize = false, image, blurred, chatViewScrollState.collectAsState()), contentScale = ContentScale.Crop)
|
||||
}
|
||||
Box(Modifier.align(Alignment.TopEnd).size(15.sp.toDp()).background(Color.Black.copy(0.25f), CircleShape), contentAlignment = Alignment.Center) {
|
||||
Icon(painterResource(MR.images.ic_arrow_outward), null, Modifier.size(13.sp.toDp()), tint = Color.White)
|
||||
}
|
||||
}
|
||||
is MsgContent.MCImage -> SmallContentPreview {
|
||||
CIImageView(image = mc.image, file = ci.file, provider, remember { mutableStateOf(false) }, smallView = true) {
|
||||
CIImageView(image = mc.image, file = ci.file, provider, noMenu, smallView = true) {
|
||||
val user = chatModel.currentUser.value ?: return@CIImageView
|
||||
withBGApi { chatModel.controller.receiveFile(chat.remoteHostId, user, it) }
|
||||
}
|
||||
}
|
||||
is MsgContent.MCVideo -> SmallContentPreview {
|
||||
CIVideoView(image = mc.image, mc.duration, file = ci.file, provider, remember { mutableStateOf(false) }, smallView = true) {
|
||||
CIVideoView(image = mc.image, mc.duration, file = ci.file, provider, noMenu, smallView = true) {
|
||||
val user = chatModel.currentUser.value ?: return@CIVideoView
|
||||
withBGApi { chatModel.controller.receiveFile(chat.remoteHostId, user, it) }
|
||||
}
|
||||
@@ -421,7 +426,7 @@ fun ChatPreviewView(
|
||||
val deleted = ci?.isDeletedContent == true || ci?.meta?.itemDeleted != null
|
||||
val showContentPreview = (showChatPreviews && chatModelDraftChatId != chat.id && !deleted) || activeVoicePreview.value != null
|
||||
if (ci != null && showContentPreview) {
|
||||
chatItemContentPreview(chat, ci)
|
||||
key(ci.id) { chatItemContentPreview(chat, ci) }
|
||||
}
|
||||
if (mc !is MsgContent.MCVoice || !showContentPreview || mc.text.isNotEmpty() || chatModelDraftChatId == chat.id) {
|
||||
Box(Modifier.offset(x = if (mc is MsgContent.MCFile && ci.meta.itemDeleted == null) -15.sp.toDp() else 0.dp)) {
|
||||
|
||||
+1
-1
@@ -206,7 +206,7 @@ fun ChatItemLinkView(linkPreview: LinkPreview, showMenu: State<Boolean>, onLongC
|
||||
stringResource(MR.strings.image_descr_link_preview),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.desktopModifyBlurredState(true, blurred, showMenu)
|
||||
.desktopModifyBlurredState(blurred, showMenu)
|
||||
.privacyBlur(true, image, blurred, chatViewScrollState.collectAsState(), onLongClick = onLongClick),
|
||||
contentScale = ContentScale.FillWidth,
|
||||
)
|
||||
|
||||
+8
-1
@@ -20,6 +20,7 @@ import java.io.IOException
|
||||
import java.net.BindException
|
||||
import java.security.SecureRandom
|
||||
import java.util.Base64
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
private const val SERVER_HOST = "localhost"
|
||||
private const val SERVER_PORT = 50395
|
||||
@@ -232,10 +233,16 @@ fun startServer(
|
||||
|
||||
val resourceNotFound = newFixedLengthResponse(Status.NOT_FOUND, "text/plain", "This page couldn't be found")
|
||||
|
||||
val webSocketAccepted = AtomicBoolean(false)
|
||||
|
||||
override fun handle(session: IHTTPSession): Response {
|
||||
return when {
|
||||
session.headers["upgrade"] == "websocket" ->
|
||||
if (hasValidCallServerToken(session.parameters, token)) {
|
||||
if (
|
||||
session.headers["origin"] == "http://${SERVER_HOST}:${listeningPort}"
|
||||
&& hasValidCallServerToken(session.parameters, token)
|
||||
&& webSocketAccepted.compareAndSet(false, true)
|
||||
) {
|
||||
super.handle(session)
|
||||
} else {
|
||||
unauthorizedResponse()
|
||||
|
||||
+19
-1
@@ -33,6 +33,23 @@ class CallServerAuthTest {
|
||||
assertEquals(101, requestStatus(webSocketUpgrade(path = "/?token=$token")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testWebSocketUpgradeRejectedFromOtherOrigin() {
|
||||
assertEquals(401, requestStatus(webSocketUpgrade(path = "/?token=$token", origin = "http://example.com")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSecondWebSocketUpgradeRejected() {
|
||||
assertEquals(101, requestStatus(webSocketUpgrade(path = "/?token=$token")))
|
||||
assertEquals(401, requestStatus(webSocketUpgrade(path = "/?token=$token")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testWebSocketUpgradeAcceptedAfterWrongToken() {
|
||||
assertEquals(401, requestStatus(webSocketUpgrade(path = "/?token=wrong")))
|
||||
assertEquals(101, requestStatus(webSocketUpgrade(path = "/?token=$token")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testCallPageRejectedWithoutToken() {
|
||||
assertEquals(401, requestStatus(get(path = "/simplex/call/")))
|
||||
@@ -46,10 +63,11 @@ class CallServerAuthTest {
|
||||
|
||||
private fun get(path: String): List<String> = listOf("GET $path HTTP/1.1", "Host: localhost:$port")
|
||||
|
||||
private fun webSocketUpgrade(path: String): List<String> =
|
||||
private fun webSocketUpgrade(path: String, origin: String = "http://localhost:$port"): List<String> =
|
||||
listOf(
|
||||
"GET $path HTTP/1.1",
|
||||
"Host: localhost:$port",
|
||||
"Origin: $origin",
|
||||
"Upgrade: websocket",
|
||||
"Connection: Upgrade",
|
||||
"Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==",
|
||||
|
||||
@@ -113,17 +113,19 @@ The `actual` platform implementation of `ActiveCallView()` and supporting compos
|
||||
|
||||
## 4. Desktop Implementation
|
||||
|
||||
### 4.1 CallView.desktop.kt (263 lines)
|
||||
### 4.1 CallView.desktop.kt (308 lines)
|
||||
|
||||
[`CallView.desktop.kt`](../../common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt)
|
||||
|
||||
Desktop calls run WebRTC in the system browser, not an embedded WebView:
|
||||
|
||||
- **NanoWSD server** ([line 209](../../common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt#L209)): `startServer()` creates a `NanoWSD` instance bound to `localhost:50395`. If that port is already in use it falls back to an OS-assigned free port (`port 0`); `WebRTCController` reads `server.listeningPort` for the browser URL. The server serves `call.html` from JAR resources at `/assets/www/desktop/call.html` for the path `/simplex/call/`. All other paths serve resources from `/assets/www/`.
|
||||
- **WebSocket communication** ([line 238](../../common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt#L238)): `MyWebSocket` handles WebSocket frames from the browser. `onMessage` deserializes JSON into `WVAPIMessage` and forwards to the response handler. `onClose` triggers `WCallResponse.End`.
|
||||
- **WebRTCController** ([line 153](../../common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt#L153)): Starts the server, then opens `http://localhost:<listeningPort>/simplex/call/` (normally `50395`) via `LocalUriHandler`. Processes `WCallCommand` queue by sending JSON over WebSocket to all active connections. On dispose, sends `WCallCommand.End` and stops the server.
|
||||
- **SendStateUpdates** ([line 137](../../common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt#L137)): Sends `WCallCommand.Description` with call state and encryption info text to the browser for display.
|
||||
- **ActiveCallView** ([line 28](../../common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt#L28)): Handles `WCallResponse` messages identically to Android (same state machine), plus a `WCallCommand.Permission` message on `Capabilities` error for browser permission denial guidance.
|
||||
- **NanoWSD server** ([line 215](../../common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt#L215)): `startServer()` creates a `NanoWSD` instance bound to `localhost:50395`. If that port is already in use it falls back to an OS-assigned free port (`port 0`); `WebRTCController` reads `server.listeningPort` for the browser URL. The server serves `call.html` from JAR resources at `/assets/www/desktop/call.html` for the path `/simplex/call/` when the request includes a valid `token` query parameter. All other paths serve resources from `/assets/www/`.
|
||||
- **Call token** ([line 271](../../common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt#L271)): `newCallServerToken()` returns 32 random bytes, base64url-encoded. `hasValidCallServerToken()` ([line 277](../../common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt#L277)) checks the `token` query parameter.
|
||||
- **WebSocket upgrade** ([line 238](../../common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt#L238)): Each server instance accepts one WebSocket upgrade. The upgrade request must include `Origin: http://localhost:<listeningPort>` and a valid token. Other upgrade requests receive `401 Unauthorized`.
|
||||
- **WebSocket communication** ([line 283](../../common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt#L283)): `MyWebSocket` handles WebSocket frames from the browser. `onMessage` deserializes JSON into `WVAPIMessage` and forwards to the response handler. `onClose` triggers `WCallResponse.End`.
|
||||
- **WebRTCController** ([line 157](../../common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt#L157)): Generates the call token, starts the server, then opens `http://localhost:<listeningPort>/simplex/call/?token=<token>` (normally `50395`) via `LocalUriHandler`. Processes `WCallCommand` queue by sending JSON over the WebSocket. On dispose, sends `WCallCommand.End` and stops the server.
|
||||
- **SendStateUpdates** ([line 141](../../common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt#L141)): Sends `WCallCommand.Description` with call state and encryption info text to the browser for display.
|
||||
- **ActiveCallView** ([line 32](../../common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt#L32)): Handles `WCallResponse` messages identically to Android (same state machine), plus a `WCallCommand.Permission` message on `Capabilities` error for browser permission denial guidance.
|
||||
|
||||
---
|
||||
|
||||
@@ -166,7 +168,7 @@ An in-app notification banner shown when a call invitation arrives while the app
|
||||
|---|---|---|---|
|
||||
| `CallView.kt` | [`common/src/commonMain/.../views/call/CallView.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/views/call/CallView.kt) | 28 | `expect fun ActiveCallView()`, delivery receipt waiting |
|
||||
| `CallView.android.kt` | [`common/src/androidMain/.../views/call/CallView.android.kt`](../../common/src/androidMain/kotlin/chat/simplex/common/views/call/CallView.android.kt) | 891 | Android WebView WebRTC, overlay, permissions |
|
||||
| `CallView.desktop.kt` | [`common/src/desktopMain/.../views/call/CallView.desktop.kt`](../../common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt) | 263 | Desktop browser WebRTC via NanoWSD |
|
||||
| `CallView.desktop.kt` | [`common/src/desktopMain/.../views/call/CallView.desktop.kt`](../../common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt) | 308 | Desktop browser WebRTC via NanoWSD |
|
||||
| `CallActivity.kt` | [`android/src/main/java/.../views/call/CallActivity.kt`](../../android/src/main/java/chat/simplex/app/views/call/CallActivity.kt) | 464 | Android call Activity, PiP, lock screen |
|
||||
| `CallService.kt` | [`android/src/main/java/.../CallService.kt`](../../android/src/main/java/chat/simplex/app/CallService.kt) | 207 | Android foreground service for calls |
|
||||
| `CallManager.kt` | [`common/src/commonMain/.../views/call/CallManager.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/views/call/CallManager.kt) | 119 | Call lifecycle management |
|
||||
|
||||
@@ -101,7 +101,7 @@ Channel creation happens in `createNtfChannelsMaybeShowAlert()` ([line 298](../.
|
||||
[Line 160](../../android/src/main/java/chat/simplex/app/model/NtfManager.android.kt#L160):
|
||||
|
||||
- Returns `false` (no notification) if app is in foreground -- in-app alert is used instead.
|
||||
- **Lock screen / screen off**: Uses `setFullScreenIntent` with a `PendingIntent` to `CallActivity`, plus `VISIBILITY_PUBLIC`.
|
||||
- **Lock screen / screen off**: Uses `setFullScreenIntent` with a `PendingIntent` to `CallActivity`.
|
||||
- **Foreground / unlocked**: Uses regular notification with Accept/Reject action buttons and a custom ringtone (`ring_once` raw resource).
|
||||
- Notification flags include `FLAG_INSISTENT` for repeating sound and vibration.
|
||||
- Call notification channel vibration pattern: `[250, 250, 0, 2600]` ms.
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
# Blur media thumbnails in reply quotes and the chat list
|
||||
|
||||
## Problem
|
||||
|
||||
"Blur media" hides images and videos in a chat until they are tapped (on desktop, hovered), but
|
||||
the same media is shown sharp in two other places:
|
||||
|
||||
- the thumbnail of a quoted image or video in a reply (`FramedItemView.ciQuoteView`, 68 dp), in
|
||||
every chat, search result, report and support chat;
|
||||
- the chat list, where the last message's image, video or link preview is drawn as a 36 sp
|
||||
thumbnail next to the message text (`ChatPreviewView.chatItemContentPreview`).
|
||||
|
||||
The chat list is on screen most of the time, so it shows exactly what the setting promised to
|
||||
hide.
|
||||
|
||||
## Cause
|
||||
|
||||
Small views were excluded on purpose: every call passed `privacyBlur(enabled = !smallView, ...)`
|
||||
and `desktopModifyBlurredState(!smallView, ...)`, the quote thumbnail never called them, and the
|
||||
chat-list link preview was a plain `Image`. The chat-list video views (`SmallVideoView`,
|
||||
`SmallVideoViewEncrypted`) created their own `remember { mutableStateOf(false) }` blur state, so
|
||||
they could not be blurred even if the modifier were enabled.
|
||||
|
||||
Enabling the existing blur for small views is not enough on its own, because
|
||||
`ImageBitmap.blurredBy` (#7483) resamples the preview to `400 / radius` pixels across the whole
|
||||
image, a width calibrated for media drawn about 360 dp wide. Drawn into a 36 sp thumbnail, 33 px
|
||||
at Soft is almost sharp.
|
||||
|
||||
## Fix
|
||||
|
||||
`privacyBlur` takes `fullSize` in place of `enabled`. Existing call sites keep passing
|
||||
`!smallView` or `true`, so in-chat media takes the same code as before: the same `remember`ed
|
||||
resample, drawn with `drawWithContent`. Small views (`fullSize = false`):
|
||||
|
||||
- are resampled relative to their drawn width: `blurredBy` gets the drawn width, and the pixel
|
||||
count is `400 * drawnWidth / 360 / radius`, so a thumbnail is blurred as much on screen as
|
||||
media in a chat. The default width is 360, which gives exactly master's `400 / radius` for
|
||||
in-chat media;
|
||||
- are drawn as the centre crop of the image scaled by `ContentScale.Crop`, clipped to the view,
|
||||
so the blurred thumbnail covers the same region as the revealed one and is blurred evenly in
|
||||
both directions; a whole image squeezed into a square would be blurred up to 4x less along
|
||||
its long side;
|
||||
- use `drawWithCache`, so the blur is computed once per size and not on every frame.
|
||||
|
||||
`enabled` is removed from `desktopModifyBlurredState`: once small views are blurred every caller
|
||||
passes `true`. It stays on `blurHidesMedia`, which still has a real caller (`CIImageView`).
|
||||
|
||||
Wiring:
|
||||
|
||||
- quote thumbnails get one blur state per quote, the item's menu state, and the same reveal as
|
||||
chat media: a tap reveals, a second tap scrolls to the quoted message, long-press opens the
|
||||
item menu;
|
||||
- the chat-list link preview gets a remembered bitmap and blur state;
|
||||
- `SmallVideoView` and `SmallVideoViewEncrypted` take the item's real blur state, and their play
|
||||
buttons, which open the video full screen, are hidden while it is blurred.
|
||||
|
||||
## Leaks closed by the change
|
||||
|
||||
Blurring the chat list exposed three ways for a thumbnail to be shown, or stay shown, without
|
||||
the user revealing it:
|
||||
|
||||
- the chat list is keyed by chat, not by message, so state remembered for one last message was
|
||||
reused for the next: a new image arriving in a chat whose previous image was revealed would be
|
||||
shown revealed. `key(ci.id)` around `chatItemContentPreview` gives each message its own state;
|
||||
- the chat list passed CIImageView and CIVideoView a throwaway `showMenu` that their long-press
|
||||
and right-click set to `true` and nothing reset. On desktop `desktopModifyBlurredState` stops
|
||||
re-blurring on hover-out while `showMenu` is true, so a right-clicked thumbnail stayed sharp.
|
||||
The chat list now passes `noMenu`, which a `LaunchedEffect` resets as soon as it is set: a
|
||||
menu that closes at once;
|
||||
- `chatViewScrollState` is a global written only by the chat view's list, and its collector was
|
||||
cancelled without a final value when a chat closed. Leaving a chat while its list was still
|
||||
flinging left it `true`, and every reveal in the chat list was undone at once by the Android
|
||||
re-blur, until another chat was opened. The collector now writes `false` on completion.
|
||||
|
||||
## Bounds
|
||||
|
||||
The preview dimensions come from the sender. `base64ToBitmap` never returns a bitmap with a zero
|
||||
side (it falls back to an error bitmap), so the crop scale is finite. A zero-size view gives a
|
||||
zero crop, which `blurredBy` clamps to one pixel. `blurredBy` keeps its existing bounds: the
|
||||
first step caps both sides at 512 px and the output height at 400 px; a very wide preview widens
|
||||
the resample to at most its own width while its height drops to a few pixels. The doubling
|
||||
ascent still targets 200 px, so a thumbnail holds a bitmap of up to 384 px (about 590 KB) - more
|
||||
than it needs at 36 sp, harmless.
|
||||
|
||||
## Base
|
||||
|
||||
The change is built on `stable` after #7483 (blur media by resampling), because the small-view
|
||||
blur extends #7483's resample. On stable, `SimpleAndAnimatedImageView` has no `blurred`
|
||||
parameter, which came from #7365 (animated images) on master; neither #7483 nor this change
|
||||
depends on it.
|
||||
|
||||
## Verification
|
||||
|
||||
Read-only so far; nothing on this branch has been built or run.
|
||||
|
||||
- In-chat media: `(400f * 360 / 360 / r).toInt()` equals master's `400 / r` for every positive
|
||||
radius, and the `fullSize` path is master's code re-indented.
|
||||
- The centre crop was checked by simulating Compose's `ContentScale.Crop` / `Alignment.Center`
|
||||
placement against the blur's crop for 125,388 combinations of preview size (1 to 10000 px,
|
||||
extreme aspects), view size (0 to 600 px) and measurement mode: no pixel differed.
|
||||
- `Modifier.kt` was compiled with Kotlin 2.1.20 and the Compose 1.8.2 plugin against the 1.8.2
|
||||
desktop jars, app references stubbed; the bytecode shows the `drawWithCache` lambda is
|
||||
remembered on the preview and radius, so recomposition does not re-blur.
|
||||
- Independent adversarial reviews, on master and again on the stable base, ended with two
|
||||
consecutive passes finding no defect.
|
||||
|
||||
To do before merging: build desktop and Android, and check with a test profile receiving an
|
||||
image, a video and a link as a chat's last message, and a reply quoting each, at Soft, Medium and
|
||||
Strong.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Keyboard and TalkBack: the outer click of a blurred video opens it full screen without
|
||||
revealing it first. This already happens in a chat on stable and master; fixing it changes
|
||||
in-chat behaviour, so it is a separate change. TalkBack also cannot reveal a blurred chat-list
|
||||
image.
|
||||
- Turning blur on does not re-blur media already on screen on Android (desktop does); chat-list
|
||||
rows pick it up when recomposed from scratch.
|
||||
- Chat-list reveals re-blur when a chat is scrolled, not when the chat list is.
|
||||
- At 36 sp, Medium and Strong both resample to a single pixel: a flat colour.
|
||||
- Chat-link cards (contact and group profile images), compose-bar previews of your own media and
|
||||
links, and the full-screen gallery (which shows neighbouring media sharp when swiped) are
|
||||
unchanged.
|
||||
- iOS has the same gaps in quotes and the chat list; it follows as a separate change.
|
||||
@@ -38,6 +38,23 @@
|
||||
</description>
|
||||
|
||||
<releases>
|
||||
<release version="7.0.3" date="2026-09-26">
|
||||
<url type="details">https://simplex.chat/blog/20260722-simplex-public-names.html</url>
|
||||
<description>
|
||||
<p>New in v7.0-v7.0.3.</p>
|
||||
<p>SimpleX public names for channels and businesses (BETA).</p>
|
||||
<p>Better channels:</p>
|
||||
<ul>
|
||||
<li>Promote subscribers to contributors.</li>
|
||||
<li>Publish your channel on your website.</li>
|
||||
<li>Verify security code with contributors.</li>
|
||||
<li>Wider messages, easier to read.</li>
|
||||
<li>Host your own chat relays.</li>
|
||||
</ul>
|
||||
<p>Add a longer description to your profile.</p>
|
||||
<p>Simplified app settings.</p>
|
||||
</description>
|
||||
</release>
|
||||
<release version="7.0.2" date="2026-09-02">
|
||||
<url type="details">https://simplex.chat/blog/20260722-simplex-public-names.html</url>
|
||||
<description>
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ cabal-version: 1.12
|
||||
-- see: https://github.com/sol/hpack
|
||||
|
||||
name: simplex-chat
|
||||
version: 7.1.0.9
|
||||
version: 7.1.0.10
|
||||
category: Web, System, Services, Cryptography
|
||||
homepage: https://github.com/simplex-chat/simplex-chat#readme
|
||||
author: simplex.chat
|
||||
|
||||
@@ -2867,7 +2867,8 @@ processChatCommand cxt nm = \case
|
||||
Nothing -> throwChatError $ CEContactNotActive ct
|
||||
APIAcceptMember groupId gmId role -> withUser $ \user@User {userId} -> do
|
||||
(g@(GIK gInfo _), m) <- withFastStore $ \db -> (,) <$> getGroupInfoKeys db cxt user groupId <*> getGroupMemberById db cxt user gmId
|
||||
assertUserGroupRole gInfo $ max GRModerator role
|
||||
-- same rule as role change (moderators grant up to member); pending member's role is a stand-in, so treat it as member
|
||||
assertUserGroupRole gInfo $ roleRequiredToChange GRMember role
|
||||
case memberStatus m of
|
||||
GSMemPendingApproval | memberCategory m == GCInviteeMember -> do -- only host can approve
|
||||
let GroupInfo {groupProfile = GroupProfile {memberAdmission}} = gInfo
|
||||
@@ -2946,6 +2947,8 @@ processChatCommand cxt nm = \case
|
||||
throwCmdError "can't change role of multiple members when admins selected, or new role is admin"
|
||||
when anyPending $ throwCmdError "can't change role of members pending approval"
|
||||
when (anyRelay || newRole == GRRelay) $ throwCmdError "relay role can't be changed"
|
||||
-- TODO [multi-owner] allow once owners are added via link data - until then promoted owner would lack link authority
|
||||
when (useRelays' gInfo && newRole == GROwner) $ throwCmdError "owner role can't be assigned in channels"
|
||||
-- TODO allow moderators (needs UI) - relay is rejected above (anyRelay), so drop the GRAdmin floor:
|
||||
-- TODO assertUserGroupRole gInfo (roleRequiredToChange maxRole newRole)
|
||||
assertUserGroupRole gInfo $ maximum ([GRAdmin, maxRole, newRole] :: [GroupMemberRole])
|
||||
|
||||
@@ -840,6 +840,16 @@ receiveViaCompleteFD user fileId RcvFileDescr {fileDescrText, fileDescrComplete}
|
||||
rcvSize = max (toInteger encSize) redirectSize
|
||||
-- 10 MB margin: encryption and chunk-size rounding make the transfer larger than the advertised size
|
||||
maxRcvSize = min expectedFileSize (toInteger FD.maxFileSizeHard) + toInteger (FD.mb 10 :: Int64)
|
||||
-- TODO re-enable redirects with relay checks
|
||||
when (isJust redirect) $ do
|
||||
cxt <- chatStoreCxt
|
||||
aci_ <- withStore $ \db -> do
|
||||
liftIO $ updateFileCancelled db user fileId (CIFSRcvError $ FileErrOther "redirect not allowed")
|
||||
lookupChatItemByFileId db cxt user fileId
|
||||
forM_ aci_ $ \aci -> do
|
||||
cleanupACIFile aci
|
||||
toView $ CEvtChatItemUpdated user aci
|
||||
throwChatError $ CEInvalidFileDescription "redirect not allowed"
|
||||
when (rcvSize > maxRcvSize) $ throwChatError $ CEFileRcvChunk "declared file size exceeds the file invitation size"
|
||||
if userApprovedRelays
|
||||
then receive' rd True
|
||||
|
||||
@@ -2826,7 +2826,8 @@ processAgentMessageConn cxt user@User {userId} entity gks_ corrId agentConnId ag
|
||||
|
||||
xGrpLinkAcpt :: GroupInfoKeys -> GroupMember -> GroupAcceptance -> GroupMemberRole -> MemberId -> RcvMessage -> UTCTime -> CM ()
|
||||
xGrpLinkAcpt g@(GIK gInfo@GroupInfo {membership} _) m acceptance role memberId msg brokerTs
|
||||
| memberRole' m < GRModerator || memberRole' m < role =
|
||||
-- same rule as role change (moderators grant up to member); pending member's role is a stand-in, so treat it as member
|
||||
| memberRole' m < roleRequiredToChange GRMember role =
|
||||
messageError "x.grp.link.acpt with insufficient member permissions"
|
||||
| sameMemberId memberId membership = processUserAccepted
|
||||
| otherwise =
|
||||
|
||||
Reference in new issue
Block a user