From 8709ad6ff3a3f44bec82bbbf93b0aaab5ade91d4 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Wed, 27 Sep 2023 17:19:48 +0800 Subject: [PATCH 01/15] desktop: enhanced video player + inline player (#3130) * desktop: enhanced video player + inline player * simplify * simplify * removed unused code * follow up --------- Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- apps/multiplatform/common/build.gradle.kts | 2 +- .../common/views/chat/item/CIVIdeoView.kt | 2 +- .../views/chat/item/ImageFullScreenView.kt | 5 +- .../videoplayer/SkiaBitmapVideoSurface.kt | 79 ++++++++ .../simplex/common/platform/Images.desktop.kt | 36 ++++ .../common/platform/VideoPlayer.desktop.kt | 179 ++++++++++++------ .../views/chat/item/CIVideoView.desktop.kt | 19 +- .../chat/item/ImageFullScreenView.desktop.kt | 49 ++--- 8 files changed, 283 insertions(+), 88 deletions(-) create mode 100644 apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/other/videoplayer/SkiaBitmapVideoSurface.kt 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/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/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) - } } } From 3c7fc6b0ee1949dbe731bc11ad4e4809474ae7fd Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Wed, 27 Sep 2023 15:26:03 +0100 Subject: [PATCH 02/15] core: support closing/re-opening store on chat stop/start (#3132) * core: support closing/re-opening store on chat stop/start * update .nix refs * kotlin: types * add test * update simplexmq --- .../chat/simplex/common/model/SimpleXAPI.kt | 25 ++++++++---- .../common/views/database/DatabaseView.kt | 2 +- cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- src/Simplex/Chat.hs | 39 ++++++++++++------- src/Simplex/Chat/Archive.hs | 2 +- src/Simplex/Chat/Controller.hs | 15 ++++++- src/Simplex/Chat/Core.hs | 2 +- stack.yaml | 2 +- tests/ChatClient.hs | 2 +- tests/ChatTests/Direct.hs | 7 +++- 11 files changed, 69 insertions(+), 31 deletions(-) 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/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/cabal.project b/cabal.project index b4024f088..b9753c9c0 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: 8d47f690838371bc848e4b31a4b09ef6bf67ccc5 + tag: fda1284ae4b7c33cae2eb8ed0376a511aecc1d51 source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 26f4ea112..faa2401b1 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."8d47f690838371bc848e4b31a4b09ef6bf67ccc5" = "1pwasv22ii3wy4xchaknlwczmy5ws7adx7gg2g58lxzrgdjm3650"; + "https://github.com/simplex-chat/simplexmq.git"."fda1284ae4b7c33cae2eb8ed0376a511aecc1d51" = "1gq7scv9z8x3xhzl914xr46na0kkrqd1i743xbw69lyx33kj9xb5"; "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"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "0kiwhvml42g9anw4d2v0zd1fpc790pj9syg5x3ik4l97fnkbbwpp"; diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index e74eaa0f5..1626fe8fd 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -83,7 +83,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 @@ -249,9 +249,13 @@ cfgServers p s = case p of SPSMP -> s.smp SPXFTP -> s.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) @@ -323,8 +327,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 @@ -333,6 +337,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 @@ -462,12 +469,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 @@ -5379,9 +5386,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, @@ -5609,6 +5616,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 f8fa0d152..55bd31e51 100644 --- a/src/Simplex/Chat/Archive.hs +++ b/src/Simplex/Chat/Archive.hs @@ -124,7 +124,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 0840970e4..a466178ce 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: 8d47f690838371bc848e4b31a4b09ef6bf67ccc5 + commit: fda1284ae4b7c33cae2eb8ed0376a511aecc1d51 - github: kazu-yamamoto/http2 commit: b5a1b7200cf5bc7044af34ba325284271f6dff25 # - ../direct-sqlcipher diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index 7da526325..d947fb63b 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -171,7 +171,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\"}" From 7e17ed7b1bd846e8df7fade8fa1c43ebfd5deadc Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Wed, 27 Sep 2023 22:34:46 +0800 Subject: [PATCH 03/15] desktop (mac): removing rpaths (#3136) * desktop (mac): removing rpaths * one more lib * added check for dir existence in linking * new line * patching libapp on mac --- .../src/commonMain/cpp/desktop/CMakeLists.txt | 5 ++ .../cpp/desktop/patch-libapp-mac.sh | 8 ++++ scripts/desktop/build-lib-mac.sh | 48 +++++++++++++++---- 3 files changed, 52 insertions(+), 9 deletions(-) create mode 100755 apps/multiplatform/common/src/commonMain/cpp/desktop/patch-libapp-mac.sh diff --git a/apps/multiplatform/common/src/commonMain/cpp/desktop/CMakeLists.txt b/apps/multiplatform/common/src/commonMain/cpp/desktop/CMakeLists.txt index cfbc1ed32..09ef6fd53 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/scripts/desktop/build-lib-mac.sh b/scripts/desktop/build-lib-mac.sh index 50bc09df5..8b0386473 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`}" GHC_VERSION=9.6.2 @@ -18,7 +20,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/$ARCH-osx-ghc-$GHC_VERSION -optl-lHSrts_thr-ghc$GHC_VERSION -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/libffi.dylib ./deps @@ -54,7 +56,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 @@ -79,13 +81,6 @@ copy_deps $LIB cp $(ghc --print-libdir)/$ARCH-osx-ghc-$GHC_VERSION/libHSghc-boot-th-$GHC_VERSION-ghc$GHC_VERSION.dylib deps 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/ @@ -95,4 +90,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 From c64d1e83618eb66d92ee6008db4df41939631033 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Wed, 27 Sep 2023 19:36:13 +0400 Subject: [PATCH 04/15] core: notify contact about contact deletion (#3131) --- simplex-chat.cabal | 1 + src/Simplex/Chat.hs | 35 +++++++++++++------ src/Simplex/Chat/Controller.hs | 4 ++- src/Simplex/Chat/Messages/CIContent.hs | 34 ++++++++++++++++++ .../Migrations/M20230926_contact_status.hs | 18 ++++++++++ src/Simplex/Chat/Migrations/chat_schema.sql | 1 + src/Simplex/Chat/Protocol.hs | 7 ++++ src/Simplex/Chat/Store/Connections.hs | 8 ++--- src/Simplex/Chat/Store/Direct.hs | 24 ++++++++++--- src/Simplex/Chat/Store/Groups.hs | 25 ++++++------- src/Simplex/Chat/Store/Messages.hs | 2 +- src/Simplex/Chat/Store/Migrations.hs | 4 ++- src/Simplex/Chat/Store/Shared.hs | 10 +++--- src/Simplex/Chat/Types.hs | 28 ++++++++++++++- src/Simplex/Chat/View.hs | 2 ++ tests/ChatTests/Direct.hs | 6 ++-- tests/ChatTests/Files.hs | 8 ++++- tests/ChatTests/Groups.hs | 14 +++++--- tests/ChatTests/Profiles.hs | 5 +++ 19 files changed, 189 insertions(+), 47 deletions(-) create mode 100644 src/Simplex/Chat/Migrations/M20230926_contact_status.hs diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 338346b65..dc3a23adc 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -113,6 +113,7 @@ library Simplex.Chat.Migrations.M20230903_connections_to_subscribe Simplex.Chat.Migrations.M20230913_member_contacts Simplex.Chat.Migrations.M20230914_member_probes + Simplex.Chat.Migrations.M20230926_contact_status Simplex.Chat.Mobile Simplex.Chat.Mobile.File Simplex.Chat.Mobile.Shared diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 1626fe8fd..3530fa82c 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -904,14 +904,15 @@ processChatCommand = \case liftIO $ updateGroupUnreadChat db user groupInfo unreadChat ok user _ -> pure $ chatCmdError (Just user) "not supported" - APIDeleteChat (ChatRef cType chatId) -> withUser $ \user@User {userId} -> case cType of + APIDeleteChat (ChatRef cType chatId) notify -> withUser $ \user@User {userId} -> case cType of CTDirect -> do ct@Contact {localDisplayName} <- withStore $ \db -> getContact db user chatId filesInfo <- withStore' $ \db -> getContactFileInfo db user ct - contactConnIds <- map aConnId <$> withStore (\db -> getContactConnections db userId ct) withChatLock "deleteChat direct" . procCmd $ do - fileAgentConnIds <- concat <$> forM filesInfo (deleteFile user) - deleteAgentConnectionsAsync user $ fileAgentConnIds <> contactConnIds + deleteFilesAndConns user filesInfo + when (contactActive ct && notify) . void $ sendDirectContactMessage ct XDirectDel + contactConnIds <- map aConnId <$> withStore (\db -> getContactConnections db userId ct) + deleteAgentConnectionsAsync user contactConnIds -- functions below are called in separate transactions to prevent crashes on android -- (possibly, race condition on integrity check?) withStore' $ \db -> deleteContactConnectionsAndFiles db userId ct @@ -1334,7 +1335,7 @@ processChatCommand = \case ConnectSimplex incognito -> withUser $ \user -> -- [incognito] generate profile to send connectViaContact user incognito adminContactReq - DeleteContact cName -> withContactName cName $ APIDeleteChat . ChatRef CTDirect + DeleteContact cName -> withContactName cName $ \ctId -> APIDeleteChat (ChatRef CTDirect ctId) True ClearContact cName -> withContactName cName $ APIClearChat . ChatRef CTDirect APIListContacts userId -> withUserId userId $ \user -> CRContactsList user <$> withStore' (`getUserContacts` user) @@ -1429,7 +1430,7 @@ processChatCommand = \case processChatCommand . APISendMessage chatRef True Nothing $ ComposedMessage Nothing Nothing mc SendMessageBroadcast msg -> withUser $ \user -> do contacts <- withStore' (`getUserContacts` user) - let cts = filter (\ct -> isReady ct && directOrUsed ct) contacts + let cts = filter (\ct -> isReady ct && contactActive ct && directOrUsed ct) contacts ChatConfig {logLevel} <- asks config withChatLock "sendMessageBroadcast" . procCmd $ do (successes, failures) <- foldM (sendAndCount user logLevel) (0, 0) cts @@ -1597,7 +1598,7 @@ processChatCommand = \case processChatCommand $ APILeaveGroup groupId DeleteGroup gName -> withUser $ \user -> do groupId <- withStore $ \db -> getGroupIdByName db user gName - processChatCommand $ APIDeleteChat (ChatRef CTGroup groupId) + processChatCommand $ APIDeleteChat (ChatRef CTGroup groupId) True ClearGroup gName -> withUser $ \user -> do groupId <- withStore $ \db -> getGroupIdByName db user gName processChatCommand $ APIClearChat (ChatRef CTGroup groupId) @@ -1979,7 +1980,7 @@ processChatCommand = \case -- read contacts before user update to correctly merge preferences -- [incognito] filter out contacts with whom user has incognito connections contacts <- - filter (\ct -> isReady ct && not (contactConnIncognito ct)) + filter (\ct -> isReady ct && contactActive ct && not (contactConnIncognito ct)) <$> withStore' (`getUserContacts` user) user' <- updateUser asks currentUser >>= atomically . (`writeTVar` Just user') @@ -3041,6 +3042,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do XFileCancel sharedMsgId -> xFileCancel ct' sharedMsgId msgMeta XFileAcptInv sharedMsgId fileConnReq_ fName -> xFileAcptInv ct' sharedMsgId fileConnReq_ fName msgMeta XInfo p -> xInfo ct' p + XDirectDel -> xDirectDel ct' msg msgMeta XGrpInv gInv -> processGroupInvitation ct' gInv msg msgMeta XInfoProbe probe -> xInfoProbe (CGMContact ct') probe XInfoProbeCheck probeHash -> xInfoProbeCheck ct' probeHash @@ -4245,6 +4247,18 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do xInfo :: Contact -> Profile -> m () xInfo c p' = void $ processContactProfileUpdate c p' True + xDirectDel :: Contact -> RcvMessage -> MsgMeta -> m () + xDirectDel c msg msgMeta = do + checkIntegrityCreateItem (CDDirectRcv c) msgMeta + ct' <- withStore' $ \db -> updateContactStatus db user c CSDeleted + contactConns <- withStore $ \db -> getContactConnections db userId ct' + deleteAgentConnectionsAsync user $ map aConnId contactConns + forM_ contactConns $ \conn -> withStore' $ \db -> updateConnectionStatus db conn ConnDeleted + let ct'' = ct' {activeConn = (contactConn ct') {connStatus = ConnDeleted}} :: Contact + ci <- saveRcvChatItem user (CDDirectRcv ct'') msg msgMeta (CIRcvDirectEvent RDEContactDeleted) + toView $ CRNewChatItem user (AChatItem SCTDirect SMDRcv (DirectChat ct'') ci) + toView $ CRContactDeletedByContact user ct'' + processContactProfileUpdate :: Contact -> Profile -> Bool -> m Contact processContactProfileUpdate c@Contact {profile = p} p' createItems | fromLocalProfile p /= p' = do @@ -4928,8 +4942,9 @@ deleteOrUpdateMemberRecord user@User {userId} member = Nothing -> deleteGroupMember db user member sendDirectContactMessage :: (MsgEncodingI e, ChatMonad m) => Contact -> ChatMsgEvent e -> m (SndMessage, Int64) -sendDirectContactMessage ct@Contact {activeConn = conn@Connection {connId, connStatus}} chatMsgEvent +sendDirectContactMessage ct@Contact {activeConn = conn@Connection {connId, connStatus}, contactStatus} chatMsgEvent | connStatus /= ConnReady && connStatus /= ConnSndReady = throwChatError $ CEContactNotReady ct + | contactStatus /= CSActive = throwChatError $ CEContactNotActive ct | connDisabled conn = throwChatError $ CEContactDisabled ct | otherwise = sendDirectMessage conn chatMsgEvent (ConnectionId connId) @@ -5418,7 +5433,7 @@ chatCommandP = "/_reaction " *> (APIChatItemReaction <$> chatRefP <* A.space <*> A.decimal <* A.space <*> onOffP <* A.space <*> jsonP), "/_read chat " *> (APIChatRead <$> chatRefP <*> optional (A.space *> ((,) <$> ("from=" *> A.decimal) <* A.space <*> ("to=" *> A.decimal)))), "/_unread chat " *> (APIChatUnread <$> chatRefP <* A.space <*> onOffP), - "/_delete " *> (APIDeleteChat <$> chatRefP), + "/_delete " *> (APIDeleteChat <$> chatRefP <*> (A.space *> "notify=" *> onOffP <|> pure True)), "/_clear chat " *> (APIClearChat <$> chatRefP), "/_accept" *> (APIAcceptContact <$> incognitoOnOffP <* A.space <*> A.decimal), "/_reject " *> (APIRejectContact <$> A.decimal), diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 69bc13ddd..2931a874e 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -248,7 +248,7 @@ data ChatCommand | APIChatItemReaction {chatRef :: ChatRef, chatItemId :: ChatItemId, add :: Bool, reaction :: MsgReaction} | APIChatRead ChatRef (Maybe (ChatItemId, ChatItemId)) | APIChatUnread ChatRef Bool - | APIDeleteChat ChatRef + | APIDeleteChat ChatRef Bool -- `notify` flag is only applied to direct chats | APIClearChat ChatRef | APIAcceptContact IncognitoEnabled Int64 | APIRejectContact Int64 @@ -491,6 +491,7 @@ data ChatResponse | CRContactUpdated {user :: User, fromContact :: Contact, toContact :: Contact} | CRContactsMerged {user :: User, intoContact :: Contact, mergedContact :: Contact} | CRContactDeleted {user :: User, contact :: Contact} + | CRContactDeletedByContact {user :: User, contact :: Contact} | CRChatCleared {user :: User, chatInfo :: AChatInfo} | CRUserContactLinkCreated {user :: User, connReqContact :: ConnReqContact} | CRUserContactLinkDeleted {user :: User} @@ -898,6 +899,7 @@ data ChatErrorType | CEInvalidChatMessage {connection :: Connection, msgMeta :: Maybe MsgMetaJSON, messageData :: Text, message :: String} | CEContactNotFound {contactName :: ContactName, suspectedMember :: Maybe (GroupInfo, GroupMember)} | CEContactNotReady {contact :: Contact} + | CEContactNotActive {contact :: Contact} | CEContactDisabled {contact :: Contact} | CEConnectionDisabled {connection :: Connection} | CEGroupUserRole {groupInfo :: GroupInfo, requiredRole :: GroupMemberRole} diff --git a/src/Simplex/Chat/Messages/CIContent.hs b/src/Simplex/Chat/Messages/CIContent.hs index df22c2684..9abc8e464 100644 --- a/src/Simplex/Chat/Messages/CIContent.hs +++ b/src/Simplex/Chat/Messages/CIContent.hs @@ -132,6 +132,7 @@ data CIContent (d :: MsgDirection) where CIRcvDecryptionError :: MsgDecryptError -> Word32 -> CIContent 'MDRcv CIRcvGroupInvitation :: CIGroupInvitation -> GroupMemberRole -> CIContent 'MDRcv CISndGroupInvitation :: CIGroupInvitation -> GroupMemberRole -> CIContent 'MDSnd + CIRcvDirectEvent :: RcvDirectEvent -> CIContent 'MDRcv CIRcvGroupEvent :: RcvGroupEvent -> CIContent 'MDRcv CISndGroupEvent :: SndGroupEvent -> CIContent 'MDSnd CIRcvConnEvent :: RcvConnEvent -> CIContent 'MDRcv @@ -179,6 +180,7 @@ ciRequiresAttention content = case msgDirection @d of CIRcvIntegrityError _ -> True CIRcvDecryptionError {} -> True CIRcvGroupInvitation {} -> True + CIRcvDirectEvent _ -> False CIRcvGroupEvent rge -> case rge of RGEMemberAdded {} -> False RGEMemberConnected -> False @@ -300,6 +302,27 @@ instance ToJSON DBSndConnEvent where toJSON (SCE v) = J.genericToJSON (singleFieldJSON $ dropPrefix "SCE") v toEncoding (SCE v) = J.genericToEncoding (singleFieldJSON $ dropPrefix "SCE") v +data RcvDirectEvent = + -- RDEProfileChanged {...} + RDEContactDeleted + deriving (Show, Generic) + +instance FromJSON RcvDirectEvent where + parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "RDE" + +instance ToJSON RcvDirectEvent where + toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "RDE" + toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "RDE" + +newtype DBRcvDirectEvent = RDE RcvDirectEvent + +instance FromJSON DBRcvDirectEvent where + parseJSON v = RDE <$> J.genericParseJSON (singleFieldJSON $ dropPrefix "RDE") v + +instance ToJSON DBRcvDirectEvent where + toJSON (RDE v) = J.genericToJSON (singleFieldJSON $ dropPrefix "RDE") v + toEncoding (RDE v) = J.genericToEncoding (singleFieldJSON $ dropPrefix "RDE") v + newtype DBMsgErrorType = DBME MsgErrorType instance FromJSON DBMsgErrorType where @@ -348,6 +371,7 @@ ciContentToText = \case CIRcvDecryptionError err n -> msgDecryptErrorText err n CIRcvGroupInvitation groupInvitation memberRole -> "received " <> ciGroupInvitationToText groupInvitation memberRole CISndGroupInvitation groupInvitation memberRole -> "sent " <> ciGroupInvitationToText groupInvitation memberRole + CIRcvDirectEvent event -> rcvDirectEventToText event CIRcvGroupEvent event -> rcvGroupEventToText event CISndGroupEvent event -> sndGroupEventToText event CIRcvConnEvent event -> rcvConnEventToText event @@ -368,6 +392,10 @@ ciGroupInvitationToText :: CIGroupInvitation -> GroupMemberRole -> Text ciGroupInvitationToText CIGroupInvitation {groupProfile = GroupProfile {displayName, fullName}} role = "invitation to join group " <> displayName <> optionalFullName displayName fullName <> " as " <> (decodeLatin1 . strEncode $ role) +rcvDirectEventToText :: RcvDirectEvent -> Text +rcvDirectEventToText = \case + RDEContactDeleted -> "contact deleted" + rcvGroupEventToText :: RcvGroupEvent -> Text rcvGroupEventToText = \case RGEMemberAdded _ p -> "added " <> profileToText p @@ -486,6 +514,7 @@ data JSONCIContent | JCIRcvDecryptionError {msgDecryptError :: MsgDecryptError, msgCount :: Word32} | JCIRcvGroupInvitation {groupInvitation :: CIGroupInvitation, memberRole :: GroupMemberRole} | JCISndGroupInvitation {groupInvitation :: CIGroupInvitation, memberRole :: GroupMemberRole} + | JCIRcvDirectEvent {rcvDirectEvent :: RcvDirectEvent} | JCIRcvGroupEvent {rcvGroupEvent :: RcvGroupEvent} | JCISndGroupEvent {sndGroupEvent :: SndGroupEvent} | JCIRcvConnEvent {rcvConnEvent :: RcvConnEvent} @@ -522,6 +551,7 @@ jsonCIContent = \case CIRcvDecryptionError err n -> JCIRcvDecryptionError err n CIRcvGroupInvitation groupInvitation memberRole -> JCIRcvGroupInvitation {groupInvitation, memberRole} CISndGroupInvitation groupInvitation memberRole -> JCISndGroupInvitation {groupInvitation, memberRole} + CIRcvDirectEvent rcvDirectEvent -> JCIRcvDirectEvent {rcvDirectEvent} CIRcvGroupEvent rcvGroupEvent -> JCIRcvGroupEvent {rcvGroupEvent} CISndGroupEvent sndGroupEvent -> JCISndGroupEvent {sndGroupEvent} CIRcvConnEvent rcvConnEvent -> JCIRcvConnEvent {rcvConnEvent} @@ -550,6 +580,7 @@ aciContentJSON = \case JCIRcvDecryptionError err n -> ACIContent SMDRcv $ CIRcvDecryptionError err n JCIRcvGroupInvitation {groupInvitation, memberRole} -> ACIContent SMDRcv $ CIRcvGroupInvitation groupInvitation memberRole JCISndGroupInvitation {groupInvitation, memberRole} -> ACIContent SMDSnd $ CISndGroupInvitation groupInvitation memberRole + JCIRcvDirectEvent {rcvDirectEvent} -> ACIContent SMDRcv $ CIRcvDirectEvent rcvDirectEvent JCIRcvGroupEvent {rcvGroupEvent} -> ACIContent SMDRcv $ CIRcvGroupEvent rcvGroupEvent JCISndGroupEvent {sndGroupEvent} -> ACIContent SMDSnd $ CISndGroupEvent sndGroupEvent JCIRcvConnEvent {rcvConnEvent} -> ACIContent SMDRcv $ CIRcvConnEvent rcvConnEvent @@ -579,6 +610,7 @@ data DBJSONCIContent | DBJCIRcvDecryptionError {msgDecryptError :: MsgDecryptError, msgCount :: Word32} | DBJCIRcvGroupInvitation {groupInvitation :: CIGroupInvitation, memberRole :: GroupMemberRole} | DBJCISndGroupInvitation {groupInvitation :: CIGroupInvitation, memberRole :: GroupMemberRole} + | DBJCIRcvDirectEvent {rcvDirectEvent :: DBRcvDirectEvent} | DBJCIRcvGroupEvent {rcvGroupEvent :: DBRcvGroupEvent} | DBJCISndGroupEvent {sndGroupEvent :: DBSndGroupEvent} | DBJCIRcvConnEvent {rcvConnEvent :: DBRcvConnEvent} @@ -615,6 +647,7 @@ dbJsonCIContent = \case CIRcvDecryptionError err n -> DBJCIRcvDecryptionError err n CIRcvGroupInvitation groupInvitation memberRole -> DBJCIRcvGroupInvitation {groupInvitation, memberRole} CISndGroupInvitation groupInvitation memberRole -> DBJCISndGroupInvitation {groupInvitation, memberRole} + CIRcvDirectEvent rde -> DBJCIRcvDirectEvent $ RDE rde CIRcvGroupEvent rge -> DBJCIRcvGroupEvent $ RGE rge CISndGroupEvent sge -> DBJCISndGroupEvent $ SGE sge CIRcvConnEvent rce -> DBJCIRcvConnEvent $ RCE rce @@ -643,6 +676,7 @@ aciContentDBJSON = \case DBJCIRcvDecryptionError err n -> ACIContent SMDRcv $ CIRcvDecryptionError err n DBJCIRcvGroupInvitation {groupInvitation, memberRole} -> ACIContent SMDRcv $ CIRcvGroupInvitation groupInvitation memberRole DBJCISndGroupInvitation {groupInvitation, memberRole} -> ACIContent SMDSnd $ CISndGroupInvitation groupInvitation memberRole + DBJCIRcvDirectEvent (RDE rde) -> ACIContent SMDRcv $ CIRcvDirectEvent rde DBJCIRcvGroupEvent (RGE rge) -> ACIContent SMDRcv $ CIRcvGroupEvent rge DBJCISndGroupEvent (SGE sge) -> ACIContent SMDSnd $ CISndGroupEvent sge DBJCIRcvConnEvent (RCE rce) -> ACIContent SMDRcv $ CIRcvConnEvent rce diff --git a/src/Simplex/Chat/Migrations/M20230926_contact_status.hs b/src/Simplex/Chat/Migrations/M20230926_contact_status.hs new file mode 100644 index 000000000..b6c5dd955 --- /dev/null +++ b/src/Simplex/Chat/Migrations/M20230926_contact_status.hs @@ -0,0 +1,18 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Migrations.M20230926_contact_status where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20230926_contact_status :: Query +m20230926_contact_status = + [sql| +ALTER TABLE contacts ADD COLUMN contact_status TEXT NOT NULL DEFAULT 'active'; +|] + +down_m20230926_contact_status :: Query +down_m20230926_contact_status = + [sql| +ALTER TABLE contacts DROP COLUMN contact_status; +|] diff --git a/src/Simplex/Chat/Migrations/chat_schema.sql b/src/Simplex/Chat/Migrations/chat_schema.sql index 141247e59..65ceb7d19 100644 --- a/src/Simplex/Chat/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Migrations/chat_schema.sql @@ -71,6 +71,7 @@ CREATE TABLE contacts( contact_group_member_id INTEGER REFERENCES group_members(group_member_id) ON DELETE SET NULL, contact_grp_inv_sent INTEGER NOT NULL DEFAULT 0, + contact_status TEXT NOT NULL DEFAULT 'active', FOREIGN KEY(user_id, local_display_name) REFERENCES display_names(user_id, local_display_name) ON DELETE CASCADE diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index 6e725e6c2..bbdddf8ce 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -215,6 +215,7 @@ data ChatMsgEvent (e :: MsgEncoding) where XFileCancel :: SharedMsgId -> ChatMsgEvent 'Json XInfo :: Profile -> ChatMsgEvent 'Json XContact :: Profile -> Maybe XContactId -> ChatMsgEvent 'Json + XDirectDel :: ChatMsgEvent 'Json XGrpInv :: GroupInvitation -> ChatMsgEvent 'Json XGrpAcpt :: MemberId -> ChatMsgEvent 'Json XGrpMemNew :: MemberInfo -> ChatMsgEvent 'Json @@ -550,6 +551,7 @@ data CMEventTag (e :: MsgEncoding) where XFileCancel_ :: CMEventTag 'Json XInfo_ :: CMEventTag 'Json XContact_ :: CMEventTag 'Json + XDirectDel_ :: CMEventTag 'Json XGrpInv_ :: CMEventTag 'Json XGrpAcpt_ :: CMEventTag 'Json XGrpMemNew_ :: CMEventTag 'Json @@ -596,6 +598,7 @@ instance MsgEncodingI e => StrEncoding (CMEventTag e) where XFileCancel_ -> "x.file.cancel" XInfo_ -> "x.info" XContact_ -> "x.contact" + XDirectDel_ -> "x.direct.del" XGrpInv_ -> "x.grp.inv" XGrpAcpt_ -> "x.grp.acpt" XGrpMemNew_ -> "x.grp.mem.new" @@ -643,6 +646,7 @@ instance StrEncoding ACMEventTag where "x.file.cancel" -> XFileCancel_ "x.info" -> XInfo_ "x.contact" -> XContact_ + "x.direct.del" -> XDirectDel_ "x.grp.inv" -> XGrpInv_ "x.grp.acpt" -> XGrpAcpt_ "x.grp.mem.new" -> XGrpMemNew_ @@ -686,6 +690,7 @@ toCMEventTag msg = case msg of XFileCancel _ -> XFileCancel_ XInfo _ -> XInfo_ XContact _ _ -> XContact_ + XDirectDel -> XDirectDel_ XGrpInv _ -> XGrpInv_ XGrpAcpt _ -> XGrpAcpt_ XGrpMemNew _ -> XGrpMemNew_ @@ -782,6 +787,7 @@ appJsonToCM AppMessageJson {v, msgId, event, params} = do XFileCancel_ -> XFileCancel <$> p "msgId" XInfo_ -> XInfo <$> p "profile" XContact_ -> XContact <$> p "profile" <*> opt "contactReqId" + XDirectDel_ -> pure XDirectDel XGrpInv_ -> XGrpInv <$> p "groupInvitation" XGrpAcpt_ -> XGrpAcpt <$> p "memberId" XGrpMemNew_ -> XGrpMemNew <$> p "memberInfo" @@ -839,6 +845,7 @@ chatToAppMessage ChatMessage {chatVRange, msgId, chatMsgEvent} = case encoding @ XFileCancel sharedMsgId -> o ["msgId" .= sharedMsgId] XInfo profile -> o ["profile" .= profile] XContact profile xContactId -> o $ ("contactReqId" .=? xContactId) ["profile" .= profile] + XDirectDel -> JM.empty XGrpInv groupInv -> o ["groupInvitation" .= groupInv] XGrpAcpt memId -> o ["memberId" .= memId] XGrpMemNew memInfo -> o ["memberInfo" .= memInfo] diff --git a/src/Simplex/Chat/Store/Connections.hs b/src/Simplex/Chat/Store/Connections.hs index 7da0d1ca8..93f3349ca 100644 --- a/src/Simplex/Chat/Store/Connections.hs +++ b/src/Simplex/Chat/Store/Connections.hs @@ -71,19 +71,19 @@ getConnectionEntity db user@User {userId, userContactId} agentConnId = do db [sql| SELECT - c.contact_profile_id, c.local_display_name, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, c.via_group, c.contact_used, c.enable_ntfs, c.send_rcpts, c.favorite, + c.contact_profile_id, c.local_display_name, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, c.via_group, c.contact_used, c.contact_status, c.enable_ntfs, c.send_rcpts, c.favorite, p.preferences, c.user_preferences, c.created_at, c.updated_at, c.chat_ts, c.contact_group_member_id, c.contact_grp_inv_sent FROM contacts c JOIN contact_profiles p ON c.contact_profile_id = p.contact_profile_id WHERE c.user_id = ? AND c.contact_id = ? AND c.deleted = 0 |] (userId, contactId) - toContact' :: Int64 -> Connection -> [(ProfileId, ContactName, Text, Text, Maybe ImageData, Maybe ConnReqContact, LocalAlias, Maybe Int64, Bool) :. (Maybe Bool, Maybe Bool, Bool, Maybe Preferences, Preferences, UTCTime, UTCTime, Maybe UTCTime, Maybe GroupMemberId, Bool)] -> Either StoreError Contact - toContact' contactId activeConn [(profileId, localDisplayName, displayName, fullName, image, contactLink, localAlias, viaGroup, contactUsed) :. (enableNtfs_, sendRcpts, favorite, preferences, userPreferences, createdAt, updatedAt, chatTs, contactGroupMemberId, contactGrpInvSent)] = + toContact' :: Int64 -> Connection -> [(ProfileId, ContactName, Text, Text, Maybe ImageData, Maybe ConnReqContact, LocalAlias, Maybe Int64, Bool, ContactStatus) :. (Maybe Bool, Maybe Bool, Bool, Maybe Preferences, Preferences, UTCTime, UTCTime, Maybe UTCTime, Maybe GroupMemberId, Bool)] -> Either StoreError Contact + toContact' contactId activeConn [(profileId, localDisplayName, displayName, fullName, image, contactLink, localAlias, viaGroup, contactUsed, contactStatus) :. (enableNtfs_, sendRcpts, favorite, preferences, userPreferences, createdAt, updatedAt, chatTs, contactGroupMemberId, contactGrpInvSent)] = let profile = LocalProfile {profileId, displayName, fullName, image, contactLink, preferences, localAlias} chatSettings = ChatSettings {enableNtfs = fromMaybe True enableNtfs_, sendRcpts, favorite} mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito activeConn - in Right Contact {contactId, localDisplayName, profile, activeConn, viaGroup, contactUsed, chatSettings, userPreferences, mergedPreferences, createdAt, updatedAt, chatTs, contactGroupMemberId, contactGrpInvSent} + in Right Contact {contactId, localDisplayName, profile, activeConn, viaGroup, contactUsed, contactStatus, chatSettings, userPreferences, mergedPreferences, createdAt, updatedAt, chatTs, contactGroupMemberId, contactGrpInvSent} toContact' _ _ _ = Left $ SEInternalError "referenced contact not found" getGroupAndMember_ :: Int64 -> Connection -> ExceptT StoreError IO (GroupInfo, GroupMember) getGroupAndMember_ groupMemberId c = ExceptT $ do diff --git a/src/Simplex/Chat/Store/Direct.hs b/src/Simplex/Chat/Store/Direct.hs index 7e8cee0e7..886c73505 100644 --- a/src/Simplex/Chat/Store/Direct.hs +++ b/src/Simplex/Chat/Store/Direct.hs @@ -42,6 +42,7 @@ module Simplex.Chat.Store.Direct deletePCCIncognitoProfile, updateContactUsed, updateContactUnreadChat, + updateContactStatus, updateGroupUnreadChat, setConnectionVerified, incConnectionAuthErrCounter, @@ -147,7 +148,7 @@ getConnReqContactXContactId db user@User {userId} cReqHash = do [sql| SELECT -- Contact - ct.contact_id, ct.contact_profile_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, cp.contact_link, cp.local_alias, ct.contact_used, ct.enable_ntfs, ct.send_rcpts, ct.favorite, + ct.contact_id, ct.contact_profile_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, cp.contact_link, cp.local_alias, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite, cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.contact_group_member_id, ct.contact_grp_inv_sent, -- Connection c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.local_alias, @@ -206,7 +207,7 @@ createDirectContact db user@User {userId} activeConn@Connection {connId, localAl let profile = toLocalProfile profileId p localAlias userPreferences = emptyChatPrefs mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito activeConn - pure $ Contact {contactId, localDisplayName, profile, activeConn, viaGroup = Nothing, contactUsed = False, chatSettings = defaultChatSettings, userPreferences, mergedPreferences, createdAt, updatedAt = createdAt, chatTs = Just createdAt, contactGroupMemberId = Nothing, contactGrpInvSent = False} + pure $ Contact {contactId, localDisplayName, profile, activeConn, viaGroup = Nothing, contactUsed = False, contactStatus = CSActive, chatSettings = defaultChatSettings, userPreferences, mergedPreferences, createdAt, updatedAt = createdAt, chatTs = Just createdAt, contactGroupMemberId = Nothing, contactGrpInvSent = False} deleteContactConnectionsAndFiles :: DB.Connection -> UserId -> Contact -> IO () deleteContactConnectionsAndFiles db userId Contact {contactId} = do @@ -387,6 +388,19 @@ updateContactUnreadChat db User {userId} Contact {contactId} unreadChat = do updatedAt <- getCurrentTime DB.execute db "UPDATE contacts SET unread_chat = ?, updated_at = ? WHERE user_id = ? AND contact_id = ?" (unreadChat, updatedAt, userId, contactId) +updateContactStatus :: DB.Connection -> User -> Contact -> ContactStatus -> IO Contact +updateContactStatus db User {userId} ct@Contact {contactId} contactStatus = do + currentTs <- getCurrentTime + DB.execute + db + [sql| + UPDATE contacts + SET contact_status = ?, updated_at = ? + WHERE user_id = ? AND contact_id = ? + |] + (contactStatus, currentTs, userId, contactId) + pure ct {contactStatus} + updateGroupUnreadChat :: DB.Connection -> User -> GroupInfo -> Bool -> IO () updateGroupUnreadChat db User {userId} GroupInfo {groupId} unreadChat = do updatedAt <- getCurrentTime @@ -491,7 +505,7 @@ createOrUpdateContactRequest db user@User {userId} userContactLinkId invId (Vers [sql| SELECT -- Contact - ct.contact_id, ct.contact_profile_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, cp.contact_link, cp.local_alias, ct.contact_used, ct.enable_ntfs, ct.send_rcpts, ct.favorite, + ct.contact_id, ct.contact_profile_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, cp.contact_link, cp.local_alias, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite, cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.contact_group_member_id, ct.contact_grp_inv_sent, -- Connection c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.local_alias, @@ -637,7 +651,7 @@ createAcceptedContact db user@User {userId, profile = LocalProfile {preferences} contactId <- insertedRowId db activeConn <- createConnection_ db userId ConnContact (Just contactId) agentConnId cReqChatVRange Nothing (Just userContactLinkId) customUserProfileId 0 createdAt subMode let mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito activeConn - pure $ Contact {contactId, localDisplayName, profile = toLocalProfile profileId profile "", activeConn, viaGroup = Nothing, contactUsed = False, chatSettings = defaultChatSettings, userPreferences, mergedPreferences, createdAt = createdAt, updatedAt = createdAt, chatTs = Just createdAt, contactGroupMemberId = Nothing, contactGrpInvSent = False} + pure $ Contact {contactId, localDisplayName, profile = toLocalProfile profileId profile "", activeConn, viaGroup = Nothing, contactUsed = False, contactStatus = CSActive, chatSettings = defaultChatSettings, userPreferences, mergedPreferences, createdAt = createdAt, updatedAt = createdAt, chatTs = Just createdAt, contactGroupMemberId = Nothing, contactGrpInvSent = False} getContactIdByName :: DB.Connection -> User -> ContactName -> ExceptT StoreError IO Int64 getContactIdByName db User {userId} cName = @@ -655,7 +669,7 @@ getContact_ db user@User {userId} contactId deleted = [sql| SELECT -- Contact - ct.contact_id, ct.contact_profile_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, cp.contact_link, cp.local_alias, ct.contact_used, ct.enable_ntfs, ct.send_rcpts, ct.favorite, + ct.contact_id, ct.contact_profile_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, cp.contact_link, cp.local_alias, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite, cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.contact_group_member_id, ct.contact_grp_inv_sent, -- Connection c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.local_alias, diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index d6aa3a5b9..e72ca8e8c 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -700,7 +700,7 @@ getContactViaMember db user@User {userId} GroupMember {groupMemberId} = [sql| SELECT -- Contact - ct.contact_id, ct.contact_profile_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, cp.contact_link, cp.local_alias, ct.contact_used, ct.enable_ntfs, ct.send_rcpts, ct.favorite, + ct.contact_id, ct.contact_profile_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, cp.contact_link, cp.local_alias, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite, cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.contact_group_member_id, ct.contact_grp_inv_sent, -- Connection c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.local_alias, @@ -1044,7 +1044,7 @@ getViaGroupContact db user@User {userId} GroupMember {groupMemberId} = db [sql| SELECT - ct.contact_id, ct.contact_profile_id, ct.local_display_name, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, ct.via_group, ct.contact_used, ct.enable_ntfs, ct.send_rcpts, ct.favorite, + ct.contact_id, ct.contact_profile_id, ct.local_display_name, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, ct.via_group, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite, p.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.contact_group_member_id, ct.contact_grp_inv_sent, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter, @@ -1062,13 +1062,13 @@ getViaGroupContact db user@User {userId} GroupMember {groupMemberId} = |] (userId, groupMemberId) where - toContact' :: ((ContactId, ProfileId, ContactName, Text, Text, Maybe ImageData, Maybe ConnReqContact, LocalAlias, Maybe Int64, Bool) :. (Maybe Bool, Maybe Bool, Bool, Maybe Preferences, Preferences, UTCTime, UTCTime, Maybe UTCTime, Maybe GroupMemberId, Bool)) :. ConnectionRow -> Contact - toContact' (((contactId, profileId, localDisplayName, displayName, fullName, image, contactLink, localAlias, viaGroup, contactUsed) :. (enableNtfs_, sendRcpts, favorite, preferences, userPreferences, createdAt, updatedAt, chatTs, contactGroupMemberId, contactGrpInvSent)) :. connRow) = + toContact' :: ((ContactId, ProfileId, ContactName, Text, Text, Maybe ImageData, Maybe ConnReqContact, LocalAlias, Maybe Int64, Bool, ContactStatus) :. (Maybe Bool, Maybe Bool, Bool, Maybe Preferences, Preferences, UTCTime, UTCTime, Maybe UTCTime, Maybe GroupMemberId, Bool)) :. ConnectionRow -> Contact + toContact' (((contactId, profileId, localDisplayName, displayName, fullName, image, contactLink, localAlias, viaGroup, contactUsed, contactStatus) :. (enableNtfs_, sendRcpts, favorite, preferences, userPreferences, createdAt, updatedAt, chatTs, contactGroupMemberId, contactGrpInvSent)) :. connRow) = let profile = LocalProfile {profileId, displayName, fullName, image, contactLink, preferences, localAlias} chatSettings = ChatSettings {enableNtfs = fromMaybe True enableNtfs_, sendRcpts, favorite} activeConn = toConnection connRow mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito activeConn - in Contact {contactId, localDisplayName, profile, activeConn, viaGroup, contactUsed, chatSettings, userPreferences, mergedPreferences, createdAt, updatedAt, chatTs, contactGroupMemberId, contactGrpInvSent} + in Contact {contactId, localDisplayName, profile, activeConn, viaGroup, contactUsed, contactStatus, chatSettings, userPreferences, mergedPreferences, createdAt, updatedAt, chatTs, contactGroupMemberId, contactGrpInvSent} updateGroupProfile :: DB.Connection -> User -> GroupInfo -> GroupProfile -> ExceptT StoreError IO GroupInfo updateGroupProfile db User {userId} g@GroupInfo {groupId, localDisplayName, groupProfile = GroupProfile {displayName}} p'@GroupProfile {displayName = newName, fullName, description, image, groupPreferences} @@ -1160,8 +1160,8 @@ getMatchingContacts :: DB.Connection -> User -> Contact -> IO [Contact] getMatchingContacts db user@User {userId} Contact {contactId, profile = LocalProfile {displayName, fullName, image}} = do contactIds <- map fromOnly <$> case image of - Just img -> DB.query db (q <> " AND p.image = ?") (userId, contactId, displayName, fullName, img) - Nothing -> DB.query db (q <> " AND p.image is NULL") (userId, contactId, displayName, fullName) + Just img -> DB.query db (q <> " AND p.image = ?") (userId, contactId, CSActive, displayName, fullName, img) + Nothing -> DB.query db (q <> " AND p.image is NULL") (userId, contactId, CSActive, displayName, fullName) rights <$> mapM (runExceptT . getContact db user) contactIds where -- this query is different from one in getMatchingMemberContacts @@ -1172,7 +1172,7 @@ getMatchingContacts db user@User {userId} Contact {contactId, profile = LocalPro FROM contacts ct JOIN contact_profiles p ON ct.contact_profile_id = p.contact_profile_id WHERE ct.user_id = ? AND ct.contact_id != ? - AND ct.deleted = 0 + AND ct.contact_status = ? AND ct.deleted = 0 AND p.display_name = ? AND p.full_name = ? |] @@ -1521,7 +1521,7 @@ createMemberContact connId <- insertedRowId db let ctConn = Connection {connId, agentConnId = AgentConnId acId, peerChatVRange, connType = ConnContact, entityId = Just contactId, viaContact = Nothing, viaUserContactLink = Nothing, viaGroupLink = False, groupLinkId = Nothing, customUserProfileId, connLevel, connStatus = ConnNew, localAlias = "", createdAt = currentTs, connectionCode = Nothing, authErrCounter = 0} mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito ctConn - pure Contact {contactId, localDisplayName, profile = memberProfile, activeConn = ctConn, viaGroup = Nothing, contactUsed = True, chatSettings = defaultChatSettings, userPreferences, mergedPreferences, createdAt = currentTs, updatedAt = currentTs, chatTs = Just currentTs, contactGroupMemberId = Just groupMemberId, contactGrpInvSent = False} + pure Contact {contactId, localDisplayName, profile = memberProfile, activeConn = ctConn, viaGroup = Nothing, contactUsed = True, contactStatus = CSActive, chatSettings = defaultChatSettings, userPreferences, mergedPreferences, createdAt = currentTs, updatedAt = currentTs, chatTs = Just currentTs, contactGroupMemberId = Just groupMemberId, contactGrpInvSent = False} getMemberContact :: DB.Connection -> User -> ContactId -> ExceptT StoreError IO (GroupInfo, GroupMember, Contact, ConnReqInvitation) getMemberContact db user contactId = do @@ -1558,7 +1558,7 @@ createMemberContactInvited contactId <- createContactUpdateMember currentTs userPreferences ctConn <- createMemberContactConn_ db user connIds gInfo mConn contactId subMode let mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito ctConn - mCt' = Contact {contactId, localDisplayName = memberLDN, profile = memberProfile, activeConn = ctConn, viaGroup = Nothing, contactUsed = True, chatSettings = defaultChatSettings, userPreferences, mergedPreferences, createdAt = currentTs, updatedAt = currentTs, chatTs = Just currentTs, contactGroupMemberId = Nothing, contactGrpInvSent = False} + mCt' = Contact {contactId, localDisplayName = memberLDN, profile = memberProfile, activeConn = ctConn, viaGroup = Nothing, contactUsed = True, contactStatus = CSActive, chatSettings = defaultChatSettings, userPreferences, mergedPreferences, createdAt = currentTs, updatedAt = currentTs, chatTs = Just currentTs, contactGroupMemberId = Nothing, contactGrpInvSent = False} m' = m {memberContactId = Just contactId} pure (mCt', m') where @@ -1586,8 +1586,9 @@ updateMemberContactInvited :: DB.Connection -> User -> (CommandId, ConnId) -> Gr updateMemberContactInvited db user connIds gInfo mConn ct@Contact {contactId, activeConn = oldContactConn} subMode = do updateConnectionStatus db oldContactConn ConnDeleted activeConn <- createMemberContactConn_ db user connIds gInfo mConn contactId subMode - ct' <- resetMemberContactFields db ct - pure (ct' :: Contact) {activeConn} + ct' <- updateContactStatus db user ct CSActive + ct'' <- resetMemberContactFields db ct' + pure (ct'' :: Contact) {activeConn} resetMemberContactFields :: DB.Connection -> Contact -> IO Contact resetMemberContactFields db ct@Contact {contactId} = do diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index c08e6b11d..458944b6e 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -478,7 +478,7 @@ getDirectChatPreviews_ db user@User {userId} = do [sql| SELECT -- Contact - ct.contact_id, ct.contact_profile_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, cp.contact_link, cp.local_alias, ct.contact_used, ct.enable_ntfs, ct.send_rcpts, ct.favorite, + ct.contact_id, ct.contact_profile_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, cp.contact_link, cp.local_alias, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite, cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.contact_group_member_id, ct.contact_grp_inv_sent, -- Connection c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.local_alias, diff --git a/src/Simplex/Chat/Store/Migrations.hs b/src/Simplex/Chat/Store/Migrations.hs index d8bab817e..2f5bfec9e 100644 --- a/src/Simplex/Chat/Store/Migrations.hs +++ b/src/Simplex/Chat/Store/Migrations.hs @@ -81,6 +81,7 @@ import Simplex.Chat.Migrations.M20230829_connections_chat_vrange import Simplex.Chat.Migrations.M20230903_connections_to_subscribe import Simplex.Chat.Migrations.M20230913_member_contacts import Simplex.Chat.Migrations.M20230914_member_probes +import Simplex.Chat.Migrations.M20230926_contact_status import Simplex.Messaging.Agent.Store.SQLite.Migrations (Migration (..)) schemaMigrations :: [(String, Query, Maybe Query)] @@ -161,7 +162,8 @@ schemaMigrations = ("20230829_connections_chat_vrange", m20230829_connections_chat_vrange, Just down_m20230829_connections_chat_vrange), ("20230903_connections_to_subscribe", m20230903_connections_to_subscribe, Just down_m20230903_connections_to_subscribe), ("20230913_member_contacts", m20230913_member_contacts, Just down_m20230913_member_contacts), - ("20230914_member_probes", m20230914_member_probes, Just down_m20230914_member_probes) + ("20230914_member_probes", m20230914_member_probes, Just down_m20230914_member_probes), + ("20230926_contact_status", m20230926_contact_status, Just down_m20230926_contact_status) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/Shared.hs b/src/Simplex/Chat/Store/Shared.hs index e979c9006..4dc4f6e82 100644 --- a/src/Simplex/Chat/Store/Shared.hs +++ b/src/Simplex/Chat/Store/Shared.hs @@ -241,24 +241,24 @@ deleteUnusedIncognitoProfileById_ db User {userId} profileId = |] [":user_id" := userId, ":profile_id" := profileId] -type ContactRow = (ContactId, ProfileId, ContactName, Maybe Int64, ContactName, Text, Maybe ImageData, Maybe ConnReqContact, LocalAlias, Bool) :. (Maybe Bool, Maybe Bool, Bool, Maybe Preferences, Preferences, UTCTime, UTCTime, Maybe UTCTime, Maybe GroupMemberId, Bool) +type ContactRow = (ContactId, ProfileId, ContactName, Maybe Int64, ContactName, Text, Maybe ImageData, Maybe ConnReqContact, LocalAlias, Bool, ContactStatus) :. (Maybe Bool, Maybe Bool, Bool, Maybe Preferences, Preferences, UTCTime, UTCTime, Maybe UTCTime, Maybe GroupMemberId, Bool) toContact :: User -> ContactRow :. ConnectionRow -> Contact -toContact user (((contactId, profileId, localDisplayName, viaGroup, displayName, fullName, image, contactLink, localAlias, contactUsed) :. (enableNtfs_, sendRcpts, favorite, preferences, userPreferences, createdAt, updatedAt, chatTs, contactGroupMemberId, contactGrpInvSent)) :. connRow) = +toContact user (((contactId, profileId, localDisplayName, viaGroup, displayName, fullName, image, contactLink, localAlias, contactUsed, contactStatus) :. (enableNtfs_, sendRcpts, favorite, preferences, userPreferences, createdAt, updatedAt, chatTs, contactGroupMemberId, contactGrpInvSent)) :. connRow) = let profile = LocalProfile {profileId, displayName, fullName, image, contactLink, preferences, localAlias} activeConn = toConnection connRow chatSettings = ChatSettings {enableNtfs = fromMaybe True enableNtfs_, sendRcpts, favorite} mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito activeConn - in Contact {contactId, localDisplayName, profile, activeConn, viaGroup, contactUsed, chatSettings, userPreferences, mergedPreferences, createdAt, updatedAt, chatTs, contactGroupMemberId, contactGrpInvSent} + in Contact {contactId, localDisplayName, profile, activeConn, viaGroup, contactUsed, contactStatus, chatSettings, userPreferences, mergedPreferences, createdAt, updatedAt, chatTs, contactGroupMemberId, contactGrpInvSent} toContactOrError :: User -> ContactRow :. MaybeConnectionRow -> Either StoreError Contact -toContactOrError user (((contactId, profileId, localDisplayName, viaGroup, displayName, fullName, image, contactLink, localAlias, contactUsed) :. (enableNtfs_, sendRcpts, favorite, preferences, userPreferences, createdAt, updatedAt, chatTs, contactGroupMemberId, contactGrpInvSent)) :. connRow) = +toContactOrError user (((contactId, profileId, localDisplayName, viaGroup, displayName, fullName, image, contactLink, localAlias, contactUsed, contactStatus) :. (enableNtfs_, sendRcpts, favorite, preferences, userPreferences, createdAt, updatedAt, chatTs, contactGroupMemberId, contactGrpInvSent)) :. connRow) = let profile = LocalProfile {profileId, displayName, fullName, image, contactLink, preferences, localAlias} chatSettings = ChatSettings {enableNtfs = fromMaybe True enableNtfs_, sendRcpts, favorite} in case toMaybeConnection connRow of Just activeConn -> let mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito activeConn - in Right Contact {contactId, localDisplayName, profile, activeConn, viaGroup, contactUsed, chatSettings, userPreferences, mergedPreferences, createdAt, updatedAt, chatTs, contactGroupMemberId, contactGrpInvSent} + in Right Contact {contactId, localDisplayName, profile, activeConn, viaGroup, contactUsed, contactStatus, chatSettings, userPreferences, mergedPreferences, createdAt, updatedAt, chatTs, contactGroupMemberId, contactGrpInvSent} _ -> Left $ SEContactNotReady localDisplayName getProfileById :: DB.Connection -> UserId -> Int64 -> ExceptT StoreError IO LocalProfile diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index 93964316c..43265671b 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -169,6 +169,7 @@ data Contact = Contact activeConn :: Connection, viaGroup :: Maybe Int64, contactUsed :: Bool, + contactStatus :: ContactStatus, chatSettings :: ChatSettings, userPreferences :: Preferences, mergedPreferences :: ContactUserPreferences, @@ -185,7 +186,7 @@ instance ToJSON Contact where toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} contactConn :: Contact -> Connection -contactConn Contact{activeConn} = activeConn +contactConn Contact {activeConn} = activeConn contactConnId :: Contact -> ConnId contactConnId = aConnId . contactConn @@ -205,9 +206,34 @@ directOrUsed ct@Contact {contactUsed} = anyDirectOrUsed :: Contact -> Bool anyDirectOrUsed Contact {contactUsed, activeConn = Connection {connLevel}} = connLevel == 0 || contactUsed +contactActive :: Contact -> Bool +contactActive Contact {contactStatus} = contactStatus == CSActive + contactSecurityCode :: Contact -> Maybe SecurityCode contactSecurityCode Contact {activeConn} = connectionCode activeConn +data ContactStatus + = CSActive + | CSDeleted -- contact deleted by contact + deriving (Eq, Show, Ord) + +instance FromField ContactStatus where fromField = fromTextField_ textDecode + +instance ToField ContactStatus where toField = toField . textEncode + +instance ToJSON ContactStatus where + toJSON = J.String . textEncode + toEncoding = JE.text . textEncode + +instance TextEncoding ContactStatus where + textDecode = \case + "active" -> Just CSActive + "deleted" -> Just CSDeleted + _ -> Nothing + textEncode = \case + CSActive -> "active" + CSDeleted -> "deleted" + data ContactRef = ContactRef { contactId :: ContactId, connId :: Int64, diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 5db0c317e..01bdfba95 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -151,6 +151,7 @@ responseToView user_ ChatConfig {logLevel, showReactions, showReceipts, testView CRSentConfirmation u -> ttyUser u ["confirmation sent!"] CRSentInvitation u customUserProfile -> ttyUser u $ viewSentInvitation customUserProfile testView CRContactDeleted u c -> ttyUser u [ttyContact' c <> ": contact is deleted"] + CRContactDeletedByContact u c -> ttyUser u [ttyFullContact c <> " deleted contact with you"] CRChatCleared u chatInfo -> ttyUser u $ viewChatCleared chatInfo CRAcceptingContactRequest u c -> ttyUser u [ttyFullContact c <> ": accepting contact request..."] CRContactAlreadyExists u c -> ttyUser u [ttyFullContact c <> ": contact already exists"] @@ -1567,6 +1568,7 @@ viewChatError logLevel = \case ] CEContactNotFound cName m_ -> viewContactNotFound cName m_ CEContactNotReady c -> [ttyContact' c <> ": not ready"] + CEContactNotActive c -> [ttyContact' c <> ": not active"] CEContactDisabled Contact {localDisplayName = c} -> [ttyContact c <> ": disabled, to enable: " <> highlight ("/enable " <> c) <> ", to delete: " <> highlight ("/d " <> c)] CEConnectionDisabled Connection {connId, connType} -> [plain $ "connection " <> textEncode connType <> " (" <> tshow connId <> ") is disabled" | logLevel <= CLLWarning] CEGroupDuplicateMember c -> ["contact " <> ttyContact c <> " is already in the group"] diff --git a/tests/ChatTests/Direct.hs b/tests/ChatTests/Direct.hs index 7dbff89a2..36e74e11f 100644 --- a/tests/ChatTests/Direct.hs +++ b/tests/ChatTests/Direct.hs @@ -156,11 +156,12 @@ testAddContact = versionTestMatrix2 runTestAddContact -- test deleting contact alice ##> "/d bob_1" alice <## "bob_1: contact is deleted" + bob <## "alice_1 (Alice) deleted contact with you" alice ##> "@bob_1 hey" alice <## "no contact bob_1" alice @@@ [("@bob", "how are you?")] alice `hasContactProfiles` ["alice", "bob"] - bob @@@ [("@alice_1", "hi"), ("@alice", "how are you?")] + bob @@@ [("@alice_1", "contact deleted"), ("@alice", "how are you?")] bob `hasContactProfiles` ["alice", "alice", "bob"] -- test clearing chat alice #$> ("/clear bob", id, "bob: all messages are removed locally ONLY") @@ -202,6 +203,7 @@ testDeleteContactDeletesProfile = -- alice deletes contact, profile is deleted alice ##> "/d bob" alice <## "bob: contact is deleted" + bob <## "alice (Alice) deleted contact with you" alice ##> "/_contacts 1" (alice bob threadDelay 500000 - bob ##> "/d alice" + bob ##> "/_delete @2 notify=off" bob <## "alice: contact is deleted" forM_ [1 .. authErrDisableCount] $ \_ -> sendAuth alice alice <## "[bob] connection is disabled, to enable: /enable bob, to delete: /d bob" diff --git a/tests/ChatTests/Files.hs b/tests/ChatTests/Files.hs index f84d4dcb4..50f86d8e0 100644 --- a/tests/ChatTests/Files.hs +++ b/tests/ChatTests/Files.hs @@ -575,6 +575,7 @@ testSendImage = -- deleting contact without files folder set should not remove file bob ##> "/d alice" bob <## "alice: contact is deleted" + alice <## "bob (Bob) deleted contact with you" fileExists <- doesFileExist "./tests/tmp/test.jpg" fileExists `shouldBe` True @@ -637,6 +638,7 @@ testFilesFoldersSendImage = checkActionDeletesFile "./tests/tmp/app_files/test.jpg" $ do bob ##> "/d alice" bob <## "alice: contact is deleted" + alice <## "bob (Bob) deleted contact with you" testFilesFoldersImageSndDelete :: HasCallStack => FilePath -> IO () testFilesFoldersImageSndDelete = @@ -660,6 +662,7 @@ testFilesFoldersImageSndDelete = checkActionDeletesFile "./tests/tmp/alice_app_files/test_1MB.pdf" $ do alice ##> "/d bob" alice <## "bob: contact is deleted" + bob <## "alice (Alice) deleted contact with you" bob ##> "/fs 1" bob <##. "receiving file 1 (test_1MB.pdf) progress" -- deleting contact should remove cancelled file @@ -689,7 +692,10 @@ testFilesFoldersImageRcvDelete = checkActionDeletesFile "./tests/tmp/app_files/test.jpg" $ do bob ##> "/d alice" bob <## "alice: contact is deleted" - alice <## "bob cancelled receiving file 1 (test.jpg)" + alice + <### [ "bob (Bob) deleted contact with you", + "bob cancelled receiving file 1 (test.jpg)" + ] alice ##> "/fs 1" alice <## "sending file 1 (test.jpg) cancelled: bob" alice <## "file transfer cancelled" diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index bf740a960..4280810ca 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -220,6 +220,7 @@ testGroupShared alice bob cath checkMessages = do -- delete contact alice ##> "/d bob" alice <## "bob: contact is deleted" + bob <## "alice (Alice) deleted contact with you" alice `send` "@bob hey" alice <### [ "@bob hey", @@ -234,7 +235,7 @@ testGroupShared alice bob cath checkMessages = do alice <# "#team bob> received" when checkMessages $ do alice @@@ [("@cath", "sent invitation to join group team as admin"), ("#team", "received")] - bob @@@ [("@alice", "received invitation to join group team as admin"), ("@cath", "hey"), ("#team", "received")] + bob @@@ [("@alice", "contact deleted"), ("@cath", "hey"), ("#team", "received")] -- test clearing chat threadDelay 1000000 alice #$> ("/clear #team", id, "#team: all messages are removed locally ONLY") @@ -629,6 +630,7 @@ testGroupDeleteInvitedContact = threadDelay 500000 alice ##> "/d bob" alice <## "bob: contact is deleted" + bob <## "alice (Alice) deleted contact with you" bob ##> "/j team" concurrently_ (alice <## "#team: bob joined the group") @@ -700,10 +702,11 @@ testDeleteGroupMemberProfileKept = -- delete contact alice ##> "/d bob" alice <## "bob: contact is deleted" + bob <## "alice (Alice) deleted contact with you" alice ##> "@bob hey" alice <## "no contact bob, use @#club bob " - bob #> "@alice hey" - bob <## "[alice, contactId: 2, connId: 1] error: connection authorization failed - this could happen if connection was deleted, secured with different credentials, or due to a bug - please re-create the connection" + bob ##> "@alice hey" + bob <## "alice: not ready" (alice "/d #team" @@ -2785,6 +2788,8 @@ testMemberContactMessage = -- alice and bob delete contacts, connect alice ##> "/d bob" alice <## "bob: contact is deleted" + bob <## "alice (Alice) deleted contact with you" + bob ##> "/d alice" bob <## "alice: contact is deleted" @@ -2893,6 +2898,7 @@ testMemberContactInvitedConnectionReplaced tmp = do alice ##> "/d bob" alice <## "bob: contact is deleted" + bob <## "alice (Alice) deleted contact with you" alice ##> "@#team bob hi" alice @@ -2910,7 +2916,7 @@ testMemberContactInvitedConnectionReplaced tmp = do (alice <## "bob (Bob): contact is connected") (bob <## "alice (Alice): contact is connected") - bob #$> ("/_get chat @2 count=100", chat, chatFeatures <> [(0, "received invitation to join group team as admin"), (0, "hi"), (0, "security code changed")] <> chatFeatures) + bob #$> ("/_get chat @2 count=100", chat, chatFeatures <> [(0, "received invitation to join group team as admin"), (0, "contact deleted"), (0, "hi"), (0, "security code changed")] <> chatFeatures) withTestChat tmp "bob" $ \bob -> do subscriptions bob 1 diff --git a/tests/ChatTests/Profiles.hs b/tests/ChatTests/Profiles.hs index 1a2b74f76..44af70a65 100644 --- a/tests/ChatTests/Profiles.hs +++ b/tests/ChatTests/Profiles.hs @@ -558,6 +558,7 @@ testConnectIncognitoInvitationLink = testChat3 aliceProfile bobProfile cathProfi -- alice deletes contact, incognito profile is deleted alice ##> ("/d " <> bobIncognito) alice <## (bobIncognito <> ": contact is deleted") + bob <## (aliceIncognito <> " deleted contact with you") alice ##> "/contacts" alice <## "cath (Catherine)" alice `hasContactProfiles` ["alice", "cath"] @@ -601,6 +602,7 @@ testConnectIncognitoContactAddress = testChat2 aliceProfile bobProfile $ -- delete contact, incognito profile is deleted bob ##> "/d alice" bob <## "alice: contact is deleted" + alice <## (bobIncognito <> " deleted contact with you") bob ##> "/contacts" (bob "/d bob" alice <## "bob: contact is deleted" + bob <## (aliceIncognitoBob <> " deleted contact with you") alice ##> "/contacts" (alice "/d alice" bob <## "alice: contact is deleted" + alice <## (bobIncognito <> " deleted contact with you") bob ##> "/contacts" (bob "/d alice" bob <## "alice: contact is deleted" + alice <## (bobIncognito <> " deleted contact with you") bob ##> "/contacts" (bob Date: Wed, 27 Sep 2023 20:07:32 +0400 Subject: [PATCH 05/15] ios: notify contact about contact deletion (#3135) --- apps/ios/Shared/Model/SimpleXAPI.swift | 6 +++ apps/ios/Shared/Views/Chat/ChatInfoView.swift | 7 ++- apps/ios/Shared/Views/Chat/ChatItemView.swift | 1 + apps/ios/Shared/Views/Chat/ChatView.swift | 7 +-- .../Views/ChatList/ChatListNavLink.swift | 2 +- .../Views/ChatList/ChatPreviewView.swift | 46 +++++++++++-------- apps/ios/SimpleXChat/APITypes.swift | 4 ++ apps/ios/SimpleXChat/ChatTypes.swift | 23 +++++++++- 8 files changed, 69 insertions(+), 27 deletions(-) diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index aef8711f3..85e66e893 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -1285,6 +1285,12 @@ func processReceivedMsg(_ res: ChatResponse) async { m.removeChat(connection.id) } } + case let .contactDeletedByContact(user, contact): + if active(user) && contact.directOrUsed { + await MainActor.run { + m.updateContact(contact) + } + } case let .contactConnected(user, contact, _): if active(user) && contact.directOrUsed { await MainActor.run { diff --git a/apps/ios/Shared/Views/Chat/ChatInfoView.swift b/apps/ios/Shared/Views/Chat/ChatInfoView.swift index ec4cb9009..81412bf31 100644 --- a/apps/ios/Shared/Views/Chat/ChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatInfoView.swift @@ -164,7 +164,7 @@ struct ChatInfoView: View { // synchronizeConnectionButtonForce() // } } - .disabled(!contact.ready) + .disabled(!contact.ready || !contact.active) if let contactLink = contact.contactLink { Section { @@ -181,7 +181,7 @@ struct ChatInfoView: View { } } - if contact.ready { + if contact.ready && contact.active { Section("Servers") { networkStatusRow() .onTapGesture { @@ -192,8 +192,7 @@ struct ChatInfoView: View { alert = .switchAddressAlert } .disabled( - !contact.ready - || connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil } + connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil } || connStats.ratchetSyncSendProhibited ) if connStats.rcvQueuesInfo.contains(where: { $0.rcvSwitchStatus != nil }) { diff --git a/apps/ios/Shared/Views/Chat/ChatItemView.swift b/apps/ios/Shared/Views/Chat/ChatItemView.swift index a79047ebc..31fe19c39 100644 --- a/apps/ios/Shared/Views/Chat/ChatItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItemView.swift @@ -79,6 +79,7 @@ struct ChatItemContentView: View { case let .rcvDecryptionError(msgDecryptError, msgCount): CIRcvDecryptionError(msgDecryptError: msgDecryptError, msgCount: msgCount, chatItem: chatItem) case let .rcvGroupInvitation(groupInvitation, memberRole): groupInvitationItemView(groupInvitation, memberRole) case let .sndGroupInvitation(groupInvitation, memberRole): groupInvitationItemView(groupInvitation, memberRole) + case .rcvDirectEvent: eventItemView() case .rcvGroupEvent(.memberConnected): CIEventView(eventText: membersConnectedItemText) case .rcvGroupEvent(.memberCreatedContact): CIMemberCreatedContactView(chatItem: chatItem) case .rcvGroupEvent: eventItemView() diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 81a063dcf..389080efc 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -150,7 +150,7 @@ struct ChatView: View { HStack { if contact.allowsFeature(.calls) { callButton(contact, .audio, imageName: "phone") - .disabled(!contact.ready) + .disabled(!contact.ready || !contact.active) } Menu { if contact.allowsFeature(.calls) { @@ -159,11 +159,11 @@ struct ChatView: View { } label: { Label("Video call", systemImage: "video") } - .disabled(!contact.ready) + .disabled(!contact.ready || !contact.active) } searchButton() toggleNtfsButton(chat) - .disabled(!contact.ready) + .disabled(!contact.ready || !contact.active) } label: { Image(systemName: "ellipsis") } @@ -321,6 +321,7 @@ struct ChatView: View { @ViewBuilder private func connectingText() -> some View { if case let .direct(contact) = chat.chatInfo, !contact.ready, + contact.active, !contact.nextSendGrpInv { Text("connecting…") .font(.caption) diff --git a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift index e7580530b..f445ae4b5 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift @@ -65,7 +65,7 @@ struct ChatListNavLink: View { } Button { AlertManager.shared.showAlert( - contact.ready + contact.ready || !contact.active ? deleteContactAlert(chat.chatInfo) : deletePendingContactAlert(chat, contact) ) diff --git a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift index 3ac8fada7..2eb6d9f6b 100644 --- a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift @@ -57,19 +57,26 @@ struct ChatPreviewView: View { } @ViewBuilder private func chatPreviewImageOverlayIcon() -> some View { - if case let .group(groupInfo) = chat.chatInfo { + switch chat.chatInfo { + case let .direct(contact): + if !contact.active { + inactiveIcon() + } else { + EmptyView() + } + case let .group(groupInfo): switch (groupInfo.membership.memberStatus) { - case .memLeft: groupInactiveIcon() - case .memRemoved: groupInactiveIcon() - case .memGroupDeleted: groupInactiveIcon() + case .memLeft: inactiveIcon() + case .memRemoved: inactiveIcon() + case .memGroupDeleted: inactiveIcon() default: EmptyView() } - } else { + default: EmptyView() } } - @ViewBuilder private func groupInactiveIcon() -> some View { + @ViewBuilder private func inactiveIcon() -> some View { Image(systemName: "multiply.circle.fill") .foregroundColor(.secondary.opacity(0.65)) .background(Circle().foregroundColor(Color(uiColor: .systemBackground))) @@ -80,7 +87,6 @@ struct ChatPreviewView: View { switch chat.chatInfo { case let .direct(contact): previewTitle(contact.verified == true ? verifiedIcon + t : t) - .foregroundColor(chat.chatInfo.ready ? .primary : .secondary) case let .group(groupInfo): let v = previewTitle(t) switch (groupInfo.membership.memberStatus) { @@ -183,7 +189,7 @@ struct ChatPreviewView: View { if !contact.ready { if contact.nextSendGrpInv { chatPreviewInfoText("send direct message") - } else { + } else if contact.active { chatPreviewInfoText("connecting…") } } @@ -228,16 +234,20 @@ struct ChatPreviewView: View { @ViewBuilder private func chatStatusImage() -> some View { switch chat.chatInfo { case let .direct(contact): - switch (chatModel.contactNetworkStatus(contact)) { - case .connected: incognitoIcon(chat.chatInfo.incognito) - case .error: - Image(systemName: "exclamationmark.circle") - .resizable() - .scaledToFit() - .frame(width: 17, height: 17) - .foregroundColor(.secondary) - default: - ProgressView() + if contact.active { + switch (chatModel.contactNetworkStatus(contact)) { + case .connected: incognitoIcon(chat.chatInfo.incognito) + case .error: + Image(systemName: "exclamationmark.circle") + .resizable() + .scaledToFit() + .frame(width: 17, height: 17) + .foregroundColor(.secondary) + default: + ProgressView() + } + } else { + incognitoIcon(chat.chatInfo.incognito) } default: incognitoIcon(chat.chatInfo.incognito) diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index b0834f571..951f726be 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -462,6 +462,7 @@ public enum ChatResponse: Decodable, Error { case contactAlreadyExists(user: UserRef, contact: Contact) case contactRequestAlreadyAccepted(user: UserRef, contact: Contact) case contactDeleted(user: UserRef, contact: Contact) + case contactDeletedByContact(user: UserRef, contact: Contact) case chatCleared(user: UserRef, chatInfo: ChatInfo) case userProfileNoChange(user: User) case userProfileUpdated(user: User, fromProfile: Profile, toProfile: Profile, updateSummary: UserProfileUpdateSummary) @@ -599,6 +600,7 @@ public enum ChatResponse: Decodable, Error { case .contactAlreadyExists: return "contactAlreadyExists" case .contactRequestAlreadyAccepted: return "contactRequestAlreadyAccepted" case .contactDeleted: return "contactDeleted" + case .contactDeletedByContact: return "contactDeletedByContact" case .chatCleared: return "chatCleared" case .userProfileNoChange: return "userProfileNoChange" case .userProfileUpdated: return "userProfileUpdated" @@ -735,6 +737,7 @@ public enum ChatResponse: Decodable, Error { case let .contactAlreadyExists(u, contact): return withUser(u, String(describing: contact)) case let .contactRequestAlreadyAccepted(u, contact): return withUser(u, String(describing: contact)) case let .contactDeleted(u, contact): return withUser(u, String(describing: contact)) + case let .contactDeletedByContact(u, contact): return withUser(u, String(describing: contact)) case let .chatCleared(u, chatInfo): return withUser(u, String(describing: chatInfo)) case .userProfileNoChange: return noDetails case let .userProfileUpdated(u, _, toProfile, _): return withUser(u, String(describing: toProfile)) @@ -1420,6 +1423,7 @@ public enum ChatErrorType: Decodable { case invalidConnReq case invalidChatMessage(connection: Connection, message: String) case contactNotReady(contact: Contact) + case contactNotActive(contact: Contact) case contactDisabled(contact: Contact) case connectionDisabled(connection: Connection) case groupUserRole(groupInfo: GroupInfo, requiredRole: GroupMemberRole) diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index c0ec04857..f9996d840 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -1373,6 +1373,7 @@ public struct Contact: Identifiable, Decodable, NamedChat { public var activeConn: Connection public var viaGroup: Int64? public var contactUsed: Bool + public var contactStatus: ContactStatus public var chatSettings: ChatSettings public var userPreferences: Preferences public var mergedPreferences: ContactUserPreferences @@ -1384,8 +1385,9 @@ public struct Contact: Identifiable, Decodable, NamedChat { public var id: ChatId { get { "@\(contactId)" } } public var apiId: Int64 { get { contactId } } public var ready: Bool { get { activeConn.connStatus == .ready } } + public var active: Bool { get { contactStatus == .active } } public var sendMsgEnabled: Bool { get { - (ready && !(activeConn.connectionStats?.ratchetSyncSendProhibited ?? false)) + (ready && active && !(activeConn.connectionStats?.ratchetSyncSendProhibited ?? false)) || nextSendGrpInv } } public var nextSendGrpInv: Bool { get { contactGroupMemberId != nil && !contactGrpInvSent } } @@ -1430,6 +1432,7 @@ public struct Contact: Identifiable, Decodable, NamedChat { profile: LocalProfile.sampleData, activeConn: Connection.sampleData, contactUsed: true, + contactStatus: .active, chatSettings: ChatSettings.defaults, userPreferences: Preferences.sampleData, mergedPreferences: ContactUserPreferences.sampleData, @@ -1439,6 +1442,11 @@ public struct Contact: Identifiable, Decodable, NamedChat { ) } +public enum ContactStatus: String, Decodable { + case active = "active" + case deleted = "deleted" +} + public struct ContactRef: Decodable, Equatable { var contactId: Int64 public var agentConnId: String @@ -2091,6 +2099,7 @@ public struct ChatItem: Identifiable, Decodable { case .rcvDecryptionError: return showNtfDir case .rcvGroupInvitation: return showNtfDir case .sndGroupInvitation: return showNtfDir + case .rcvDirectEvent: return false case .rcvGroupEvent(rcvGroupEvent: let rcvGroupEvent): switch rcvGroupEvent { case .groupUpdated: return false @@ -2513,6 +2522,7 @@ public enum CIContent: Decodable, ItemContent { case rcvDecryptionError(msgDecryptError: MsgDecryptError, msgCount: UInt32) case rcvGroupInvitation(groupInvitation: CIGroupInvitation, memberRole: GroupMemberRole) case sndGroupInvitation(groupInvitation: CIGroupInvitation, memberRole: GroupMemberRole) + case rcvDirectEvent(rcvDirectEvent: RcvDirectEvent) case rcvGroupEvent(rcvGroupEvent: RcvGroupEvent) case sndGroupEvent(sndGroupEvent: SndGroupEvent) case rcvConnEvent(rcvConnEvent: RcvConnEvent) @@ -2542,6 +2552,7 @@ public enum CIContent: Decodable, ItemContent { case let .rcvDecryptionError(msgDecryptError, _): return msgDecryptError.text case let .rcvGroupInvitation(groupInvitation, _): return groupInvitation.text case let .sndGroupInvitation(groupInvitation, _): return groupInvitation.text + case let .rcvDirectEvent(rcvDirectEvent): return rcvDirectEvent.text case let .rcvGroupEvent(rcvGroupEvent): return rcvGroupEvent.text case let .sndGroupEvent(sndGroupEvent): return sndGroupEvent.text case let .rcvConnEvent(rcvConnEvent): return rcvConnEvent.text @@ -3195,6 +3206,16 @@ public enum CIGroupInvitationStatus: String, Decodable { case expired } +public enum RcvDirectEvent: Decodable { + case contactDeleted + + var text: String { + switch self { + case .contactDeleted: return NSLocalizedString("deleted contact", comment: "rcv direct event chat item") + } + } +} + public enum RcvGroupEvent: Decodable { case memberAdded(groupMemberId: Int64, profile: Profile) case memberConnected From ea319313f10c41127e604cee2391ce759aaab8b3 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Wed, 27 Sep 2023 21:15:19 +0100 Subject: [PATCH 06/15] core: return error response when wrong passphrase is passed to start --- src/Simplex/Chat.hs | 26 +++++++++++++++----------- src/Simplex/Chat/Archive.hs | 26 +++++++++++++++----------- src/Simplex/Chat/Controller.hs | 3 --- src/Simplex/Chat/Core.hs | 2 +- src/Simplex/Chat/View.hs | 2 +- tests/ChatTests/Direct.hs | 2 ++ 6 files changed, 34 insertions(+), 27 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 3530fa82c..a16cb98ae 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -34,6 +34,7 @@ import qualified Data.ByteString.Base64 as B64 import Data.ByteString.Char8 (ByteString) import qualified Data.ByteString.Char8 as B import Data.Char (isSpace, toLower) +import Data.Composition ((.:)) import Data.Constraint (Dict (..)) import Data.Either (fromRight, rights) import Data.Fixed (div') @@ -217,8 +218,8 @@ newChatController ChatDatabase {chatStore, agentStore} user cfg@ChatConfig {agen where configServers :: DefaultAgentServers configServers = - let smp' = fromMaybe (defaultServers.smp) (nonEmpty smpServers) - xftp' = fromMaybe (defaultServers.xftp) (nonEmpty xftpServers) + let smp' = fromMaybe defaultServers.smp (nonEmpty smpServers) + xftp' = fromMaybe defaultServers.xftp (nonEmpty xftpServers) in defaultServers {smp = smp', xftp = xftp', netCfg = networkConfig} agentServers :: ChatConfig -> IO InitialAgentServers agentServers config@ChatConfig {defaultServers = defServers@DefaultAgentServers {ntf, netCfg}} = do @@ -249,13 +250,9 @@ cfgServers p s = case p of SPSMP -> s.smp SPXFTP -> s.xftp -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 +startChatController :: forall m. ChatMonad' m => Bool -> Bool -> Bool -> m (Async ()) +startChatController subConns enableExpireCIs startXFTPWorkers = do + resumeAgentClient =<< asks smpAgent unless subConns $ chatWriteVar subscriptionMode SMOnlyCreate users <- fromRight [] <$> runExceptT (withStoreCtx' (Just "startChatController, getUsers") getUsers) @@ -469,10 +466,17 @@ processChatCommand = \case checkDeleteChatUser user' withChatLock "deleteUser" . procCmd $ deleteChatUser user' delSMPQueues DeleteUser uName delSMPQueues viewPwd_ -> withUserName uName $ \userId -> APIDeleteUser userId delSMPQueues viewPwd_ - APIStartChat cfg -> withUser' $ \_ -> + APIStartChat ChatCtrlCfg {subConns, enableExpireCIs, startXFTPWorkers, openDBWithKey} -> withUser' $ \_ -> asks agentAsync >>= readTVarIO >>= \case Just _ -> pure CRChatRunning - _ -> checkStoreNotChanged $ startChatController cfg $> CRChatStarted + _ -> checkStoreNotChanged $ do + forM_ openDBWithKey $ \(DBEncryptionKey dbKey) -> do + ChatController {chatStore, smpAgent} <- ask + open chatStore dbKey + open (agentClientStore smpAgent) dbKey + startChatController subConns enableExpireCIs startXFTPWorkers $> CRChatStarted + where + open = handleDBError DBErrorOpen .: openSQLiteStore APIStopChat closeStore -> do ask >>= (`stopChatController` closeStore) pure CRChatStopped diff --git a/src/Simplex/Chat/Archive.hs b/src/Simplex/Chat/Archive.hs index 55bd31e51..8d1b99328 100644 --- a/src/Simplex/Chat/Archive.hs +++ b/src/Simplex/Chat/Archive.hs @@ -9,6 +9,7 @@ module Simplex.Chat.Archive importArchive, deleteStorage, sqlCipherExport, + handleDBError, ) where @@ -139,17 +140,7 @@ sqlCipherExport DBEncryptionConfig {currentKey = DBEncryptionKey key, newKey = D withDB (`SQL.exec` testSQL) DBErrorOpen atomically $ writeTVar dbEnc $ not (null key') where - withDB a err = - liftIO (bracket (SQL.open $ T.pack f) SQL.close a $> Nothing) - `catch` checkSQLError - `catch` (\(e :: SomeException) -> sqliteError' e) - >>= mapM_ (throwDBError . err) - where - checkSQLError e = case SQL.sqlError e of - SQL.ErrorNotADatabase -> pure $ Just SQLiteErrorNotADatabase - _ -> sqliteError' e - sqliteError' :: Show e => e -> m (Maybe SQLiteError) - sqliteError' = pure . Just . SQLiteError . show + withDB a err = handleDBError err $ bracket (SQL.open $ T.pack f) SQL.close a exportSQL = T.unlines $ keySQL key @@ -166,3 +157,16 @@ sqlCipherExport DBEncryptionConfig {currentKey = DBEncryptionKey key, newKey = D "SELECT count(*) FROM sqlite_master;" ] keySQL k = ["PRAGMA key = " <> sqlString k <> ";" | not (null k)] + +handleDBError :: forall m. ChatMonad m => (SQLiteError -> DatabaseError) -> IO () -> m () +handleDBError err a = + (liftIO a $> Nothing) + `catch` checkSQLError + `catch` (\(e :: SomeException) -> sqliteError' e) + >>= mapM_ (throwDBError . err) + where + checkSQLError e = case SQL.sqlError e of + SQL.ErrorNotADatabase -> pure $ Just SQLiteErrorNotADatabase + _ -> sqliteError' e + sqliteError' :: Show e => e -> m (Maybe SQLiteError) + sqliteError' = pure . Just . SQLiteError . show diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 2931a874e..3f3dae94f 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -629,9 +629,6 @@ data ChatCtrlCfg = ChatCtrlCfg } 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 b09413037..4af161ab4 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 defChatCtrlCfg) cc + a1 <- runReaderT (startChatController True True True) cc a2 <- async $ chat u cc waitEither_ a1 a2 diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 01bdfba95..19d729bf7 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -1648,7 +1648,7 @@ viewChatError logLevel = \case DBErrorEncrypted -> ["error: chat database is already encrypted"] DBErrorPlaintext -> ["error: chat database is not encrypted"] DBErrorExport e -> ["error encrypting database: " <> sqliteError' e] - DBErrorOpen e -> ["error opening database after encryption: " <> sqliteError' e] + DBErrorOpen e -> ["error opening database: " <> sqliteError' e] e -> ["chat database error: " <> sShow e] ChatErrorAgent err entity_ -> case err of CMD PROHIBITED -> [withConnEntity <> "error: command is prohibited"] diff --git a/tests/ChatTests/Direct.hs b/tests/ChatTests/Direct.hs index 36e74e11f..d9e8bac2f 100644 --- a/tests/ChatTests/Direct.hs +++ b/tests/ChatTests/Direct.hs @@ -959,6 +959,8 @@ testDatabaseEncryption tmp = do alice <## "chat stopped" alice ##> "/db key wrongkey nextkey" alice <## "error encrypting database: wrong passphrase or invalid database file" + alice ##> "/_start key=wrongkey" + alice <## "error opening database: wrong passphrase or invalid database file" alice ##> "/_start key=mykey" alice <## "chat started" testChatWorking alice bob From 942e5eb8c49d8d28b51941f4f32f8b2cd610e54c Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Wed, 27 Sep 2023 22:19:20 +0100 Subject: [PATCH 07/15] docs: update branches --- docs/CONTRIBUTING.md | 34 +++++++++++++++++++++++----------- 1 file changed, 23 insertions(+), 11 deletions(-) diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index dc18bebb0..0aa09c516 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -38,9 +38,15 @@ You will have to add `/opt/homebrew/opt/openssl@1.1/bin` to your PATH in order t - `master` - branch for beta version releases (GHC 9.6.2). -- `master-android` - used to build beta Android core library with Nix (GHC 8.10.7). +- `master-ghc8107` - branch for beta version releases (GHC 8.10.7). -- `master-ios` - used to build beta iOS core library with Nix (GHC 8.10.7) – this branch should be the same as `master-android` except Nix configuration files. +- `master-android` - used to build beta Android core library with Nix (GHC 8.10.7), same as `master-ghc8107` + +- `master-ios` - used to build beta iOS core library with Nix (GHC 8.10.7). + +- `windows-ghc8107` - branch for windows core library build (GHC 8.10.7). + +`master-ios` and `windows-ghc8107` branches should be the same as `master-ghc8107` except Nix configuration files. **In simplexmq repo** @@ -54,24 +60,30 @@ You will have to add `/opt/homebrew/opt/openssl@1.1/bin` to your PATH in order t 2. If simplexmq repo was changed, to build mobile core libraries you need to merge its `master` branch into `master-ghc8107` branch. -3. To build Android core library: -- merge `master` branch to `master-android` branch. +3. To build core libraries for Android, iOS and windows: +- merge `master` branch to `master-ghc8107` branch. +- update `simplexmq` commit in `master-ghc8107` branch to the commit in `master-ghc8107` branch (probably, when resolving merge conflicts). - update code to be compatible with GHC 8.10.7 (see below). -- update `simplexmq` commit in `master-android` branch to the commit in `master-ghc8107` branch. - push to GitHub. -4. To build iOS core library, merge `master-android` branch to `master-ios` branch, and push to GitHub. +4. To build Android core library, merge `master-ghc8107` branch to `master-android` branch, and push to GitHub. -5. To build Desktop and CLI apps, make tag in `master` branch, APK files should be attached to the release. +5. To build iOS core library, merge `master-ghc8107` branch to `master-ios` branch, and push to GitHub. -6. After the public release to App Store and Play Store, merge: +6. To build windows core library, merge `master-ghc8107` branch to `windows-ghc8107` branch, and push to GitHub. + +7. To build Desktop and CLI apps, make tag in `master` branch, APK files should be attached to the release. + +8. After the public release to App Store and Play Store, merge: - `master` to `stable` -- `master` to `master-android` (and compile/update code) -- `master-android` to `master-ios` +- `master` to `master-ghc8107` (and compile/update code) +- `master-ghc8107` to `master-android` +- `master-ghc8107` to `master-ios` +- `master-ghc8107` to `windows-ghc8107` - `master-android` to `stable-android` - `master-ios` to `stable-ios` -7. Independently, `master` branch of simplexmq repo should be merged to `stable` branch on stable releases. +9. Independently, `master` branch of simplexmq repo should be merged to `stable` branch on stable releases. ## Differences between GHC 8.10.7 and GHC 9.6.2 From dea96df27bf7002b1d52942c59bf8c1981dd67e7 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Thu, 28 Sep 2023 09:26:54 +0100 Subject: [PATCH 08/15] docs: update join team --- docs/JOIN_TEAM.md | 27 +++++++++++++-------------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/docs/JOIN_TEAM.md b/docs/JOIN_TEAM.md index 5a31b3e05..cf33df1ee 100644 --- a/docs/JOIN_TEAM.md +++ b/docs/JOIN_TEAM.md @@ -15,31 +15,30 @@ We want to add up to 3 people to the team. ## Who we are looking for -### Systems Haskell engineer +### Application Haskell engineer -You are a servers/network/Haskell expert: -- network libraries. +You are an expert in language models, databases and Haskell: +- expert knowledge of SQL. - exception handling, concurrency, STM. - type systems - we use ad hoc dependent types a lot. -- strictness. -- has some expertise in network protocols, cryptography and general information security principles and approaches. +- experience integrating open-source language models. +- experience developing community-centric applications. - interested to build the next generation of messaging network. -You will be focussed mostly on our servers code, and will also contribute to the core client code written in Haskell. +You will be focussed mostly on our client applications, and will also contribute to the servers also written in Haskell. +### iOS / Mac engineer -### Product engineer (iOS) - -You are a product UX expert who designs great user experiences directly in iOS code: -- iOS and Mac platforms, including: - - SwiftUI and UIKit. - - extensions, including notification service extension and sharing extension. - - low level inter-process communication primitives for concurrency. +You are an expert in Apple platforms, including: +- iOS and Mac platform architecture. +- Swift and Objective-C. +- SwiftUI and UIKit. +- extensions, including notification service extension and sharing extension. +- low level inter-process communication primitives for concurrency. - interested about creating the next generation of UX for a communication/social network. Knowledge of Android and Kotlin Multiplatform would be a bonus - we use Kotlin Jetpack Compose for our Android and desktop apps. - ## About you - **Passionate about joining SimpleX Chat team**: From 957f3b3eb0421096c59058f7870e7c16507956ae Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Thu, 28 Sep 2023 13:16:03 +0400 Subject: [PATCH 09/15] core: delete unused contact silently (#3140) --- src/Simplex/Chat.hs | 26 ++++++++++++++++---------- tests/ChatTests/Direct.hs | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 10 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index a16cb98ae..822d6cd4b 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -4252,16 +4252,22 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do xInfo c p' = void $ processContactProfileUpdate c p' True xDirectDel :: Contact -> RcvMessage -> MsgMeta -> m () - xDirectDel c msg msgMeta = do - checkIntegrityCreateItem (CDDirectRcv c) msgMeta - ct' <- withStore' $ \db -> updateContactStatus db user c CSDeleted - contactConns <- withStore $ \db -> getContactConnections db userId ct' - deleteAgentConnectionsAsync user $ map aConnId contactConns - forM_ contactConns $ \conn -> withStore' $ \db -> updateConnectionStatus db conn ConnDeleted - let ct'' = ct' {activeConn = (contactConn ct') {connStatus = ConnDeleted}} :: Contact - ci <- saveRcvChatItem user (CDDirectRcv ct'') msg msgMeta (CIRcvDirectEvent RDEContactDeleted) - toView $ CRNewChatItem user (AChatItem SCTDirect SMDRcv (DirectChat ct'') ci) - toView $ CRContactDeletedByContact user ct'' + xDirectDel c msg msgMeta = + if directOrUsed c + then do + checkIntegrityCreateItem (CDDirectRcv c) msgMeta + ct' <- withStore' $ \db -> updateContactStatus db user c CSDeleted + contactConns <- withStore $ \db -> getContactConnections db userId ct' + deleteAgentConnectionsAsync user $ map aConnId contactConns + forM_ contactConns $ \conn -> withStore' $ \db -> updateConnectionStatus db conn ConnDeleted + let ct'' = ct' {activeConn = (contactConn ct') {connStatus = ConnDeleted}} :: Contact + ci <- saveRcvChatItem user (CDDirectRcv ct'') msg msgMeta (CIRcvDirectEvent RDEContactDeleted) + toView $ CRNewChatItem user (AChatItem SCTDirect SMDRcv (DirectChat ct'') ci) + toView $ CRContactDeletedByContact user ct'' + else do + contactConns <- withStore $ \db -> getContactConnections db userId c + deleteAgentConnectionsAsync user $ map aConnId contactConns + withStore' $ \db -> deleteContact db user c processContactProfileUpdate :: Contact -> Profile -> Bool -> m Contact processContactProfileUpdate c@Contact {profile = p} p' createItems diff --git a/tests/ChatTests/Direct.hs b/tests/ChatTests/Direct.hs index d9e8bac2f..445a5ab99 100644 --- a/tests/ChatTests/Direct.hs +++ b/tests/ChatTests/Direct.hs @@ -31,6 +31,7 @@ chatDirectTests = do describe "direct messages" $ do describe "add contact and send/receive message" testAddContact it "deleting contact deletes profile" testDeleteContactDeletesProfile + it "unused contact is deleted silently" testDeleteUnusedContactSilent it "direct message quoted replies" testDirectMessageQuotedReply it "direct message update" testDirectMessageUpdate it "direct message edit history" testDirectMessageEditHistory @@ -214,6 +215,42 @@ testDeleteContactDeletesProfile = (bob FilePath -> IO () +testDeleteUnusedContactSilent = + testChatCfg3 testCfgCreateGroupDirect aliceProfile bobProfile cathProfile $ + \alice bob cath -> do + createGroup3 "team" alice bob cath + bob ##> "/contacts" + bob <### ["alice (Alice)", "cath (Catherine)"] + bob `hasContactProfiles` ["bob", "alice", "cath"] + cath ##> "/contacts" + cath <### ["alice (Alice)", "bob (Bob)"] + cath `hasContactProfiles` ["cath", "alice", "bob"] + -- bob deletes cath, cath's bob contact is deleted silently + bob ##> "/d cath" + bob <## "cath: contact is deleted" + bob ##> "/contacts" + bob <## "alice (Alice)" + threadDelay 50000 + cath ##> "/contacts" + cath <## "alice (Alice)" + -- group messages work + alice #> "#team hello" + concurrentlyN_ + [ bob <# "#team alice> hello", + cath <# "#team alice> hello" + ] + bob #> "#team hi there" + concurrentlyN_ + [ alice <# "#team bob> hi there", + cath <# "#team bob> hi there" + ] + cath #> "#team hey" + concurrentlyN_ + [ alice <# "#team cath> hey", + bob <# "#team cath> hey" + ] + testDirectMessageQuotedReply :: HasCallStack => FilePath -> IO () testDirectMessageQuotedReply = testChat2 aliceProfile bobProfile $ From 682dfe503cdcaea6cb16d6f9eabe6fdcdb6afd3d Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Thu, 28 Sep 2023 13:52:43 +0400 Subject: [PATCH 10/15] android, desktop: notify contact about contact deletion (#3139) Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- .../chat/simplex/common/model/ChatModel.kt | 23 ++++++- .../chat/simplex/common/model/SimpleXAPI.kt | 10 +++ .../simplex/common/views/chat/ChatInfoView.kt | 5 +- .../simplex/common/views/chat/ChatView.kt | 15 ++-- .../common/views/chat/item/ChatItemView.kt | 1 + .../common/views/chatlist/ChatPreviewView.kt | 68 +++++++++++-------- .../commonMain/resources/MR/base/strings.xml | 3 + 7 files changed, 87 insertions(+), 38 deletions(-) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index 887abe756..0eb02515b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -797,6 +797,7 @@ data class Contact( val activeConn: Connection, val viaGroup: Long? = null, val contactUsed: Boolean, + val contactStatus: ContactStatus, val chatSettings: ChatSettings, val userPreferences: ChatPreferences, val mergedPreferences: ContactUserPreferences, @@ -809,8 +810,9 @@ data class Contact( override val id get() = "@$contactId" override val apiId get() = contactId override val ready get() = activeConn.connStatus == ConnStatus.Ready + val active get() = contactStatus == ContactStatus.Active override val sendMsgEnabled get() = - (ready && !(activeConn.connectionStats?.ratchetSyncSendProhibited ?: false)) + (ready && active && !(activeConn.connectionStats?.ratchetSyncSendProhibited ?: false)) || nextSendGrpInv val nextSendGrpInv get() = contactGroupMemberId != null && !contactGrpInvSent override val ntfsEnabled get() = chatSettings.enableNtfs @@ -859,6 +861,7 @@ data class Contact( profile = LocalProfile.sampleData, activeConn = Connection.sampleData, contactUsed = true, + contactStatus = ContactStatus.Active, chatSettings = ChatSettings(enableNtfs = true, sendRcpts = null, favorite = false), userPreferences = ChatPreferences.sampleData, mergedPreferences = ContactUserPreferences.sampleData, @@ -869,6 +872,12 @@ data class Contact( } } +@Serializable +enum class ContactStatus { + @SerialName("active") Active, + @SerialName("deleted") Deleted; +} + @Serializable class ContactRef( val contactId: Long, @@ -1471,6 +1480,7 @@ data class ChatItem ( is CIContent.RcvDecryptionError -> showNtfDir is CIContent.RcvGroupInvitation -> showNtfDir is CIContent.SndGroupInvitation -> showNtfDir + is CIContent.RcvDirectEventContent -> false is CIContent.RcvGroupEventContent -> when (content.rcvGroupEvent) { is RcvGroupEvent.MemberAdded -> false is RcvGroupEvent.MemberConnected -> false @@ -1854,6 +1864,7 @@ sealed class CIContent: ItemContent { @Serializable @SerialName("rcvDecryptionError") class RcvDecryptionError(val msgDecryptError: MsgDecryptError, val msgCount: UInt): CIContent() { override val msgContent: MsgContent? get() = null } @Serializable @SerialName("rcvGroupInvitation") class RcvGroupInvitation(val groupInvitation: CIGroupInvitation, val memberRole: GroupMemberRole): CIContent() { override val msgContent: MsgContent? get() = null } @Serializable @SerialName("sndGroupInvitation") class SndGroupInvitation(val groupInvitation: CIGroupInvitation, val memberRole: GroupMemberRole): CIContent() { override val msgContent: MsgContent? get() = null } + @Serializable @SerialName("rcvDirectEvent") class RcvDirectEventContent(val rcvDirectEvent: RcvDirectEvent): CIContent() { override val msgContent: MsgContent? get() = null } @Serializable @SerialName("rcvGroupEvent") class RcvGroupEventContent(val rcvGroupEvent: RcvGroupEvent): CIContent() { override val msgContent: MsgContent? get() = null } @Serializable @SerialName("sndGroupEvent") class SndGroupEventContent(val sndGroupEvent: SndGroupEvent): CIContent() { override val msgContent: MsgContent? get() = null } @Serializable @SerialName("rcvConnEvent") class RcvConnEventContent(val rcvConnEvent: RcvConnEvent): CIContent() { override val msgContent: MsgContent? get() = null } @@ -1881,6 +1892,7 @@ sealed class CIContent: ItemContent { is RcvDecryptionError -> msgDecryptError.text is RcvGroupInvitation -> groupInvitation.text is SndGroupInvitation -> groupInvitation.text + is RcvDirectEventContent -> rcvDirectEvent.text is RcvGroupEventContent -> rcvGroupEvent.text is SndGroupEventContent -> sndGroupEvent.text is RcvConnEventContent -> rcvConnEvent.text @@ -2487,6 +2499,15 @@ sealed class MsgErrorType() { } } +@Serializable +sealed class RcvDirectEvent() { + @Serializable @SerialName("contactDeleted") class ContactDeleted(): RcvDirectEvent() + + val text: String get() = when (this) { + is ContactDeleted -> generalGetString(MR.strings.rcv_direct_event_contact_deleted) + } +} + @Serializable sealed class RcvGroupEvent() { @Serializable @SerialName("memberAdded") class MemberAdded(val groupMemberId: Long, val profile: Profile): RcvGroupEvent() 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 87cac179f..619788f6e 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 @@ -1366,6 +1366,11 @@ object ChatController { chatModel.removeChat(r.connection.id) } } + is CR.ContactDeletedByContact -> { + if (active(r.user) && r.contact.directOrUsed) { + chatModel.updateContact(r.contact) + } + } is CR.ContactConnected -> { if (active(r.user) && r.contact.directOrUsed) { chatModel.updateContact(r.contact) @@ -3304,6 +3309,7 @@ sealed class CR { @Serializable @SerialName("contactAlreadyExists") class ContactAlreadyExists(val user: UserRef, val contact: Contact): CR() @Serializable @SerialName("contactRequestAlreadyAccepted") class ContactRequestAlreadyAccepted(val user: UserRef, val contact: Contact): CR() @Serializable @SerialName("contactDeleted") class ContactDeleted(val user: UserRef, val contact: Contact): CR() + @Serializable @SerialName("contactDeletedByContact") class ContactDeletedByContact(val user: UserRef, val contact: Contact): CR() @Serializable @SerialName("chatCleared") class ChatCleared(val user: UserRef, val chatInfo: ChatInfo): CR() @Serializable @SerialName("userProfileNoChange") class UserProfileNoChange(val user: User): CR() @Serializable @SerialName("userProfileUpdated") class UserProfileUpdated(val user: User, val fromProfile: Profile, val toProfile: Profile, val updateSummary: UserProfileUpdateSummary): CR() @@ -3435,6 +3441,7 @@ sealed class CR { is ContactAlreadyExists -> "contactAlreadyExists" is ContactRequestAlreadyAccepted -> "contactRequestAlreadyAccepted" is ContactDeleted -> "contactDeleted" + is ContactDeletedByContact -> "contactDeletedByContact" is ChatCleared -> "chatCleared" is UserProfileNoChange -> "userProfileNoChange" is UserProfileUpdated -> "userProfileUpdated" @@ -3563,6 +3570,7 @@ sealed class CR { is ContactAlreadyExists -> withUser(user, json.encodeToString(contact)) is ContactRequestAlreadyAccepted -> withUser(user, json.encodeToString(contact)) is ContactDeleted -> withUser(user, json.encodeToString(contact)) + is ContactDeletedByContact -> withUser(user, json.encodeToString(contact)) is ChatCleared -> withUser(user, json.encodeToString(chatInfo)) is UserProfileNoChange -> withUser(user, noDetails()) is UserProfileUpdated -> withUser(user, json.encodeToString(toProfile)) @@ -3831,6 +3839,7 @@ sealed class ChatErrorType { is InvalidConnReq -> "invalidConnReq" is InvalidChatMessage -> "invalidChatMessage" is ContactNotReady -> "contactNotReady" + is ContactNotActive -> "contactNotActive" is ContactDisabled -> "contactDisabled" is ConnectionDisabled -> "connectionDisabled" is GroupUserRole -> "groupUserRole" @@ -3906,6 +3915,7 @@ sealed class ChatErrorType { @Serializable @SerialName("invalidConnReq") object InvalidConnReq: ChatErrorType() @Serializable @SerialName("invalidChatMessage") class InvalidChatMessage(val connection: Connection, val message: String): ChatErrorType() @Serializable @SerialName("contactNotReady") class ContactNotReady(val contact: Contact): ChatErrorType() + @Serializable @SerialName("contactNotActive") class ContactNotActive(val contact: Contact): ChatErrorType() @Serializable @SerialName("contactDisabled") class ContactDisabled(val contact: Contact): ChatErrorType() @Serializable @SerialName("connectionDisabled") class ConnectionDisabled(val connection: Connection): ChatErrorType() @Serializable @SerialName("groupUserRole") class GroupUserRole(val groupInfo: GroupInfo, val requiredRole: GroupMemberRole): ChatErrorType() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt index 5fcb90c1c..1ba742b03 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt @@ -15,7 +15,6 @@ import androidx.compose.foundation.layout.* import androidx.compose.foundation.text.* import androidx.compose.material.* import androidx.compose.runtime.* -import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -291,7 +290,7 @@ fun ChatInfoLayout( SectionDividerSpaced() } - if (contact.ready) { + if (contact.ready && contact.active) { SectionView { if (connectionCode != null) { VerifyCodeButton(contact.verified, verifyClicked) @@ -318,7 +317,7 @@ fun ChatInfoLayout( SectionDividerSpaced() } - if (contact.ready) { + if (contact.ready && contact.active) { SectionView(title = stringResource(MR.strings.conn_stats_section_title_servers)) { SectionItemView({ AlertManager.shared.showAlertMsg( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt index 596a7426a..b22b2f91c 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt @@ -118,7 +118,12 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId: Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally ) { - if (chat.chatInfo is ChatInfo.Direct && !chat.chatInfo.contact.ready && !chat.chatInfo.contact.nextSendGrpInv) { + if ( + chat.chatInfo is ChatInfo.Direct + && !chat.chatInfo.contact.ready + && chat.chatInfo.contact.active + && !chat.chatInfo.contact.nextSendGrpInv + ) { Text( generalGetString(MR.strings.contact_connection_pending), Modifier.padding(top = 4.dp), @@ -550,15 +555,15 @@ fun ChatInfoToolbar( showMenu.value = false startCall(CallMediaType.Audio) }, - enabled = chat.chatInfo.contact.ready) { + enabled = chat.chatInfo.contact.ready && chat.chatInfo.contact.active) { Icon( painterResource(MR.images.ic_call_500), stringResource(MR.strings.icon_descr_more_button), - tint = if (chat.chatInfo.contact.ready) MaterialTheme.colors.primary else MaterialTheme.colors.secondary + tint = if (chat.chatInfo.contact.ready && chat.chatInfo.contact.active) MaterialTheme.colors.primary else MaterialTheme.colors.secondary ) } } - if (chat.chatInfo.contact.ready) { + if (chat.chatInfo.contact.ready && chat.chatInfo.contact.active) { menuItems.add { ItemAction(stringResource(MR.strings.icon_descr_video_call).capitalize(Locale.current), painterResource(MR.images.ic_videocam), onClick = { showMenu.value = false @@ -576,7 +581,7 @@ fun ChatInfoToolbar( } } } - if ((chat.chatInfo is ChatInfo.Direct && chat.chatInfo.contact.ready) || chat.chatInfo is ChatInfo.Group) { + if ((chat.chatInfo is ChatInfo.Direct && chat.chatInfo.contact.ready && chat.chatInfo.contact.active) || chat.chatInfo is ChatInfo.Group) { val ntfsEnabled = remember { mutableStateOf(chat.chatInfo.ntfsEnabled) } menuItems.add { ItemAction( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt index e1d45ffb3..b5236e249 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt @@ -352,6 +352,7 @@ fun ChatItemView( is CIContent.RcvDecryptionError -> CIRcvDecryptionError(c.msgDecryptError, c.msgCount, cInfo, cItem, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember) is CIContent.RcvGroupInvitation -> CIGroupInvitationView(cItem, c.groupInvitation, c.memberRole, joinGroup = joinGroup, chatIncognito = cInfo.incognito) is CIContent.SndGroupInvitation -> CIGroupInvitationView(cItem, c.groupInvitation, c.memberRole, joinGroup = joinGroup, chatIncognito = cInfo.incognito) + is CIContent.RcvDirectEventContent -> EventItemView() is CIContent.RcvGroupEventContent -> when (c.rcvGroupEvent) { is RcvGroupEvent.MemberConnected -> CIEventView(membersConnectedItemText()) is RcvGroupEvent.MemberCreatedContact -> CIMemberCreatedContactView(cItem, openDirectChat) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt index 780e3515d..a5775d369 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt @@ -42,7 +42,7 @@ fun ChatPreviewView( val cInfo = chat.chatInfo @Composable - fun groupInactiveIcon() { + fun inactiveIcon() { Icon( painterResource(MR.images.ic_cancel_filled), stringResource(MR.strings.icon_descr_group_inactive), @@ -53,13 +53,19 @@ fun ChatPreviewView( @Composable fun chatPreviewImageOverlayIcon() { - if (cInfo is ChatInfo.Group) { - when (cInfo.groupInfo.membership.memberStatus) { - GroupMemberStatus.MemLeft -> groupInactiveIcon() - GroupMemberStatus.MemRemoved -> groupInactiveIcon() - GroupMemberStatus.MemGroupDeleted -> groupInactiveIcon() - else -> {} + when (cInfo) { + is ChatInfo.Direct -> + if (!cInfo.contact.active) { + inactiveIcon() + } + is ChatInfo.Group -> + when (cInfo.groupInfo.membership.memberStatus) { + GroupMemberStatus.MemLeft -> inactiveIcon() + GroupMemberStatus.MemRemoved -> inactiveIcon() + GroupMemberStatus.MemGroupDeleted -> inactiveIcon() + else -> {} } + else -> {} } } @@ -125,7 +131,7 @@ fun ChatPreviewView( if (cInfo.contact.verified) { VerifiedIcon() } - chatPreviewTitleText(if (cInfo.ready) Color.Unspecified else MaterialTheme.colors.secondary) + chatPreviewTitleText() } is ChatInfo.Group -> when (cInfo.groupInfo.membership.memberStatus) { @@ -174,7 +180,7 @@ fun ChatPreviewView( is ChatInfo.Direct -> if (cInfo.contact.nextSendGrpInv) { Text(stringResource(MR.strings.member_contact_send_direct_message), color = MaterialTheme.colors.secondary) - } else if (!cInfo.ready) { + } else if (!cInfo.ready && cInfo.contact.active) { Text(stringResource(MR.strings.contact_connection_pending), color = MaterialTheme.colors.secondary) } is ChatInfo.Group -> @@ -191,28 +197,32 @@ fun ChatPreviewView( @Composable fun chatStatusImage() { if (cInfo is ChatInfo.Direct) { - val descr = contactNetworkStatus?.statusString - when (contactNetworkStatus) { - is NetworkStatus.Connected -> - IncognitoIcon(chat.chatInfo.incognito) + if (cInfo.contact.active) { + val descr = contactNetworkStatus?.statusString + when (contactNetworkStatus) { + is NetworkStatus.Connected -> + IncognitoIcon(chat.chatInfo.incognito) - is NetworkStatus.Error -> - Icon( - painterResource(MR.images.ic_error), - contentDescription = descr, - tint = MaterialTheme.colors.secondary, - modifier = Modifier - .size(19.dp) - ) + is NetworkStatus.Error -> + Icon( + painterResource(MR.images.ic_error), + contentDescription = descr, + tint = MaterialTheme.colors.secondary, + modifier = Modifier + .size(19.dp) + ) - else -> - CircularProgressIndicator( - Modifier - .padding(horizontal = 2.dp) - .size(15.dp), - color = MaterialTheme.colors.secondary, - strokeWidth = 1.5.dp - ) + else -> + CircularProgressIndicator( + Modifier + .padding(horizontal = 2.dp) + .size(15.dp), + color = MaterialTheme.colors.secondary, + strokeWidth = 1.5.dp + ) + } + } else { + IncognitoIcon(chat.chatInfo.incognito) } } else { IncognitoIcon(chat.chatInfo.incognito) diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index ab0d943f3..26e9948d6 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -1105,6 +1105,9 @@ You rejected group invitation Group invitation expired + + deleted contact + invited %1$s connected From c1854b7d507708942397869d09b8828096e9d0d4 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Thu, 28 Sep 2023 18:39:43 +0800 Subject: [PATCH 11/15] desktop: fix script for building the lib (#3141) --- scripts/desktop/build-lib-mac.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/desktop/build-lib-mac.sh b/scripts/desktop/build-lib-mac.sh index 8b0386473..303e33154 100755 --- a/scripts/desktop/build-lib-mac.sh +++ b/scripts/desktop/build-lib-mac.sh @@ -117,7 +117,7 @@ for lib in $(find . -type f -name "*.$LIB_EXT"); do 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` +LOCAL_DIRS=`for lib in $(find . -type f -name "*.$LIB_EXT"); do otool -l $lib | grep -E "/Users|/opt/|/usr/local" && echo $lib || true; done` if [ -n "$LOCAL_DIRS" ]; then echo These libs still point to local directories: echo $LOCAL_DIRS From bc7baf560be0f6ec88bcba5b55f1b6dd5fa8a513 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Fri, 29 Sep 2023 11:24:16 +0400 Subject: [PATCH 12/15] core: filter out connections of deleted contacts and group members on subscribe (#3144) --- src/Simplex/Chat.hs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 822d6cd4b..896edd26b 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -2570,7 +2570,7 @@ subscribeUserConnections onlyNeeded agentBatchSubscribe user@User {userId} = do getContactConns :: m ([ConnId], Map ConnId Contact) getContactConns = do cts <- withStore_ ("subscribeUserConnections " <> show userId <> ", getUserContacts") getUserContacts - let connIds = map contactConnId cts + let connIds = map contactConnId (filter contactActive cts) pure (connIds, M.fromList $ zip connIds cts) getUserContactLinkConns :: m ([ConnId], Map ConnId UserContact) getUserContactLinkConns = do @@ -2580,7 +2580,7 @@ subscribeUserConnections onlyNeeded agentBatchSubscribe user@User {userId} = do getGroupMemberConns :: m ([Group], [ConnId], Map ConnId GroupMember) getGroupMemberConns = do gs <- withStore_ ("subscribeUserConnections " <> show userId <> ", getUserGroups") getUserGroups - let mPairs = concatMap (\(Group _ ms) -> mapMaybe (\m -> (,m) <$> memberConnId m) ms) gs + let mPairs = concatMap (\(Group _ ms) -> mapMaybe (\m -> (,m) <$> memberConnId m) (filter (not . memberRemoved) ms)) gs pure (gs, map fst mPairs, M.fromList mPairs) getSndFileTransferConns :: m ([ConnId], Map ConnId SndFileTransfer) getSndFileTransferConns = do From 1d34500fba510a01be73dad81d6fb9f7447e7a41 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Fri, 29 Sep 2023 11:14:10 +0100 Subject: [PATCH 13/15] core: revert stop/close changes made for Windows (#3145) * Revert "core: return error response when wrong passphrase is passed to start" This reverts commit ea319313f10c41127e604cee2391ce759aaab8b3. * Revert "core: support closing/re-opening store on chat stop/start (#3132)" This reverts commit 3c7fc6b0ee1949dbe731bc11ad4e4809474ae7fd. --- .../chat/simplex/common/model/SimpleXAPI.kt | 25 ++++------- .../common/views/database/DatabaseView.kt | 2 +- cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- src/Simplex/Chat.hs | 43 ++++++------------- src/Simplex/Chat/Archive.hs | 28 ++++++------ src/Simplex/Chat/Controller.hs | 12 +----- src/Simplex/Chat/View.hs | 2 +- stack.yaml | 2 +- tests/ChatClient.hs | 2 +- tests/ChatTests/Direct.hs | 9 +--- 11 files changed, 42 insertions(+), 87 deletions(-) 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 619788f6e..4bb06afd8 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(openDBWithKey: String? = null): Boolean { - val r = sendCmd(CC.StartChat(ChatCtrlCfg(subConns = true, enableExpireCIs = true, startXFTPWorkers = true, openDBWithKey = openDBWithKey))) + suspend fun apiStartChat(): Boolean { + val r = sendCmd(CC.StartChat(expire = true)) when (r) { is CR.ChatStarted -> return true is CR.ChatRunning -> return false @@ -525,8 +525,8 @@ object ChatController { } } - suspend fun apiStopChat(closeStore: Boolean): Boolean { - val r = sendCmd(CC.ApiStopChat(closeStore)) + suspend fun apiStopChat(): Boolean { + val r = sendCmd(CC.ApiStopChat()) when (r) { is CR.ChatStopped -> return true else -> throw Error("failed stopping chat: ${r.responseType} ${r.details}") @@ -1834,8 +1834,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 cfg: ChatCtrlCfg): CC() - class ApiStopChat(val closeStore: Boolean): CC() + class StartChat(val expire: Boolean): CC() + class ApiStopChat: CC() class SetTempFolder(val tempFolder: String): CC() class SetFilesFolder(val filesFolder: String): CC() class ApiSetXFTPConfig(val config: XFTPFileConfig?): CC() @@ -1938,9 +1938,8 @@ 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 ${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 StartChat -> "/_start subscribe=on expire=${onOff(expire)} xftp=on" + is ApiStopChat -> "/_stop" is SetTempFolder -> "/_temp_folder $tempFolder" is SetFilesFolder -> "/_files_folder $filesFolder" is ApiSetXFTPConfig -> if (config != null) "/_xftp on ${json.encodeToString(config)}" else "/_xftp off" @@ -2157,14 +2156,6 @@ 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/database/DatabaseView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt index 3eb2e7d73..fa0f8f54d 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(false) + m.controller.apiStopChat() m.chatRunning.value = false } diff --git a/cabal.project b/cabal.project index b9753c9c0..b4024f088 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: fda1284ae4b7c33cae2eb8ed0376a511aecc1d51 + tag: 8d47f690838371bc848e4b31a4b09ef6bf67ccc5 source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index faa2401b1..26f4ea112 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."fda1284ae4b7c33cae2eb8ed0376a511aecc1d51" = "1gq7scv9z8x3xhzl914xr46na0kkrqd1i743xbw69lyx33kj9xb5"; + "https://github.com/simplex-chat/simplexmq.git"."8d47f690838371bc848e4b31a4b09ef6bf67ccc5" = "1pwasv22ii3wy4xchaknlwczmy5ws7adx7gg2g58lxzrgdjm3650"; "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"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "0kiwhvml42g9anw4d2v0zd1fpc790pj9syg5x3ik4l97fnkbbwpp"; diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 896edd26b..10f925471 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -34,7 +34,6 @@ import qualified Data.ByteString.Base64 as B64 import Data.ByteString.Char8 (ByteString) import qualified Data.ByteString.Char8 as B import Data.Char (isSpace, toLower) -import Data.Composition ((.:)) import Data.Constraint (Dict (..)) import Data.Either (fromRight, rights) import Data.Fixed (div') @@ -84,7 +83,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, closeSQLiteStore, openSQLiteStore) +import Simplex.Messaging.Agent.Store.SQLite (MigrationConfirmation (..), MigrationError, SQLiteStore (dbNew), execSQL, upMigration, withConnection) 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 @@ -218,8 +217,8 @@ newChatController ChatDatabase {chatStore, agentStore} user cfg@ChatConfig {agen where configServers :: DefaultAgentServers configServers = - let smp' = fromMaybe defaultServers.smp (nonEmpty smpServers) - xftp' = fromMaybe defaultServers.xftp (nonEmpty xftpServers) + let smp' = fromMaybe (defaultServers.smp) (nonEmpty smpServers) + xftp' = fromMaybe (defaultServers.xftp) (nonEmpty xftpServers) in defaultServers {smp = smp', xftp = xftp', netCfg = networkConfig} agentServers :: ChatConfig -> IO InitialAgentServers agentServers config@ChatConfig {defaultServers = defServers@DefaultAgentServers {ntf, netCfg}} = do @@ -252,7 +251,7 @@ cfgServers p s = case p of startChatController :: forall m. ChatMonad' m => Bool -> Bool -> Bool -> m (Async ()) startChatController subConns enableExpireCIs startXFTPWorkers = do - resumeAgentClient =<< asks smpAgent + asks smpAgent >>= resumeAgentClient unless subConns $ chatWriteVar subscriptionMode SMOnlyCreate users <- fromRight [] <$> runExceptT (withStoreCtx' (Just "startChatController, getUsers") getUsers) @@ -324,8 +323,8 @@ restoreCalls = do calls <- asks currentCalls atomically $ writeTVar calls callsMap -stopChatController :: forall m. MonadUnliftIO m => ChatController -> Bool -> m () -stopChatController ChatController {chatStore, smpAgent, agentAsync = s, sndFiles, rcvFiles, expireCIFlags} closeStore = do +stopChatController :: forall m. MonadUnliftIO m => ChatController -> m () +stopChatController ChatController {smpAgent, agentAsync = s, sndFiles, rcvFiles, expireCIFlags} = do disconnectAgentClient smpAgent readTVarIO s >>= mapM_ (\(a1, a2) -> uninterruptibleCancel a1 >> mapM_ uninterruptibleCancel a2) closeFiles sndFiles @@ -334,9 +333,6 @@ stopChatController ChatController {chatStore, smpAgent, agentAsync = s, sndFiles 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 @@ -466,19 +462,12 @@ processChatCommand = \case checkDeleteChatUser user' withChatLock "deleteUser" . procCmd $ deleteChatUser user' delSMPQueues DeleteUser uName delSMPQueues viewPwd_ -> withUserName uName $ \userId -> APIDeleteUser userId delSMPQueues viewPwd_ - APIStartChat ChatCtrlCfg {subConns, enableExpireCIs, startXFTPWorkers, openDBWithKey} -> withUser' $ \_ -> + StartChat subConns enableExpireCIs startXFTPWorkers -> withUser' $ \_ -> asks agentAsync >>= readTVarIO >>= \case Just _ -> pure CRChatRunning - _ -> checkStoreNotChanged $ do - forM_ openDBWithKey $ \(DBEncryptionKey dbKey) -> do - ChatController {chatStore, smpAgent} <- ask - open chatStore dbKey - open (agentClientStore smpAgent) dbKey - startChatController subConns enableExpireCIs startXFTPWorkers $> CRChatStarted - where - open = handleDBError DBErrorOpen .: openSQLiteStore - APIStopChat closeStore -> do - ask >>= (`stopChatController` closeStore) + _ -> checkStoreNotChanged $ startChatController subConns enableExpireCIs startXFTPWorkers $> CRChatStarted + APIStopChat -> do + ask >>= stopChatController pure CRChatStopped APIActivateChat -> withUser $ \_ -> do restoreCalls @@ -5411,9 +5400,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" *> (APIStartChat <$> ((A.space *> jsonP) <|> chatCtrlCfgP)), - "/_stop close" $> APIStopChat {closeStore = True}, - "/_stop" $> APIStopChat False, + "/_start subscribe=" *> (StartChat <$> onOffP <* " expire=" <*> onOffP <* " xftp=" <*> onOffP), + "/_start" $> StartChat True True True, + "/_stop" $> APIStopChat, "/_app activate" $> APIActivateChat, "/_app suspend " *> (APISuspendChat <$> A.decimal), "/_resubscribe all" $> ResubscribeAllConnections, @@ -5641,12 +5630,6 @@ 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 8d1b99328..f8fa0d152 100644 --- a/src/Simplex/Chat/Archive.hs +++ b/src/Simplex/Chat/Archive.hs @@ -9,7 +9,6 @@ module Simplex.Chat.Archive importArchive, deleteStorage, sqlCipherExport, - handleDBError, ) where @@ -125,7 +124,7 @@ sqlCipherExport DBEncryptionConfig {currentKey = DBEncryptionKey key, newKey = D checkFile `with` fs backup `with` fs (export chatDb chatEncrypted >> export agentDb agentEncrypted) - `catchChatError` \e -> tryChatError (restore `with` fs) >> throwError e + `catchChatError` \e -> (restore `with` fs) >> throwError e where action `with` StorageFiles {chatDb, agentDb} = action chatDb >> action agentDb backup f = copyFile f (f <> ".bak") @@ -140,7 +139,17 @@ sqlCipherExport DBEncryptionConfig {currentKey = DBEncryptionKey key, newKey = D withDB (`SQL.exec` testSQL) DBErrorOpen atomically $ writeTVar dbEnc $ not (null key') where - withDB a err = handleDBError err $ bracket (SQL.open $ T.pack f) SQL.close a + withDB a err = + liftIO (bracket (SQL.open $ T.pack f) SQL.close a $> Nothing) + `catch` checkSQLError + `catch` (\(e :: SomeException) -> sqliteError' e) + >>= mapM_ (throwDBError . err) + where + checkSQLError e = case SQL.sqlError e of + SQL.ErrorNotADatabase -> pure $ Just SQLiteErrorNotADatabase + _ -> sqliteError' e + sqliteError' :: Show e => e -> m (Maybe SQLiteError) + sqliteError' = pure . Just . SQLiteError . show exportSQL = T.unlines $ keySQL key @@ -157,16 +166,3 @@ sqlCipherExport DBEncryptionConfig {currentKey = DBEncryptionKey key, newKey = D "SELECT count(*) FROM sqlite_master;" ] keySQL k = ["PRAGMA key = " <> sqlString k <> ";" | not (null k)] - -handleDBError :: forall m. ChatMonad m => (SQLiteError -> DatabaseError) -> IO () -> m () -handleDBError err a = - (liftIO a $> Nothing) - `catch` checkSQLError - `catch` (\(e :: SomeException) -> sqliteError' e) - >>= mapM_ (throwDBError . err) - where - checkSQLError e = case SQL.sqlError e of - SQL.ErrorNotADatabase -> pure $ Just SQLiteErrorNotADatabase - _ -> sqliteError' e - sqliteError' :: Show e => e -> m (Maybe SQLiteError) - sqliteError' = pure . Just . SQLiteError . show diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 3f3dae94f..122a4be3f 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) - | APIStartChat ChatCtrlCfg - | APIStopChat {closeStore :: Bool} + | StartChat {subscribeConnections :: Bool, enableExpireChatItems :: Bool, startXFTPWorkers :: Bool} + | APIStopChat | APIActivateChat | APISuspendChat {suspendTimeout :: Int} | ResubscribeAllConnections @@ -621,14 +621,6 @@ 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) - newtype UserPwd = UserPwd {unUserPwd :: Text} deriving (Eq, Show) diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 19d729bf7..01bdfba95 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -1648,7 +1648,7 @@ viewChatError logLevel = \case DBErrorEncrypted -> ["error: chat database is already encrypted"] DBErrorPlaintext -> ["error: chat database is not encrypted"] DBErrorExport e -> ["error encrypting database: " <> sqliteError' e] - DBErrorOpen e -> ["error opening database: " <> sqliteError' e] + DBErrorOpen e -> ["error opening database after encryption: " <> sqliteError' e] e -> ["chat database error: " <> sShow e] ChatErrorAgent err entity_ -> case err of CMD PROHIBITED -> [withConnEntity <> "error: command is prohibited"] diff --git a/stack.yaml b/stack.yaml index a466178ce..0840970e4 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: fda1284ae4b7c33cae2eb8ed0376a511aecc1d51 + commit: 8d47f690838371bc848e4b31a4b09ef6bf67ccc5 - github: kazu-yamamoto/http2 commit: b5a1b7200cf5bc7044af34ba325284271f6dff25 # - ../direct-sqlcipher diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index d947fb63b..7da526325 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -171,7 +171,7 @@ startTestChat_ db cfg opts user = do stopTestChat :: TestCC -> IO () stopTestChat TestCC {chatController = cc, chatAsync, termAsync} = do - stopChatController cc False + stopChatController cc uninterruptibleCancel termAsync uninterruptibleCancel chatAsync threadDelay 200000 diff --git a/tests/ChatTests/Direct.hs b/tests/ChatTests/Direct.hs index 445a5ab99..5c4d96cc9 100644 --- a/tests/ChatTests/Direct.hs +++ b/tests/ChatTests/Direct.hs @@ -992,17 +992,10 @@ testDatabaseEncryption tmp = do alice ##> "/_start" alice <## "chat started" testChatWorking alice bob - alice ##> "/_stop close" + alice ##> "/_stop" alice <## "chat stopped" alice ##> "/db key wrongkey nextkey" alice <## "error encrypting database: wrong passphrase or invalid database file" - alice ##> "/_start key=wrongkey" - alice <## "error opening 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\"}" From 70a65e8969a67905a36116b04806aa7bd7d0c800 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Fri, 29 Sep 2023 13:09:48 +0100 Subject: [PATCH 14/15] core: close stores before import/delete/encryption operations to make compatible with windows, make encryption more resilient (#3146) * core: close stores before import/delete/encryption operations to make compatible with windows, make encryption more resilient * remove file names * do not remove files if already removed --- cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- src/Simplex/Chat/Archive.hs | 86 +++++++++++++++++++++---------------- stack.yaml | 2 +- 4 files changed, 52 insertions(+), 40 deletions(-) diff --git a/cabal.project b/cabal.project index b4024f088..af664652d 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: 8d47f690838371bc848e4b31a4b09ef6bf67ccc5 + tag: ec1b72cb8013a65a5d9783104a47ae44f5730089 source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 26f4ea112..b6ca36e31 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."8d47f690838371bc848e4b31a4b09ef6bf67ccc5" = "1pwasv22ii3wy4xchaknlwczmy5ws7adx7gg2g58lxzrgdjm3650"; + "https://github.com/simplex-chat/simplexmq.git"."ec1b72cb8013a65a5d9783104a47ae44f5730089" = "1lz5rvgxp242zg95r9zd9j50y45314cf8nfpjg1qsa55nrk2w19b"; "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"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "0kiwhvml42g9anw4d2v0zd1fpc790pj9syg5x3ik4l97fnkbbwpp"; diff --git a/src/Simplex/Chat/Archive.hs b/src/Simplex/Chat/Archive.hs index f8fa0d152..e0de971bd 100644 --- a/src/Simplex/Chat/Archive.hs +++ b/src/Simplex/Chat/Archive.hs @@ -21,7 +21,7 @@ import qualified Data.Text as T import qualified Database.SQLite3 as SQL import Simplex.Chat.Controller import Simplex.Messaging.Agent.Client (agentClientStore) -import Simplex.Messaging.Agent.Store.SQLite (SQLiteStore (..), sqlString) +import Simplex.Messaging.Agent.Store.SQLite (SQLiteStore (..), sqlString, closeSQLiteStore) import Simplex.Messaging.Util import System.FilePath import UnliftIO.Directory @@ -42,9 +42,9 @@ archiveFilesFolder = "simplex_v1_files" exportArchive :: ChatMonad m => ArchiveConfig -> m () exportArchive cfg@ArchiveConfig {archivePath, disableCompression} = withTempDir cfg "simplex-chat." $ \dir -> do - StorageFiles {chatDb, agentDb, filesPath} <- storageFiles - copyFile chatDb $ dir archiveChatDbFile - copyFile agentDb $ dir archiveAgentDbFile + StorageFiles {chatStore, agentStore, filesPath} <- storageFiles + copyFile (dbFilePath chatStore) $ dir archiveChatDbFile + copyFile (dbFilePath agentStore) $ dir archiveAgentDbFile forM_ filesPath $ \fp -> copyDirectoryFiles fp $ dir archiveFilesFolder let method = if disableCompression == Just True then Z.Store else Z.Deflate @@ -54,11 +54,11 @@ importArchive :: ChatMonad m => ArchiveConfig -> m [ArchiveError] importArchive cfg@ArchiveConfig {archivePath} = withTempDir cfg "simplex-chat." $ \dir -> do Z.withArchive archivePath $ Z.unpackInto dir - StorageFiles {chatDb, agentDb, filesPath} <- storageFiles - backup chatDb - backup agentDb - copyFile (dir archiveChatDbFile) chatDb - copyFile (dir archiveAgentDbFile) agentDb + fs@StorageFiles {chatStore, agentStore, filesPath} <- storageFiles + liftIO $ closeSQLiteStore `withStores` fs + backup `withDBs` fs + copyFile (dir archiveChatDbFile) $ dbFilePath chatStore + copyFile (dir archiveAgentDbFile) $ dbFilePath agentStore copyFiles dir filesPath `E.catch` \(e :: E.SomeException) -> pure [AEImport . ChatError . CEException $ show e] where @@ -94,53 +94,60 @@ copyDirectoryFiles fromDir toDir = do deleteStorage :: ChatMonad m => m () deleteStorage = do - StorageFiles {chatDb, agentDb, filesPath} <- storageFiles - removeFile chatDb - removeFile agentDb - mapM_ removePathForcibly filesPath - tmpPath <- readTVarIO =<< asks tempDirectory - mapM_ removePathForcibly tmpPath + fs <- storageFiles + liftIO $ closeSQLiteStore `withStores` fs + remove `withDBs` fs + mapM_ removeDir $ filesPath fs + mapM_ removeDir =<< chatReadVar tempDirectory + where + remove f = whenM (doesFileExist f) $ removeFile f + removeDir d = whenM (doesDirectoryExist d) $ removePathForcibly d data StorageFiles = StorageFiles - { chatDb :: FilePath, - chatEncrypted :: TVar Bool, - agentDb :: FilePath, - agentEncrypted :: TVar Bool, + { chatStore :: SQLiteStore, + agentStore :: SQLiteStore, filesPath :: Maybe FilePath } storageFiles :: ChatMonad m => m StorageFiles storageFiles = do ChatController {chatStore, filesFolder, smpAgent} <- ask - let SQLiteStore {dbFilePath = chatDb, dbEncrypted = chatEncrypted} = chatStore - SQLiteStore {dbFilePath = agentDb, dbEncrypted = agentEncrypted} = agentClientStore smpAgent + let agentStore = agentClientStore smpAgent filesPath <- readTVarIO filesFolder - pure StorageFiles {chatDb, chatEncrypted, agentDb, agentEncrypted, filesPath} + pure StorageFiles {chatStore, agentStore, filesPath} sqlCipherExport :: forall m. ChatMonad m => DBEncryptionConfig -> m () sqlCipherExport DBEncryptionConfig {currentKey = DBEncryptionKey key, newKey = DBEncryptionKey key'} = when (key /= key') $ do - fs@StorageFiles {chatDb, chatEncrypted, agentDb, agentEncrypted} <- storageFiles - checkFile `with` fs - backup `with` fs - (export chatDb chatEncrypted >> export agentDb agentEncrypted) - `catchChatError` \e -> (restore `with` fs) >> throwError e + fs <- storageFiles + checkFile `withDBs` fs + backup `withDBs` fs + checkEncryption `withStores` fs + removeExported `withDBs` fs + export `withDBs` fs + -- closing after encryption prevents closing in case wrong encryption key was passed + liftIO $ closeSQLiteStore `withStores` fs + (moveExported `withStores` fs) + `catchChatError` \e -> (restore `withDBs` fs) >> throwError e where - action `with` StorageFiles {chatDb, agentDb} = action chatDb >> action agentDb backup f = copyFile f (f <> ".bak") restore f = copyFile (f <> ".bak") f checkFile f = unlessM (doesFileExist f) $ throwDBError $ DBErrorNoFile f - export f dbEnc = do - enc <- readTVarIO dbEnc + checkEncryption SQLiteStore {dbEncrypted} = do + enc <- readTVarIO dbEncrypted when (enc && null key) $ throwDBError DBErrorEncrypted when (not enc && not (null key)) $ throwDBError DBErrorPlaintext - withDB (`SQL.exec` exportSQL) DBErrorExport - renameFile (f <> ".exported") f - withDB (`SQL.exec` testSQL) DBErrorOpen - atomically $ writeTVar dbEnc $ not (null key') + exported = (<> ".exported") + removeExported f = whenM (doesFileExist $ exported f) $ removeFile (exported f) + moveExported SQLiteStore {dbFilePath = f, dbEncrypted} = do + renameFile (exported f) f + atomically $ writeTVar dbEncrypted $ not (null key') + export f = do + withDB f (`SQL.exec` exportSQL) DBErrorExport + withDB (exported f) (`SQL.exec` testSQL) DBErrorOpen where - withDB a err = - liftIO (bracket (SQL.open $ T.pack f) SQL.close a $> Nothing) + withDB f' a err = + liftIO (bracket (SQL.open $ T.pack f') SQL.close a $> Nothing) `catch` checkSQLError `catch` (\(e :: SomeException) -> sqliteError' e) >>= mapM_ (throwDBError . err) @@ -162,7 +169,12 @@ sqlCipherExport DBEncryptionConfig {currentKey = DBEncryptionKey key, newKey = D keySQL key' <> [ "PRAGMA foreign_keys = ON;", "PRAGMA secure_delete = ON;", - "PRAGMA auto_vacuum = FULL;", "SELECT count(*) FROM sqlite_master;" ] keySQL k = ["PRAGMA key = " <> sqlString k <> ";" | not (null k)] + +withDBs :: Monad m => (FilePath -> m b) -> StorageFiles -> m b +action `withDBs` StorageFiles {chatStore, agentStore} = action (dbFilePath chatStore) >> action (dbFilePath agentStore) + +withStores :: Monad m => (SQLiteStore -> m b) -> StorageFiles -> m b +action `withStores` StorageFiles {chatStore, agentStore} = action chatStore >> action agentStore diff --git a/stack.yaml b/stack.yaml index 0840970e4..bce5dd3a6 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: 8d47f690838371bc848e4b31a4b09ef6bf67ccc5 + commit: ec1b72cb8013a65a5d9783104a47ae44f5730089 - github: kazu-yamamoto/http2 commit: b5a1b7200cf5bc7044af34ba325284271f6dff25 # - ../direct-sqlcipher From 5ce388522e855389d7263cc97590c1fed9122f1f Mon Sep 17 00:00:00 2001 From: Alexander Bondarenko <486682+dpwiz@users.noreply.github.com> Date: Fri, 29 Sep 2023 17:50:20 +0300 Subject: [PATCH 15/15] Move toView and withStore* to a common module (#3147) --- src/Simplex/Chat.hs | 33 --------------------- src/Simplex/Chat/Controller.hs | 53 +++++++++++++++++++++++++++------- 2 files changed, 43 insertions(+), 43 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 10f925471..2a568d628 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -19,7 +19,6 @@ module Simplex.Chat where import Control.Applicative (optional, (<|>)) import Control.Concurrent.STM (retry) -import qualified Control.Exception as E import Control.Logger.Simple import Control.Monad import Control.Monad.Except @@ -356,11 +355,6 @@ execChatCommand_ u cmd = either (CRChatCmdError u) id <$> runExceptT (processCha parseChatCommand :: ByteString -> Either String ChatCommand parseChatCommand = A.parseOnly chatCommandP . B.dropWhileEnd isSpace -toView :: ChatMonad' m => ChatResponse -> m () -toView event = do - q <- asks outputQ - atomically $ writeTBQueue q (Nothing, event) - processChatCommand :: forall m. ChatMonad m => ChatCommand -> m ChatResponse processChatCommand = \case ShowActiveUser -> withUser' $ pure . CRActiveUser @@ -5346,33 +5340,6 @@ withAgent action = >>= runExceptT . action >>= liftEither . first (`ChatErrorAgent` Nothing) -withStore' :: ChatMonad m => (DB.Connection -> IO a) -> m a -withStore' action = withStore $ liftIO . action - -withStore :: ChatMonad m => (DB.Connection -> ExceptT StoreError IO a) -> m a -withStore = withStoreCtx Nothing - -withStoreCtx' :: ChatMonad m => Maybe String -> (DB.Connection -> IO a) -> m a -withStoreCtx' ctx_ action = withStoreCtx ctx_ $ liftIO . action - -withStoreCtx :: ChatMonad m => Maybe String -> (DB.Connection -> ExceptT StoreError IO a) -> m a -withStoreCtx ctx_ action = do - ChatController {chatStore} <- ask - liftEitherError ChatErrorStore $ case ctx_ of - Nothing -> withTransaction chatStore (runExceptT . action) `E.catch` handleInternal "" - -- uncomment to debug store performance - -- Just ctx -> do - -- t1 <- liftIO getCurrentTime - -- putStrLn $ "withStoreCtx start :: " <> show t1 <> " :: " <> ctx - -- r <- withTransactionCtx ctx_ chatStore (runExceptT . action) `E.catch` handleInternal (" (" <> ctx <> ")") - -- t2 <- liftIO getCurrentTime - -- putStrLn $ "withStoreCtx end :: " <> show t2 <> " :: " <> ctx <> " :: duration=" <> show (diffToMilliseconds $ diffUTCTime t2 t1) - -- pure r - Just _ -> withTransaction chatStore (runExceptT . action) `E.catch` handleInternal "" - where - handleInternal :: String -> E.SomeException -> IO (Either StoreError a) - handleInternal ctxStr e = pure . Left . SEInternalError $ show e <> ctxStr - chatCommandP :: Parser ChatCommand chatCommandP = choice diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 122a4be3f..15c06cba9 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -46,7 +46,7 @@ import Simplex.Chat.Markdown (MarkdownList) import Simplex.Chat.Messages import Simplex.Chat.Messages.CIContent import Simplex.Chat.Protocol -import Simplex.Chat.Store (AutoAccept, StoreError, UserContactLink, UserMsgReceiptSettings) +import Simplex.Chat.Store (AutoAccept, StoreError (..), UserContactLink, UserMsgReceiptSettings) import Simplex.Chat.Types import Simplex.Chat.Types.Preferences import Simplex.Messaging.Agent (AgentClient, SubscriptionsInfo) @@ -54,8 +54,9 @@ import Simplex.Messaging.Agent.Client (AgentLocks, ProtocolTestFailure) import Simplex.Messaging.Agent.Env.SQLite (AgentConfig, NetworkConfig) import Simplex.Messaging.Agent.Lock import Simplex.Messaging.Agent.Protocol -import Simplex.Messaging.Agent.Store.SQLite (MigrationConfirmation, SQLiteStore, UpMigration) +import Simplex.Messaging.Agent.Store.SQLite (MigrationConfirmation, SQLiteStore, UpMigration, withTransaction) import Simplex.Messaging.Agent.Store.SQLite.DB (SlowQueryStats (..)) +import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.File (CryptoFile (..)) import qualified Simplex.Messaging.Crypto.File as CF @@ -66,7 +67,7 @@ import Simplex.Messaging.Protocol (AProtoServerWithAuth, AProtocolType, CorrId, import Simplex.Messaging.TMap (TMap) import Simplex.Messaging.Transport (simplexMQVersion) import Simplex.Messaging.Transport.Client (TransportHost) -import Simplex.Messaging.Util (allFinally, catchAllErrors, tryAllErrors, (<$$>)) +import Simplex.Messaging.Util (allFinally, catchAllErrors, liftEitherError, tryAllErrors, (<$$>)) import Simplex.Messaging.Version import System.IO (Handle) import System.Mem.Weak (Weak) @@ -969,6 +970,15 @@ instance ToJSON SQLiteError where throwDBError :: ChatMonad m => DatabaseError -> m () throwDBError = throwError . ChatErrorDatabase +data ArchiveError + = AEImport {chatError :: ChatError} + | AEImportFile {file :: String, chatError :: ChatError} + deriving (Show, Exception, Generic) + +instance ToJSON ArchiveError where + toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "AE" + toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "AE" + type ChatMonad' m = (MonadUnliftIO m, MonadReader ChatController m) type ChatMonad m = (ChatMonad' m, MonadError ChatError m) @@ -1008,11 +1018,34 @@ unsetActive a = asks activeTo >>= atomically . (`modifyTVar` unset) where unset a' = if a == a' then ActiveNone else a' -data ArchiveError - = AEImport {chatError :: ChatError} - | AEImportFile {file :: String, chatError :: ChatError} - deriving (Show, Exception, Generic) +toView :: ChatMonad' m => ChatResponse -> m () +toView event = do + q <- asks outputQ + atomically $ writeTBQueue q (Nothing, event) -instance ToJSON ArchiveError where - toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "AE" - toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "AE" +withStore' :: ChatMonad m => (DB.Connection -> IO a) -> m a +withStore' action = withStore $ liftIO . action + +withStore :: ChatMonad m => (DB.Connection -> ExceptT StoreError IO a) -> m a +withStore = withStoreCtx Nothing + +withStoreCtx' :: ChatMonad m => Maybe String -> (DB.Connection -> IO a) -> m a +withStoreCtx' ctx_ action = withStoreCtx ctx_ $ liftIO . action + +withStoreCtx :: ChatMonad m => Maybe String -> (DB.Connection -> ExceptT StoreError IO a) -> m a +withStoreCtx ctx_ action = do + ChatController {chatStore} <- ask + liftEitherError ChatErrorStore $ case ctx_ of + Nothing -> withTransaction chatStore (runExceptT . action) `catch` handleInternal "" + -- uncomment to debug store performance + -- Just ctx -> do + -- t1 <- liftIO getCurrentTime + -- putStrLn $ "withStoreCtx start :: " <> show t1 <> " :: " <> ctx + -- r <- withTransactionCtx ctx_ chatStore (runExceptT . action) `E.catch` handleInternal (" (" <> ctx <> ")") + -- t2 <- liftIO getCurrentTime + -- putStrLn $ "withStoreCtx end :: " <> show t2 <> " :: " <> ctx <> " :: duration=" <> show (diffToMilliseconds $ diffUTCTime t2 t1) + -- pure r + Just _ -> withTransaction chatStore (runExceptT . action) `catch` handleInternal "" + where + handleInternal :: String -> SomeException -> IO (Either StoreError a) + handleInternal ctxStr e = pure . Left . SEInternalError $ show e <> ctxStr