Merge branch 'master' into remote-desktop

This commit is contained in:
Evgeny Poberezkin 2023-11-08 13:10:42 +00:00
commit 3839267f88
32 changed files with 299 additions and 91 deletions

View File

@ -81,7 +81,7 @@ jobs:
- name: Setup Haskell - name: Setup Haskell
uses: haskell-actions/setup@v2 uses: haskell-actions/setup@v2
with: with:
ghc-version: "9.6.2" ghc-version: "9.6.3"
cabal-version: "3.10.1.0" cabal-version: "3.10.1.0"
- name: Cache dependencies - name: Cache dependencies

View File

@ -8,11 +8,11 @@ RUN a=$(arch); curl https://downloads.haskell.org/~ghcup/$a-linux-ghcup -o /usr/
chmod +x /usr/bin/ghcup chmod +x /usr/bin/ghcup
# Install ghc # Install ghc
RUN ghcup install ghc 9.6.2 RUN ghcup install ghc 9.6.3
# Install cabal # Install cabal
RUN ghcup install cabal 3.10.1.0 RUN ghcup install cabal 3.10.1.0
# Set both as default # Set both as default
RUN ghcup set ghc 9.6.2 && \ RUN ghcup set ghc 9.6.3 && \
ghcup set cabal 3.10.1.0 ghcup set cabal 3.10.1.0
COPY . /project COPY . /project

View File

@ -120,6 +120,10 @@ class AppDelegate: NSObject, UIApplicationDelegate {
BGManager.shared.receiveMessages(complete) BGManager.shared.receiveMessages(complete)
} }
static func keepScreenOn(_ on: Bool) {
UIApplication.shared.isIdleTimerDisabled = on
}
} }
class SceneDelegate: NSObject, ObservableObject, UIWindowSceneDelegate { class SceneDelegate: NSObject, ObservableObject, UIWindowSceneDelegate {

View File

@ -46,6 +46,7 @@ class AudioRecorder {
audioRecorder?.record(forDuration: MAX_VOICE_MESSAGE_LENGTH) audioRecorder?.record(forDuration: MAX_VOICE_MESSAGE_LENGTH)
await MainActor.run { await MainActor.run {
AppDelegate.keepScreenOn(true)
recordingTimer = Timer.scheduledTimer(withTimeInterval: 0.01, repeats: true) { timer in recordingTimer = Timer.scheduledTimer(withTimeInterval: 0.01, repeats: true) { timer in
guard let time = self.audioRecorder?.currentTime else { return } guard let time = self.audioRecorder?.currentTime else { return }
self.onTimer?(time) self.onTimer?(time)
@ -57,6 +58,9 @@ class AudioRecorder {
} }
return nil return nil
} catch let error { } catch let error {
await MainActor.run {
AppDelegate.keepScreenOn(false)
}
logger.error("AudioRecorder startAudioRecording error \(error.localizedDescription)") logger.error("AudioRecorder startAudioRecording error \(error.localizedDescription)")
return .error(error.localizedDescription) return .error(error.localizedDescription)
} }
@ -71,6 +75,7 @@ class AudioRecorder {
timer.invalidate() timer.invalidate()
} }
recordingTimer = nil recordingTimer = nil
AppDelegate.keepScreenOn(false)
} }
private func checkPermission() async -> Bool { private func checkPermission() async -> Bool {
@ -121,6 +126,7 @@ class AudioPlayer: NSObject, AVAudioPlayerDelegate {
playbackTimer = Timer.scheduledTimer(withTimeInterval: 0.01, repeats: true) { timer in playbackTimer = Timer.scheduledTimer(withTimeInterval: 0.01, repeats: true) { timer in
if self.audioPlayer?.isPlaying ?? false { if self.audioPlayer?.isPlaying ?? false {
AppDelegate.keepScreenOn(true)
guard let time = self.audioPlayer?.currentTime else { return } guard let time = self.audioPlayer?.currentTime else { return }
self.onTimer?(time) self.onTimer?(time)
} }
@ -129,6 +135,7 @@ class AudioPlayer: NSObject, AVAudioPlayerDelegate {
func pause() { func pause() {
audioPlayer?.pause() audioPlayer?.pause()
AppDelegate.keepScreenOn(false)
} }
func play() { func play() {
@ -149,6 +156,7 @@ class AudioPlayer: NSObject, AVAudioPlayerDelegate {
func stop() { func stop() {
if let player = audioPlayer { if let player = audioPlayer {
player.stop() player.stop()
AppDelegate.keepScreenOn(false)
} }
audioPlayer = nil audioPlayer = nil
if let timer = playbackTimer { if let timer = playbackTimer {

View File

@ -39,6 +39,7 @@ struct ActiveCallView: View {
} }
.onAppear { .onAppear {
logger.debug("ActiveCallView: appear client is nil \(client == nil), scenePhase \(String(describing: scenePhase), privacy: .public), canConnectCall \(canConnectCall)") logger.debug("ActiveCallView: appear client is nil \(client == nil), scenePhase \(String(describing: scenePhase), privacy: .public), canConnectCall \(canConnectCall)")
AppDelegate.keepScreenOn(true)
createWebRTCClient() createWebRTCClient()
dismissAllSheets() dismissAllSheets()
} }
@ -48,6 +49,7 @@ struct ActiveCallView: View {
} }
.onDisappear { .onDisappear {
logger.debug("ActiveCallView: disappear") logger.debug("ActiveCallView: disappear")
AppDelegate.keepScreenOn(false)
client?.endCall() client?.endCall()
} }
.onChange(of: m.callCommand) { _ in sendCommandToClient()} .onChange(of: m.callCommand) { _ in sendCommandToClient()}

View File

@ -6,6 +6,7 @@
import Foundation import Foundation
import SwiftUI import SwiftUI
import AVKit import AVKit
import Combine
struct VideoPlayerView: UIViewRepresentable { struct VideoPlayerView: UIViewRepresentable {
@ -37,6 +38,9 @@ struct VideoPlayerView: UIViewRepresentable {
player.seek(to: CMTime.zero) player.seek(to: CMTime.zero)
player.play() player.play()
} }
context.coordinator.publisher = player.publisher(for: \.timeControlStatus).sink { status in
AppDelegate.keepScreenOn(status == .playing)
}
return controller.view return controller.view
} }
@ -50,11 +54,13 @@ struct VideoPlayerView: UIViewRepresentable {
class Coordinator: NSObject { class Coordinator: NSObject {
var controller: AVPlayerViewController? var controller: AVPlayerViewController?
var timeObserver: Any? = nil var timeObserver: Any? = nil
var publisher: AnyCancellable? = nil
deinit { deinit {
if let timeObserver = timeObserver { if let timeObserver = timeObserver {
NotificationCenter.default.removeObserver(timeObserver) NotificationCenter.default.removeObserver(timeObserver)
} }
publisher?.cancel()
} }
} }
} }

View File

@ -48,6 +48,7 @@ actual class RecorderNative: RecorderInterface {
recStartedAt = System.currentTimeMillis() recStartedAt = System.currentTimeMillis()
progressJob = CoroutineScope(Dispatchers.Default).launch { progressJob = CoroutineScope(Dispatchers.Default).launch {
while(isActive) { while(isActive) {
keepScreenOn(true)
onProgressUpdate(progress(), false) onProgressUpdate(progress(), false)
delay(50) delay(50)
} }
@ -84,6 +85,7 @@ actual class RecorderNative: RecorderInterface {
progressJob = null progressJob = null
filePath = null filePath = null
recorder = null recorder = null
keepScreenOn(false)
return (realDuration(path) ?: 0).also { recStartedAt = null } return (realDuration(path) ?: 0).also { recStartedAt = null }
} }
@ -170,6 +172,7 @@ actual object AudioPlayer: AudioPlayerInterface {
progressJob = CoroutineScope(Dispatchers.Default).launch { progressJob = CoroutineScope(Dispatchers.Default).launch {
onProgressUpdate(player.currentPosition, TrackState.PLAYING) onProgressUpdate(player.currentPosition, TrackState.PLAYING)
while(isActive && player.isPlaying) { while(isActive && player.isPlaying) {
keepScreenOn(true)
// Even when current position is equal to duration, the player has isPlaying == true for some time, // 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 // so help to make the playback stopped in UI immediately
if (player.currentPosition == player.duration) { if (player.currentPosition == player.duration) {
@ -187,6 +190,7 @@ actual object AudioPlayer: AudioPlayerInterface {
if (isActive) { if (isActive) {
onProgressUpdate(player.duration, TrackState.PAUSED) onProgressUpdate(player.duration, TrackState.PAUSED)
} }
keepScreenOn(false)
onProgressUpdate(null, TrackState.PAUSED) onProgressUpdate(null, TrackState.PAUSED)
} }
return player.duration return player.duration
@ -196,6 +200,7 @@ actual object AudioPlayer: AudioPlayerInterface {
progressJob?.cancel() progressJob?.cancel()
progressJob = null progressJob = null
player.pause() player.pause()
keepScreenOn(false)
return player.currentPosition return player.currentPosition
} }
@ -203,6 +208,7 @@ actual object AudioPlayer: AudioPlayerInterface {
if (currentlyPlaying.value == null) return if (currentlyPlaying.value == null) return
player.stop() player.stop()
stopListener() stopListener()
keepScreenOn(false)
} }
override fun stop(item: ChatItem) = stop(item.file?.fileName) override fun stop(item: ChatItem) = stop(item.file?.fileName)
@ -263,6 +269,7 @@ actual object AudioPlayer: AudioPlayerInterface {
override fun pause(audioPlaying: MutableState<Boolean>, pro: MutableState<Int>) { override fun pause(audioPlaying: MutableState<Boolean>, pro: MutableState<Int>) {
pro.value = pause() pro.value = pause()
audioPlaying.value = false audioPlaying.value = false
keepScreenOn(false)
} }
override fun seekTo(ms: Int, pro: MutableState<Int>, filePath: String?) { override fun seekTo(ms: Int, pro: MutableState<Int>, filePath: String?) {

View File

@ -1,13 +1,10 @@
package chat.simplex.common.platform package chat.simplex.common.platform
import android.media.MediaMetadataRetriever
import android.media.session.PlaybackState import android.media.session.PlaybackState
import android.net.Uri import android.net.Uri
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import chat.simplex.common.helpers.toUri
import chat.simplex.common.views.helpers.* import chat.simplex.common.views.helpers.*
import chat.simplex.res.MR import chat.simplex.res.MR
import com.google.android.exoplayer2.* import com.google.android.exoplayer2.*
@ -134,6 +131,7 @@ actual class VideoPlayer actual constructor(
player.addListener(object: Player.Listener{ player.addListener(object: Player.Listener{
override fun onIsPlayingChanged(isPlaying: Boolean) { override fun onIsPlayingChanged(isPlaying: Boolean) {
super.onIsPlayingChanged(isPlaying) super.onIsPlayingChanged(isPlaying)
keepScreenOn(isPlaying)
// Produce non-ideal transition from stopped to playing state while showing preview image in ChatView // Produce non-ideal transition from stopped to playing state while showing preview image in ChatView
// videoPlaying.value = isPlaying // videoPlaying.value = isPlaying
} }
@ -192,6 +190,7 @@ actual class VideoPlayer actual constructor(
override fun release(remove: Boolean) { override fun release(remove: Boolean) {
player.release() player.release()
keepScreenOn(false)
if (remove) { if (remove) {
VideoPlayerHolder.players.remove(uri to gallery) VideoPlayerHolder.players.remove(uri to gallery)
} }

View File

@ -196,12 +196,14 @@ actual fun ActiveCallView() {
chatModel.activeCallViewIsVisible.value = true chatModel.activeCallViewIsVisible.value = true
// After the first call, End command gets added to the list which prevents making another calls // After the first call, End command gets added to the list which prevents making another calls
chatModel.callCommand.removeAll { it is WCallCommand.End } chatModel.callCommand.removeAll { it is WCallCommand.End }
keepScreenOn(true)
onDispose { onDispose {
activity.volumeControlStream = prevVolumeControlStream activity.volumeControlStream = prevVolumeControlStream
// Unlock orientation // Unlock orientation
activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
chatModel.activeCallViewIsVisible.value = false chatModel.activeCallViewIsVisible.value = false
chatModel.callCommand.clear() chatModel.callCommand.clear()
keepScreenOn(false)
} }
} }
} }

View File

@ -11,6 +11,7 @@ import android.text.Spanned
import android.text.SpannedString import android.text.SpannedString
import android.text.style.* import android.text.style.*
import android.util.Base64 import android.util.Base64
import android.view.WindowManager
import androidx.compose.ui.graphics.* import androidx.compose.ui.graphics.*
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.* import androidx.compose.ui.text.*
@ -43,6 +44,17 @@ fun Resources.getText(id: StringResource, vararg args: Any): CharSequence {
return HtmlCompat.fromHtml(formattedHtml, HtmlCompat.FROM_HTML_MODE_LEGACY) return HtmlCompat.fromHtml(formattedHtml, HtmlCompat.FROM_HTML_MODE_LEGACY)
} }
fun keepScreenOn(on: Boolean) {
val window = mainActivity.get()?.window ?: return
Handler(Looper.getMainLooper()).post {
if (on) {
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
} else {
window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
}
}
}
actual fun escapedHtmlToAnnotatedString(text: String, density: Density): AnnotatedString { actual fun escapedHtmlToAnnotatedString(text: String, density: Density): AnnotatedString {
return spannableStringToAnnotatedString(HtmlCompat.fromHtml(text, HtmlCompat.FROM_HTML_MODE_LEGACY), density) return spannableStringToAnnotatedString(HtmlCompat.fromHtml(text, HtmlCompat.FROM_HTML_MODE_LEGACY), density)
} }

View File

@ -137,7 +137,7 @@ fun saveAnimImage(uri: URI, encrypted: Boolean): CryptoFile? {
val destFileName = generateNewFileName("IMG", ext) val destFileName = generateNewFileName("IMG", ext)
val destFile = File(getAppFilePath(destFileName)) val destFile = File(getAppFilePath(destFileName))
if (encrypted) { if (encrypted) {
val args = writeCryptoFile(destFile.absolutePath, uri.inputStream()?.readAllBytes() ?: return null) val args = writeCryptoFile(destFile.absolutePath, uri.inputStream()?.readBytes() ?: return null)
CryptoFile(destFileName, args) CryptoFile(destFileName, args)
} else { } else {
Files.copy(uri.inputStream(), destFile.toPath()) Files.copy(uri.inputStream(), destFile.toPath())

View File

@ -11,8 +11,7 @@ import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import chat.simplex.common.model.ChatModel import chat.simplex.common.model.ChatModel
import chat.simplex.common.model.PendingContactConnection import chat.simplex.common.model.PendingContactConnection
import chat.simplex.common.views.helpers.ModalManager import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.helpers.withApi
import chat.simplex.common.views.usersettings.UserAddressView import chat.simplex.common.views.usersettings.UserAddressView
import chat.simplex.res.MR import chat.simplex.res.MR
@ -24,7 +23,7 @@ enum class CreateLinkTab {
fun CreateLinkView(m: ChatModel, initialSelection: CreateLinkTab) { fun CreateLinkView(m: ChatModel, initialSelection: CreateLinkTab) {
val selection = remember { mutableStateOf(initialSelection) } val selection = remember { mutableStateOf(initialSelection) }
val connReqInvitation = rememberSaveable { m.connReqInv } val connReqInvitation = rememberSaveable { m.connReqInv }
val contactConnection: MutableState<PendingContactConnection?> = rememberSaveable { mutableStateOf(null) } val contactConnection: MutableState<PendingContactConnection?> = rememberSaveable(stateSaver = serializableSaver()) { mutableStateOf(null) }
val creatingConnReq = rememberSaveable { mutableStateOf(false) } val creatingConnReq = rememberSaveable { mutableStateOf(false) }
LaunchedEffect(selection.value) { LaunchedEffect(selection.value) {
if ( if (

View File

@ -170,9 +170,8 @@ actual fun isAnimImage(uri: URI, drawable: Any?): Boolean {
return path.endsWith(".gif") || path.endsWith(".webp") return path.endsWith(".gif") || path.endsWith(".webp")
} }
@Suppress("NewApi")
actual fun loadImageBitmap(inputStream: InputStream): ImageBitmap = actual fun loadImageBitmap(inputStream: InputStream): ImageBitmap =
Image.makeFromEncoded(inputStream.readAllBytes()).toComposeImageBitmap() Image.makeFromEncoded(inputStream.readBytes()).toComposeImageBitmap()
// https://stackoverflow.com/a/68926993 // https://stackoverflow.com/a/68926993
fun BufferedImage.rotate(angle: Double): BufferedImage { fun BufferedImage.rotate(angle: Double): BufferedImage {

View File

@ -189,12 +189,11 @@ fun startServer(onResponse: (WVAPIMessage) -> Unit): NanoWSD {
val server = object: NanoWSD(SERVER_HOST, SERVER_PORT) { val server = object: NanoWSD(SERVER_HOST, SERVER_PORT) {
override fun openWebSocket(session: IHTTPSession): WebSocket = MyWebSocket(onResponse, session) override fun openWebSocket(session: IHTTPSession): WebSocket = MyWebSocket(onResponse, session)
@Suppress("NewApi")
fun resourcesToResponse(path: String): Response { fun resourcesToResponse(path: String): Response {
val uri = Class.forName("chat.simplex.common.AppKt").getResource("/assets/www$path") ?: return resourceNotFound val uri = Class.forName("chat.simplex.common.AppKt").getResource("/assets/www$path") ?: return resourceNotFound
val response = newFixedLengthResponse( val response = newFixedLengthResponse(
Status.OK, getMimeTypeForFile(uri.file), Status.OK, getMimeTypeForFile(uri.file),
uri.openStream().readAllBytes() uri.openStream().readBytes()
) )
response.setKeepAlive(true) response.setKeepAlive(true)
response.setUseGzip(true) response.setUseGzip(true)

View File

@ -102,7 +102,7 @@ DOCKER_BUILDKIT=1 docker build --output ~/.local/bin .
#### In any OS #### In any OS
1. Install [Haskell GHCup](https://www.haskell.org/ghcup/), GHC 9.6.2 and cabal 3.10.1.0: 1. Install [Haskell GHCup](https://www.haskell.org/ghcup/), GHC 9.6.3 and cabal 3.10.1.0:
```shell ```shell
curl --proto '=https' --tlsv1.2 -sSf https://get-ghcup.haskell.org | sh curl --proto '=https' --tlsv1.2 -sSf https://get-ghcup.haskell.org | sh

View File

@ -30,29 +30,29 @@ You will have to add `/opt/homebrew/opt/openssl@1.1/bin` to your PATH in order t
**In simplex-chat repo** **In simplex-chat repo**
- `stable` - stable release of the apps, can be used for updates to the previous stable release (GHC 9.6.2). - `stable` - stable release of the apps, can be used for updates to the previous stable release (GHC 9.6.3).
- `stable-android` - used to build stable Android core library with Nix (GHC 8.10.7). - `stable-android` - used to build stable Android core library with Nix (GHC 8.10.7) - only for Android armv7a.
- `stable-ios` - used to build stable iOS core library with Nix (GHC 8.10.7) this branch should be the same as `stable-android` except Nix configuration files. - `stable-ios` - used to build stable iOS core library with Nix (GHC 8.10.7) this branch should be the same as `stable-android` except Nix configuration files. Deprecated.
- `master` - branch for beta version releases (GHC 9.6.2). - `master` - branch for beta version releases (GHC 9.6.3).
- `master-ghc8107` - branch for beta version releases (GHC 8.10.7). - `master-ghc8107` - branch for beta version releases (GHC 8.10.7). Deprecated.
- `master-android` - used to build beta Android core library with Nix (GHC 8.10.7), same as `master-ghc8107` - `master-android` - used to build beta Android core library with Nix (GHC 8.10.7) - only for Android armv7a.
- `master-ios` - used to build beta iOS core library with Nix (GHC 8.10.7). - `master-ios` - used to build beta iOS core library with Nix (GHC 8.10.7). Deprecated.
- `windows-ghc8107` - branch for windows core library build (GHC 8.10.7). - `windows-ghc8107` - branch for windows core library build (GHC 8.10.7). Deprecated?
`master-ios` and `windows-ghc8107` branches should be the same as `master-ghc8107` except Nix configuration files. `master-ios` and `windows-ghc8107` branches should be the same as `master-ghc8107` except Nix configuration files.
**In simplexmq repo** **In simplexmq repo**
- `master` - uses GHC 9.6.2 its commit should be used in `master` branch of simplex-chat repo. - `master` - uses GHC 9.6.3 its commit should be used in `master` branch of simplex-chat repo.
- `master-ghc8107` - its commit should be used in `master-android` (and `master-ios`) branch of simplex-chat repo. - `master-ghc8107` - its commit should be used in `master-android` (and `master-ios`) branch of simplex-chat repo. Deprecated.
## Development & release process ## Development & release process
@ -61,53 +61,44 @@ 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. 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 core libraries for Android, iOS and windows: 3. To build core libraries for Android, iOS and windows:
- merge `master` branch to `master-ghc8107` branch. - merge `master` branch to `master-android` 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 code to be compatible with GHC 8.10.7 (see below).
- push to GitHub. - push to GitHub.
4. To build Android core library, merge `master-ghc8107` branch to `master-android` branch, and push to GitHub. 4. All libraries should be built from `master` branch, Android armv7a - from `master-android` branch.
5. To build iOS core library, merge `master-ghc8107` branch to `master-ios` 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.
6. To build windows core library, merge `master-ghc8107` branch to `windows-ghc8107` branch, and push to GitHub. 6. After the public release to App Store and Play Store, merge:
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 `stable`
- `master` to `master-ghc8107` (and compile/update code) - `master` to `master-android` (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-android` to `stable-android`
- `master-ios` to `stable-ios`
9. Independently, `master` branch of simplexmq repo should be merged to `stable` branch on stable releases. 7. 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 ## Differences between GHC 8.10.7 and GHC 9.6.3
1. The main difference is related to `DuplicateRecordFields` extension. 1. The main difference is related to `DuplicateRecordFields` extension.
It is no longer possible in GHC 9.6.2 to specify type when using selectors, instead OverloadedRecordDot extension and syntax are used that need to be removed in GHC 8.10.7: It is no longer possible in GHC 9.6.3 to specify type when using selectors, instead OverloadedRecordDot extension and syntax are used that need to be removed in GHC 8.10.7:
```haskell ```haskell
{-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE DuplicateRecordFields #-}
-- use this in GHC 9.6.2 when needed -- use this in GHC 9.6.3 when needed
{-# LANGUAGE OverloadedRecordDot #-} {-# LANGUAGE OverloadedRecordDot #-}
-- GHC 9.6.2 syntax -- GHC 9.6.3 syntax
let x = record.field let x = record.field
-- GHC 8.10.7 syntax removed in GHC 9.6.2 -- GHC 8.10.7 syntax removed in GHC 9.6.3
let x = field (record :: Record) let x = field (record :: Record)
``` ```
It is still possible to specify type when using record update syntax, use this pragma to suppress compiler warning: It is still possible to specify type when using record update syntax, use this pragma to suppress compiler warning:
```haskell ```haskell
-- use this in GHC 9.6.2 when needed -- use this in GHC 9.6.3 when needed
{-# OPTIONS_GHC -fno-warn-ambiguous-fields #-} {-# OPTIONS_GHC -fno-warn-ambiguous-fields #-}
let r' = (record :: Record) {field = value} let r' = (record :: Record) {field = value}
@ -116,7 +107,7 @@ let r' = (record :: Record) {field = value}
2. Most monad functions now have to be imported from `Control.Monad`, and not from specific monad modules (e.g. `Control.Monad.Except`). 2. Most monad functions now have to be imported from `Control.Monad`, and not from specific monad modules (e.g. `Control.Monad.Except`).
```haskell ```haskell
-- use this in GHC 9.6.2 when needed -- use this in GHC 9.6.3 when needed
import Control.Monad import Control.Monad
``` ```

View File

@ -8,7 +8,7 @@ function readlink() {
OS=linux OS=linux
ARCH=${1:-`uname -a | rev | cut -d' ' -f2 | rev`} ARCH=${1:-`uname -a | rev | cut -d' ' -f2 | rev`}
GHC_VERSION=9.6.2 GHC_VERSION=9.6.3
if [ "$ARCH" == "aarch64" ]; then if [ "$ARCH" == "aarch64" ]; then
COMPOSE_ARCH=arm64 COMPOSE_ARCH=arm64

View File

@ -5,7 +5,7 @@ set -e
OS=mac OS=mac
ARCH="${1:-`uname -a | rev | cut -d' ' -f1 | rev`}" ARCH="${1:-`uname -a | rev | cut -d' ' -f1 | rev`}"
COMPOSE_ARCH=$ARCH COMPOSE_ARCH=$ARCH
GHC_VERSION=9.6.2 GHC_VERSION=9.6.3
if [ "$ARCH" == "arm64" ]; then if [ "$ARCH" == "arm64" ]; then
ARCH=aarch64 ARCH=aarch64

View File

@ -121,7 +121,8 @@ library
Simplex.Chat.Migrations.M20231010_member_settings Simplex.Chat.Migrations.M20231010_member_settings
Simplex.Chat.Migrations.M20231019_indexes Simplex.Chat.Migrations.M20231019_indexes
Simplex.Chat.Migrations.M20231030_xgrplinkmem_received Simplex.Chat.Migrations.M20231030_xgrplinkmem_received
Simplex.Chat.Migrations.M20231031_remote_controller Simplex.Chat.Migrations.M20231107_indexes
Simplex.Chat.Migrations.M20231114_remote_controller
Simplex.Chat.Mobile Simplex.Chat.Mobile
Simplex.Chat.Mobile.File Simplex.Chat.Mobile.File
Simplex.Chat.Mobile.Shared Simplex.Chat.Mobile.Shared

File diff suppressed because one or more lines are too long

View File

@ -344,6 +344,7 @@ data ChatCommand
| APIConnectPlan UserId AConnectionRequestUri | APIConnectPlan UserId AConnectionRequestUri
| APIConnect UserId IncognitoEnabled (Maybe AConnectionRequestUri) | APIConnect UserId IncognitoEnabled (Maybe AConnectionRequestUri)
| Connect IncognitoEnabled (Maybe AConnectionRequestUri) | Connect IncognitoEnabled (Maybe AConnectionRequestUri)
| APIConnectContactViaAddress UserId IncognitoEnabled ContactId
| ConnectSimplex IncognitoEnabled -- UserId (not used in UI) | ConnectSimplex IncognitoEnabled -- UserId (not used in UI)
| DeleteContact ContactName | DeleteContact ContactName
| ClearContact ContactName | ClearContact ContactName
@ -541,6 +542,7 @@ data ChatResponse
| CRConnectionPlan {user :: User, connectionPlan :: ConnectionPlan} | CRConnectionPlan {user :: User, connectionPlan :: ConnectionPlan}
| CRSentConfirmation {user :: User} | CRSentConfirmation {user :: User}
| CRSentInvitation {user :: User, customUserProfile :: Maybe Profile} | CRSentInvitation {user :: User, customUserProfile :: Maybe Profile}
| CRSentInvitationToContact {user :: User, contact :: Contact, customUserProfile :: Maybe Profile}
| CRContactUpdated {user :: User, fromContact :: Contact, toContact :: Contact} | CRContactUpdated {user :: User, fromContact :: Contact, toContact :: Contact}
| CRGroupMemberUpdated {user :: User, groupInfo :: GroupInfo, fromMember :: GroupMember, toMember :: GroupMember} | CRGroupMemberUpdated {user :: User, groupInfo :: GroupInfo, fromMember :: GroupMember, toMember :: GroupMember}
| CRContactsMerged {user :: User, intoContact :: Contact, mergedContact :: Contact, updatedContact :: Contact} | CRContactsMerged {user :: User, intoContact :: Contact, mergedContact :: Contact, updatedContact :: Contact}
@ -724,6 +726,7 @@ data ContactAddressPlan
| CAPConnectingConfirmReconnect | CAPConnectingConfirmReconnect
| CAPConnectingProhibit {contact :: Contact} | CAPConnectingProhibit {contact :: Contact}
| CAPKnown {contact :: Contact} | CAPKnown {contact :: Contact}
| CAPContactViaAddress {contact :: Contact}
deriving (Show) deriving (Show)
data GroupLinkPlan data GroupLinkPlan
@ -744,6 +747,7 @@ connectionPlanProceed = \case
CAPOk -> True CAPOk -> True
CAPOwnLink -> True CAPOwnLink -> True
CAPConnectingConfirmReconnect -> True CAPConnectingConfirmReconnect -> True
CAPContactViaAddress _ -> True
_ -> False _ -> False
CPGroupLink glp -> case glp of CPGroupLink glp -> case glp of
GLPOk -> True GLPOk -> True

View File

@ -0,0 +1,18 @@
{-# LANGUAGE QuasiQuotes #-}
module Simplex.Chat.Migrations.M20231107_indexes where
import Database.SQLite.Simple (Query)
import Database.SQLite.Simple.QQ (sql)
m20231107_indexes :: Query
m20231107_indexes =
[sql|
CREATE INDEX idx_contact_profiles_contact_link ON contact_profiles(user_id, contact_link);
|]
down_m20231107_indexes :: Query
down_m20231107_indexes =
[sql|
DROP INDEX idx_contact_profiles_contact_link;
|]

View File

@ -1,12 +1,12 @@
{-# LANGUAGE QuasiQuotes #-} {-# LANGUAGE QuasiQuotes #-}
module Simplex.Chat.Migrations.M20231031_remote_controller where module Simplex.Chat.Migrations.M20231114_remote_controller where
import Database.SQLite.Simple (Query) import Database.SQLite.Simple (Query)
import Database.SQLite.Simple.QQ (sql) import Database.SQLite.Simple.QQ (sql)
m20231031_remote_controller :: Query m20231114_remote_controller :: Query
m20231031_remote_controller = m20231114_remote_controller =
[sql| [sql|
CREATE TABLE remote_hosts ( -- hosts known to a controlling app CREATE TABLE remote_hosts ( -- hosts known to a controlling app
remote_host_id INTEGER PRIMARY KEY AUTOINCREMENT, remote_host_id INTEGER PRIMARY KEY AUTOINCREMENT,
@ -25,8 +25,8 @@ CREATE TABLE remote_controllers ( -- controllers known to a hosting app
); );
|] |]
down_m20231031_remote_controller :: Query down_m20231114_remote_controller :: Query
down_m20231031_remote_controller = down_m20231114_remote_controller =
[sql| [sql|
DROP TABLE remote_hosts; DROP TABLE remote_hosts;
DROP TABLE remote_controllers; DROP TABLE remote_controllers;

View File

@ -764,3 +764,7 @@ CREATE INDEX idx_connections_via_contact_uri_hash ON connections(
user_id, user_id,
via_contact_uri_hash via_contact_uri_hash
); );
CREATE INDEX idx_contact_profiles_contact_link ON contact_profiles(
user_id,
contact_link
);

View File

@ -23,6 +23,7 @@ module Simplex.Chat.Store.Direct
createDirectConnection, createDirectConnection,
createIncognitoProfile, createIncognitoProfile,
createConnReqConnection, createConnReqConnection,
createAddressContactConnection,
getProfileById, getProfileById,
getConnReqContactXContactId, getConnReqContactXContactId,
getContactByConnReqHash, getContactByConnReqHash,
@ -119,6 +120,12 @@ deletePendingContactConnection db userId connId =
|] |]
(userId, connId, ConnContact) (userId, connId, ConnContact)
createAddressContactConnection :: DB.Connection -> User -> Contact -> ConnId -> ConnReqUriHash -> XContactId -> Maybe Profile -> SubscriptionMode -> ExceptT StoreError IO Contact
createAddressContactConnection db user@User {userId} Contact {contactId} acId cReqHash xContactId incognitoProfile subMode = do
PendingContactConnection {pccConnId} <- liftIO $ createConnReqConnection db userId acId cReqHash xContactId incognitoProfile Nothing subMode
liftIO $ DB.execute db "UPDATE connections SET contact_id = ? WHERE connection_id = ?" (contactId, pccConnId)
getContact db user contactId
createConnReqConnection :: DB.Connection -> UserId -> ConnId -> ConnReqUriHash -> XContactId -> Maybe Profile -> Maybe GroupLinkId -> SubscriptionMode -> IO PendingContactConnection createConnReqConnection :: DB.Connection -> UserId -> ConnId -> ConnReqUriHash -> XContactId -> Maybe Profile -> Maybe GroupLinkId -> SubscriptionMode -> IO PendingContactConnection
createConnReqConnection db userId acId cReqHash xContactId incognitoProfile groupLinkId subMode = do createConnReqConnection db userId acId cReqHash xContactId incognitoProfile groupLinkId subMode = do
createdAt <- getCurrentTime createdAt <- getCurrentTime
@ -195,12 +202,13 @@ createIncognitoProfile db User {userId} p = do
createDirectContact :: DB.Connection -> User -> Connection -> Profile -> ExceptT StoreError IO Contact createDirectContact :: DB.Connection -> User -> Connection -> Profile -> ExceptT StoreError IO Contact
createDirectContact db user@User {userId} conn@Connection {connId, localAlias} p@Profile {preferences} = do createDirectContact db user@User {userId} conn@Connection {connId, localAlias} p@Profile {preferences} = do
createdAt <- liftIO getCurrentTime currentTs <- liftIO getCurrentTime
(localDisplayName, contactId, profileId) <- createContact_ db userId connId p localAlias Nothing createdAt (Just createdAt) (localDisplayName, contactId, profileId) <- createContact_ db userId p localAlias Nothing currentTs (Just currentTs)
liftIO $ DB.execute db "UPDATE connections SET contact_id = ?, updated_at = ? WHERE connection_id = ?" (contactId, currentTs, connId)
let profile = toLocalProfile profileId p localAlias let profile = toLocalProfile profileId p localAlias
userPreferences = emptyChatPrefs userPreferences = emptyChatPrefs
mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito conn mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito conn
pure $ Contact {contactId, localDisplayName, profile, activeConn = Just conn, viaGroup = Nothing, contactUsed = False, contactStatus = CSActive, chatSettings = defaultChatSettings, userPreferences, mergedPreferences, createdAt, updatedAt = createdAt, chatTs = Just createdAt, contactGroupMemberId = Nothing, contactGrpInvSent = False} pure $ Contact {contactId, localDisplayName, profile, activeConn = Just conn, viaGroup = Nothing, contactUsed = False, contactStatus = CSActive, chatSettings = defaultChatSettings, userPreferences, mergedPreferences, createdAt = currentTs, updatedAt = currentTs, chatTs = Just currentTs, contactGroupMemberId = Nothing, contactGrpInvSent = False}
deleteContactConnectionsAndFiles :: DB.Connection -> UserId -> Contact -> IO () deleteContactConnectionsAndFiles :: DB.Connection -> UserId -> Contact -> IO ()
deleteContactConnectionsAndFiles db userId Contact {contactId} = do deleteContactConnectionsAndFiles db userId Contact {contactId} = do
@ -678,17 +686,20 @@ getContact_ db user@User {userId} contactId deleted =
LEFT JOIN connections c ON c.contact_id = ct.contact_id LEFT JOIN connections c ON c.contact_id = ct.contact_id
WHERE ct.user_id = ? AND ct.contact_id = ? WHERE ct.user_id = ? AND ct.contact_id = ?
AND ct.deleted = ? AND ct.deleted = ?
AND c.connection_id = ( AND (
SELECT cc_connection_id FROM ( c.connection_id = (
SELECT SELECT cc_connection_id FROM (
cc.connection_id AS cc_connection_id, SELECT
cc.created_at AS cc_created_at, cc.connection_id AS cc_connection_id,
(CASE WHEN cc.conn_status = ? OR cc.conn_status = ? THEN 1 ELSE 0 END) AS cc_conn_status_ord cc.created_at AS cc_created_at,
FROM connections cc (CASE WHEN cc.conn_status = ? OR cc.conn_status = ? THEN 1 ELSE 0 END) AS cc_conn_status_ord
WHERE cc.user_id = ct.user_id AND cc.contact_id = ct.contact_id FROM connections cc
ORDER BY cc_conn_status_ord DESC, cc_created_at DESC WHERE cc.user_id = ct.user_id AND cc.contact_id = ct.contact_id
LIMIT 1 ORDER BY cc_conn_status_ord DESC, cc_created_at DESC
LIMIT 1
)
) )
OR c.connection_id IS NULL
) )
|] |]
(userId, contactId, deleted, ConnReady, ConnSndReady) (userId, contactId, deleted, ConnReady, ConnSndReady)
@ -712,7 +723,7 @@ getPendingContactConnections db User {userId} = do
|] |]
[":user_id" := userId, ":conn_type" := ConnContact] [":user_id" := userId, ":conn_type" := ConnContact]
getContactConnections :: DB.Connection -> UserId -> Contact -> ExceptT StoreError IO [Connection] getContactConnections :: DB.Connection -> UserId -> Contact -> IO [Connection]
getContactConnections db userId Contact {contactId} = getContactConnections db userId Contact {contactId} =
connections =<< liftIO getConnections_ connections =<< liftIO getConnections_
where where
@ -728,7 +739,7 @@ getContactConnections db userId Contact {contactId} =
WHERE c.user_id = ? AND ct.user_id = ? AND ct.contact_id = ? WHERE c.user_id = ? AND ct.user_id = ? AND ct.contact_id = ?
|] |]
(userId, userId, contactId) (userId, userId, contactId)
connections [] = throwError $ SEContactNotFound contactId connections [] = pure []
connections rows = pure $ map toConnection rows connections rows = pure $ map toConnection rows
getConnectionById :: DB.Connection -> User -> Int64 -> ExceptT StoreError IO Connection getConnectionById :: DB.Connection -> User -> Int64 -> ExceptT StoreError IO Connection

View File

@ -1054,7 +1054,8 @@ createIntroReMember db user@User {userId} gInfo@GroupInfo {groupId} _host@GroupM
Just (directCmdId, directAgentConnId) -> do Just (directCmdId, directAgentConnId) -> do
Connection {connId = directConnId} <- liftIO $ createConnection_ db userId ConnContact Nothing directAgentConnId mcvr memberContactId Nothing customUserProfileId cLevel currentTs subMode Connection {connId = directConnId} <- liftIO $ createConnection_ db userId ConnContact Nothing directAgentConnId mcvr memberContactId Nothing customUserProfileId cLevel currentTs subMode
liftIO $ setCommandConnId db user directCmdId directConnId liftIO $ setCommandConnId db user directCmdId directConnId
(localDisplayName, contactId, memProfileId) <- createContact_ db userId directConnId memberProfile "" (Just groupId) currentTs Nothing (localDisplayName, contactId, memProfileId) <- createContact_ db userId memberProfile "" (Just groupId) currentTs Nothing
liftIO $ DB.execute db "UPDATE connections SET contact_id = ?, updated_at = ? WHERE connection_id = ?" (contactId, currentTs, directConnId)
pure $ NewGroupMember {memInfo, memCategory = GCPreMember, memStatus = GSMemIntroduced, memInvitedBy = IBUnknown, localDisplayName, memContactId = Just contactId, memProfileId} pure $ NewGroupMember {memInfo, memCategory = GCPreMember, memStatus = GSMemIntroduced, memInvitedBy = IBUnknown, localDisplayName, memContactId = Just contactId, memProfileId}
Nothing -> do Nothing -> do
(localDisplayName, memProfileId) <- createNewMemberProfile_ db user memberProfile currentTs (localDisplayName, memProfileId) <- createNewMemberProfile_ db user memberProfile currentTs

View File

@ -87,7 +87,8 @@ import Simplex.Chat.Migrations.M20231009_via_group_link_uri_hash
import Simplex.Chat.Migrations.M20231010_member_settings import Simplex.Chat.Migrations.M20231010_member_settings
import Simplex.Chat.Migrations.M20231019_indexes import Simplex.Chat.Migrations.M20231019_indexes
import Simplex.Chat.Migrations.M20231030_xgrplinkmem_received import Simplex.Chat.Migrations.M20231030_xgrplinkmem_received
import Simplex.Chat.Migrations.M20231031_remote_controller import Simplex.Chat.Migrations.M20231107_indexes
import Simplex.Chat.Migrations.M20231114_remote_controller
import Simplex.Messaging.Agent.Store.SQLite.Migrations (Migration (..)) import Simplex.Messaging.Agent.Store.SQLite.Migrations (Migration (..))
schemaMigrations :: [(String, Query, Maybe Query)] schemaMigrations :: [(String, Query, Maybe Query)]
@ -175,7 +176,8 @@ schemaMigrations =
("20231010_member_settings", m20231010_member_settings, Just down_m20231010_member_settings), ("20231010_member_settings", m20231010_member_settings, Just down_m20231010_member_settings),
("20231019_indexes", m20231019_indexes, Just down_m20231019_indexes), ("20231019_indexes", m20231019_indexes, Just down_m20231019_indexes),
("20231030_xgrplinkmem_received", m20231030_xgrplinkmem_received, Just down_m20231030_xgrplinkmem_received), ("20231030_xgrplinkmem_received", m20231030_xgrplinkmem_received, Just down_m20231030_xgrplinkmem_received),
("20231031_remote_controller", m20231031_remote_controller, Just down_m20231031_remote_controller) ("20231107_indexes", m20231107_indexes, Just down_m20231107_indexes),
("20231114_remote_controller", m20231114_remote_controller, Just down_m20231114_remote_controller)
] ]
-- | The list of migrations in ascending order by date -- | The list of migrations in ascending order by date

View File

@ -5,6 +5,7 @@
{-# LANGUAGE QuasiQuotes #-} {-# LANGUAGE QuasiQuotes #-}
{-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TemplateHaskell #-} {-# LANGUAGE TemplateHaskell #-}
{-# LANGUAGE TupleSections #-}
{-# LANGUAGE TypeApplications #-} {-# LANGUAGE TypeApplications #-}
{-# LANGUAGE TypeOperators #-} {-# LANGUAGE TypeOperators #-}
@ -44,6 +45,7 @@ module Simplex.Chat.Store.Profiles
getUserAddress, getUserAddress,
getUserContactLinkById, getUserContactLinkById,
getUserContactLinkByConnReq, getUserContactLinkByConnReq,
getContactWithoutConnViaAddress,
updateUserAddressAutoAccept, updateUserAddressAutoAccept,
getProtocolServers, getProtocolServers,
overwriteProtocolServers, overwriteProtocolServers,
@ -87,7 +89,7 @@ import Simplex.Messaging.Encoding.String
import Simplex.Messaging.Parsers (defaultJSON) import Simplex.Messaging.Parsers (defaultJSON)
import Simplex.Messaging.Protocol (BasicAuth (..), ProtoServerWithAuth (..), ProtocolServer (..), ProtocolTypeI (..), SubscriptionMode) import Simplex.Messaging.Protocol (BasicAuth (..), ProtoServerWithAuth (..), ProtocolServer (..), ProtocolTypeI (..), SubscriptionMode)
import Simplex.Messaging.Transport.Client (TransportHost) import Simplex.Messaging.Transport.Client (TransportHost)
import Simplex.Messaging.Util (safeDecodeUtf8) import Simplex.Messaging.Util (safeDecodeUtf8, eitherToMaybe)
createUserRecord :: DB.Connection -> AgentUserId -> Profile -> Bool -> ExceptT StoreError IO User createUserRecord :: DB.Connection -> AgentUserId -> Profile -> Bool -> ExceptT StoreError IO User
createUserRecord db auId p activeUser = createUserRecordAt db auId p activeUser =<< liftIO getCurrentTime createUserRecord db auId p activeUser = createUserRecordAt db auId p activeUser =<< liftIO getCurrentTime
@ -453,6 +455,21 @@ getUserContactLinkByConnReq db User {userId} (cReqSchema1, cReqSchema2) =
|] |]
(userId, cReqSchema1, cReqSchema2) (userId, cReqSchema1, cReqSchema2)
getContactWithoutConnViaAddress :: DB.Connection -> User -> (ConnReqContact, ConnReqContact) -> IO (Maybe Contact)
getContactWithoutConnViaAddress db user@User {userId} (cReqSchema1, cReqSchema2) = do
ctId_ <- maybeFirstRow fromOnly $
DB.query
db
[sql|
SELECT ct.contact_id
FROM contacts ct
JOIN contact_profiles cp ON cp.contact_profile_id = ct.contact_profile_id
LEFT JOIN connections c ON c.contact_id = ct.contact_id
WHERE cp.user_id = ? AND cp.contact_link IN (?,?) AND c.connection_id IS NULL
|]
(userId, cReqSchema1, cReqSchema2)
maybe (pure Nothing) (fmap eitherToMaybe . runExceptT . getContact db user) ctId_
updateUserAddressAutoAccept :: DB.Connection -> User -> Maybe AutoAccept -> ExceptT StoreError IO UserContactLink updateUserAddressAutoAccept :: DB.Connection -> User -> Maybe AutoAccept -> ExceptT StoreError IO UserContactLink
updateUserAddressAutoAccept db user@User {userId} autoAccept = do updateUserAddressAutoAccept db user@User {userId} autoAccept = do
link <- getUserAddress db user link <- getUserAddress db user

View File

@ -217,8 +217,13 @@ setCommandConnId db User {userId} cmdId connId = do
|] |]
(connId, updatedAt, userId, cmdId) (connId, updatedAt, userId, cmdId)
createContact_ :: DB.Connection -> UserId -> Int64 -> Profile -> LocalAlias -> Maybe Int64 -> UTCTime -> Maybe UTCTime -> ExceptT StoreError IO (Text, ContactId, ProfileId) createContact :: DB.Connection -> User -> Profile -> ExceptT StoreError IO ()
createContact_ db userId connId Profile {displayName, fullName, image, contactLink, preferences} localAlias viaGroup currentTs chatTs = createContact db User {userId} profile = do
currentTs <- liftIO getCurrentTime
void $ createContact_ db userId profile "" Nothing currentTs Nothing
createContact_ :: DB.Connection -> UserId -> Profile -> LocalAlias -> Maybe Int64 -> UTCTime -> Maybe UTCTime -> ExceptT StoreError IO (Text, ContactId, ProfileId)
createContact_ db userId Profile {displayName, fullName, image, contactLink, preferences} localAlias viaGroup currentTs chatTs =
ExceptT . withLocalDisplayName db userId displayName $ \ldn -> do ExceptT . withLocalDisplayName db userId displayName $ \ldn -> do
DB.execute DB.execute
db db
@ -230,7 +235,6 @@ createContact_ db userId connId Profile {displayName, fullName, image, contactLi
"INSERT INTO contacts (contact_profile_id, local_display_name, user_id, via_group, created_at, updated_at, chat_ts) VALUES (?,?,?,?,?,?,?)" "INSERT INTO contacts (contact_profile_id, local_display_name, user_id, via_group, created_at, updated_at, chat_ts) VALUES (?,?,?,?,?,?,?)"
(profileId, ldn, userId, viaGroup, currentTs, currentTs, chatTs) (profileId, ldn, userId, viaGroup, currentTs, currentTs, chatTs)
contactId <- insertedRowId db contactId <- insertedRowId db
DB.execute db "UPDATE connections SET contact_id = ?, updated_at = ? WHERE connection_id = ?" (contactId, currentTs, connId)
pure $ Right (ldn, contactId, profileId) pure $ Right (ldn, contactId, profileId)
deleteUnusedIncognitoProfileById_ :: DB.Connection -> User -> ProfileId -> IO () deleteUnusedIncognitoProfileById_ :: DB.Connection -> User -> ProfileId -> IO ()

View File

@ -161,6 +161,7 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe
CRConnectionPlan u connectionPlan -> ttyUser u $ viewConnectionPlan connectionPlan CRConnectionPlan u connectionPlan -> ttyUser u $ viewConnectionPlan connectionPlan
CRSentConfirmation u -> ttyUser u ["confirmation sent!"] CRSentConfirmation u -> ttyUser u ["confirmation sent!"]
CRSentInvitation u customUserProfile -> ttyUser u $ viewSentInvitation customUserProfile testView CRSentInvitation u customUserProfile -> ttyUser u $ viewSentInvitation customUserProfile testView
CRSentInvitationToContact u _c customUserProfile -> ttyUser u $ viewSentInvitation customUserProfile testView
CRContactDeleted u c -> ttyUser u [ttyContact' c <> ": contact is deleted"] CRContactDeleted u c -> ttyUser u [ttyContact' c <> ": contact is deleted"]
CRContactDeletedByContact u c -> ttyUser u [ttyFullContact c <> " deleted contact with you"] CRContactDeletedByContact u c -> ttyUser u [ttyFullContact c <> " deleted contact with you"]
CRChatCleared u chatInfo -> ttyUser u $ viewChatCleared chatInfo CRChatCleared u chatInfo -> ttyUser u $ viewChatCleared chatInfo
@ -1343,6 +1344,7 @@ viewConnectionPlan = \case
[ ctAddr ("known contact " <> ttyContact' ct), [ ctAddr ("known contact " <> ttyContact' ct),
"use " <> ttyToContact' ct <> highlight' "<message>" <> " to send messages" "use " <> ttyToContact' ct <> highlight' "<message>" <> " to send messages"
] ]
CAPContactViaAddress ct -> [ctAddr ("known contact without connection " <> ttyContact' ct)]
where where
ctAddr = ("contact address: " <>) ctAddr = ("contact address: " <>)
CPGroupLink glp -> case glp of CPGroupLink glp -> case glp of

View File

@ -7,10 +7,16 @@ import ChatClient
import ChatTests.Utils import ChatTests.Utils
import Control.Concurrent (threadDelay) import Control.Concurrent (threadDelay)
import Control.Concurrent.Async (concurrently_) import Control.Concurrent.Async (concurrently_)
import Control.Monad.Except
import qualified Data.Attoparsec.ByteString.Char8 as A
import qualified Data.ByteString.Char8 as B
import qualified Data.Text as T import qualified Data.Text as T
import Simplex.Chat.Types (ConnStatus (..), GroupMemberRole (..), Profile (..)) import Simplex.Chat.Types (ConnStatus (..), GroupMemberRole (..), Profile (..))
import System.Directory (copyFile, createDirectoryIfMissing) import System.Directory (copyFile, createDirectoryIfMissing)
import Test.Hspec import Test.Hspec
import Simplex.Chat.Store.Shared (createContact)
import Control.Monad
import Simplex.Messaging.Encoding.String (StrEncoding(..))
chatProfileTests :: SpecWith FilePath chatProfileTests :: SpecWith FilePath
chatProfileTests = do chatProfileTests = do
@ -33,6 +39,7 @@ chatProfileTests = do
it "own contact address" testPlanAddressOwn it "own contact address" testPlanAddressOwn
it "connecting via contact address" testPlanAddressConnecting it "connecting via contact address" testPlanAddressConnecting
it "re-connect with deleted contact" testPlanAddressContactDeletedReconnected it "re-connect with deleted contact" testPlanAddressContactDeletedReconnected
it "contact via address" testPlanAddressContactViaAddress
describe "incognito" $ do describe "incognito" $ do
it "connect incognito via invitation link" testConnectIncognitoInvitationLink it "connect incognito via invitation link" testConnectIncognitoInvitationLink
it "connect incognito via contact address" testConnectIncognitoContactAddress it "connect incognito via contact address" testConnectIncognitoContactAddress
@ -755,6 +762,66 @@ testPlanAddressContactDeletedReconnected =
bob <## "contact address: known contact alice_1" bob <## "contact address: known contact alice_1"
bob <## "use @alice_1 <message> to send messages" bob <## "use @alice_1 <message> to send messages"
testPlanAddressContactViaAddress :: HasCallStack => FilePath -> IO ()
testPlanAddressContactViaAddress =
testChat2 aliceProfile bobProfile $
\alice bob -> do
alice ##> "/ad"
cLink <- getContactLink alice True
alice ##> "/pa on" -- not necessary, without it bob would receive profile update removing contact link
alice <## "new contact address set"
case A.parseOnly strP (B.pack cLink) of
Left _ -> error "error parsing contact link"
Right cReq -> do
let profile = aliceProfile {contactLink = Just cReq}
void $ withCCUser bob $ \user -> withCCTransaction bob $ \db -> runExceptT $ createContact db user profile
bob @@@ [("@alice", "")]
bob ##> "/delete @alice"
bob <## "alice: contact is deleted"
void $ withCCUser bob $ \user -> withCCTransaction bob $ \db -> runExceptT $ createContact db user profile
bob @@@ [("@alice", "")]
bob ##> ("/_connect plan 1 " <> cLink)
bob <## "contact address: known contact without connection alice"
let cLinkSchema2 = linkAnotherSchema cLink
bob ##> ("/_connect plan 1 " <> cLinkSchema2)
bob <## "contact address: known contact without connection alice"
-- terminal api
bob ##> ("/c " <> cLink)
connecting alice bob
bob ##> "/_delete @2 notify=off"
bob <## "alice: contact is deleted"
alice ##> "/_delete @2 notify=off"
alice <## "bob: contact is deleted"
void $ withCCUser bob $ \user -> withCCTransaction bob $ \db -> runExceptT $ createContact db user profile
bob @@@ [("@alice", "")]
-- GUI api
bob ##> "/_connect contact 1 2"
connecting alice bob
where
connecting alice bob = do
bob <## "connection request sent!"
alice <## "bob (Bob) wants to connect to you!"
alice <## "to accept: /ac bob"
alice <## "to reject: /rc bob (the sender will NOT be notified)"
alice ##> "/ac bob"
alice <## "bob (Bob): accepting contact request..."
concurrently_
(bob <## "alice (Alice): contact is connected")
(alice <## "bob (Bob): contact is connected")
alice <##> bob
bob @@@ [("@alice", "hey")]
testConnectIncognitoInvitationLink :: HasCallStack => FilePath -> IO () testConnectIncognitoInvitationLink :: HasCallStack => FilePath -> IO ()
testConnectIncognitoInvitationLink = testChat3 aliceProfile bobProfile cathProfile $ testConnectIncognitoInvitationLink = testChat3 aliceProfile bobProfile cathProfile $
\alice bob cath -> do \alice bob cath -> do

View File

@ -438,6 +438,17 @@ getContactProfiles cc = do
profiles <- withTransaction (chatStore $ chatController cc) $ \db -> getUserContactProfiles db user profiles <- withTransaction (chatStore $ chatController cc) $ \db -> getUserContactProfiles db user
pure $ map (\Profile {displayName} -> displayName) profiles pure $ map (\Profile {displayName} -> displayName) profiles
withCCUser :: TestCC -> (User -> IO a) -> IO a
withCCUser cc action = do
user_ <- readTVarIO (currentUser $ chatController cc)
case user_ of
Nothing -> error "no user"
Just user -> action user
withCCTransaction :: TestCC -> (DB.Connection -> IO a) -> IO a
withCCTransaction cc action =
withTransaction (chatStore $ chatController cc) $ \db -> action db
getProfilePictureByName :: TestCC -> String -> IO (Maybe String) getProfilePictureByName :: TestCC -> String -> IO (Maybe String)
getProfilePictureByName cc displayName = getProfilePictureByName cc displayName =
withTransaction (chatStore $ chatController cc) $ \db -> withTransaction (chatStore $ chatController cc) $ \db ->