diff --git a/apps/multiplatform/common/build.gradle.kts b/apps/multiplatform/common/build.gradle.kts index 6a5fd1d0f..9fb40c93d 100644 --- a/apps/multiplatform/common/build.gradle.kts +++ b/apps/multiplatform/common/build.gradle.kts @@ -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 diff --git a/apps/multiplatform/common/src/commonMain/cpp/desktop/CMakeLists.txt b/apps/multiplatform/common/src/commonMain/cpp/desktop/CMakeLists.txt index 849a6c98a..5cbc6883a 100644 --- a/apps/multiplatform/common/src/commonMain/cpp/desktop/CMakeLists.txt +++ b/apps/multiplatform/common/src/commonMain/cpp/desktop/CMakeLists.txt @@ -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 diff --git a/apps/multiplatform/common/src/commonMain/cpp/desktop/patch-libapp-mac.sh b/apps/multiplatform/common/src/commonMain/cpp/desktop/patch-libapp-mac.sh new file mode 100755 index 000000000..c44df8f1f --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/cpp/desktop/patch-libapp-mac.sh @@ -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 diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index 060738bc1..87cac179f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -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?, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVIdeoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVIdeoView.kt index 78bdf53d1..996dc819f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVIdeoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVIdeoView.kt @@ -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*/) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.kt index 05d11208f..4d4d847cc 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.kt @@ -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) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt index fa0f8f54d..3eb2e7d73 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt @@ -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 } diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/other/videoplayer/SkiaBitmapVideoSurface.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/other/videoplayer/SkiaBitmapVideoSurface.kt new file mode 100644 index 000000000..c2f37fd5d --- /dev/null +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/other/videoplayer/SkiaBitmapVideoSurface.kt @@ -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(null) + + val bitmap: State = 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) { + 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, + 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, + ) +} diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Images.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Images.desktop.kt index b7341cb4a..c04587656 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Images.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Images.desktop.kt @@ -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) +} diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/VideoPlayer.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/VideoPlayer.desktop.kt index e590c2e20..9f8c1884e 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/VideoPlayer.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/VideoPlayer.desktop.kt @@ -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 = mutableStateOf(0L) override val preview: MutableState = 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 = ArrayList() + private val helperPlayersPool: ArrayList = 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) } } diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.desktop.kt index c85057b47..7a46873f7 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.desktop.kt @@ -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 { diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.desktop.kt index 9aafc83d2..bd395c2c9 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.desktop.kt @@ -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) - } } } diff --git a/blog/20230925-simplex-chat-v5-3-desktop-app-local-file-encryption-directory-service.md b/blog/20230925-simplex-chat-v5-3-desktop-app-local-file-encryption-directory-service.md index 2aa8a7bc0..8928e9724 100644 --- a/blog/20230925-simplex-chat-v5-3-desktop-app-local-file-encryption-directory-service.md +++ b/blog/20230925-simplex-chat-v5-3-desktop-app-local-file-encryption-directory-service.md @@ -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*, Bulgarian, Finnish ## Multiplatform desktop app - + 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 diff --git a/blog/images/simplex-desktop-light.png b/blog/images/simplex-desktop-light.png new file mode 100644 index 000000000..d28b7b88c Binary files /dev/null and b/blog/images/simplex-desktop-light.png differ diff --git a/cabal.project b/cabal.project index 7c8886d94..3012f0186 100644 --- a/cabal.project +++ b/cabal.project @@ -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 diff --git a/docs/DOWNLOADS.md b/docs/DOWNLOADS.md index 83fa18f97..810fd6f4c 100644 --- a/docs/DOWNLOADS.md +++ b/docs/DOWNLOADS.md @@ -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). diff --git a/scripts/desktop/build-lib-mac.sh b/scripts/desktop/build-lib-mac.sh index b5d738be9..4b73bbf1a 100755 --- a/scripts/desktop/build-lib-mac.sh +++ b/scripts/desktop/build-lib-mac.sh @@ -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 diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 2ac9c9f1f..6d085d97d 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -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"; diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index b331faf5d..1854ede26 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -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,") diff --git a/src/Simplex/Chat/Archive.hs b/src/Simplex/Chat/Archive.hs index 244478550..6ede07f7d 100644 --- a/src/Simplex/Chat/Archive.hs +++ b/src/Simplex/Chat/Archive.hs @@ -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") diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 2c829e4a9..69bc13ddd 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -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) diff --git a/src/Simplex/Chat/Core.hs b/src/Simplex/Chat/Core.hs index 4af161ab4..b09413037 100644 --- a/src/Simplex/Chat/Core.hs +++ b/src/Simplex/Chat/Core.hs @@ -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 diff --git a/stack.yaml b/stack.yaml index 868e19faf..7cae228d9 100644 --- a/stack.yaml +++ b/stack.yaml @@ -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 diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index de6353d2e..baf232107 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -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 diff --git a/tests/ChatTests/Direct.hs b/tests/ChatTests/Direct.hs index 3db405222..7dbff89a2 100644 --- a/tests/ChatTests/Direct.hs +++ b/tests/ChatTests/Direct.hs @@ -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\"}" diff --git a/website/src/_includes/blog_previews/20230925.html b/website/src/_includes/blog_previews/20230925.html index 9fb062639..fa05fa72c 100644 --- a/website/src/_includes/blog_previews/20230925.html +++ b/website/src/_includes/blog_previews/20230925.html @@ -2,10 +2,10 @@
  • new desktop app! 💻
  • -
  • encrypt locally storead files & media
  • +
  • encrypt locally stored files & media
  • directory service and other group improvements
  • simplified incognito mode
  • -
  • better app responsiveness and stability, and 40% reduced memory usage.
  • +
  • better app responsiveness, stability and 40% reduced memory usage.
  • new privacy settings: show last messages & save draft.