Compare commits

..

10 Commits

Author SHA1 Message Date
Evgeny Poberezkin c7f5443920 fix 2023-04-07 11:01:33 +01:00
Evgeny Poberezkin ccff79aa59 core: allow messages with contact requests 2023-04-07 10:15:19 +01:00
Evgeny Poberezkin ccb52e0acd ios: validate server protocol 2023-04-06 23:25:40 +01:00
Evgeny Poberezkin d84b30c071 core: update simplemq (preset xftp servers) 2023-04-06 23:16:16 +01:00
Evgeny Poberezkin 5ae0afe1fe ios: update servers API/UI (#2149)
* ios: update servers API/UI

* fix UI

* fix
2023-04-06 22:48:32 +01:00
Evgeny Poberezkin d250e503b0 core: update simplexmq (fix file reception on 32 bit CPUs) 2023-04-06 21:06:46 +01:00
Stanislav Dmitrenko afb0ae3d03 ios: video support (#2115)
* ios: video support

* made video experience prettier

* line reordering

* fix warning

* remove playback speed

* fullscreen player

* removed unused code

* fix conflict

* setting playing status better

* thumbnail dimensions and loading indicator

* fill under video

---------

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2023-04-06 18:26:48 +01:00
Evgeny Poberezkin 1a3f0bed47 core: update servers API to include XFTP servers, ios: generalize UI to manage servers (#2140)
* core: update servers API to include XFTP servers, ios: generalize UI to manage servers

* add test

* update migrations to pass tests

* fix readme

* update simplexmq
2023-04-05 21:59:12 +01:00
Stanislav Dmitrenko 1e280fb7e1 android: prevent possible race in chat items (#2148)
* android: prevent possible race in chat items

* change
2023-04-05 18:53:35 +01:00
Evgeny Poberezkin 6feac55380 core: update http2 library 2023-04-05 10:23:35 +01:00
56 changed files with 1288 additions and 479 deletions
@@ -192,10 +192,13 @@ class ChatModel(val controller: ChatController) {
// add to current chat
if (chatId.value == cInfo.id) {
withContext(Dispatchers.Main) {
if (chatItems.lastOrNull()?.id == ChatItem.TEMP_LIVE_CHAT_ITEM_ID) {
chatItems.add(kotlin.math.max(0, chatItems.lastIndex), cItem)
} else {
chatItems.add(cItem)
// Prevent situation when chat item already in the list received from backend
if (chatItems.none { it.id == cItem.id }) {
if (chatItems.lastOrNull()?.id == ChatItem.TEMP_LIVE_CHAT_ITEM_ID) {
chatItems.add(kotlin.math.max(0, chatItems.lastIndex), cItem)
} else {
chatItems.add(cItem)
}
}
}
}
@@ -222,19 +225,19 @@ class ChatModel(val controller: ChatController) {
res = true
}
// update current chat
if (chatId.value == cInfo.id) {
val itemIndex = chatItems.indexOfFirst { it.id == cItem.id }
if (itemIndex >= 0) {
chatItems[itemIndex] = cItem
return false
} else {
withContext(Dispatchers.Main) {
return if (chatId.value == cInfo.id) {
withContext(Dispatchers.Main) {
val itemIndex = chatItems.indexOfFirst { it.id == cItem.id }
if (itemIndex >= 0) {
chatItems[itemIndex] = cItem
false
} else {
chatItems.add(cItem)
true
}
return true
}
} else {
return res
res
}
}
+4 -2
View File
@@ -40,7 +40,8 @@ class AudioRecorder {
AVEncoderBitRateKey: 12000,
AVNumberOfChannelsKey: 1
]
audioRecorder = try AVAudioRecorder(url: getAppFilePath(fileName), settings: settings)
let url = getAppFilePath(fileName)
audioRecorder = try AVAudioRecorder(url: url, settings: settings)
audioRecorder?.record(forDuration: MAX_VOICE_MESSAGE_LENGTH)
await MainActor.run {
@@ -102,7 +103,8 @@ class AudioPlayer: NSObject, AVAudioPlayerDelegate {
}
func start(fileName: String) {
audioPlayer = try? AVAudioPlayer(contentsOf: getAppFilePath(fileName))
let url = getAppFilePath(fileName)
audioPlayer = try? AVAudioPlayer(contentsOf: url)
audioPlayer?.delegate = self
audioPlayer?.prepareToPlay()
audioPlayer?.play()
+2 -4
View File
@@ -33,8 +33,6 @@ final class ChatModel: ObservableObject {
// items in the terminal view
@Published var terminalItems: [TerminalItem] = []
@Published var userAddress: UserContactLink?
@Published var userSMPServers: [ServerCfg]?
@Published var presetSMPServers: [String]?
@Published var chatItemTTL: ChatItemTTL = .none
@Published var appOpenUrl: URL?
@Published var deviceToken: DeviceToken?
@@ -55,13 +53,13 @@ final class ChatModel: ObservableObject {
// currently showing QR code
@Published var connReqInv: String?
// audio recording and playback
@Published var stopPreviousRecPlay: Bool = false // value is not taken into account, only the fact it switches
@Published var stopPreviousRecPlay: URL? = nil // coordinates currently playing source
@Published var draft: ComposeState?
@Published var draftChatId: String?
var messageDelivery: Dictionary<Int64, () -> Void> = [:]
var filesToDelete: [String] = []
var filesToDelete: Set<URL> = []
static let shared = ChatModel()
+38
View File
@@ -9,6 +9,7 @@
import Foundation
import SimpleXChat
import SwiftUI
import AVKit
func getLoadedFilePath(_ file: CIFile?) -> String? {
if let fileName = getLoadedFileName(file) {
@@ -42,6 +43,17 @@ func getLoadedImage(_ file: CIFile?) -> UIImage? {
return nil
}
func getLoadedVideo(_ file: CIFile?) -> URL? {
let loadedFilePath = getLoadedFilePath(file)
if loadedFilePath != nil, let fileName = file?.filePath {
let filePath = getAppFilePath(fileName)
if FileManager.default.fileExists(atPath: filePath.path) {
return filePath
}
}
return nil
}
func saveAnimImage(_ image: UIImage) -> String? {
let fileName = generateNewFileName("IMG", "gif")
guard let imageData = image.imageData else { return nil }
@@ -164,6 +176,20 @@ func saveFileFromURL(_ url: URL) -> String? {
return savedFile
}
func saveFileFromURLWithoutLoad(_ url: URL) -> String? {
let savedFile: String?
do {
let fileName = uniqueCombine(url.lastPathComponent)
try FileManager.default.moveItem(at: url, to: getAppFilePath(fileName))
ChatModel.shared.filesToDelete.remove(url)
savedFile = fileName
} catch {
logger.error("FileUtils.saveFileFromURLWithoutLoad error: \(error.localizedDescription)")
savedFile = nil
}
return savedFile
}
func generateNewFileName(_ prefix: String, _ ext: String) -> String {
uniqueCombine("\(prefix)_\(getTimestamp()).\(ext)")
}
@@ -204,6 +230,18 @@ private func dropPrefix(_ s: String, _ prefix: String) -> String {
s.hasPrefix(prefix) ? String(s.dropFirst(prefix.count)) : s
}
extension AVAsset {
func generatePreview() -> (UIImage, Int)? {
let generator = AVAssetImageGenerator(asset: self)
generator.appliesPreferredTrackTransform = true
var actualTime = CMTimeMake(value: 0, timescale: 0)
if let image = try? generator.copyCGImage(at: CMTimeMakeWithSeconds(0.0, preferredTimescale: 1), actualTime: &actualTime) {
return (UIImage(cgImage: image), Int(duration.seconds))
}
return nil
}
}
extension UIImage {
func replaceColor(_ from: UIColor, _ to: UIColor) -> UIImage {
if let cgImage = cgImage {
+11 -22
View File
@@ -391,30 +391,22 @@ func apiDeleteToken(token: DeviceToken) async throws {
try await sendCommandOkResp(.apiDeleteToken(token: token))
}
func getUserSMPServers() throws -> ([ServerCfg], [String]) {
let userId = try currentUserId("getUserSMPServers")
return try userSMPServersResponse(chatSendCmdSync(.apiGetUserSMPServers(userId: userId)))
}
func getUserSMPServersAsync() async throws -> ([ServerCfg], [String]) {
let userId = try currentUserId("getUserSMPServersAsync")
return try userSMPServersResponse(await chatSendCmd(.apiGetUserSMPServers(userId: userId)))
}
private func userSMPServersResponse(_ r: ChatResponse) throws -> ([ServerCfg], [String]) {
if case let .userSMPServers(_, smpServers, presetServers) = r { return (smpServers, presetServers) }
func getUserProtoServers(_ serverProtocol: ServerProtocol) throws -> UserProtoServers {
let userId = try currentUserId("getUserProtoServers")
let r = chatSendCmdSync(.apiGetUserProtoServers(userId: userId, serverProtocol: serverProtocol))
if case let .userProtoServers(_, servers) = r { return servers }
throw r
}
func setUserSMPServers(smpServers: [ServerCfg]) async throws {
let userId = try currentUserId("setUserSMPServers")
try await sendCommandOkResp(.apiSetUserSMPServers(userId: userId, smpServers: smpServers))
func setUserProtoServers(_ serverProtocol: ServerProtocol, servers: [ServerCfg]) async throws {
let userId = try currentUserId("setUserProtoServers")
try await sendCommandOkResp(.apiSetUserProtoServers(userId: userId, serverProtocol: serverProtocol, servers: servers))
}
func testSMPServer(smpServer: String) async throws -> Result<(), SMPTestFailure> {
let userId = try currentUserId("testSMPServer")
let r = await chatSendCmd(.apiTestSMPServer(userId: userId, smpServer: smpServer))
if case let .smpTestResult(_, testFailure) = r {
func testProtoServer(server: String) async throws -> Result<(), ProtocolTestFailure> {
let userId = try currentUserId("testProtoServer")
let r = await chatSendCmd(.apiTestProtoServer(userId: userId, server: server))
if case let .serverTestResult(_, _, testFailure) = r {
if let t = testFailure {
return .failure(t)
}
@@ -1098,7 +1090,6 @@ func changeActiveUserAsync_(_ userId: Int64, viewPwd: String?) async throws {
func getUserChatData() throws {
let m = ChatModel.shared
m.userAddress = try apiGetUserAddress()
(m.userSMPServers, m.presetSMPServers) = try getUserSMPServers()
m.chatItemTTL = try getChatItemTTL()
let chats = try apiGetChats()
m.chats = chats.map { Chat.init($0) }
@@ -1106,13 +1097,11 @@ func getUserChatData() throws {
private func getUserChatDataAsync() async throws {
let userAddress = try await apiGetUserAddressAsync()
let servers = try await getUserSMPServersAsync()
let chatItemTTL = try await getChatItemTTLAsync()
let chats = try await apiGetChatsAsync()
await MainActor.run {
let m = ChatModel.shared
m.userAddress = userAddress
(m.userSMPServers, m.presetSMPServers) = servers
m.chatItemTTL = chatItemTTL
m.chats = chats.map { Chat.init($0) }
}
@@ -24,7 +24,7 @@ struct CIImageView: View {
if let uiImage = getLoadedImage(file) {
imageView(uiImage)
.fullScreenCover(isPresented: $showFullScreenImage) {
FullScreenImageView(chatItem: chatItem, image: uiImage, showView: $showFullScreenImage, scrollProxy: scrollProxy)
FullScreenMediaView(chatItem: chatItem, image: uiImage, showView: $showFullScreenImage, scrollProxy: scrollProxy)
}
.onTapGesture { showFullScreenImage = true }
} else if let data = Data(base64Encoded: dropImagePrefix(image)),
@@ -0,0 +1,334 @@
//
// CIVideoView.swift
// SimpleX
//
// Created by Avently on 30/03/2023.
// Copyright © 2023 SimpleX Chat. All rights reserved.
//
import SwiftUI
import AVKit
import SimpleXChat
struct CIVideoView: View {
@Environment(\.colorScheme) var colorScheme
private let chatItem: ChatItem
private let image: String
@State private var duration: Int
@State private var progress: Int = 0
@State private var videoPlaying: Bool = false
private let maxWidth: CGFloat
@Binding private var videoWidth: CGFloat?
@State private var scrollProxy: ScrollViewProxy?
@State private var preview: UIImage? = nil
@State private var player: AVPlayer?
@State private var url: URL?
@State private var showFullScreenPlayer = false
@State private var timeObserver: Any? = nil
@State private var fullScreenTimeObserver: Any? = nil
init(chatItem: ChatItem, image: String, duration: Int, maxWidth: CGFloat, videoWidth: Binding<CGFloat?>, scrollProxy: ScrollViewProxy?) {
self.chatItem = chatItem
self.image = image
self._duration = State(initialValue: duration)
self.maxWidth = maxWidth
self._videoWidth = videoWidth
self.scrollProxy = scrollProxy
if let url = getLoadedVideo(chatItem.file) {
self._player = State(initialValue: VideoPlayerView.getOrCreatePlayer(url, false))
self._url = State(initialValue: url)
}
if let data = Data(base64Encoded: dropImagePrefix(image)),
let uiImage = UIImage(data: data) {
self._preview = State(initialValue: uiImage)
}
}
var body: some View {
let file = chatItem.file
ZStack {
ZStack(alignment: .topLeading) {
if let file = file, let preview = preview, let player = player, let url = url {
videoView(player, url, file, preview, duration)
} else if let data = Data(base64Encoded: dropImagePrefix(image)),
let uiImage = UIImage(data: data) {
imageView(uiImage)
.onTapGesture {
if let file = file {
switch file.fileStatus {
case .rcvInvitation:
receiveFileIfValidSize(file: file, receiveFile: receiveFile)
case .rcvAccepted:
switch file.fileProtocol {
case .xftp:
AlertManager.shared.showAlertMsg(
title: "Waiting for video",
message: "Video will be received when your contact completes uploading it."
)
case .smp:
AlertManager.shared.showAlertMsg(
title: "Waiting for video",
message: "Video will be received when your contact is online, please wait or check later!"
)
}
case .rcvTransfer: () // ?
case .rcvComplete: () // ?
case .rcvCancelled: () // TODO
default: ()
}
}
}
}
durationProgress()
}
if let file = file, case .rcvInvitation = file.fileStatus {
Button {
receiveFileIfValidSize(file: file, receiveFile: receiveFile)
} label: {
playPauseIcon("play.fill")
}
}
}
}
private func videoView(_ player: AVPlayer, _ url: URL, _ file: CIFile, _ preview: UIImage, _ duration: Int) -> some View {
let w = preview.size.width <= preview.size.height ? maxWidth * 0.75 : maxWidth
DispatchQueue.main.async { videoWidth = w }
return ZStack(alignment: .topTrailing) {
ZStack(alignment: .center) {
VideoPlayerView(player: player, url: url, showControls: false)
.frame(width: w, height: w * preview.size.height / preview.size.width)
.onChange(of: ChatModel.shared.stopPreviousRecPlay) { playingUrl in
if playingUrl != url {
player.pause()
videoPlaying = false
}
}
.fullScreenCover(isPresented: $showFullScreenPlayer) {
fullScreenPlayer(url)
}
.onTapGesture {
switch player.timeControlStatus {
case .playing:
player.pause()
videoPlaying = false
case .paused:
showFullScreenPlayer = true
default: ()
}
}
if !videoPlaying {
Button {
ChatModel.shared.stopPreviousRecPlay = url
player.play()
} label: {
playPauseIcon("play.fill")
}
}
}
loadingIndicator()
}
.onAppear {
addObserver(player, url)
}
.onDisappear {
removeObserver()
player.pause()
videoPlaying = false
}
}
private func playPauseIcon(_ image: String, _ color: Color = .white) -> some View {
Image(systemName: image)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 12, height: 12)
.foregroundColor(color)
.padding(.leading, 4)
.frame(width: 40, height: 40)
.background(Color.black.opacity(0.35))
.clipShape(Circle())
}
private func durationProgress() -> some View {
HStack {
Text("\(durationText(videoPlaying ? progress : duration))")
.foregroundColor(.white)
.font(.caption)
.padding(.vertical, 3)
.padding(.horizontal, 6)
.background(Color.black.opacity(0.35))
.cornerRadius(10)
.padding([.top, .leading], 6)
if let file = chatItem.file, !videoPlaying {
Text("\(ByteCountFormatter.string(fromByteCount: file.fileSize, countStyle: .binary))")
.foregroundColor(.white)
.font(.caption)
.padding(.vertical, 3)
.padding(.horizontal, 6)
.background(Color.black.opacity(0.35))
.cornerRadius(10)
.padding(.top, 6)
}
}
}
private func imageView(_ img: UIImage) -> some View {
let w = img.size.width <= img.size.height ? maxWidth * 0.75 : .infinity
DispatchQueue.main.async { videoWidth = w }
return ZStack(alignment: .topTrailing) {
Image(uiImage: img)
.resizable()
.scaledToFit()
.frame(maxWidth: w)
loadingIndicator()
}
}
@ViewBuilder private func loadingIndicator() -> some View {
if let file = chatItem.file {
switch file.fileStatus {
case .sndStored:
switch file.fileProtocol {
case .xftp: progressView()
case .smp: EmptyView()
}
case let .sndTransfer(sndProgress, sndTotal):
switch file.fileProtocol {
case .xftp: progressCircle(sndProgress, sndTotal)
case .smp: progressView()
}
case .sndComplete:
Image(systemName: "checkmark")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 10, height: 10)
.foregroundColor(.white)
.padding(13)
case .rcvInvitation:
Image(systemName: "arrow.down")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 14, height: 14)
.foregroundColor(.white)
.padding(11)
case .rcvAccepted:
Image(systemName: "ellipsis")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 14, height: 14)
.foregroundColor(.white)
.padding(11)
case let .rcvTransfer(rcvProgress, rcvTotal):
if file.fileProtocol == .xftp && rcvProgress < rcvTotal {
progressCircle(rcvProgress, rcvTotal)
} else {
progressView()
}
default: EmptyView()
}
}
}
private func progressView() -> some View {
ProgressView()
.progressViewStyle(.circular)
.frame(width: 16, height: 16)
.tint(.white)
.padding(11)
}
private func progressCircle(_ progress: Int64, _ total: Int64) -> some View {
Circle()
.trim(from: 0, to: Double(progress) / Double(total))
.stroke(
Color(uiColor: .white),
style: StrokeStyle(lineWidth: 2)
)
.rotationEffect(.degrees(-90))
.frame(width: 16, height: 16)
.padding([.trailing, .top], 11)
}
private func receiveFileIfValidSize(file: CIFile, receiveFile: @escaping (User, Int64) async -> Void) {
Task {
if let user = ChatModel.shared.currentUser {
await receiveFile(user, file.fileId)
}
// TODO image accepted alert?
}
}
private func fullScreenPlayer(_ url: URL) -> some View {
ZStack {
Color.black.edgesIgnoringSafeArea(.all)
VideoPlayer(player: createFullScreenPlayerAndPlay(url)) {
}
.overlay(alignment: .topLeading, content: {
Button(action: { showFullScreenPlayer = false },
label: {
Image(systemName: "multiply")
.resizable()
.tint(.white)
.frame(width: 15, height: 15)
.padding(.leading, 15)
.padding(.top, 13)
}
)
})
.gesture(
DragGesture(minimumDistance: 80)
.onChanged { gesture in
let t = gesture.translation
let w = abs(t.width)
if t.height > 60 && t.height > w * 2 {
showFullScreenPlayer = false
}
}
)
.onDisappear {
if let fullScreenTimeObserver = fullScreenTimeObserver {
NotificationCenter.default.removeObserver(fullScreenTimeObserver)
}
fullScreenTimeObserver = nil
}
}
}
private func createFullScreenPlayerAndPlay(_ url: URL) -> AVPlayer {
let player = AVPlayer(url: url)
DispatchQueue.main.asyncAfter(deadline: .now()) {
ChatModel.shared.stopPreviousRecPlay = url
player.play()
fullScreenTimeObserver = NotificationCenter.default.addObserver(forName: .AVPlayerItemDidPlayToEndTime, object: player.currentItem, queue: .main) { _ in
player.seek(to: CMTime.zero)
player.play()
}
}
return player
}
private func addObserver(_ player: AVPlayer, _ url: URL) {
timeObserver = player.addPeriodicTimeObserver(forInterval: CMTime(seconds: 0.01, preferredTimescale: CMTimeScale(NSEC_PER_SEC)), queue: .main) { time in
if let item = player.currentItem {
let dur = CMTimeGetSeconds(item.duration)
if !dur.isInfinite && !dur.isNaN {
duration = Int(dur)
}
progress = Int(CMTimeGetSeconds(player.currentTime()))
// `if` prevents showing Play button while the playback seeks to start and then plays
if player.currentTime() != player.currentItem?.duration && player.currentTime() != .zero {
videoPlaying = player.timeControlStatus == .playing || player.timeControlStatus == .waitingToPlayAtSpecifiedRate
}
}
}
}
private func removeObserver() {
if let timeObserver = timeObserver {
player?.removeTimeObserver(timeObserver)
}
timeObserver = nil
}
}
@@ -203,7 +203,7 @@ struct VoiceMessagePlayer: View {
private func startPlayback(_ recordingFileName: String) {
startingPlayback = true
chatModel.stopPreviousRecPlay.toggle()
chatModel.stopPreviousRecPlay = getAppFilePath(recordingFileName)
audioPlayer = AudioPlayer(
onTimer: { playbackTime = $0 },
onFinishPlayback: {
@@ -23,6 +23,7 @@ struct FramedItemView: View {
@State var scrollProxy: ScrollViewProxy? = nil
@State var msgWidth: CGFloat = 0
@State var imgWidth: CGFloat? = nil
@State var videoWidth: CGFloat? = nil
@State var metaColor = Color.secondary
@State var showFullScreenImage = false
@@ -64,7 +65,7 @@ struct FramedItemView: View {
.overlay(DetermineWidth())
}
}
.background(chatItemFrameColorMaybeImage(chatItem, colorScheme))
.background(chatItemFrameColorMaybeImageOrVideo(chatItem, colorScheme))
.cornerRadius(18)
.onPreferenceChange(DetermineWidth.Key.self) { msgWidth = $0 }
@@ -103,6 +104,19 @@ struct FramedItemView: View {
} else {
ciMsgContentView (chatItem, showMember)
}
case let .video(text, image, duration):
CIVideoView(chatItem: chatItem, image: image, duration: duration, maxWidth: maxWidth, videoWidth: $videoWidth, scrollProxy: scrollProxy)
.overlay(DetermineWidth())
if text == "" && !chatItem.meta.isLive {
Color.clear
.frame(width: 0, height: 0)
.preference(
key: MetaColorPreferenceKey.self,
value: .white
)
} else {
ciMsgContentView (chatItem, showMember)
}
case let .voice(text, duration):
FramedCIVoiceView(chatItem: chatItem, recordingFile: chatItem.file, duration: duration)
.overlay(DetermineWidth())
@@ -152,8 +166,8 @@ struct FramedItemView: View {
.overlay(DetermineWidth())
.frame(minWidth: msgWidth, alignment: .leading)
.background(chatItemFrameContextColor(chatItem, colorScheme))
if let imgWidth = imgWidth, imgWidth < maxWidth {
v.frame(maxWidth: imgWidth, alignment: .leading)
if let mediaWidth = maxMediaWidth(), mediaWidth < maxWidth {
v.frame(maxWidth: mediaWidth, alignment: .leading)
} else {
v
}
@@ -175,6 +189,19 @@ struct FramedItemView: View {
} else {
ciQuotedMsgView(qi)
}
case let .video(_, image, _):
if let data = Data(base64Encoded: dropImagePrefix(image)),
let uiImage = UIImage(data: data) {
ciQuotedMsgView(qi)
.padding(.trailing, 70).frame(minWidth: msgWidth, alignment: .leading)
Image(uiImage: uiImage)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: 68, height: 68)
.clipped()
} else {
ciQuotedMsgView(qi)
}
case .file:
ciQuotedMsgView(qi)
.padding(.trailing, 20).frame(minWidth: msgWidth, alignment: .leading)
@@ -190,9 +217,9 @@ struct FramedItemView: View {
.overlay(DetermineWidth())
.frame(minWidth: msgWidth, alignment: .leading)
.background(chatItemFrameContextColor(chatItem, colorScheme))
if let imgWidth = imgWidth, imgWidth < maxWidth {
v.frame(maxWidth: imgWidth, alignment: .leading)
if let mediaWidth = maxMediaWidth(), mediaWidth < maxWidth {
v.frame(maxWidth: mediaWidth, alignment: .leading)
} else {
v
}
@@ -243,9 +270,9 @@ struct FramedItemView: View {
.overlay(DetermineWidth())
.frame(minWidth: 0, alignment: .leading)
.textSelection(.enabled)
if let imgWidth = imgWidth, imgWidth < maxWidth {
v.frame(maxWidth: imgWidth, alignment: .leading)
if let mediaWidth = maxMediaWidth(), mediaWidth < maxWidth {
v.frame(maxWidth: mediaWidth, alignment: .leading)
} else {
v
}
@@ -258,6 +285,16 @@ struct FramedItemView: View {
ciMsgContentView (chatItem, showMember)
}
}
private func maxMediaWidth() -> CGFloat? {
if let imgWidth = imgWidth, let videoWidth = videoWidth {
return imgWidth > videoWidth ? imgWidth : videoWidth
} else if let imgWidth = imgWidth {
return imgWidth
} else {
return videoWidth
}
}
}
func isRightToLeft(_ s: String) -> Bool {
@@ -274,15 +311,17 @@ private struct MetaColorPreferenceKey: PreferenceKey {
}
}
func onlyImage(_ ci: ChatItem) -> Bool {
func onlyImageOrVideo(_ ci: ChatItem) -> Bool {
if case let .image(text, _) = ci.content.msgContent {
return ci.meta.itemDeleted == nil && !ci.meta.isLive && ci.quotedItem == nil && text == ""
} else if case let .video(text, _, _) = ci.content.msgContent {
return ci.meta.itemDeleted == nil && !ci.meta.isLive && ci.quotedItem == nil && text == ""
}
return false
}
func chatItemFrameColorMaybeImage(_ ci: ChatItem, _ colorScheme: ColorScheme) -> Color {
onlyImage(ci)
func chatItemFrameColorMaybeImageOrVideo(_ ci: ChatItem, _ colorScheme: ColorScheme) -> Color {
onlyImageOrVideo(ci)
? Color.clear
: chatItemFrameColor(ci, colorScheme)
}
@@ -9,36 +9,61 @@
import SwiftUI
import SimpleXChat
import SwiftyGif
import AVKit
struct FullScreenImageView: View {
struct FullScreenMediaView: View {
@EnvironmentObject var m: ChatModel
@State var chatItem: ChatItem
@State var image: UIImage
@State var image: UIImage?
@State var player: AVPlayer? = nil
@State var url: URL? = nil
@Binding var showView: Bool
@State var scrollProxy: ScrollViewProxy?
@State private var showNext = false
@State private var nextImage: UIImage?
@State private var nextPlayer: AVPlayer?
@State private var nextURL: URL?
@State private var scrolling = false
@State private var offset: CGFloat = 0
@State private var nextOffset: CGFloat = 0
var body: some View {
GeometryReader(content: imageScrollView)
GeometryReader(content: mediaScrollView)
}
func imageScrollView(_ g: GeometryProxy) -> some View {
func mediaScrollView(_ g: GeometryProxy) -> some View {
ZStack {
Color.black.edgesIgnoringSafeArea(.all)
if showNext, let nextImage = nextImage {
imageView(image).offset(x: offset)
if let image = image {
imageView(image).offset(x: offset)
} else if let player = player, let url = url {
videoView(player, url).offset(x: offset)
}
imageView(nextImage).offset(x: offset + nextOffset)
} else if showNext, let nextPlayer = nextPlayer, let nextURL = nextURL {
if let image = image {
imageView(image).offset(x: offset)
} else if let player = player, let url = url {
videoView(player, url).offset(x: offset)
}
videoView(nextPlayer, nextURL).offset(x: offset + nextOffset)
} else {
ZoomableScrollView {
imageView(image)
if let image = image {
imageView(image)
} else if let player = player, let url = url {
videoView(player, url)
}
}
}
}
.onTapGesture { showView = false }
.onAppear {
startPlayerAndNotify()
}
.onDisappear {
player?.pause()
}
.gesture(
DragGesture(minimumDistance: 80)
.onChanged { gesture in
@@ -53,9 +78,17 @@ struct FullScreenImageView: View {
let previous = t.width > 0
scrolling = true
if let item = m.nextChatItemData(chatItem.id, previous: previous, map: chatItemImage) {
var img: UIImage
(chatItem, img) = item
var img: UIImage?
var url: URL?
(chatItem, img, url) = item
nextImage = img
nextPlayer?.pause()
if let url = url {
nextPlayer = VideoPlayerView.getOrCreatePlayer(url, true)
} else {
nextPlayer = nil
}
nextURL = url
let s = g.size.width
var toOffset: CGFloat
(toOffset, nextOffset) = previous ? (s, -s) : (-s, s)
@@ -65,6 +98,14 @@ struct FullScreenImageView: View {
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
image = img
player?.pause()
self.url = url
if let url = url {
player = VideoPlayerView.getOrCreatePlayer(url, true)
startPlayerAndNotify()
} else {
player = nil
}
showNext = false
offset = 0
}
@@ -87,13 +128,30 @@ struct FullScreenImageView: View {
.scaledToFit()
}
}
.onTapGesture { showView = false }
}
private func chatItemImage(_ ci: ChatItem) -> (ChatItem, UIImage)? {
private func videoView( _ player: AVPlayer, _ url: URL) -> some View {
VideoPlayerView(player: player, url: url, showControls: true)
}
private func chatItemImage(_ ci: ChatItem) -> (ChatItem, UIImage?, URL?)? {
if case .image = ci.content.msgContent,
let img = getLoadedImage(ci.file) {
return (ci, img)
return (ci, img, nil)
}
// Currently, video support in gallery is not enabled
/*else if case .video = ci.content.msgContent,
let url = getLoadedVideo(ci.file) {
return (ci, nil, url)
}*/
return nil
}
private func startPlayerAndNotify() {
if let player = player {
ChatModel.shared.stopPreviousRecPlay = url
player.play()
}
}
}
@@ -78,6 +78,7 @@ struct ChatView: View {
if chatModel.chatId == nil { dismiss() }
}
.onDisappear {
VideoPlayerView.players.removeAll()
if chatModel.chatId == cInfo.id && !presentationMode.wrappedValue.isPresented {
chatModel.chatId = nil
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
@@ -14,7 +14,7 @@ import PhotosUI
enum ComposePreview {
case noPreview
case linkPreview(linkPreview: LinkPreview?)
case imagePreviews(imagePreviews: [(String, UploadContent?)])
case mediaPreviews(mediaPreviews: [(String, UploadContent?)])
case voicePreview(recordingFileName: String, duration: Int)
case filePreview(fileName: String, file: URL)
}
@@ -105,7 +105,7 @@ struct ComposeState {
var sendEnabled: Bool {
switch preview {
case .imagePreviews: return true
case .mediaPreviews: return true
case .voicePreview: return voiceMessageRecordingState == .finished
case .filePreview: return true
default: return !message.isEmpty || liveMessage != nil
@@ -118,7 +118,7 @@ struct ComposeState {
var linkPreviewAllowed: Bool {
switch preview {
case .imagePreviews: return false
case .mediaPreviews: return false
case .voicePreview: return false
case .filePreview: return false
default: return useLinkPreviews
@@ -175,7 +175,9 @@ func chatItemPreview(chatItem: ChatItem) -> ComposePreview {
case let .link(_, preview: preview):
chatItemPreview = .linkPreview(linkPreview: preview)
case let .image(_, image):
chatItemPreview = .imagePreviews(imagePreviews: [(image, nil)])
chatItemPreview = .mediaPreviews(mediaPreviews: [(image, nil)])
case let .video(_, image, _):
chatItemPreview = .mediaPreviews(mediaPreviews: [(image, nil)])
case let .voice(_, duration):
chatItemPreview = .voicePreview(recordingFileName: chatItem.file?.fileName ?? "", duration: duration)
case .file:
@@ -190,11 +192,13 @@ func chatItemPreview(chatItem: ChatItem) -> ComposePreview {
enum UploadContent: Equatable {
case simpleImage(image: UIImage)
case animatedImage(image: UIImage)
case video(image: UIImage, url: URL, duration: Int)
var uiImage: UIImage {
switch self {
case let .simpleImage(image): return image
case let .animatedImage(image): return image
case let .video(image, _, _): return image
}
}
@@ -216,6 +220,14 @@ enum UploadContent: Equatable {
}
return nil
}
static func loadVideoFromURL(url: URL) -> UploadContent? {
let asset = AVAsset(url: url)
if let (image, duration) = asset.generatePreview() {
return .video(image: image, url: url, duration: duration)
}
return nil
}
}
struct ComposeView: View {
@@ -232,9 +244,9 @@ struct ComposeView: View {
@AppStorage(GROUP_DEFAULT_XFTP_SEND_ENABLED, store: groupDefaults) private var xftpSendEnabled = false
@State private var showChooseSource = false
@State private var showImagePicker = false
@State private var showMediaPicker = false
@State private var showTakePhoto = false
@State var chosenImages: [UploadContent] = []
@State var chosenMedia: [UploadContent] = []
@State private var showFileImporter = false
@State private var audioRecorder: AudioRecorder?
@@ -286,7 +298,7 @@ struct ComposeView: View {
},
finishVoiceMessageRecording: finishVoiceMessageRecording,
allowVoiceMessagesToContact: allowVoiceMessagesToContact,
onImagesAdded: { images in if !images.isEmpty { chosenImages = images }},
onMediaAdded: { media in if !media.isEmpty { chosenMedia = media }},
keyboardVisible: $keyboardVisible
)
.padding(.trailing, 12)
@@ -329,7 +341,7 @@ struct ComposeView: View {
showTakePhoto = true
}
Button("Choose from library") {
showImagePicker = true
showMediaPicker = true
}
if UIPasteboard.general.hasImages {
Button("Paste image") {
@@ -337,7 +349,7 @@ struct ComposeView: View {
if p.hasItemConformingToTypeIdentifier(UTType.data.identifier) {
p.loadFileRepresentation(forTypeIdentifier: UTType.data.identifier) { url, error in
if let url = url, let image = UploadContent.loadFromURL(url: url) {
chosenImages.append(image)
chosenMedia.append(image)
}
}
}
@@ -351,31 +363,31 @@ struct ComposeView: View {
.fullScreenCover(isPresented: $showTakePhoto) {
ZStack {
Color.black.edgesIgnoringSafeArea(.all)
CameraImageListPicker(images: $chosenImages)
CameraImageListPicker(images: $chosenMedia)
}
}
.sheet(isPresented: $showImagePicker) {
LibraryImageListPicker(images: $chosenImages, selectionLimit: 10) { itemsSelected in
showImagePicker = false
.sheet(isPresented: $showMediaPicker) {
LibraryMediaListPicker(media: $chosenMedia, selectionLimit: 10) { itemsSelected in
showMediaPicker = false
if itemsSelected {
DispatchQueue.main.async {
composeState = composeState.copy(preview: .imagePreviews(imagePreviews: []))
composeState = composeState.copy(preview: .mediaPreviews(mediaPreviews: []))
}
}
}
}
.onChange(of: chosenImages) { images in
.onChange(of: chosenMedia) { selected in
Task {
var imgs: [(String, UploadContent)] = []
for image in images {
if let img = resizeImageToStrSize(image.uiImage, maxDataSize: 14000) {
imgs.append((img, image))
var media: [(String, UploadContent)] = []
for content in selected {
if let img = resizeImageToStrSize(content.uiImage, maxDataSize: 14000) {
media.append((img, content))
await MainActor.run {
composeState = composeState.copy(preview: .imagePreviews(imagePreviews: imgs))
composeState = composeState.copy(preview: .mediaPreviews(mediaPreviews: media))
}
}
}
if imgs.count == 0 {
if media.count == 0 {
await MainActor.run {
composeState = composeState.copy(preview: .noPreview)
}
@@ -514,12 +526,12 @@ struct ComposeView: View {
EmptyView()
case let .linkPreview(linkPreview: preview):
ComposeLinkView(linkPreview: preview, cancelPreview: cancelLinkPreview)
case let .imagePreviews(imagePreviews: images):
case let .mediaPreviews(mediaPreviews: media):
ComposeImageView(
images: images.map { (img, _) in img },
images: media.map { (img, _) in img },
cancelImage: {
composeState = composeState.copy(preview: .noPreview)
chosenImages = []
chosenMedia = []
},
cancelEnabled: !composeState.editing)
case let .voicePreview(recordingFileName, _):
@@ -594,21 +606,29 @@ struct ComposeView: View {
sent = await send(.text(msgText), quoted: quoted, live: live)
case .linkPreview:
sent = await send(checkLinkPreview(), quoted: quoted, live: live)
case let .imagePreviews(imagePreviews: images):
let last = images.count - 1
case let .mediaPreviews(mediaPreviews: media):
let last = media.count - 1
if last >= 0 {
for i in 0..<last {
sent = await sendImage(images[i])
if case (_, .video(_, _, _)) = media[i] {
sent = await sendVideo(media[i])
} else {
sent = await sendImage(media[i])
}
_ = try? await Task.sleep(nanoseconds: 100_000000)
}
sent = await sendImage(images[last], text: msgText, quoted: quoted, live: live)
if case (_, .video(_, _, _)) = media[last] {
sent = await sendVideo(media[last], text: msgText, quoted: quoted, live: live)
} else {
sent = await sendImage(media[last], text: msgText, quoted: quoted, live: live)
}
}
if sent == nil {
sent = await send(.text(msgText), quoted: quoted, live: live)
}
case let .voicePreview(recordingFileName, duration):
stopPlayback.toggle()
chatModel.filesToDelete.removeAll { $0 == recordingFileName }
chatModel.filesToDelete.remove(getAppFilePath(recordingFileName))
sent = await send(.voice(text: msgText, duration: duration), quoted: quoted, file: recordingFileName)
case let .filePreview(_, file):
if let savedFile = saveFileFromURL(file) {
@@ -657,6 +677,8 @@ struct ComposeView: View {
return checkLinkPreview()
case .image(_, let image):
return .image(text: msgText, image: image)
case .video(_, let image, let duration):
return .video(text: msgText, image: image, duration: duration)
case .voice(_, let duration):
return .voice(text: msgText, duration: duration)
case .file:
@@ -674,6 +696,14 @@ struct ComposeView: View {
return nil
}
func sendVideo(_ imageData: (String, UploadContent?), text: String = "", quoted: Int64? = nil, live: Bool = false) async -> ChatItem? {
let (image, data) = imageData
if case let .video(_, url, duration) = data, let savedFile = saveFileFromURLWithoutLoad(url) {
return await send(.video(text: text, image: image, duration: duration), quoted: quoted, file: savedFile, live: live)
}
return nil
}
func send(_ mc: MsgContent, quoted: Int64?, file: String? = nil, live: Bool = false) async -> ChatItem? {
if let chatItem = await apiSendMessage(
type: chat.chatInfo.chatType,
@@ -711,14 +741,15 @@ struct ComposeView: View {
switch img {
case let .simpleImage(image): return saveImage(image)
case let .animatedImage(image): return saveAnimImage(image)
default: return nil
}
}
}
private func startVoiceMessageRecording() async {
startingRecording = true
chatModel.stopPreviousRecPlay.toggle()
let fileName = generateNewFileName("voice", "m4a")
chatModel.stopPreviousRecPlay = getAppFilePath(fileName)
audioRecorder = AudioRecorder(
onTimer: { voiceMessageRecordingTime = $0 },
onFinishRecording: {
@@ -804,7 +835,7 @@ struct ComposeView: View {
composeState = ComposeState()
resetLinkPreview()
}
chosenImages = []
chosenMedia = []
audioRecorder = nil
voiceMessageRecordingTime = nil
startingRecording = false
@@ -814,7 +845,7 @@ struct ComposeView: View {
if case .recording = composeState.voiceMessageRecordingState {
finishVoiceMessageRecording()
if let fileName = composeState.voiceMessageRecordingFileName {
chatModel.filesToDelete.append(fileName)
chatModel.filesToDelete.insert(getAppFilePath(fileName))
}
}
chatModel.draft = composeState
@@ -164,7 +164,7 @@ struct ComposeVoiceView: View {
private func startPlayback() {
startingPlayback = true
chatModel.stopPreviousRecPlay.toggle()
chatModel.stopPreviousRecPlay = getAppFilePath(recordingFileName)
audioPlayer = AudioPlayer(
onTimer: { playbackTime = $0 },
onFinishPlayback: {
@@ -23,7 +23,7 @@ struct SendMessageView: View {
var startVoiceMessageRecording: (() -> Void)? = nil
var finishVoiceMessageRecording: (() -> Void)? = nil
var allowVoiceMessagesToContact: (() -> Void)? = nil
var onImagesAdded: ([UploadContent]) -> Void
var onMediaAdded: ([UploadContent]) -> Void
@State private var holdingVMR = false
@Namespace var namespace
@FocusState.Binding var keyboardVisible: Bool
@@ -69,7 +69,7 @@ struct SendMessageView: View {
font: teUiFont,
focused: $keyboardVisible,
alignment: alignment,
onImagesAdded: onImagesAdded
onImagesAdded: onMediaAdded
)
.allowsTightening(false)
.frame(height: teHeight)
@@ -365,7 +365,7 @@ struct SendMessageView_Previews: PreviewProvider {
SendMessageView(
composeState: $composeStateNew,
sendMessage: {},
onImagesAdded: { _ in },
onMediaAdded: { _ in },
keyboardVisible: $keyboardVisible
)
}
@@ -375,7 +375,7 @@ struct SendMessageView_Previews: PreviewProvider {
SendMessageView(
composeState: $composeStateEditing,
sendMessage: {},
onImagesAdded: { _ in },
onMediaAdded: { _ in },
keyboardVisible: $keyboardVisible
)
}
@@ -143,7 +143,7 @@ struct ChatPreviewView: View {
func attachment() -> Text {
switch draft.preview {
case let .filePreview(fileName, _): return image("doc.fill") + Text(fileName) + Text(" ")
case .imagePreviews: return image("photo")
case .mediaPreviews: return image("photo")
case let .voicePreview(_, duration): return image("play.fill") + Text(durationText(duration))
default: return Text("")
}
@@ -159,6 +159,7 @@ struct ChatPreviewView: View {
switch cItem.content.msgContent {
case .file: return "doc.fill"
case .image: return "photo"
case .video: return "video"
case .voice: return "play.fill"
default: return nil
}
+61 -27
View File
@@ -17,7 +17,7 @@ struct LibraryImagePicker: View {
@State var images: [UploadContent] = []
var body: some View {
LibraryImageListPicker(images: $images, selectionLimit: 1, didFinishPicking: didFinishPicking)
LibraryMediaListPicker(media: $images, selectionLimit: 1, didFinishPicking: didFinishPicking)
.onChange(of: images) { _ in
if let img = images.first {
image = img.uiImage
@@ -26,19 +26,20 @@ struct LibraryImagePicker: View {
}
}
struct LibraryImageListPicker: UIViewControllerRepresentable {
struct LibraryMediaListPicker: UIViewControllerRepresentable {
typealias UIViewControllerType = PHPickerViewController
@Binding var images: [UploadContent]
@AppStorage(GROUP_DEFAULT_XFTP_SEND_ENABLED, store: groupDefaults) var xftpSendEnabled = false
@Binding var media: [UploadContent]
var selectionLimit: Int
var didFinishPicking: (_ didSelectItems: Bool) -> Void
class Coordinator: PHPickerViewControllerDelegate {
let parent: LibraryImageListPicker
let dispatchQueue = DispatchQueue(label: "chat.simplex.app.LibraryImageListPicker")
var images: [UploadContent] = []
var imageCount: Int = 0
let parent: LibraryMediaListPicker
let dispatchQueue = DispatchQueue(label: "chat.simplex.app.LibraryMediaListPicker")
var media: [UploadContent] = []
var mediaCount: Int = 0
init(_ parent: LibraryImageListPicker) {
init(_ parent: LibraryMediaListPicker) {
self.parent = parent
}
@@ -48,13 +49,23 @@ struct LibraryImageListPicker: UIViewControllerRepresentable {
return
}
parent.images = []
images = []
imageCount = results.count
parent.media = []
media = []
mediaCount = results.count
for result in results {
logger.log("LibraryImageListPicker result")
logger.log("LibraryMediaListPicker result")
let p = result.itemProvider
if p.hasItemConformingToTypeIdentifier(UTType.data.identifier) {
if p.hasItemConformingToTypeIdentifier(UTType.movie.identifier) {
p.loadFileRepresentation(forTypeIdentifier: UTType.movie.identifier) { url, error in
if let url = url {
let tempUrl = URL(fileURLWithPath: getTempFilesDirectory().path + "/" + generateNewFileName("video", url.pathExtension))
if ((try? FileManager.default.copyItem(at: url, to: tempUrl)) != nil) {
ChatModel.shared.filesToDelete.insert(tempUrl)
self.loadVideo(url: tempUrl, error: error)
}
}
}
} else if p.hasItemConformingToTypeIdentifier(UTType.data.identifier) {
p.loadFileRepresentation(forTypeIdentifier: UTType.data.identifier) { url, error in
self.loadImage(object: url, error: error)
}
@@ -65,14 +76,14 @@ struct LibraryImageListPicker: UIViewControllerRepresentable {
}
}
} else {
dispatchQueue.sync { self.imageCount -= 1}
dispatchQueue.sync { self.mediaCount -= 1}
}
}
DispatchQueue.main.asyncAfter(deadline: .now() + 10) {
self.dispatchQueue.sync {
if self.parent.images.count == 0 {
logger.log("LibraryImageListPicker: added \(self.images.count) images out of \(results.count)")
self.parent.images = self.images
if self.parent.media.count == 0 {
logger.log("LibraryMediaListPicker: added \(self.media.count) images out of \(results.count)")
self.parent.media = self.media
}
}
}
@@ -80,19 +91,35 @@ struct LibraryImageListPicker: UIViewControllerRepresentable {
func loadImage(object: Any?, error: Error? = nil) {
if let error = error {
logger.error("LibraryImageListPicker: couldn't load image with error: \(error.localizedDescription)")
logger.error("LibraryMediaListPicker: couldn't load image with error: \(error.localizedDescription)")
} else if let image = object as? UIImage {
images.append(.simpleImage(image: image))
logger.log("LibraryImageListPicker: added image")
media.append(.simpleImage(image: image))
logger.log("LibraryMediaListPicker: added image")
} else if let url = object as? URL, let image = UploadContent.loadFromURL(url: url) {
images.append(image)
media.append(image)
}
dispatchQueue.sync {
self.imageCount -= 1
if self.imageCount == 0 && self.parent.images.count == 0 {
logger.log("LibraryImageListPicker: added all images")
self.parent.images = self.images
self.images = []
self.mediaCount -= 1
if self.mediaCount == 0 && self.parent.media.count == 0 {
logger.log("LibraryMediaListPicker: added all media")
self.parent.media = self.media
self.media = []
}
}
}
func loadVideo(url: URL?, error: Error? = nil) {
if let error = error {
logger.error("LibraryMediaListPicker: couldn't load video with error: \(error.localizedDescription)")
} else if let url = url as URL?, let video = UploadContent.loadVideoFromURL(url: url) {
media.append(video)
}
dispatchQueue.sync {
self.mediaCount -= 1
if self.mediaCount == 0 && self.parent.media.count == 0 {
logger.log("LibraryMediaListPicker: added all media")
self.parent.media = self.media
self.media = []
}
}
}
@@ -104,8 +131,15 @@ struct LibraryImageListPicker: UIViewControllerRepresentable {
func makeUIViewController(context: Context) -> PHPickerViewController {
var config = PHPickerConfiguration()
config.filter = .images
let allowVideoAttachment = xftpSendEnabled
if allowVideoAttachment {
config.filter = .any(of: [.images, .videos])
} else {
config.filter = .images
}
config.selectionLimit = selectionLimit
config.selection = .ordered
//config.preferredAssetRepresentationMode = .current
let controller = PHPickerViewController(configuration: config)
controller.delegate = context.coordinator
return controller
@@ -0,0 +1,61 @@
//
// Created by Avently on 30.03.2023.
// Copyright (c) 2023 SimpleX Chat. All rights reserved.
//
import Foundation
import SwiftUI
import AVKit
struct VideoPlayerView: UIViewRepresentable {
static var players: [String: AVPlayer] = [:]
static func getOrCreatePlayer(_ url: URL, _ gallery: Bool) -> AVPlayer {
if let player = players[url.absoluteString + gallery.description] {
return player
} else {
let player = AVPlayer(url: url)
players[url.absoluteString + gallery.description] = player
return player
}
}
typealias UIViewType = UIView
let player: AVPlayer
let url: URL
let showControls: Bool
func makeUIView(context: UIViewRepresentableContext<VideoPlayerView>) -> UIView {
let controller = AVPlayerViewController()
controller.showsPlaybackControls = showControls
if #available(iOS 16.0, *) {
controller.speeds = []
}
controller.player = player
context.coordinator.controller = controller
context.coordinator.timeObserver = NotificationCenter.default.addObserver(forName: .AVPlayerItemDidPlayToEndTime, object: player.currentItem, queue: .main) { _ in
player.seek(to: CMTime.zero)
player.play()
}
return controller.view
}
func updateUIView(_ uiView: UIView, context: UIViewRepresentableContext<VideoPlayerView>) {
}
func makeCoordinator() -> VideoPlayerView.Coordinator {
Coordinator()
}
class Coordinator: NSObject {
var controller: AVPlayerViewController?
var timeObserver: Any? = nil
deinit {
print("deinit coordinator of VideoPlayer")
if let timeObserver = timeObserver {
NotificationCenter.default.removeObserver(timeObserver)
}
}
}
}
+1 -1
View File
@@ -74,7 +74,7 @@ struct TerminalView: View {
composeState: $composeState,
sendMessage: sendMessage,
showVoiceMessageButton: false,
onImagesAdded: { _ in },
onMediaAdded: { _ in },
keyboardVisible: $keyboardVisible
)
.padding(.horizontal, 12)
@@ -44,7 +44,7 @@ struct DeveloperView: View {
Section {
settingsRow("arrow.up.doc") {
Toggle("Send files via XFTP", isOn: $xftpSendEnabled)
Toggle("Send videos and files via XFTP", isOn: $xftpSendEnabled)
.onChange(of: xftpSendEnabled) { _ in
do {
try setXFTPConfig(getXFTPCfg())
@@ -16,7 +16,7 @@ struct ExperimentalFeaturesView: View {
List {
Section("") {
settingsRow("arrow.up.doc") {
Toggle("Send files via XFTP", isOn: $xftpSendEnabled)
Toggle("Send videos and files via XFTP", isOn: $xftpSendEnabled)
.onChange(of: xftpSendEnabled) { _ in
do {
try setXFTPConfig(getXFTPCfg())
@@ -25,6 +25,7 @@ private enum NetworkAlert: Identifiable {
struct NetworkAndServers: View {
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
@AppStorage(GROUP_DEFAULT_XFTP_SEND_ENABLED, store: groupDefaults) private var xftpSendEnabled = false
@State private var cfgLoaded = false
@State private var currentNetCfg = NetCfg.defaults
@State private var netCfg = NetCfg.defaults
@@ -37,12 +38,21 @@ struct NetworkAndServers: View {
List {
Section {
NavigationLink {
SMPServersView()
ProtocolServersView(serverProtocol: .smp)
.navigationTitle("Your SMP servers")
} label: {
Text("SMP servers")
}
if xftpSendEnabled {
NavigationLink {
ProtocolServersView(serverProtocol: .xftp)
.navigationTitle("Your XFTP servers")
} label: {
Text("XFTP servers")
}
}
Picker("Use .onion hosts", selection: $onionHosts) {
ForEach(OnionHosts.values, id: \.self) { Text($0.text) }
}
@@ -62,7 +72,7 @@ struct NetworkAndServers: View {
Text("Advanced network settings")
}
} header: {
Text("Messages")
Text("Messages & files")
} footer: {
Text("Using .onion hosts requires compatible VPN provider.")
}
@@ -9,13 +9,16 @@
import SwiftUI
import SimpleXChat
struct SMPServerView: View {
struct ProtocolServerView: View {
@Environment(\.dismiss) var dismiss: DismissAction
let serverProtocol: ServerProtocol
@Binding var server: ServerCfg
@State var serverToEdit: ServerCfg
@State private var showTestFailure = false
@State private var testing = false
@State private var testFailure: SMPTestFailure?
@State private var testFailure: ProtocolTestFailure?
var proto: String { serverProtocol.rawValue.uppercased() }
var body: some View {
ZStack {
@@ -28,7 +31,7 @@ struct SMPServerView: View {
ProgressView().scaleEffect(2)
}
}
.modifier(BackButton(label: "Your SMP servers") {
.modifier(BackButton(label: "Your \(proto) servers") {
server = serverToEdit
dismiss()
})
@@ -57,7 +60,8 @@ struct SMPServerView: View {
private func customServer() -> some View {
VStack {
let valid = parseServerAddress(serverToEdit.server)?.valid == true
let serverAddress = parseServerAddress(serverToEdit.server)
let valid = serverAddress?.valid == true && serverAddress?.serverProtocol == serverProtocol
List {
Section {
TextEditor(text: $serverToEdit.server)
@@ -144,18 +148,17 @@ struct BackButton: ViewModifier {
}
}
func testServerConnection(server: Binding<ServerCfg>) async -> SMPTestFailure? {
func testServerConnection(server: Binding<ServerCfg>) async -> ProtocolTestFailure? {
do {
let r = try await testSMPServer(smpServer: server.wrappedValue.server)
switch r {
case .success:
await MainActor.run { server.wrappedValue.tested = true }
return nil
case let .failure(f):
await MainActor.run { server.wrappedValue.tested = false }
return f
}
let r = try await testProtoServer(server: server.wrappedValue.server)
switch r {
case .success:
await MainActor.run { server.wrappedValue.tested = true }
return nil
case let .failure(f):
await MainActor.run { server.wrappedValue.tested = false }
return f
}
} catch let error {
logger.error("testServerConnection \(responseError(error))")
await MainActor.run {
@@ -169,8 +172,12 @@ func serverHostname(_ srv: String) -> String {
parseServerAddress(srv)?.hostnames.first ?? srv
}
struct SMPServerView_Previews: PreviewProvider {
struct ProtocolServerView_Previews: PreviewProvider {
static var previews: some View {
SMPServerView(server: Binding.constant(ServerCfg.sampleData.custom), serverToEdit: ServerCfg.sampleData.custom)
ProtocolServerView(
serverProtocol: .smp,
server: Binding.constant(ServerCfg.sampleData.custom),
serverToEdit: ServerCfg.sampleData.custom
)
}
}
@@ -1,5 +1,5 @@
//
// SMPServersView.swift
// ProtocolServersView.swift
// SimpleX (iOS)
//
// Created by Evgeny on 15/11/2022.
@@ -11,29 +11,35 @@ import SimpleXChat
private let howToUrl = URL(string: "https://github.com/simplex-chat/simplex-chat/blob/stable/docs/SERVER.md")!
struct SMPServersView: View {
struct ProtocolServersView: View {
@Environment(\.dismiss) var dismiss: DismissAction
@EnvironmentObject private var m: ChatModel
@Environment(\.editMode) private var editMode
@State private var servers = ChatModel.shared.userSMPServers ?? []
let serverProtocol: ServerProtocol
@State private var currServers: [ServerCfg] = []
@State private var presetServers: [String] = []
@State private var servers: [ServerCfg] = []
@State private var selectedServer: String? = nil
@State private var showAddServer = false
@State private var showScanSMPServer = false
@State private var showScanProtoServer = false
@State private var justOpened = true
@State private var testing = false
@State private var alert: SMPServerAlert? = nil
@State private var alert: ServerAlert? = nil
@State private var showSaveDialog = false
var proto: String { serverProtocol.rawValue.uppercased() }
var body: some View {
ZStack {
smpServersView()
protocolServersView()
if testing {
ProgressView().scaleEffect(2)
}
}
}
enum SMPServerAlert: Identifiable {
case testsFailed(failures: [String: SMPTestFailure])
enum ServerAlert: Identifiable {
case testsFailed(failures: [String: ProtocolTestFailure])
case error(title: LocalizedStringKey, error: LocalizedStringKey = "")
var id: String {
@@ -44,11 +50,11 @@ struct SMPServersView: View {
}
}
private func smpServersView() -> some View {
private func protocolServersView() -> some View {
List {
Section {
ForEach($servers) { srv in
smpServerView(srv)
protocolServerView(srv)
}
.onMove { indexSet, offset in
servers.move(fromOffsets: indexSet, toOffset: offset)
@@ -60,18 +66,18 @@ struct SMPServersView: View {
showAddServer = true
}
} header: {
Text("SMP servers")
Text("\(proto) servers")
} footer: {
Text("The servers for new connections of your current chat profile **\(m.currentUser?.displayName ?? "")**.")
.lineLimit(10)
}
Section {
Button("Reset") { servers = m.userSMPServers ?? [] }
.disabled(servers == m.userSMPServers || testing)
Button("Reset") { servers = currServers }
.disabled(servers == currServers || testing)
Button("Test servers", action: testServers)
.disabled(testing || allServersDisabled)
Button("Save servers", action: saveSMPServers)
Button("Save servers", action: saveServers)
.disabled(saveDisabled)
howToButton()
}
@@ -82,24 +88,26 @@ struct SMPServersView: View {
servers.append(ServerCfg.empty)
selectedServer = servers.last?.id
}
Button("Scan server QR code") { showScanSMPServer = true }
Button("Scan server QR code") { showScanProtoServer = true }
Button("Add preset servers", action: addAllPresets)
.disabled(hasAllPresets())
}
.sheet(isPresented: $showScanSMPServer) {
ScanSMPServer(servers: $servers)
.sheet(isPresented: $showScanProtoServer) {
ScanProtocolServer(servers: $servers)
}
.modifier(BackButton {
if saveDisabled {
dismiss()
justOpened = false
} else {
showSaveDialog = true
}
})
.confirmationDialog("Save servers?", isPresented: $showSaveDialog) {
Button("Save") {
saveSMPServers()
saveServers()
dismiss()
justOpened = false
}
Button("Exit without saving") { dismiss() }
}
@@ -119,11 +127,27 @@ struct SMPServersView: View {
)
}
}
.onAppear {
// this condition is needed to prevent re-setting the servers when exiting single server view
if !justOpened { return }
do {
let r = try getUserProtoServers(serverProtocol)
currServers = r.protoServers
presetServers = r.presetServers
servers = currServers
} catch let error {
alert = .error(
title: "Error loading \(proto) servers",
error: "Error: \(responseError(error))"
)
}
justOpened = false
}
}
private var saveDisabled: Bool {
servers.isEmpty ||
servers == m.userSMPServers ||
servers == currServers ||
testing ||
!servers.allSatisfy { srv in
if let address = parseServerAddress(srv.server) {
@@ -138,18 +162,22 @@ struct SMPServersView: View {
servers.allSatisfy { !$0.enabled }
}
private func smpServerView(_ server: Binding<ServerCfg>) -> some View {
private func protocolServerView(_ server: Binding<ServerCfg>) -> some View {
let srv = server.wrappedValue
return NavigationLink(tag: srv.id, selection: $selectedServer) {
SMPServerView(server: server, serverToEdit: srv)
.navigationBarTitle(srv.preset ? "Preset server" : "Your server")
.navigationBarTitleDisplayMode(.large)
ProtocolServerView(
serverProtocol: serverProtocol,
server: server,
serverToEdit: srv
)
.navigationBarTitle(srv.preset ? "Preset server" : "Your server")
.navigationBarTitleDisplayMode(.large)
} label: {
let address = parseServerAddress(srv.server)
HStack {
Group {
if let address = address {
if !address.valid {
if !address.valid || address.serverProtocol != serverProtocol {
invalidServer()
} else if !uniqueAddress(srv, address) {
Image(systemName: "exclamationmark.circle").foregroundColor(.red)
@@ -201,11 +229,11 @@ struct SMPServersView: View {
}
private func hasAllPresets() -> Bool {
m.presetSMPServers?.allSatisfy { hasPreset($0) } ?? true
presetServers.allSatisfy { hasPreset($0) }
}
private func addAllPresets() {
for srv in m.presetSMPServers ?? [] {
for srv in presetServers {
if !hasPreset(srv) {
servers.append(ServerCfg(server: srv, preset: true, tested: nil, enabled: true))
}
@@ -238,8 +266,8 @@ struct SMPServersView: View {
}
}
private func runServersTest() async -> [String: SMPTestFailure] {
var fs: [String: SMPTestFailure] = [:]
private func runServersTest() async -> [String: ProtocolTestFailure] {
var fs: [String: ProtocolTestFailure] = [:]
for i in 0..<servers.count {
if servers[i].enabled {
if let f = await testServerConnection(server: $servers[i]) {
@@ -250,21 +278,21 @@ struct SMPServersView: View {
return fs
}
func saveSMPServers() {
func saveServers() {
Task {
do {
try await setUserSMPServers(smpServers: servers)
try await setUserProtoServers(serverProtocol, servers: servers)
await MainActor.run {
m.userSMPServers = servers
currServers = servers
editMode?.wrappedValue = .inactive
}
} catch let error {
let err = responseError(error)
logger.error("saveSMPServers setUserSMPServers error: \(err)")
logger.error("saveServers setUserProtocolServers error: \(err)")
await MainActor.run {
alert = .error(
title: "Error saving SMP servers",
error: "Make sure SMP server addresses are in correct format, line separated and are not duplicated (\(responseError(error)))."
title: "Error saving \(proto) servers",
error: "Make sure \(proto) server addresses are in correct format, line separated and are not duplicated (\(responseError(error)))."
)
}
}
@@ -272,8 +300,8 @@ struct SMPServersView: View {
}
}
struct SMPServersView_Previews: PreviewProvider {
struct ProtocolServersView_Previews: PreviewProvider {
static var previews: some View {
SMPServersView()
ProtocolServersView(serverProtocol: .smp)
}
}
@@ -1,5 +1,5 @@
//
// ScanSMPServer.swift
// ScanProtocolServer.swift
// SimpleX (iOS)
//
// Created by Evgeny on 19/11/2022.
@@ -10,7 +10,7 @@ import SwiftUI
import SimpleXChat
import CodeScanner
struct ScanSMPServer: View {
struct ScanProtocolServer: View {
@Environment(\.dismiss) var dismiss: DismissAction
@Binding var servers: [ServerCfg]
@State private var showAddressError = false
@@ -47,14 +47,14 @@ struct ScanSMPServer: View {
showAddressError = true
}
case let .failure(e):
logger.error("ScanSMPServer.processQRCode QR code error: \(e.localizedDescription)")
logger.error("ScanProtocolServer.processQRCode QR code error: \(e.localizedDescription)")
dismiss()
}
}
}
struct ScanSMPServer_Previews: PreviewProvider {
struct ScanProtocolServer_Previews: PreviewProvider {
static var previews: some View {
ScanSMPServer(servers: Binding.constant([]))
ScanProtocolServer(servers: Binding.constant([]))
}
}
@@ -3081,8 +3081,8 @@ Wir werden Serverredundanzen hinzufügen, um verloren gegangene Nachrichten zu v
<target>Direktnachricht senden</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Send files via XFTP" xml:space="preserve">
<source>Send files via XFTP</source>
<trans-unit id="Send videos and files via XFTP" xml:space="preserve">
<source>Send videos and files via XFTP</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Send link previews" xml:space="preserve">
@@ -3084,9 +3084,9 @@ We will be adding server redundancy to prevent lost messages.</target>
<target>Send direct message</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Send files via XFTP" xml:space="preserve">
<source>Send files via XFTP</source>
<target>Send files via XFTP</target>
<trans-unit id="Send videos and files via XFTP" xml:space="preserve">
<source>Send videos and files via XFTP</source>
<target>Send videos and files via XFTP</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Send link previews" xml:space="preserve">
@@ -3082,8 +3082,8 @@ Añadiremos redundancia de servidores para evitar la pérdida de mensajes.</targ
<target>Enviar mensaje directo</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Send files via XFTP" xml:space="preserve">
<source>Send files via XFTP</source>
<trans-unit id="Send videos and files via XFTP" xml:space="preserve">
<source>Send videos and files via XFTP</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Send link previews" xml:space="preserve">
@@ -3081,8 +3081,8 @@ Nous allons ajouter une redondance des serveurs pour éviter la perte de message
<target>Envoi de message direct</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Send files via XFTP" xml:space="preserve">
<source>Send files via XFTP</source>
<trans-unit id="Send videos and files via XFTP" xml:space="preserve">
<source>Send videos and files via XFTP</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Send link previews" xml:space="preserve">
@@ -3081,8 +3081,8 @@ Aggiungeremo la ridondanza del server per prevenire la perdita di messaggi.</tar
<target>Invia messaggio diretto</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Send files via XFTP" xml:space="preserve">
<source>Send files via XFTP</source>
<trans-unit id="Send videos and files via XFTP" xml:space="preserve">
<source>Send videos and files via XFTP</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Send link previews" xml:space="preserve">
@@ -3081,8 +3081,8 @@ We zullen serverredundantie toevoegen om verloren berichten te voorkomen.</targe
<target>Direct bericht sturen</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Send files via XFTP" xml:space="preserve">
<source>Send files via XFTP</source>
<trans-unit id="Send videos and files via XFTP" xml:space="preserve">
<source>Send videos and files via XFTP</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Send link previews" xml:space="preserve">
@@ -3084,9 +3084,9 @@ We will be adding server redundancy to prevent lost messages.</source>
<target>Отправить сообщение</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Send files via XFTP" xml:space="preserve">
<source>Send files via XFTP</source>
<target>Отправлять файлы через XFTP</target>
<trans-unit id="Send videos and files via XFTP" xml:space="preserve">
<source>Send videos and files via XFTP</source>
<target>Отправлять видео и файлы через XFTP</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Send link previews" xml:space="preserve">
@@ -277,6 +277,12 @@ func receivedMsgNtf(_ res: ChatResponse) async -> (String, NSENotification)? {
privacyAcceptImagesGroupDefault.get() {
cItem = apiReceiveFile(fileId: file.fileId)?.chatItem ?? cItem
}
} else if case .video = cItem.content.msgContent {
if let file = cItem.file,
file.fileSize <= MAX_VIDEO_SIZE_AUTO_RCV,
privacyAcceptImagesGroupDefault.get() {
cItem = apiReceiveFile(fileId: file.fileId)?.chatItem ?? cItem
}
} else if case .voice = cItem.content.msgContent { // TODO check inlineFileMode != IFMSent
if let file = cItem.file,
file.fileSize <= MAX_IMAGE_SIZE,
+44 -36
View File
@@ -10,9 +10,11 @@
184152CEF68D2336FC2EBCB0 /* CallViewRenderers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18415B08031E8FB0F7FC27F9 /* CallViewRenderers.swift */; };
1841538E296606C74533367C /* UserPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18415835CBD939A9ABDC108A /* UserPicker.swift */; };
1841560FD1CD447955474C1D /* UserProfilesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18415845648CA4F5A8BCA272 /* UserProfilesView.swift */; };
184158C131FDB829D8A117EA /* VideoPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18415DAAAD1ADBEDB0EDA852 /* VideoPlayerView.swift */; };
1841594C978674A7B42EF0C0 /* AnimatedImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1841511920742C6E152E469F /* AnimatedImageView.swift */; };
18415B0585EB5A9A0A7CA8CD /* PressedButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18415A7F0F189D87DEFEABCA /* PressedButtonStyle.swift */; };
18415C6C56DBCEC2CBBD2F11 /* WebRTCClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18415323A4082FC92887F906 /* WebRTCClient.swift */; };
18415F9A2D551F9757DA4654 /* CIVideoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18415FD2E36F13F596A45BB4 /* CIVideoView.swift */; };
18415FEFE153C5920BFB7828 /* GroupWelcomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1841516F0CE5992B0EDFB377 /* GroupWelcomeView.swift */; };
3C71477A281C0F6800CB4D4B /* www in Resources */ = {isa = PBXBuildFile; fileRef = 3C714779281C0F6800CB4D4B /* www */; };
3C8C548928133C84000A3EC7 /* PasteToConnectView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C8C548828133C84000A3EC7 /* PasteToConnectView.swift */; };
@@ -25,7 +27,7 @@
5C05DF532840AA1D00C683F9 /* CallSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C05DF522840AA1D00C683F9 /* CallSettings.swift */; };
5C063D2727A4564100AEC577 /* ChatPreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C063D2627A4564100AEC577 /* ChatPreviewView.swift */; };
5C10D88828EED12E00E58BF0 /* ContactConnectionInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C10D88728EED12E00E58BF0 /* ContactConnectionInfo.swift */; };
5C10D88A28F187F300E58BF0 /* FullScreenImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C10D88928F187F300E58BF0 /* FullScreenImageView.swift */; };
5C10D88A28F187F300E58BF0 /* FullScreenMediaView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C10D88928F187F300E58BF0 /* FullScreenMediaView.swift */; };
5C116CDC27AABE0400E66D01 /* ContactRequestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C116CDB27AABE0400E66D01 /* ContactRequestView.swift */; };
5C13730B28156D2700F43030 /* ContactConnectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C13730A28156D2700F43030 /* ContactConnectionView.swift */; };
5C1A4C1E27A715B700EAD5AD /* ChatItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C1A4C1D27A715B700EAD5AD /* ChatItemView.swift */; };
@@ -63,10 +65,10 @@
5C7505A827B6D34800BE3227 /* ChatInfoToolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C7505A727B6D34800BE3227 /* ChatInfoToolbar.swift */; };
5C764E89279CBCB3000C6508 /* ChatModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C764E88279CBCB3000C6508 /* ChatModel.swift */; };
5C8F01CD27A6F0D8007D2C8D /* CodeScanner in Frameworks */ = {isa = PBXBuildFile; productRef = 5C8F01CC27A6F0D8007D2C8D /* CodeScanner */; };
5C93292F29239A170090FFF9 /* SMPServersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C93292E29239A170090FFF9 /* SMPServersView.swift */; };
5C93293129239BED0090FFF9 /* SMPServerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C93293029239BED0090FFF9 /* SMPServerView.swift */; };
5C93292F29239A170090FFF9 /* ProtocolServersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C93292E29239A170090FFF9 /* ProtocolServersView.swift */; };
5C93293129239BED0090FFF9 /* ProtocolServerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C93293029239BED0090FFF9 /* ProtocolServerView.swift */; };
5C93293F2928E0FD0090FFF9 /* AudioRecPlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C93293E2928E0FD0090FFF9 /* AudioRecPlay.swift */; };
5C9329412929248A0090FFF9 /* ScanSMPServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C9329402929248A0090FFF9 /* ScanSMPServer.swift */; };
5C9329412929248A0090FFF9 /* ScanProtocolServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C9329402929248A0090FFF9 /* ScanProtocolServer.swift */; };
5C971E1D27AEBEF600C8A3CE /* ChatInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C971E1C27AEBEF600C8A3CE /* ChatInfoView.swift */; };
5C971E2127AEBF8300C8A3CE /* ChatInfoImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C971E2027AEBF8300C8A3CE /* ChatInfoImage.swift */; };
5C9A5BDB2871E05400A5B906 /* SetNotificationsMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C9A5BDA2871E05400A5B906 /* SetNotificationsMode.swift */; };
@@ -97,11 +99,11 @@
5CB346E52868AA7F001FD2EF /* SuspendChat.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB346E42868AA7F001FD2EF /* SuspendChat.swift */; };
5CB346E72868D76D001FD2EF /* NotificationsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB346E62868D76D001FD2EF /* NotificationsView.swift */; };
5CB346E92869E8BA001FD2EF /* PushEnvironment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB346E82869E8BA001FD2EF /* PushEnvironment.swift */; };
5CB6348429DCD8130066AD6B /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CB6347F29DCD8130066AD6B /* libgmpxx.a */; };
5CB6348529DCD8130066AD6B /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CB6348029DCD8130066AD6B /* libffi.a */; };
5CB6348629DCD8130066AD6B /* libHSsimplex-chat-4.6.1.1-mpt0wL13f7GxVMIrCoj3B-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CB6348129DCD8130066AD6B /* libHSsimplex-chat-4.6.1.1-mpt0wL13f7GxVMIrCoj3B-ghc8.10.7.a */; };
5CB6348729DCD8130066AD6B /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CB6348229DCD8130066AD6B /* libgmp.a */; };
5CB6348829DCD8130066AD6B /* libHSsimplex-chat-4.6.1.1-mpt0wL13f7GxVMIrCoj3B.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CB6348329DCD8130066AD6B /* libHSsimplex-chat-4.6.1.1-mpt0wL13f7GxVMIrCoj3B.a */; };
5CB6349829DF7CF00066AD6B /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CB6349329DF7CF00066AD6B /* libgmpxx.a */; };
5CB6349929DF7CF00066AD6B /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CB6349429DF7CF00066AD6B /* libffi.a */; };
5CB6349A29DF7CF00066AD6B /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CB6349529DF7CF00066AD6B /* libgmp.a */; };
5CB6349B29DF7CF00066AD6B /* libHSsimplex-chat-4.6.1.1-KdibHfABD5wCx2UMHpStjD-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CB6349629DF7CF00066AD6B /* libHSsimplex-chat-4.6.1.1-KdibHfABD5wCx2UMHpStjD-ghc8.10.7.a */; };
5CB6349C29DF7CF00066AD6B /* libHSsimplex-chat-4.6.1.1-KdibHfABD5wCx2UMHpStjD.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CB6349729DF7CF00066AD6B /* libHSsimplex-chat-4.6.1.1-KdibHfABD5wCx2UMHpStjD.a */; };
5CB924D727A8563F00ACCCDD /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB924D627A8563F00ACCCDD /* SettingsView.swift */; };
5CB924E127A867BA00ACCCDD /* UserProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB924E027A867BA00ACCCDD /* UserProfile.swift */; };
5CB924E427A8683A00ACCCDD /* UserAddress.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB924E327A8683A00ACCCDD /* UserAddress.swift */; };
@@ -240,6 +242,8 @@
18415845648CA4F5A8BCA272 /* UserProfilesView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UserProfilesView.swift; sourceTree = "<group>"; };
18415A7F0F189D87DEFEABCA /* PressedButtonStyle.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PressedButtonStyle.swift; sourceTree = "<group>"; };
18415B08031E8FB0F7FC27F9 /* CallViewRenderers.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CallViewRenderers.swift; sourceTree = "<group>"; };
18415DAAAD1ADBEDB0EDA852 /* VideoPlayerView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VideoPlayerView.swift; sourceTree = "<group>"; };
18415FD2E36F13F596A45BB4 /* CIVideoView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CIVideoView.swift; sourceTree = "<group>"; };
3C714779281C0F6800CB4D4B /* www */ = {isa = PBXFileReference; lastKnownFileType = folder; name = www; path = ../android/app/src/main/assets/www; sourceTree = "<group>"; };
3C8C548828133C84000A3EC7 /* PasteToConnectView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasteToConnectView.swift; sourceTree = "<group>"; };
3CDBCF4127FAE51000354CDD /* ComposeLinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeLinkView.swift; sourceTree = "<group>"; };
@@ -251,7 +255,7 @@
5C05DF522840AA1D00C683F9 /* CallSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallSettings.swift; sourceTree = "<group>"; };
5C063D2627A4564100AEC577 /* ChatPreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatPreviewView.swift; sourceTree = "<group>"; };
5C10D88728EED12E00E58BF0 /* ContactConnectionInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactConnectionInfo.swift; sourceTree = "<group>"; };
5C10D88928F187F300E58BF0 /* FullScreenImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FullScreenImageView.swift; sourceTree = "<group>"; };
5C10D88928F187F300E58BF0 /* FullScreenMediaView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FullScreenMediaView.swift; sourceTree = "<group>"; };
5C116CDB27AABE0400E66D01 /* ContactRequestView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactRequestView.swift; sourceTree = "<group>"; };
5C13730A28156D2700F43030 /* ContactConnectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactConnectionView.swift; sourceTree = "<group>"; };
5C13730C2815740A00F43030 /* DebugJSON.playground */ = {isa = PBXFileReference; lastKnownFileType = file.playground; path = DebugJSON.playground; sourceTree = "<group>"; xcLanguageSpecificationIdentifier = xcode.lang.swift; };
@@ -307,10 +311,10 @@
5C8B41CA29AF41BC00888272 /* cs */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = cs; path = cs.lproj/Localizable.strings; sourceTree = "<group>"; };
5C8B41CB29AF44CF00888272 /* cs */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = cs; path = "cs.lproj/SimpleX--iOS--InfoPlist.strings"; sourceTree = "<group>"; };
5C8B41CC29AF44CF00888272 /* cs */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = cs; path = cs.lproj/InfoPlist.strings; sourceTree = "<group>"; };
5C93292E29239A170090FFF9 /* SMPServersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SMPServersView.swift; sourceTree = "<group>"; };
5C93293029239BED0090FFF9 /* SMPServerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SMPServerView.swift; sourceTree = "<group>"; };
5C93292E29239A170090FFF9 /* ProtocolServersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProtocolServersView.swift; sourceTree = "<group>"; };
5C93293029239BED0090FFF9 /* ProtocolServerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProtocolServerView.swift; sourceTree = "<group>"; };
5C93293E2928E0FD0090FFF9 /* AudioRecPlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioRecPlay.swift; sourceTree = "<group>"; };
5C9329402929248A0090FFF9 /* ScanSMPServer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanSMPServer.swift; sourceTree = "<group>"; };
5C9329402929248A0090FFF9 /* ScanProtocolServer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanProtocolServer.swift; sourceTree = "<group>"; };
5C971E1C27AEBEF600C8A3CE /* ChatInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatInfoView.swift; sourceTree = "<group>"; };
5C971E2027AEBF8300C8A3CE /* ChatInfoImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatInfoImage.swift; sourceTree = "<group>"; };
5C9A5BDA2871E05400A5B906 /* SetNotificationsMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetNotificationsMode.swift; sourceTree = "<group>"; };
@@ -351,11 +355,11 @@
5CB346E42868AA7F001FD2EF /* SuspendChat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuspendChat.swift; sourceTree = "<group>"; };
5CB346E62868D76D001FD2EF /* NotificationsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsView.swift; sourceTree = "<group>"; };
5CB346E82869E8BA001FD2EF /* PushEnvironment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushEnvironment.swift; sourceTree = "<group>"; };
5CB6347F29DCD8130066AD6B /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; };
5CB6348029DCD8130066AD6B /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; };
5CB6348129DCD8130066AD6B /* libHSsimplex-chat-4.6.1.1-mpt0wL13f7GxVMIrCoj3B-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-4.6.1.1-mpt0wL13f7GxVMIrCoj3B-ghc8.10.7.a"; sourceTree = "<group>"; };
5CB6348229DCD8130066AD6B /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
5CB6348329DCD8130066AD6B /* libHSsimplex-chat-4.6.1.1-mpt0wL13f7GxVMIrCoj3B.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-4.6.1.1-mpt0wL13f7GxVMIrCoj3B.a"; sourceTree = "<group>"; };
5CB6349329DF7CF00066AD6B /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; };
5CB6349429DF7CF00066AD6B /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; };
5CB6349529DF7CF00066AD6B /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
5CB6349629DF7CF00066AD6B /* libHSsimplex-chat-4.6.1.1-KdibHfABD5wCx2UMHpStjD-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-4.6.1.1-KdibHfABD5wCx2UMHpStjD-ghc8.10.7.a"; sourceTree = "<group>"; };
5CB6349729DF7CF00066AD6B /* libHSsimplex-chat-4.6.1.1-KdibHfABD5wCx2UMHpStjD.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-4.6.1.1-KdibHfABD5wCx2UMHpStjD.a"; sourceTree = "<group>"; };
5CB924D627A8563F00ACCCDD /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
5CB924E027A867BA00ACCCDD /* UserProfile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfile.swift; sourceTree = "<group>"; };
5CB924E327A8683A00ACCCDD /* UserAddress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAddress.swift; sourceTree = "<group>"; };
@@ -467,13 +471,13 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
5CB6348629DCD8130066AD6B /* libHSsimplex-chat-4.6.1.1-mpt0wL13f7GxVMIrCoj3B-ghc8.10.7.a in Frameworks */,
5CB6348829DCD8130066AD6B /* libHSsimplex-chat-4.6.1.1-mpt0wL13f7GxVMIrCoj3B.a in Frameworks */,
5CB6348429DCD8130066AD6B /* libgmpxx.a in Frameworks */,
5CB6349C29DF7CF00066AD6B /* libHSsimplex-chat-4.6.1.1-KdibHfABD5wCx2UMHpStjD.a in Frameworks */,
5CB6349B29DF7CF00066AD6B /* libHSsimplex-chat-4.6.1.1-KdibHfABD5wCx2UMHpStjD-ghc8.10.7.a in Frameworks */,
5CB6349829DF7CF00066AD6B /* libgmpxx.a in Frameworks */,
5CB6349A29DF7CF00066AD6B /* libgmp.a in Frameworks */,
5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */,
5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */,
5CB6348729DCD8130066AD6B /* libgmp.a in Frameworks */,
5CB6348529DCD8130066AD6B /* libffi.a in Frameworks */,
5CB6349929DF7CF00066AD6B /* libffi.a in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -532,11 +536,11 @@
5C764E5C279C70B7000C6508 /* Libraries */ = {
isa = PBXGroup;
children = (
5CB6348029DCD8130066AD6B /* libffi.a */,
5CB6348229DCD8130066AD6B /* libgmp.a */,
5CB6347F29DCD8130066AD6B /* libgmpxx.a */,
5CB6348129DCD8130066AD6B /* libHSsimplex-chat-4.6.1.1-mpt0wL13f7GxVMIrCoj3B-ghc8.10.7.a */,
5CB6348329DCD8130066AD6B /* libHSsimplex-chat-4.6.1.1-mpt0wL13f7GxVMIrCoj3B.a */,
5CB6349429DF7CF00066AD6B /* libffi.a */,
5CB6349529DF7CF00066AD6B /* libgmp.a */,
5CB6349329DF7CF00066AD6B /* libgmpxx.a */,
5CB6349629DF7CF00066AD6B /* libHSsimplex-chat-4.6.1.1-KdibHfABD5wCx2UMHpStjD-ghc8.10.7.a */,
5CB6349729DF7CF00066AD6B /* libHSsimplex-chat-4.6.1.1-KdibHfABD5wCx2UMHpStjD.a */,
);
path = Libraries;
sourceTree = "<group>";
@@ -584,6 +588,7 @@
5CA7DFC229302AF000F7FDDE /* AppSheet.swift */,
18415A7F0F189D87DEFEABCA /* PressedButtonStyle.swift */,
5CCB939B297EFCB100399E78 /* NavStackCompat.swift */,
18415DAAAD1ADBEDB0EDA852 /* VideoPlayerView.swift */,
);
path = Helpers;
sourceTree = "<group>";
@@ -685,9 +690,9 @@
5CB924E027A867BA00ACCCDD /* UserProfile.swift */,
5CC036DF29C488D500C0EF20 /* HiddenProfileView.swift */,
5C577F7C27C83AA10006112D /* MarkdownHelp.swift */,
5C93292E29239A170090FFF9 /* SMPServersView.swift */,
5C93293029239BED0090FFF9 /* SMPServerView.swift */,
5C9329402929248A0090FFF9 /* ScanSMPServer.swift */,
5C93292E29239A170090FFF9 /* ProtocolServersView.swift */,
5C93293029239BED0090FFF9 /* ProtocolServerView.swift */,
5C9329402929248A0090FFF9 /* ScanProtocolServer.swift */,
5CB2084E28DA4B4800D024EC /* RTCServers.swift */,
5C3F1D592844B4DE00EC8A82 /* ExperimentalFeaturesView.swift */,
64F1CC3A28B39D8600CD1FB1 /* IncognitoHelp.swift */,
@@ -753,7 +758,7 @@
5CEACCEC27DEA495000BD591 /* MsgContentView.swift */,
5C3A88D027DF57800060F1C2 /* FramedItemView.swift */,
649BCDA12805D6EF00C3A862 /* CIImageView.swift */,
5C10D88928F187F300E58BF0 /* FullScreenImageView.swift */,
5C10D88928F187F300E58BF0 /* FullScreenMediaView.swift */,
648010AA281ADD15009009B9 /* CIFileView.swift */,
644EFFDF292CFD7F00525D5B /* CIVoiceView.swift */,
3CDBCF4727FF621E00354CDD /* CILinkView.swift */,
@@ -768,6 +773,7 @@
644EFFE32937BE9700525D5B /* MarkedDeletedItemView.swift */,
1841511920742C6E152E469F /* AnimatedImageView.swift */,
6407BA82295DA85D0082BA18 /* CIInvalidJSONView.swift */,
18415FD2E36F13F596A45BB4 /* CIVideoView.swift */,
);
path = ChatItem;
sourceTree = "<group>";
@@ -1022,7 +1028,7 @@
5C6AD81327A834E300348BD7 /* NewChatButton.swift in Sources */,
5C3F1D58284363C400EC8A82 /* PrivacySettings.swift in Sources */,
5C55A923283CEDE600C4E99E /* SoundPlayer.swift in Sources */,
5C93292F29239A170090FFF9 /* SMPServersView.swift in Sources */,
5C93292F29239A170090FFF9 /* ProtocolServersView.swift in Sources */,
5CB924D727A8563F00ACCCDD /* SettingsView.swift in Sources */,
5CEACCE327DE9246000BD591 /* ComposeView.swift in Sources */,
5C65DAF929D0CC20003CEE45 /* DeveloperView.swift in Sources */,
@@ -1032,7 +1038,7 @@
5C13730B28156D2700F43030 /* ContactConnectionView.swift in Sources */,
644EFFE0292CFD7F00525D5B /* CIVoiceView.swift in Sources */,
6432857C2925443C00FBE5C8 /* GroupPreferencesView.swift in Sources */,
5C93293129239BED0090FFF9 /* SMPServerView.swift in Sources */,
5C93293129239BED0090FFF9 /* ProtocolServerView.swift in Sources */,
5C9CC7AD28C55D7800BEF955 /* DatabaseEncryptionView.swift in Sources */,
5CBD285A295711D700EC2CF4 /* ImageUtils.swift in Sources */,
5CE4407927ADB701007B033A /* EmojiItemView.swift in Sources */,
@@ -1052,7 +1058,7 @@
3CDBCF4227FAE51000354CDD /* ComposeLinkView.swift in Sources */,
3CDBCF4827FF621E00354CDD /* CILinkView.swift in Sources */,
5C7505A827B6D34800BE3227 /* ChatInfoToolbar.swift in Sources */,
5C10D88A28F187F300E58BF0 /* FullScreenImageView.swift in Sources */,
5C10D88A28F187F300E58BF0 /* FullScreenMediaView.swift in Sources */,
D72A9088294BD7A70047C86D /* NativeTextEditor.swift in Sources */,
5C00164428A26FBC0094D739 /* ContextMenu.swift in Sources */,
5C3A88D127DF57800060F1C2 /* FramedItemView.swift in Sources */,
@@ -1122,7 +1128,7 @@
6440CA00288857A10062C672 /* CIEventView.swift in Sources */,
5CB0BA92282713FD00B3292C /* CreateProfile.swift in Sources */,
5C5F2B7027EBC704006A9D5F /* ProfileImage.swift in Sources */,
5C9329412929248A0090FFF9 /* ScanSMPServer.swift in Sources */,
5C9329412929248A0090FFF9 /* ScanProtocolServer.swift in Sources */,
64AA1C6C27F3537400AC7277 /* DeletedItemView.swift in Sources */,
5C93293F2928E0FD0090FFF9 /* AudioRecPlay.swift in Sources */,
5C029EA82837DBB3004A9677 /* CICallItemView.swift in Sources */,
@@ -1143,6 +1149,8 @@
18415C6C56DBCEC2CBBD2F11 /* WebRTCClient.swift in Sources */,
184152CEF68D2336FC2EBCB0 /* CallViewRenderers.swift in Sources */,
18415FEFE153C5920BFB7828 /* GroupWelcomeView.swift in Sources */,
18415F9A2D551F9757DA4654 /* CIVideoView.swift in Sources */,
184158C131FDB829D8A117EA /* VideoPlayerView.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
+80 -31
View File
@@ -57,9 +57,9 @@ public enum ChatCommand {
case apiGroupLinkMemberRole(groupId: Int64, memberRole: GroupMemberRole)
case apiDeleteGroupLink(groupId: Int64)
case apiGetGroupLink(groupId: Int64)
case apiGetUserSMPServers(userId: Int64)
case apiSetUserSMPServers(userId: Int64, smpServers: [ServerCfg])
case apiTestSMPServer(userId: Int64, smpServer: String)
case apiGetUserProtoServers(userId: Int64, serverProtocol: ServerProtocol)
case apiSetUserProtoServers(userId: Int64, serverProtocol: ServerProtocol, servers: [ServerCfg])
case apiTestProtoServer(userId: Int64, server: String)
case apiSetChatItemTTL(userId: Int64, seconds: Int64?)
case apiGetChatItemTTL(userId: Int64)
case apiSetNetworkConfig(networkConfig: NetCfg)
@@ -158,9 +158,9 @@ public enum ChatCommand {
case let .apiGroupLinkMemberRole(groupId, memberRole): return "/_set link role #\(groupId) \(memberRole)"
case let .apiDeleteGroupLink(groupId): return "/_delete link #\(groupId)"
case let .apiGetGroupLink(groupId): return "/_get link #\(groupId)"
case let .apiGetUserSMPServers(userId): return "/_smp \(userId)"
case let .apiSetUserSMPServers(userId, smpServers): return "/_smp \(userId) \(smpServersStr(smpServers: smpServers))"
case let .apiTestSMPServer(userId, smpServer): return "/_smp test \(userId) \(smpServer)"
case let .apiGetUserProtoServers(userId, serverProtocol): return "/_servers \(userId) \(serverProtocol)"
case let .apiSetUserProtoServers(userId, serverProtocol, servers): return "/_servers \(userId) \(serverProtocol) \(protoServersStr(servers))"
case let .apiTestProtoServer(userId, server): return "/_server test \(userId) \(server)"
case let .apiSetChatItemTTL(userId, seconds): return "/_ttl \(userId) \(chatItemTTLStr(seconds: seconds))"
case let .apiGetChatItemTTL(userId): return "/_ttl \(userId)"
case let .apiSetNetworkConfig(networkConfig): return "/_network \(encodeJSON(networkConfig))"
@@ -260,9 +260,9 @@ public enum ChatCommand {
case .apiGroupLinkMemberRole: return "apiGroupLinkMemberRole"
case .apiDeleteGroupLink: return "apiDeleteGroupLink"
case .apiGetGroupLink: return "apiGetGroupLink"
case .apiGetUserSMPServers: return "apiGetUserSMPServers"
case .apiSetUserSMPServers: return "apiSetUserSMPServers"
case .apiTestSMPServer: return "testSMPServer"
case .apiGetUserProtoServers: return "apiGetUserProtoServers"
case .apiSetUserProtoServers: return "apiSetUserProtoServers"
case .apiTestProtoServer: return "apiTestProtoServer"
case .apiSetChatItemTTL: return "apiSetChatItemTTL"
case .apiGetChatItemTTL: return "apiGetChatItemTTL"
case .apiSetNetworkConfig: return "apiSetNetworkConfig"
@@ -313,8 +313,8 @@ public enum ChatCommand {
"\(type.rawValue)\(id)"
}
func smpServersStr(smpServers: [ServerCfg]) -> String {
smpServers.isEmpty ? "default" : encodeJSON(SMPServersConfig(smpServers: smpServers))
func protoServersStr(_ servers: [ServerCfg]) -> String {
encodeJSON(ProtoServersConfig(servers: servers))
}
func chatItemTTLStr(seconds: Int64?) -> String {
@@ -375,8 +375,8 @@ public enum ChatResponse: Decodable, Error {
case chatSuspended
case apiChats(user: User, chats: [ChatData])
case apiChat(user: User, chat: ChatData)
case userSMPServers(user: User, smpServers: [ServerCfg], presetSMPServers: [String])
case smpTestResult(user: User, smpTestFailure: SMPTestFailure?)
case userProtoServers(user: User, servers: UserProtoServers)
case serverTestResult(user: User, testServer: String, testFailure: ProtocolTestFailure?)
case chatItemTTL(user: User, chatItemTTL: Int64?)
case networkConfig(networkConfig: NetCfg)
case contactInfo(user: User, contact: Contact, connectionStats: ConnectionStats, customUserProfile: Profile?)
@@ -488,8 +488,8 @@ public enum ChatResponse: Decodable, Error {
case .chatSuspended: return "chatSuspended"
case .apiChats: return "apiChats"
case .apiChat: return "apiChat"
case .userSMPServers: return "userSMPServers"
case .smpTestResult: return "smpTestResult"
case .userProtoServers: return "userProtoServers"
case .serverTestResult: return "serverTestResult"
case .chatItemTTL: return "chatItemTTL"
case .networkConfig: return "networkConfig"
case .contactInfo: return "contactInfo"
@@ -601,8 +601,8 @@ public enum ChatResponse: Decodable, Error {
case .chatSuspended: return noDetails
case let .apiChats(u, chats): return withUser(u, String(describing: chats))
case let .apiChat(u, chat): return withUser(u, String(describing: chat))
case let .userSMPServers(u, smpServers, presetServers): return withUser(u, "smpServers: \(String(describing: smpServers))\npresetServers: \(String(describing: presetServers))")
case let .smpTestResult(u, smpTestFailure): return withUser(u, String(describing: smpTestFailure))
case let .userProtoServers(u, servers): return withUser(u, "servers: \(String(describing: servers))")
case let .serverTestResult(u, server, testFailure): return withUser(u, "server: \(server)\result: \(String(describing: testFailure))")
case let .chatItemTTL(u, chatItemTTL): return withUser(u, String(describing: chatItemTTL))
case let .networkConfig(networkConfig): return String(describing: networkConfig)
case let .contactInfo(u, contact, connectionStats, customUserProfile): return withUser(u, "contact: \(String(describing: contact))\nconnectionStats: \(String(describing: connectionStats))\ncustomUserProfile: \(String(describing: customUserProfile))")
@@ -760,6 +760,21 @@ struct SMPServersConfig: Encodable {
var smpServers: [ServerCfg]
}
public enum ServerProtocol: String, Decodable {
case smp
case xftp
}
public struct ProtoServersConfig: Codable {
public var servers: [ServerCfg]
}
public struct UserProtoServers: Decodable {
public var serverProtocol: ServerProtocol
public var protoServers: [ServerCfg]
public var presetServers: [String]
}
public struct ServerCfg: Identifiable, Equatable, Codable {
public var server: String
public var preset: Bool
@@ -824,29 +839,39 @@ public struct ServerCfg: Identifiable, Equatable, Codable {
}
}
public enum SMPTestStep: String, Decodable, Equatable {
public enum ProtocolTestStep: String, Decodable, Equatable {
case connect
case disconnect
case createQueue
case secureQueue
case deleteQueue
case disconnect
case createFile
case uploadFile
case downloadFile
case compareFile
case deleteFile
var text: String {
switch self {
case .connect: return NSLocalizedString("Connect", comment: "server test step")
case .disconnect: return NSLocalizedString("Disconnect", comment: "server test step")
case .createQueue: return NSLocalizedString("Create queue", comment: "server test step")
case .secureQueue: return NSLocalizedString("Secure queue", comment: "server test step")
case .deleteQueue: return NSLocalizedString("Delete queue", comment: "server test step")
case .disconnect: return NSLocalizedString("Disconnect", comment: "server test step")
case .createFile: return NSLocalizedString("Create file", comment: "server test step")
case .uploadFile: return NSLocalizedString("Upload file", comment: "server test step")
case .downloadFile: return NSLocalizedString("Download file", comment: "server test step")
case .compareFile: return NSLocalizedString("Compare file", comment: "server test step")
case .deleteFile: return NSLocalizedString("Delete file", comment: "server test step")
}
}
}
public struct SMPTestFailure: Decodable, Error, Equatable {
var testStep: SMPTestStep
public struct ProtocolTestFailure: Decodable, Error, Equatable {
var testStep: ProtocolTestStep
var testError: AgentErrorType
public static func == (l: SMPTestFailure, r: SMPTestFailure) -> Bool {
public static func == (l: ProtocolTestFailure, r: ProtocolTestFailure) -> Bool {
l.testStep == r.testStep
}
@@ -855,6 +880,8 @@ public struct SMPTestFailure: Decodable, Error, Equatable {
switch testError {
case .SMP(.AUTH):
return err + " " + NSLocalizedString("Server requires authorization to create queues, check password", comment: "server test error")
case .XFTP(.AUTH):
return err + " " + NSLocalizedString("Server requires authorization to upload, check password", comment: "server test error")
case .BROKER(_, .NETWORK):
return err + " " + NSLocalizedString("Possibly, certificate fingerprint in server address is incorrect", comment: "server test error")
default:
@@ -864,12 +891,14 @@ public struct SMPTestFailure: Decodable, Error, Equatable {
}
public struct ServerAddress: Decodable {
public var serverProtocol: ServerProtocol
public var hostnames: [String]
public var port: String
public var keyHash: String
public var basicAuth: String
public init(hostnames: [String], port: String, keyHash: String, basicAuth: String = "") {
public init(serverProtocol: ServerProtocol, hostnames: [String], port: String, keyHash: String, basicAuth: String = "") {
self.serverProtocol = serverProtocol
self.hostnames = hostnames
self.port = port
self.keyHash = keyHash
@@ -877,21 +906,25 @@ public struct ServerAddress: Decodable {
}
public var uri: String {
"smp://\(keyHash)\(basicAuth == "" ? "" : ":" + basicAuth)@\(hostnames.joined(separator: ","))"
"\(serverProtocol)://\(keyHash)\(basicAuth == "" ? "" : ":" + basicAuth)@\(hostnames.joined(separator: ","))"
}
public var valid: Bool {
hostnames.count > 0 && Set(hostnames).count == hostnames.count
}
static public var empty = ServerAddress(
hostnames: [],
port: "",
keyHash: "",
basicAuth: ""
)
static func empty(_ serverProtocol: ServerProtocol) -> ServerAddress {
ServerAddress(
serverProtocol: serverProtocol,
hostnames: [],
port: "",
keyHash: "",
basicAuth: ""
)
}
static public var sampleData = ServerAddress(
serverProtocol: .smp,
hostnames: ["smp.simplex.im", "1234.onion"],
port: "",
keyHash: "LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=",
@@ -1236,6 +1269,7 @@ public enum AgentErrorType: Decodable {
case CMD(cmdErr: CommandErrorType)
case CONN(connErr: ConnectionErrorType)
case SMP(smpErr: ProtocolErrorType)
case XFTP(xftpErr: XFTPErrorType)
case NTF(ntfErr: ProtocolErrorType)
case BROKER(brokerAddress: String, brokerErr: BrokerErrorType)
case AGENT(agentErr: SMPAgentError)
@@ -1277,6 +1311,21 @@ public enum ProtocolErrorType: Decodable {
case INTERNAL
}
public enum XFTPErrorType: Decodable {
case BLOCK
case SESSION
case CMD(cmdErr: ProtocolCommandError)
case AUTH
case SIZE
case QUOTA
case DIGEST
case CRYPTO
case NO_FILE
case HAS_FILE
case FILE_IO
case INTERNAL
}
public enum ProtocolCommandError: Decodable {
case UNKNOWN
case SYNTAX
+19
View File
@@ -2289,6 +2289,7 @@ public enum MsgContent {
case text(String)
case link(text: String, preview: LinkPreview)
case image(text: String, image: String)
case video(text: String, image: String, duration: Int)
case voice(text: String, duration: Int)
case file(String)
// TODO include original JSON, possibly using https://github.com/zoul/generic-json-swift
@@ -2299,6 +2300,7 @@ public enum MsgContent {
case let .text(text): return text
case let .link(text, _): return text
case let .image(text, _): return text
case let .video(text, _, _): return text
case let .voice(text, _): return text
case let .file(text): return text
case let .unknown(_, text): return text
@@ -2326,6 +2328,13 @@ public enum MsgContent {
}
}
public var isVideo: Bool {
switch self {
case .video: return true
default: return false
}
}
var cmdString: String {
"json \(encodeJSON(self))"
}
@@ -2356,6 +2365,11 @@ extension MsgContent: Decodable {
let text = try container.decode(String.self, forKey: CodingKeys.text)
let image = try container.decode(String.self, forKey: CodingKeys.image)
self = .image(text: text, image: image)
case "video":
let text = try container.decode(String.self, forKey: CodingKeys.text)
let image = try container.decode(String.self, forKey: CodingKeys.image)
let duration = try container.decode(Int.self, forKey: CodingKeys.duration)
self = .video(text: text, image: image, duration: duration)
case "voice":
let text = try container.decode(String.self, forKey: CodingKeys.text)
let duration = try container.decode(Int.self, forKey: CodingKeys.duration)
@@ -2388,6 +2402,11 @@ extension MsgContent: Encodable {
try container.encode("image", forKey: .type)
try container.encode(text, forKey: .text)
try container.encode(image, forKey: .image)
case let .video(text, image, duration):
try container.encode("video", forKey: .type)
try container.encode(text, forKey: .text)
try container.encode(image, forKey: .image)
try container.encode(duration, forKey: .duration)
case let .voice(text, duration):
try container.encode("voice", forKey: .type)
try container.encode(text, forKey: .text)
+10
View File
@@ -16,6 +16,8 @@ public let MAX_IMAGE_SIZE: Int64 = 236700
public let MAX_IMAGE_SIZE_AUTO_RCV: Int64 = MAX_IMAGE_SIZE * 2
public let MAX_VIDEO_SIZE_AUTO_RCV: Int64 = 8000000
public let MAX_FILE_SIZE_XFTP: Int64 = 1_073_741_824
public let MAX_FILE_SIZE_SMP: Int64 = 8000000
@@ -182,6 +184,14 @@ public func saveFile(_ data: Data, _ fileName: String) -> String? {
}
}
public func removeFile(_ url: URL) {
do {
try FileManager.default.removeItem(atPath: url.path)
} catch {
logger.error("FileUtils.removeFile error: \(error.localizedDescription)")
}
}
public func removeFile(_ fileName: String) {
do {
try FileManager.default.removeItem(atPath: getAppFilePath(fileName).path)
+1 -1
View File
@@ -2125,7 +2125,7 @@
"Send direct message" = "Отправить сообщение";
/* No comment provided by engineer. */
"Send files via XFTP" = "Отправлять файлы через XFTP";
"Send videos and files via XFTP" = "Отправлять видео и файлы через XFTP";
/* No comment provided by engineer. */
"Send link previews" = "Отправлять картинки ссылок";
+2 -2
View File
@@ -7,7 +7,7 @@ constraints: zip +disable-bzip2 +disable-zstd
source-repository-package
type: git
location: https://github.com/simplex-chat/simplexmq.git
tag: 44f0dd39f3d1536c979b09e268dbdf681f9b0bb8
tag: 215d2414b7a76e3d501017b24fb6b301bf022546
source-repository-package
type: git
@@ -17,7 +17,7 @@ source-repository-package
source-repository-package
type: git
location: https://github.com/kazu-yamamoto/http2.git
tag: 159417b413a684a9b754e10e4a5db4376aa8c6b9
tag: b5a1b7200cf5bc7044af34ba325284271f6dff25
source-repository-package
type: git
+1
View File
@@ -20,6 +20,7 @@ dependencies:
- base64-bytestring >= 1.0 && < 1.3
- bytestring == 0.10.*
- composition == 1.0.*
- constraints >= 0.12 && < 0.14
- containers == 0.6.*
- cryptonite >= 0.27 && < 0.30
- directory == 1.3.*
+2 -2
View File
@@ -1,7 +1,7 @@
{
"https://github.com/simplex-chat/simplexmq.git"."44f0dd39f3d1536c979b09e268dbdf681f9b0bb8" = "0dh0q2vng374kkq8s1lnnv658xfv6q7b9cgshiqxs9hxij64kxav";
"https://github.com/simplex-chat/simplexmq.git"."215d2414b7a76e3d501017b24fb6b301bf022546" = "1gzq4zlpndlanvg9ryhyz29fd60xic6dnprsijy6wc78cf6rw3vb";
"https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38";
"https://github.com/kazu-yamamoto/http2.git"."159417b413a684a9b754e10e4a5db4376aa8c6b9" = "17jjw582f4ls1m14abym1p0xlpjx1viqsfcpl4fkykv0sksbxdg7";
"https://github.com/kazu-yamamoto/http2.git"."b5a1b7200cf5bc7044af34ba325284271f6dff25" = "0dqb50j57an64nf4qcf5vcz4xkd1vzvghvf8bk529c1k30r9nfzb";
"https://github.com/simplex-chat/direct-sqlcipher.git"."34309410eb2069b029b8fc1872deb1e0db123294" = "0kwkmhyfsn2lixdlgl15smgr1h5gjk7fky6abzh8rng2h5ymnffd";
"https://github.com/simplex-chat/sqlcipher-simple.git"."5e154a2aeccc33ead6c243ec07195ab673137221" = "1d1gc5wax4vqg0801ajsmx1sbwvd9y7p7b8mmskvqsmpbwgbh0m0";
"https://github.com/simplex-chat/aeson.git"."3eb66f9a68f103b5f1489382aad89f5712a64db7" = "0kilkx59fl6c3qy3kjczqvm8c3f4n3p0bdk9biyflf51ljnzp4yp";
+7
View File
@@ -89,6 +89,7 @@ library
Simplex.Chat.Migrations.M20230318_file_description
Simplex.Chat.Migrations.M20230321_agent_file_deleted
Simplex.Chat.Migrations.M20230328_files_protocol
Simplex.Chat.Migrations.M20230402_protocol_servers
Simplex.Chat.Mobile
Simplex.Chat.Mobile.WebRTC
Simplex.Chat.Options
@@ -117,6 +118,7 @@ library
, base64-bytestring >=1.0 && <1.3
, bytestring ==0.10.*
, composition ==1.0.*
, constraints >=0.12 && <0.14
, containers ==0.6.*
, cryptonite >=0.27 && <0.30
, direct-sqlcipher ==2.3.*
@@ -164,6 +166,7 @@ executable simplex-bot
, base64-bytestring >=1.0 && <1.3
, bytestring ==0.10.*
, composition ==1.0.*
, constraints >=0.12 && <0.14
, containers ==0.6.*
, cryptonite >=0.27 && <0.30
, direct-sqlcipher ==2.3.*
@@ -212,6 +215,7 @@ executable simplex-bot-advanced
, base64-bytestring >=1.0 && <1.3
, bytestring ==0.10.*
, composition ==1.0.*
, constraints >=0.12 && <0.14
, containers ==0.6.*
, cryptonite >=0.27 && <0.30
, direct-sqlcipher ==2.3.*
@@ -261,6 +265,7 @@ executable simplex-broadcast-bot
, base64-bytestring >=1.0 && <1.3
, bytestring ==0.10.*
, composition ==1.0.*
, constraints >=0.12 && <0.14
, containers ==0.6.*
, cryptonite >=0.27 && <0.30
, direct-sqlcipher ==2.3.*
@@ -310,6 +315,7 @@ executable simplex-chat
, base64-bytestring >=1.0 && <1.3
, bytestring ==0.10.*
, composition ==1.0.*
, constraints >=0.12 && <0.14
, containers ==0.6.*
, cryptonite >=0.27 && <0.30
, direct-sqlcipher ==2.3.*
@@ -372,6 +378,7 @@ test-suite simplex-chat-test
, base64-bytestring >=1.0 && <1.3
, bytestring ==0.10.*
, composition ==1.0.*
, constraints >=0.12 && <0.14
, containers ==0.6.*
, cryptonite >=0.27 && <0.30
, deepseq ==1.4.*
+88 -72
View File
@@ -29,6 +29,7 @@ import qualified Data.ByteString.Base64 as B64
import Data.ByteString.Char8 (ByteString)
import qualified Data.ByteString.Char8 as B
import Data.Char (isSpace)
import Data.Constraint (Dict (..))
import Data.Either (fromRight, rights)
import Data.Fixed (div')
import Data.Functor (($>))
@@ -72,7 +73,7 @@ import qualified Simplex.Messaging.Crypto as C
import Simplex.Messaging.Encoding
import Simplex.Messaging.Encoding.String
import Simplex.Messaging.Parsers (base64P)
import Simplex.Messaging.Protocol (EntityId, ErrorType (..), MsgBody, MsgFlags (..), NtfServer, ProtoServerWithAuth, ProtocolType (..), ProtocolTypeI)
import Simplex.Messaging.Protocol (AProtoServerWithAuth (..), AProtocolType (..), EntityId, ErrorType (..), MsgBody, MsgFlags (..), NtfServer, ProtoServerWithAuth, ProtocolTypeI, SProtocolType (..), UserProtocol, userProtocol)
import qualified Simplex.Messaging.Protocol as SMP
import qualified Simplex.Messaging.TMap as TM
import Simplex.Messaging.Transport.Client (defaultSocksProxy)
@@ -182,27 +183,32 @@ newChatController ChatDatabase {chatStore, agentStore} user cfg@ChatConfig {agen
agentServers :: ChatConfig -> IO InitialAgentServers
agentServers config@ChatConfig {defaultServers = defServers@DefaultAgentServers {ntf, netCfg}} = do
users <- withTransaction chatStore getUsers
smp' <- getUserServers users smp
xftp' <- getUserServers users xftp
smp' <- getUserServers users SPSMP
xftp' <- getUserServers users SPXFTP
pure InitialAgentServers {smp = smp', xftp = xftp', ntf, netCfg}
where
getUserServers :: forall p. ProtocolTypeI p => [User] -> (DefaultAgentServers -> NonEmpty (ProtoServerWithAuth p)) -> IO (Map UserId (NonEmpty (ProtoServerWithAuth p)))
getUserServers users srvSel = case users of
[] -> pure $ M.fromList [(1, srvSel defServers)]
getUserServers :: forall p. (ProtocolTypeI p, UserProtocol p) => [User] -> SProtocolType p -> IO (Map UserId (NonEmpty (ProtoServerWithAuth p)))
getUserServers users protocol = case users of
[] -> pure $ M.fromList [(1, cfgServers protocol defServers)]
_ -> M.fromList <$> initialServers
where
initialServers :: IO [(UserId, NonEmpty (ProtoServerWithAuth p))]
initialServers = mapM (\u -> (aUserId u,) <$> userServers u) users
userServers :: User -> IO (NonEmpty (ProtoServerWithAuth p))
userServers user' = activeAgentServers config srvSel <$> withTransaction chatStore (`getProtocolServers` user')
userServers user' = activeAgentServers config protocol <$> withTransaction chatStore (`getProtocolServers` user')
activeAgentServers :: ChatConfig -> (DefaultAgentServers -> NonEmpty (ProtoServerWithAuth p)) -> [ServerCfg p] -> NonEmpty (ProtoServerWithAuth p)
activeAgentServers ChatConfig {defaultServers} srvSel =
fromMaybe (srvSel defaultServers)
activeAgentServers :: UserProtocol p => ChatConfig -> SProtocolType p -> [ServerCfg p] -> NonEmpty (ProtoServerWithAuth p)
activeAgentServers ChatConfig {defaultServers} p =
fromMaybe (cfgServers p defaultServers)
. nonEmpty
. map (\ServerCfg {server} -> server)
. filter (\ServerCfg {enabled} -> enabled)
cfgServers :: UserProtocol p => SProtocolType p -> (DefaultAgentServers -> NonEmpty (ProtoServerWithAuth p))
cfgServers = \case
SPSMP -> smp
SPXFTP -> xftp
startChatController :: forall m. ChatMonad' m => Bool -> Bool -> m (Async ())
startChatController subConns enableExpireCIs = do
asks smpAgent >>= resumeAgentClient
@@ -294,33 +300,37 @@ processChatCommand = \case
ShowActiveUser -> withUser' $ pure . CRActiveUser
CreateActiveUser p@Profile {displayName} sameServers -> do
u <- asks currentUser
(smp, smpServers) <- chooseServers
(smp, smpServers) <- chooseServers SPSMP
(xftp, xftpServers) <- chooseServers SPXFTP
auId <-
withStore' getUsers >>= \case
[] -> pure 1
users -> do
when (any (\User {localDisplayName = n} -> n == displayName) users) $
throwChatError $ CEUserExists displayName
withAgent (`createUser` smp)
withAgent (\a -> createUser a smp xftp)
user <- withStore $ \db -> createUserRecord db (AgentUserId auId) p True
unless (null smpServers) $
withStore $ \db -> overwriteSMPServers db user smpServers
storeServers user smpServers
storeServers user xftpServers
setActive ActiveNone
atomically . writeTVar u $ Just user
pure $ CRActiveUser user
where
chooseServers :: m (NonEmpty SMPServerWithAuth, [ServerCfg 'PSMP])
chooseServers
chooseServers :: (ProtocolTypeI p, UserProtocol p) => SProtocolType p -> m (NonEmpty (ProtoServerWithAuth p), [ServerCfg p])
chooseServers protocol
| sameServers =
asks currentUser >>= readTVarIO >>= \case
Nothing -> throwChatError CENoActiveUser
Just user -> do
smpServers <- withStore' (`getSMPServers` user)
smpServers <- withStore' (`getProtocolServers` user)
cfg <- asks config
pure (activeAgentServers cfg smp smpServers, smpServers)
pure (activeAgentServers cfg protocol smpServers, smpServers)
| otherwise = do
DefaultAgentServers {smp} <- asks $ defaultServers . config
pure (smp, [])
defServers <- asks $ defaultServers . config
pure (cfgServers protocol defServers, [])
storeServers user servers =
unless (null servers) $
withStore $ \db -> overwriteProtocolServers db user servers
ListUsers -> CRUsersList <$> withStore' getUsersInfo
APISetActiveUser userId' viewPwd_ -> withUser $ \user -> do
user' <- privateGetUser userId'
@@ -902,30 +912,29 @@ processChatCommand = \case
pure user_ $>>= \user ->
withStore (\db -> Just <$> getConnectionEntity db user agentConnId) `catchError` (\e -> toView (CRChatError (Just user) e) $> Nothing)
pure CRNtfMessages {user_, connEntity, msgTs = msgTs', ntfMessages}
APIGetUserSMPServers userId -> withUserId userId $ \user -> do
ChatConfig {defaultServers = DefaultAgentServers {smp = defaultSMPServers}} <- asks config
smpServers <- withStore' (`getSMPServers` user)
let smpServers' = fromMaybe (L.map toServerCfg defaultSMPServers) $ nonEmpty smpServers
pure $ CRUserSMPServers user smpServers' defaultSMPServers
APIGetUserProtoServers userId (AProtocolType p) -> withUserId userId $ \user -> withServerProtocol p $ do
ChatConfig {defaultServers} <- asks config
servers <- withStore' (`getProtocolServers` user)
let defServers = cfgServers p defaultServers
servers' = fromMaybe (L.map toServerCfg defServers) $ nonEmpty servers
pure $ CRUserProtoServers user $ AUPS $ UserProtoServers p servers' defServers
where
toServerCfg server = ServerCfg {server, preset = True, tested = Nothing, enabled = True}
GetUserSMPServers -> withUser $ \User {userId} ->
processChatCommand $ APIGetUserSMPServers userId
APISetUserSMPServers userId (SMPServersConfig smpServers) -> withUserId userId $ \user -> withChatLock "setUserSMPServers" $ do
withStore $ \db -> overwriteSMPServers db user smpServers
cfg <- asks config
withAgent $ \a -> setSMPServers a (aUserId user) $ activeAgentServers cfg smp smpServers
ok user
SetUserSMPServers smpServersConfig -> withUser $ \User {userId} ->
processChatCommand $ APISetUserSMPServers userId smpServersConfig
APIGetUserServers _ -> pure $ chatCmdError Nothing "TODO"
GetUserServers -> pure $ chatCmdError Nothing "TODO"
APISetUserServers _ _ -> pure $ chatCmdError Nothing "TODO"
SetUserServers _ -> pure $ chatCmdError Nothing "TODO"
APITestSMPServer userId smpServer -> withUserId userId $ \user ->
CRSmpTestResult user <$> withAgent (\a -> testSMPServerConnection a (aUserId user) smpServer)
TestSMPServer smpServer -> withUser $ \User {userId} ->
processChatCommand $ APITestSMPServer userId smpServer
GetUserProtoServers aProtocol -> withUser $ \User {userId} ->
processChatCommand $ APIGetUserProtoServers userId aProtocol
APISetUserProtoServers userId (APSC p (ProtoServersConfig servers)) -> withUserId userId $ \user -> withServerProtocol p $
withChatLock "setUserSMPServers" $ do
withStore $ \db -> overwriteProtocolServers db user servers
cfg <- asks config
withAgent $ \a -> setProtocolServers a (aUserId user) $ activeAgentServers cfg p servers
ok user
SetUserProtoServers serversConfig -> withUser $ \User {userId} ->
processChatCommand $ APISetUserProtoServers userId serversConfig
APITestProtoServer userId srv@(AProtoServerWithAuth p server) -> withUserId userId $ \user ->
withServerProtocol p $
CRServerTestResult user srv <$> withAgent (\a -> testProtocolServer a (aUserId user) server)
TestProtoServer srv -> withUser $ \User {userId} ->
processChatCommand $ APITestProtoServer userId srv
APISetChatItemTTL userId newTTL_ -> withUser' $ \user -> do
checkSameUser userId user
checkStoreNotChanged $
@@ -1061,8 +1070,9 @@ processChatCommand = \case
pure $ CRInvitation user cReq
AddContact -> withUser $ \User {userId} ->
processChatCommand $ APIAddContact userId
APIConnect userId (Just (ACR SCMInvitation cReq)) -> withUserId userId $ \user -> withChatLock "connect" . procCmd $ do
APIConnect userId (Just (ACR SCMInvitation cReq)) mc_ -> withUserId userId $ \user -> withChatLock "connect" . procCmd $ do
-- [incognito] generate profile to send
when (isJust mc_) $ throwChatError CEConnReqMessageProhibited
incognito <- readTVarIO =<< asks incognitoMode
incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing
let profileToSend = userProfileToSend user incognitoProfile Nothing
@@ -1070,13 +1080,11 @@ processChatCommand = \case
conn <- withStore' $ \db -> createDirectConnection db user connId cReq ConnJoined $ incognitoProfile $> profileToSend
toView $ CRNewContactConnection user conn
pure $ CRSentConfirmation user
APIConnect userId (Just (ACR SCMContact cReq)) -> withUserId userId (`connectViaContact` cReq)
APIConnect _ Nothing -> throwChatError CEInvalidConnReq
Connect cReqUri -> withUser $ \User {userId} ->
processChatCommand $ APIConnect userId cReqUri
ConnectSimplex -> withUser $ \user ->
-- [incognito] generate profile to send
connectViaContact user adminContactReq
APIConnect userId (Just (ACR SCMContact cReq)) mc_ -> withUserId userId $ \user -> connectViaContact user cReq mc_
APIConnect _ Nothing _ -> throwChatError CEInvalidConnReq
Connect cReqUri msg_ -> withUser $ \User {userId} ->
processChatCommand $ APIConnect userId cReqUri $ MCText <$> msg_
ConnectSimplex msg_ -> withUser $ \user -> connectViaContact user adminContactReq $ MCText <$> msg_
DeleteContact cName -> withContactName cName $ APIDeleteChat . ChatRef CTDirect
ClearContact cName -> withContactName cName $ APIClearChat . ChatRef CTDirect
APIListContacts userId -> withUserId userId $ \user ->
@@ -1527,12 +1535,14 @@ processChatCommand = \case
CTDirect -> withStore $ \db -> getDirectChatItemIdByText db userId cId SMDSnd msg
CTGroup -> withStore $ \db -> getGroupChatItemIdByText db user cId (Just localDisplayName) msg
_ -> throwChatError $ CECommandError "not supported"
connectViaContact :: User -> ConnectionRequestUri 'CMContact -> m ChatResponse
connectViaContact user@User {userId} cReq@(CRContactUri ConnReqUriData {crClientData}) = withChatLock "connectViaContact" $ do
connectViaContact :: User -> ConnectionRequestUri 'CMContact -> Maybe MsgContent -> m ChatResponse
connectViaContact user@User {userId} cReq@(CRContactUri ConnReqUriData {crClientData}) mc_ = withChatLock "connectViaContact" $ do
let cReqHash = ConnReqUriHash . C.sha256Hash $ strEncode cReq
withStore' (\db -> getConnReqContactXContactId db user cReqHash) >>= \case
(Just contact, _) -> pure $ CRContactAlreadyExists user contact
(_, xContactId_) -> procCmd $ do
let groupLinkId = crClientData >>= decodeJSON >>= \(CRDataGroup gli) -> Just gli
when (isJust groupLinkId && isJust mc_) $ throwChatError CEConnReqMessageProhibited
let randomXContactId = XContactId <$> drgRandomBytes 16
xContactId <- maybe randomXContactId pure xContactId_
-- [incognito] generate profile to send
@@ -1543,8 +1553,7 @@ processChatCommand = \case
incognito <- readTVarIO =<< asks incognitoMode
incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing
let profileToSend = userProfileToSend user incognitoProfile Nothing
connId <- withAgent $ \a -> joinConnection a (aUserId user) True cReq $ directMessage (XContact profileToSend $ Just xContactId)
let groupLinkId = crClientData >>= decodeJSON >>= \(CRDataGroup gli) -> Just gli
connId <- withAgent $ \a -> joinConnection a (aUserId user) True cReq $ directMessage (XContact profileToSend (Just xContactId) mc_)
conn <- withStore' $ \db -> createConnReqConnection db userId connId cReqHash xContactId incognitoProfile groupLinkId
toView $ CRNewContactConnection user conn
pure $ CRSentInvitation user incognitoProfile
@@ -1661,6 +1670,10 @@ processChatCommand = \case
atomically $ TM.delete ctId calls
ok user
| otherwise -> throwChatError $ CECallContact contactId
withServerProtocol :: ProtocolTypeI p => SProtocolType p -> (UserProtocol p => m a) -> m a
withServerProtocol p action = case userProtocol p of
Just Dict -> action
_ -> throwChatError $ CEServerProtocol $ AProtocolType p
forwardFile :: ChatName -> FileTransferId -> (ChatName -> FilePath -> ChatCommand) -> m ChatResponse
forwardFile chatName fileId sendCommand = withUser $ \user -> do
withStore (\db -> getFileTransfer db user fileId) >>= \case
@@ -2908,8 +2921,8 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do
REQ invId _ connInfo -> do
ChatMessage {chatMsgEvent} <- parseChatMessage connInfo
case chatMsgEvent of
XContact p xContactId_ -> profileContactRequest invId p xContactId_
XInfo p -> profileContactRequest invId p Nothing
XContact p xContactId_ mc_ -> profileContactRequest invId p xContactId_ mc_
XInfo p -> profileContactRequest invId p Nothing Nothing
-- TODO show/log error, other events in contact request
_ -> pure ()
MERR _ err -> do
@@ -2921,19 +2934,21 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do
-- TODO add debugging output
_ -> pure ()
where
profileContactRequest :: InvitationId -> Profile -> Maybe XContactId -> m ()
profileContactRequest invId p xContactId_ = do
profileContactRequest :: InvitationId -> Profile -> Maybe XContactId -> Maybe MsgContent -> m ()
profileContactRequest invId p xContactId_ _mc_ = do
withStore (\db -> createOrUpdateContactRequest db user userContactLinkId invId p xContactId_) >>= \case
CORContact contact -> toView $ CRContactRequestAlreadyAccepted user contact
CORRequest cReq@UserContactRequest {localDisplayName} -> do
withStore' (\db -> getUserContactLinkById db userId userContactLinkId) >>= \case
Just (UserContactLink {autoAccept}, groupId_, _) ->
-- TODO add chat item for contact request
case autoAccept of
Just AutoAccept {acceptIncognito} -> case groupId_ of
Nothing -> do
-- [incognito] generate profile to send, create connection with incognito profile
incognitoProfile <- if acceptIncognito then Just . NewIncognito <$> liftIO generateRandomProfile else pure Nothing
ct <- acceptContactRequestAsync user cReq incognitoProfile
-- TODO move chat item to contact
toView $ CRAcceptingContactRequest user ct
Just groupId -> do
gInfo@GroupInfo {membership = membership@GroupMember {memberProfile}} <- withStore $ \db -> getGroupInfo db user groupId
@@ -4457,17 +4472,17 @@ chatCommandP =
"/_remove #" *> (APIRemoveMember <$> A.decimal <* A.space <*> A.decimal),
"/_leave #" *> (APILeaveGroup <$> A.decimal),
"/_members #" *> (APIListMembers <$> A.decimal),
-- /smp_servers is deprecated, use /smp and /_smp
"/smp_servers default" $> SetUserSMPServers (SMPServersConfig []),
"/smp_servers " *> (SetUserSMPServers . SMPServersConfig . map toServerCfg <$> smpServersP),
"/smp_servers" $> GetUserSMPServers,
"/smp default" $> SetUserSMPServers (SMPServersConfig []),
"/_smp test " *> (APITestSMPServer <$> A.decimal <* A.space <*> strP),
"/smp test " *> (TestSMPServer <$> strP),
"/_smp " *> (APISetUserSMPServers <$> A.decimal <* A.space <*> jsonP),
"/smp " *> (SetUserSMPServers . SMPServersConfig . map toServerCfg <$> smpServersP),
"/_smp " *> (APIGetUserSMPServers <$> A.decimal),
"/smp" $> GetUserSMPServers,
"/_server test " *> (APITestProtoServer <$> A.decimal <* A.space <*> strP),
"/smp test " *> (TestProtoServer . AProtoServerWithAuth SPSMP <$> strP),
"/xftp test " *> (TestProtoServer . AProtoServerWithAuth SPXFTP <$> strP),
"/_servers " *> (APISetUserProtoServers <$> A.decimal <* A.space <*> srvCfgP),
"/smp " *> (SetUserProtoServers . APSC SPSMP . ProtoServersConfig . map toServerCfg <$> protocolServersP),
"/smp default" $> SetUserProtoServers (APSC SPSMP $ ProtoServersConfig []),
"/xftp " *> (SetUserProtoServers . APSC SPXFTP . ProtoServersConfig . map toServerCfg <$> protocolServersP),
"/xftp default" $> SetUserProtoServers (APSC SPXFTP $ ProtoServersConfig []),
"/_servers " *> (APIGetUserProtoServers <$> A.decimal <* A.space <*> strP),
"/smp" $> GetUserProtoServers (AProtocolType SPSMP),
"/xftp" $> GetUserProtoServers (AProtocolType SPXFTP),
"/_ttl " *> (APISetChatItemTTL <$> A.decimal <* A.space <*> ciTTLDecimal),
"/ttl " *> (SetChatItemTTL <$> ciTTL),
"/_ttl " *> (APIGetChatItemTTL <$> A.decimal),
@@ -4533,9 +4548,9 @@ chatCommandP =
(">#" <|> "> #") *> (SendGroupMessageQuote <$> displayName <* A.space <* char_ '@' <*> (Just <$> displayName) <* A.space <*> quotedMsg <*> msgTextP),
"/_contacts " *> (APIListContacts <$> A.decimal),
"/contacts" $> ListContacts,
"/_connect " *> (APIConnect <$> A.decimal <* A.space <*> ((Just <$> strP) <|> A.takeByteString $> Nothing)),
"/_connect " *> (APIConnect <$> A.decimal <* A.space <*> ((Just <$> strP) <|> A.takeTill (== ' ') $> Nothing) <*> optional (A.space *> msgContentP)),
"/_connect " *> (APIAddContact <$> A.decimal),
("/connect " <|> "/c ") *> (Connect <$> ((Just <$> strP) <|> A.takeByteString $> Nothing)),
("/connect " <|> "/c ") *> (Connect <$> ((Just <$> strP) <|> A.takeTill (== ' ') $> Nothing) <*> optional (A.space *> msgTextP)),
("/connect" <|> "/c") $> AddContact,
SendMessage <$> chatNameP <* A.space <*> msgTextP,
"/live " *> (SendLiveMessage <$> chatNameP <*> (A.space *> msgTextP <|> pure "")),
@@ -4559,7 +4574,7 @@ chatCommandP =
("/freceive " <|> "/fr ") *> (ReceiveFile <$> A.decimal <*> optional (" inline=" *> onOffP) <*> optional (A.space *> filePath)),
("/fcancel " <|> "/fc ") *> (CancelFile <$> A.decimal),
("/fstatus " <|> "/fs ") *> (FileStatus <$> A.decimal),
"/simplex" $> ConnectSimplex,
"/simplex" *> (ConnectSimplex <$> optional (A.space *> msgTextP)),
"/_address " *> (APICreateMyAddress <$> A.decimal),
("/address" <|> "/ad") $> CreateMyAddress,
"/_delete_address " *> (APIDeleteMyAddress <$> A.decimal),
@@ -4686,6 +4701,7 @@ chatCommandP =
onOffP
(Just <$> (AutoAccept <$> (" incognito=" *> onOffP <|> pure False) <*> optional (A.space *> msgContentP)))
(pure Nothing)
srvCfgP = strP >>= \case AProtocolType p -> APSC p <$> (A.space *> jsonP)
toServerCfg server = ServerCfg {server, preset = False, tested = Nothing, enabled = True}
char_ = optional . A.char
+42 -21
View File
@@ -4,9 +4,11 @@
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE GADTs #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE StandaloneDeriving #-}
{-# LANGUAGE StrictData #-}
{-# LANGUAGE TemplateHaskell #-}
@@ -45,7 +47,7 @@ import Simplex.Chat.Protocol
import Simplex.Chat.Store (AutoAccept, StoreError, UserContactLink)
import Simplex.Chat.Types
import Simplex.Messaging.Agent (AgentClient)
import Simplex.Messaging.Agent.Client (AgentLocks, SMPTestFailure)
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
@@ -54,7 +56,7 @@ import qualified Simplex.Messaging.Crypto as C
import Simplex.Messaging.Encoding.String
import Simplex.Messaging.Notifications.Protocol (DeviceToken (..), NtfTknStatus)
import Simplex.Messaging.Parsers (dropPrefix, enumJSON, parseAll, parseString, sumTypeJSON)
import Simplex.Messaging.Protocol (AProtocolType, CorrId, MsgFlags, NtfServer, ProtocolType (..), QueueId, XFTPServerWithAuth)
import Simplex.Messaging.Protocol (AProtoServerWithAuth, AProtocolType, CorrId, MsgFlags, NtfServer, ProtoServerWithAuth, ProtocolTypeI, QueueId, SProtocolType, UserProtocol, XFTPServerWithAuth)
import Simplex.Messaging.TMap (TMap)
import Simplex.Messaging.Transport (simplexMQVersion)
import Simplex.Messaging.Transport.Client (TransportHost)
@@ -258,16 +260,12 @@ data ChatCommand
| APIGroupLinkMemberRole GroupId GroupMemberRole
| APIDeleteGroupLink GroupId
| APIGetGroupLink GroupId
| APIGetUserSMPServers UserId
| GetUserSMPServers
| APISetUserSMPServers UserId SMPServersConfig
| SetUserSMPServers SMPServersConfig
| APIGetUserServers UserId
| GetUserServers
| APISetUserServers UserId ServersConfig
| SetUserServers ServersConfig
| APITestSMPServer UserId SMPServerWithAuth
| TestSMPServer SMPServerWithAuth
| APIGetUserProtoServers UserId AProtocolType
| GetUserProtoServers AProtocolType
| APISetUserProtoServers UserId AProtoServersConfig
| SetUserProtoServers AProtoServersConfig
| APITestProtoServer UserId AProtoServerWithAuth
| TestProtoServer AProtoServerWithAuth
| APISetChatItemTTL UserId (Maybe Int64)
| SetChatItemTTL (Maybe Int64)
| APIGetChatItemTTL UserId
@@ -300,9 +298,9 @@ data ChatCommand
| Welcome
| APIAddContact UserId
| AddContact
| APIConnect UserId (Maybe AConnectionRequestUri)
| Connect (Maybe AConnectionRequestUri)
| ConnectSimplex -- UserId (not used in UI)
| APIConnect UserId (Maybe AConnectionRequestUri) (Maybe MsgContent)
| Connect (Maybe AConnectionRequestUri) (Maybe Text)
| ConnectSimplex (Maybe Text)
| DeleteContact ContactName
| ClearContact ContactName
| APIListContacts UserId
@@ -386,8 +384,8 @@ data ChatResponse
| CRChatItems {user :: User, chatItems :: [AChatItem]}
| CRChatItemId User (Maybe ChatItemId)
| CRApiParsedMarkdown {formattedText :: Maybe MarkdownList}
| CRUserSMPServers {user :: User, smpServers :: NonEmpty (ServerCfg 'PSMP), presetSMPServers :: NonEmpty SMPServerWithAuth}
| CRSmpTestResult {user :: User, smpTestFailure :: Maybe SMPTestFailure}
| CRUserProtoServers {user :: User, servers :: AUserProtoServers}
| CRServerTestResult {user :: User, testServer :: AProtoServerWithAuth, testFailure :: Maybe ProtocolTestFailure}
| CRChatItemTTL {user :: User, chatItemTTL :: Maybe Int64}
| CRNetworkConfig {networkConfig :: NetworkConfig}
| CRContactInfo {user :: User, contact :: Contact, connectionStats :: ConnectionStats, customUserProfile :: Maybe Profile}
@@ -566,11 +564,31 @@ instance ToJSON AgentQueueId where
toJSON = strToJSON
toEncoding = strToJEncoding
data SMPServersConfig = SMPServersConfig {smpServers :: [ServerCfg 'PSMP]}
data ProtoServersConfig p = ProtoServersConfig {servers :: [ServerCfg p]}
deriving (Show, Generic, FromJSON)
data ServersConfig = ServersConfig {servers :: [AServerCfg]}
deriving (Show, Generic, FromJSON)
data AProtoServersConfig = forall p. ProtocolTypeI p => APSC (SProtocolType p) (ProtoServersConfig p)
deriving instance Show AProtoServersConfig
data UserProtoServers p = UserProtoServers
{ serverProtocol :: SProtocolType p,
protoServers :: NonEmpty (ServerCfg p),
presetServers :: NonEmpty (ProtoServerWithAuth p)
}
deriving (Show, Generic)
instance ProtocolTypeI p => ToJSON (UserProtoServers p) where
toJSON = J.genericToJSON J.defaultOptions
toEncoding = J.genericToEncoding J.defaultOptions
data AUserProtoServers = forall p. (ProtocolTypeI p, UserProtocol p) => AUPS (UserProtoServers p)
instance ToJSON AUserProtoServers where
toJSON (AUPS s) = J.genericToJSON J.defaultOptions s
toEncoding (AUPS s) = J.genericToEncoding J.defaultOptions s
deriving instance Show AUserProtoServers
data ArchiveConfig = ArchiveConfig {archivePath :: FilePath, disableCompression :: Maybe Bool, parentTempDirectory :: Maybe FilePath}
deriving (Show, Generic, FromJSON)
@@ -679,7 +697,8 @@ data ParsedServerAddress = ParsedServerAddress
instance ToJSON ParsedServerAddress where toEncoding = J.genericToEncoding J.defaultOptions
data ServerAddress = ServerAddress
{ hostnames :: NonEmpty String,
{ serverProtocol :: AProtocolType,
hostnames :: NonEmpty String,
port :: String,
keyHash :: String,
basicAuth :: String
@@ -750,6 +769,7 @@ data ChatErrorType
| CEChatStoreChanged
| CEInvalidConnReq
| CEInvalidChatMessage {message :: String}
| CEConnReqMessageProhibited
| CEContactNotReady {contact :: Contact}
| CEContactDisabled {contact :: Contact}
| CEConnectionDisabled {connection :: Connection}
@@ -794,6 +814,7 @@ data ChatErrorType
| CEAgentVersion
| CEAgentNoSubResult {agentConnId :: AgentConnId}
| CECommandError {message :: String}
| CEServerProtocol {serverProtocol :: AProtocolType}
| CEAgentCommandError {message :: String}
| CEInvalidFileDescription {message :: String}
| CEInternalError {message :: String}
@@ -35,5 +35,5 @@ SELECT
DROP TABLE smp_servers;
ALTER TABLE new_smp_servers RENAME TO smp_servers;
CREATE INDEX idx_smp_servers_user_id ON smp_servers(user_id);
CREATE INDEX idx_smp_servers_user_id ON "smp_servers"(user_id);
|]
@@ -0,0 +1,20 @@
{-# LANGUAGE QuasiQuotes #-}
module Simplex.Chat.Migrations.M20230402_protocol_servers where
import Database.SQLite.Simple (Query)
import Database.SQLite.Simple.QQ (sql)
m20230402_protocol_servers :: Query
m20230402_protocol_servers =
[sql|
ALTER TABLE smp_servers RENAME TO protocol_servers;
ALTER TABLE protocol_servers ADD COLUMN protocol TEXT NOT NULL DEFAULT 'smp';
|]
down_m20230402_protocol_servers :: Query
down_m20230402_protocol_servers =
[sql|
ALTER TABLE protocol_servers DROP COLUMN protocol;
ALTER TABLE protocol_servers RENAME TO smp_servers;
|]
+3 -2
View File
@@ -547,7 +547,7 @@ CREATE INDEX idx_snd_file_chunks_file_id_connection_id ON snd_file_chunks(
CREATE INDEX idx_snd_files_group_member_id ON snd_files(group_member_id);
CREATE INDEX idx_snd_files_connection_id ON snd_files(connection_id);
CREATE INDEX idx_snd_files_file_id ON snd_files(file_id);
CREATE TABLE IF NOT EXISTS "smp_servers"(
CREATE TABLE IF NOT EXISTS "protocol_servers"(
smp_server_id INTEGER PRIMARY KEY,
host TEXT NOT NULL,
port TEXT NOT NULL,
@@ -559,9 +559,10 @@ CREATE TABLE IF NOT EXISTS "smp_servers"(
user_id INTEGER NOT NULL REFERENCES users ON DELETE CASCADE,
created_at TEXT NOT NULL DEFAULT(datetime('now')),
updated_at TEXT NOT NULL DEFAULT(datetime('now')),
protocol TEXT NOT NULL DEFAULT 'smp',
UNIQUE(user_id, host, port)
);
CREATE INDEX idx_smp_servers_user_id ON smp_servers(user_id);
CREATE INDEX idx_smp_servers_user_id ON "protocol_servers"(user_id);
CREATE INDEX idx_chat_items_item_deleted_by_group_member_id ON chat_items(
item_deleted_by_group_member_id
);
+4 -4
View File
@@ -44,7 +44,7 @@ import Simplex.Messaging.Client (defaultNetworkConfig)
import qualified Simplex.Messaging.Crypto as C
import Simplex.Messaging.Encoding.String
import Simplex.Messaging.Parsers (dropPrefix, sumTypeJSON)
import Simplex.Messaging.Protocol (BasicAuth (..), CorrId (..), ProtoServerWithAuth (..), ProtocolServer (..), SMPServerWithAuth)
import Simplex.Messaging.Protocol (AProtoServerWithAuth (..), AProtocolType (..), BasicAuth (..), CorrId (..), ProtoServerWithAuth (..), ProtocolServer (..))
import Simplex.Messaging.Util (catchAll, liftEitherWith, safeDecodeUtf8)
import System.Timeout (timeout)
@@ -194,11 +194,11 @@ chatParseMarkdown = LB.unpack . J.encode . ParsedMarkdown . parseMaybeMarkdownLi
chatParseServer :: String -> JSONString
chatParseServer = LB.unpack . J.encode . toServerAddress . strDecode . B.pack
where
toServerAddress :: Either String SMPServerWithAuth -> ParsedServerAddress
toServerAddress :: Either String AProtoServerWithAuth -> ParsedServerAddress
toServerAddress = \case
Right (ProtoServerWithAuth ProtocolServer {host, port, keyHash = C.KeyHash kh} auth) ->
Right (AProtoServerWithAuth protocol (ProtoServerWithAuth ProtocolServer {host, port, keyHash = C.KeyHash kh} auth)) ->
let basicAuth = maybe "" (\(BasicAuth a) -> enc a) auth
in ParsedServerAddress (Just ServerAddress {hostnames = L.map enc host, port, keyHash = enc kh, basicAuth}) ""
in ParsedServerAddress (Just ServerAddress {serverProtocol = AProtocolType protocol, hostnames = L.map enc host, port, keyHash = enc kh, basicAuth}) ""
Left e -> ParsedServerAddress Nothing e
enc :: StrEncoding a => a -> String
enc = B.unpack . strEncode
+8 -14
View File
@@ -11,7 +11,7 @@ module Simplex.Chat.Options
chatOptsP,
coreChatOptsP,
getChatOpts,
smpServersP,
protocolServersP,
fullNetworkConfig,
)
where
@@ -25,7 +25,7 @@ import Simplex.Chat.Controller (ChatLogLevel (..), updateStr, versionNumber, ver
import Simplex.Messaging.Client (NetworkConfig (..), defaultNetworkConfig)
import Simplex.Messaging.Encoding.String
import Simplex.Messaging.Parsers (parseAll)
import Simplex.Messaging.Protocol (SMPServerWithAuth, XFTPServerWithAuth)
import Simplex.Messaging.Protocol (ProtocolTypeI, ProtoServerWithAuth, SMPServerWithAuth, XFTPServerWithAuth)
import Simplex.Messaging.Transport.Client (SocksProxy, defaultSocksProxy)
import System.FilePath (combine)
@@ -82,7 +82,7 @@ coreChatOptsP appDir defaultDbFileName = do
)
smpServers <-
option
parseSMPServers
parseProtocolServers
( long "server"
<> short 's'
<> metavar "SERVER"
@@ -91,7 +91,7 @@ coreChatOptsP appDir defaultDbFileName = do
)
xftpServers <-
option
parseXFTPServers
parseProtocolServers
( long "xftp-server"
<> metavar "SERVER"
<> help "Semicolon-separated list of XFTP server(s) to use (each server can have more than one hostname)"
@@ -243,11 +243,8 @@ fullNetworkConfig socksProxy tcpTimeout logTLSErrors =
let tcpConnectTimeout = (tcpTimeout * 3) `div` 2
in defaultNetworkConfig {socksProxy, tcpTimeout, tcpConnectTimeout, logTLSErrors}
parseSMPServers :: ReadM [SMPServerWithAuth]
parseSMPServers = eitherReader $ parseAll smpServersP . B.pack
parseXFTPServers :: ReadM [XFTPServerWithAuth]
parseXFTPServers = eitherReader $ parseAll xftpServersP . B.pack
parseProtocolServers :: ProtocolTypeI p => ReadM [ProtoServerWithAuth p]
parseProtocolServers = eitherReader $ parseAll protocolServersP . B.pack
parseSocksProxy :: ReadM (Maybe SocksProxy)
parseSocksProxy = eitherReader $ parseAll strP . B.pack
@@ -258,11 +255,8 @@ parseServerPort = eitherReader $ parseAll serverPortP . B.pack
serverPortP :: A.Parser (Maybe String)
serverPortP = Just . B.unpack <$> A.takeWhile A.isDigit
smpServersP :: A.Parser [SMPServerWithAuth]
smpServersP = strP `A.sepBy1` A.char ';'
xftpServersP :: A.Parser [XFTPServerWithAuth]
xftpServersP = strP `A.sepBy1` A.char ';'
protocolServersP :: ProtocolTypeI p => A.Parser [ProtoServerWithAuth p]
protocolServersP = strP `A.sepBy1` A.char ';'
parseLogLevel :: ReadM ChatLogLevel
parseLogLevel = eitherReader $ \case
+4 -4
View File
@@ -189,7 +189,7 @@ data ChatMsgEvent (e :: MsgEncoding) where
XFileAcptInv :: SharedMsgId -> Maybe ConnReqInvitation -> String -> ChatMsgEvent 'Json
XFileCancel :: SharedMsgId -> ChatMsgEvent 'Json
XInfo :: Profile -> ChatMsgEvent 'Json
XContact :: Profile -> Maybe XContactId -> ChatMsgEvent 'Json
XContact :: Profile -> Maybe XContactId -> Maybe MsgContent -> ChatMsgEvent 'Json
XGrpInv :: GroupInvitation -> ChatMsgEvent 'Json
XGrpAcpt :: MemberId -> ChatMsgEvent 'Json
XGrpMemNew :: MemberInfo -> ChatMsgEvent 'Json
@@ -605,7 +605,7 @@ toCMEventTag msg = case msg of
XFileAcptInv {} -> XFileAcptInv_
XFileCancel _ -> XFileCancel_
XInfo _ -> XInfo_
XContact _ _ -> XContact_
XContact {} -> XContact_
XGrpInv _ -> XGrpInv_
XGrpAcpt _ -> XGrpAcpt_
XGrpMemNew _ -> XGrpMemNew_
@@ -692,7 +692,7 @@ appJsonToCM AppMessageJson {msgId, event, params} = do
XFileAcptInv_ -> XFileAcptInv <$> p "msgId" <*> opt "fileConnReq" <*> p "fileName"
XFileCancel_ -> XFileCancel <$> p "msgId"
XInfo_ -> XInfo <$> p "profile"
XContact_ -> XContact <$> p "profile" <*> opt "contactReqId"
XContact_ -> XContact <$> p "profile" <*> opt "contactReqId" <*> opt "content"
XGrpInv_ -> XGrpInv <$> p "groupInvitation"
XGrpAcpt_ -> XGrpAcpt <$> p "memberId"
XGrpMemNew_ -> XGrpMemNew <$> p "memberInfo"
@@ -747,7 +747,7 @@ chatToAppMessage ChatMessage {msgId, chatMsgEvent} = case encoding @e of
XFileAcptInv sharedMsgId fileConnReq fileName -> o $ ("fileConnReq" .=? fileConnReq) ["msgId" .= sharedMsgId, "fileName" .= fileName]
XFileCancel sharedMsgId -> o ["msgId" .= sharedMsgId]
XInfo profile -> o ["profile" .= profile]
XContact profile xContactId -> o $ ("contactReqId" .=? xContactId) ["profile" .= profile]
XContact profile xContactId content -> o $ ("contactReqId" .=? xContactId) $ ("content" .=? content) ["profile" .= profile]
XGrpInv groupInv -> o ["groupInvitation" .= groupInv]
XGrpAcpt memId -> o ["memberId" .= memId]
XGrpMemNew memInfo -> o ["memberInfo" .= memInfo]
+23 -29
View File
@@ -246,8 +246,6 @@ module Simplex.Chat.Store
updateGroupChatItemsRead,
getGroupUnreadTimedItems,
setGroupChatItemDeleteAt,
getSMPServers,
overwriteSMPServers,
getProtocolServers,
overwriteProtocolServers,
createCall,
@@ -297,7 +295,7 @@ import Data.Maybe (fromMaybe, isJust, isNothing, listToMaybe, mapMaybe)
import Data.Ord (Down (..))
import Data.Text (Text)
import qualified Data.Text as T
import Data.Text.Encoding (encodeUtf8)
import Data.Text.Encoding (decodeLatin1, encodeUtf8)
import Data.Time (addUTCTime)
import Data.Time.Clock (UTCTime (..), getCurrentTime)
import Data.Time.LocalTime (TimeZone, getCurrentTimeZone)
@@ -365,6 +363,7 @@ import Simplex.Chat.Migrations.M20230317_hidden_profiles
import Simplex.Chat.Migrations.M20230318_file_description
import Simplex.Chat.Migrations.M20230321_agent_file_deleted
import Simplex.Chat.Migrations.M20230328_files_protocol
import Simplex.Chat.Migrations.M20230402_protocol_servers
import Simplex.Chat.Protocol
import Simplex.Chat.Types
import Simplex.Chat.Util (week)
@@ -372,8 +371,9 @@ import Simplex.Messaging.Agent.Protocol (ACorrId, AgentMsgId, ConnId, Invitation
import Simplex.Messaging.Agent.Store.SQLite (MigrationConfirmation, MigrationError, SQLiteStore (..), createSQLiteStore, firstRow, firstRow', maybeFirstRow, withTransaction)
import Simplex.Messaging.Agent.Store.SQLite.Migrations (Migration (..))
import qualified Simplex.Messaging.Crypto as C
import Simplex.Messaging.Encoding.String
import Simplex.Messaging.Parsers (dropPrefix, sumTypeJSON)
import Simplex.Messaging.Protocol (BasicAuth (..), ProtoServerWithAuth (..), ProtocolServer (..), ProtocolType (..), ProtocolTypeI (..), SProtocolType (..), pattern SMPServer)
import Simplex.Messaging.Protocol (BasicAuth (..), ProtoServerWithAuth (..), ProtocolServer (..), ProtocolTypeI (..))
import Simplex.Messaging.Transport.Client (TransportHost)
import Simplex.Messaging.Util (eitherToMaybe, safeDecodeUtf8)
import UnliftIO.STM
@@ -435,7 +435,8 @@ schemaMigrations =
("20230317_hidden_profiles", m20230317_hidden_profiles, Just down_m20230317_hidden_profiles),
("20230318_file_description", m20230318_file_description, Just down_m20230318_file_description),
("20230321_agent_file_deleted", m20230321_agent_file_deleted, Just down_m20230321_agent_file_deleted),
("20230328_files_protocol", m20230328_files_protocol, Just down_m20230328_files_protocol)
("20230328_files_protocol", m20230328_files_protocol, Just down_m20230328_files_protocol),
("20230402_protocol_servers", m20230402_protocol_servers, Just down_m20230402_protocol_servers)
]
-- | The list of migrations in ascending order by date
@@ -4849,49 +4850,42 @@ toGroupChatItemList tz currentTs userContactId (((Just itemId, Just itemTs, Just
either (const []) (: []) $ toGroupChatItem tz currentTs userContactId (((itemId, itemTs, itemContent, itemText, itemStatus, sharedMsgId, itemDeleted, itemEdited, createdAt, updatedAt) :. (timedTTL, timedDeleteAt, itemLive) :. fileRow) :. memberRow_ :. (quoteRow :. quotedMemberRow_) :. deletedByGroupMemberRow_)
toGroupChatItemList _ _ _ _ = []
getSMPServers :: DB.Connection -> User -> IO [ServerCfg 'PSMP]
getSMPServers db User {userId} =
getProtocolServers :: forall p. ProtocolTypeI p => DB.Connection -> User -> IO [ServerCfg p]
getProtocolServers db User {userId} =
map toServerCfg
<$> DB.query
db
[sql|
SELECT host, port, key_hash, basic_auth, preset, tested, enabled
FROM smp_servers
WHERE user_id = ?;
FROM protocol_servers
WHERE user_id = ? AND protocol = ?;
|]
(Only userId)
(userId, decodeLatin1 $ strEncode protocol)
where
toServerCfg :: (NonEmpty TransportHost, String, C.KeyHash, Maybe Text, Bool, Maybe Bool, Bool) -> ServerCfg 'PSMP
protocol = protocolTypeI @p
toServerCfg :: (NonEmpty TransportHost, String, C.KeyHash, Maybe Text, Bool, Maybe Bool, Bool) -> ServerCfg p
toServerCfg (host, port, keyHash, auth_, preset, tested, enabled) =
let server = ProtoServerWithAuth (SMPServer host port keyHash) (BasicAuth . encodeUtf8 <$> auth_)
let server = ProtoServerWithAuth (ProtocolServer protocol host port keyHash) (BasicAuth . encodeUtf8 <$> auth_)
in ServerCfg {server, preset, tested, enabled}
overwriteSMPServers :: DB.Connection -> User -> [ServerCfg 'PSMP] -> ExceptT StoreError IO ()
overwriteSMPServers db User {userId} servers =
overwriteProtocolServers :: forall p. ProtocolTypeI p => DB.Connection -> User -> [ServerCfg p] -> ExceptT StoreError IO ()
overwriteProtocolServers db User {userId} servers =
checkConstraint SEUniqueID . ExceptT $ do
currentTs <- getCurrentTime
DB.execute db "DELETE FROM smp_servers WHERE user_id = ?" (Only userId)
DB.execute db "DELETE FROM protocol_servers WHERE user_id = ? AND protocol = ? " (userId, protocol)
forM_ servers $ \ServerCfg {server, preset, tested, enabled} -> do
let ProtoServerWithAuth ProtocolServer {host, port, keyHash} auth_ = server
DB.execute
db
[sql|
INSERT INTO smp_servers
(host, port, key_hash, basic_auth, preset, tested, enabled, user_id, created_at, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?)
INSERT INTO protocol_servers
(protocol, host, port, key_hash, basic_auth, preset, tested, enabled, user_id, created_at, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?)
|]
(host, port, keyHash, safeDecodeUtf8 . unBasicAuth <$> auth_, preset, tested, enabled, userId, currentTs, currentTs)
((protocol, host, port, keyHash, safeDecodeUtf8 . unBasicAuth <$> auth_) :. (preset, tested, enabled, userId, currentTs, currentTs))
pure $ Right ()
getProtocolServers :: forall p. ProtocolTypeI p => DB.Connection -> User -> IO [ServerCfg p]
getProtocolServers db user = case protocolTypeI @p of
SPSMP -> getSMPServers db user
_ -> pure [] -- TODO read from the new table of all servers (alternatively, we could migrate data)
overwriteProtocolServers :: forall p. ProtocolTypeI p => DB.Connection -> User -> [ServerCfg p] -> ExceptT StoreError IO ()
overwriteProtocolServers db user servers = case protocolTypeI @p of
SPSMP -> overwriteSMPServers db user servers
_ -> pure () -- TODO write the new table of all servers
where
protocol = decodeLatin1 $ strEncode $ protocolTypeI @p
createCall :: DB.Connection -> User -> Call -> UTCTime -> IO ()
createCall db user@User {userId} Call {contactId, callId, chatItemId, callState} callTs = do
+1 -19
View File
@@ -52,7 +52,7 @@ import Simplex.FileTransfer.Description (FileDigest)
import Simplex.Messaging.Agent.Protocol (ACommandTag (..), ACorrId, AParty (..), APartyCmdTag (..), ConnId, ConnectionMode (..), ConnectionRequestUri, InvitationId, SAEntity (..), UserId)
import Simplex.Messaging.Encoding.String
import Simplex.Messaging.Parsers (dropPrefix, enumJSON, fromTextField_, sumTypeJSON, taggedObjectJSON)
import Simplex.Messaging.Protocol (AProtoServerWithAuth, ProtoServerWithAuth, ProtocolTypeI)
import Simplex.Messaging.Protocol (ProtoServerWithAuth, ProtocolTypeI)
import Simplex.Messaging.Util (safeDecodeUtf8, (<$?>))
class IsContact a where
@@ -2100,21 +2100,3 @@ instance ProtocolTypeI p => ToJSON (ServerCfg p) where
instance ProtocolTypeI p => FromJSON (ServerCfg p) where
parseJSON = J.genericParseJSON J.defaultOptions {J.omitNothingFields = True}
data AServerCfg = AServerCfg
{ server :: AProtoServerWithAuth,
preset :: Bool,
tested :: Maybe Bool,
enabled :: Bool
}
deriving instance Show AServerCfg
deriving instance Generic AServerCfg
instance ToJSON AServerCfg where
toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True}
toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True}
instance FromJSON AServerCfg where
parseJSON = J.genericParseJSON J.defaultOptions {J.omitNothingFields = True}
+36 -21
View File
@@ -18,10 +18,12 @@ import Data.Char (toUpper)
import Data.Function (on)
import Data.Int (Int64)
import Data.List (groupBy, intercalate, intersperse, partition, sortOn)
import Data.List.NonEmpty (NonEmpty)
import qualified Data.List.NonEmpty as L
import Data.Maybe (fromMaybe, isJust, isNothing, mapMaybe)
import Data.Text (Text)
import qualified Data.Text as T
import Data.Text.Encoding (decodeLatin1)
import Data.Time.Clock (DiffTime, UTCTime)
import Data.Time.Format (defaultTimeLocale, formatTime)
import Data.Time.LocalTime (ZonedTime (..), localDay, localTimeOfDay, timeOfDayToTime, utcToZonedTime)
@@ -38,14 +40,15 @@ import Simplex.Chat.Protocol
import Simplex.Chat.Store (AutoAccept (..), StoreError (..), UserContactLink (..))
import Simplex.Chat.Styled
import Simplex.Chat.Types
import Simplex.Messaging.Agent.Client (SMPTestFailure (..), SMPTestStep (..))
import qualified Simplex.FileTransfer.Protocol as XFTP
import Simplex.Messaging.Agent.Client (ProtocolTestFailure (..), ProtocolTestStep (..))
import Simplex.Messaging.Agent.Env.SQLite (NetworkConfig (..))
import Simplex.Messaging.Agent.Protocol
import qualified Simplex.Messaging.Crypto as C
import Simplex.Messaging.Encoding
import Simplex.Messaging.Encoding.String
import Simplex.Messaging.Parsers (dropPrefix, taggedObjectJSON)
import Simplex.Messaging.Protocol (AProtocolType, ProtocolServer (..), ProtocolTypeI)
import Simplex.Messaging.Protocol (AProtoServerWithAuth (..), AProtocolType, ProtocolServer (..), ProtocolTypeI, SProtocolType (..))
import qualified Simplex.Messaging.Protocol as SMP
import Simplex.Messaging.Transport.Client (TransportHost (..))
import Simplex.Messaging.Util (bshow, tshow)
@@ -68,8 +71,8 @@ responseToView user_ ChatConfig {logLevel, testView} liveItems ts = \case
CRChats chats -> viewChats ts chats
CRApiChat u chat -> ttyUser u $ if testView then testViewChat chat else [plain . bshow $ J.encode chat]
CRApiParsedMarkdown ft -> [plain . bshow $ J.encode ft]
CRUserSMPServers u smpServers _ -> ttyUser u $ viewSMPServers (L.toList smpServers) testView
CRSmpTestResult u testFailure -> ttyUser u $ viewSMPTestResult testFailure
CRUserProtoServers u userServers -> ttyUser u $ viewUserServers userServers testView
CRServerTestResult u srv testFailure -> ttyUser u $ viewServerTestResult srv testFailure
CRChatItemTTL u ttl -> ttyUser u $ viewChatItemTTL ttl
CRNetworkConfig cfg -> viewNetworkConfig cfg
CRContactInfo u ct cStats customUserProfile -> ttyUser u $ viewContactInfo ct cStats customUserProfile
@@ -748,36 +751,46 @@ viewUserPrivacy User {userId} User {userId = userId', localDisplayName = n', sho
]
-- TODO make more generic messages or split
viewSMPServers :: ProtocolTypeI p => [ServerCfg p] -> Bool -> [StyledString]
viewSMPServers servers testView =
viewUserServers :: AUserProtoServers -> Bool -> [StyledString]
viewUserServers (AUPS UserProtoServers {serverProtocol = p, protoServers}) testView =
if testView
then [customServers]
else
[ customServers,
"",
"use " <> highlight' "/smp test <srv>" <> " to test SMP server connection",
"use " <> highlight' "/smp set <srv1[,srv2,...]>" <> " to switch to custom SMP servers",
"use " <> highlight' "/smp default" <> " to remove custom SMP servers and use default",
"(chat option " <> highlight' "-s" <> " (" <> highlight' "--server" <> ") has precedence over saved SMP servers for chat session)"
"use " <> highlight (srvCmd <> " test <srv>") <> " to test " <> pName <> " server connection",
"use " <> highlight (srvCmd <> " set <srv1[,srv2,...]>") <> " to switch to custom " <> pName <> " servers",
"use " <> highlight (srvCmd <> " default") <> " to remove custom " <> pName <> " servers and use default"
]
<> case p of
SPSMP -> ["(chat option " <> highlight' "-s" <> " (" <> highlight' "--server" <> ") has precedence over saved SMP servers for chat session)"]
SPXFTP -> ["(chat option " <> highlight' "-xftp-servers" <> " has precedence over saved XFTP servers for chat session)"]
where
srvCmd = "/" <> strEncode p
pName = protocolName p
customServers =
if null servers
if null protoServers
then "no custom SMP servers saved"
else viewServers servers
else viewServers protoServers
viewSMPTestResult :: Maybe SMPTestFailure -> [StyledString]
viewSMPTestResult = \case
Just SMPTestFailure {testStep, testError} ->
protocolName :: ProtocolTypeI p => SProtocolType p -> StyledString
protocolName = plain . map toUpper . T.unpack . decodeLatin1 . strEncode
viewServerTestResult :: AProtoServerWithAuth -> Maybe ProtocolTestFailure -> [StyledString]
viewServerTestResult (AProtoServerWithAuth p _) = \case
Just ProtocolTestFailure {testStep, testError} ->
result
<> ["Server requires authorization to create queues, check password" | testStep == TSCreateQueue && testError == SMP SMP.AUTH]
<> ["Possibly, certificate fingerprint in server address is incorrect" | testStep == TSConnect && brokerErr]
<> [pName <> " server requires authorization to create queues, check password" | testStep == TSCreateQueue && testError == SMP SMP.AUTH]
<> [pName <> " server requires authorization to upload files, check password" | testStep == TSCreateFile && testError == XFTP XFTP.AUTH]
<> ["Possibly, certificate fingerprint in " <> pName <> " server address is incorrect" | testStep == TSConnect && brokerErr]
where
result = ["SMP server test failed at " <> plain (drop 2 $ show testStep) <> ", error: " <> plain (strEncode testError)]
result = [pName <> " server test failed at " <> plain (drop 2 $ show testStep) <> ", error: " <> plain (strEncode testError)]
brokerErr = case testError of
BROKER _ NETWORK -> True
_ -> False
_ -> ["SMP server test passed"]
_ -> [pName <> " server test passed"]
where
pName = protocolName p
viewChatItemTTL :: Maybe Int64 -> [StyledString]
viewChatItemTTL = \case
@@ -825,8 +838,8 @@ viewConnectionStats ConnectionStats {rcvServers, sndServers} =
["receiving messages via: " <> viewServerHosts rcvServers | not $ null rcvServers]
<> ["sending messages via: " <> viewServerHosts sndServers | not $ null sndServers]
viewServers :: ProtocolTypeI p => [ServerCfg p] -> StyledString
viewServers = plain . intercalate ", " . map (B.unpack . strEncode . (\ServerCfg {server} -> server))
viewServers :: ProtocolTypeI p => NonEmpty (ServerCfg p) -> StyledString
viewServers = plain . intercalate ", " . map (B.unpack . strEncode . (\ServerCfg {server} -> server)) . L.toList
viewServerHosts :: [SMPServer] -> StyledString
viewServerHosts = plain . intercalate ", " . map showSMPServer
@@ -1258,6 +1271,7 @@ viewChatError logLevel = \case
CEChatStoreChanged -> ["error: chat store changed, please restart chat"]
CEInvalidConnReq -> viewInvalidConnReq
CEInvalidChatMessage e -> ["chat message error: " <> sShow e]
CEConnReqMessageProhibited -> ["message is not allowed with this connection link"]
CEContactNotReady c -> [ttyContact' c <> ": not ready"]
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]
@@ -1304,6 +1318,7 @@ viewChatError logLevel = \case
CEDirectMessagesProhibited dir ct -> viewDirectMessagesProhibited dir ct
CEAgentVersion -> ["unsupported agent version"]
CEAgentNoSubResult connId -> ["no subscription result for connection: " <> sShow connId]
CEServerProtocol p -> [plain $ "Servers for protocol " <> strEncode p <> " cannot be configured by the users"]
CECommandError e -> ["bad chat command: " <> plain e]
CEAgentCommandError e -> ["agent command error: " <> plain e]
CEInvalidFileDescription e -> ["invalid file description: " <> plain e]
+2 -2
View File
@@ -49,9 +49,9 @@ extra-deps:
# - simplexmq-1.0.0@sha256:34b2004728ae396e3ae449cd090ba7410781e2b3cefc59259915f4ca5daa9ea8,8561
# - ../simplexmq
- github: simplex-chat/simplexmq
commit: 44f0dd39f3d1536c979b09e268dbdf681f9b0bb8
commit: 215d2414b7a76e3d501017b24fb6b301bf022546
- github: kazu-yamamoto/http2
commit: 159417b413a684a9b754e10e4a5db4376aa8c6b9
commit: b5a1b7200cf5bc7044af34ba325284271f6dff25
# - ../direct-sqlcipher
- github: simplex-chat/direct-sqlcipher
commit: 34309410eb2069b029b8fc1872deb1e0db123294
+1 -1
View File
@@ -59,7 +59,7 @@ testOpts =
dbKey = "",
-- dbKey = "this is a pass-phrase to encrypt the database",
smpServers = ["smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:7001"],
xftpServers = ["xftp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=@localhost:7002"],
xftpServers = ["xftp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:7002"],
networkConfig = defaultNetworkConfig,
logLevel = CLLImportant,
logConnections = False,
+34 -2
View File
@@ -36,6 +36,9 @@ chatDirectTests = do
describe "SMP servers" $ do
it "get and set SMP servers" testGetSetSMPServers
it "test SMP server connection" testTestSMPServerConnection
describe "XFTP servers" $ do
it "get and set XFTP servers" testGetSetXFTPServers
it "test XFTP server connection" testTestXFTPServer
describe "async connection handshake" $ do
it "connect when initiating client goes offline" testAsyncInitiatingOffline
it "connect when accepting client goes offline" testAsyncAcceptingOffline
@@ -393,7 +396,7 @@ testGetSetSMPServers :: HasCallStack => FilePath -> IO ()
testGetSetSMPServers =
testChat2 aliceProfile bobProfile $
\alice _ -> do
alice #$> ("/_smp 1", id, "smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:7001")
alice #$> ("/_servers 1 smp", id, "smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:7001")
alice #$> ("/smp smp://1234-w==@smp1.example.im", id, "ok")
alice #$> ("/smp", id, "smp://1234-w==@smp1.example.im")
alice #$> ("/smp smp://1234-w==:password@smp1.example.im", id, "ok")
@@ -416,7 +419,36 @@ testTestSMPServerConnection =
alice <## "SMP server test passed"
alice ##> "/smp test smp://LcJU@localhost:7001"
alice <## "SMP server test failed at Connect, error: BROKER smp://LcJU@localhost:7001 NETWORK"
alice <## "Possibly, certificate fingerprint in server address is incorrect"
alice <## "Possibly, certificate fingerprint in SMP server address is incorrect"
testGetSetXFTPServers :: HasCallStack => FilePath -> IO ()
testGetSetXFTPServers =
testChat2 aliceProfile bobProfile $
\alice _ -> withXFTPServer $ do
alice #$> ("/_servers 1 xftp", id, "xftp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:7002")
alice #$> ("/xftp xftp://1234-w==@xftp1.example.im", id, "ok")
alice #$> ("/xftp", id, "xftp://1234-w==@xftp1.example.im")
alice #$> ("/xftp xftp://1234-w==:password@xftp1.example.im", id, "ok")
alice #$> ("/xftp", id, "xftp://1234-w==:password@xftp1.example.im")
alice #$> ("/xftp xftp://2345-w==@xftp2.example.im;xftp://3456-w==@xftp3.example.im:5224", id, "ok")
alice #$> ("/xftp", id, "xftp://2345-w==@xftp2.example.im, xftp://3456-w==@xftp3.example.im:5224")
alice #$> ("/xftp default", id, "ok")
alice #$> ("/xftp", id, "xftp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:7002")
testTestXFTPServer :: HasCallStack => FilePath -> IO ()
testTestXFTPServer =
testChat2 aliceProfile bobProfile $
\alice _ -> withXFTPServer $ do
alice ##> "/xftp test xftp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=@localhost:7002"
alice <## "XFTP server test passed"
-- to test with password:
-- alice <## "XFTP server test failed at CreateFile, error: XFTP AUTH"
-- alice <## "Server requires authorization to upload files, check password"
alice ##> "/xftp test xftp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:7002"
alice <## "XFTP server test passed"
alice ##> "/xftp test xftp://LcJU@localhost:7002"
alice <## "XFTP server test failed at Connect, error: BROKER xftp://LcJU@localhost:7002 NETWORK"
alice <## "Possibly, certificate fingerprint in XFTP server address is incorrect"
testAsyncInitiatingOffline :: HasCallStack => FilePath -> IO ()
testAsyncInitiatingOffline tmp = do
+5 -5
View File
@@ -201,16 +201,16 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do
#==# XInfo Profile {displayName = "alice", fullName = "", image = Nothing, preferences = testChatPreferences}
it "x.contact with xContactId" $
"{\"event\":\"x.contact\",\"params\":{\"contactReqId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"voice\":{\"allow\":\"yes\"}}}}}"
#==# XContact testProfile (Just $ XContactId "\1\2\3\4")
#==# XContact testProfile (Just $ XContactId "\1\2\3\4") Nothing
it "x.contact without XContactId" $
"{\"event\":\"x.contact\",\"params\":{\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"voice\":{\"allow\":\"yes\"}}}}}"
#==# XContact testProfile Nothing
#==# XContact testProfile Nothing Nothing
it "x.contact with content null" $
"{\"event\":\"x.contact\",\"params\":{\"content\":null,\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"voice\":{\"allow\":\"yes\"}}}}}"
==# XContact testProfile Nothing
it "x.contact with content (ignored)" $
==# XContact testProfile Nothing Nothing
it "x.contact with content" $
"{\"event\":\"x.contact\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"voice\":{\"allow\":\"yes\"}}}}}"
==# XContact testProfile Nothing
#==# XContact testProfile Nothing (Just $ MCText "hello")
it "x.grp.inv" $
"{\"event\":\"x.grp.inv\",\"params\":{\"groupInvitation\":{\"connRequest\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"invitedMember\":{\"memberRole\":\"member\",\"memberId\":\"BQYHCA==\"},\"groupProfile\":{\"fullName\":\"Team\",\"displayName\":\"team\",\"groupPreferences\":{\"voice\":{\"enable\":\"on\"}}},\"fromMember\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\"}}}}"
#==# XGrpInv GroupInvitation {fromMember = MemberIdRole (MemberId "\1\2\3\4") GRAdmin, invitedMember = MemberIdRole (MemberId "\5\6\7\8") GRMember, connRequest = testConnReq, groupProfile = testGroupProfile, groupLinkId = Nothing}