Merge branch 'master' into master-ghc8107

This commit is contained in:
Evgeny Poberezkin 2023-09-27 16:04:25 +01:00
commit 98a3fc214d
26 changed files with 418 additions and 140 deletions

View File

@ -97,7 +97,7 @@ kotlin {
implementation("com.github.Dansoftowner:jSystemThemeDetector:3.6")
implementation("com.sshtools:two-slices:0.9.0-SNAPSHOT")
implementation("org.slf4j:slf4j-simple:2.0.7")
implementation("uk.co.caprica:vlcj:4.7.0")
implementation("uk.co.caprica:vlcj:4.7.3")
}
}
val desktopTest by getting

View File

@ -73,6 +73,11 @@ else()
target_link_libraries(app-lib rts simplex)
endif()
if(APPLE)
add_custom_command(TARGET app-lib POST_BUILD
COMMAND ${CMAKE_CURRENT_SOURCE_DIR}/patch-libapp-mac.sh
)
endif()
# Trying to copy resulting files into needed directory, but none of these work for some reason. This could allow to

View File

@ -0,0 +1,8 @@
#!/bin/bash
set -e
lib=libapp-lib.dylib
RPATHS=$(otool -l $lib | grep -E '/Users|/opt/|/usr/local' | cut -d' ' -f11)
for RPATH in $RPATHS; do
install_name_tool -delete_rpath $RPATH $lib
done

View File

@ -516,8 +516,8 @@ object ChatController {
throw Exception("failed to delete the user ${r.responseType} ${r.details}")
}
suspend fun apiStartChat(): Boolean {
val r = sendCmd(CC.StartChat(expire = true))
suspend fun apiStartChat(openDBWithKey: String? = null): Boolean {
val r = sendCmd(CC.StartChat(ChatCtrlCfg(subConns = true, enableExpireCIs = true, startXFTPWorkers = true, openDBWithKey = openDBWithKey)))
when (r) {
is CR.ChatStarted -> return true
is CR.ChatRunning -> return false
@ -525,8 +525,8 @@ object ChatController {
}
}
suspend fun apiStopChat(): Boolean {
val r = sendCmd(CC.ApiStopChat())
suspend fun apiStopChat(closeStore: Boolean): Boolean {
val r = sendCmd(CC.ApiStopChat(closeStore))
when (r) {
is CR.ChatStopped -> return true
else -> throw Error("failed stopping chat: ${r.responseType} ${r.details}")
@ -1829,8 +1829,8 @@ sealed class CC {
class ApiMuteUser(val userId: Long): CC()
class ApiUnmuteUser(val userId: Long): CC()
class ApiDeleteUser(val userId: Long, val delSMPQueues: Boolean, val viewPwd: String?): CC()
class StartChat(val expire: Boolean): CC()
class ApiStopChat: CC()
class StartChat(val cfg: ChatCtrlCfg): CC()
class ApiStopChat(val closeStore: Boolean): CC()
class SetTempFolder(val tempFolder: String): CC()
class SetFilesFolder(val filesFolder: String): CC()
class ApiSetXFTPConfig(val config: XFTPFileConfig?): CC()
@ -1933,8 +1933,9 @@ sealed class CC {
is ApiMuteUser -> "/_mute user $userId"
is ApiUnmuteUser -> "/_unmute user $userId"
is ApiDeleteUser -> "/_delete user $userId del_smp=${onOff(delSMPQueues)}${maybePwd(viewPwd)}"
is StartChat -> "/_start subscribe=on expire=${onOff(expire)} xftp=on"
is ApiStopChat -> "/_stop"
// is StartChat -> "/_start ${json.encodeToString(cfg)}" // this can be used with the new core
is StartChat -> "/_start subscribe=on expire=${onOff(cfg.enableExpireCIs)} xftp=on"
is ApiStopChat -> if (closeStore) "/_stop close" else "/_stop"
is SetTempFolder -> "/_temp_folder $tempFolder"
is SetFilesFolder -> "/_files_folder $filesFolder"
is ApiSetXFTPConfig -> if (config != null) "/_xftp on ${json.encodeToString(config)}" else "/_xftp off"
@ -2151,6 +2152,14 @@ sealed class CC {
}
}
@Serializable
data class ChatCtrlCfg (
val subConns: Boolean,
val enableExpireCIs: Boolean,
val startXFTPWorkers: Boolean,
val openDBWithKey: String?
)
@Serializable
data class NewUser(
val profile: Profile?,

View File

@ -127,7 +127,7 @@ private fun VideoView(uri: URI, file: CIFile, defaultPreview: ImageBitmap, defau
)
if (showPreview.value) {
VideoPreviewImageView(preview, onClick, onLongClick)
PlayButton(brokenVideo, onLongClick = onLongClick, if (appPlatform.isAndroid) play else onClick)
PlayButton(brokenVideo, onLongClick = onLongClick, play)
}
DurationProgress(file, videoPlaying, duration, progress/*, soundEnabled*/)
}

View File

@ -158,12 +158,11 @@ private fun VideoView(modifier: Modifier, uri: URI, defaultPreview: ImageBitmap,
player.stop()
}
LaunchedEffect(Unit) {
player.enableSound(true)
snapshotFlow { isCurrentPage.value }
.distinctUntilChanged()
.collect {
// Do not autoplay on desktop because it needs workaround
if (it && appPlatform.isAndroid) play() else if (!it) stop()
if (it) play() else stop()
player.enableSound(true)
}
}

View File

@ -419,7 +419,7 @@ private fun stopChat(m: ChatModel) {
}
suspend fun stopChatAsync(m: ChatModel) {
m.controller.apiStopChat()
m.controller.apiStopChat(false)
m.chatRunning.value = false
}

View File

@ -0,0 +1,79 @@
package org.jetbrains.compose.videoplayer
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asComposeImageBitmap
import org.jetbrains.skia.Bitmap
import org.jetbrains.skia.ColorAlphaType
import org.jetbrains.skia.ColorType
import org.jetbrains.skia.ImageInfo
import uk.co.caprica.vlcj.player.base.MediaPlayer
import uk.co.caprica.vlcj.player.embedded.videosurface.CallbackVideoSurface
import uk.co.caprica.vlcj.player.embedded.videosurface.VideoSurface
import uk.co.caprica.vlcj.player.embedded.videosurface.VideoSurfaceAdapters
import uk.co.caprica.vlcj.player.embedded.videosurface.callback.BufferFormat
import uk.co.caprica.vlcj.player.embedded.videosurface.callback.BufferFormatCallback
import uk.co.caprica.vlcj.player.embedded.videosurface.callback.RenderCallback
import uk.co.caprica.vlcj.player.embedded.videosurface.callback.format.RV32BufferFormat
import java.nio.ByteBuffer
import javax.swing.SwingUtilities
// https://github.com/JetBrains/compose-multiplatform/pull/3336/files
internal class SkiaBitmapVideoSurface : VideoSurface(VideoSurfaceAdapters.getVideoSurfaceAdapter()) {
private val videoSurface = SkiaBitmapVideoSurface()
private lateinit var imageInfo: ImageInfo
private lateinit var frameBytes: ByteArray
private val skiaBitmap: Bitmap = Bitmap()
private val composeBitmap = mutableStateOf<ImageBitmap?>(null)
val bitmap: State<ImageBitmap?> = composeBitmap
override fun attach(mediaPlayer: MediaPlayer) {
videoSurface.attach(mediaPlayer)
}
private inner class SkiaBitmapBufferFormatCallback : BufferFormatCallback {
private var sourceWidth: Int = 0
private var sourceHeight: Int = 0
override fun getBufferFormat(sourceWidth: Int, sourceHeight: Int): BufferFormat {
this.sourceWidth = sourceWidth
this.sourceHeight = sourceHeight
return RV32BufferFormat(sourceWidth, sourceHeight)
}
override fun allocatedBuffers(buffers: Array<ByteBuffer>) {
frameBytes = buffers[0].run { ByteArray(remaining()).also(::get) }
imageInfo = ImageInfo(
sourceWidth,
sourceHeight,
ColorType.BGRA_8888,
ColorAlphaType.PREMUL,
)
}
}
private inner class SkiaBitmapRenderCallback : RenderCallback {
override fun display(
mediaPlayer: MediaPlayer,
nativeBuffers: Array<ByteBuffer>,
bufferFormat: BufferFormat,
) {
SwingUtilities.invokeLater {
nativeBuffers[0].rewind()
nativeBuffers[0].get(frameBytes)
skiaBitmap.installPixels(imageInfo, frameBytes, bufferFormat.width * 4)
composeBitmap.value = skiaBitmap.asComposeImageBitmap()
}
}
}
private inner class SkiaBitmapVideoSurface : CallbackVideoSurface(
SkiaBitmapBufferFormatCallback(),
SkiaBitmapRenderCallback(),
true,
videoSurfaceAdapter,
)
}

View File

@ -6,6 +6,8 @@ import boofcv.struct.image.GrayU8
import chat.simplex.res.MR
import org.jetbrains.skia.Image
import java.awt.RenderingHints
import java.awt.geom.AffineTransform
import java.awt.image.AffineTransformOp
import java.awt.image.BufferedImage
import java.io.*
import java.net.URI
@ -171,3 +173,37 @@ actual fun isAnimImage(uri: URI, drawable: Any?): Boolean {
@Suppress("NewApi")
actual fun loadImageBitmap(inputStream: InputStream): ImageBitmap =
Image.makeFromEncoded(inputStream.readAllBytes()).toComposeImageBitmap()
// https://stackoverflow.com/a/68926993
fun BufferedImage.rotate(angle: Double): BufferedImage {
val sin = Math.abs(Math.sin(Math.toRadians(angle)))
val cos = Math.abs(Math.cos(Math.toRadians(angle)))
val w = width
val h = height
val neww = Math.floor(w * cos + h * sin).toInt()
val newh = Math.floor(h * cos + w * sin).toInt()
val rotated = BufferedImage(neww, newh, type)
val graphic = rotated.createGraphics()
graphic.translate((neww - w) / 2, (newh - h) / 2)
graphic.rotate(Math.toRadians(angle), (w / 2).toDouble(), (h / 2).toDouble())
graphic.drawRenderedImage(this, null)
graphic.dispose()
return rotated
}
// https://stackoverflow.com/a/9559043
fun BufferedImage.flip(vertically: Boolean, horizontally: Boolean): BufferedImage {
if (!vertically && !horizontally) return this
val tx: AffineTransform
if (vertically && horizontally) {
tx = AffineTransform.getScaleInstance(-1.0, -1.0)
tx.translate(-width.toDouble(), -height.toDouble())
} else if (vertically) {
tx = AffineTransform.getScaleInstance(1.0, -1.0)
tx.translate(0.0, -height.toDouble())
} else {
tx = AffineTransform.getScaleInstance(-1.0, 1.0)
tx.translate(-width.toDouble(), 0.0)
}
return AffineTransformOp(tx, AffineTransformOp.TYPE_NEAREST_NEIGHBOR).filter(this, null)
}

View File

@ -2,17 +2,20 @@ package chat.simplex.common.platform
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.toComposeImageBitmap
import androidx.compose.ui.graphics.*
import chat.simplex.common.views.helpers.*
import chat.simplex.res.MR
import kotlinx.coroutines.*
import uk.co.caprica.vlcj.player.base.MediaPlayer
import uk.co.caprica.vlcj.media.VideoOrientation
import uk.co.caprica.vlcj.player.base.*
import uk.co.caprica.vlcj.player.component.CallbackMediaPlayerComponent
import uk.co.caprica.vlcj.player.component.EmbeddedMediaPlayerComponent
import java.awt.Component
import java.awt.image.BufferedImage
import java.io.File
import java.net.URI
import java.util.concurrent.Executors
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.math.max
actual class VideoPlayer actual constructor(
@ -29,17 +32,14 @@ actual class VideoPlayer actual constructor(
override val duration: MutableState<Long> = mutableStateOf(0L)
override val preview: MutableState<ImageBitmap> = mutableStateOf(defaultPreview)
val mediaPlayerComponent = initializeMediaPlayerComponent()
val mediaPlayerComponent by lazy { runBlocking(playerThread.asCoroutineDispatcher()) { getOrCreatePlayer() } }
val player by lazy { mediaPlayerComponent.mediaPlayer() }
init {
withBGApi {
setPreviewAndDuration()
}
setPreviewAndDuration()
}
private val currentVolume: Int by lazy { player.audio().volume() }
private var isReleased: Boolean = false
private var isReleased: AtomicBoolean = AtomicBoolean(false)
private val listener: MutableState<((position: Long?, state: TrackState) -> Unit)?> = mutableStateOf(null)
private var progressJob: Job? = null
@ -48,6 +48,7 @@ actual class VideoPlayer actual constructor(
PLAYING, PAUSED, STOPPED
}
/** Should be called in [playerThread]. Otherwise, it creates deadlocks in [player.stop] and [player.release] calls */
private fun start(seek: Long? = null, onProgressUpdate: (position: Long?, state: TrackState) -> Unit): Boolean {
val filepath = getAppFilePath(uri)
if (filepath == null || !File(filepath).exists()) {
@ -87,7 +88,7 @@ actual class VideoPlayer actual constructor(
// Player can only be accessed in one specific thread
progressJob = CoroutineScope(Dispatchers.Main).launch {
onProgressUpdate(player.currentPosition.toLong(), TrackState.PLAYING)
while (isActive && !isReleased && player.isPlaying) {
while (isActive && !isReleased.get() && player.isPlaying) {
// Even when current position is equal to duration, the player has isPlaying == true for some time,
// so help to make the playback stopped in UI immediately
if (player.currentPosition == player.duration) {
@ -97,7 +98,7 @@ actual class VideoPlayer actual constructor(
delay(50)
onProgressUpdate(player.currentPosition.toLong(), TrackState.PLAYING)
}
if (isActive && !isReleased) {
if (isActive && !isReleased.get()) {
onProgressUpdate(player.currentPosition.toLong(), TrackState.PAUSED)
}
onProgressUpdate(null, TrackState.PAUSED)
@ -107,9 +108,11 @@ actual class VideoPlayer actual constructor(
}
override fun stop() {
if (isReleased || !videoPlaying.value) return
player.controls().stop()
stopListener()
if (isReleased.get() || !videoPlaying.value) return
playerThread.execute {
player.stop()
stopListener()
}
}
private fun stopListener() {
@ -133,45 +136,57 @@ actual class VideoPlayer actual constructor(
if (progress.value == duration.value) {
progress.value = 0
}
videoPlaying.value = start(progress.value) { pro, _ ->
if (pro != null) {
progress.value = pro
}
if ((pro == null || pro == duration.value) && duration.value != 0L) {
videoPlaying.value = false
if (pro == duration.value) {
progress.value = if (resetOnEnd) 0 else duration.value
}/* else if (state == TrackState.STOPPED) {
playerThread.execute {
videoPlaying.value = start(progress.value) { pro, _ ->
if (pro != null) {
progress.value = pro
}
if ((pro == null || pro == duration.value) && duration.value != 0L) {
videoPlaying.value = false
if (pro == duration.value) {
progress.value = if (resetOnEnd) 0 else duration.value
}/* else if (state == TrackState.STOPPED) {
progress.value = 0 //
}*/
}
}
}
}
override fun enableSound(enable: Boolean): Boolean {
if (isReleased) return false
if (soundEnabled.value == enable) return false
// Impossible to change volume for only one player. It changes for every player
// https://github.com/caprica/vlcj/issues/985
return false
/*if (isReleased.get() || soundEnabled.value == enable) return false
soundEnabled.value = enable
player.audio().setVolume(if (enable) currentVolume else 0)
return true
playerThread.execute {
player.audio().isMute = !enable
}
return true*/
}
override fun release(remove: Boolean) { withApi {
if (isReleased) return@withApi
isReleased = true
// TODO
/** [player.release] freezes thread for some reason. It happens periodically. So doing this we don't see the freeze, but it's still there */
if (player.isPlaying) player.stop()
CoroutineScope(Dispatchers.IO).launch { player.release() }
if (remove) {
VideoPlayerHolder.players.remove(uri to gallery)
override fun release(remove: Boolean) {
CoroutineScope(playerThread.asCoroutineDispatcher()).launch {
if (isReleased.get()) return@launch
isReleased.set(true)
if (player.isPlaying) {
player.stop()
}
if (usePool) {
putPlayer(mediaPlayerComponent)
} else {
player.release()
}
if (remove) {
VideoPlayerHolder.players.remove(uri to gallery)
}
}
}}
}
private val MediaPlayer.currentPosition: Int
get() = if (isReleased) 0 else max(0, player.status().time().toInt())
get() = if (isReleased.get()) 0 else max(0, status().time().toInt())
private suspend fun setPreviewAndDuration() {
private fun setPreviewAndDuration() {
// It freezes main thread, doing it in IO thread
CoroutineScope(Dispatchers.IO).launch {
val previewAndDuration = VideoPlayerHolder.previewsAndDurations.getOrPut(uri) { getBitmapFromVideo(defaultPreview, uri) }
@ -182,35 +197,79 @@ actual class VideoPlayer actual constructor(
}
}
private fun initializeMediaPlayerComponent(): Component {
return if (desktopPlatform.isMac()) {
CallbackMediaPlayerComponent()
} else {
EmbeddedMediaPlayerComponent()
}
}
private fun Component.mediaPlayer() = when (this) {
is CallbackMediaPlayerComponent -> mediaPlayer()
is EmbeddedMediaPlayerComponent -> mediaPlayer()
else -> error("mediaPlayer() can only be called on vlcj player components")
}
companion object {
suspend fun getBitmapFromVideo(defaultPreview: ImageBitmap?, uri: URI?): VideoPlayerInterface.PreviewAndDuration {
val player = CallbackMediaPlayerComponent().mediaPlayer()
private val usePool = false
private fun Component.mediaPlayer() = when (this) {
is CallbackMediaPlayerComponent -> mediaPlayer()
is EmbeddedMediaPlayerComponent -> mediaPlayer()
else -> error("mediaPlayer() can only be called on vlcj player components")
}
private fun initializeMediaPlayerComponent(): Component {
return if (desktopPlatform.isMac()) {
CallbackMediaPlayerComponent()
} else {
EmbeddedMediaPlayerComponent()
}
}
suspend fun getBitmapFromVideo(defaultPreview: ImageBitmap?, uri: URI?): VideoPlayerInterface.PreviewAndDuration = withContext(playerThread.asCoroutineDispatcher()) {
val mediaComponent = getOrCreateHelperPlayer()
val player = mediaComponent.mediaPlayer()
if (uri == null || !File(uri.rawPath).exists()) {
return VideoPlayerInterface.PreviewAndDuration(preview = defaultPreview, timestamp = 0L, duration = 0L)
return@withContext VideoPlayerInterface.PreviewAndDuration(preview = defaultPreview, timestamp = 0L, duration = 0L)
}
player.media().startPaused(uri.toString().replaceFirst("file:", "file://"))
val start = System.currentTimeMillis()
while (player.snapshots()?.get() == null && start + 5000 > System.currentTimeMillis()) {
var snap: BufferedImage? = null
while (snap == null && start + 5000 > System.currentTimeMillis()) {
snap = player.snapshots()?.get()
delay(10)
}
val preview = player.snapshots()?.get()?.toComposeImageBitmap()
val orientation = player.media().info().videoTracks().first().orientation()
val preview: ImageBitmap? = when (orientation) {
VideoOrientation.TOP_LEFT -> snap
VideoOrientation.TOP_RIGHT -> snap?.flip(false, true)
VideoOrientation.BOTTOM_LEFT -> snap?.flip(true, false)
VideoOrientation.BOTTOM_RIGHT -> snap?.rotate(180.0)
VideoOrientation.LEFT_TOP -> snap /* Transposed */
VideoOrientation.LEFT_BOTTOM -> snap?.rotate(-90.0)
VideoOrientation.RIGHT_TOP -> snap?.rotate(90.0)
VideoOrientation.RIGHT_BOTTOM -> snap /* Anti-transposed */
else -> snap
}?.toComposeImageBitmap()
val duration = player.duration.toLong()
CoroutineScope(Dispatchers.IO).launch { player.release() }
return VideoPlayerInterface.PreviewAndDuration(preview = preview, timestamp = 0L, duration = duration)
player.stop()
putHelperPlayer(mediaComponent)
return@withContext VideoPlayerInterface.PreviewAndDuration(preview = preview, timestamp = 0L, duration = duration)
}
val playerThread = Executors.newSingleThreadExecutor()
private val playersPool: ArrayList<Component> = ArrayList()
private val helperPlayersPool: ArrayList<CallbackMediaPlayerComponent> = ArrayList()
private fun getOrCreatePlayer(): Component = playersPool.removeFirstOrNull() ?: createNew()
private fun createNew(): Component =
initializeMediaPlayerComponent().apply {
mediaPlayer().events().addMediaPlayerEventListener(object: MediaPlayerEventAdapter() {
override fun mediaPlayerReady(mediaPlayer: MediaPlayer?) {
playerThread.execute {
mediaPlayer?.audio()?.setVolume(100)
mediaPlayer?.audio()?.isMute = false
}
}
override fun stopped(mediaPlayer: MediaPlayer?) {
//playerThread.execute { mediaPlayer().videoSurface().set(null) }
}
})
}
private fun putPlayer(player: Component) = playersPool.add(player)
private fun getOrCreateHelperPlayer(): CallbackMediaPlayerComponent = helperPlayersPool.removeFirstOrNull() ?: CallbackMediaPlayerComponent()
private fun putHelperPlayer(player: CallbackMediaPlayerComponent) = helperPlayersPool.add(player)
}
}

View File

@ -1,12 +1,29 @@
package chat.simplex.common.views.chat.item
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import chat.simplex.common.platform.VideoPlayer
import chat.simplex.common.platform.isPlaying
import chat.simplex.common.views.helpers.onRightClick
@Composable
actual fun PlayerView(player: VideoPlayer, width: Dp, onClick: () -> Unit, onLongClick: () -> Unit, stop: () -> Unit) {}
actual fun PlayerView(player: VideoPlayer, width: Dp, onClick: () -> Unit, onLongClick: () -> Unit, stop: () -> Unit) {
Box {
SurfaceFromPlayer(player,
Modifier
.width(width)
.combinedClickable(
onLongClick = onLongClick,
onClick = { if (player.player.isPlaying) stop() else onClick() }
)
.onRightClick(onLongClick)
)
}
}
@Composable
actual fun LocalWindowWidth(): Dp {

View File

@ -6,17 +6,15 @@ import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.awt.SwingPanel
import androidx.compose.ui.graphics.*
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp
import chat.simplex.common.platform.*
import chat.simplex.common.simplexWindowState
import chat.simplex.common.views.helpers.getBitmapFromByteArray
import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import kotlinx.coroutines.delay
import org.jetbrains.compose.videoplayer.SkiaBitmapVideoSurface
import kotlin.math.max
@Composable
@ -28,30 +26,40 @@ actual fun FullScreenImageView(modifier: Modifier, data: ByteArray, imageBitmap:
modifier = modifier,
)
}
@Composable
actual fun FullScreenVideoView(player: VideoPlayer, modifier: Modifier, close: () -> Unit) {
// Workaround. Without changing size of the window the screen flashes a lot even if it's not being recomposed
LaunchedEffect(Unit) {
simplexWindowState.windowState.size = simplexWindowState.windowState.size.copy(width = simplexWindowState.windowState.size.width + 1.dp)
delay(50)
player.play(true)
simplexWindowState.windowState.size = simplexWindowState.windowState.size.copy(width = simplexWindowState.windowState.size.width - 1.dp)
}
Box {
Box(Modifier.fillMaxSize().padding(bottom = 50.dp)) {
val factory = remember { { player.mediaPlayerComponent } }
SwingPanel(
background = Color.Transparent,
modifier = Modifier,
factory = factory
)
SurfaceFromPlayer(player, modifier)
IconButton(onClick = close, Modifier.padding(top = 5.dp)) {
Icon(painterResource(MR.images.ic_arrow_back_ios_new), null, Modifier.size(30.dp), tint = MaterialTheme.colors.primary)
}
}
Controls(player, close)
Controls(player)
}
}
@Composable
private fun BoxScope.Controls(player: VideoPlayer, close: () -> Unit) {
fun BoxScope.SurfaceFromPlayer(player: VideoPlayer, modifier: Modifier) {
val surface = remember {
SkiaBitmapVideoSurface().also {
player.player.videoSurface().set(it)
}
}
surface.bitmap.value?.let { bitmap ->
Image(
bitmap,
modifier = modifier.align(Alignment.Center),
contentDescription = null,
contentScale = ContentScale.Fit,
alignment = Alignment.Center,
)
}
}
@Composable
private fun BoxScope.Controls(player: VideoPlayer) {
val playing = remember(player) { player.videoPlaying }
val progress = remember(player) { player.progress }
val duration = remember(player) { player.duration }
@ -62,10 +70,7 @@ private fun BoxScope.Controls(player: VideoPlayer, close: () -> Unit) {
Slider(
value = progress.value.toFloat() / max(0.0001f, duration.value.toFloat()),
onValueChange = { player.player.seekTo((it * duration.value).toInt()) },
modifier = Modifier.fillMaxWidth().weight(1f)
modifier = Modifier.fillMaxWidth()
)
IconButton(onClick = close,) {
Icon(painterResource(MR.images.ic_close), null, Modifier.size(30.dp), tint = MaterialTheme.colors.primary)
}
}
}

View File

@ -2,7 +2,7 @@
layout: layouts/article.html
title: "SimpleX Chat v5.3 released: desktop app, local file encryption and improved groups with directory service"
date: 2023-09-25
image: /docs/images/simplex-desktop-light.png
image: images/simplex-desktop-light.png
imageWide: true
previewBody: blog_previews/20230925.html
permalink: "/blog/20230925-simplex-chat-v5-3-desktop-app-local-file-encryption-directory-service.html"
@ -36,7 +36,7 @@ Also, we added 6 new interface languages: Arabic<sup>*</sup>, Bulgarian, Finnish
## Multiplatform desktop app
<img src="/docs/images/simplex-desktop-light.png" width="640">
<img src="./images/simplex-desktop-light.png" width="640">
Thanks a lot to everybody who was testing the desktop app since July it really helped to make it stable!
@ -50,6 +50,8 @@ Other limitations of the desktop app:
- you cannot send voice messages.
- there is no support for calls yet.
You can download the desktop app for Linux and Mac via [downloads page](https://simplex.chat/downloads). Windows version will be available soon.
## Group directory service and other group improvements
<img src="./images/20230925-directory.png" width="330" class="float-to-left">

Binary file not shown.

After

Width:  |  Height:  |  Size: 545 KiB

View File

@ -9,7 +9,7 @@ constraints: zip +disable-bzip2 +disable-zstd
source-repository-package
type: git
location: https://github.com/simplex-chat/simplexmq.git
tag: 53c793d5590d3c781aa3fbf72993eee262c7aa83
tag: e7bd0fb31af4f04ce7ac6fdc2c81ef52ce918b48
source-repository-package
type: git

View File

@ -7,7 +7,7 @@ revision: 20.09.2023
| Updated 20.09.2023 | Languages: EN |
# Download SimpleX apps
The latest version is v5.3.
The latest version is v5.3.1.
- [desktop](#desktop-app)
- [mobile](#mobile-apps)
@ -19,24 +19,24 @@ The latest version is v5.3.
Using the same profile as on mobile device is not yet supported you need to create a separate profile to use desktop apps.
**Linux**: [AppImage](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.0/simplex-desktop-x86_64.AppImage) (most Linux distros), [Ubuntu 20.04](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.0/simplex-desktop-ubuntu-20_04-x86_64.deb) (and Debian-based distros), [Ubuntu 22.04](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.0/simplex-desktop-ubuntu-22_04-x86_64.deb).
**Linux**: [AppImage](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.1/simplex-desktop-x86_64.AppImage) (most Linux distros), [Ubuntu 20.04](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.1/simplex-desktop-ubuntu-20_04-x86_64.deb) (and Debian-based distros), [Ubuntu 22.04](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.1/simplex-desktop-ubuntu-22_04-x86_64.deb).
**Mac**: [x86_64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.0/simplex-desktop-macos-x86_64.dmg) (Intel), [aarch64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.0/simplex-desktop-macos-aarch64.dmg) (Apple Silicon).
**Mac**: [x86_64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.1/simplex-desktop-macos-x86_64.dmg) (Intel), [aarch64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.1/simplex-desktop-macos-aarch64.dmg) (Apple Silicon).
**Windows**: coming soon.
## Mobile apps
**iOS**: [App store](https://apps.apple.com/us/app/simplex-chat/id1605771084) (v5.2.3), [TestFlight](https://testflight.apple.com/join/DWuT2LQu) (v5.3-beta.9).
**iOS**: [App store](https://apps.apple.com/us/app/simplex-chat/id1605771084), [TestFlight](https://testflight.apple.com/join/DWuT2LQu).
**Android**: [Play store](https://play.google.com/store/apps/details?id=chat.simplex.app), [F-Droid](https://simplex.chat/fdroid/), [APK aarch64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.0/simplex.apk), [APK armv7](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.0/simplex-armv7a.apk).
**Android**: [Play store](https://play.google.com/store/apps/details?id=chat.simplex.app), [F-Droid](https://simplex.chat/fdroid/), [APK aarch64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.1/simplex.apk), [APK armv7](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.1/simplex-armv7a.apk).
## Terminal (console) app
See [Using terminal app](/docs/CLI.md).
**Linux**: [Ubuntu 20.04](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.0/simplex-chat-ubuntu-20_04-x86-64), [Ubuntu 22.04](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.0/simplex-chat-ubuntu-22_04-x86-64).
**Linux**: [Ubuntu 20.04](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.1/simplex-chat-ubuntu-20_04-x86-64), [Ubuntu 22.04](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.1/simplex-chat-ubuntu-22_04-x86-64).
**Mac** [x86_64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.0/simplex-chat-macos-x86-64), aarch64 - [compile from source](./CLI.md#).
**Mac** [x86_64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.1/simplex-chat-macos-x86-64), aarch64 - [compile from source](./CLI.md#).
**Windows**: [x86_64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.0/simplex-chat-windows-x86-64).
**Windows**: [x86_64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.1/simplex-chat-windows-x86-64).

View File

@ -1,5 +1,7 @@
#!/bin/bash
set -e
OS=mac
ARCH="${1:-`uname -a | rev | cut -d' ' -f1 | rev`}"
if [ "$ARCH" == "arm64" ]; then
@ -15,7 +17,7 @@ rm -rf $BUILD_DIR
cabal build lib:simplex-chat lib:simplex-chat --ghc-options="-optl-Wl,-rpath,@loader_path -optl-Wl,-L$GHC_LIBS_DIR/rts -optl-lHSrts_thr-ghc8.10.7 -optl-lffi"
cd $BUILD_DIR/build
mkdir deps 2> /dev/null
mkdir deps 2> /dev/null || true
# It's not included by default for some reason. Compiled lib tries to find system one but it's not always available
cp $GHC_LIBS_DIR/rts/libffi.dylib ./deps
@ -38,7 +40,7 @@ function copy_deps() {
cp $LIB ./deps
if [[ "$NON_FINAL_RPATHS" == *"@loader_path/.."* ]]; then
# Need to point the lib to @loader_path instead
# Need to point the lib to @loader_path instead
install_name_tool -add_rpath @loader_path ./deps/`basename $LIB`
fi
#echo LIB $LIB
@ -61,13 +63,6 @@ function copy_deps() {
copy_deps $LIB
rm deps/`basename $LIB`
if [ -e deps/libHSdrct-*.$LIB_EXT ]; then
LIBCRYPTO_PATH=$(otool -l deps/libHSdrct-*.$LIB_EXT | grep libcrypto | cut -d' ' -f11)
install_name_tool -change $LIBCRYPTO_PATH @rpath/libcrypto.1.1.$LIB_EXT deps/libHSdrct*.$LIB_EXT
cp $LIBCRYPTO_PATH deps/libcrypto.1.1.$LIB_EXT
chmod 755 deps/libcrypto.1.1.$LIB_EXT
fi
cd -
rm -rf apps/multiplatform/common/src/commonMain/cpp/desktop/libs/$OS-$ARCH/
@ -77,4 +72,39 @@ rm -rf apps/multiplatform/desktop/build/cmake
mkdir -p apps/multiplatform/common/src/commonMain/cpp/desktop/libs/$OS-$ARCH/
cp -r $BUILD_DIR/build/deps apps/multiplatform/common/src/commonMain/cpp/desktop/libs/$OS-$ARCH/
cp $BUILD_DIR/build/libHSsimplex-chat-*-inplace-ghc*.$LIB_EXT apps/multiplatform/common/src/commonMain/cpp/desktop/libs/$OS-$ARCH/
cd apps/multiplatform/common/src/commonMain/cpp/desktop/libs/$OS-$ARCH/
LIBCRYPTO_PATH=$(otool -l deps/libHSdrct-*.$LIB_EXT | grep libcrypto | cut -d' ' -f11)
install_name_tool -change $LIBCRYPTO_PATH @rpath/libcrypto.1.1.$LIB_EXT deps/libHSdrct-*.$LIB_EXT
cp $LIBCRYPTO_PATH deps/libcrypto.1.1.$LIB_EXT
chmod 755 deps/libcrypto.1.1.$LIB_EXT
install_name_tool -id "libcrypto.1.1.$LIB_EXT" deps/libcrypto.1.1.$LIB_EXT
install_name_tool -id "libffi.8.$LIB_EXT" deps/libffi.$LIB_EXT
LIBCRYPTO_PATH=$(otool -l $LIB | grep libcrypto | cut -d' ' -f11)
if [ -n "$LIBCRYPTO_PATH" ]; then
install_name_tool -change $LIBCRYPTO_PATH @rpath/libcrypto.1.1.$LIB_EXT $LIB
fi
LIBCRYPTO_PATH=$(otool -l deps/libHSsmplxmq*.$LIB_EXT | grep libcrypto | cut -d' ' -f11)
if [ -n "$LIBCRYPTO_PATH" ]; then
install_name_tool -change $LIBCRYPTO_PATH @rpath/libcrypto.1.1.$LIB_EXT deps/libHSsmplxmq*.$LIB_EXT
fi
for lib in $(find . -type f -name "*.$LIB_EXT"); do
RPATHS=`otool -l $lib | grep -E "path /Users/|path /usr/local|path /opt/" | cut -d' ' -f11`
for RPATH in $RPATHS; do
install_name_tool -delete_rpath $RPATH $lib
done
done
LOCAL_DIRS=`for lib in $(find . -type f -name "*.$LIB_EXT"); do otool -l $lib | grep -E "/Users|/opt/|/usr/local" && echo $lib; done`
if [ -n "$LOCAL_DIRS" ]; then
echo These libs still point to local directories:
echo $LOCAL_DIRS
exit 1
fi
cd -
scripts/desktop/prepare-vlc-mac.sh

View File

@ -1,5 +1,5 @@
{
"https://github.com/simplex-chat/simplexmq.git"."53c793d5590d3c781aa3fbf72993eee262c7aa83" = "0f0ldlgqwrapgfw5gnaj00xvb14c8nykyjr9fhy79h4r16g614x8";
"https://github.com/simplex-chat/simplexmq.git"."e7bd0fb31af4f04ce7ac6fdc2c81ef52ce918b48" = "0yzf8a3267c1iihhl1vx3rybzq0mwzkff57nrqhww1i8pmgva74i";
"https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38";
"https://github.com/kazu-yamamoto/http2.git"."b5a1b7200cf5bc7044af34ba325284271f6dff25" = "0dqb50j57an64nf4qcf5vcz4xkd1vzvghvf8bk529c1k30r9nfzb";
"https://github.com/simplex-chat/direct-sqlcipher.git"."34309410eb2069b029b8fc1872deb1e0db123294" = "0kwkmhyfsn2lixdlgl15smgr1h5gjk7fky6abzh8rng2h5ymnffd";

View File

@ -79,7 +79,7 @@ import Simplex.Messaging.Agent.Client (AgentStatsKey (..), SubInfo (..), agentCl
import Simplex.Messaging.Agent.Env.SQLite (AgentConfig (..), InitialAgentServers (..), createAgentStore, defaultAgentConfig)
import Simplex.Messaging.Agent.Lock
import Simplex.Messaging.Agent.Protocol
import Simplex.Messaging.Agent.Store.SQLite (MigrationConfirmation (..), MigrationError, SQLiteStore (dbNew), execSQL, upMigration, withConnection)
import Simplex.Messaging.Agent.Store.SQLite (MigrationConfirmation (..), MigrationError, SQLiteStore (dbNew), execSQL, upMigration, withConnection, closeSQLiteStore, openSQLiteStore)
import Simplex.Messaging.Agent.Store.SQLite.DB (SlowQueryStats (..))
import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB
import qualified Simplex.Messaging.Agent.Store.SQLite.Migrations as Migrations
@ -245,9 +245,13 @@ cfgServers = \case
SPSMP -> smp
SPXFTP -> xftp
startChatController :: forall m. ChatMonad' m => Bool -> Bool -> Bool -> m (Async ())
startChatController subConns enableExpireCIs startXFTPWorkers = do
asks smpAgent >>= resumeAgentClient
startChatController :: forall m. ChatMonad' m => ChatCtrlCfg -> m (Async ())
startChatController ChatCtrlCfg {subConns, enableExpireCIs, startXFTPWorkers, openDBWithKey} = do
ChatController {chatStore, smpAgent} <- ask
forM_ openDBWithKey $ \(DBEncryptionKey dbKey) -> liftIO $ do
openSQLiteStore chatStore dbKey
openSQLiteStore (agentClientStore smpAgent) dbKey
resumeAgentClient smpAgent
unless subConns $
chatWriteVar subscriptionMode SMOnlyCreate
users <- fromRight [] <$> runExceptT (withStoreCtx' (Just "startChatController, getUsers") getUsers)
@ -319,8 +323,8 @@ restoreCalls = do
calls <- asks currentCalls
atomically $ writeTVar calls callsMap
stopChatController :: forall m. MonadUnliftIO m => ChatController -> m ()
stopChatController ChatController {smpAgent, agentAsync = s, sndFiles, rcvFiles, expireCIFlags} = do
stopChatController :: forall m. MonadUnliftIO m => ChatController -> Bool -> m ()
stopChatController ChatController {chatStore, smpAgent, agentAsync = s, sndFiles, rcvFiles, expireCIFlags} closeStore = do
disconnectAgentClient smpAgent
readTVarIO s >>= mapM_ (\(a1, a2) -> uninterruptibleCancel a1 >> mapM_ uninterruptibleCancel a2)
closeFiles sndFiles
@ -329,6 +333,9 @@ stopChatController ChatController {smpAgent, agentAsync = s, sndFiles, rcvFiles,
keys <- M.keys <$> readTVar expireCIFlags
forM_ keys $ \k -> TM.insert k False expireCIFlags
writeTVar s Nothing
when closeStore $ liftIO $ do
closeSQLiteStore chatStore
closeSQLiteStore $ agentClientStore smpAgent
where
closeFiles :: TVar (Map Int64 Handle) -> m ()
closeFiles files = do
@ -458,12 +465,12 @@ processChatCommand = \case
checkDeleteChatUser user'
withChatLock "deleteUser" . procCmd $ deleteChatUser user' delSMPQueues
DeleteUser uName delSMPQueues viewPwd_ -> withUserName uName $ \userId -> APIDeleteUser userId delSMPQueues viewPwd_
StartChat subConns enableExpireCIs startXFTPWorkers -> withUser' $ \_ ->
APIStartChat cfg -> withUser' $ \_ ->
asks agentAsync >>= readTVarIO >>= \case
Just _ -> pure CRChatRunning
_ -> checkStoreNotChanged $ startChatController subConns enableExpireCIs startXFTPWorkers $> CRChatStarted
APIStopChat -> do
ask >>= stopChatController
_ -> checkStoreNotChanged $ startChatController cfg $> CRChatStarted
APIStopChat closeStore -> do
ask >>= (`stopChatController` closeStore)
pure CRChatStopped
APIActivateChat -> withUser $ \_ -> do
restoreCalls
@ -5370,9 +5377,9 @@ chatCommandP =
"/_delete user " *> (APIDeleteUser <$> A.decimal <* " del_smp=" <*> onOffP <*> optional (A.space *> jsonP)),
"/delete user " *> (DeleteUser <$> displayName <*> pure True <*> optional (A.space *> pwdP)),
("/user" <|> "/u") $> ShowActiveUser,
"/_start subscribe=" *> (StartChat <$> onOffP <* " expire=" <*> onOffP <* " xftp=" <*> onOffP),
"/_start" $> StartChat True True True,
"/_stop" $> APIStopChat,
"/_start" *> (APIStartChat <$> ((A.space *> jsonP) <|> chatCtrlCfgP)),
"/_stop close" $> APIStopChat {closeStore = True},
"/_stop" $> APIStopChat False,
"/_app activate" $> APIActivateChat,
"/_app suspend " *> (APISuspendChat <$> A.decimal),
"/_resubscribe all" $> ResubscribeAllConnections,
@ -5600,6 +5607,12 @@ chatCommandP =
]
where
choice = A.choice . map (\p -> p <* A.takeWhile (== ' ') <* A.endOfInput)
chatCtrlCfgP = do
subConns <- (" subscribe=" *> onOffP) <|> pure True
enableExpireCIs <- (" expire=" *> onOffP) <|> pure True
startXFTPWorkers <- (" xftp=" *> onOffP) <|> pure True
openDBWithKey <- optional $ " key=" *> dbKeyP
pure ChatCtrlCfg {subConns, enableExpireCIs, startXFTPWorkers, openDBWithKey}
incognitoP = (A.space *> ("incognito" <|> "i")) $> True <|> pure False
incognitoOnOffP = (A.space *> "incognito=" *> onOffP) <|> pure False
imagePrefix = (<>) <$> "data:" <*> ("image/png;base64," <|> "image/jpg;base64,")

View File

@ -123,7 +123,7 @@ sqlCipherExport DBEncryptionConfig {currentKey = DBEncryptionKey key, newKey = D
checkFile `with` fs
backup `with` fs
(export chatDb chatEncrypted >> export agentDb agentEncrypted)
`catchChatError` \e -> (restore `with` fs) >> throwError e
`catchChatError` \e -> tryChatError (restore `with` fs) >> throwError e
where
action `with` StorageFiles {chatDb, agentDb} = action chatDb >> action agentDb
backup f = copyFile f (f <> ".bak")

View File

@ -221,8 +221,8 @@ data ChatCommand
| UnmuteUser
| APIDeleteUser UserId Bool (Maybe UserPwd)
| DeleteUser UserName Bool (Maybe UserPwd)
| StartChat {subscribeConnections :: Bool, enableExpireChatItems :: Bool, startXFTPWorkers :: Bool}
| APIStopChat
| APIStartChat ChatCtrlCfg
| APIStopChat {closeStore :: Bool}
| APIActivateChat
| APISuspendChat {suspendTimeout :: Int}
| ResubscribeAllConnections
@ -620,6 +620,17 @@ instance ToJSON ChatResponse where
toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "CR"
toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "CR"
data ChatCtrlCfg = ChatCtrlCfg
{ subConns :: Bool,
enableExpireCIs :: Bool,
startXFTPWorkers :: Bool,
openDBWithKey :: Maybe DBEncryptionKey
}
deriving (Show, Generic, FromJSON)
defChatCtrlCfg :: ChatCtrlCfg
defChatCtrlCfg = ChatCtrlCfg True True True Nothing
newtype UserPwd = UserPwd {unUserPwd :: Text}
deriving (Eq, Show)

View File

@ -35,7 +35,7 @@ runSimplexChat :: ChatOpts -> User -> ChatController -> (User -> ChatController
runSimplexChat ChatOpts {maintenance} u cc chat
| maintenance = wait =<< async (chat u cc)
| otherwise = do
a1 <- runReaderT (startChatController True True True) cc
a1 <- runReaderT (startChatController defChatCtrlCfg) cc
a2 <- async $ chat u cc
waitEither_ a1 a2

View File

@ -49,7 +49,7 @@ extra-deps:
# - simplexmq-1.0.0@sha256:34b2004728ae396e3ae449cd090ba7410781e2b3cefc59259915f4ca5daa9ea8,8561
# - ../simplexmq
- github: simplex-chat/simplexmq
commit: 53c793d5590d3c781aa3fbf72993eee262c7aa83
commit: e7bd0fb31af4f04ce7ac6fdc2c81ef52ce918b48
- github: kazu-yamamoto/http2
commit: b5a1b7200cf5bc7044af34ba325284271f6dff25
# - ../direct-sqlcipher

View File

@ -168,7 +168,7 @@ startTestChat_ db cfg opts user = do
stopTestChat :: TestCC -> IO ()
stopTestChat TestCC {chatController = cc, chatAsync, termAsync} = do
stopChatController cc
stopChatController cc False
uninterruptibleCancel termAsync
uninterruptibleCancel chatAsync
threadDelay 200000

View File

@ -953,10 +953,15 @@ testDatabaseEncryption tmp = do
alice ##> "/_start"
alice <## "chat started"
testChatWorking alice bob
alice ##> "/_stop"
alice ##> "/_stop close"
alice <## "chat stopped"
alice ##> "/db key wrongkey nextkey"
alice <## "error encrypting database: wrong passphrase or invalid database file"
alice ##> "/_start key=mykey"
alice <## "chat started"
testChatWorking alice bob
alice ##> "/_stop close"
alice <## "chat stopped"
alice ##> "/db key mykey nextkey"
alice <## "ok"
alice ##> "/_db encryption {\"currentKey\":\"nextkey\",\"newKey\":\"anotherkey\"}"

View File

@ -2,10 +2,10 @@
<ul class="mb-[12px]">
<li>new desktop app! 💻</li>
<li>encrypt locally storead files & media</li>
<li>encrypt locally stored files & media</li>
<li>directory service and other group improvements</li>
<li>simplified incognito mode</li>
<li>better app responsiveness and stability, and 40% reduced memory usage.</li>
<li>better app responsiveness, stability and 40% reduced memory usage.</li>
<li>new privacy settings: show last messages & save draft.</li>
</ul>