mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2024-12-17 17:20:21 +01:00
Compare commits
52 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 960010c243 | |||
| 25214076d8 | |||
| 3c8e277e2e | |||
| db2ccaa450 | |||
| 4b7458b58f | |||
| 05383477d9 | |||
| c27973d202 | |||
| 51a2e09714 | |||
| ec8ae9febe | |||
| e37654772f | |||
| b7709c59d3 | |||
| 395654098c | |||
| 2d643e8d29 | |||
| 4e9703f0ff | |||
| b0b249a56a | |||
| 92c89632d4 | |||
| d54b453b49 | |||
| e6e27db243 | |||
| b629c22ee0 | |||
| f7d7f5461f | |||
| c544a636f6 | |||
| 73de74d7e9 | |||
| 654a7885c3 | |||
| daf67c0456 | |||
| e361bcf140 | |||
| 5de9087207 | |||
| 364b62320b | |||
| d83a6b7133 | |||
| cd21a74b83 | |||
| 6d523d5b4b | |||
| 2a321b3ff8 | |||
| 865a32c608 | |||
| e3df7945d5 | |||
| bb1620d7d2 | |||
| edc5a4c31b | |||
| 4260c20012 | |||
| 1a7efbc333 | |||
| e4984cb38d | |||
| dfa9775d7e | |||
| e39544dd24 | |||
| 71bcfc2848 | |||
| 91f10c056f | |||
| 3d8d84f978 | |||
| fec34ca875 | |||
| 3a0920e950 | |||
| f4ae60756c | |||
| eedc1b2860 | |||
| 24a35698dc | |||
| 7e37155938 | |||
| c8b38183c9 | |||
| 90a866ca56 | |||
| 7e9e71ffbd |
+27
-18
@@ -1,32 +1,41 @@
|
||||
FROM ubuntu:focal AS build
|
||||
ARG TAG=22.04
|
||||
|
||||
# Install curl and simplex-chat-related dependencies
|
||||
RUN apt-get update && apt-get install -y curl git build-essential libgmp3-dev zlib1g-dev libssl-dev
|
||||
FROM ubuntu:${TAG} AS build
|
||||
|
||||
### Build stage
|
||||
|
||||
# Install curl and git and simplex-chat dependencies
|
||||
RUN apt-get update && apt-get install -y curl git build-essential libgmp3-dev zlib1g-dev llvm-12 llvm-12-dev libnuma-dev libssl-dev
|
||||
|
||||
# Specify bootstrap Haskell versions
|
||||
ENV BOOTSTRAP_HASKELL_GHC_VERSION=9.6.3
|
||||
ENV BOOTSTRAP_HASKELL_CABAL_VERSION=3.10.1.0
|
||||
|
||||
# Install ghcup
|
||||
RUN a=$(arch); curl https://downloads.haskell.org/~ghcup/$a-linux-ghcup -o /usr/bin/ghcup && \
|
||||
chmod +x /usr/bin/ghcup
|
||||
|
||||
# Install ghc
|
||||
RUN ghcup install ghc 9.6.3
|
||||
# Install cabal
|
||||
RUN ghcup install cabal 3.10.1.0
|
||||
# Set both as default
|
||||
RUN ghcup set ghc 9.6.3 && \
|
||||
ghcup set cabal 3.10.1.0
|
||||
|
||||
COPY . /project
|
||||
WORKDIR /project
|
||||
RUN curl --proto '=https' --tlsv1.2 -sSf https://get-ghcup.haskell.org | BOOTSTRAP_HASKELL_NONINTERACTIVE=1 sh
|
||||
|
||||
# Adjust PATH
|
||||
ENV PATH="/root/.cabal/bin:/root/.ghcup/bin:$PATH"
|
||||
|
||||
# Set both as default
|
||||
RUN ghcup set ghc "${BOOTSTRAP_HASKELL_GHC_VERSION}" && \
|
||||
ghcup set cabal "${BOOTSTRAP_HASKELL_CABAL_VERSION}"
|
||||
|
||||
COPY . /project
|
||||
WORKDIR /project
|
||||
|
||||
# Adjust build
|
||||
RUN cp ./scripts/cabal.project.local.linux ./cabal.project.local
|
||||
|
||||
# Compile simplex-chat
|
||||
RUN cabal update
|
||||
RUN cabal install
|
||||
RUN cabal build exe:simplex-chat
|
||||
|
||||
# Strip the binary from debug symbols to reduce size
|
||||
RUN bin=$(find /project/dist-newstyle -name "simplex-chat" -type f -executable) && \
|
||||
mv "$bin" ./ && \
|
||||
strip ./simplex-chat
|
||||
|
||||
# Copy compiled app from build stage
|
||||
FROM scratch AS export-stage
|
||||
COPY --from=build /root/.cabal/bin/simplex-chat /
|
||||
COPY --from=build /project/simplex-chat /
|
||||
|
||||
@@ -34,6 +34,8 @@ struct ContentView: View {
|
||||
@State private var waitingForOrPassedAuth = true
|
||||
@State private var chatListActionSheet: ChatListActionSheet? = nil
|
||||
|
||||
private let callTopPadding: CGFloat = 50
|
||||
|
||||
private enum ChatListActionSheet: Identifiable {
|
||||
case planAndConnectSheet(sheet: PlanAndConnectActionSheet)
|
||||
|
||||
@@ -50,16 +52,28 @@ struct ContentView: View {
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
let showCallArea = chatModel.activeCall != nil && chatModel.activeCall?.callState != .waitCapabilities && chatModel.activeCall?.callState != .invitationAccepted
|
||||
// contentView() has to be in a single branch, so that enabling authentication doesn't trigger re-rendering and close settings.
|
||||
// i.e. with separate branches like this settings are closed: `if prefPerformLA { ... contentView() ... } else { contentView() }
|
||||
if !prefPerformLA || accessAuthenticated {
|
||||
contentView()
|
||||
.padding(.top, showCallArea ? callTopPadding : 0)
|
||||
} else {
|
||||
lockButton()
|
||||
.padding(.top, showCallArea ? callTopPadding : 0)
|
||||
}
|
||||
|
||||
if showCallArea, let call = chatModel.activeCall {
|
||||
VStack {
|
||||
activeCallInteractiveArea(call)
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
|
||||
if chatModel.showCallView, let call = chatModel.activeCall {
|
||||
callView(call)
|
||||
}
|
||||
|
||||
if !showSettings, let la = chatModel.laRequest {
|
||||
LocalAuthView(authRequest: la)
|
||||
.onDisappear {
|
||||
@@ -135,11 +149,11 @@ struct ContentView: View {
|
||||
if case .onboardingComplete = step,
|
||||
chatModel.currentUser != nil {
|
||||
mainView()
|
||||
.actionSheet(item: $chatListActionSheet) { sheet in
|
||||
switch sheet {
|
||||
case let .planAndConnectSheet(sheet): return planAndConnectActionSheet(sheet, dismiss: false)
|
||||
.actionSheet(item: $chatListActionSheet) { sheet in
|
||||
switch sheet {
|
||||
case let .planAndConnectSheet(sheet): return planAndConnectActionSheet(sheet, dismiss: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
OnboardingView(onboarding: step)
|
||||
}
|
||||
@@ -163,6 +177,40 @@ struct ContentView: View {
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func activeCallInteractiveArea(_ call: Call) -> some View {
|
||||
HStack {
|
||||
Text(call.contact.displayName).font(.body).foregroundColor(.white)
|
||||
Spacer()
|
||||
CallDuration(call: call)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.frame(height: callTopPadding - 10)
|
||||
.background(Color(uiColor: UIColor(red: 47/255, green: 208/255, blue: 88/255, alpha: 1)))
|
||||
.onTapGesture {
|
||||
chatModel.activeCallViewIsCollapsed = false
|
||||
}
|
||||
}
|
||||
|
||||
struct CallDuration: View {
|
||||
let call: Call
|
||||
@State var text: String = ""
|
||||
@State var timer: Timer? = nil
|
||||
|
||||
var body: some View {
|
||||
Text(text).frame(minWidth: text.count <= 5 ? 52 : 77, alignment: .leading).offset(x: 4).font(.body).foregroundColor(.white)
|
||||
.onAppear {
|
||||
timer = Timer.scheduledTimer(withTimeInterval: 0.3, repeats: true) { timer in
|
||||
if let connectedAt = call.connectedAt {
|
||||
text = durationText(Int(Date.now.timeIntervalSince1970 - connectedAt.timeIntervalSince1970))
|
||||
}
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
_ = timer?.invalidate()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func lockButton() -> some View {
|
||||
Button(action: authenticateContentViewAccess) { Label("Unlock", systemImage: "lock") }
|
||||
}
|
||||
|
||||
@@ -80,6 +80,7 @@ final class ChatModel: ObservableObject {
|
||||
@Published var tokenRegistered = false
|
||||
@Published var tokenStatus: NtfTknStatus?
|
||||
@Published var notificationMode = NotificationsMode.off
|
||||
@Published var notificationServer: String?
|
||||
@Published var notificationPreview: NotificationPreviewMode = ntfPreviewModeGroupDefault.get()
|
||||
// pending notification actions
|
||||
@Published var ntfContactRequest: NTFContactRequest?
|
||||
@@ -89,6 +90,7 @@ final class ChatModel: ObservableObject {
|
||||
@Published var activeCall: Call?
|
||||
let callCommand: WebRTCCommandProcessor = WebRTCCommandProcessor()
|
||||
@Published var showCallView = false
|
||||
@Published var activeCallViewIsCollapsed = false
|
||||
// remote desktop
|
||||
@Published var remoteCtrlSession: RemoteCtrlSession?
|
||||
// currently showing invitation
|
||||
|
||||
@@ -252,12 +252,6 @@ func apiSetFilesFolder(filesFolder: String) throws {
|
||||
throw r
|
||||
}
|
||||
|
||||
func setXFTPConfig(_ cfg: XFTPFileConfig?) throws {
|
||||
let r = chatSendCmdSync(.apiSetXFTPConfig(config: cfg))
|
||||
if case .cmdOk = r { return }
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiSetEncryptLocalFiles(_ enable: Bool) throws {
|
||||
let r = chatSendCmdSync(.apiSetEncryptLocalFiles(enable: enable))
|
||||
if case .cmdOk = r { return }
|
||||
@@ -412,14 +406,14 @@ func apiDeleteMemberChatItem(groupId: Int64, groupMemberId: Int64, itemId: Int64
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiGetNtfToken() -> (DeviceToken?, NtfTknStatus?, NotificationsMode) {
|
||||
func apiGetNtfToken() -> (DeviceToken?, NtfTknStatus?, NotificationsMode, String?) {
|
||||
let r = chatSendCmdSync(.apiGetNtfToken)
|
||||
switch r {
|
||||
case let .ntfToken(token, status, ntfMode): return (token, status, ntfMode)
|
||||
case .chatCmdError(_, .errorAgent(.CMD(.PROHIBITED))): return (nil, nil, .off)
|
||||
case let .ntfToken(token, status, ntfMode, ntfServer): return (token, status, ntfMode, ntfServer)
|
||||
case .chatCmdError(_, .errorAgent(.CMD(.PROHIBITED))): return (nil, nil, .off, nil)
|
||||
default:
|
||||
logger.debug("apiGetNtfToken response: \(String(describing: r))")
|
||||
return (nil, nil, .off)
|
||||
return (nil, nil, .off, nil)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1249,7 +1243,6 @@ func initializeChat(start: Bool, confirmStart: Bool = false, dbKey: String? = ni
|
||||
}
|
||||
try apiSetTempFolder(tempFolder: getTempFilesDirectory().path)
|
||||
try apiSetFilesFolder(filesFolder: getAppFilesDirectory().path)
|
||||
try setXFTPConfig(getXFTPCfg())
|
||||
try apiSetEncryptLocalFiles(privacyEncryptLocalFilesGroupDefault.get())
|
||||
m.chatInitialized = true
|
||||
m.currentUser = try apiGetActiveUser()
|
||||
@@ -1309,7 +1302,7 @@ func startChat(refreshInvitations: Bool = true) throws {
|
||||
if (refreshInvitations) {
|
||||
try refreshCallInvitations()
|
||||
}
|
||||
(m.savedToken, m.tokenStatus, m.notificationMode) = apiGetNtfToken()
|
||||
(m.savedToken, m.tokenStatus, m.notificationMode, m.notificationServer) = apiGetNtfToken()
|
||||
// deviceToken is set when AppDelegate.application(didRegisterForRemoteNotificationsWithDeviceToken:) is called,
|
||||
// when it is called before startChat
|
||||
if let token = m.deviceToken {
|
||||
@@ -1861,7 +1854,9 @@ func chatItemSimpleUpdate(_ user: any UserLike, _ aChatItem: AChatItem) async {
|
||||
let cItem = aChatItem.chatItem
|
||||
if active(user) {
|
||||
if await MainActor.run(body: { m.upsertChatItem(cInfo, cItem) }) {
|
||||
NtfManager.shared.notifyMessageReceived(user, cInfo, cItem)
|
||||
if cItem.showNotification {
|
||||
NtfManager.shared.notifyMessageReceived(user, cInfo, cItem)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,49 +12,67 @@ import SimpleXChat
|
||||
|
||||
struct ActiveCallView: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
@ObservedObject var call: Call
|
||||
@Environment(\.scenePhase) var scenePhase
|
||||
@State private var client: WebRTCClient? = nil
|
||||
@State private var activeCall: WebRTCClient.Call? = nil
|
||||
@State private var localRendererAspectRatio: CGFloat? = nil
|
||||
@Binding var canConnectCall: Bool
|
||||
@State var prevColorScheme: ColorScheme = .dark
|
||||
@State var pipShown = false
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .bottom) {
|
||||
if let client = client, [call.peerMedia, call.localMedia].contains(.video), activeCall != nil {
|
||||
GeometryReader { g in
|
||||
let width = g.size.width * 0.3
|
||||
ZStack(alignment: .topTrailing) {
|
||||
CallViewRemote(client: client, activeCall: $activeCall)
|
||||
CallViewLocal(client: client, activeCall: $activeCall, localRendererAspectRatio: $localRendererAspectRatio)
|
||||
.cornerRadius(10)
|
||||
.frame(width: width, height: width / (localRendererAspectRatio ?? 1))
|
||||
.padding([.top, .trailing], 17)
|
||||
ZStack(alignment: .topLeading) {
|
||||
ZStack(alignment: .bottom) {
|
||||
if let client = client, [call.peerMedia, call.localMedia].contains(.video), activeCall != nil {
|
||||
GeometryReader { g in
|
||||
let width = g.size.width * 0.3
|
||||
ZStack(alignment: .topTrailing) {
|
||||
CallViewRemote(client: client, activeCall: $activeCall, activeCallViewIsCollapsed: $m.activeCallViewIsCollapsed, pipShown: $pipShown)
|
||||
CallViewLocal(client: client, activeCall: $activeCall, localRendererAspectRatio: $localRendererAspectRatio, pipShown: $pipShown)
|
||||
.cornerRadius(10)
|
||||
.frame(width: width, height: width / (localRendererAspectRatio ?? 1))
|
||||
.padding([.top, .trailing], 17)
|
||||
ZStack(alignment: .center) {
|
||||
// For some reason, when the view in GeometryReader and ZStack is visible, it steals clicks on a back button, so showing something on top like this with background color helps (.clear color doesn't work)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Color.primary.opacity(0.000001))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let call = m.activeCall, let client = client {
|
||||
ActiveCallOverlay(call: call, client: client)
|
||||
if let call = m.activeCall, let client = client, (!pipShown || !call.supportsVideo) {
|
||||
ActiveCallOverlay(call: call, client: client)
|
||||
}
|
||||
}
|
||||
}
|
||||
.allowsHitTesting(!m.activeCallViewIsCollapsed)
|
||||
.opacity(m.activeCallViewIsCollapsed ? 0 : 1)
|
||||
.onAppear {
|
||||
logger.debug("ActiveCallView: appear client is nil \(client == nil), scenePhase \(String(describing: scenePhase)), canConnectCall \(canConnectCall)")
|
||||
AppDelegate.keepScreenOn(true)
|
||||
createWebRTCClient()
|
||||
dismissAllSheets()
|
||||
hideKeyboard()
|
||||
prevColorScheme = colorScheme
|
||||
}
|
||||
.onChange(of: canConnectCall) { _ in
|
||||
logger.debug("ActiveCallView: canConnectCall changed to \(canConnectCall)")
|
||||
createWebRTCClient()
|
||||
}
|
||||
.onChange(of: m.activeCallViewIsCollapsed) { _ in
|
||||
hideKeyboard()
|
||||
}
|
||||
.onDisappear {
|
||||
logger.debug("ActiveCallView: disappear")
|
||||
Task { await m.callCommand.setClient(nil) }
|
||||
AppDelegate.keepScreenOn(false)
|
||||
client?.endCall()
|
||||
}
|
||||
.background(.black)
|
||||
.preferredColorScheme(.dark)
|
||||
.background(m.activeCallViewIsCollapsed ? .clear : .black)
|
||||
// Quite a big delay when opening/closing the view when a scheme changes (globally) this way. It's not needed when CallKit is used since status bar is green with white text on it
|
||||
.preferredColorScheme(m.activeCallViewIsCollapsed || CallController.useCallKit() ? prevColorScheme : .dark)
|
||||
}
|
||||
|
||||
private func createWebRTCClient() {
|
||||
@@ -69,8 +87,8 @@ struct ActiveCallView: View {
|
||||
@MainActor
|
||||
private func processRtcMessage(msg: WVAPIMessage) {
|
||||
if call == m.activeCall,
|
||||
let call = m.activeCall,
|
||||
let client = client {
|
||||
let call = m.activeCall,
|
||||
let client = client {
|
||||
logger.debug("ActiveCallView: response \(msg.resp.respType)")
|
||||
switch msg.resp {
|
||||
case let .capabilities(capabilities):
|
||||
@@ -90,7 +108,7 @@ struct ActiveCallView: View {
|
||||
Task {
|
||||
do {
|
||||
try await apiSendCallOffer(call.contact, offer, iceCandidates,
|
||||
media: call.localMedia, capabilities: capabilities)
|
||||
media: call.localMedia, capabilities: capabilities)
|
||||
} catch {
|
||||
logger.error("apiSendCallOffer \(responseError(error))")
|
||||
}
|
||||
@@ -122,13 +140,15 @@ struct ActiveCallView: View {
|
||||
if let callStatus = WebRTCCallStatus.init(rawValue: state.connectionState),
|
||||
case .connected = callStatus {
|
||||
call.direction == .outgoing
|
||||
? CallController.shared.reportOutgoingCall(call: call, connectedAt: nil)
|
||||
: CallController.shared.reportIncomingCall(call: call, connectedAt: nil)
|
||||
? CallController.shared.reportOutgoingCall(call: call, connectedAt: nil)
|
||||
: CallController.shared.reportIncomingCall(call: call, connectedAt: nil)
|
||||
call.callState = .connected
|
||||
call.connectedAt = .now
|
||||
}
|
||||
if state.connectionState == "closed" {
|
||||
closeCallView(client)
|
||||
m.activeCall = nil
|
||||
m.activeCallViewIsCollapsed = false
|
||||
}
|
||||
Task {
|
||||
do {
|
||||
@@ -140,6 +160,7 @@ struct ActiveCallView: View {
|
||||
case let .connected(connectionInfo):
|
||||
call.callState = .connected
|
||||
call.connectionInfo = connectionInfo
|
||||
call.connectedAt = .now
|
||||
case .ended:
|
||||
closeCallView(client)
|
||||
call.callState = .ended
|
||||
@@ -153,6 +174,7 @@ struct ActiveCallView: View {
|
||||
case .end:
|
||||
closeCallView(client)
|
||||
m.activeCall = nil
|
||||
m.activeCallViewIsCollapsed = false
|
||||
default: ()
|
||||
}
|
||||
case let .error(message):
|
||||
@@ -181,7 +203,7 @@ struct ActiveCallOverlay: View {
|
||||
VStack {
|
||||
switch call.localMedia {
|
||||
case .video:
|
||||
callInfoView(call, .leading)
|
||||
videoCallInfoView(call)
|
||||
.foregroundColor(.white)
|
||||
.opacity(0.8)
|
||||
.padding()
|
||||
@@ -208,16 +230,25 @@ struct ActiveCallOverlay: View {
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
|
||||
case .audio:
|
||||
VStack {
|
||||
ProfileImage(imageStr: call.contact.profile.image)
|
||||
.scaledToFit()
|
||||
.frame(width: 192, height: 192)
|
||||
callInfoView(call, .center)
|
||||
ZStack(alignment: .topLeading) {
|
||||
Button {
|
||||
chatModel.activeCallViewIsCollapsed = true
|
||||
} label: {
|
||||
Label("Back", systemImage: "chevron.left")
|
||||
.padding()
|
||||
.foregroundColor(.white.opacity(0.8))
|
||||
}
|
||||
VStack {
|
||||
ProfileImage(imageStr: call.contact.profile.image)
|
||||
.scaledToFit()
|
||||
.frame(width: 192, height: 192)
|
||||
audioCallInfoView(call)
|
||||
}
|
||||
.foregroundColor(.white)
|
||||
.opacity(0.8)
|
||||
.padding()
|
||||
.frame(maxHeight: .infinity)
|
||||
}
|
||||
.foregroundColor(.white)
|
||||
.opacity(0.8)
|
||||
.padding()
|
||||
.frame(maxHeight: .infinity)
|
||||
|
||||
Spacer()
|
||||
|
||||
@@ -235,12 +266,12 @@ struct ActiveCallOverlay: View {
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
|
||||
private func callInfoView(_ call: Call, _ alignment: Alignment) -> some View {
|
||||
private func audioCallInfoView(_ call: Call) -> some View {
|
||||
VStack {
|
||||
Text(call.contact.chatViewName)
|
||||
.lineLimit(1)
|
||||
.font(.title)
|
||||
.frame(maxWidth: .infinity, alignment: alignment)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
Group {
|
||||
Text(call.callState.text)
|
||||
HStack {
|
||||
@@ -251,7 +282,36 @@ struct ActiveCallOverlay: View {
|
||||
}
|
||||
}
|
||||
.font(.subheadline)
|
||||
.frame(maxWidth: .infinity, alignment: alignment)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
}
|
||||
}
|
||||
|
||||
private func videoCallInfoView(_ call: Call) -> some View {
|
||||
VStack {
|
||||
Button {
|
||||
chatModel.activeCallViewIsCollapsed = true
|
||||
} label: {
|
||||
HStack(alignment: .center, spacing: 16) {
|
||||
Image(systemName: "chevron.left")
|
||||
.resizable()
|
||||
.frame(width: 10, height: 18)
|
||||
Text(call.contact.chatViewName)
|
||||
.lineLimit(1)
|
||||
.font(.title)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
Group {
|
||||
Text(call.callState.text)
|
||||
HStack {
|
||||
Text(call.encryptionStatus)
|
||||
if let connInfo = call.connectionInfo {
|
||||
Text("(") + Text(connInfo.text) + Text(")")
|
||||
}
|
||||
}
|
||||
}
|
||||
.font(.subheadline)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -92,6 +92,7 @@ class CallManager {
|
||||
if case .ended = call.callState {
|
||||
logger.debug("CallManager.endCall: call ended")
|
||||
m.activeCall = nil
|
||||
m.activeCallViewIsCollapsed = false
|
||||
m.showCallView = false
|
||||
completed()
|
||||
} else {
|
||||
@@ -100,6 +101,7 @@ class CallManager {
|
||||
await m.callCommand.processCommand(.end)
|
||||
await MainActor.run {
|
||||
m.activeCall = nil
|
||||
m.activeCallViewIsCollapsed = false
|
||||
m.showCallView = false
|
||||
completed()
|
||||
}
|
||||
|
||||
@@ -6,14 +6,20 @@
|
||||
import SwiftUI
|
||||
import WebRTC
|
||||
import SimpleXChat
|
||||
import AVKit
|
||||
|
||||
struct CallViewRemote: UIViewRepresentable {
|
||||
var client: WebRTCClient
|
||||
var activeCall: Binding<WebRTCClient.Call?>
|
||||
@State var enablePip: (Bool) -> Void = {_ in }
|
||||
@Binding var activeCallViewIsCollapsed: Bool
|
||||
@Binding var pipShown: Bool
|
||||
|
||||
init(client: WebRTCClient, activeCall: Binding<WebRTCClient.Call?>) {
|
||||
init(client: WebRTCClient, activeCall: Binding<WebRTCClient.Call?>, activeCallViewIsCollapsed: Binding<Bool>, pipShown: Binding<Bool>) {
|
||||
self.client = client
|
||||
self.activeCall = activeCall
|
||||
self._activeCallViewIsCollapsed = activeCallViewIsCollapsed
|
||||
self._pipShown = pipShown
|
||||
}
|
||||
|
||||
func makeUIView(context: Context) -> UIView {
|
||||
@@ -23,12 +29,120 @@ struct CallViewRemote: UIViewRepresentable {
|
||||
remoteRenderer.videoContentMode = .scaleAspectFill
|
||||
client.addRemoteRenderer(call, remoteRenderer)
|
||||
addSubviewAndResize(remoteRenderer, into: view)
|
||||
|
||||
if AVPictureInPictureController.isPictureInPictureSupported() {
|
||||
makeViewWithRTCRenderer(call, remoteRenderer, view, context)
|
||||
}
|
||||
}
|
||||
return view
|
||||
}
|
||||
|
||||
func makeViewWithRTCRenderer(_ call: WebRTCClient.Call, _ remoteRenderer: RTCMTLVideoView, _ view: UIView, _ context: Context) {
|
||||
let pipRemoteRenderer = RTCMTLVideoView(frame: view.frame)
|
||||
pipRemoteRenderer.videoContentMode = .scaleAspectFill
|
||||
|
||||
let pipVideoCallViewController = AVPictureInPictureVideoCallViewController()
|
||||
pipVideoCallViewController.preferredContentSize = CGSize(width: 1080, height: 1920)
|
||||
addSubviewAndResize(pipRemoteRenderer, into: pipVideoCallViewController.view)
|
||||
let pipContentSource = AVPictureInPictureController.ContentSource(
|
||||
activeVideoCallSourceView: view,
|
||||
contentViewController: pipVideoCallViewController
|
||||
)
|
||||
|
||||
let pipController = AVPictureInPictureController(contentSource: pipContentSource)
|
||||
pipController.canStartPictureInPictureAutomaticallyFromInline = true
|
||||
pipController.delegate = context.coordinator
|
||||
context.coordinator.pipController = pipController
|
||||
context.coordinator.willShowHide = { show in
|
||||
if show {
|
||||
client.addRemoteRenderer(call, pipRemoteRenderer)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
||||
activeCallViewIsCollapsed = true
|
||||
}
|
||||
} else {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
|
||||
activeCallViewIsCollapsed = false
|
||||
}
|
||||
}
|
||||
}
|
||||
context.coordinator.didShowHide = { show in
|
||||
if show {
|
||||
remoteRenderer.isHidden = true
|
||||
} else {
|
||||
client.removeRemoteRenderer(call, pipRemoteRenderer)
|
||||
remoteRenderer.isHidden = false
|
||||
}
|
||||
pipShown = show
|
||||
}
|
||||
DispatchQueue.main.async {
|
||||
enablePip = { enable in
|
||||
if enable != pipShown /* pipController.isPictureInPictureActive */ {
|
||||
if enable {
|
||||
pipController.startPictureInPicture()
|
||||
} else {
|
||||
pipController.stopPictureInPicture()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator()
|
||||
}
|
||||
|
||||
func updateUIView(_ view: UIView, context: Context) {
|
||||
logger.debug("CallView.updateUIView remote")
|
||||
DispatchQueue.main.async {
|
||||
if activeCallViewIsCollapsed != pipShown {
|
||||
enablePip(activeCallViewIsCollapsed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Coordinator
|
||||
class Coordinator: NSObject, AVPictureInPictureControllerDelegate {
|
||||
var pipController: AVPictureInPictureController? = nil
|
||||
var willShowHide: (Bool) -> Void = { _ in }
|
||||
var didShowHide: (Bool) -> Void = { _ in }
|
||||
|
||||
func pictureInPictureControllerWillStartPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) {
|
||||
willShowHide(true)
|
||||
}
|
||||
|
||||
func pictureInPictureControllerDidStartPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) {
|
||||
didShowHide(true)
|
||||
}
|
||||
|
||||
func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, failedToStartPictureInPictureWithError error: Error) {
|
||||
logger.error("PiP failed to start: \(error.localizedDescription)")
|
||||
}
|
||||
|
||||
func pictureInPictureControllerWillStopPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) {
|
||||
willShowHide(false)
|
||||
}
|
||||
|
||||
func pictureInPictureControllerDidStopPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) {
|
||||
didShowHide(false)
|
||||
}
|
||||
|
||||
deinit {
|
||||
pipController?.stopPictureInPicture()
|
||||
pipController?.canStartPictureInPictureAutomaticallyFromInline = false
|
||||
pipController?.contentSource = nil
|
||||
pipController?.delegate = nil
|
||||
pipController = nil
|
||||
}
|
||||
}
|
||||
|
||||
class SampleBufferVideoCallView: UIView {
|
||||
override class var layerClass: AnyClass {
|
||||
get { return AVSampleBufferDisplayLayer.self }
|
||||
}
|
||||
|
||||
var sampleBufferDisplayLayer: AVSampleBufferDisplayLayer {
|
||||
return layer as! AVSampleBufferDisplayLayer
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,11 +150,14 @@ struct CallViewLocal: UIViewRepresentable {
|
||||
var client: WebRTCClient
|
||||
var activeCall: Binding<WebRTCClient.Call?>
|
||||
var localRendererAspectRatio: Binding<CGFloat?>
|
||||
@State var pipStateChanged: (Bool) -> Void = {_ in }
|
||||
@Binding var pipShown: Bool
|
||||
|
||||
init(client: WebRTCClient, activeCall: Binding<WebRTCClient.Call?>, localRendererAspectRatio: Binding<CGFloat?>) {
|
||||
init(client: WebRTCClient, activeCall: Binding<WebRTCClient.Call?>, localRendererAspectRatio: Binding<CGFloat?>, pipShown: Binding<Bool>) {
|
||||
self.client = client
|
||||
self.activeCall = activeCall
|
||||
self.localRendererAspectRatio = localRendererAspectRatio
|
||||
self._pipShown = pipShown
|
||||
}
|
||||
|
||||
func makeUIView(context: Context) -> UIView {
|
||||
@@ -50,12 +167,18 @@ struct CallViewLocal: UIViewRepresentable {
|
||||
client.addLocalRenderer(call, localRenderer)
|
||||
client.startCaptureLocalVideo(call)
|
||||
addSubviewAndResize(localRenderer, into: view)
|
||||
DispatchQueue.main.async {
|
||||
pipStateChanged = { shown in
|
||||
localRenderer.isHidden = shown
|
||||
}
|
||||
}
|
||||
}
|
||||
return view
|
||||
}
|
||||
|
||||
func updateUIView(_ view: UIView, context: Context) {
|
||||
logger.debug("CallView.updateUIView local")
|
||||
pipStateChanged(pipShown)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ class Call: ObservableObject, Equatable {
|
||||
@Published var speakerEnabled = false
|
||||
@Published var videoEnabled: Bool
|
||||
@Published var connectionInfo: ConnectionInfo?
|
||||
@Published var connectedAt: Date? = nil
|
||||
|
||||
init(
|
||||
direction: CallDirection,
|
||||
@@ -59,6 +60,7 @@ class Call: ObservableObject, Equatable {
|
||||
}
|
||||
}
|
||||
var hasMedia: Bool { get { callState == .offerSent || callState == .negotiated || callState == .connected } }
|
||||
var supportsVideo: Bool { get { peerMedia == .video || localMedia == .video } }
|
||||
}
|
||||
|
||||
enum CallDirection {
|
||||
|
||||
@@ -331,6 +331,10 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
|
||||
activeCall.remoteStream?.add(renderer)
|
||||
}
|
||||
|
||||
func removeRemoteRenderer(_ activeCall: Call, _ renderer: RTCVideoRenderer) {
|
||||
activeCall.remoteStream?.remove(renderer)
|
||||
}
|
||||
|
||||
func startCaptureLocalVideo(_ activeCall: Call) {
|
||||
#if targetEnvironment(simulator)
|
||||
guard
|
||||
@@ -410,6 +414,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
|
||||
guard let call = activeCall.wrappedValue else { return }
|
||||
logger.debug("WebRTCClient: ending the call")
|
||||
activeCall.wrappedValue = nil
|
||||
(call.localCamera as? RTCCameraVideoCapturer)?.stopCapture()
|
||||
call.connection.close()
|
||||
call.connection.delegate = nil
|
||||
call.frameEncryptor?.delegate = nil
|
||||
|
||||
@@ -29,6 +29,9 @@ struct CIImageView: View {
|
||||
FullScreenMediaView(chatItem: chatItem, image: uiImage, showView: $showFullScreenImage, scrollProxy: scrollProxy)
|
||||
}
|
||||
.onTapGesture { showFullScreenImage = true }
|
||||
.onChange(of: m.activeCallViewIsCollapsed) { _ in
|
||||
showFullScreenImage = false
|
||||
}
|
||||
} else if let data = Data(base64Encoded: dropImagePrefix(image)),
|
||||
let uiImage = UIImage(data: data) {
|
||||
imageView(uiImage)
|
||||
|
||||
@@ -120,6 +120,9 @@ struct CIVideoView: View {
|
||||
showFullScreenPlayer = urlDecrypted != nil
|
||||
}
|
||||
}
|
||||
.onChange(of: m.activeCallViewIsCollapsed) { _ in
|
||||
showFullScreenPlayer = false
|
||||
}
|
||||
if !decryptionInProgress {
|
||||
Button {
|
||||
decrypt(file: file) {
|
||||
@@ -168,6 +171,9 @@ struct CIVideoView: View {
|
||||
default: ()
|
||||
}
|
||||
}
|
||||
.onChange(of: m.activeCallViewIsCollapsed) { _ in
|
||||
showFullScreenPlayer = false
|
||||
}
|
||||
if !videoPlaying {
|
||||
Button {
|
||||
m.stopPreviousRecPlay = url
|
||||
|
||||
@@ -161,11 +161,15 @@ struct ChatView: View {
|
||||
HStack {
|
||||
let callsPrefEnabled = contact.mergedPreferences.calls.enabled.forUser
|
||||
if callsPrefEnabled {
|
||||
callButton(contact, .audio, imageName: "phone")
|
||||
.disabled(!contact.ready || !contact.active)
|
||||
if chatModel.activeCall == nil {
|
||||
callButton(contact, .audio, imageName: "phone")
|
||||
.disabled(!contact.ready || !contact.active)
|
||||
} else if let call = chatModel.activeCall, call.contact.id == cInfo.id {
|
||||
endCallButton(call)
|
||||
}
|
||||
}
|
||||
Menu {
|
||||
if callsPrefEnabled {
|
||||
if callsPrefEnabled && chatModel.activeCall == nil {
|
||||
Button {
|
||||
CallController.shared.startCall(contact, .video)
|
||||
} label: {
|
||||
@@ -422,7 +426,19 @@ struct ChatView: View {
|
||||
Image(systemName: imageName)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func endCallButton(_ call: Call) -> some View {
|
||||
Button {
|
||||
if let uuid = call.callkitUUID {
|
||||
CallController.shared.endCall(callUUID: uuid)
|
||||
} else {
|
||||
CallController.shared.endCall(call: call) {}
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "phone.down.fill").tint(.red)
|
||||
}
|
||||
}
|
||||
|
||||
private func searchButton() -> some View {
|
||||
Button {
|
||||
searchMode = true
|
||||
|
||||
@@ -234,39 +234,29 @@ struct GroupChatInfoView: View {
|
||||
Spacer()
|
||||
memberInfo(member)
|
||||
}
|
||||
|
||||
// revert from this:
|
||||
|
||||
if user {
|
||||
v
|
||||
} else if member.canBeRemoved(groupInfo: groupInfo) {
|
||||
removeSwipe(member, blockSwipe(member, v))
|
||||
} else if groupInfo.membership.memberRole >= .admin {
|
||||
// TODO if there are more actions, refactor with lists of swipeActions
|
||||
let canBlockForAll = member.canBlockForAll(groupInfo: groupInfo)
|
||||
let canRemove = member.canBeRemoved(groupInfo: groupInfo)
|
||||
if canBlockForAll && canRemove {
|
||||
removeSwipe(member, blockForAllSwipe(member, v))
|
||||
} else if canBlockForAll {
|
||||
blockForAllSwipe(member, v)
|
||||
} else if canRemove {
|
||||
removeSwipe(member, v)
|
||||
} else {
|
||||
v
|
||||
}
|
||||
} else {
|
||||
blockSwipe(member, v)
|
||||
if !member.blockedByAdmin {
|
||||
blockSwipe(member, v)
|
||||
} else {
|
||||
v
|
||||
}
|
||||
}
|
||||
// revert to this: vvv
|
||||
// if user {
|
||||
// v
|
||||
// } else if groupInfo.membership.memberRole >= .admin {
|
||||
// // TODO if there are more actions, refactor with lists of swipeActions
|
||||
// let canBlockForAll = member.canBlockForAll(groupInfo: groupInfo)
|
||||
// let canRemove = member.canBeRemoved(groupInfo: groupInfo)
|
||||
// if canBlockForAll && canRemove {
|
||||
// removeSwipe(member, blockForAllSwipe(member, v))
|
||||
// } else if canBlockForAll {
|
||||
// blockForAllSwipe(member, v)
|
||||
// } else if canRemove {
|
||||
// removeSwipe(member, v)
|
||||
// } else {
|
||||
// v
|
||||
// }
|
||||
// } else {
|
||||
// if !member.blockedByAdmin {
|
||||
// blockSwipe(member, v)
|
||||
// } else {
|
||||
// v
|
||||
// }
|
||||
// }
|
||||
// ^^^
|
||||
}
|
||||
|
||||
@ViewBuilder private func memberInfo(_ member: GroupMember) -> some View {
|
||||
|
||||
@@ -168,24 +168,11 @@ struct GroupMemberInfoView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// revert from this:
|
||||
Section {
|
||||
if member.memberSettings.showMessages {
|
||||
blockMemberButton(member)
|
||||
} else {
|
||||
unblockMemberButton(member)
|
||||
}
|
||||
if member.canBeRemoved(groupInfo: groupInfo) {
|
||||
removeMemberButton(member)
|
||||
}
|
||||
if groupInfo.membership.memberRole >= .admin {
|
||||
adminDestructiveSection(member)
|
||||
} else {
|
||||
nonAdminBlockSection(member)
|
||||
}
|
||||
// revert to this: vvv
|
||||
// if groupInfo.membership.memberRole >= .admin {
|
||||
// adminDestructiveSection(member)
|
||||
// } else {
|
||||
// nonAdminBlockSection(member)
|
||||
// }
|
||||
// ^^^
|
||||
|
||||
if developerTools {
|
||||
Section("For console") {
|
||||
|
||||
@@ -42,25 +42,6 @@ struct DeveloperView: View {
|
||||
} footer: {
|
||||
(developerTools ? Text("Show:") : Text("Hide:")) + Text(" ") + Text("Database IDs and Transport isolation option.")
|
||||
}
|
||||
|
||||
// Section {
|
||||
// settingsRow("arrow.up.doc") {
|
||||
// Toggle("Send videos and files via XFTP", isOn: $xftpSendEnabled)
|
||||
// .onChange(of: xftpSendEnabled) { _ in
|
||||
// do {
|
||||
// try setXFTPConfig(getXFTPCfg())
|
||||
// } catch {
|
||||
// logger.error("setXFTPConfig: cannot set XFTP config \(responseError(error))")
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// } header: {
|
||||
// Text("Experimental")
|
||||
// } footer: {
|
||||
// if xftpSendEnabled {
|
||||
// Text("v4.6.1+ is required to receive via XFTP.")
|
||||
// }
|
||||
// }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,6 +76,10 @@ struct NotificationsView: View {
|
||||
Text(m.notificationPreview.label)
|
||||
}
|
||||
}
|
||||
|
||||
if let server = m.notificationServer {
|
||||
smpServers("Push server", [server])
|
||||
}
|
||||
} header: {
|
||||
Text("Push notifications")
|
||||
} footer: {
|
||||
@@ -87,6 +91,9 @@ struct NotificationsView: View {
|
||||
}
|
||||
}
|
||||
.disabled(legacyDatabase)
|
||||
.onAppear {
|
||||
(m.savedToken, m.tokenStatus, m.notificationMode, m.notificationServer) = apiGetNtfToken()
|
||||
}
|
||||
}
|
||||
|
||||
private func notificationAlert(_ alert: NotificationAlert, _ token: DeviceToken) -> Alert {
|
||||
@@ -125,6 +132,7 @@ struct NotificationsView: View {
|
||||
m.tokenStatus = .new
|
||||
notificationMode = .off
|
||||
m.notificationMode = .off
|
||||
m.notificationServer = nil
|
||||
}
|
||||
} catch let error {
|
||||
await MainActor.run {
|
||||
@@ -135,11 +143,13 @@ struct NotificationsView: View {
|
||||
}
|
||||
default:
|
||||
do {
|
||||
let status = try await apiRegisterToken(token: token, notificationMode: mode)
|
||||
let _ = try await apiRegisterToken(token: token, notificationMode: mode)
|
||||
let (_, tknStatus, ntfMode, ntfServer) = apiGetNtfToken()
|
||||
await MainActor.run {
|
||||
m.tokenStatus = status
|
||||
notificationMode = mode
|
||||
m.notificationMode = mode
|
||||
m.tokenStatus = tknStatus
|
||||
notificationMode = ntfMode
|
||||
m.notificationMode = ntfMode
|
||||
m.notificationServer = ntfServer
|
||||
}
|
||||
} catch let error {
|
||||
await MainActor.run {
|
||||
|
||||
@@ -453,7 +453,6 @@ var receiverStarted = false
|
||||
let startLock = DispatchSemaphore(value: 1)
|
||||
let suspendLock = DispatchSemaphore(value: 1)
|
||||
var networkConfig: NetCfg = getNetCfg()
|
||||
let xftpConfig: XFTPFileConfig? = getXFTPCfg()
|
||||
|
||||
// startChat uses semaphore startLock to ensure that only one didReceive thread can start chat controller
|
||||
// Subsequent calls to didReceive will be waiting on semaphore and won't start chat again, as it will be .active
|
||||
@@ -499,7 +498,6 @@ func doStartChat() -> DBMigrationResult? {
|
||||
try setNetworkConfig(networkConfig)
|
||||
try apiSetTempFolder(tempFolder: getTempFilesDirectory().path)
|
||||
try apiSetFilesFolder(filesFolder: getAppFilesDirectory().path)
|
||||
try setXFTPConfig(xftpConfig)
|
||||
try apiSetEncryptLocalFiles(privacyEncryptLocalFilesGroupDefault.get())
|
||||
// prevent suspension while starting chat
|
||||
suspendLock.wait()
|
||||
@@ -733,12 +731,6 @@ func apiSetFilesFolder(filesFolder: String) throws {
|
||||
throw r
|
||||
}
|
||||
|
||||
func setXFTPConfig(_ cfg: XFTPFileConfig?) throws {
|
||||
let r = sendSimpleXCmd(.apiSetXFTPConfig(config: cfg))
|
||||
if case .cmdOk = r { return }
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiSetEncryptLocalFiles(_ enable: Bool) throws {
|
||||
let r = sendSimpleXCmd(.apiSetEncryptLocalFiles(enable: enable))
|
||||
if case .cmdOk = r { return }
|
||||
|
||||
@@ -29,11 +29,6 @@
|
||||
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 */; };
|
||||
5C29C3A52B6D09B2003DF84C /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C29C3A02B6D09B2003DF84C /* libgmpxx.a */; };
|
||||
5C29C3A62B6D09B2003DF84C /* libHSsimplex-chat-5.5.2.0-B3iqnZovI7Z5cYCa3ekeAz.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C29C3A12B6D09B2003DF84C /* libHSsimplex-chat-5.5.2.0-B3iqnZovI7Z5cYCa3ekeAz.a */; };
|
||||
5C29C3A72B6D09B2003DF84C /* libHSsimplex-chat-5.5.2.0-B3iqnZovI7Z5cYCa3ekeAz-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C29C3A22B6D09B2003DF84C /* libHSsimplex-chat-5.5.2.0-B3iqnZovI7Z5cYCa3ekeAz-ghc9.6.3.a */; };
|
||||
5C29C3A82B6D09B2003DF84C /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C29C3A32B6D09B2003DF84C /* libgmp.a */; };
|
||||
5C29C3A92B6D09B2003DF84C /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C29C3A42B6D09B2003DF84C /* libffi.a */; };
|
||||
5C2E260727A2941F00F70299 /* SimpleXAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C2E260627A2941F00F70299 /* SimpleXAPI.swift */; };
|
||||
5C2E260B27A30CFA00F70299 /* ChatListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C2E260A27A30CFA00F70299 /* ChatListView.swift */; };
|
||||
5C2E260F27A30FDC00F70299 /* ChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C2E260E27A30FDC00F70299 /* ChatView.swift */; };
|
||||
@@ -95,6 +90,11 @@
|
||||
5CB0BA90282713D900B3292C /* SimpleXInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB0BA8F282713D900B3292C /* SimpleXInfo.swift */; };
|
||||
5CB0BA92282713FD00B3292C /* CreateProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB0BA91282713FD00B3292C /* CreateProfile.swift */; };
|
||||
5CB0BA9A2827FD8800B3292C /* HowItWorks.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB0BA992827FD8800B3292C /* HowItWorks.swift */; };
|
||||
5CB1CE9C2B8771DB00963938 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CB1CE972B8771DB00963938 /* libffi.a */; };
|
||||
5CB1CE9D2B8771DB00963938 /* libHSsimplex-chat-5.5.5.0-4Xh8aGOq2uNGBj7gMJTaKI.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CB1CE982B8771DB00963938 /* libHSsimplex-chat-5.5.5.0-4Xh8aGOq2uNGBj7gMJTaKI.a */; };
|
||||
5CB1CE9E2B8771DB00963938 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CB1CE992B8771DB00963938 /* libgmpxx.a */; };
|
||||
5CB1CE9F2B8771DB00963938 /* libHSsimplex-chat-5.5.5.0-4Xh8aGOq2uNGBj7gMJTaKI-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CB1CE9A2B8771DB00963938 /* libHSsimplex-chat-5.5.5.0-4Xh8aGOq2uNGBj7gMJTaKI-ghc9.6.3.a */; };
|
||||
5CB1CEA02B8771DB00963938 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CB1CE9B2B8771DB00963938 /* libgmp.a */; };
|
||||
5CB2084F28DA4B4800D024EC /* RTCServers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB2084E28DA4B4800D024EC /* RTCServers.swift */; };
|
||||
5CB346E52868AA7F001FD2EF /* SuspendChat.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB346E42868AA7F001FD2EF /* SuspendChat.swift */; };
|
||||
5CB346E72868D76D001FD2EF /* NotificationsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB346E62868D76D001FD2EF /* NotificationsView.swift */; };
|
||||
@@ -278,11 +278,6 @@
|
||||
5C245F3C2B501E98001CC39F /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
5C245F3D2B501F13001CC39F /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = "tr.lproj/SimpleX--iOS--InfoPlist.strings"; sourceTree = "<group>"; };
|
||||
5C245F3E2B501F13001CC39F /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/InfoPlist.strings; sourceTree = "<group>"; };
|
||||
5C29C3A02B6D09B2003DF84C /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; };
|
||||
5C29C3A12B6D09B2003DF84C /* libHSsimplex-chat-5.5.2.0-B3iqnZovI7Z5cYCa3ekeAz.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.5.2.0-B3iqnZovI7Z5cYCa3ekeAz.a"; sourceTree = "<group>"; };
|
||||
5C29C3A22B6D09B2003DF84C /* libHSsimplex-chat-5.5.2.0-B3iqnZovI7Z5cYCa3ekeAz-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.5.2.0-B3iqnZovI7Z5cYCa3ekeAz-ghc9.6.3.a"; sourceTree = "<group>"; };
|
||||
5C29C3A32B6D09B2003DF84C /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
|
||||
5C29C3A42B6D09B2003DF84C /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; };
|
||||
5C2E260627A2941F00F70299 /* SimpleXAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpleXAPI.swift; sourceTree = "<group>"; };
|
||||
5C2E260A27A30CFA00F70299 /* ChatListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatListView.swift; sourceTree = "<group>"; };
|
||||
5C2E260E27A30FDC00F70299 /* ChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatView.swift; sourceTree = "<group>"; };
|
||||
@@ -377,6 +372,11 @@
|
||||
5CB0BA8F282713D900B3292C /* SimpleXInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpleXInfo.swift; sourceTree = "<group>"; };
|
||||
5CB0BA91282713FD00B3292C /* CreateProfile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateProfile.swift; sourceTree = "<group>"; };
|
||||
5CB0BA992827FD8800B3292C /* HowItWorks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HowItWorks.swift; sourceTree = "<group>"; };
|
||||
5CB1CE972B8771DB00963938 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; };
|
||||
5CB1CE982B8771DB00963938 /* libHSsimplex-chat-5.5.5.0-4Xh8aGOq2uNGBj7gMJTaKI.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.5.5.0-4Xh8aGOq2uNGBj7gMJTaKI.a"; sourceTree = "<group>"; };
|
||||
5CB1CE992B8771DB00963938 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; };
|
||||
5CB1CE9A2B8771DB00963938 /* libHSsimplex-chat-5.5.5.0-4Xh8aGOq2uNGBj7gMJTaKI-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.5.5.0-4Xh8aGOq2uNGBj7gMJTaKI-ghc9.6.3.a"; sourceTree = "<group>"; };
|
||||
5CB1CE9B2B8771DB00963938 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
|
||||
5CB2084E28DA4B4800D024EC /* RTCServers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RTCServers.swift; sourceTree = "<group>"; };
|
||||
5CB2085428DE647400D024EC /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
5CB346E42868AA7F001FD2EF /* SuspendChat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuspendChat.swift; sourceTree = "<group>"; };
|
||||
@@ -514,13 +514,13 @@
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
5C29C3A62B6D09B2003DF84C /* libHSsimplex-chat-5.5.2.0-B3iqnZovI7Z5cYCa3ekeAz.a in Frameworks */,
|
||||
5C29C3A52B6D09B2003DF84C /* libgmpxx.a in Frameworks */,
|
||||
5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */,
|
||||
5CB1CEA02B8771DB00963938 /* libgmp.a in Frameworks */,
|
||||
5CB1CE9E2B8771DB00963938 /* libgmpxx.a in Frameworks */,
|
||||
5CB1CE9C2B8771DB00963938 /* libffi.a in Frameworks */,
|
||||
5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */,
|
||||
5C29C3A92B6D09B2003DF84C /* libffi.a in Frameworks */,
|
||||
5C29C3A82B6D09B2003DF84C /* libgmp.a in Frameworks */,
|
||||
5C29C3A72B6D09B2003DF84C /* libHSsimplex-chat-5.5.2.0-B3iqnZovI7Z5cYCa3ekeAz-ghc9.6.3.a in Frameworks */,
|
||||
5CB1CE9D2B8771DB00963938 /* libHSsimplex-chat-5.5.5.0-4Xh8aGOq2uNGBj7gMJTaKI.a in Frameworks */,
|
||||
5CB1CE9F2B8771DB00963938 /* libHSsimplex-chat-5.5.5.0-4Xh8aGOq2uNGBj7gMJTaKI-ghc9.6.3.a in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -582,11 +582,11 @@
|
||||
5C764E5C279C70B7000C6508 /* Libraries */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
5C29C3A42B6D09B2003DF84C /* libffi.a */,
|
||||
5C29C3A32B6D09B2003DF84C /* libgmp.a */,
|
||||
5C29C3A02B6D09B2003DF84C /* libgmpxx.a */,
|
||||
5C29C3A22B6D09B2003DF84C /* libHSsimplex-chat-5.5.2.0-B3iqnZovI7Z5cYCa3ekeAz-ghc9.6.3.a */,
|
||||
5C29C3A12B6D09B2003DF84C /* libHSsimplex-chat-5.5.2.0-B3iqnZovI7Z5cYCa3ekeAz.a */,
|
||||
5CB1CE972B8771DB00963938 /* libffi.a */,
|
||||
5CB1CE9B2B8771DB00963938 /* libgmp.a */,
|
||||
5CB1CE992B8771DB00963938 /* libgmpxx.a */,
|
||||
5CB1CE9A2B8771DB00963938 /* libHSsimplex-chat-5.5.5.0-4Xh8aGOq2uNGBj7gMJTaKI-ghc9.6.3.a */,
|
||||
5CB1CE982B8771DB00963938 /* libHSsimplex-chat-5.5.5.0-4Xh8aGOq2uNGBj7gMJTaKI.a */,
|
||||
);
|
||||
path = Libraries;
|
||||
sourceTree = "<group>";
|
||||
@@ -1509,7 +1509,7 @@
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 196;
|
||||
CURRENT_PROJECT_VERSION = 199;
|
||||
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
||||
ENABLE_BITCODE = NO;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
@@ -1531,7 +1531,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 5.5.2;
|
||||
MARKETING_VERSION = 5.5.4;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app;
|
||||
PRODUCT_NAME = SimpleX;
|
||||
SDKROOT = iphoneos;
|
||||
@@ -1552,7 +1552,7 @@
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 196;
|
||||
CURRENT_PROJECT_VERSION = 199;
|
||||
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
||||
ENABLE_BITCODE = NO;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
@@ -1574,7 +1574,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 5.5.2;
|
||||
MARKETING_VERSION = 5.5.4;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app;
|
||||
PRODUCT_NAME = SimpleX;
|
||||
SDKROOT = iphoneos;
|
||||
@@ -1633,7 +1633,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements";
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 196;
|
||||
CURRENT_PROJECT_VERSION = 199;
|
||||
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
||||
ENABLE_BITCODE = NO;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@@ -1646,7 +1646,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 5.5.2;
|
||||
MARKETING_VERSION = 5.5.4;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
@@ -1665,7 +1665,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements";
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 196;
|
||||
CURRENT_PROJECT_VERSION = 199;
|
||||
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
||||
ENABLE_BITCODE = NO;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@@ -1678,7 +1678,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 5.5.2;
|
||||
MARKETING_VERSION = 5.5.4;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
@@ -1697,7 +1697,7 @@
|
||||
APPLICATION_EXTENSION_API_ONLY = YES;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 196;
|
||||
CURRENT_PROJECT_VERSION = 199;
|
||||
DEFINES_MODULE = YES;
|
||||
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
||||
DYLIB_COMPATIBILITY_VERSION = 1;
|
||||
@@ -1721,7 +1721,7 @@
|
||||
"$(inherited)",
|
||||
"$(PROJECT_DIR)/Libraries/sim",
|
||||
);
|
||||
MARKETING_VERSION = 5.5.2;
|
||||
MARKETING_VERSION = 5.5.4;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat;
|
||||
PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)";
|
||||
SDKROOT = iphoneos;
|
||||
@@ -1743,7 +1743,7 @@
|
||||
APPLICATION_EXTENSION_API_ONLY = YES;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 196;
|
||||
CURRENT_PROJECT_VERSION = 199;
|
||||
DEFINES_MODULE = YES;
|
||||
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
||||
DYLIB_COMPATIBILITY_VERSION = 1;
|
||||
@@ -1767,7 +1767,7 @@
|
||||
"$(inherited)",
|
||||
"$(PROJECT_DIR)/Libraries/sim",
|
||||
);
|
||||
MARKETING_VERSION = 5.5.2;
|
||||
MARKETING_VERSION = 5.5.4;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat;
|
||||
PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)";
|
||||
SDKROOT = iphoneos;
|
||||
|
||||
@@ -31,7 +31,6 @@ public enum ChatCommand {
|
||||
case apiSuspendChat(timeoutMicroseconds: Int)
|
||||
case setTempFolder(tempFolder: String)
|
||||
case setFilesFolder(filesFolder: String)
|
||||
case apiSetXFTPConfig(config: XFTPFileConfig?)
|
||||
case apiSetEncryptLocalFiles(enable: Bool)
|
||||
case apiExportArchive(config: ArchiveConfig)
|
||||
case apiImportArchive(config: ArchiveConfig)
|
||||
@@ -162,11 +161,6 @@ public enum ChatCommand {
|
||||
case let .apiSuspendChat(timeoutMicroseconds): return "/_app suspend \(timeoutMicroseconds)"
|
||||
case let .setTempFolder(tempFolder): return "/_temp_folder \(tempFolder)"
|
||||
case let .setFilesFolder(filesFolder): return "/_files_folder \(filesFolder)"
|
||||
case let .apiSetXFTPConfig(cfg): if let cfg = cfg {
|
||||
return "/_xftp on \(encodeJSON(cfg))"
|
||||
} else {
|
||||
return "/_xftp off"
|
||||
}
|
||||
case let .apiSetEncryptLocalFiles(enable): return "/_files_encrypt \(onOff(enable))"
|
||||
case let .apiExportArchive(cfg): return "/_db export \(encodeJSON(cfg))"
|
||||
case let .apiImportArchive(cfg): return "/_db import \(encodeJSON(cfg))"
|
||||
@@ -311,7 +305,6 @@ public enum ChatCommand {
|
||||
case .apiSuspendChat: return "apiSuspendChat"
|
||||
case .setTempFolder: return "setTempFolder"
|
||||
case .setFilesFolder: return "setFilesFolder"
|
||||
case .apiSetXFTPConfig: return "apiSetXFTPConfig"
|
||||
case .apiSetEncryptLocalFiles: return "apiSetEncryptLocalFiles"
|
||||
case .apiExportArchive: return "apiExportArchive"
|
||||
case .apiImportArchive: return "apiImportArchive"
|
||||
@@ -613,7 +606,7 @@ public enum ChatResponse: Decodable, Error {
|
||||
case callEnded(user: UserRef, contact: Contact)
|
||||
case callInvitations(callInvitations: [RcvCallInvitation])
|
||||
case ntfTokenStatus(status: NtfTknStatus)
|
||||
case ntfToken(token: DeviceToken, status: NtfTknStatus, ntfMode: NotificationsMode)
|
||||
case ntfToken(token: DeviceToken, status: NtfTknStatus, ntfMode: NotificationsMode, ntfServer: String)
|
||||
case ntfMessages(user_: User?, connEntity_: ConnectionEntity?, msgTs: Date?, ntfMessages: [NtfMsgInfo])
|
||||
case ntfMessage(user: UserRef, connEntity: ConnectionEntity, ntfMessage: NtfMsgInfo)
|
||||
case contactConnectionDeleted(user: UserRef, connection: PendingContactConnection)
|
||||
@@ -912,7 +905,7 @@ public enum ChatResponse: Decodable, Error {
|
||||
case let .callEnded(u, contact): return withUser(u, "contact: \(contact.id)")
|
||||
case let .callInvitations(invs): return String(describing: invs)
|
||||
case let .ntfTokenStatus(status): return String(describing: status)
|
||||
case let .ntfToken(token, status, ntfMode): return "token: \(token)\nstatus: \(status.rawValue)\nntfMode: \(ntfMode.rawValue)"
|
||||
case let .ntfToken(token, status, ntfMode, ntfServer): return "token: \(token)\nstatus: \(status.rawValue)\nntfMode: \(ntfMode.rawValue)\nntfServer: \(ntfServer)"
|
||||
case let .ntfMessages(u, connEntity, msgTs, ntfMessages): return withUser(u, "connEntity: \(String(describing: connEntity))\nmsgTs: \(String(describing: msgTs))\nntfMessages: \(String(describing: ntfMessages))")
|
||||
case let .ntfMessage(u, connEntity, ntfMessage): return withUser(u, "connEntity: \(String(describing: connEntity))\nntfMessage: \(String(describing: ntfMessage))")
|
||||
case let .contactConnectionDeleted(u, connection): return withUser(u, String(describing: connection))
|
||||
@@ -1005,10 +998,6 @@ struct ComposedMessage: Encodable {
|
||||
var msgContent: MsgContent
|
||||
}
|
||||
|
||||
public struct XFTPFileConfig: Encodable {
|
||||
var minFileSize: Int64
|
||||
}
|
||||
|
||||
public struct ArchiveConfig: Encodable {
|
||||
var archivePath: String
|
||||
var disableCompression: Bool?
|
||||
|
||||
@@ -265,10 +265,6 @@ public class Default<T> {
|
||||
}
|
||||
}
|
||||
|
||||
public func getXFTPCfg() -> XFTPFileConfig {
|
||||
return XFTPFileConfig(minFileSize: 0)
|
||||
}
|
||||
|
||||
public func getNetCfg() -> NetCfg {
|
||||
let onionHosts = networkUseOnionHostsGroupDefault.get()
|
||||
let (hostMode, requiredHostMode) = onionHosts.hostMode
|
||||
|
||||
@@ -2268,7 +2268,7 @@ public struct ChatItem: Identifiable, Decodable {
|
||||
case .rcvDirectEvent(rcvDirectEvent: let rcvDirectEvent):
|
||||
switch rcvDirectEvent {
|
||||
case .contactDeleted: return false
|
||||
case .profileUpdated: return true
|
||||
case .profileUpdated: return false
|
||||
}
|
||||
case .rcvGroupEvent(rcvGroupEvent: let rcvGroupEvent):
|
||||
switch rcvGroupEvent {
|
||||
|
||||
@@ -109,7 +109,7 @@ class CallService: Service() {
|
||||
|
||||
val builder = NotificationCompat.Builder(this, CALL_NOTIFICATION_CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ntf_icon)
|
||||
.setLargeIcon(icon)
|
||||
.setLargeIcon(icon.clipToCircle())
|
||||
.setColor(0x88FFFF)
|
||||
.setContentTitle(title)
|
||||
.setContentText(text)
|
||||
|
||||
@@ -73,7 +73,7 @@ class SimplexApp: Application(), LifecycleEventObserver {
|
||||
|
||||
override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
|
||||
Log.d(TAG, "onStateChanged: $event")
|
||||
withBGApi {
|
||||
withLongRunningApi {
|
||||
when (event) {
|
||||
Lifecycle.Event.ON_START -> {
|
||||
isAppOnForeground = true
|
||||
|
||||
+10
@@ -61,6 +61,16 @@ actual fun cropToSquare(image: ImageBitmap): ImageBitmap {
|
||||
return Bitmap.createBitmap(image.asAndroidBitmap(), xOffset, yOffset, side, side).asImageBitmap()
|
||||
}
|
||||
|
||||
fun Bitmap.clipToCircle(): Bitmap {
|
||||
val circle = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
|
||||
val path = android.graphics.Path()
|
||||
path.addCircle(width / 2f, height / 2f, min(width, height) / 2f, android.graphics.Path.Direction.CCW)
|
||||
val canvas = android.graphics.Canvas(circle)
|
||||
canvas.clipPath(path)
|
||||
canvas.drawBitmap(this, 0f, 0f, null)
|
||||
return circle
|
||||
}
|
||||
|
||||
actual fun compressImageStr(bitmap: ImageBitmap): String {
|
||||
val usePng = bitmap.hasAlpha()
|
||||
val ext = if (usePng) "png" else "jpg"
|
||||
|
||||
+18
-8
@@ -14,17 +14,27 @@ import chat.simplex.common.views.helpers.*
|
||||
import java.io.BufferedOutputStream
|
||||
import java.io.File
|
||||
import chat.simplex.res.MR
|
||||
import kotlin.math.min
|
||||
|
||||
actual fun ClipboardManager.shareText(text: String) {
|
||||
val sendIntent: Intent = Intent().apply {
|
||||
action = Intent.ACTION_SEND
|
||||
putExtra(Intent.EXTRA_TEXT, text)
|
||||
type = "text/plain"
|
||||
flags = FLAG_ACTIVITY_NEW_TASK
|
||||
var text = text
|
||||
for (i in 10 downTo 1) {
|
||||
try {
|
||||
val sendIntent: Intent = Intent().apply {
|
||||
action = Intent.ACTION_SEND
|
||||
putExtra(Intent.EXTRA_TEXT, text)
|
||||
type = "text/plain"
|
||||
flags = FLAG_ACTIVITY_NEW_TASK
|
||||
}
|
||||
val shareIntent = Intent.createChooser(sendIntent, null)
|
||||
shareIntent.addFlags(FLAG_ACTIVITY_NEW_TASK)
|
||||
androidAppContext.startActivity(shareIntent)
|
||||
break
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to share text: ${e.stackTraceToString()}")
|
||||
text = text.substring(0, min(i * 1000, text.length))
|
||||
}
|
||||
}
|
||||
val shareIntent = Intent.createChooser(sendIntent, null)
|
||||
shareIntent.addFlags(FLAG_ACTIVITY_NEW_TASK)
|
||||
androidAppContext.startActivity(shareIntent)
|
||||
}
|
||||
|
||||
actual fun shareFile(text: String, fileSource: CryptoFile) {
|
||||
|
||||
+2
-1
@@ -114,7 +114,8 @@ actual class GlobalExceptionsHandler: Thread.UncaughtExceptionHandler {
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(MR.strings.app_was_crashed),
|
||||
text = e.stackTraceToString()
|
||||
text = e.stackTraceToString(),
|
||||
shareText = true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+22
-11
@@ -133,15 +133,15 @@ actual fun ActiveCallView() {
|
||||
is WCallResponse.Capabilities -> withBGApi {
|
||||
val callType = CallType(call.localMedia, r.capabilities)
|
||||
chatModel.controller.apiSendCallInvitation(callRh, call.contact, callType)
|
||||
chatModel.activeCall.value = call.copy(callState = CallState.InvitationSent, localCapabilities = r.capabilities)
|
||||
updateActiveCall(call) { it.copy(callState = CallState.InvitationSent, localCapabilities = r.capabilities) }
|
||||
}
|
||||
is WCallResponse.Offer -> withBGApi {
|
||||
chatModel.controller.apiSendCallOffer(callRh, call.contact, r.offer, r.iceCandidates, call.localMedia, r.capabilities)
|
||||
chatModel.activeCall.value = call.copy(callState = CallState.OfferSent, localCapabilities = r.capabilities)
|
||||
updateActiveCall(call) { it.copy(callState = CallState.OfferSent, localCapabilities = r.capabilities) }
|
||||
}
|
||||
is WCallResponse.Answer -> withBGApi {
|
||||
chatModel.controller.apiSendCallAnswer(callRh, call.contact, r.answer, r.iceCandidates)
|
||||
chatModel.activeCall.value = call.copy(callState = CallState.Negotiated)
|
||||
updateActiveCall(call) { it.copy(callState = CallState.Negotiated) }
|
||||
}
|
||||
is WCallResponse.Ice -> withBGApi {
|
||||
chatModel.controller.apiSendCallExtraInfo(callRh, call.contact, r.iceCandidates)
|
||||
@@ -150,7 +150,7 @@ actual fun ActiveCallView() {
|
||||
try {
|
||||
val callStatus = json.decodeFromString<WebRTCCallStatus>("\"${r.state.connectionState}\"")
|
||||
if (callStatus == WebRTCCallStatus.Connected) {
|
||||
chatModel.activeCall.value = call.copy(callState = CallState.Connected, connectedAt = Clock.System.now())
|
||||
updateActiveCall(call) { it.copy(callState = CallState.Connected, connectedAt = Clock.System.now()) }
|
||||
setCallSound(call.soundSpeaker, audioViaBluetooth)
|
||||
}
|
||||
withBGApi { chatModel.controller.apiCallStatus(callRh, call.contact, callStatus) }
|
||||
@@ -158,7 +158,7 @@ actual fun ActiveCallView() {
|
||||
Log.d(TAG,"call status ${r.state.connectionState} not used")
|
||||
}
|
||||
is WCallResponse.Connected -> {
|
||||
chatModel.activeCall.value = call.copy(callState = CallState.Connected, connectionInfo = r.connectionInfo)
|
||||
updateActiveCall(call) { it.copy(callState = CallState.Connected, connectionInfo = r.connectionInfo) }
|
||||
scope.launch {
|
||||
setCallSound(call.soundSpeaker, audioViaBluetooth)
|
||||
}
|
||||
@@ -167,20 +167,22 @@ actual fun ActiveCallView() {
|
||||
withBGApi { chatModel.callManager.endCall(call) }
|
||||
}
|
||||
is WCallResponse.Ended -> {
|
||||
chatModel.activeCall.value = call.copy(callState = CallState.Ended)
|
||||
updateActiveCall(call) { it.copy(callState = CallState.Ended) }
|
||||
withBGApi { chatModel.callManager.endCall(call) }
|
||||
}
|
||||
is WCallResponse.Ok -> when (val cmd = apiMsg.command) {
|
||||
is WCallCommand.Answer ->
|
||||
chatModel.activeCall.value = call.copy(callState = CallState.Negotiated)
|
||||
updateActiveCall(call) { it.copy(callState = CallState.Negotiated) }
|
||||
is WCallCommand.Media -> {
|
||||
when (cmd.media) {
|
||||
CallMediaType.Video -> chatModel.activeCall.value = call.copy(videoEnabled = cmd.enable)
|
||||
CallMediaType.Audio -> chatModel.activeCall.value = call.copy(audioEnabled = cmd.enable)
|
||||
updateActiveCall(call) {
|
||||
when (cmd.media) {
|
||||
CallMediaType.Video -> it.copy(videoEnabled = cmd.enable)
|
||||
CallMediaType.Audio -> it.copy(audioEnabled = cmd.enable)
|
||||
}
|
||||
}
|
||||
}
|
||||
is WCallCommand.Camera -> {
|
||||
chatModel.activeCall.value = call.copy(localCamera = cmd.camera)
|
||||
updateActiveCall(call) { it.copy(localCamera = cmd.camera) }
|
||||
if (!call.audioEnabled) {
|
||||
chatModel.callCommand.add(WCallCommand.Media(CallMediaType.Audio, enable = false))
|
||||
}
|
||||
@@ -615,6 +617,15 @@ class WebRTCInterface(private val onResponse: (WVAPIMessage) -> Unit) {
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateActiveCall(initial: Call, transform: (Call) -> Call) {
|
||||
val activeCall = chatModel.activeCall.value
|
||||
if (activeCall != null && activeCall.contact.apiId == initial.contact.apiId) {
|
||||
chatModel.activeCall.value = transform(activeCall)
|
||||
} else {
|
||||
Log.d(TAG, "withActiveCall: ignoring, not in call with the contact ${activeCall?.contact?.id}")
|
||||
}
|
||||
}
|
||||
|
||||
private class LocalContentWebViewClient(val webView: MutableState<WebView?>, private val assetLoader: WebViewAssetLoader) : WebViewClientCompat() {
|
||||
override fun shouldInterceptRequest(
|
||||
view: WebView,
|
||||
|
||||
@@ -8,13 +8,39 @@ void hs_init_with_rtsopts(int * argc, char **argv[]);
|
||||
|
||||
JNIEXPORT void JNICALL
|
||||
Java_chat_simplex_common_platform_CoreKt_initHS(JNIEnv *env, jclass clazz) {
|
||||
// setup static arena for bump allocation and passing to RTS
|
||||
char *argv[32] = {NULL,};
|
||||
int argc = 0; // number of arguments used so far, always stands at the first NULL in argv
|
||||
// common args
|
||||
argv[argc++] = "simplex"; // fake program name
|
||||
argv[argc++] = "+RTS"; // start adding RTS options
|
||||
argv[argc++] = "-T"; // make GC counters available from inside the program
|
||||
argv[argc++] = "-A16m"; // chunk size for new allocations (less frequent GC)
|
||||
argv[argc++] = "-H64m"; // larger heap size on start (faster boot)
|
||||
argv[argc++] = "-M8G"; // keep memory usage under 8G, collecting more aggressively when approaching it (and crashing sooner rather than taking down the whole system)
|
||||
int eventlog = 1; // TODO: geto option flag from app (pointer for -ol?)
|
||||
int eventlog_threads = 2; // XXX: can be a bit flag on eventlog
|
||||
if (eventlog) {
|
||||
argv[argc++] = "-olsimplex.eventlog"; // produce simplex.eventlog in "current" directory
|
||||
if (eventlog_threads) {
|
||||
argv[argc++] = "-l-asgu"; // collect scheduler, GC, and user events
|
||||
} else {
|
||||
argv[argc++] = "-l-agu"; // collect GC and user events
|
||||
}
|
||||
}
|
||||
int profiling = 1; // TODO: get option flag from app (pointer for -po?)
|
||||
if (profiling) {
|
||||
argv[argc++] = "-posimplex"; // produce simplex.hp in "current" directory
|
||||
argv[argc++] = "-hT"; // emit heap profile by closure type
|
||||
}
|
||||
int non_moving_gc = !profiling;
|
||||
#ifdef _WIN32
|
||||
int argc = 4;
|
||||
char *argv[] = {"simplex", "+RTS", "-A16m", "-H64m", NULL}; // non-moving GC is broken on windows with GHC 9.4-9.6.3
|
||||
#else
|
||||
int argc = 5;
|
||||
char *argv[] = {"simplex", "+RTS", "-A16m", "-H64m", "-xn", NULL}; // see android/simplex-api.c for details
|
||||
// non-moving GC is broken on windows with GHC 9.4-9.6.3
|
||||
non_moving_gc = 0;
|
||||
#endif
|
||||
if (non_moving_gc)
|
||||
argv[argc++] = "-xn";
|
||||
// wrap args as expected by RTS
|
||||
char **pargv = argv;
|
||||
hs_init_with_rtsopts(&argc, &pargv);
|
||||
}
|
||||
|
||||
+1
-1
@@ -1822,7 +1822,7 @@ data class ChatItem (
|
||||
is CIContent.SndGroupInvitation -> false
|
||||
is CIContent.RcvDirectEventContent -> when (content.rcvDirectEvent) {
|
||||
is RcvDirectEvent.ContactDeleted -> false
|
||||
is RcvDirectEvent.ProfileUpdated -> true
|
||||
is RcvDirectEvent.ProfileUpdated -> false
|
||||
}
|
||||
is CIContent.RcvGroupEventContent -> when (content.rcvGroupEvent) {
|
||||
is RcvGroupEvent.MemberAdded -> false
|
||||
|
||||
+17
-19
@@ -451,7 +451,21 @@ object ChatController {
|
||||
}
|
||||
try {
|
||||
val msg = recvMsg(ctrl)
|
||||
if (msg != null) processReceivedMsg(msg)
|
||||
if (msg != null) {
|
||||
val finishedWithoutTimeout = withTimeoutOrNull(60_000L) {
|
||||
processReceivedMsg(msg)
|
||||
}
|
||||
if (finishedWithoutTimeout == null) {
|
||||
Log.e(TAG, "Timeout reached while processing received message: " + msg.resp.responseType)
|
||||
if (appPreferences.developerTools.get() && appPreferences.showSlowApiCalls.get()) {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(MR.strings.possible_slow_function_title),
|
||||
text = generalGetString(MR.strings.possible_slow_function_desc).format(60, msg.resp.responseType + "\n" + Exception().stackTraceToString()),
|
||||
shareText = true
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "ChatController recvMsg/processReceivedMsg exception: " + e.stackTraceToString());
|
||||
} catch (e: Throwable) {
|
||||
@@ -617,12 +631,6 @@ object ChatController {
|
||||
throw Error("failed to set remote hosts folder: ${r.responseType} ${r.details}")
|
||||
}
|
||||
|
||||
suspend fun apiSetXFTPConfig(cfg: XFTPFileConfig?) {
|
||||
val r = sendCmd(null, CC.ApiSetXFTPConfig(cfg))
|
||||
if (r is CR.CmdOk) return
|
||||
throw Error("apiSetXFTPConfig bad response: ${r.responseType} ${r.details}")
|
||||
}
|
||||
|
||||
suspend fun apiSetEncryptLocalFiles(enable: Boolean) = sendCommandOkResp(null, CC.ApiSetEncryptLocalFiles(enable))
|
||||
|
||||
suspend fun apiExportArchive(config: ArchiveConfig) {
|
||||
@@ -1685,7 +1693,7 @@ object ChatController {
|
||||
chatModel.networkStatuses[s.agentConnId] = s.networkStatus
|
||||
}
|
||||
}
|
||||
is CR.NewChatItem -> {
|
||||
is CR.NewChatItem -> withBGApi {
|
||||
val cInfo = r.chatItem.chatInfo
|
||||
val cItem = r.chatItem.chatItem
|
||||
if (active(r.user)) {
|
||||
@@ -1700,7 +1708,7 @@ object ChatController {
|
||||
((mc is MsgContent.MCImage && file.fileSize <= MAX_IMAGE_SIZE_AUTO_RCV)
|
||||
|| (mc is MsgContent.MCVideo && file.fileSize <= MAX_VIDEO_SIZE_AUTO_RCV)
|
||||
|| (mc is MsgContent.MCVoice && file.fileSize <= MAX_VOICE_SIZE_AUTO_RCV && file.fileStatus !is CIFileStatus.RcvAccepted))) {
|
||||
withBGApi { receiveFile(rhId, r.user, file.fileId, auto = true) }
|
||||
receiveFile(rhId, r.user, file.fileId, auto = true)
|
||||
}
|
||||
if (cItem.showNotification && (allowedToShowNotification() || chatModel.chatId.value != cInfo.id || chatModel.remoteHostId() != rhId)) {
|
||||
ntfManager.notifyMessageReceived(r.user, cInfo, cItem)
|
||||
@@ -2157,10 +2165,6 @@ object ChatController {
|
||||
}
|
||||
}
|
||||
|
||||
fun getXFTPCfg(): XFTPFileConfig {
|
||||
return XFTPFileConfig(minFileSize = 0)
|
||||
}
|
||||
|
||||
fun getNetCfg(): NetCfg {
|
||||
val useSocksProxy = appPrefs.networkUseSocksProxy.get()
|
||||
val proxyHostPort = appPrefs.networkProxyHostPort.get()
|
||||
@@ -2269,7 +2273,6 @@ sealed class CC {
|
||||
class SetTempFolder(val tempFolder: String): CC()
|
||||
class SetFilesFolder(val filesFolder: String): CC()
|
||||
class SetRemoteHostsFolder(val remoteHostsFolder: String): CC()
|
||||
class ApiSetXFTPConfig(val config: XFTPFileConfig?): CC()
|
||||
class ApiSetEncryptLocalFiles(val enable: Boolean): CC()
|
||||
class ApiExportArchive(val config: ArchiveConfig): CC()
|
||||
class ApiImportArchive(val config: ArchiveConfig): CC()
|
||||
@@ -2399,7 +2402,6 @@ sealed class CC {
|
||||
is SetTempFolder -> "/_temp_folder $tempFolder"
|
||||
is SetFilesFolder -> "/_files_folder $filesFolder"
|
||||
is SetRemoteHostsFolder -> "/remote_hosts_folder $remoteHostsFolder"
|
||||
is ApiSetXFTPConfig -> if (config != null) "/_xftp on ${json.encodeToString(config)}" else "/_xftp off"
|
||||
is ApiSetEncryptLocalFiles -> "/_files_encrypt ${onOff(enable)}"
|
||||
is ApiExportArchive -> "/_db export ${json.encodeToString(config)}"
|
||||
is ApiImportArchive -> "/_db import ${json.encodeToString(config)}"
|
||||
@@ -2534,7 +2536,6 @@ sealed class CC {
|
||||
is SetTempFolder -> "setTempFolder"
|
||||
is SetFilesFolder -> "setFilesFolder"
|
||||
is SetRemoteHostsFolder -> "setRemoteHostsFolder"
|
||||
is ApiSetXFTPConfig -> "apiSetXFTPConfig"
|
||||
is ApiSetEncryptLocalFiles -> "apiSetEncryptLocalFiles"
|
||||
is ApiExportArchive -> "apiExportArchive"
|
||||
is ApiImportArchive -> "apiImportArchive"
|
||||
@@ -2700,9 +2701,6 @@ sealed class ChatPagination {
|
||||
@Serializable
|
||||
class ComposedMessage(val fileSource: CryptoFile?, val quotedItemId: Long?, val msgContent: MsgContent)
|
||||
|
||||
@Serializable
|
||||
class XFTPFileConfig(val minFileSize: Long)
|
||||
|
||||
@Serializable
|
||||
class ArchiveConfig(val archivePath: String, val disableCompression: Boolean? = null, val parentTempDirectory: String? = null)
|
||||
|
||||
|
||||
@@ -91,7 +91,6 @@ suspend fun initChatController(useKey: String? = null, confirmMigrations: Migrat
|
||||
if (appPlatform.isDesktop) {
|
||||
controller.apiSetRemoteHostsFolder(remoteHostsDir.absolutePath)
|
||||
}
|
||||
controller.apiSetXFTPConfig(controller.getXFTPCfg())
|
||||
controller.apiSetEncryptLocalFiles(controller.appPrefs.privacyEncryptLocalFiles.get())
|
||||
// If we migrated successfully means previous re-encryption process on database level finished successfully too
|
||||
if (appPreferences.encryptionStartedAt.get() != null) appPreferences.encryptionStartedAt.set(null)
|
||||
|
||||
+2
-2
@@ -267,7 +267,7 @@ fun ComposeView(
|
||||
fun loadLinkPreview(url: String, wait: Long? = null) {
|
||||
if (pendingLinkUrl.value == url) {
|
||||
composeState.value = composeState.value.copy(preview = ComposePreview.CLinkPreview(null))
|
||||
withLongRunningApi(slow = 30_000, deadlock = 60_000) {
|
||||
withLongRunningApi(slow = 60_000) {
|
||||
if (wait != null) delay(wait)
|
||||
val lp = getLinkPreview(url)
|
||||
if (lp != null && pendingLinkUrl.value == url) {
|
||||
@@ -551,7 +551,7 @@ fun ComposeView(
|
||||
}
|
||||
|
||||
fun sendMessage(ttl: Int?) {
|
||||
withLongRunningApi(slow = 30_000, deadlock = 60_000) {
|
||||
withLongRunningApi(slow = 120_000) {
|
||||
sendMessageAsync(null, false, ttl)
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -54,7 +54,7 @@ fun AddGroupMembersView(rhId: Long?, groupInfo: GroupInfo, creatingGroup: Boolea
|
||||
},
|
||||
inviteMembers = {
|
||||
allowModifyMembers = false
|
||||
withLongRunningApi(slow = 30_000, deadlock = 120_000) {
|
||||
withLongRunningApi(slow = 120_000) {
|
||||
for (contactId in selectedContacts) {
|
||||
val member = chatModel.controller.apiAddMember(rhId, groupInfo.groupId, contactId, selectedRole.value)
|
||||
if (member != null) {
|
||||
|
||||
+39
-61
@@ -152,7 +152,7 @@ fun leaveGroupDialog(rhId: Long?, groupInfo: GroupInfo, chatModel: ChatModel, cl
|
||||
text = generalGetString(MR.strings.you_will_stop_receiving_messages_from_this_group_chat_history_will_be_preserved),
|
||||
confirmText = generalGetString(MR.strings.leave_group_button),
|
||||
onConfirm = {
|
||||
withBGApi {
|
||||
withLongRunningApi(60_000) {
|
||||
chatModel.controller.leaveGroup(rhId, groupInfo.groupId)
|
||||
close?.invoke()
|
||||
}
|
||||
@@ -424,69 +424,47 @@ private fun MemberVerifiedShield() {
|
||||
|
||||
@Composable
|
||||
private fun DropDownMenuForMember(rhId: Long?, member: GroupMember, groupInfo: GroupInfo, showMenu: MutableState<Boolean>) {
|
||||
// revert from this:
|
||||
DefaultDropdownMenu(showMenu) {
|
||||
if (member.canBeRemoved(groupInfo)) {
|
||||
ItemAction(stringResource(MR.strings.remove_member_button), painterResource(MR.images.ic_delete), color = MaterialTheme.colors.error, onClick = {
|
||||
removeMemberAlert(rhId, groupInfo, member)
|
||||
showMenu.value = false
|
||||
})
|
||||
if (groupInfo.membership.memberRole >= GroupMemberRole.Admin) {
|
||||
val canBlockForAll = member.canBlockForAll(groupInfo)
|
||||
val canRemove = member.canBeRemoved(groupInfo)
|
||||
if (canBlockForAll || canRemove) {
|
||||
DefaultDropdownMenu(showMenu) {
|
||||
if (canBlockForAll) {
|
||||
if (member.blockedByAdmin) {
|
||||
ItemAction(stringResource(MR.strings.unblock_for_all), painterResource(MR.images.ic_do_not_touch), onClick = {
|
||||
unblockForAllAlert(rhId, groupInfo, member)
|
||||
showMenu.value = false
|
||||
})
|
||||
} else {
|
||||
ItemAction(stringResource(MR.strings.block_for_all), painterResource(MR.images.ic_back_hand), color = MaterialTheme.colors.error, onClick = {
|
||||
blockForAllAlert(rhId, groupInfo, member)
|
||||
showMenu.value = false
|
||||
})
|
||||
}
|
||||
}
|
||||
if (canRemove) {
|
||||
ItemAction(stringResource(MR.strings.remove_member_button), painterResource(MR.images.ic_delete), color = MaterialTheme.colors.error, onClick = {
|
||||
removeMemberAlert(rhId, groupInfo, member)
|
||||
showMenu.value = false
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
if (member.memberSettings.showMessages) {
|
||||
ItemAction(stringResource(MR.strings.block_member_button), painterResource(MR.images.ic_back_hand), color = MaterialTheme.colors.error, onClick = {
|
||||
blockMemberAlert(rhId, groupInfo, member)
|
||||
showMenu.value = false
|
||||
})
|
||||
} else {
|
||||
ItemAction(stringResource(MR.strings.unblock_member_button), painterResource(MR.images.ic_do_not_touch), onClick = {
|
||||
unblockMemberAlert(rhId, groupInfo, member)
|
||||
showMenu.value = false
|
||||
})
|
||||
} else if (!member.blockedByAdmin) {
|
||||
DefaultDropdownMenu(showMenu) {
|
||||
if (member.memberSettings.showMessages) {
|
||||
ItemAction(stringResource(MR.strings.block_member_button), painterResource(MR.images.ic_back_hand), color = MaterialTheme.colors.error, onClick = {
|
||||
blockMemberAlert(rhId, groupInfo, member)
|
||||
showMenu.value = false
|
||||
})
|
||||
} else {
|
||||
ItemAction(stringResource(MR.strings.unblock_member_button), painterResource(MR.images.ic_do_not_touch), onClick = {
|
||||
unblockMemberAlert(rhId, groupInfo, member)
|
||||
showMenu.value = false
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
// revert to this: vvv
|
||||
// if (groupInfo.membership.memberRole >= GroupMemberRole.Admin) {
|
||||
// val canBlockForAll = member.canBlockForAll(groupInfo)
|
||||
// val canRemove = member.canBeRemoved(groupInfo)
|
||||
// if (canBlockForAll || canRemove) {
|
||||
// DefaultDropdownMenu(showMenu) {
|
||||
// if (canBlockForAll) {
|
||||
// if (member.blockedByAdmin) {
|
||||
// ItemAction(stringResource(MR.strings.unblock_for_all), painterResource(MR.images.ic_do_not_touch), onClick = {
|
||||
// unblockForAllAlert(rhId, groupInfo, member)
|
||||
// showMenu.value = false
|
||||
// })
|
||||
// } else {
|
||||
// ItemAction(stringResource(MR.strings.block_for_all), painterResource(MR.images.ic_back_hand), color = MaterialTheme.colors.error, onClick = {
|
||||
// blockForAllAlert(rhId, groupInfo, member)
|
||||
// showMenu.value = false
|
||||
// })
|
||||
// }
|
||||
// }
|
||||
// if (canRemove) {
|
||||
// ItemAction(stringResource(MR.strings.remove_member_button), painterResource(MR.images.ic_delete), color = MaterialTheme.colors.error, onClick = {
|
||||
// removeMemberAlert(rhId, groupInfo, member)
|
||||
// showMenu.value = false
|
||||
// })
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// } else if (!member.blockedByAdmin) {
|
||||
// DefaultDropdownMenu(showMenu) {
|
||||
// if (member.memberSettings.showMessages) {
|
||||
// ItemAction(stringResource(MR.strings.block_member_button), painterResource(MR.images.ic_back_hand), color = MaterialTheme.colors.error, onClick = {
|
||||
// blockMemberAlert(rhId, groupInfo, member)
|
||||
// showMenu.value = false
|
||||
// })
|
||||
// } else {
|
||||
// ItemAction(stringResource(MR.strings.unblock_member_button), painterResource(MR.images.ic_do_not_touch), onClick = {
|
||||
// unblockMemberAlert(rhId, groupInfo, member)
|
||||
// showMenu.value = false
|
||||
// })
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// ^^^
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
+4
-18
@@ -387,25 +387,11 @@ fun GroupMemberInfoLayout(
|
||||
}
|
||||
}
|
||||
|
||||
// revert from this:
|
||||
SectionDividerSpaced(maxBottomPadding = false)
|
||||
SectionView {
|
||||
if (member.memberSettings.showMessages) {
|
||||
BlockMemberButton(blockMember)
|
||||
} else {
|
||||
UnblockMemberButton(unblockMember)
|
||||
}
|
||||
if (member.canBeRemoved(groupInfo)) {
|
||||
RemoveMemberButton(removeMember)
|
||||
}
|
||||
if (groupInfo.membership.memberRole >= GroupMemberRole.Admin) {
|
||||
AdminDestructiveSection()
|
||||
} else {
|
||||
NonAdminBlockSection()
|
||||
}
|
||||
// revert to this: vvv
|
||||
// if (groupInfo.membership.memberRole >= GroupMemberRole.Admin) {
|
||||
// AdminDestructiveSection()
|
||||
// } else {
|
||||
// NonAdminBlockSection()
|
||||
// }
|
||||
// ^^^
|
||||
|
||||
if (developerTools) {
|
||||
SectionDividerSpaced()
|
||||
|
||||
+1
-1
@@ -94,7 +94,7 @@ fun CIFileView(
|
||||
FileProtocol.LOCAL -> {}
|
||||
}
|
||||
file.fileStatus is CIFileStatus.RcvComplete || (file.fileStatus is CIFileStatus.SndStored && file.fileProtocol == FileProtocol.LOCAL) -> {
|
||||
withLongRunningApi(slow = 60_000, deadlock = 600_000) {
|
||||
withLongRunningApi(slow = 600_000) {
|
||||
var filePath = getLoadedFilePath(file)
|
||||
if (chatModel.connectedToRemote() && filePath == null) {
|
||||
file.loadRemoteFile(true)
|
||||
|
||||
+1
-1
@@ -41,7 +41,7 @@ fun CIVideoView(
|
||||
val filePath = remember(file, CIFile.cachedRemoteFileRequests.toList()) { mutableStateOf(getLoadedFilePath(file)) }
|
||||
if (chatModel.connectedToRemote()) {
|
||||
LaunchedEffect(file) {
|
||||
withLongRunningApi(slow = 60_000, deadlock = 600_000) {
|
||||
withLongRunningApi(slow = 600_000) {
|
||||
if (file != null && file.loaded && getLoadedFilePath(file) == null) {
|
||||
file.loadRemoteFile(false)
|
||||
filePath.value = getLoadedFilePath(file)
|
||||
|
||||
+1
-1
@@ -213,7 +213,7 @@ fun ChatItemView(
|
||||
showMenu.value = false
|
||||
}
|
||||
if (chatModel.connectedToRemote() && fileSource == null) {
|
||||
withLongRunningApi(slow = 60_000, deadlock = 600_000) {
|
||||
withLongRunningApi(slow = 600_000) {
|
||||
cItem.file?.loadRemoteFile(true)
|
||||
fileSource = getLoadedFileSource(cItem.file)
|
||||
shareIfExists()
|
||||
|
||||
+1
-1
@@ -143,7 +143,7 @@ private fun ShareList(chatModel: ChatModel, search: String) {
|
||||
}
|
||||
val chats by remember(search) {
|
||||
derivedStateOf {
|
||||
if (search.isEmpty()) chatModel.chats.filter { it.chatInfo.ready } else chatModel.chats.filter { it.chatInfo.ready }.filter(filter)
|
||||
if (search.isEmpty()) chatModel.chats.toList().filter { it.chatInfo.ready } else chatModel.chats.toList().filter { it.chatInfo.ready }.filter(filter)
|
||||
}
|
||||
}
|
||||
LazyColumn(
|
||||
|
||||
+13
-1
@@ -12,6 +12,7 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.*
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
@@ -22,6 +23,7 @@ import chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.res.MR
|
||||
import dev.icerock.moko.resources.StringResource
|
||||
import dev.icerock.moko.resources.compose.painterResource
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
||||
@@ -189,6 +191,7 @@ class AlertManager {
|
||||
title: String, text: String? = null,
|
||||
confirmText: String = generalGetString(MR.strings.ok),
|
||||
hostDevice: Pair<Long?, String>? = null,
|
||||
shareText: Boolean? = null
|
||||
) {
|
||||
showAlert {
|
||||
AlertDialog(
|
||||
@@ -202,10 +205,19 @@ class AlertManager {
|
||||
delay(200)
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
// Can pass shareText = false to prevent showing Share button if it's needed in a specific case
|
||||
val showShareButton = text != null && (shareText == true || (shareText == null && text.length > 500))
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(horizontal = DEFAULT_PADDING),
|
||||
horizontalArrangement = Arrangement.Center
|
||||
horizontalArrangement = if (showShareButton) Arrangement.SpaceBetween else Arrangement.Center
|
||||
) {
|
||||
val clipboard = LocalClipboardManager.current
|
||||
if (showShareButton && text != null) {
|
||||
TextButton(onClick = {
|
||||
clipboard.shareText(text)
|
||||
hideAlert()
|
||||
}) { Text(stringResource(MR.strings.share_verb)) }
|
||||
}
|
||||
TextButton(
|
||||
onClick = {
|
||||
hideAlert()
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@ class ProcessedErrors <T: AgentErrorType>(val interval: Long) {
|
||||
|
||||
fun newError(error: T, offerRestart: Boolean) {
|
||||
timer.cancel()
|
||||
timer = withLongRunningApi(slow = 70_000, deadlock = 130_000) {
|
||||
timer = withLongRunningApi(slow = 130_000) {
|
||||
val delayBeforeNext = (lastShownTimestamp + interval) - System.currentTimeMillis()
|
||||
if ((lastShownOfferRestart || !offerRestart) && delayBeforeNext >= 0) {
|
||||
delay(delayBeforeNext)
|
||||
|
||||
+9
-17
@@ -37,30 +37,22 @@ fun withBGApi(action: suspend CoroutineScope.() -> Unit): Job =
|
||||
CoroutineScope(singleThreadDispatcher).launch(block = { wrapWithLogging(action, it) })
|
||||
}
|
||||
|
||||
fun withLongRunningApi(slow: Long = Long.MAX_VALUE, deadlock: Long = Long.MAX_VALUE, action: suspend CoroutineScope.() -> Unit): Job =
|
||||
fun withLongRunningApi(slow: Long = Long.MAX_VALUE, action: suspend CoroutineScope.() -> Unit): Job =
|
||||
Exception().let {
|
||||
CoroutineScope(Dispatchers.Default).launch(block = { wrapWithLogging(action, it, slow = slow, deadlock = deadlock) })
|
||||
CoroutineScope(Dispatchers.Default).launch(block = { wrapWithLogging(action, it, slow = slow) })
|
||||
}
|
||||
|
||||
private suspend fun wrapWithLogging(action: suspend CoroutineScope.() -> Unit, exception: java.lang.Exception, slow: Long = 10_000, deadlock: Long = 60_000) = coroutineScope {
|
||||
private suspend fun wrapWithLogging(action: suspend CoroutineScope.() -> Unit, exception: java.lang.Exception, slow: Long = 20_000) = coroutineScope {
|
||||
val start = System.currentTimeMillis()
|
||||
val job = launch {
|
||||
delay(deadlock)
|
||||
Log.e(TAG, "Possible deadlock of the thread, not finished after ${deadlock / 1000}s:\n${exception.stackTraceToString()}")
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(MR.strings.possible_deadlock_title),
|
||||
text = generalGetString(MR.strings.possible_deadlock_desc).format(deadlock / 1000, exception.stackTraceToString()),
|
||||
)
|
||||
}
|
||||
action()
|
||||
job.cancel()
|
||||
if (appPreferences.developerTools.get() && appPreferences.showSlowApiCalls.get()) {
|
||||
val end = System.currentTimeMillis()
|
||||
if (end - start > slow) {
|
||||
Log.e(TAG, "Possible problem with execution of the thread, took ${(end - start) / 1000}s:\n${exception.stackTraceToString()}")
|
||||
val end = System.currentTimeMillis()
|
||||
if (end - start > slow) {
|
||||
Log.e(TAG, "Possible problem with execution of the thread, took ${(end - start) / 1000}s:\n${exception.stackTraceToString()}")
|
||||
if (appPreferences.developerTools.get() && appPreferences.showSlowApiCalls.get()) {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(MR.strings.possible_slow_function_title),
|
||||
text = generalGetString(MR.strings.possible_slow_function_desc).format((end - start) / 1000, exception.stackTraceToString()),
|
||||
shareText = true
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -419,7 +411,7 @@ expect fun ByteArray.toBase64StringForPassphrase(): String
|
||||
|
||||
// Android's default implementation that was used before multiplatform, adds non-needed characters at the end of string
|
||||
// which can be bypassed by:
|
||||
// fun String.toByteArrayFromBase64(): ByteArray = Base64.getDecoder().decode(this.trimEnd { it == '\n' || it == ' ' })
|
||||
// fun String.toByteArrayFromBase64(): ByteArray = Base64.getMimeDecoder().decode(this.trimEnd { it == '\n' || it == ' ' })
|
||||
expect fun String.toByteArrayFromBase64ForPassphrase(): ByteArray
|
||||
|
||||
val LongRange.Companion.saver
|
||||
|
||||
+2
-2
@@ -96,7 +96,7 @@ fun PrivacySettingsView(
|
||||
val currentUser = chatModel.currentUser.value
|
||||
if (currentUser != null) {
|
||||
fun setSendReceiptsContacts(enable: Boolean, clearOverrides: Boolean) {
|
||||
withLongRunningApi(slow = 30_000, deadlock = 60_000) {
|
||||
withLongRunningApi(slow = 60_000) {
|
||||
val mrs = UserMsgReceiptSettings(enable, clearOverrides)
|
||||
chatModel.controller.apiSetUserContactReceipts(currentUser, mrs)
|
||||
chatModel.controller.appPrefs.privacyDeliveryReceiptsSet.set(true)
|
||||
@@ -119,7 +119,7 @@ fun PrivacySettingsView(
|
||||
}
|
||||
|
||||
fun setSendReceiptsGroups(enable: Boolean, clearOverrides: Boolean) {
|
||||
withLongRunningApi(slow = 30_000, deadlock = 60_000) {
|
||||
withLongRunningApi(slow = 60_000) {
|
||||
val mrs = UserMsgReceiptSettings(enable, clearOverrides)
|
||||
chatModel.controller.apiSetUserGroupReceipts(currentUser, mrs)
|
||||
chatModel.controller.appPrefs.privacyDeliveryReceiptsSet.set(true)
|
||||
|
||||
@@ -1588,8 +1588,6 @@
|
||||
<string name="remote_ctrl_error_busy">سطح المكتب مشغول</string>
|
||||
<string name="remote_ctrl_error_bad_version">يحتوي سطح المكتب على إصدار غير مدعوم. يُرجى التأكد من استخدام نفس الإصدار على كلا الجهازين</string>
|
||||
<string name="past_member_vName">العضو السابق %1$s</string>
|
||||
<string name="possible_deadlock_title">مأزق</string>
|
||||
<string name="possible_deadlock_desc">يستغرق تنفيذ التعليمات البرمجية وقتًا طويلاً جدًا: %1$d ثانية. من المحتمل أن التطبيق مجمّد: %2$s</string>
|
||||
<string name="possible_slow_function_title">وظيفة بطيئة</string>
|
||||
<string name="developer_options_section">خيارات المطور</string>
|
||||
<string name="profile_update_event_member_name_changed">تغيّر العضو %1$s إلى %2$s</string>
|
||||
|
||||
@@ -147,8 +147,6 @@
|
||||
<string name="smp_server_test_delete_file">Delete file</string>
|
||||
<string name="error_deleting_user">Error deleting user profile</string>
|
||||
<string name="error_updating_user_privacy">Error updating user privacy</string>
|
||||
<string name="possible_deadlock_title">Deadlock</string>
|
||||
<string name="possible_deadlock_desc">Execution of code takes too long time: %1$d seconds. Probably, the app is frozen: %2$s</string>
|
||||
<string name="possible_slow_function_title">Slow function</string>
|
||||
<string name="possible_slow_function_desc">Execution of function takes too long time: %1$d seconds: %2$s</string>
|
||||
|
||||
@@ -806,6 +804,10 @@
|
||||
<string name="callstate_connected">connected</string>
|
||||
<string name="callstate_ended">ended</string>
|
||||
|
||||
<!-- CallView -->
|
||||
<string name="unable_to_open_browser_title">Error opening browser</string>
|
||||
<string name="unable_to_open_browser_desc">The default web browser is required for calls. Please configure the default browser in the system, and share more information with the developers.</string>
|
||||
|
||||
<!-- SimpleXInfo -->
|
||||
<string name="next_generation_of_private_messaging">The next generation of private messaging</string>
|
||||
<string name="privacy_redefined">Privacy redefined</string>
|
||||
|
||||
@@ -1555,7 +1555,6 @@
|
||||
<string name="chat_is_stopped_you_should_transfer_database">Чатът е спрян. Ако вече сте използвали тази база данни на друго устройство, трябва да я прехвърлите обратно, преди да стартирате чата отново.</string>
|
||||
<string name="remote_ctrl_error_bad_invitation">Настолното устройство има грешен код за връзка</string>
|
||||
<string name="remote_ctrl_error_bad_version">Настолното устройство е с неподдържана версия. Моля, уверете се, че използвате една и съща версия и на двете устройства</string>
|
||||
<string name="possible_deadlock_desc">Изпълнението на кода отнема твърде много време: %1$d секунди. Вероятно приложението е замразено: %2$s</string>
|
||||
<string name="possible_slow_function_title">Бавна функция</string>
|
||||
<string name="possible_slow_function_desc">Изпълнението на функцията отнема твърде много време: %1$d секунди: %2$s</string>
|
||||
<string name="show_internal_errors">Покажи вътрешните грешки</string>
|
||||
@@ -1591,5 +1590,4 @@
|
||||
\nПрепоръчително е да рестартирате приложението.</string>
|
||||
<string name="developer_options_section">Опции за разработчици</string>
|
||||
<string name="show_slow_api_calls">Показване на бавни API заявки</string>
|
||||
<string name="possible_deadlock_title">Грешка в заключено положение</string>
|
||||
</resources>
|
||||
@@ -1672,9 +1672,7 @@
|
||||
<string name="possible_slow_function_title">Langsame Funktion</string>
|
||||
<string name="show_slow_api_calls">Zeige langsame API-Aufrufe an</string>
|
||||
<string name="group_member_status_unknown_short">unbekannt</string>
|
||||
<string name="possible_deadlock_title">Blockade</string>
|
||||
<string name="developer_options_section">Optionen für Entwickler</string>
|
||||
<string name="possible_deadlock_desc">Die Code-Ausführung dauert zu lange: %1$d Sekunden. Wahrscheinlich ist die App eingefroren: %2$s</string>
|
||||
<string name="group_member_status_unknown">unbekannter Gruppenmitglieds-Status</string>
|
||||
<string name="v5_5_private_notes_descr">Mit verschlüsselten Dateien und Medien.</string>
|
||||
<string name="v5_5_private_notes">Private Notizen</string>
|
||||
|
||||
@@ -1559,11 +1559,9 @@
|
||||
<string name="remote_host_error_bad_state"><![CDATA[État médiocre de la connexion au mobile <b>%s</b>.]]></string>
|
||||
<string name="remote_ctrl_was_disconnected_title">Connexion interrompue</string>
|
||||
<string name="remote_ctrl_error_bad_state">État médiocre de la connexion avec le bureau</string>
|
||||
<string name="possible_deadlock_title">Impasse</string>
|
||||
<string name="remote_ctrl_error_bad_version">La version de l\'ordinateur de bureau n\'est pas prise en charge. Veillez à utiliser la même version sur les deux appareils.</string>
|
||||
<string name="remote_ctrl_error_disconnected">Le bureau a été déconnecté</string>
|
||||
<string name="developer_options_section">Options pour les développeurs</string>
|
||||
<string name="possible_deadlock_desc">Le code prend trop de temps à s\'exécuter : %1$d secondes. Il est probable que l\'application soit figée : %2$s</string>
|
||||
<string name="agent_internal_error_title">Erreur interne</string>
|
||||
<string name="remote_host_error_bad_version"><![CDATA[La version du mobile <b>%s</b> n\'est pas prise en charge. Veillez à utiliser la même version sur les deux appareils.]]></string>
|
||||
<string name="show_internal_errors">Afficher les erreurs internes</string>
|
||||
|
||||
@@ -1583,9 +1583,7 @@
|
||||
<string name="possible_slow_function_title">Lassú funkció</string>
|
||||
<string name="show_slow_api_calls">Lassú API-hívások megjelenítése</string>
|
||||
<string name="remote_host_error_inactive"><![CDATA[A(z) <b>%s</b> mobil eszköz inaktív]]></string>
|
||||
<string name="possible_deadlock_title">Elakadt</string>
|
||||
<string name="developer_options_section">Fejlesztői beállítások</string>
|
||||
<string name="possible_deadlock_desc">A kód végrehajtása túl sokáig tart: %1$d másodperc. Valószínűleg az alkalmazás lefagyott: %2$s</string>
|
||||
<string name="possible_slow_function_desc">A funkció végrehajtása túl sokáig tart: %1$d másodperc: %2$s</string>
|
||||
<string name="remote_host_error_busy"><![CDATA[A(z) <b>%s</b> mobil eszköz elfoglalt]]></string>
|
||||
<string name="past_member_vName">Legutóbbi tag %1$s</string>
|
||||
|
||||
@@ -1591,9 +1591,7 @@
|
||||
<string name="possible_slow_function_title">Funzione lenta</string>
|
||||
<string name="show_slow_api_calls">Mostra chiamate API lente</string>
|
||||
<string name="group_member_status_unknown_short">sconosciuto</string>
|
||||
<string name="possible_deadlock_desc">L\'esecuzione del codice impiega troppo tempo: %1$d secondi. Probabilmente l\'app è congelata: %2$s</string>
|
||||
<string name="group_member_status_unknown">stato sconosciuto</string>
|
||||
<string name="possible_deadlock_title">Stallo</string>
|
||||
<string name="developer_options_section">Opzioni sviluppatore</string>
|
||||
<string name="v5_5_private_notes">Note private</string>
|
||||
<string name="v5_5_new_interface_languages">Interfaccia in ungherese e turco</string>
|
||||
|
||||
@@ -1571,9 +1571,7 @@
|
||||
<string name="remote_ctrl_error_busy">PC版が処理中</string>
|
||||
<string name="remote_ctrl_error_disconnected">PC版が切断されました</string>
|
||||
<string name="remote_ctrl_error_bad_version">ご利用のPC版のバージョンがサポートされてません。両端末が同じバージョンかどうか、ご確認ください。</string>
|
||||
<string name="possible_deadlock_title">デッドロック状態</string>
|
||||
<string name="developer_options_section">開発者向けの設定</string>
|
||||
<string name="possible_deadlock_desc">処理時間が異常にかかるようです: %1$d 秒。アプリが固まった恐れがあります: %2$s</string>
|
||||
<string name="remote_host_error_busy"><![CDATA[携帯版 <b>%s</b> がただいま処理中]]></string>
|
||||
<string name="possible_slow_function_desc">機能の処理時間が以上にかかってます: %1$d 秒: %2$s</string>
|
||||
<string name="show_internal_errors">内部エラーを表示</string>
|
||||
|
||||
@@ -1574,7 +1574,6 @@
|
||||
<string name="remote_host_error_missing"><![CDATA[Mobiel <b>%s</b> ontbreekt]]></string>
|
||||
<string name="remote_host_error_bad_state"><![CDATA[De verbinding met de mobiel <b>%s</b> is in slechte staat]]></string>
|
||||
<string name="remote_ctrl_error_disconnected">De verbinding met desktop is verbroken</string>
|
||||
<string name="possible_deadlock_title">Impasse</string>
|
||||
<string name="possible_slow_function_desc">Uitvoering van functie duurt te lang: %1$d seconden: %2$s</string>
|
||||
<string name="possible_slow_function_title">Langzame functie</string>
|
||||
<string name="developer_options_section">Ontwikkelaars opties</string>
|
||||
@@ -1588,7 +1587,6 @@
|
||||
<string name="restart_chat_button">Chat opnieuw starten</string>
|
||||
<string name="remote_host_error_timeout"><![CDATA[Time-out bereikt tijdens het verbinden met de mobiel <b>%s</b>]]></string>
|
||||
<string name="remote_ctrl_error_bad_state">De verbinding met de desktop is in slechte staat</string>
|
||||
<string name="possible_deadlock_desc">Het uitvoeren van de code duurt te lang: %1$d seconden. Waarschijnlijk is de app vastgelopen: %2$s</string>
|
||||
<string name="remote_ctrl_error_bad_invitation">Desktop heeft verkeerde uitnodigingscode</string>
|
||||
<string name="remote_host_error_bad_version"><![CDATA[Mobiel <b>%s</b> heeft een niet-ondersteunde versie. Zorg ervoor dat u op beide apparaten dezelfde versie gebruikt]]></string>
|
||||
<string name="remote_ctrl_error_timeout">Time-out bereikt tijdens het verbinden met de desktop</string>
|
||||
|
||||
@@ -1606,7 +1606,6 @@
|
||||
<string name="remote_ctrl_error_bad_version">Komputer ma niewspieraną wersję. Proszę upewnić się, że używasz tych samych wersji na obu urządzeniach</string>
|
||||
<string name="blocked_by_admin_items_description">%d wiadomości zablokowanych przez admina</string>
|
||||
<string name="error_creating_message">Błąd tworzenia wiadomości</string>
|
||||
<string name="possible_deadlock_desc">Wykonanie kodu zajmuje za dużo czasu: %1$d sekund. Prawdopodobnie aplikacja jest zamrożona: %2$s</string>
|
||||
<string name="possible_slow_function_desc">Wykonanie kodu zajmuje za dużo czasu: %1$d sekund: %2$s</string>
|
||||
<string name="note_folder_local_display_name">Prywatne notatki</string>
|
||||
<string name="group_member_status_unknown">nieznany status</string>
|
||||
@@ -1621,7 +1620,6 @@
|
||||
<string name="remote_host_error_inactive"><![CDATA[Telefon <b>%s</b> jest nieaktywny]]></string>
|
||||
<string name="remote_host_error_bad_version"><![CDATA[Telefon <b>%s</b> ma niewspieraną wersję. Proszę, upewnij się, że używasz tej samej wersji na obydwu urządzeniach]]></string>
|
||||
<string name="group_member_status_unknown_short">nieznany</string>
|
||||
<string name="possible_deadlock_title">Blokada</string>
|
||||
<string name="profile_update_event_contact_name_changed">kontakt %1$s zmieniony na %2$s</string>
|
||||
<string name="profile_update_event_removed_address">usunięto adres kontaktu</string>
|
||||
<string name="profile_update_event_removed_picture">usunięto zdjęcie profilu</string>
|
||||
|
||||
@@ -1680,8 +1680,6 @@
|
||||
<string name="error_showing_message">ошибка отображения сообщения</string>
|
||||
<string name="error_showing_content">ошибка отображения содержания</string>
|
||||
<string name="remote_ctrl_disconnected_with_reason">Отсоединён по причине: %s</string>
|
||||
<string name="possible_deadlock_title">Взаимная блокировка</string>
|
||||
<string name="possible_deadlock_desc">Выполнение задачи занимает долгое время: %1$d секунд. Возможно, приложение заблокировано: %2$s</string>
|
||||
<string name="possible_slow_function_desc">Выполнение задачи занимает долгое время: %1$d секунд: %2$s</string>
|
||||
<string name="possible_slow_function_title">Медленный вызов</string>
|
||||
<string name="profile_update_event_contact_name_changed">контакт %1$s изменён на %2$s</string>
|
||||
|
||||
@@ -1586,8 +1586,6 @@
|
||||
<string name="remote_host_error_bad_state"><![CDATA[到移动主机 <b>%s</b>的连接状态不佳]]></string>
|
||||
<string name="remote_host_error_timeout"><![CDATA[连接到移动主机<b>%s</b>时超时]]></string>
|
||||
<string name="failed_to_create_user_invalid_desc">显示名无效。请另选一个名称。</string>
|
||||
<string name="possible_deadlock_title">死锁</string>
|
||||
<string name="possible_deadlock_desc">代码执行花费的时间过久:%1$d秒。应用可能卡住了:%2$s</string>
|
||||
<string name="possible_slow_function_title">慢函数</string>
|
||||
<string name="show_slow_api_calls">显示缓慢的 API 调用</string>
|
||||
<string name="past_member_vName">过往成员 %1$s</string>
|
||||
|
||||
@@ -39,7 +39,8 @@ fun showApp() {
|
||||
WindowExceptionHandler { e ->
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(MR.strings.app_was_crashed),
|
||||
text = e.stackTraceToString()
|
||||
text = e.stackTraceToString(),
|
||||
shareText = true
|
||||
)
|
||||
Log.e(TAG, "App crashed, thread name: " + Thread.currentThread().name + ", exception: " + e.stackTraceToString())
|
||||
window.dispatchEvent(WindowEvent(window, WindowEvent.WINDOW_CLOSING))
|
||||
|
||||
+3
-3
@@ -17,14 +17,14 @@ import javax.imageio.stream.MemoryCacheImageOutputStream
|
||||
import kotlin.math.sqrt
|
||||
|
||||
private fun errorBitmap(): ImageBitmap =
|
||||
ImageIO.read(ByteArrayInputStream(Base64.getDecoder().decode("iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAAXNSR0IArs4c6QAAAKVJREFUeF7t1kENACEUQ0FQhnVQ9lfGO+xggITQdvbMzArPey+8fa3tAfwAEdABZQspQStgBssEcgAIkSAJkiAJljtEgiRIgmUCSZAESZAESZAEyx0iQRIkwTKBJEiCv5fgvTd1wDmn7QAP4AeIgA4oW0gJWgEzWCZwbQ7gAA7ggLKFOIADOKBMIAeAEAmSIAmSYLlDJEiCJFgmkARJkARJ8N8S/ADTZUewBvnTOQAAAABJRU5ErkJggg=="))).toComposeImageBitmap()
|
||||
ImageIO.read(ByteArrayInputStream(Base64.getMimeDecoder().decode("iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAAXNSR0IArs4c6QAAAKVJREFUeF7t1kENACEUQ0FQhnVQ9lfGO+xggITQdvbMzArPey+8fa3tAfwAEdABZQspQStgBssEcgAIkSAJkiAJljtEgiRIgmUCSZAESZAESZAEyx0iQRIkwTKBJEiCv5fgvTd1wDmn7QAP4AeIgA4oW0gJWgEzWCZwbQ7gAA7ggLKFOIADOKBMIAeAEAmSIAmSYLlDJEiCJFgmkARJkARJ8N8S/ADTZUewBvnTOQAAAABJRU5ErkJggg=="))).toComposeImageBitmap()
|
||||
|
||||
actual fun base64ToBitmap(base64ImageString: String): ImageBitmap {
|
||||
val imageString = base64ImageString
|
||||
.removePrefix("data:image/png;base64,")
|
||||
.removePrefix("data:image/jpg;base64,")
|
||||
return try {
|
||||
ImageIO.read(ByteArrayInputStream(Base64.getDecoder().decode(imageString))).toComposeImageBitmap()
|
||||
ImageIO.read(ByteArrayInputStream(Base64.getMimeDecoder().decode(imageString))).toComposeImageBitmap()
|
||||
} catch (e: IOException) {
|
||||
Log.e(TAG, "base64ToBitmap error: $e")
|
||||
errorBitmap()
|
||||
@@ -77,7 +77,7 @@ actual fun compressImageStr(bitmap: ImageBitmap): String {
|
||||
return try {
|
||||
val encoded = Base64.getEncoder().encodeToString(compressImageData(bitmap, usePng).toByteArray())
|
||||
"data:image/$ext;base64,$encoded"
|
||||
} catch (e: IOException) {
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "resizeImageToStrSize error: $e")
|
||||
throw e
|
||||
}
|
||||
|
||||
+14
-1
@@ -146,8 +146,21 @@ private fun SendStateUpdates() {
|
||||
@Composable
|
||||
fun WebRTCController(callCommand: SnapshotStateList<WCallCommand>, onResponse: (WVAPIMessage) -> Unit) {
|
||||
val uriHandler = LocalUriHandler.current
|
||||
val endCall = {
|
||||
val call = chatModel.activeCall.value
|
||||
if (call != null) withBGApi { chatModel.callManager.endCall(call) }
|
||||
}
|
||||
val server = remember {
|
||||
uriHandler.openUri("http://${SERVER_HOST}:$SERVER_PORT/simplex/call/")
|
||||
try {
|
||||
uriHandler.openUri("http://${SERVER_HOST}:$SERVER_PORT/simplex/call/")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Unable to open browser: ${e.stackTraceToString()}")
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(MR.strings.unable_to_open_browser_title),
|
||||
text = generalGetString(MR.strings.unable_to_open_browser_desc)
|
||||
)
|
||||
endCall()
|
||||
}
|
||||
startServer(onResponse)
|
||||
}
|
||||
fun processCommand(cmd: WCallCommand) {
|
||||
|
||||
+2
-2
@@ -42,7 +42,7 @@ actual fun SaveContentItemAction(cItem: ChatItem, saveFileLauncher: FileChooserL
|
||||
}
|
||||
var fileSource = getLoadedFileSource(cItem.file)
|
||||
if (chatModel.connectedToRemote() && fileSource == null) {
|
||||
withLongRunningApi(slow = 60_000, deadlock = 600_000) {
|
||||
withLongRunningApi(slow = 600_000) {
|
||||
cItem.file?.loadRemoteFile(true)
|
||||
fileSource = getLoadedFileSource(cItem.file)
|
||||
saveIfExists()
|
||||
@@ -51,7 +51,7 @@ actual fun SaveContentItemAction(cItem: ChatItem, saveFileLauncher: FileChooserL
|
||||
})
|
||||
}
|
||||
|
||||
actual fun copyItemToClipboard(cItem: ChatItem, clipboard: ClipboardManager) = withLongRunningApi(slow = 60_000, deadlock = 600_000) {
|
||||
actual fun copyItemToClipboard(cItem: ChatItem, clipboard: ClipboardManager) = withLongRunningApi(slow = 600_000) {
|
||||
var fileSource = getLoadedFileSource(cItem.file)
|
||||
if (chatModel.connectedToRemote() && fileSource == null) {
|
||||
cItem.file?.loadRemoteFile(true)
|
||||
|
||||
@@ -25,11 +25,11 @@ android.nonTransitiveRClass=true
|
||||
android.enableJetifier=true
|
||||
kotlin.mpp.androidSourceSetLayoutVersion=2
|
||||
|
||||
android.version_name=5.5.2
|
||||
android.version_code=179
|
||||
android.version_name=5.5.5
|
||||
android.version_code=185
|
||||
|
||||
desktop.version_name=5.5.2
|
||||
desktop.version_code=28
|
||||
desktop.version_name=5.5.5
|
||||
desktop.version_code=31
|
||||
|
||||
kotlin.version=1.8.20
|
||||
gradle.plugin.version=7.4.2
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@ constraints: zip +disable-bzip2 +disable-zstd
|
||||
source-repository-package
|
||||
type: git
|
||||
location: https://github.com/simplex-chat/simplexmq.git
|
||||
tag: a516c2f72c81bb4a433c4065b1b5aa484b8292b1
|
||||
tag: 246a0d10c22ebe02af2eb34773b77cce10247459
|
||||
|
||||
source-repository-package
|
||||
type: git
|
||||
|
||||
+10
-10
@@ -1,13 +1,13 @@
|
||||
---
|
||||
title: Download SimpleX apps
|
||||
permalink: /downloads/index.html
|
||||
revision: 25.11.2023
|
||||
revision: 11.02.2024
|
||||
---
|
||||
|
||||
| Updated 25.11.2023 | Languages: EN |
|
||||
| Updated 11.02.2024 | Languages: EN |
|
||||
# Download SimpleX apps
|
||||
|
||||
The latest stable version is v5.5.
|
||||
The latest stable version is v5.5.3.
|
||||
|
||||
You can get the latest beta releases from [GitHub](https://github.com/simplex-chat/simplex-chat/releases).
|
||||
|
||||
@@ -21,24 +21,24 @@ You can get the latest beta releases from [GitHub](https://github.com/simplex-ch
|
||||
|
||||
Using the same profile as on mobile device is not yet supported – you need to create a separate profile to use desktop apps.
|
||||
|
||||
**Linux**: [AppImage](https://github.com/simplex-chat/simplex-chat/releases/download/v5.5.0/simplex-desktop-x86_64.AppImage) (most Linux distros), [Ubuntu 20.04](https://github.com/simplex-chat/simplex-chat/releases/download/v5.5.0/simplex-desktop-ubuntu-20_04-x86_64.deb) (and Debian-based distros), [Ubuntu 22.04](https://github.com/simplex-chat/simplex-chat/releases/download/v5.5.0/simplex-desktop-ubuntu-22_04-x86_64.deb).
|
||||
**Linux**: [AppImage](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex-desktop-x86_64.AppImage) (most Linux distros), [Ubuntu 20.04](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex-desktop-ubuntu-20_04-x86_64.deb) (and Debian-based distros), [Ubuntu 22.04](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex-desktop-ubuntu-22_04-x86_64.deb).
|
||||
|
||||
**Mac**: [x86_64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.5.0/simplex-desktop-macos-x86_64.dmg) (Intel), [aarch64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.5.0/simplex-desktop-macos-aarch64.dmg) (Apple Silicon).
|
||||
**Mac**: [aarch64](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex-desktop-macos-aarch64.dmg) (Apple Silicon), [x86_64](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex-desktop-macos-x86_64.dmg) (Intel).
|
||||
|
||||
**Windows**: [x86_64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.5.0/simplex-desktop-windows-x86_64.msi).
|
||||
**Windows**: [x86_64](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex-desktop-windows-x86_64.msi).
|
||||
|
||||
## Mobile apps
|
||||
|
||||
**iOS**: [App store](https://apps.apple.com/us/app/simplex-chat/id1605771084), [TestFlight](https://testflight.apple.com/join/DWuT2LQu).
|
||||
|
||||
**Android**: [Play store](https://play.google.com/store/apps/details?id=chat.simplex.app), [F-Droid](https://simplex.chat/fdroid/), [APK aarch64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.5.0/simplex.apk), [APK armv7](https://github.com/simplex-chat/simplex-chat/releases/download/v5.5.0/simplex-armv7a.apk).
|
||||
**Android**: [Play store](https://play.google.com/store/apps/details?id=chat.simplex.app), [F-Droid](https://simplex.chat/fdroid/), [APK aarch64](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex.apk), [APK armv7](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex-armv7a.apk).
|
||||
|
||||
## Terminal (console) app
|
||||
|
||||
See [Using terminal app](/docs/CLI.md).
|
||||
|
||||
**Linux**: [Ubuntu 20.04](https://github.com/simplex-chat/simplex-chat/releases/download/v5.5.0/simplex-chat-ubuntu-20_04-x86-64), [Ubuntu 22.04](https://github.com/simplex-chat/simplex-chat/releases/download/v5.5.0/simplex-chat-ubuntu-22_04-x86-64).
|
||||
**Linux**: [Ubuntu 20.04](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex-chat-ubuntu-20_04-x86-64), [Ubuntu 22.04](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex-chat-ubuntu-22_04-x86-64).
|
||||
|
||||
**Mac** [x86_64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.5.0/simplex-chat-macos-x86-64), aarch64 - [compile from source](/docs/CLI.md#).
|
||||
**Mac** [x86_64](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex-chat-macos-x86-64), aarch64 - [compile from source](/docs/CLI.md#).
|
||||
|
||||
**Windows**: [x86_64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.5.0/simplex-chat-windows-x86-64).
|
||||
**Windows**: [x86_64](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex-chat-windows-x86-64).
|
||||
|
||||
+59
-2
@@ -24,7 +24,7 @@ _Please note_: when you change the servers in the app configuration, it only aff
|
||||
- Semi-automatic deployment:
|
||||
- [Offical installation script](https://github.com/simplex-chat/simplexmq#using-installation-script)
|
||||
- [Docker container](https://github.com/simplex-chat/simplexmq#using-docker)
|
||||
- [Linode StackScript](https://github.com/simplex-chat/simplexmq#deploy-smp-server-on-linode)
|
||||
- [Linode Marketplace](https://www.linode.com/marketplace/apps/simplex-chat/simplex-chat/)
|
||||
|
||||
Manual installation requires some preliminary actions:
|
||||
|
||||
@@ -33,7 +33,7 @@ Manual installation requires some preliminary actions:
|
||||
- Using offical binaries:
|
||||
|
||||
```sh
|
||||
curl -L https://github.com/simplex-chat/simplexmq/releases/latest/download/smp-server-ubuntu-20_04-x86-64 -o /usr/local/bin/smp-server
|
||||
curl -L https://github.com/simplex-chat/simplexmq/releases/latest/download/smp-server-ubuntu-20_04-x86-64 -o /usr/local/bin/smp-server && chmod +x /usr/local/bin/smp-server
|
||||
```
|
||||
|
||||
- Compiling from source:
|
||||
@@ -417,6 +417,63 @@ To import `csv` to `Grafana` one should:
|
||||
|
||||
For further documentation, see: [CSV Data Source for Grafana - Documentation](https://grafana.github.io/grafana-csv-datasource/)
|
||||
|
||||
# Updating your SMP server
|
||||
|
||||
To update your smp-server to latest version, choose your installation method and follow the steps:
|
||||
|
||||
- Manual deployment
|
||||
1. Stop the server:
|
||||
```sh
|
||||
sudo systemctl stop smp-server
|
||||
```
|
||||
2. Update the binary:
|
||||
```sh
|
||||
curl -L https://github.com/simplex-chat/simplexmq/releases/latest/download/smp-server-ubuntu-20_04-x86-64 -o /usr/local/bin/smp-server && chmod +x /usr/local/bin/smp-server
|
||||
```
|
||||
3. Start the server:
|
||||
```sh
|
||||
sudo systemctl start smp-server
|
||||
```
|
||||
|
||||
- [Offical installation script](https://github.com/simplex-chat/simplexmq#using-installation-script)
|
||||
1. Execute the followin command:
|
||||
```sh
|
||||
sudo simplex-servers-update
|
||||
```
|
||||
2. Done!
|
||||
|
||||
- [Docker container](https://github.com/simplex-chat/simplexmq#using-docker)
|
||||
1. Stop and remove the container:
|
||||
```sh
|
||||
docker rm $(docker stop $(docker ps -a -q --filter ancestor=simplexchat/smp-server --format="{{.ID}}"))
|
||||
```
|
||||
2. Pull latest image:
|
||||
```sh
|
||||
docker pull simplexchat/smp-server:latest
|
||||
```
|
||||
3. Start new container:
|
||||
```sh
|
||||
docker run -d \
|
||||
-p 5223:5223 \
|
||||
-v $HOME/simplex/smp/config:/etc/opt/simplex:z \
|
||||
-v $HOME/simplex/smp/logs:/var/opt/simplex:z \
|
||||
simplexchat/smp-server:latest
|
||||
```
|
||||
|
||||
- [Linode Marketplace](https://www.linode.com/marketplace/apps/simplex-chat/simplex-chat/)
|
||||
1. Pull latest images:
|
||||
```sh
|
||||
docker-compose --project-directory /etc/docker/compose/simplex pull
|
||||
```
|
||||
2. Restart the containers:
|
||||
```sh
|
||||
docker-compose --project-directory /etc/docker/compose/simplex up -d --remove-orphans
|
||||
```
|
||||
3. Remove obsolete images:
|
||||
```sh
|
||||
docker image prune
|
||||
```
|
||||
|
||||
### Configuring the app to use the server
|
||||
|
||||
To configure the app to use your messaging server copy it's full address, including password, and add it to the app. You have an option to use your server together with preset servers or without them - you can remove or disable them.
|
||||
|
||||
+61
-1
@@ -24,6 +24,7 @@ XFTP is a new file transfer protocol focussed on meta-data protection - it is ba
|
||||
- Semi-automatic deployment:
|
||||
- [Offical installation script](https://github.com/simplex-chat/simplexmq#using-installation-script)
|
||||
- [Docker container](https://github.com/simplex-chat/simplexmq#using-docker)
|
||||
- [Linode Marketplace](https://www.linode.com/marketplace/apps/simplex-chat/simplex-chat/)
|
||||
|
||||
Manual installation requires some preliminary actions:
|
||||
|
||||
@@ -32,7 +33,7 @@ Manual installation requires some preliminary actions:
|
||||
- Using offical binaries:
|
||||
|
||||
```sh
|
||||
curl -L https://github.com/simplex-chat/simplexmq/releases/latest/download/xftp-server-ubuntu-20_04-x86-64 -o /usr/local/bin/xftp-server
|
||||
curl -L https://github.com/simplex-chat/simplexmq/releases/latest/download/xftp-server-ubuntu-20_04-x86-64 -o /usr/local/bin/xftp-server && chmod +x /usr/local/bin/xftp-server
|
||||
```
|
||||
|
||||
- Compiling from source:
|
||||
@@ -418,6 +419,65 @@ To import `csv` to `Grafana` one should:
|
||||
|
||||
For further documentation, see: [CSV Data Source for Grafana - Documentation](https://grafana.github.io/grafana-csv-datasource/)
|
||||
|
||||
|
||||
# Updating your XFTP server
|
||||
|
||||
To update your XFTP server to latest version, choose your installation method and follow the steps:
|
||||
|
||||
- Manual deployment
|
||||
1. Stop the server:
|
||||
```sh
|
||||
sudo systemctl stop xftp-server
|
||||
```
|
||||
2. Update the binary:
|
||||
```sh
|
||||
curl -L https://github.com/simplex-chat/simplexmq/releases/latest/download/xftp-server-ubuntu-20_04-x86-64 -o /usr/local/bin/xftp-server && chmod +x /usr/local/bin/xftp-server
|
||||
```
|
||||
3. Start the server:
|
||||
```sh
|
||||
sudo systemctl start xftp-server
|
||||
```
|
||||
|
||||
- [Offical installation script](https://github.com/simplex-chat/simplexmq#using-installation-script)
|
||||
1. Execute the followin command:
|
||||
```sh
|
||||
sudo simplex-servers-update
|
||||
```
|
||||
2. Done!
|
||||
|
||||
- [Docker container](https://github.com/simplex-chat/simplexmq#using-docker)
|
||||
1. Stop and remove the container:
|
||||
```sh
|
||||
docker rm $(docker stop $(docker ps -a -q --filter ancestor=simplexchat/xftp-server --format="{{.ID}}"))
|
||||
```
|
||||
2. Pull latest image:
|
||||
```sh
|
||||
docker pull simplexchat/xftp-server:latest
|
||||
```
|
||||
3. Start new container:
|
||||
```sh
|
||||
docker run -d \
|
||||
-p 443:443 \
|
||||
-v $HOME/simplex/xftp/config:/etc/opt/simplex-xftp:z \
|
||||
-v $HOME/simplex/xftp/logs:/var/opt/simplex-xftp:z \
|
||||
-v $HOME/simplex/xftp/files:/srv/xftp:z \
|
||||
simplexchat/xftp-server:latest
|
||||
```
|
||||
|
||||
- [Linode Marketplace](https://www.linode.com/marketplace/apps/simplex-chat/simplex-chat/)
|
||||
1. Pull latest images:
|
||||
```sh
|
||||
docker-compose --project-directory /etc/docker/compose/simplex pull
|
||||
```
|
||||
2. Restart the containers:
|
||||
```sh
|
||||
docker-compose --project-directory /etc/docker/compose/simplex up -d --remove-orphans
|
||||
```
|
||||
3. Remove obsolete images:
|
||||
```sh
|
||||
docker image prune
|
||||
```
|
||||
|
||||
### Configuring the app to use the server
|
||||
|
||||
Please see: [SMP Server: Configuring the app to use the server](./SERVER.md#configuring-the-app-to-use-the-server).
|
||||
|
||||
@@ -82,7 +82,7 @@ def RatchetInitAlicePQ2HE(state, SK, bob_dh_public_key, shared_hka, shared_nhkb,
|
||||
state.PQRs = GENERATE_PQKEM()
|
||||
state.PQRr = bob_pq_kem_encapsulation_key
|
||||
state.PQRss = random // shared secret for KEM
|
||||
state.PQRenc_ss = PQKEM-ENC(state.PQRr.encaps, state.PQRss) // encapsulated additional shared secret
|
||||
state.PQRct = PQKEM-ENC(state.PQRr, state.PQRss) // encapsulated additional shared secret
|
||||
// above added for KEM
|
||||
// below augments DH key agreement with PQ shared secret
|
||||
state.RK, state.CKs, state.NHKs = KDF_RK_HE(SK, DH(state.DHRs, state.DHRr) || state.PQRss)
|
||||
@@ -103,7 +103,7 @@ def RatchetInitBobPQ2HE(state, SK, bob_dh_key_pair, shared_hka, shared_nhkb, bob
|
||||
state.PQRs = bob_pq_kem_key_pair
|
||||
state.PQRr = None
|
||||
state.PQRss = None
|
||||
state.PQRenc_ss = None
|
||||
state.PQRct = None
|
||||
// above added for KEM
|
||||
state.RK = SK
|
||||
state.CKs = None
|
||||
@@ -132,10 +132,10 @@ def RatchetEncryptPQ2HE(state, plaintext, AD):
|
||||
// encapsulation key from PQRs and encapsulated shared secret is added to header
|
||||
header = HEADER_PQ2(
|
||||
dh = state.DHRs.public,
|
||||
kem = state.PQRs.public, // added for KEM #2
|
||||
ct = state.PQRct // added for KEM #1
|
||||
pn = state.PN,
|
||||
n = state.Ns,
|
||||
encaps = state.PQRs.encaps, // added for KEM #1
|
||||
enc_ss = state.PQRenc_ss // added for KEM #2
|
||||
)
|
||||
enc_header = HENCRYPT(state.HKs, header)
|
||||
state.Ns += 1
|
||||
@@ -162,6 +162,16 @@ def RatchetDecryptPQ2HE(state, enc_header, ciphertext, AD):
|
||||
state.Nr += 1
|
||||
return DECRYPT(mk, ciphertext, CONCAT(AD, enc_header))
|
||||
|
||||
// DecryptHeader is the same as in double ratchet specification
|
||||
def DecryptHeader(state, enc_header):
|
||||
header = HDECRYPT(state.HKr, enc_header)
|
||||
if header != None:
|
||||
return header, False
|
||||
header = HDECRYPT(state.NHKr, enc_header)
|
||||
if header != None:
|
||||
return header, True
|
||||
raise Error()
|
||||
|
||||
def DHRatchetPQ2HE(state, header):
|
||||
state.PN = state.Ns
|
||||
state.Ns = 0
|
||||
@@ -170,16 +180,16 @@ def DHRatchetPQ2HE(state, header):
|
||||
state.HKr = state.NHKr
|
||||
state.DHRr = header.dh
|
||||
// save new encapsulation key from header
|
||||
state.PQRr = header.encaps
|
||||
state.PQRr = header.kem
|
||||
// decapsulate shared secret from header - KEM #2
|
||||
ss = PQKEM-DEC(state.PQRs.decaps, header.enc_ss)
|
||||
ss = PQKEM-DEC(state.PQRs.private, header.ct)
|
||||
// use decapsulated shared secret with receiving ratchet
|
||||
state.RK, state.CKr, state.NHKr = KDF_RK_HE(state.RK, DH(state.DHRs, state.DHRr) || ss)
|
||||
state.DHRs = GENERATE_DH()
|
||||
// below is added for KEM
|
||||
state.PQRs = GENERATE_PQKEM() // generate new PQ key pair
|
||||
state.PQRss = random // shared secret for KEM
|
||||
state.PQRenc_ss = PQKEM-ENC(state.PQRr.encaps, state.PQRss) // encapsulated additional shared secret KEM #1
|
||||
state.PQRct = PQKEM-ENC(state.PQRr, state.PQRss) // encapsulated additional shared secret KEM #1
|
||||
// above is added for KEM
|
||||
// use new shared secret with sending ratchet
|
||||
state.RK, state.CKs, state.NHKs = KDF_RK_HE(state.RK, DH(state.DHRs, state.DHRr) || state.PQRss)
|
||||
@@ -201,7 +211,7 @@ The main downside is the absense of performance-efficient implementation for aar
|
||||
|
||||
## Implementation considerations for SimpleX Chat
|
||||
|
||||
As SimpleX Chat pads messages to a fixed size, using 16kb transport blocks, the size increase introduced by this scheme will not cause additional traffic in most cases. For large texts it may require additional messages to be sent. Similarly, for media previews it may require either reducing the preview size (and quality) or sending additional messages.
|
||||
As SimpleX Chat pads messages to a fixed size, using 16kb transport blocks, the size increase introduced by this scheme will not cause additional traffic in most cases. For large texts it may require additional messages to be sent. Similarly, for media previews it may require either reducing the preview size (and quality), or sending additional messages, or compressing the current JSON encoding, e.g. with zstd algorithm.
|
||||
|
||||
That might be the primary reason why this scheme was not adopted by Signal, as it would have resulted in substantial traffic growth – to the best of our knowledge, Signal messages are not padded to a fixed size.
|
||||
|
||||
@@ -209,6 +219,8 @@ Sharing the initial keys in case of SimpleX Chat it is equivalent to sharing the
|
||||
|
||||
It is possible to postpone sharing the encapsulation key until the first message from Alice (confirmation message in SMP protocol), the party sending connection request. The upside here is that the invitation link size would not increase. The downside is that the user profile shared in this confirmation will not be encrypted with PQ-resistant algorithm. To mitigate it, the hadnshake protocol can be modified to postpone sending the user profile until the second message from Alice (HELLO message in SMP protocol).
|
||||
|
||||
Another consideration is pairwise ratchets in groups. Key generation in sntrup761 is quite slow - on slow devices it can probably be as slow as 10 keys per second, so using this primitive in groups larger than 10 members would result in slow performance. An option could be not to use ratchets in groups at all, but that would result in the lack of protection in small groups that simply combine multiple devices of 1-3 people. So a better option would be to support dynamically adding and removing sntrup761 keys for pairwise ratchets in groups, which means that when sending each message a boolean flag needs to be passed whether to use PQ KEM or not.
|
||||
|
||||
## Summary
|
||||
|
||||
If chosen PQ KEM proves secure against quantum computer attacks, then the proposed augmented double ratchet will also be secure against quantum computer attack, including break-in recovery property, while keeping deniability and forward secrecy, because the [same proof](https://eprint.iacr.org/2016/1013.pdf) as for double ratchet algorithm would hold here, provided KEM is secure.
|
||||
|
||||
@@ -108,3 +108,33 @@ Sending member builds messages history starting starting from requested/remember
|
||||
\***
|
||||
|
||||
Same XGrpMsgHistory protocol event could be sent by host to new members, after sending introductions.
|
||||
|
||||
---
|
||||
|
||||
Update 2024-02-12:
|
||||
|
||||
### Group "pings"
|
||||
|
||||
Alternatively to tracking unanswered messages counts per member, which is complex and in some cases as discussed above ineffective, group members could periodically send group wide pings indicating their active presence.
|
||||
|
||||
```haskell
|
||||
XGrpPing :: ChatMsgEvent 'Json
|
||||
```
|
||||
|
||||
Members track:
|
||||
|
||||
- inactive flag (as above - set on QUOTA errors as well)
|
||||
- last_snd_ts on group
|
||||
- last_rcv_ts on group member
|
||||
|
||||
Clients run a worker process for checking last_snd_ts in each of their groups, and send pings to groups on a periodic basis.
|
||||
|
||||
- part of cleanup manager or separate process?
|
||||
- on each worker step, for each group matching criteria to send ping, send ping with a random delay to reduce correlation between groups (spawn a separate thread with a random delay for each group)
|
||||
- criteria for sending ping: last_snd_ts earlier than group_ping_interval ago
|
||||
- configure group_ping_interval to, for example, 23 hours (so that if user opens app each day at same time client will match criteria to send pings daily)
|
||||
|
||||
Clients receiving pings:
|
||||
|
||||
- update last_rcv_ts
|
||||
- when sending a message to group, check only for timestamp difference (no unanswered snd msg count logic as above)
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
# Database migration and other operations
|
||||
|
||||
## Problem
|
||||
|
||||
Migrating database to another device is very complex for most people - it is multi-step and error-prone.
|
||||
|
||||
In addition to that, any database operation is confusing as it requires stopping chat.
|
||||
|
||||
## Solution
|
||||
|
||||
Let users migrate database to another device by scanning QR code.
|
||||
|
||||
Simplify other database operations by removing the need to compose multiple actions, stop chat, etc.
|
||||
|
||||
To support it, we already added the way to represent the file as link/QR code (by uploading file description to XFTP, and supporting "recursive" descriptions).
|
||||
|
||||
There will be these actions in the Database settings (no stop/start chat toggle):
|
||||
|
||||
- Export database.
|
||||
- Import database.
|
||||
- Migrate from another device.
|
||||
- Set passphrase (or Change passphrase if it was set).
|
||||
- Remove passphrase from device / Store passphrase on the device.
|
||||
|
||||
Stop chat toggle will be moved to dev tools.
|
||||
|
||||
Migrate to another device will be available in the top part of the settings,
|
||||
|
||||
|
||||
### Database export
|
||||
|
||||
Currently, it requires these steps:
|
||||
|
||||
1. Open Database settings.
|
||||
2. Stop chat (many users don't understand it).
|
||||
3. Tap "Export database" in settings.
|
||||
4. Look at the alert that says "set passphrase".
|
||||
5. Tap Ok.
|
||||
6. Tap Set passphrase.
|
||||
7. Enter passphrase and confirm.
|
||||
8. Exit back to Database settings.
|
||||
9. Tap "Export database" again.
|
||||
10. Choose file location and save.
|
||||
11. Tap "New archive".
|
||||
12. Remove exported archive.
|
||||
|
||||
These steps are all very confusing, and if they were to stay as composable steps, they belong to dev tools.
|
||||
|
||||
Instead we can offer these simple steps:
|
||||
|
||||
1. Open Database settings.
|
||||
2. Tap "Export database".
|
||||
3. Alert will appear saying: "The chat will stop, and you will need to set (or verify) database passphrase. Continue?".
|
||||
4. Tap "Ok".
|
||||
5. Enter passphrase and confirm in the window that appears (or verify if it was already set, possibly allowing to skip this step).
|
||||
7. Choose whether to save file or upload to XFTP and generate link.
|
||||
8. File: choose file location and save.
|
||||
Link: show upload progress and then show link to copy.
|
||||
9. Alert will appear saying: "Database exported!", exported archive will be automatically removed.
|
||||
|
||||
So instead of asking users to understand the required sequence of steps, we will guide them through the required process.
|
||||
|
||||
### Database import
|
||||
|
||||
1. Open Database settings.
|
||||
2. Tap "Import database".
|
||||
3. Alert will appear saying: "The chat will stop, you will import?".
|
||||
4. File: choose file location and tap "Import".
|
||||
Link: paste link (or scan QR code) and tap "Import".
|
||||
5. Confirm to replace database.
|
||||
6. Start chat automatically once imported.
|
||||
|
||||
### Set or change passphrase
|
||||
|
||||
1. Open Database settings.
|
||||
2. Tap "Set passphrase" or "Change passphrase" (if it was set).
|
||||
3. Choose - store passphrase on the device or enter it every time the app starts.
|
||||
|
||||
### Remove / store passphrase from the device
|
||||
|
||||
To remove:
|
||||
|
||||
1. Open Database settings.
|
||||
2. Tap "Remove passphrase".
|
||||
3. Confirm to remove passphrase in alert.
|
||||
4. Button is replaced with Store.
|
||||
|
||||
To store:
|
||||
|
||||
1. Open Database settings.
|
||||
2. Tap "Store passphrase".
|
||||
3. Enter current passphrase - it is verified.
|
||||
4. Button is replaced with Remove.
|
||||
|
||||
### Migrate database to / from another device
|
||||
|
||||
#### User experience
|
||||
|
||||
This function is the most important, and it should be available from the main section in settings, under "Use from desktop" (or under "Link from mobile" on desktop).
|
||||
|
||||
On the receiving device it will be available via Database settings and also on the Onboarding screen, so users don't need to create a profile.
|
||||
|
||||
The steps are:
|
||||
|
||||
On the source device:
|
||||
1. Tap "Migrate to another device".
|
||||
2. The chat will stop showing "Stopping chat" to the user.
|
||||
3. If passphrase was:
|
||||
- not set: make user set it in a separate screen.
|
||||
- set: make user verify it.
|
||||
5. Show the screen to confirm the upload.
|
||||
6. Upload progress (full screen circular progress showing the share, with the %s and total/uploaded size) will be shown.
|
||||
7. Once upload is completed, show QR code (with option to copy link), instruct to tap "Migrate from another device" on the receiving device.
|
||||
|
||||
On the receiving device:
|
||||
2. Tap "Migrate from another device".
|
||||
2. The chat will stop (if not from Onboarding) showing "Stopping chat" to the user.
|
||||
4. Scan QR code (with option to paste link on desktop only).
|
||||
5. Show similar download progress, but probably in reversed direction - design TBC.
|
||||
6. Once download is completed, show "Replace the current database" (if not from Onboarding).
|
||||
7. Once imported, start chat automatically, and once chat started show "Tap remove database on source device".
|
||||
|
||||
On the source device:
|
||||
1. Tap "Remove database" on the showing screen (this should also remove uploaded file).
|
||||
|
||||
#### Implementation considerations
|
||||
|
||||
The latest updates allow uploading and downloading XFTP files without messages.
|
||||
|
||||
So to perform the above, the second instance of the chat controller will be required, that probably requires supporting additional/optional chat controller parameter in the APIs that are required for that process.
|
||||
@@ -0,0 +1,38 @@
|
||||
# Inactive group members (simplified)
|
||||
|
||||
[Original doc](./2023-11-21-inactive-group-members.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Groups traffic is higher than necessary due to sending messages to inactive group members.
|
||||
|
||||
## Solution
|
||||
|
||||
### Improve connection deletion
|
||||
|
||||
- When leaving or deleting group, batch db operations to optimize performance.
|
||||
- In agent - fix race where connection can be deleted while it has remaining pending messages.
|
||||
- Current agent logic is to immediately delete connection if it has no rcv queues left.
|
||||
- Simplest should be to make a smart version of `deleteConn` for this improvement, checking `snd_messages` table for remaining messages, and keep connection around in case there are.
|
||||
- While this may improve delivery of group leave and delete messages, it may as well have undesirable side effects for other use cases, as any pending messages will be sent prior to deleting connection. For example, user sends several messages on bad network, decides to delete contact, messages are still delivered when user is on good network before deletion, even though this contradicts user's intent and messages hadn't left user's device at the time of deletion. Considering this race when it happens is identical to simply leaving groups by deleting app, or deleting user profile only locally, it may be a bad idea to affect regular contact deletion for this use case.
|
||||
|
||||
### Track member inactivity
|
||||
|
||||
- Mark members as inactive on QUOTA errors, reset as active on QCONT
|
||||
- track `group_members.inactive` flag per group member
|
||||
- on SMP.QUOTA error agent to notify client with ERR CONN QUOTA (new ConnectionErrorType QUOTA)
|
||||
- on receiving QCONT agent to notify client (new event)
|
||||
- apart from QCONT, reset on any message or receipt
|
||||
- Don't send to member if inactive
|
||||
- don't send only content messages (x.msg.new, etc.) and always send messages altering group state?
|
||||
- or don't send any messages?
|
||||
- Track number of skipped messages per member and first skipped message
|
||||
- count `group_members.skipped_msg_cnt`
|
||||
- only count messages of same types/criteria that are included into history
|
||||
- track `group_members.skipped_first_shared_msg_id` (only content or including service messages?)
|
||||
- Send XGrpMsgSkipped before next message
|
||||
- check `skipped_msg_cnt` > 0 and `skipped_first_shared_msg_id` is not null to only send once, reset after sending
|
||||
|
||||
```haskell
|
||||
XGrpMsgSkipped :: SharedMsgId -> Int64 -> ChatMsgEvent 'Json -- from, count
|
||||
```
|
||||
@@ -0,0 +1,60 @@
|
||||
# Migrating app settings to another device
|
||||
|
||||
## Problem
|
||||
|
||||
This is related to simplified database migration UX in the [previous RFC](./2024-02-12-database-migration.md).
|
||||
|
||||
Currently, when database is imported after the onboarding is complete, users can configure the app prior to the import.
|
||||
|
||||
Some of the settings are particularly important for privacy and security:
|
||||
- SOCKS proxy settings
|
||||
- Automatic image etc. downloads
|
||||
- Link previews
|
||||
|
||||
With the new UX, the chat will start automatically, without giving users a chance to configure the app. That means that we have to migrate settings to a new device as well, as part of the archive.
|
||||
|
||||
## Solution
|
||||
|
||||
There are several possible approaches:
|
||||
- put settings to the database via the API
|
||||
- save settings as some file with cross-platform format (e.g. JSON or YAML or properties used on desktop).
|
||||
|
||||
The second approach seems much simpler than maintaining the settings in the database.
|
||||
|
||||
If we save a file, then there are two options:
|
||||
- native apps maintain cross-platform schemas for this file, support any JSON and parse it in a safe way (so that even invalid or incorrect JSON - e.g., array instead of object - or invalid types in some properties do not cause the failure of properties that are correct).
|
||||
- this schema and type will be maintained in the core library, that will be responsible for storing and reading the settings and passing to native UI as correct record of a given type.
|
||||
|
||||
The downside of the second approach is that addition of any property that needs to be migrated will have to be done on any change in either of the platforms. The downside of the first approach is that neither app platform will be self-sufficient any more, and not only iOS/Android would have to take into account code, but also each other code.
|
||||
|
||||
If we go with the second approach, there will be these types:
|
||||
|
||||
```haskell
|
||||
data AppSettings = AppSettings
|
||||
{ networkConfig :: NetworkConfig, -- existing type in Haskell and all UIs
|
||||
privacyConfig :: PrivacyConfig -- new type, etc.
|
||||
-- ... additional properties after the initial release should be added as Maybe, as all extensions
|
||||
}
|
||||
|
||||
data ArchiveConfig = ArchiveConfig
|
||||
{ -- existing properties
|
||||
archivePath :: FilePath,
|
||||
disableCompression :: Maybe Bool,
|
||||
parentTempDirectory :: Maybe FilePath,
|
||||
-- new property
|
||||
appSettings :: AppSettings
|
||||
-- for export, these settings will contain the settings passed from the UI and will be saved to JSON file as simplex_v1_settings.json in the archive
|
||||
-- for import, these settings will contain the defaults that will be used if some property or subproperty is missing in JSON
|
||||
}
|
||||
|
||||
-- importArchive :: ChatMonad m => ArchiveConfig -> m [ArchiveError] -- current type
|
||||
importArchive :: ChatMonad m => ArchiveConfig -> m ArchiveImportResult -- new type
|
||||
|
||||
-- | CRArchiveImported {archiveErrors :: [ArchiveError]} -- current type
|
||||
| CRArchiveImported {importResult :: ArchiveImportResult} -- new type
|
||||
|
||||
data ArchiveImportResult = ArchiveImportResult
|
||||
{ archiveErrors :: [ArchiveError],
|
||||
appSettings :: Maybe AppSettings
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,98 @@
|
||||
# PQ integration in chat
|
||||
|
||||
## Problem
|
||||
|
||||
- Group size not known when joining
|
||||
- Communicate intent and current state of each conversation
|
||||
|
||||
## Solution
|
||||
|
||||
### Group size not known when joining
|
||||
|
||||
- Add to XGrpInv GroupInvitation
|
||||
- pros: easy
|
||||
- cons: size can change before joining, but can ignore as it's still a good estimate
|
||||
|
||||
or
|
||||
|
||||
- Send before introductions
|
||||
- new protocol message
|
||||
- XGrpIntro :: GrpIntro -> ChatMsgEvent 'Json -- (GrpIntro is a box type with Int, for possible extension)
|
||||
- or put into XGrpInfo
|
||||
- XGrpInfo :: GroupProfile -> GroupStats -> ChatMsgEvent 'Json -- GroupData?
|
||||
- can update profile between invitation if it happened before joining
|
||||
- can later add logic to "verify" stats?
|
||||
- may be over-complicated until since there "supposed" use cases are out-of-scope / not planned / not known
|
||||
|
||||
- What should be default if it's not known? (e.g. admin has older version)
|
||||
- On -> then off when member count reaches 20?
|
||||
|
||||
### Communicate intent and current state of each conversation
|
||||
|
||||
- Current state items
|
||||
- RCEPQEnabled (see #3845) both for direct conversation and per member (regular event items, merged in UI)
|
||||
- created when PQ changes for contact/member (e.g. received from agent on MsgMeta / SENT)
|
||||
- experimental toggle is planned: it doesn't affect contacts/members with already enabled PQ
|
||||
- contact enabled PQ always overrides toggle (can't downgrade)
|
||||
- member enabled PQ also overrides, but can downgrade if group size increases past 20
|
||||
|
||||
- New items communicating state of e2e encryption in conversation
|
||||
- should be well pronounced in UI, not merged
|
||||
- should always say that conversation is e2e encrypted
|
||||
- in direct chats:
|
||||
- reflect actual state of PQ at the time of creation
|
||||
- created during connection handshake when receiving first info about PQ in MsgMeta / some other event (TBC agent api)
|
||||
- will not update if state changes (e.g. upgrades), as toggle is planned to be removed, PQ can't be downgraded, all will support soon
|
||||
- flag in contacts table "e2e_info_created" to only create it once?
|
||||
- should create for legacy contacts or not?
|
||||
- in groups:
|
||||
- reflect intent (should say "PQ will be used for members who support") based on number of members (see above) + toggle
|
||||
- created at the same time as feature items? race with history may be possible, but we don't observe it? need to double check or ignore
|
||||
- if based on XGrpInv GroupInvitation (first option above), can create item even before joining
|
||||
- also will not update (as conversation progresses and it will scroll far up anyway) even if group size changes and it's disabled
|
||||
- flag in groups table "e2e_info_created" to only create it once? and state is only reflected by RCEPQEnabled items?
|
||||
- or create new such item if group size increases and PQ is off / decreases and PQ is on?
|
||||
- "large group" thresholds have to different for group size increasing (e.g. 20) and decreases (e.g. 15), to avoid constant switching on the border.
|
||||
|
||||
- Example texts for "e2e encryption info" chat items:
|
||||
- for direct conversations:
|
||||
- with PQ (and also forward a couple releases when more clients have upgraded):
|
||||
```
|
||||
Messages in this conversation are end-to-end encrypted.
|
||||
Post-quantum encryption is enabled.
|
||||
```
|
||||
- no PQ (experimental toggle disabled):
|
||||
```
|
||||
-//- (e2ee)
|
||||
Post-quantum encryption is not enabled. [Also possibly:] Enabling post-quantum encryption in experimental settings will enable it in this conversation if your contact supports it.
|
||||
```
|
||||
- no PQ (experimental toggle enabled):
|
||||
```
|
||||
-//-
|
||||
Post-quantum encryption will be enabled when your contact upgrades.
|
||||
```
|
||||
"upgrades" / "supports it" / "starts to support it"
|
||||
- can be of different color, but seems unnecessary
|
||||
- created once at the start of conversation
|
||||
- created once for old contacts when PQ is enabled?
|
||||
- for groups:
|
||||
- with PQ (small group; toggle enabled or later, as above):
|
||||
```
|
||||
-//-
|
||||
Post-quantum encryption will be enabled for members who support it.
|
||||
```
|
||||
can remove qualification later when most clients have upgraded
|
||||
- no PQ (large group):
|
||||
```
|
||||
-//-
|
||||
Post-quantum encryption is not enabled (group is too large).
|
||||
```
|
||||
- created each time group changes between small/large, or once?
|
||||
- created for old groups when experimental toggle is first turned on, and first message is received?
|
||||
|
||||
|
||||
- Save PQ encryption on chat items (messages)?
|
||||
- in meta for direct + group rcv
|
||||
- in group_snd_item_statuses for group snd?
|
||||
- display in chat item details (info)
|
||||
- may be overkill if aggressive upgrade strategy is planned
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
name: simplex-chat
|
||||
version: 5.5.2.0
|
||||
version: 5.5.5.0
|
||||
#synopsis:
|
||||
#description:
|
||||
homepage: https://github.com/simplex-chat/simplex-chat#readme
|
||||
|
||||
@@ -12,7 +12,6 @@ export type ChatCommand =
|
||||
| APIStopChat
|
||||
| SetTempFolder
|
||||
| SetFilesFolder
|
||||
| APISetXFTPConfig
|
||||
| SetIncognito
|
||||
| APIExportArchive
|
||||
| APIImportArchive
|
||||
@@ -112,7 +111,6 @@ type ChatCommandTag =
|
||||
| "apiStopChat"
|
||||
| "setTempFolder"
|
||||
| "setFilesFolder"
|
||||
| "apiSetXFTPConfig"
|
||||
| "setIncognito"
|
||||
| "apiExportArchive"
|
||||
| "apiImportArchive"
|
||||
@@ -242,15 +240,6 @@ export interface SetFilesFolder extends IChatCommand {
|
||||
filePath: string
|
||||
}
|
||||
|
||||
export interface APISetXFTPConfig extends IChatCommand {
|
||||
type: "apiSetXFTPConfig"
|
||||
config?: XFTPFileConfig
|
||||
}
|
||||
|
||||
export interface XFTPFileConfig {
|
||||
minFileSize: number
|
||||
}
|
||||
|
||||
export interface SetIncognito extends IChatCommand {
|
||||
type: "setIncognito"
|
||||
incognito: boolean
|
||||
@@ -707,8 +696,6 @@ export function cmdString(cmd: ChatCommand): string {
|
||||
return `/_temp_folder ${cmd.tempFolder}`
|
||||
case "setFilesFolder":
|
||||
return `/_files_folder ${cmd.filePath}`
|
||||
case "apiSetXFTPConfig":
|
||||
return `/_xftp ${onOff(cmd.config)}${maybeJSON(cmd.config)}`
|
||||
case "setIncognito":
|
||||
return `/incognito ${onOff(cmd.incognito)}`
|
||||
case "apiExportArchive":
|
||||
|
||||
@@ -103,7 +103,7 @@ build() {
|
||||
|
||||
for arch in $arches; do
|
||||
|
||||
tag_full="$(git tag --points-at HEAD)"
|
||||
tag_full="$(git tag --points-at HEAD | head -n1)"
|
||||
tag_version="${tag_full%%-*}"
|
||||
|
||||
if [ "$arch" = "armv7a" ] && [ -n "$tag_full" ] ; then
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"https://github.com/simplex-chat/simplexmq.git"."a516c2f72c81bb4a433c4065b1b5aa484b8292b1" = "05ny2i262c236li5w040i1nd3l037cpzgbzjknlla9dd139f3al3";
|
||||
"https://github.com/simplex-chat/simplexmq.git"."246a0d10c22ebe02af2eb34773b77cce10247459" = "0kx5swx1g9jimg7ks008nqzvkyx5x9irjkjwvgwrd3km5g0wnzf4";
|
||||
"https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38";
|
||||
"https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d";
|
||||
"https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl";
|
||||
|
||||
+6
-1
@@ -5,7 +5,7 @@ cabal-version: 1.12
|
||||
-- see: https://github.com/sol/hpack
|
||||
|
||||
name: simplex-chat
|
||||
version: 5.5.2.0
|
||||
version: 5.5.5.0
|
||||
category: Web, System, Services, Cryptography
|
||||
homepage: https://github.com/simplex-chat/simplex-chat#readme
|
||||
author: simplex.chat
|
||||
@@ -26,6 +26,7 @@ flag swift
|
||||
library
|
||||
exposed-modules:
|
||||
Simplex.Chat
|
||||
Simplex.Chat.AppSettings
|
||||
Simplex.Chat.Archive
|
||||
Simplex.Chat.Bot
|
||||
Simplex.Chat.Bot.KnownContacts
|
||||
@@ -133,6 +134,9 @@ library
|
||||
Simplex.Chat.Migrations.M20240104_members_profile_update
|
||||
Simplex.Chat.Migrations.M20240115_block_member_for_all
|
||||
Simplex.Chat.Migrations.M20240122_indexes
|
||||
Simplex.Chat.Migrations.M20240214_redirect_file_id
|
||||
Simplex.Chat.Migrations.M20240222_app_settings
|
||||
Simplex.Chat.Migrations.M20240226_users_restrict
|
||||
Simplex.Chat.Mobile
|
||||
Simplex.Chat.Mobile.File
|
||||
Simplex.Chat.Mobile.Shared
|
||||
@@ -148,6 +152,7 @@ library
|
||||
Simplex.Chat.Remote.Transport
|
||||
Simplex.Chat.Remote.Types
|
||||
Simplex.Chat.Store
|
||||
Simplex.Chat.Store.AppSettings
|
||||
Simplex.Chat.Store.Connections
|
||||
Simplex.Chat.Store.Direct
|
||||
Simplex.Chat.Store.Files
|
||||
|
||||
+355
-297
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,190 @@
|
||||
{-# LANGUAGE NamedFieldPuns #-}
|
||||
{-# LANGUAGE OverloadedStrings #-}
|
||||
{-# LANGUAGE StrictData #-}
|
||||
{-# LANGUAGE TemplateHaskell #-}
|
||||
|
||||
module Simplex.Chat.AppSettings where
|
||||
|
||||
import Control.Applicative ((<|>))
|
||||
import Data.Aeson (FromJSON (..), (.:?))
|
||||
import qualified Data.Aeson as J
|
||||
import qualified Data.Aeson.TH as JQ
|
||||
import Data.Maybe (fromMaybe)
|
||||
import Data.Text (Text)
|
||||
import Simplex.Messaging.Client (NetworkConfig, defaultNetworkConfig)
|
||||
import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON)
|
||||
import Simplex.Messaging.Util (catchAll_)
|
||||
|
||||
data AppPlatform = APIOS | APAndroid | APDesktop deriving (Show)
|
||||
|
||||
data NotificationMode = NMOff | NMPeriodic | NMInstant deriving (Show)
|
||||
|
||||
data NotificationPreviewMode = NPMHidden | NPMContact | NPMMessage deriving (Show)
|
||||
|
||||
data LockScreenCalls = LSCDisable | LSCShow | LSCAccept deriving (Show)
|
||||
|
||||
data AppSettings = AppSettings
|
||||
{ appPlatform :: Maybe AppPlatform,
|
||||
networkConfig :: Maybe NetworkConfig,
|
||||
privacyEncryptLocalFiles :: Maybe Bool,
|
||||
privacyAcceptImages :: Maybe Bool,
|
||||
privacyLinkPreviews :: Maybe Bool,
|
||||
privacyShowChatPreviews :: Maybe Bool,
|
||||
privacySaveLastDraft :: Maybe Bool,
|
||||
privacyProtectScreen :: Maybe Bool,
|
||||
notificationMode :: Maybe NotificationMode,
|
||||
notificationPreviewMode :: Maybe NotificationPreviewMode,
|
||||
webrtcPolicyRelay :: Maybe Bool,
|
||||
webrtcICEServers :: Maybe [Text],
|
||||
confirmRemoteSessions :: Maybe Bool,
|
||||
connectRemoteViaMulticast :: Maybe Bool,
|
||||
connectRemoteViaMulticastAuto :: Maybe Bool,
|
||||
developerTools :: Maybe Bool,
|
||||
confirmDBUpgrades :: Maybe Bool,
|
||||
androidCallOnLockScreen :: Maybe LockScreenCalls,
|
||||
iosCallKitEnabled :: Maybe Bool,
|
||||
iosCallKitCallsInRecents :: Maybe Bool
|
||||
}
|
||||
deriving (Show)
|
||||
|
||||
defaultAppSettings :: AppSettings
|
||||
defaultAppSettings =
|
||||
AppSettings
|
||||
{ appPlatform = Nothing,
|
||||
networkConfig = Just defaultNetworkConfig,
|
||||
privacyEncryptLocalFiles = Just True,
|
||||
privacyAcceptImages = Just True,
|
||||
privacyLinkPreviews = Just True,
|
||||
privacyShowChatPreviews = Just True,
|
||||
privacySaveLastDraft = Just True,
|
||||
privacyProtectScreen = Just False,
|
||||
notificationMode = Just NMInstant,
|
||||
notificationPreviewMode = Just NPMMessage,
|
||||
webrtcPolicyRelay = Just True,
|
||||
webrtcICEServers = Just [],
|
||||
confirmRemoteSessions = Just False,
|
||||
connectRemoteViaMulticast = Just True,
|
||||
connectRemoteViaMulticastAuto = Just True,
|
||||
developerTools = Just False,
|
||||
confirmDBUpgrades = Just False,
|
||||
androidCallOnLockScreen = Just LSCShow,
|
||||
iosCallKitEnabled = Just True,
|
||||
iosCallKitCallsInRecents = Just False
|
||||
}
|
||||
|
||||
defaultParseAppSettings :: AppSettings
|
||||
defaultParseAppSettings =
|
||||
AppSettings
|
||||
{ appPlatform = Nothing,
|
||||
networkConfig = Nothing,
|
||||
privacyEncryptLocalFiles = Nothing,
|
||||
privacyAcceptImages = Nothing,
|
||||
privacyLinkPreviews = Nothing,
|
||||
privacyShowChatPreviews = Nothing,
|
||||
privacySaveLastDraft = Nothing,
|
||||
privacyProtectScreen = Nothing,
|
||||
notificationMode = Nothing,
|
||||
notificationPreviewMode = Nothing,
|
||||
webrtcPolicyRelay = Nothing,
|
||||
webrtcICEServers = Nothing,
|
||||
confirmRemoteSessions = Nothing,
|
||||
connectRemoteViaMulticast = Nothing,
|
||||
connectRemoteViaMulticastAuto = Nothing,
|
||||
developerTools = Nothing,
|
||||
confirmDBUpgrades = Nothing,
|
||||
androidCallOnLockScreen = Nothing,
|
||||
iosCallKitEnabled = Nothing,
|
||||
iosCallKitCallsInRecents = Nothing
|
||||
}
|
||||
|
||||
combineAppSettings :: AppSettings -> AppSettings -> AppSettings
|
||||
combineAppSettings platformDefaults storedSettings =
|
||||
AppSettings
|
||||
{ appPlatform = p appPlatform,
|
||||
networkConfig = p networkConfig,
|
||||
privacyEncryptLocalFiles = p privacyEncryptLocalFiles,
|
||||
privacyAcceptImages = p privacyAcceptImages,
|
||||
privacyLinkPreviews = p privacyLinkPreviews,
|
||||
privacyShowChatPreviews = p privacyShowChatPreviews,
|
||||
privacySaveLastDraft = p privacySaveLastDraft,
|
||||
privacyProtectScreen = p privacyProtectScreen,
|
||||
notificationMode = p notificationMode,
|
||||
notificationPreviewMode = p notificationPreviewMode,
|
||||
webrtcPolicyRelay = p webrtcPolicyRelay,
|
||||
webrtcICEServers = p webrtcICEServers,
|
||||
confirmRemoteSessions = p confirmRemoteSessions,
|
||||
connectRemoteViaMulticast = p connectRemoteViaMulticast,
|
||||
connectRemoteViaMulticastAuto = p connectRemoteViaMulticastAuto,
|
||||
developerTools = p developerTools,
|
||||
confirmDBUpgrades = p confirmDBUpgrades,
|
||||
iosCallKitEnabled = p iosCallKitEnabled,
|
||||
iosCallKitCallsInRecents = p iosCallKitCallsInRecents,
|
||||
androidCallOnLockScreen = p androidCallOnLockScreen
|
||||
}
|
||||
where
|
||||
p :: (AppSettings -> Maybe a) -> Maybe a
|
||||
p sel = sel storedSettings <|> sel platformDefaults <|> sel defaultAppSettings
|
||||
|
||||
$(JQ.deriveJSON (enumJSON $ dropPrefix "AP") ''AppPlatform)
|
||||
|
||||
$(JQ.deriveJSON (enumJSON $ dropPrefix "NM") ''NotificationMode)
|
||||
|
||||
$(JQ.deriveJSON (enumJSON $ dropPrefix "NPM") ''NotificationPreviewMode)
|
||||
|
||||
$(JQ.deriveJSON (enumJSON $ dropPrefix "LSC") ''LockScreenCalls)
|
||||
|
||||
$(JQ.deriveToJSON defaultJSON ''AppSettings)
|
||||
|
||||
instance FromJSON AppSettings where
|
||||
parseJSON (J.Object v) = do
|
||||
appPlatform <- p "appPlatform"
|
||||
networkConfig <- p "networkConfig"
|
||||
privacyEncryptLocalFiles <- p "privacyEncryptLocalFiles"
|
||||
privacyAcceptImages <- p "privacyAcceptImages"
|
||||
privacyLinkPreviews <- p "privacyLinkPreviews"
|
||||
privacyShowChatPreviews <- p "privacyShowChatPreviews"
|
||||
privacySaveLastDraft <- p "privacySaveLastDraft"
|
||||
privacyProtectScreen <- p "privacyProtectScreen"
|
||||
notificationMode <- p "notificationMode"
|
||||
notificationPreviewMode <- p "notificationPreviewMode"
|
||||
webrtcPolicyRelay <- p "webrtcPolicyRelay"
|
||||
webrtcICEServers <- p "webrtcICEServers"
|
||||
confirmRemoteSessions <- p "confirmRemoteSessions"
|
||||
connectRemoteViaMulticast <- p "connectRemoteViaMulticast"
|
||||
connectRemoteViaMulticastAuto <- p "connectRemoteViaMulticastAuto"
|
||||
developerTools <- p "developerTools"
|
||||
confirmDBUpgrades <- p "confirmDBUpgrades"
|
||||
iosCallKitEnabled <- p "iosCallKitEnabled"
|
||||
iosCallKitCallsInRecents <- p "iosCallKitCallsInRecents"
|
||||
androidCallOnLockScreen <- p "androidCallOnLockScreen"
|
||||
pure
|
||||
AppSettings
|
||||
{ appPlatform,
|
||||
networkConfig,
|
||||
privacyEncryptLocalFiles,
|
||||
privacyAcceptImages,
|
||||
privacyLinkPreviews,
|
||||
privacyShowChatPreviews,
|
||||
privacySaveLastDraft,
|
||||
privacyProtectScreen,
|
||||
notificationMode,
|
||||
notificationPreviewMode,
|
||||
webrtcPolicyRelay,
|
||||
webrtcICEServers,
|
||||
confirmRemoteSessions,
|
||||
connectRemoteViaMulticast,
|
||||
connectRemoteViaMulticastAuto,
|
||||
developerTools,
|
||||
confirmDBUpgrades,
|
||||
iosCallKitEnabled,
|
||||
iosCallKitCallsInRecents,
|
||||
androidCallOnLockScreen
|
||||
}
|
||||
where
|
||||
p key = v .:? key <|> pure Nothing
|
||||
parseJSON _ = pure defaultParseAppSettings
|
||||
|
||||
readAppSettings :: FilePath -> Maybe AppSettings -> IO AppSettings
|
||||
readAppSettings f platformDefaults =
|
||||
combineAppSettings (fromMaybe defaultAppSettings platformDefaults) . fromMaybe defaultParseAppSettings
|
||||
<$> (J.decodeFileStrict f `catchAll_` pure Nothing)
|
||||
+35
-20
@@ -9,6 +9,7 @@ module Simplex.Chat.Archive
|
||||
importArchive,
|
||||
deleteStorage,
|
||||
sqlCipherExport,
|
||||
sqlCipherTestKey,
|
||||
archiveFilesFolder,
|
||||
)
|
||||
where
|
||||
@@ -20,6 +21,7 @@ import Control.Monad.Reader
|
||||
import qualified Data.ByteArray as BA
|
||||
import Data.Functor (($>))
|
||||
import Data.Maybe (fromMaybe)
|
||||
import Data.Text (Text)
|
||||
import qualified Data.Text as T
|
||||
import qualified Database.SQLite3 as SQL
|
||||
import Simplex.Chat.Controller
|
||||
@@ -147,19 +149,8 @@ sqlCipherExport DBEncryptionConfig {currentKey = DBEncryptionKey key, newKey = D
|
||||
atomically $ writeTVar dbKey $ storeKey key' (fromMaybe False keepKey)
|
||||
export f = do
|
||||
withDB f (`SQL.exec` exportSQL) DBErrorExport
|
||||
withDB (exported f) (`SQL.exec` testSQL) DBErrorOpen
|
||||
withDB (exported f) (`SQL.exec` testSQL key') DBErrorOpen
|
||||
where
|
||||
withDB f' a err =
|
||||
liftIO (bracket (SQL.open $ T.pack f') SQL.close a $> Nothing)
|
||||
`catch` checkSQLError
|
||||
`catch` (\(e :: SomeException) -> sqliteError' e)
|
||||
>>= mapM_ (throwDBError . err)
|
||||
where
|
||||
checkSQLError e = case SQL.sqlError e of
|
||||
SQL.ErrorNotADatabase -> pure $ Just SQLiteErrorNotADatabase
|
||||
_ -> sqliteError' e
|
||||
sqliteError' :: Show e => e -> m (Maybe SQLiteError)
|
||||
sqliteError' = pure . Just . SQLiteError . show
|
||||
exportSQL =
|
||||
T.unlines $
|
||||
keySQL key
|
||||
@@ -167,14 +158,38 @@ sqlCipherExport DBEncryptionConfig {currentKey = DBEncryptionKey key, newKey = D
|
||||
"SELECT sqlcipher_export('exported');",
|
||||
"DETACH DATABASE exported;"
|
||||
]
|
||||
testSQL =
|
||||
T.unlines $
|
||||
keySQL key'
|
||||
<> [ "PRAGMA foreign_keys = ON;",
|
||||
"PRAGMA secure_delete = ON;",
|
||||
"SELECT count(*) FROM sqlite_master;"
|
||||
]
|
||||
keySQL k = ["PRAGMA key = " <> keyString k <> ";" | not (BA.null k)]
|
||||
|
||||
withDB :: forall a m. ChatMonad m => FilePath -> (SQL.Database -> IO a) -> (SQLiteError -> DatabaseError) -> m ()
|
||||
withDB f' a err =
|
||||
liftIO (bracket (SQL.open $ T.pack f') SQL.close a $> Nothing)
|
||||
`catch` checkSQLError
|
||||
`catch` (\(e :: SomeException) -> sqliteError' e)
|
||||
>>= mapM_ (throwDBError . err)
|
||||
where
|
||||
checkSQLError e = case SQL.sqlError e of
|
||||
SQL.ErrorNotADatabase -> pure $ Just SQLiteErrorNotADatabase
|
||||
_ -> sqliteError' e
|
||||
sqliteError' :: Show e => e -> m (Maybe SQLiteError)
|
||||
sqliteError' = pure . Just . SQLiteError . show
|
||||
|
||||
testSQL :: BA.ScrubbedBytes -> Text
|
||||
testSQL k =
|
||||
T.unlines $
|
||||
keySQL k
|
||||
<> [ "PRAGMA foreign_keys = ON;",
|
||||
"PRAGMA secure_delete = ON;",
|
||||
"SELECT count(*) FROM sqlite_master;"
|
||||
]
|
||||
|
||||
keySQL :: BA.ScrubbedBytes -> [Text]
|
||||
keySQL k = ["PRAGMA key = " <> keyString k <> ";" | not (BA.null k)]
|
||||
|
||||
sqlCipherTestKey :: forall m. ChatMonad m => DBEncryptionKey -> m ()
|
||||
sqlCipherTestKey (DBEncryptionKey key) = do
|
||||
fs <- storageFiles
|
||||
testKey `withDBs` fs
|
||||
where
|
||||
testKey f = withDB f (`SQL.exec` testSQL key) DBErrorOpen
|
||||
|
||||
withDBs :: Monad m => (FilePath -> m b) -> StorageFiles -> m b
|
||||
action `withDBs` StorageFiles {chatStore, agentStore} = action (dbFilePath chatStore) >> action (dbFilePath agentStore)
|
||||
|
||||
@@ -49,6 +49,7 @@ import Data.Word (Word16)
|
||||
import Language.Haskell.TH (Exp, Q, runIO)
|
||||
import Numeric.Natural
|
||||
import qualified Paths_simplex_chat as SC
|
||||
import Simplex.Chat.AppSettings
|
||||
import Simplex.Chat.Call
|
||||
import Simplex.Chat.Markdown (MarkdownList)
|
||||
import Simplex.Chat.Messages
|
||||
@@ -59,6 +60,7 @@ import Simplex.Chat.Remote.Types
|
||||
import Simplex.Chat.Store (AutoAccept, StoreError (..), UserContactLink, UserMsgReceiptSettings)
|
||||
import Simplex.Chat.Types
|
||||
import Simplex.Chat.Types.Preferences
|
||||
import Simplex.FileTransfer.Description (FileDescriptionURI)
|
||||
import Simplex.Messaging.Agent (AgentClient, SubscriptionsInfo)
|
||||
import Simplex.Messaging.Agent.Client (AgentLocks, AgentWorkersDetails (..), AgentWorkersSummary (..), ProtocolTestFailure)
|
||||
import Simplex.Messaging.Agent.Env.SQLite (AgentConfig, NetworkConfig)
|
||||
@@ -128,8 +130,6 @@ data ChatConfig = ChatConfig
|
||||
xftpDescrPartSize :: Int,
|
||||
inlineFiles :: InlineFilesConfig,
|
||||
autoAcceptFileSize :: Integer,
|
||||
xftpFileConfig :: Maybe XFTPFileConfig, -- Nothing - XFTP is disabled
|
||||
tempDir :: Maybe FilePath,
|
||||
showReactions :: Bool,
|
||||
showReceipts :: Bool,
|
||||
subscriptionEvents :: Bool,
|
||||
@@ -204,7 +204,6 @@ data ChatController = ChatController
|
||||
timedItemThreads :: TMap (ChatRef, ChatItemId) (TVar (Maybe (Weak ThreadId))),
|
||||
showLiveItems :: TVar Bool,
|
||||
encryptLocalFiles :: TVar Bool,
|
||||
userXFTPFileConfig :: TVar (Maybe XFTPFileConfig),
|
||||
tempDirectory :: TVar (Maybe FilePath),
|
||||
logFilePath :: Maybe FilePath,
|
||||
contactMergeEnabled :: TVar Bool
|
||||
@@ -242,14 +241,16 @@ data ChatCommand
|
||||
| SetTempFolder FilePath
|
||||
| SetFilesFolder FilePath
|
||||
| SetRemoteHostsFolder FilePath
|
||||
| APISetXFTPConfig (Maybe XFTPFileConfig)
|
||||
| APISetEncryptLocalFiles Bool
|
||||
| SetContactMergeEnabled Bool
|
||||
| APIExportArchive ArchiveConfig
|
||||
| ExportArchive
|
||||
| APIImportArchive ArchiveConfig
|
||||
| APISaveAppSettings AppSettings
|
||||
| APIGetAppSettings (Maybe AppSettings)
|
||||
| APIDeleteStorage
|
||||
| APIStorageEncryption DBEncryptionConfig
|
||||
| TestStorageEncryption DBEncryptionKey
|
||||
| ExecChatStoreSQL Text
|
||||
| ExecAgentStoreSQL Text
|
||||
| SlowSQLQueries
|
||||
@@ -452,6 +453,8 @@ data ChatCommand
|
||||
| ListRemoteCtrls
|
||||
| StopRemoteCtrl -- Stop listening for announcements or terminate an active session
|
||||
| DeleteRemoteCtrl RemoteCtrlId -- Remove all local data associated with a remote controller session
|
||||
| APIUploadStandaloneFile UserId CryptoFile
|
||||
| APIDownloadStandaloneFile UserId FileDescriptionURI CryptoFile
|
||||
| QuitChat
|
||||
| ShowVersion
|
||||
| DebugLocks
|
||||
@@ -473,7 +476,6 @@ allowRemoteCommand = \case
|
||||
SetTempFolder _ -> False
|
||||
SetFilesFolder _ -> False
|
||||
SetRemoteHostsFolder _ -> False
|
||||
APISetXFTPConfig _ -> False
|
||||
APISetEncryptLocalFiles _ -> False
|
||||
APIExportArchive _ -> False
|
||||
APIImportArchive _ -> False
|
||||
@@ -592,21 +594,26 @@ data ChatResponse
|
||||
| CRRcvFileAccepted {user :: User, chatItem :: AChatItem}
|
||||
| CRRcvFileAcceptedSndCancelled {user :: User, rcvFileTransfer :: RcvFileTransfer}
|
||||
| CRRcvFileDescrNotReady {user :: User, chatItem :: AChatItem}
|
||||
| CRRcvFileStart {user :: User, chatItem :: AChatItem}
|
||||
| CRRcvFileProgressXFTP {user :: User, chatItem :: AChatItem, receivedSize :: Int64, totalSize :: Int64}
|
||||
| CRRcvStandaloneFileCreated {user :: User, rcvFileTransfer :: RcvFileTransfer} -- returned by _download
|
||||
| CRRcvFileStart {user :: User, chatItem :: AChatItem} -- sent by chats
|
||||
| CRRcvFileProgressXFTP {user :: User, chatItem_ :: Maybe AChatItem, receivedSize :: Int64, totalSize :: Int64, rcvFileTransfer :: RcvFileTransfer}
|
||||
| CRRcvFileComplete {user :: User, chatItem :: AChatItem}
|
||||
| CRRcvFileCancelled {user :: User, chatItem :: AChatItem, rcvFileTransfer :: RcvFileTransfer}
|
||||
| CRRcvStandaloneFileComplete {user :: User, targetPath :: FilePath, rcvFileTransfer :: RcvFileTransfer}
|
||||
| CRRcvFileCancelled {user :: User, chatItem_ :: Maybe AChatItem, rcvFileTransfer :: RcvFileTransfer}
|
||||
| CRRcvFileSndCancelled {user :: User, chatItem :: AChatItem, rcvFileTransfer :: RcvFileTransfer}
|
||||
| CRRcvFileError {user :: User, chatItem :: AChatItem, agentError :: AgentErrorType}
|
||||
| CRRcvFileError {user :: User, chatItem_ :: Maybe AChatItem, agentError :: AgentErrorType, rcvFileTransfer :: RcvFileTransfer}
|
||||
| CRSndFileStart {user :: User, chatItem :: AChatItem, sndFileTransfer :: SndFileTransfer}
|
||||
| CRSndFileComplete {user :: User, chatItem :: AChatItem, sndFileTransfer :: SndFileTransfer}
|
||||
| CRSndFileRcvCancelled {user :: User, chatItem :: AChatItem, sndFileTransfer :: SndFileTransfer}
|
||||
| CRSndFileCancelled {user :: User, chatItem :: AChatItem, fileTransferMeta :: FileTransferMeta, sndFileTransfers :: [SndFileTransfer]}
|
||||
| CRSndFileStartXFTP {user :: User, chatItem :: AChatItem, fileTransferMeta :: FileTransferMeta}
|
||||
| CRSndFileProgressXFTP {user :: User, chatItem :: AChatItem, fileTransferMeta :: FileTransferMeta, sentSize :: Int64, totalSize :: Int64}
|
||||
| CRSndFileRcvCancelled {user :: User, chatItem_ :: Maybe AChatItem, sndFileTransfer :: SndFileTransfer}
|
||||
| CRSndFileCancelled {user :: User, chatItem_ :: Maybe AChatItem, fileTransferMeta :: FileTransferMeta, sndFileTransfers :: [SndFileTransfer]}
|
||||
| CRSndStandaloneFileCreated {user :: User, fileTransferMeta :: FileTransferMeta} -- returned by _upload
|
||||
| CRSndFileStartXFTP {user :: User, chatItem :: AChatItem, fileTransferMeta :: FileTransferMeta} -- not used
|
||||
| CRSndFileProgressXFTP {user :: User, chatItem_ :: Maybe AChatItem, fileTransferMeta :: FileTransferMeta, sentSize :: Int64, totalSize :: Int64}
|
||||
| CRSndFileRedirectStartXFTP {user :: User, fileTransferMeta :: FileTransferMeta, redirectMeta :: FileTransferMeta}
|
||||
| CRSndFileCompleteXFTP {user :: User, chatItem :: AChatItem, fileTransferMeta :: FileTransferMeta}
|
||||
| CRSndFileCancelledXFTP {user :: User, chatItem :: AChatItem, fileTransferMeta :: FileTransferMeta}
|
||||
| CRSndFileError {user :: User, chatItem :: AChatItem}
|
||||
| CRSndStandaloneFileComplete {user :: User, fileTransferMeta :: FileTransferMeta, rcvURIs :: [Text]}
|
||||
| CRSndFileCancelledXFTP {user :: User, chatItem_ :: Maybe AChatItem, fileTransferMeta :: FileTransferMeta}
|
||||
| CRSndFileError {user :: User, chatItem_ :: Maybe AChatItem, fileTransferMeta :: FileTransferMeta}
|
||||
| CRUserProfileUpdated {user :: User, fromProfile :: Profile, toProfile :: Profile, updateSummary :: UserProfileUpdateSummary}
|
||||
| CRUserProfileImage {user :: User, profile :: Profile}
|
||||
| CRContactAliasUpdated {user :: User, toContact :: Contact}
|
||||
@@ -672,7 +679,7 @@ data ChatResponse
|
||||
| CRUserContactLinkSubscribed -- TODO delete
|
||||
| CRUserContactLinkSubError {chatError :: ChatError} -- TODO delete
|
||||
| CRNtfTokenStatus {status :: NtfTknStatus}
|
||||
| CRNtfToken {token :: DeviceToken, status :: NtfTknStatus, ntfMode :: NotificationsMode}
|
||||
| CRNtfToken {token :: DeviceToken, status :: NtfTknStatus, ntfMode :: NotificationsMode, ntfServer :: NtfServer}
|
||||
| CRNtfMessages {user_ :: Maybe User, connEntity_ :: Maybe ConnectionEntity, msgTs :: Maybe UTCTime, ntfMessages :: [NtfMsgInfo]}
|
||||
| CRNtfMessage {user :: User, connEntity :: ConnectionEntity, ntfMessage :: NtfMsgInfo}
|
||||
| CRContactConnectionDeleted {user :: User, connection :: PendingContactConnection}
|
||||
@@ -707,6 +714,7 @@ data ChatResponse
|
||||
| CRChatError {user_ :: Maybe User, chatError :: ChatError}
|
||||
| CRChatErrors {user_ :: Maybe User, chatErrors :: [ChatError]}
|
||||
| CRArchiveImported {archiveErrors :: [ArchiveError]}
|
||||
| CRAppSettings {appSettings :: AppSettings}
|
||||
| CRTimedAction {action :: String, durationMilliseconds :: Int64}
|
||||
deriving (Show)
|
||||
|
||||
@@ -934,22 +942,14 @@ instance FromJSON ComposedMessage where
|
||||
parseJSON invalid =
|
||||
JT.prependFailure "bad ComposedMessage, " (JT.typeMismatch "Object" invalid)
|
||||
|
||||
data XFTPFileConfig = XFTPFileConfig
|
||||
{ minFileSize :: Integer
|
||||
}
|
||||
deriving (Show)
|
||||
|
||||
defaultXFTPFileConfig :: XFTPFileConfig
|
||||
defaultXFTPFileConfig = XFTPFileConfig {minFileSize = 0}
|
||||
|
||||
data NtfMsgInfo = NtfMsgInfo {msgId :: Text, msgTs :: UTCTime}
|
||||
deriving (Show)
|
||||
|
||||
ntfMsgInfo :: SMPMsgMeta -> NtfMsgInfo
|
||||
ntfMsgInfo SMPMsgMeta {msgId, msgTs} = NtfMsgInfo {msgId = decodeLatin1 $ strEncode msgId, msgTs = systemToUTCTime msgTs}
|
||||
|
||||
crNtfToken :: (DeviceToken, NtfTknStatus, NotificationsMode) -> ChatResponse
|
||||
crNtfToken (token, status, ntfMode) = CRNtfToken {token, status, ntfMode}
|
||||
crNtfToken :: (DeviceToken, NtfTknStatus, NotificationsMode, NtfServer) -> ChatResponse
|
||||
crNtfToken (token, status, ntfMode, ntfServer) = CRNtfToken {token, status, ntfMode, ntfServer}
|
||||
|
||||
data SwitchProgress = SwitchProgress
|
||||
{ queueDirection :: QueueDirection,
|
||||
@@ -1001,11 +1001,6 @@ data CoreVersionInfo = CoreVersionInfo
|
||||
}
|
||||
deriving (Show)
|
||||
|
||||
data SendFileMode
|
||||
= SendFileSMP (Maybe InlineFileMode)
|
||||
| SendFileXFTP
|
||||
deriving (Show)
|
||||
|
||||
data SlowSQLQuery = SlowSQLQuery
|
||||
{ query :: Text,
|
||||
queryStats :: SlowQueryStats
|
||||
@@ -1257,6 +1252,14 @@ mkChatError :: SomeException -> ChatError
|
||||
mkChatError = ChatError . CEException . show
|
||||
{-# INLINE mkChatError #-}
|
||||
|
||||
catchStoreError :: ExceptT StoreError IO a -> (StoreError -> ExceptT StoreError IO a) -> ExceptT StoreError IO a
|
||||
catchStoreError = catchAllErrors mkStoreError
|
||||
{-# INLINE catchStoreError #-}
|
||||
|
||||
mkStoreError :: SomeException -> StoreError
|
||||
mkStoreError = SEInternalError . show
|
||||
{-# INLINE mkStoreError #-}
|
||||
|
||||
chatCmdError :: Maybe User -> String -> ChatResponse
|
||||
chatCmdError user = CRChatCmdError user . ChatError . CECommandError
|
||||
|
||||
@@ -1409,6 +1412,4 @@ $(JQ.deriveFromJSON defaultJSON ''ArchiveConfig)
|
||||
|
||||
$(JQ.deriveFromJSON defaultJSON ''DBEncryptionConfig)
|
||||
|
||||
$(JQ.deriveJSON defaultJSON ''XFTPFileConfig)
|
||||
|
||||
$(JQ.deriveToJSON defaultJSON ''ComposedMessage)
|
||||
|
||||
@@ -30,10 +30,11 @@ import qualified Data.Text as T
|
||||
import Data.Text.Encoding (encodeUtf8)
|
||||
import Simplex.Chat.Types
|
||||
import Simplex.Chat.Types.Util
|
||||
import Simplex.Messaging.Agent.Protocol (AConnectionRequestUri (..), ConnReqScheme (..), ConnReqUriData (..), ConnectionRequestUri (..), SMPQueue (..))
|
||||
import Simplex.Messaging.Agent.Protocol (AConnectionRequestUri (..), ConnReqUriData (..), ConnectionRequestUri (..), SMPQueue (..))
|
||||
import Simplex.Messaging.Encoding.String
|
||||
import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, fstToLower, sumTypeJSON)
|
||||
import Simplex.Messaging.Protocol (ProtocolServer (..))
|
||||
import Simplex.Messaging.ServiceScheme (ServiceScheme (..))
|
||||
import Simplex.Messaging.Util (safeDecodeUtf8)
|
||||
import System.Console.ANSI.Types
|
||||
import qualified Text.Email.Validate as Email
|
||||
@@ -231,10 +232,10 @@ markdownP = mconcat <$> A.many' fragmentP
|
||||
simplexUriFormat :: AConnectionRequestUri -> Format
|
||||
simplexUriFormat = \case
|
||||
ACR _ (CRContactUri crData) ->
|
||||
let uri = safeDecodeUtf8 . strEncode $ CRContactUri crData {crScheme = CRSSimplex}
|
||||
let uri = safeDecodeUtf8 . strEncode $ CRContactUri crData {crScheme = SSSimplex}
|
||||
in SimplexLink (linkType' crData) uri $ uriHosts crData
|
||||
ACR _ (CRInvitationUri crData e2e) ->
|
||||
let uri = safeDecodeUtf8 . strEncode $ CRInvitationUri crData {crScheme = CRSSimplex} e2e
|
||||
let uri = safeDecodeUtf8 . strEncode $ CRInvitationUri crData {crScheme = SSSimplex} e2e
|
||||
in SimplexLink XLInvitation uri $ uriHosts crData
|
||||
where
|
||||
uriHosts ConnReqUriData {crSmpQueues} = L.map (safeDecodeUtf8 . strEncode) $ sconcat $ L.map (host . qServer) crSmpQueues
|
||||
|
||||
@@ -360,6 +360,24 @@ mkCIMeta itemId itemContent itemText itemStatus itemSharedMsgId itemDeleted item
|
||||
_ -> False
|
||||
in CIMeta {itemId, itemTs, itemText, itemStatus, itemSharedMsgId, itemDeleted, itemEdited, itemTimed, itemLive, editable, forwardedByMember, createdAt, updatedAt}
|
||||
|
||||
dummyMeta :: ChatItemId -> UTCTime -> Text -> CIMeta c 'MDSnd
|
||||
dummyMeta itemId ts itemText =
|
||||
CIMeta
|
||||
{ itemId,
|
||||
itemTs = ts,
|
||||
itemText,
|
||||
itemStatus = CISSndNew,
|
||||
itemSharedMsgId = Nothing,
|
||||
itemDeleted = Nothing,
|
||||
itemEdited = False,
|
||||
itemTimed = Nothing,
|
||||
itemLive = Nothing,
|
||||
editable = False,
|
||||
forwardedByMember = Nothing,
|
||||
createdAt = ts,
|
||||
updatedAt = ts
|
||||
}
|
||||
|
||||
data CITimed = CITimed
|
||||
{ ttl :: Int, -- seconds
|
||||
deleteAt :: Maybe UTCTime -- this is initially Nothing for received items, the timer starts when they are read
|
||||
|
||||
@@ -139,7 +139,7 @@ data CIContent (d :: MsgDirection) where
|
||||
CISndModerated :: CIContent 'MDSnd
|
||||
CIRcvModerated :: CIContent 'MDRcv
|
||||
CIRcvBlocked :: CIContent 'MDRcv
|
||||
CIInvalidJSON :: Text -> CIContent d
|
||||
CIInvalidJSON :: Text -> CIContent d -- this is also used for logical database errors, e.g. SEBadChatItem
|
||||
-- ^ This type is used both in API and in DB, so we use different JSON encodings for the database and for the API
|
||||
-- ! ^ Nested sum types also have to use different encodings for database and API
|
||||
-- ! ^ to avoid breaking cross-platform compatibility, see RcvGroupEvent and SndGroupEvent
|
||||
@@ -172,7 +172,7 @@ ciRequiresAttention content = case msgDirection @d of
|
||||
CIRcvGroupInvitation {} -> True
|
||||
CIRcvDirectEvent rde -> case rde of
|
||||
RDEContactDeleted -> False
|
||||
RDEProfileUpdated {} -> True
|
||||
RDEProfileUpdated {} -> False
|
||||
CIRcvGroupEvent rge -> case rge of
|
||||
RGEMemberAdded {} -> False
|
||||
RGEMemberConnected -> False
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
{-# LANGUAGE QuasiQuotes #-}
|
||||
|
||||
module Simplex.Chat.Migrations.M20240214_redirect_file_id where
|
||||
|
||||
import Database.SQLite.Simple (Query)
|
||||
import Database.SQLite.Simple.QQ (sql)
|
||||
|
||||
m20240214_redirect_file_id :: Query
|
||||
m20240214_redirect_file_id =
|
||||
[sql|
|
||||
ALTER TABLE files ADD COLUMN redirect_file_id INTEGER REFERENCES files ON DELETE CASCADE;
|
||||
|
||||
CREATE INDEX idx_files_redirect_file_id on files(redirect_file_id);
|
||||
|]
|
||||
|
||||
down_m20240214_redirect_file_id :: Query
|
||||
down_m20240214_redirect_file_id =
|
||||
[sql|
|
||||
DROP INDEX idx_files_redirect_file_id;
|
||||
|
||||
ALTER TABLE files DROP COLUMN redirect_file_id;
|
||||
|]
|
||||
@@ -0,0 +1,20 @@
|
||||
{-# LANGUAGE QuasiQuotes #-}
|
||||
|
||||
module Simplex.Chat.Migrations.M20240222_app_settings where
|
||||
|
||||
import Database.SQLite.Simple (Query)
|
||||
import Database.SQLite.Simple.QQ (sql)
|
||||
|
||||
m20240222_app_settings :: Query
|
||||
m20240222_app_settings =
|
||||
[sql|
|
||||
CREATE TABLE app_settings (
|
||||
app_settings TEXT NOT NULL
|
||||
);
|
||||
|]
|
||||
|
||||
down_m20240222_app_settings :: Query
|
||||
down_m20240222_app_settings =
|
||||
[sql|
|
||||
DROP TABLE app_settings;
|
||||
|]
|
||||
@@ -0,0 +1,30 @@
|
||||
{-# LANGUAGE QuasiQuotes #-}
|
||||
|
||||
module Simplex.Chat.Migrations.M20240226_users_restrict where
|
||||
|
||||
import Database.SQLite.Simple (Query)
|
||||
import Database.SQLite.Simple.QQ (sql)
|
||||
|
||||
m20240226_users_restrict :: Query
|
||||
m20240226_users_restrict =
|
||||
[sql|
|
||||
PRAGMA writable_schema=1;
|
||||
|
||||
UPDATE sqlite_master
|
||||
SET sql = replace(sql, 'ON DELETE CASCADE', 'ON DELETE RESTRICT')
|
||||
WHERE name = 'users' AND type = 'table';
|
||||
|
||||
PRAGMA writable_schema=0;
|
||||
|]
|
||||
|
||||
down_m20240226_users_restrict :: Query
|
||||
down_m20240226_users_restrict =
|
||||
[sql|
|
||||
PRAGMA writable_schema=1;
|
||||
|
||||
UPDATE sqlite_master
|
||||
SET sql = replace(sql, 'ON DELETE RESTRICT', 'ON DELETE CASCADE')
|
||||
WHERE name = 'users' AND type = 'table';
|
||||
|
||||
PRAGMA writable_schema=0;
|
||||
|]
|
||||
@@ -22,7 +22,7 @@ CREATE TABLE contact_profiles(
|
||||
);
|
||||
CREATE TABLE users(
|
||||
user_id INTEGER PRIMARY KEY,
|
||||
contact_id INTEGER NOT NULL UNIQUE REFERENCES contacts ON DELETE CASCADE
|
||||
contact_id INTEGER NOT NULL UNIQUE REFERENCES contacts ON DELETE RESTRICT
|
||||
DEFERRABLE INITIALLY DEFERRED,
|
||||
local_display_name TEXT NOT NULL UNIQUE,
|
||||
active_user INTEGER NOT NULL DEFAULT 0,
|
||||
@@ -37,7 +37,7 @@ CREATE TABLE users(
|
||||
user_member_profile_updated_at TEXT, -- 1 for active user
|
||||
FOREIGN KEY(user_id, local_display_name)
|
||||
REFERENCES display_names(user_id, local_display_name)
|
||||
ON DELETE CASCADE
|
||||
ON DELETE RESTRICT
|
||||
ON UPDATE CASCADE
|
||||
DEFERRABLE INITIALLY DEFERRED
|
||||
);
|
||||
@@ -193,7 +193,8 @@ CREATE TABLE files(
|
||||
protocol TEXT NOT NULL DEFAULT 'smp',
|
||||
file_crypto_key BLOB,
|
||||
file_crypto_nonce BLOB,
|
||||
note_folder_id INTEGER DEFAULT NULL REFERENCES note_folders ON DELETE CASCADE
|
||||
note_folder_id INTEGER DEFAULT NULL REFERENCES note_folders ON DELETE CASCADE,
|
||||
redirect_file_id INTEGER REFERENCES files ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE snd_files(
|
||||
file_id INTEGER NOT NULL REFERENCES files ON DELETE CASCADE,
|
||||
@@ -561,6 +562,7 @@ CREATE TABLE note_folders(
|
||||
favorite INTEGER NOT NULL DEFAULT 0,
|
||||
unread_chat INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE TABLE app_settings(app_settings TEXT NOT NULL);
|
||||
CREATE INDEX contact_profiles_index ON contact_profiles(
|
||||
display_name,
|
||||
full_name
|
||||
@@ -854,3 +856,4 @@ CREATE INDEX idx_chat_items_notes_item_status on chat_items(
|
||||
note_folder_id,
|
||||
item_status
|
||||
);
|
||||
CREATE INDEX idx_files_redirect_file_id on files(redirect_file_id);
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
{-# LANGUAGE OverloadedStrings #-}
|
||||
|
||||
module Simplex.Chat.Store.AppSettings where
|
||||
|
||||
import Control.Monad (join)
|
||||
import Control.Monad.IO.Class (liftIO)
|
||||
import qualified Data.Aeson as J
|
||||
import Data.Maybe (fromMaybe)
|
||||
import Database.SQLite.Simple (Only (..))
|
||||
import Simplex.Chat.AppSettings (AppSettings (..), combineAppSettings, defaultAppSettings, defaultParseAppSettings)
|
||||
import Simplex.Messaging.Agent.Store.SQLite (maybeFirstRow)
|
||||
import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB
|
||||
|
||||
saveAppSettings :: DB.Connection -> AppSettings -> IO ()
|
||||
saveAppSettings db appSettings = do
|
||||
DB.execute_ db "DELETE FROM app_settings"
|
||||
DB.execute db "INSERT INTO app_settings (app_settings) VALUES (?)" (Only $ J.encode appSettings)
|
||||
|
||||
getAppSettings :: DB.Connection -> Maybe AppSettings -> IO AppSettings
|
||||
getAppSettings db platformDefaults = do
|
||||
stored_ <- join <$> liftIO (maybeFirstRow (J.decodeStrict . fromOnly) $ DB.query_ db "SELECT app_settings FROM app_settings")
|
||||
pure $ combineAppSettings (fromMaybe defaultAppSettings platformDefaults) (fromMaybe defaultParseAppSettings stored_)
|
||||
@@ -229,37 +229,45 @@ deleteContactConnectionsAndFiles db userId Contact {contactId} = do
|
||||
(userId, contactId)
|
||||
DB.execute db "DELETE FROM files WHERE user_id = ? AND contact_id = ?" (userId, contactId)
|
||||
|
||||
deleteContact :: DB.Connection -> User -> Contact -> IO ()
|
||||
deleteContact db user@User {userId} Contact {contactId, localDisplayName, activeConn} = do
|
||||
DB.execute db "DELETE FROM chat_items WHERE user_id = ? AND contact_id = ?" (userId, contactId)
|
||||
ctMember :: (Maybe ContactId) <- maybeFirstRow fromOnly $ DB.query db "SELECT contact_id FROM group_members WHERE user_id = ? AND contact_id = ? LIMIT 1" (userId, contactId)
|
||||
if isNothing ctMember
|
||||
then do
|
||||
deleteContactProfile_ db userId contactId
|
||||
DB.execute db "DELETE FROM display_names WHERE user_id = ? AND local_display_name = ?" (userId, localDisplayName)
|
||||
else do
|
||||
currentTs <- getCurrentTime
|
||||
DB.execute db "UPDATE group_members SET contact_id = NULL, updated_at = ? WHERE user_id = ? AND contact_id = ?" (currentTs, userId, contactId)
|
||||
DB.execute db "DELETE FROM contacts WHERE user_id = ? AND contact_id = ?" (userId, contactId)
|
||||
forM_ activeConn $ \Connection {customUserProfileId} ->
|
||||
forM_ customUserProfileId $ \profileId ->
|
||||
deleteUnusedIncognitoProfileById_ db user profileId
|
||||
deleteContact :: DB.Connection -> User -> Contact -> ExceptT StoreError IO ()
|
||||
deleteContact db user@User {userId} ct@Contact {contactId, localDisplayName, activeConn} = do
|
||||
assertNotUser db user ct
|
||||
liftIO $ do
|
||||
DB.execute db "DELETE FROM chat_items WHERE user_id = ? AND contact_id = ?" (userId, contactId)
|
||||
ctMember :: (Maybe ContactId) <- maybeFirstRow fromOnly $ DB.query db "SELECT contact_id FROM group_members WHERE user_id = ? AND contact_id = ? LIMIT 1" (userId, contactId)
|
||||
if isNothing ctMember
|
||||
then do
|
||||
deleteContactProfile_ db userId contactId
|
||||
-- user's local display name already checked in assertNotUser
|
||||
DB.execute db "DELETE FROM display_names WHERE user_id = ? AND local_display_name = ?" (userId, localDisplayName)
|
||||
else do
|
||||
currentTs <- getCurrentTime
|
||||
DB.execute db "UPDATE group_members SET contact_id = NULL, updated_at = ? WHERE user_id = ? AND contact_id = ?" (currentTs, userId, contactId)
|
||||
DB.execute db "DELETE FROM contacts WHERE user_id = ? AND contact_id = ?" (userId, contactId)
|
||||
forM_ activeConn $ \Connection {customUserProfileId} ->
|
||||
forM_ customUserProfileId $ \profileId ->
|
||||
deleteUnusedIncognitoProfileById_ db user profileId
|
||||
|
||||
-- should only be used if contact is not member of any groups
|
||||
deleteContactWithoutGroups :: DB.Connection -> User -> Contact -> IO ()
|
||||
deleteContactWithoutGroups db user@User {userId} Contact {contactId, localDisplayName, activeConn} = do
|
||||
DB.execute db "DELETE FROM chat_items WHERE user_id = ? AND contact_id = ?" (userId, contactId)
|
||||
deleteContactProfile_ db userId contactId
|
||||
DB.execute db "DELETE FROM display_names WHERE user_id = ? AND local_display_name = ?" (userId, localDisplayName)
|
||||
DB.execute db "DELETE FROM contacts WHERE user_id = ? AND contact_id = ?" (userId, contactId)
|
||||
forM_ activeConn $ \Connection {customUserProfileId} ->
|
||||
forM_ customUserProfileId $ \profileId ->
|
||||
deleteUnusedIncognitoProfileById_ db user profileId
|
||||
deleteContactWithoutGroups :: DB.Connection -> User -> Contact -> ExceptT StoreError IO ()
|
||||
deleteContactWithoutGroups db user@User {userId} ct@Contact {contactId, localDisplayName, activeConn} = do
|
||||
assertNotUser db user ct
|
||||
liftIO $ do
|
||||
DB.execute db "DELETE FROM chat_items WHERE user_id = ? AND contact_id = ?" (userId, contactId)
|
||||
deleteContactProfile_ db userId contactId
|
||||
-- user's local display name already checked in assertNotUser
|
||||
DB.execute db "DELETE FROM display_names WHERE user_id = ? AND local_display_name = ?" (userId, localDisplayName)
|
||||
DB.execute db "DELETE FROM contacts WHERE user_id = ? AND contact_id = ?" (userId, contactId)
|
||||
forM_ activeConn $ \Connection {customUserProfileId} ->
|
||||
forM_ customUserProfileId $ \profileId ->
|
||||
deleteUnusedIncognitoProfileById_ db user profileId
|
||||
|
||||
setContactDeleted :: DB.Connection -> User -> Contact -> IO ()
|
||||
setContactDeleted db User {userId} Contact {contactId} = do
|
||||
currentTs <- getCurrentTime
|
||||
DB.execute db "UPDATE contacts SET deleted = 1, updated_at = ? WHERE user_id = ? AND contact_id = ?" (currentTs, userId, contactId)
|
||||
setContactDeleted :: DB.Connection -> User -> Contact -> ExceptT StoreError IO ()
|
||||
setContactDeleted db user@User {userId} ct@Contact {contactId} = do
|
||||
assertNotUser db user ct
|
||||
liftIO $ do
|
||||
currentTs <- getCurrentTime
|
||||
DB.execute db "UPDATE contacts SET deleted = 1, updated_at = ? WHERE user_id = ? AND contact_id = ?" (currentTs, userId, contactId)
|
||||
|
||||
getDeletedContacts :: DB.Connection -> User -> IO [Contact]
|
||||
getDeletedContacts db user@User {userId} = do
|
||||
@@ -320,7 +328,7 @@ updateContactProfile db user@User {userId} c p'
|
||||
ExceptT . withLocalDisplayName db userId newName $ \ldn -> do
|
||||
currentTs <- getCurrentTime
|
||||
updateContactProfile_' db userId profileId p' currentTs
|
||||
updateContactLDN_ db userId contactId localDisplayName ldn currentTs
|
||||
updateContactLDN_ db user contactId localDisplayName ldn currentTs
|
||||
pure $ Right c {localDisplayName = ldn, profile, mergedPreferences}
|
||||
where
|
||||
Contact {contactId, localDisplayName, profile = LocalProfile {profileId, displayName, localAlias}, userPreferences} = c
|
||||
@@ -491,8 +499,8 @@ updateMemberContactProfile_' db userId profileId Profile {displayName, fullName,
|
||||
|]
|
||||
(displayName, fullName, image, updatedAt, userId, profileId)
|
||||
|
||||
updateContactLDN_ :: DB.Connection -> UserId -> Int64 -> ContactName -> ContactName -> UTCTime -> IO ()
|
||||
updateContactLDN_ db userId contactId displayName newName updatedAt = do
|
||||
updateContactLDN_ :: DB.Connection -> User -> Int64 -> ContactName -> ContactName -> UTCTime -> IO ()
|
||||
updateContactLDN_ db user@User {userId} contactId displayName newName updatedAt = do
|
||||
DB.execute
|
||||
db
|
||||
"UPDATE contacts SET local_display_name = ?, updated_at = ? WHERE user_id = ? AND contact_id = ?"
|
||||
@@ -501,7 +509,7 @@ updateContactLDN_ db userId contactId displayName newName updatedAt = do
|
||||
db
|
||||
"UPDATE group_members SET local_display_name = ?, updated_at = ? WHERE user_id = ? AND contact_id = ?"
|
||||
(newName, updatedAt, userId, contactId)
|
||||
DB.execute db "DELETE FROM display_names WHERE local_display_name = ? AND user_id = ?" (displayName, userId)
|
||||
safeDeleteLDN db user displayName
|
||||
|
||||
getContactByName :: DB.Connection -> User -> ContactName -> ExceptT StoreError IO Contact
|
||||
getContactByName db user localDisplayName = do
|
||||
@@ -614,7 +622,7 @@ createOrUpdateContactRequest db user@User {userId} userContactLinkId invId (Vers
|
||||
WHERE user_id = ? AND contact_request_id = ?
|
||||
|]
|
||||
(invId, minV, maxV, ldn, currentTs, userId, cReqId)
|
||||
DB.execute db "DELETE FROM display_names WHERE local_display_name = ? AND user_id = ?" (oldLdn, userId)
|
||||
safeDeleteLDN db user oldLdn
|
||||
where
|
||||
updateProfile currentTs =
|
||||
DB.execute
|
||||
@@ -684,8 +692,9 @@ deleteContactRequest db User {userId} contactRequestId = do
|
||||
SELECT local_display_name FROM contact_requests
|
||||
WHERE user_id = ? AND contact_request_id = ?
|
||||
)
|
||||
AND local_display_name NOT IN (SELECT local_display_name FROM users WHERE user_id = ?)
|
||||
|]
|
||||
(userId, userId, contactRequestId)
|
||||
(userId, userId, contactRequestId, userId)
|
||||
DB.execute db "DELETE FROM contact_requests WHERE user_id = ? AND contact_request_id = ?" (userId, contactRequestId)
|
||||
|
||||
createAcceptedContact :: DB.Connection -> User -> ConnId -> VersionRange -> ContactName -> ProfileId -> Profile -> Int64 -> Maybe XContactId -> Maybe IncognitoProfile -> SubscriptionMode -> Bool -> IO Contact
|
||||
|
||||
@@ -14,7 +14,6 @@ module Simplex.Chat.Store.Files
|
||||
( getLiveSndFileTransfers,
|
||||
getLiveRcvFileTransfers,
|
||||
getPendingSndChunks,
|
||||
createSndDirectFileTransfer,
|
||||
createSndDirectFTConnection,
|
||||
createSndGroupFileTransfer,
|
||||
createSndGroupFileTransferConnection,
|
||||
@@ -39,6 +38,7 @@ module Simplex.Chat.Store.Files
|
||||
getGroupFileIdBySharedMsgId,
|
||||
getDirectFileIdBySharedMsgId,
|
||||
getChatRefByFileId,
|
||||
lookupChatRefByFileId,
|
||||
updateSndFileStatus,
|
||||
createSndFileChunk,
|
||||
updateSndFileChunkMsg,
|
||||
@@ -46,6 +46,7 @@ module Simplex.Chat.Store.Files
|
||||
deleteSndFileChunks,
|
||||
createRcvFileTransfer,
|
||||
createRcvGroupFileTransfer,
|
||||
createRcvStandaloneFileTransfer,
|
||||
appendRcvFD,
|
||||
getRcvFileDescrByRcvFileId,
|
||||
getRcvFileDescrBySndFileId,
|
||||
@@ -70,6 +71,7 @@ module Simplex.Chat.Store.Files
|
||||
getFileTransfer,
|
||||
getFileTransferProgress,
|
||||
getFileTransferMeta,
|
||||
lookupFileTransferRedirectMeta,
|
||||
getSndFileTransfer,
|
||||
getSndFileTransfers,
|
||||
getContactFileInfo,
|
||||
@@ -86,12 +88,14 @@ import Control.Monad
|
||||
import Control.Monad.Except
|
||||
import Control.Monad.IO.Class
|
||||
import Data.Either (rights)
|
||||
import Data.Functor ((<&>))
|
||||
import Data.Int (Int64)
|
||||
import Data.Maybe (fromMaybe, isJust, listToMaybe)
|
||||
import Data.Text (Text)
|
||||
import Data.Time (addUTCTime)
|
||||
import Data.Time.Clock (UTCTime (..), getCurrentTime, nominalDay)
|
||||
import Data.Type.Equality
|
||||
import Data.Word (Word32)
|
||||
import Database.SQLite.Simple (Only (..), (:.) (..))
|
||||
import Database.SQLite.Simple.QQ (sql)
|
||||
import Database.SQLite.Simple.ToField (ToField)
|
||||
@@ -169,23 +173,6 @@ getPendingSndChunks db fileId connId =
|
||||
|]
|
||||
(fileId, connId)
|
||||
|
||||
createSndDirectFileTransfer :: DB.Connection -> UserId -> Contact -> FilePath -> FileInvitation -> Maybe ConnId -> Integer -> SubscriptionMode -> IO FileTransferMeta
|
||||
createSndDirectFileTransfer db userId Contact {contactId} filePath FileInvitation {fileName, fileSize, fileInline} acId_ chunkSize subMode = do
|
||||
currentTs <- getCurrentTime
|
||||
DB.execute
|
||||
db
|
||||
"INSERT INTO files (user_id, contact_id, file_name, file_path, file_size, chunk_size, file_inline, ci_file_status, protocol, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?)"
|
||||
((userId, contactId, fileName, filePath, fileSize, chunkSize) :. (fileInline, CIFSSndStored, FPSMP, currentTs, currentTs))
|
||||
fileId <- insertedRowId db
|
||||
forM_ acId_ $ \acId -> do
|
||||
Connection {connId} <- createSndFileConnection_ db userId fileId acId subMode
|
||||
let fileStatus = FSNew
|
||||
DB.execute
|
||||
db
|
||||
"INSERT INTO snd_files (file_id, file_status, file_inline, connection_id, created_at, updated_at) VALUES (?,?,?,?,?,?)"
|
||||
(fileId, fileStatus, fileInline, connId, currentTs, currentTs)
|
||||
pure FileTransferMeta {fileId, xftpSndFile = Nothing, fileName, filePath, fileSize, fileInline, chunkSize, cancelled = False}
|
||||
|
||||
createSndDirectFTConnection :: DB.Connection -> User -> Int64 -> (CommandId, ConnId) -> SubscriptionMode -> IO ()
|
||||
createSndDirectFTConnection db user@User {userId} fileId (cmdId, acId) subMode = do
|
||||
currentTs <- getCurrentTime
|
||||
@@ -204,7 +191,7 @@ createSndGroupFileTransfer db userId GroupInfo {groupId} filePath FileInvitation
|
||||
"INSERT INTO files (user_id, group_id, file_name, file_path, file_size, chunk_size, file_inline, ci_file_status, protocol, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?)"
|
||||
((userId, groupId, fileName, filePath, fileSize, chunkSize) :. (fileInline, CIFSSndStored, FPSMP, currentTs, currentTs))
|
||||
fileId <- insertedRowId db
|
||||
pure FileTransferMeta {fileId, xftpSndFile = Nothing, fileName, filePath, fileSize, fileInline, chunkSize, cancelled = False}
|
||||
pure FileTransferMeta {fileId, xftpSndFile = Nothing, xftpRedirectFor = Nothing, fileName, filePath, fileSize, fileInline, chunkSize, cancelled = False}
|
||||
|
||||
createSndGroupFileTransferConnection :: DB.Connection -> User -> Int64 -> (CommandId, ConnId) -> GroupMember -> SubscriptionMode -> IO ()
|
||||
createSndGroupFileTransferConnection db user@User {userId} fileId (cmdId, acId) GroupMember {groupMemberId} subMode = do
|
||||
@@ -277,16 +264,16 @@ getSndFTViaMsgDelivery db User {userId} Connection {connId, agentConnId} agentMs
|
||||
(\n -> SndFileTransfer {fileId, fileStatus, fileName, fileSize, chunkSize, filePath, fileDescrId, fileInline, groupMemberId, recipientDisplayName = n, connId, agentConnId})
|
||||
<$> (contactName_ <|> memberName_)
|
||||
|
||||
createSndFileTransferXFTP :: DB.Connection -> User -> ContactOrGroup -> CryptoFile -> FileInvitation -> AgentSndFileId -> Integer -> IO FileTransferMeta
|
||||
createSndFileTransferXFTP db User {userId} contactOrGroup (CryptoFile filePath cryptoArgs) FileInvitation {fileName, fileSize} agentSndFileId chunkSize = do
|
||||
createSndFileTransferXFTP :: DB.Connection -> User -> Maybe ContactOrGroup -> CryptoFile -> FileInvitation -> AgentSndFileId -> Maybe FileTransferId -> Integer -> IO FileTransferMeta
|
||||
createSndFileTransferXFTP db User {userId} contactOrGroup_ (CryptoFile filePath cryptoArgs) FileInvitation {fileName, fileSize} agentSndFileId xftpRedirectFor chunkSize = do
|
||||
currentTs <- getCurrentTime
|
||||
let xftpSndFile = Just XFTPSndFile {agentSndFileId, privateSndFileDescr = Nothing, agentSndFileDeleted = False, cryptoArgs}
|
||||
DB.execute
|
||||
db
|
||||
"INSERT INTO files (contact_id, group_id, user_id, file_name, file_path, file_crypto_key, file_crypto_nonce, file_size, chunk_size, agent_snd_file_id, ci_file_status, protocol, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)"
|
||||
(contactAndGroupIds contactOrGroup :. (userId, fileName, filePath, CF.fileKey <$> cryptoArgs, CF.fileNonce <$> cryptoArgs, fileSize, chunkSize, agentSndFileId, CIFSSndStored, FPXFTP, currentTs, currentTs))
|
||||
"INSERT INTO files (contact_id, group_id, user_id, file_name, file_path, file_crypto_key, file_crypto_nonce, file_size, chunk_size, redirect_file_id, agent_snd_file_id, ci_file_status, protocol, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)"
|
||||
(maybe (Nothing, Nothing) contactAndGroupIds contactOrGroup_ :. (userId, fileName, filePath, CF.fileKey <$> cryptoArgs, CF.fileNonce <$> cryptoArgs, fileSize, chunkSize) :. (xftpRedirectFor, agentSndFileId, CIFSSndStored, FPXFTP, currentTs, currentTs))
|
||||
fileId <- insertedRowId db
|
||||
pure FileTransferMeta {fileId, xftpSndFile, fileName, filePath, fileSize, fileInline = Nothing, chunkSize, cancelled = False}
|
||||
pure FileTransferMeta {fileId, xftpSndFile, xftpRedirectFor, fileName, filePath, fileSize, fileInline = Nothing, chunkSize, cancelled = False}
|
||||
|
||||
createSndFTDescrXFTP :: DB.Connection -> User -> Maybe GroupMember -> Connection -> FileTransferMeta -> FileDescr -> IO ()
|
||||
createSndFTDescrXFTP db User {userId} m Connection {connId} FileTransferMeta {fileId} FileDescr {fileDescrText, fileDescrPartNo, fileDescrComplete} = do
|
||||
@@ -421,11 +408,14 @@ getDirectFileIdBySharedMsgId db User {userId} Contact {contactId} sharedMsgId =
|
||||
(userId, contactId, sharedMsgId)
|
||||
|
||||
getChatRefByFileId :: DB.Connection -> User -> Int64 -> ExceptT StoreError IO ChatRef
|
||||
getChatRefByFileId db User {userId} fileId =
|
||||
liftIO getChatRef >>= \case
|
||||
[(Just contactId, Nothing)] -> pure $ ChatRef CTDirect contactId
|
||||
[(Nothing, Just groupId)] -> pure $ ChatRef CTGroup groupId
|
||||
_ -> throwError $ SEInternalError "could not retrieve chat ref by file id"
|
||||
getChatRefByFileId db user fileId = liftIO (lookupChatRefByFileId db user fileId) >>= maybe (throwError $ SEInternalError "could not retrieve chat ref by file id") pure
|
||||
|
||||
lookupChatRefByFileId :: DB.Connection -> User -> Int64 -> IO (Maybe ChatRef)
|
||||
lookupChatRefByFileId db User {userId} fileId =
|
||||
getChatRef <&> \case
|
||||
[(Just contactId, Nothing)] -> Just $ ChatRef CTDirect contactId
|
||||
[(Nothing, Just groupId)] -> Just $ ChatRef CTGroup groupId
|
||||
_ -> Nothing
|
||||
where
|
||||
getChatRef =
|
||||
DB.query
|
||||
@@ -536,6 +526,23 @@ createRcvGroupFileTransfer db userId GroupMember {groupId, groupMemberId, localD
|
||||
(fileId, FSNew, fileConnReq, fileInline, rcvFileInline, groupMemberId, rfdId, currentTs, currentTs)
|
||||
pure RcvFileTransfer {fileId, xftpRcvFile, fileInvitation = f, fileStatus = RFSNew, rcvFileInline, senderDisplayName = c, chunkSize, cancelled = False, grpMemberId = Just groupMemberId, cryptoArgs = Nothing}
|
||||
|
||||
createRcvStandaloneFileTransfer :: DB.Connection -> UserId -> CryptoFile -> Int64 -> Word32 -> ExceptT StoreError IO Int64
|
||||
createRcvStandaloneFileTransfer db userId (CryptoFile filePath cfArgs_) fileSize chunkSize = do
|
||||
currentTs <- liftIO getCurrentTime
|
||||
fileId <- liftIO $ do
|
||||
DB.execute
|
||||
db
|
||||
"INSERT INTO files (user_id, file_name, file_path, file_size, chunk_size, ci_file_status, protocol, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?)"
|
||||
(userId, takeFileName filePath, filePath, fileSize, chunkSize, CIFSRcvInvitation, FPXFTP, currentTs, currentTs)
|
||||
insertedRowId db
|
||||
liftIO . forM_ cfArgs_ $ \cfArgs -> setFileCryptoArgs_ db fileId cfArgs currentTs
|
||||
liftIO $
|
||||
DB.execute
|
||||
db
|
||||
"INSERT INTO rcv_files (file_id, file_status, created_at, updated_at) VALUES (?,?,?,?)"
|
||||
(fileId, FSNew, currentTs, currentTs)
|
||||
pure fileId
|
||||
|
||||
createRcvFD_ :: DB.Connection -> UserId -> UTCTime -> FileDescr -> ExceptT StoreError IO RcvFileDescr
|
||||
createRcvFD_ db userId currentTs FileDescr {fileDescrText, fileDescrPartNo, fileDescrComplete} = do
|
||||
when (fileDescrPartNo /= 0) $ throwError SERcvFileInvalidDescrPart
|
||||
@@ -662,9 +669,9 @@ getRcvFileTransfer_ db userId fileId = do
|
||||
(FileStatus, Maybe ConnReqInvitation, Maybe Int64, String, Integer, Integer, Maybe Bool) :. (Maybe ContactName, Maybe ContactName, Maybe FilePath, Maybe C.SbKey, Maybe C.CbNonce, Maybe InlineFileMode, Maybe InlineFileMode, Maybe AgentRcvFileId, Bool) :. (Maybe Int64, Maybe AgentConnId) ->
|
||||
ExceptT StoreError IO RcvFileTransfer
|
||||
rcvFileTransfer rfd_ ((fileStatus', fileConnReq, grpMemberId, fileName, fileSize, chunkSize, cancelled_) :. (contactName_, memberName_, filePath_, fileKey, fileNonce, fileInline, rcvFileInline, agentRcvFileId, agentRcvFileDeleted) :. (connId_, agentConnId_)) =
|
||||
case contactName_ <|> memberName_ of
|
||||
case contactName_ <|> memberName_ <|> standaloneName_ of
|
||||
Nothing -> throwError $ SERcvFileInvalid fileId
|
||||
Just name -> do
|
||||
Just name ->
|
||||
case fileStatus' of
|
||||
FSNew -> pure $ ft name RFSNew
|
||||
FSAccepted -> ft name . RFSAccepted <$> rfi
|
||||
@@ -672,6 +679,9 @@ getRcvFileTransfer_ db userId fileId = do
|
||||
FSComplete -> ft name . RFSComplete <$> rfi
|
||||
FSCancelled -> ft name . RFSCancelled <$> rfi_
|
||||
where
|
||||
standaloneName_ = case (connId_, agentRcvFileId, filePath_) of
|
||||
(Nothing, Just _, Just _) -> Just "" -- filePath marks files that are accepted from contact or, in this case, set by createRcvDirectFileTransfer
|
||||
_ -> Nothing
|
||||
ft senderDisplayName fileStatus =
|
||||
let fileInvitation = FileInvitation {fileName, fileSize, fileDigest = Nothing, fileConnReq, fileInline, fileDescr = Nothing}
|
||||
cryptoArgs = CFArgs <$> fileKey <*> fileNonce
|
||||
@@ -906,17 +916,22 @@ getFileTransferMeta_ db userId fileId =
|
||||
DB.query
|
||||
db
|
||||
[sql|
|
||||
SELECT file_name, file_size, chunk_size, file_path, file_crypto_key, file_crypto_nonce, file_inline, agent_snd_file_id, agent_snd_file_deleted, private_snd_file_descr, cancelled
|
||||
SELECT file_name, file_size, chunk_size, file_path, file_crypto_key, file_crypto_nonce, file_inline, agent_snd_file_id, agent_snd_file_deleted, private_snd_file_descr, cancelled, redirect_file_id
|
||||
FROM files
|
||||
WHERE user_id = ? AND file_id = ?
|
||||
|]
|
||||
(userId, fileId)
|
||||
where
|
||||
fileTransferMeta :: (String, Integer, Integer, FilePath, Maybe C.SbKey, Maybe C.CbNonce, Maybe InlineFileMode, Maybe AgentSndFileId, Bool, Maybe Text, Maybe Bool) -> FileTransferMeta
|
||||
fileTransferMeta (fileName, fileSize, chunkSize, filePath, fileKey, fileNonce, fileInline, aSndFileId_, agentSndFileDeleted, privateSndFileDescr, cancelled_) =
|
||||
fileTransferMeta :: (String, Integer, Integer, FilePath, Maybe C.SbKey, Maybe C.CbNonce, Maybe InlineFileMode, Maybe AgentSndFileId, Bool, Maybe Text, Maybe Bool, Maybe FileTransferId) -> FileTransferMeta
|
||||
fileTransferMeta (fileName, fileSize, chunkSize, filePath, fileKey, fileNonce, fileInline, aSndFileId_, agentSndFileDeleted, privateSndFileDescr, cancelled_, xftpRedirectFor) =
|
||||
let cryptoArgs = CFArgs <$> fileKey <*> fileNonce
|
||||
xftpSndFile = (\fId -> XFTPSndFile {agentSndFileId = fId, privateSndFileDescr, agentSndFileDeleted, cryptoArgs}) <$> aSndFileId_
|
||||
in FileTransferMeta {fileId, xftpSndFile, fileName, fileSize, chunkSize, filePath, fileInline, cancelled = fromMaybe False cancelled_}
|
||||
in FileTransferMeta {fileId, xftpSndFile, xftpRedirectFor, fileName, fileSize, chunkSize, filePath, fileInline, cancelled = fromMaybe False cancelled_}
|
||||
|
||||
lookupFileTransferRedirectMeta :: DB.Connection -> User -> Int64 -> IO [FileTransferMeta]
|
||||
lookupFileTransferRedirectMeta db User {userId} fileId = do
|
||||
redirects <- DB.query db "SELECT file_id FROM files WHERE user_id = ? AND redirect_file_id = ?" (userId, fileId)
|
||||
rights <$> mapM (runExceptT . getFileTransferMeta_ db userId . fromOnly) redirects
|
||||
|
||||
createLocalFile :: ToField (CIFileStatus d) => CIFileStatus d -> DB.Connection -> User -> NoteFolder -> ChatItemId -> UTCTime -> CryptoFile -> Integer -> Integer -> IO Int64
|
||||
createLocalFile fileStatus db User {userId} NoteFolder {noteFolderId} chatItemId itemTs CryptoFile {filePath, cryptoArgs} fileSize fileChunkSize = do
|
||||
|
||||
@@ -225,8 +225,9 @@ deleteGroupLink db User {userId} GroupInfo {groupId} = do
|
||||
JOIN user_contact_links uc USING (user_contact_link_id)
|
||||
WHERE uc.user_id = ? AND uc.group_id = ?
|
||||
)
|
||||
AND local_display_name NOT IN (SELECT local_display_name FROM users WHERE user_id = ?)
|
||||
|]
|
||||
(userId, userId, groupId)
|
||||
(userId, userId, groupId, userId)
|
||||
DB.execute
|
||||
db
|
||||
[sql|
|
||||
@@ -586,7 +587,7 @@ deleteGroup :: DB.Connection -> User -> GroupInfo -> IO ()
|
||||
deleteGroup db user@User {userId} g@GroupInfo {groupId, localDisplayName} = do
|
||||
deleteGroupProfile_ db userId groupId
|
||||
DB.execute db "DELETE FROM groups WHERE user_id = ? AND group_id = ?" (userId, groupId)
|
||||
DB.execute db "DELETE FROM display_names WHERE user_id = ? AND local_display_name = ?" (userId, localDisplayName)
|
||||
safeDeleteLDN db user localDisplayName
|
||||
forM_ (incognitoMembershipProfile g) $ deleteUnusedIncognitoProfileById_ db user . localProfileId
|
||||
|
||||
deleteGroupProfile_ :: DB.Connection -> UserId -> GroupId -> IO ()
|
||||
@@ -1044,14 +1045,14 @@ deleteGroupMember db user@User {userId} m@GroupMember {groupMemberId, groupId, m
|
||||
when (memberIncognito m) $ deleteUnusedIncognitoProfileById_ db user $ localProfileId memberProfile
|
||||
|
||||
cleanupMemberProfileAndName_ :: DB.Connection -> User -> GroupMember -> IO ()
|
||||
cleanupMemberProfileAndName_ db User {userId} GroupMember {groupMemberId, memberContactId, memberContactProfileId, localDisplayName} =
|
||||
cleanupMemberProfileAndName_ db user@User {userId} GroupMember {groupMemberId, memberContactId, memberContactProfileId, localDisplayName} =
|
||||
-- check record has no memberContactId (contact_id) - it means contact has been deleted and doesn't use profile & ldn
|
||||
when (isNothing memberContactId) $ do
|
||||
-- check other group member records don't use profile & ldn
|
||||
sameProfileMember :: (Maybe GroupMemberId) <- maybeFirstRow fromOnly $ DB.query db "SELECT group_member_id FROM group_members WHERE user_id = ? AND contact_profile_id = ? AND group_member_id != ? LIMIT 1" (userId, memberContactProfileId, groupMemberId)
|
||||
when (isNothing sameProfileMember) $ do
|
||||
DB.execute db "DELETE FROM contact_profiles WHERE user_id = ? AND contact_profile_id = ?" (userId, memberContactProfileId)
|
||||
DB.execute db "DELETE FROM display_names WHERE user_id = ? AND local_display_name = ?" (userId, localDisplayName)
|
||||
safeDeleteLDN db user localDisplayName
|
||||
|
||||
deleteGroupMemberConnection :: DB.Connection -> User -> GroupMember -> IO ()
|
||||
deleteGroupMemberConnection db User {userId} GroupMember {groupMemberId} =
|
||||
@@ -1330,7 +1331,7 @@ getViaGroupContact db user@User {userId} GroupMember {groupMemberId} = do
|
||||
maybe (pure Nothing) (fmap eitherToMaybe . runExceptT . getContact db user) contactId_
|
||||
|
||||
updateGroupProfile :: DB.Connection -> User -> GroupInfo -> GroupProfile -> ExceptT StoreError IO GroupInfo
|
||||
updateGroupProfile db User {userId} g@GroupInfo {groupId, localDisplayName, groupProfile = GroupProfile {displayName}} p'@GroupProfile {displayName = newName, fullName, description, image, groupPreferences}
|
||||
updateGroupProfile db user@User {userId} g@GroupInfo {groupId, localDisplayName, groupProfile = GroupProfile {displayName}} p'@GroupProfile {displayName = newName, fullName, description, image, groupPreferences}
|
||||
| displayName == newName = liftIO $ do
|
||||
currentTs <- getCurrentTime
|
||||
updateGroupProfile_ currentTs
|
||||
@@ -1361,7 +1362,7 @@ updateGroupProfile db User {userId} g@GroupInfo {groupId, localDisplayName, grou
|
||||
db
|
||||
"UPDATE groups SET local_display_name = ?, updated_at = ? WHERE user_id = ? AND group_id = ?"
|
||||
(ldn, currentTs, userId, groupId)
|
||||
DB.execute db "DELETE FROM display_names WHERE local_display_name = ? AND user_id = ?" (localDisplayName, userId)
|
||||
safeDeleteLDN db user localDisplayName
|
||||
|
||||
getGroupInfo :: DB.Connection -> VersionRange -> User -> Int64 -> ExceptT StoreError IO GroupInfo
|
||||
getGroupInfo db vr User {userId, userContactId} groupId =
|
||||
@@ -1464,7 +1465,7 @@ getMatchingContacts db user@User {userId} Contact {contactId, profile = LocalPro
|
||||
FROM contacts ct
|
||||
JOIN contact_profiles p ON ct.contact_profile_id = p.contact_profile_id
|
||||
WHERE ct.user_id = ? AND ct.contact_id != ?
|
||||
AND ct.contact_status = ? AND ct.deleted = 0
|
||||
AND ct.contact_status = ? AND ct.deleted = 0 AND ct.is_user = 0
|
||||
AND p.display_name = ? AND p.full_name = ?
|
||||
|]
|
||||
|
||||
@@ -1502,7 +1503,7 @@ getMatchingMemberContacts db user@User {userId} GroupMember {memberProfile = Loc
|
||||
FROM contacts ct
|
||||
JOIN contact_profiles p ON ct.contact_profile_id = p.contact_profile_id
|
||||
WHERE ct.user_id = ?
|
||||
AND ct.contact_status = ? AND ct.deleted = 0
|
||||
AND ct.contact_status = ? AND ct.deleted = 0 AND ct.is_user = 0
|
||||
AND p.display_name = ? AND p.full_name = ?
|
||||
|]
|
||||
|
||||
@@ -1615,6 +1616,8 @@ mergeContactRecords db user@User {userId} to@Contact {localDisplayName = keepLDN
|
||||
let (toCt, fromCt) = toFromContacts to from
|
||||
Contact {contactId = toContactId, localDisplayName = toLDN} = toCt
|
||||
Contact {contactId = fromContactId, localDisplayName = fromLDN} = fromCt
|
||||
assertNotUser db user toCt
|
||||
assertNotUser db user fromCt
|
||||
liftIO $ do
|
||||
currentTs <- getCurrentTime
|
||||
-- next query fixes incorrect unused contacts deletion
|
||||
@@ -2018,7 +2021,7 @@ createMemberContactConn_
|
||||
pure Connection {connId, agentConnId = AgentConnId acId, peerChatVRange, connType = ConnContact, contactConnInitiated = False, entityId = Just contactId, viaContact = Nothing, viaUserContactLink = Nothing, viaGroupLink = False, groupLinkId = Nothing, customUserProfileId, connLevel, connStatus = ConnJoined, localAlias = "", createdAt = currentTs, connectionCode = Nothing, authErrCounter = 0}
|
||||
|
||||
updateMemberProfile :: DB.Connection -> User -> GroupMember -> Profile -> ExceptT StoreError IO GroupMember
|
||||
updateMemberProfile db User {userId} m p'
|
||||
updateMemberProfile db user@User {userId} m p'
|
||||
| displayName == newName = do
|
||||
liftIO $ updateMemberContactProfileReset_ db userId profileId p'
|
||||
pure m {memberProfile = profile}
|
||||
@@ -2030,7 +2033,7 @@ updateMemberProfile db User {userId} m p'
|
||||
db
|
||||
"UPDATE group_members SET local_display_name = ?, updated_at = ? WHERE user_id = ? AND group_member_id = ?"
|
||||
(ldn, currentTs, userId, groupMemberId)
|
||||
DB.execute db "DELETE FROM display_names WHERE local_display_name = ? AND user_id = ?" (localDisplayName, userId)
|
||||
safeDeleteLDN db user localDisplayName
|
||||
pure $ Right m {localDisplayName = ldn, memberProfile = profile}
|
||||
where
|
||||
GroupMember {groupMemberId, localDisplayName, memberProfile = LocalProfile {profileId, displayName, localAlias}} = m
|
||||
@@ -2038,7 +2041,7 @@ updateMemberProfile db User {userId} m p'
|
||||
profile = toLocalProfile profileId p' localAlias
|
||||
|
||||
updateContactMemberProfile :: DB.Connection -> User -> GroupMember -> Contact -> Profile -> ExceptT StoreError IO (GroupMember, Contact)
|
||||
updateContactMemberProfile db User {userId} m ct@Contact {contactId} p'
|
||||
updateContactMemberProfile db user@User {userId} m ct@Contact {contactId} p'
|
||||
| displayName == newName = do
|
||||
liftIO $ updateMemberContactProfile_ db userId profileId p'
|
||||
pure (m {memberProfile = profile}, ct {profile} :: Contact)
|
||||
@@ -2046,7 +2049,7 @@ updateContactMemberProfile db User {userId} m ct@Contact {contactId} p'
|
||||
ExceptT . withLocalDisplayName db userId newName $ \ldn -> do
|
||||
currentTs <- getCurrentTime
|
||||
updateMemberContactProfile_' db userId profileId p' currentTs
|
||||
updateContactLDN_ db userId contactId localDisplayName ldn currentTs
|
||||
updateContactLDN_ db user contactId localDisplayName ldn currentTs
|
||||
pure $ Right (m {localDisplayName = ldn, memberProfile = profile}, ct {localDisplayName = ldn, profile} :: Contact)
|
||||
where
|
||||
GroupMember {localDisplayName, memberProfile = LocalProfile {profileId, displayName, localAlias}} = m
|
||||
|
||||
@@ -39,7 +39,7 @@ module Simplex.Chat.Store.Messages
|
||||
getDirectChat,
|
||||
getGroupChat,
|
||||
getLocalChat,
|
||||
getDirectChatItemsLast,
|
||||
getDirectChatItemLast,
|
||||
getAllChatItems,
|
||||
getAChatItem,
|
||||
updateDirectChatItem,
|
||||
@@ -92,6 +92,7 @@ module Simplex.Chat.Store.Messages
|
||||
getLocalChatItemIdByText,
|
||||
getLocalChatItemIdByText',
|
||||
getChatItemByFileId,
|
||||
lookupChatItemByFileId,
|
||||
getChatItemByGroupId,
|
||||
updateDirectChatItemStatus,
|
||||
getTimedItems,
|
||||
@@ -125,6 +126,7 @@ import Data.List (sortBy)
|
||||
import Data.Maybe (fromMaybe, isJust, mapMaybe)
|
||||
import Data.Ord (Down (..), comparing)
|
||||
import Data.Text (Text)
|
||||
import qualified Data.Text as T
|
||||
import Data.Time (addUTCTime)
|
||||
import Data.Time.Clock (UTCTime (..), getCurrentTime)
|
||||
import Database.SQLite.Simple (NamedParam (..), Only (..), Query, (:.) (..))
|
||||
@@ -828,7 +830,7 @@ toLocalChatItem currentTs ((itemId, itemTs, AMsgDirection msgDir, itemContentTex
|
||||
cItem :: MsgDirectionI d => SMsgDirection d -> CIDirection 'CTLocal d -> CIStatus d -> CIContent d -> Maybe (CIFile d) -> CChatItem 'CTLocal
|
||||
cItem d chatDir ciStatus content file =
|
||||
CChatItem d ChatItem {chatDir, meta = ciMeta content ciStatus, content, formattedText = parseMaybeMarkdownList itemText, quotedItem = Nothing, reactions = [], file}
|
||||
badItem = Left $ SEBadChatItem itemId
|
||||
badItem = Left $ SEBadChatItem itemId (Just itemTs)
|
||||
ciMeta :: CIContent d -> CIStatus d -> CIMeta 'CTLocal d
|
||||
ciMeta content status =
|
||||
let itemDeleted' = case itemDeleted of
|
||||
@@ -922,97 +924,118 @@ getDirectChat :: DB.Connection -> User -> Int64 -> ChatPagination -> Maybe Strin
|
||||
getDirectChat db user contactId pagination search_ = do
|
||||
let search = fromMaybe "" search_
|
||||
ct <- getContact db user contactId
|
||||
liftIO . getDirectChatReactions_ db ct =<< case pagination of
|
||||
liftIO $ case pagination of
|
||||
CPLast count -> getDirectChatLast_ db user ct count search
|
||||
CPAfter afterId count -> getDirectChatAfter_ db user ct afterId count search
|
||||
CPBefore beforeId count -> getDirectChatBefore_ db user ct beforeId count search
|
||||
|
||||
getDirectChatLast_ :: DB.Connection -> User -> Contact -> Int -> String -> ExceptT StoreError IO (Chat 'CTDirect)
|
||||
getDirectChatLast_ db user ct@Contact {contactId} count search = do
|
||||
let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False}
|
||||
chatItems <- getDirectChatItemsLast db user contactId count search
|
||||
pure $ Chat (DirectChat ct) (reverse chatItems) stats
|
||||
|
||||
-- the last items in reverse order (the last item in the conversation is the first in the returned list)
|
||||
getDirectChatItemsLast :: DB.Connection -> User -> ContactId -> Int -> String -> ExceptT StoreError IO [CChatItem 'CTDirect]
|
||||
getDirectChatItemsLast db User {userId} contactId count search = ExceptT $ do
|
||||
currentTs <- getCurrentTime
|
||||
mapM (toDirectChatItem currentTs)
|
||||
<$> DB.query
|
||||
db
|
||||
[sql|
|
||||
SELECT
|
||||
-- ChatItem
|
||||
i.chat_item_id, i.item_ts, i.item_sent, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_deleted_ts, i.item_edited, i.created_at, i.updated_at, i.timed_ttl, i.timed_delete_at, i.item_live,
|
||||
-- CIFile
|
||||
f.file_id, f.file_name, f.file_size, f.file_path, f.file_crypto_key, f.file_crypto_nonce, f.ci_file_status, f.protocol,
|
||||
-- DirectQuote
|
||||
ri.chat_item_id, i.quoted_shared_msg_id, i.quoted_sent_at, i.quoted_content, i.quoted_sent
|
||||
FROM chat_items i
|
||||
LEFT JOIN files f ON f.chat_item_id = i.chat_item_id
|
||||
LEFT JOIN chat_items ri ON ri.user_id = i.user_id AND ri.contact_id = i.contact_id AND ri.shared_msg_id = i.quoted_shared_msg_id
|
||||
WHERE i.user_id = ? AND i.contact_id = ? AND i.item_text LIKE '%' || ? || '%'
|
||||
ORDER BY i.created_at DESC, i.chat_item_id DESC
|
||||
LIMIT ?
|
||||
|]
|
||||
(userId, contactId, search, count)
|
||||
|
||||
getDirectChatAfter_ :: DB.Connection -> User -> Contact -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTDirect)
|
||||
getDirectChatAfter_ db User {userId} ct@Contact {contactId} afterChatItemId count search = do
|
||||
getDirectChatLast_ :: DB.Connection -> User -> Contact -> Int -> String -> IO (Chat 'CTDirect)
|
||||
getDirectChatLast_ db user@User {userId} ct@Contact {contactId} count search = do
|
||||
let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False}
|
||||
chatItems <- ExceptT getDirectChatItemsAfter_
|
||||
pure $ Chat (DirectChat ct) chatItems stats
|
||||
chatItemIds <- getDirectChatItemIdsLast_
|
||||
currentTs <- getCurrentTime
|
||||
chatItems <- mapM (safeGetDirectItem db user ct currentTs) chatItemIds
|
||||
pure $ Chat (DirectChat ct) (reverse chatItems) stats
|
||||
where
|
||||
getDirectChatItemsAfter_ :: IO (Either StoreError [CChatItem 'CTDirect])
|
||||
getDirectChatItemsAfter_ = do
|
||||
currentTs <- getCurrentTime
|
||||
mapM (toDirectChatItem currentTs)
|
||||
getDirectChatItemIdsLast_ :: IO [ChatItemId]
|
||||
getDirectChatItemIdsLast_ =
|
||||
map fromOnly
|
||||
<$> DB.query
|
||||
db
|
||||
[sql|
|
||||
SELECT
|
||||
-- ChatItem
|
||||
i.chat_item_id, i.item_ts, i.item_sent, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_deleted_ts, i.item_edited, i.created_at, i.updated_at, i.timed_ttl, i.timed_delete_at, i.item_live,
|
||||
-- CIFile
|
||||
f.file_id, f.file_name, f.file_size, f.file_path, f.file_crypto_key, f.file_crypto_nonce, f.ci_file_status, f.protocol,
|
||||
-- DirectQuote
|
||||
ri.chat_item_id, i.quoted_shared_msg_id, i.quoted_sent_at, i.quoted_content, i.quoted_sent
|
||||
FROM chat_items i
|
||||
LEFT JOIN files f ON f.chat_item_id = i.chat_item_id
|
||||
LEFT JOIN chat_items ri ON ri.user_id = i.user_id AND ri.contact_id = i.contact_id AND ri.shared_msg_id = i.quoted_shared_msg_id
|
||||
WHERE i.user_id = ? AND i.contact_id = ? AND i.item_text LIKE '%' || ? || '%'
|
||||
AND i.chat_item_id > ?
|
||||
ORDER BY i.created_at ASC, i.chat_item_id ASC
|
||||
SELECT chat_item_id
|
||||
FROM chat_items
|
||||
WHERE user_id = ? AND contact_id = ? AND item_text LIKE '%' || ? || '%'
|
||||
ORDER BY created_at DESC, chat_item_id DESC
|
||||
LIMIT ?
|
||||
|]
|
||||
(userId, contactId, search, count)
|
||||
|
||||
safeGetDirectItem :: DB.Connection -> User -> Contact -> UTCTime -> ChatItemId -> IO (CChatItem 'CTDirect)
|
||||
safeGetDirectItem db user ct currentTs itemId =
|
||||
runExceptT (getDirectCIWithReactions db user ct itemId)
|
||||
>>= pure <$> safeToDirectItem currentTs itemId
|
||||
|
||||
safeToDirectItem :: UTCTime -> ChatItemId -> Either StoreError (CChatItem 'CTDirect) -> CChatItem 'CTDirect
|
||||
safeToDirectItem currentTs itemId = \case
|
||||
Right ci -> ci
|
||||
Left e@(SEBadChatItem _ (Just itemTs)) -> badDirectItem itemTs e
|
||||
Left e -> badDirectItem currentTs e
|
||||
where
|
||||
badDirectItem :: UTCTime -> StoreError -> CChatItem 'CTDirect
|
||||
badDirectItem ts e =
|
||||
let errorText = T.pack $ show e
|
||||
in CChatItem
|
||||
SMDSnd
|
||||
ChatItem
|
||||
{ chatDir = CIDirectSnd,
|
||||
meta = dummyMeta itemId ts errorText,
|
||||
content = CIInvalidJSON errorText,
|
||||
formattedText = Nothing,
|
||||
quotedItem = Nothing,
|
||||
reactions = [],
|
||||
file = Nothing
|
||||
}
|
||||
|
||||
getDirectChatItemLast :: DB.Connection -> User -> ContactId -> ExceptT StoreError IO (CChatItem 'CTDirect)
|
||||
getDirectChatItemLast db user@User {userId} contactId = do
|
||||
chatItemId <-
|
||||
ExceptT . firstRow fromOnly (SEChatItemNotFoundByContactId contactId) $
|
||||
DB.query
|
||||
db
|
||||
[sql|
|
||||
SELECT chat_item_id
|
||||
FROM chat_items
|
||||
WHERE user_id = ? AND contact_id = ?
|
||||
ORDER BY created_at DESC, chat_item_id DESC
|
||||
LIMIT 1
|
||||
|]
|
||||
(userId, contactId)
|
||||
getDirectChatItem db user contactId chatItemId
|
||||
|
||||
getDirectChatAfter_ :: DB.Connection -> User -> Contact -> ChatItemId -> Int -> String -> IO (Chat 'CTDirect)
|
||||
getDirectChatAfter_ db user@User {userId} ct@Contact {contactId} afterChatItemId count search = do
|
||||
let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False}
|
||||
chatItemIds <- getDirectChatItemIdsAfter_
|
||||
currentTs <- getCurrentTime
|
||||
chatItems <- mapM (safeGetDirectItem db user ct currentTs) chatItemIds
|
||||
pure $ Chat (DirectChat ct) chatItems stats
|
||||
where
|
||||
getDirectChatItemIdsAfter_ :: IO [ChatItemId]
|
||||
getDirectChatItemIdsAfter_ =
|
||||
map fromOnly
|
||||
<$> DB.query
|
||||
db
|
||||
[sql|
|
||||
SELECT chat_item_id
|
||||
FROM chat_items
|
||||
WHERE user_id = ? AND contact_id = ? AND item_text LIKE '%' || ? || '%'
|
||||
AND chat_item_id > ?
|
||||
ORDER BY created_at ASC, chat_item_id ASC
|
||||
LIMIT ?
|
||||
|]
|
||||
(userId, contactId, search, afterChatItemId, count)
|
||||
|
||||
getDirectChatBefore_ :: DB.Connection -> User -> Contact -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTDirect)
|
||||
getDirectChatBefore_ db User {userId} ct@Contact {contactId} beforeChatItemId count search = do
|
||||
getDirectChatBefore_ :: DB.Connection -> User -> Contact -> ChatItemId -> Int -> String -> IO (Chat 'CTDirect)
|
||||
getDirectChatBefore_ db user@User {userId} ct@Contact {contactId} beforeChatItemId count search = do
|
||||
let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False}
|
||||
chatItems <- ExceptT getDirectChatItemsBefore_
|
||||
chatItemIds <- getDirectChatItemsIdsBefore_
|
||||
currentTs <- getCurrentTime
|
||||
chatItems <- mapM (safeGetDirectItem db user ct currentTs) chatItemIds
|
||||
pure $ Chat (DirectChat ct) (reverse chatItems) stats
|
||||
where
|
||||
getDirectChatItemsBefore_ :: IO (Either StoreError [CChatItem 'CTDirect])
|
||||
getDirectChatItemsBefore_ = do
|
||||
currentTs <- getCurrentTime
|
||||
mapM (toDirectChatItem currentTs)
|
||||
getDirectChatItemsIdsBefore_ :: IO [ChatItemId]
|
||||
getDirectChatItemsIdsBefore_ =
|
||||
map fromOnly
|
||||
<$> DB.query
|
||||
db
|
||||
[sql|
|
||||
SELECT
|
||||
-- ChatItem
|
||||
i.chat_item_id, i.item_ts, i.item_sent, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_deleted_ts, i.item_edited, i.created_at, i.updated_at, i.timed_ttl, i.timed_delete_at, i.item_live,
|
||||
-- CIFile
|
||||
f.file_id, f.file_name, f.file_size, f.file_path, f.file_crypto_key, f.file_crypto_nonce, f.ci_file_status, f.protocol,
|
||||
-- DirectQuote
|
||||
ri.chat_item_id, i.quoted_shared_msg_id, i.quoted_sent_at, i.quoted_content, i.quoted_sent
|
||||
FROM chat_items i
|
||||
LEFT JOIN files f ON f.chat_item_id = i.chat_item_id
|
||||
LEFT JOIN chat_items ri ON ri.user_id = i.user_id AND ri.contact_id = i.contact_id AND ri.shared_msg_id = i.quoted_shared_msg_id
|
||||
WHERE i.user_id = ? AND i.contact_id = ? AND i.item_text LIKE '%' || ? || '%'
|
||||
AND i.chat_item_id < ?
|
||||
ORDER BY i.created_at DESC, i.chat_item_id DESC
|
||||
SELECT chat_item_id
|
||||
FROM chat_items
|
||||
WHERE user_id = ? AND contact_id = ? AND item_text LIKE '%' || ? || '%'
|
||||
AND chat_item_id < ?
|
||||
ORDER BY created_at DESC, chat_item_id DESC
|
||||
LIMIT ?
|
||||
|]
|
||||
(userId, contactId, search, beforeChatItemId, count)
|
||||
@@ -1022,15 +1045,16 @@ getGroupChat db vr user groupId pagination search_ = do
|
||||
let search = fromMaybe "" search_
|
||||
g <- getGroupInfo db vr user groupId
|
||||
case pagination of
|
||||
CPLast count -> getGroupChatLast_ db user g count search
|
||||
CPLast count -> liftIO $ getGroupChatLast_ db user g count search
|
||||
CPAfter afterId count -> getGroupChatAfter_ db user g afterId count search
|
||||
CPBefore beforeId count -> getGroupChatBefore_ db user g beforeId count search
|
||||
|
||||
getGroupChatLast_ :: DB.Connection -> User -> GroupInfo -> Int -> String -> ExceptT StoreError IO (Chat 'CTGroup)
|
||||
getGroupChatLast_ :: DB.Connection -> User -> GroupInfo -> Int -> String -> IO (Chat 'CTGroup)
|
||||
getGroupChatLast_ db user@User {userId} g@GroupInfo {groupId} count search = do
|
||||
let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False}
|
||||
chatItemIds <- liftIO getGroupChatItemIdsLast_
|
||||
chatItems <- mapM (getGroupCIWithReactions db user g) chatItemIds
|
||||
chatItemIds <- getGroupChatItemIdsLast_
|
||||
currentTs <- getCurrentTime
|
||||
chatItems <- mapM (safeGetGroupItem db user g currentTs) chatItemIds
|
||||
pure $ Chat (GroupChat g) (reverse chatItems) stats
|
||||
where
|
||||
getGroupChatItemIdsLast_ :: IO [ChatItemId]
|
||||
@@ -1047,6 +1071,32 @@ getGroupChatLast_ db user@User {userId} g@GroupInfo {groupId} count search = do
|
||||
|]
|
||||
(userId, groupId, search, count)
|
||||
|
||||
safeGetGroupItem :: DB.Connection -> User -> GroupInfo -> UTCTime -> ChatItemId -> IO (CChatItem 'CTGroup)
|
||||
safeGetGroupItem db user g currentTs itemId =
|
||||
runExceptT (getGroupCIWithReactions db user g itemId)
|
||||
>>= pure <$> safeToGroupItem currentTs itemId
|
||||
|
||||
safeToGroupItem :: UTCTime -> ChatItemId -> Either StoreError (CChatItem 'CTGroup) -> CChatItem 'CTGroup
|
||||
safeToGroupItem currentTs itemId = \case
|
||||
Right ci -> ci
|
||||
Left e@(SEBadChatItem _ (Just itemTs)) -> badGroupItem itemTs e
|
||||
Left e -> badGroupItem currentTs e
|
||||
where
|
||||
badGroupItem :: UTCTime -> StoreError -> CChatItem 'CTGroup
|
||||
badGroupItem ts e =
|
||||
let errorText = T.pack $ show e
|
||||
in CChatItem
|
||||
SMDSnd
|
||||
ChatItem
|
||||
{ chatDir = CIGroupSnd,
|
||||
meta = dummyMeta itemId ts errorText,
|
||||
content = CIInvalidJSON errorText,
|
||||
formattedText = Nothing,
|
||||
quotedItem = Nothing,
|
||||
reactions = [],
|
||||
file = Nothing
|
||||
}
|
||||
|
||||
getGroupMemberChatItemLast :: DB.Connection -> User -> GroupId -> GroupMemberId -> ExceptT StoreError IO (CChatItem 'CTGroup)
|
||||
getGroupMemberChatItemLast db user@User {userId} groupId groupMemberId = do
|
||||
chatItemId <-
|
||||
@@ -1068,7 +1118,8 @@ getGroupChatAfter_ db user@User {userId} g@GroupInfo {groupId} afterChatItemId c
|
||||
let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False}
|
||||
afterChatItem <- getGroupChatItem db user groupId afterChatItemId
|
||||
chatItemIds <- liftIO $ getGroupChatItemIdsAfter_ (chatItemTs afterChatItem)
|
||||
chatItems <- mapM (getGroupCIWithReactions db user g) chatItemIds
|
||||
currentTs <- liftIO getCurrentTime
|
||||
chatItems <- liftIO $ mapM (safeGetGroupItem db user g currentTs) chatItemIds
|
||||
pure $ Chat (GroupChat g) chatItems stats
|
||||
where
|
||||
getGroupChatItemIdsAfter_ :: UTCTime -> IO [ChatItemId]
|
||||
@@ -1091,7 +1142,8 @@ getGroupChatBefore_ db user@User {userId} g@GroupInfo {groupId} beforeChatItemId
|
||||
let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False}
|
||||
beforeChatItem <- getGroupChatItem db user groupId beforeChatItemId
|
||||
chatItemIds <- liftIO $ getGroupChatItemIdsBefore_ (chatItemTs beforeChatItem)
|
||||
chatItems <- mapM (getGroupCIWithReactions db user g) chatItemIds
|
||||
currentTs <- liftIO getCurrentTime
|
||||
chatItems <- liftIO $ mapM (safeGetGroupItem db user g currentTs) chatItemIds
|
||||
pure $ Chat (GroupChat g) (reverse chatItems) stats
|
||||
where
|
||||
getGroupChatItemIdsBefore_ :: UTCTime -> IO [ChatItemId]
|
||||
@@ -1113,16 +1165,17 @@ getLocalChat :: DB.Connection -> User -> Int64 -> ChatPagination -> Maybe String
|
||||
getLocalChat db user folderId pagination search_ = do
|
||||
let search = fromMaybe "" search_
|
||||
nf <- getNoteFolder db user folderId
|
||||
case pagination of
|
||||
liftIO $ case pagination of
|
||||
CPLast count -> getLocalChatLast_ db user nf count search
|
||||
CPAfter afterId count -> getLocalChatAfter_ db user nf afterId count search
|
||||
CPBefore beforeId count -> getLocalChatBefore_ db user nf beforeId count search
|
||||
|
||||
getLocalChatLast_ :: DB.Connection -> User -> NoteFolder -> Int -> String -> ExceptT StoreError IO (Chat 'CTLocal)
|
||||
getLocalChatLast_ :: DB.Connection -> User -> NoteFolder -> Int -> String -> IO (Chat 'CTLocal)
|
||||
getLocalChatLast_ db user@User {userId} nf@NoteFolder {noteFolderId} count search = do
|
||||
let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False}
|
||||
chatItemIds <- liftIO getLocalChatItemIdsLast_
|
||||
chatItems <- mapM (getLocalChatItem db user noteFolderId) chatItemIds
|
||||
chatItemIds <- getLocalChatItemIdsLast_
|
||||
currentTs <- getCurrentTime
|
||||
chatItems <- mapM (safeGetLocalItem db user nf currentTs) chatItemIds
|
||||
pure $ Chat (LocalChat nf) (reverse chatItems) stats
|
||||
where
|
||||
getLocalChatItemIdsLast_ :: IO [ChatItemId]
|
||||
@@ -1139,11 +1192,38 @@ getLocalChatLast_ db user@User {userId} nf@NoteFolder {noteFolderId} count searc
|
||||
|]
|
||||
(userId, noteFolderId, search, count)
|
||||
|
||||
getLocalChatAfter_ :: DB.Connection -> User -> NoteFolder -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTLocal)
|
||||
safeGetLocalItem :: DB.Connection -> User -> NoteFolder -> UTCTime -> ChatItemId -> IO (CChatItem 'CTLocal)
|
||||
safeGetLocalItem db user NoteFolder {noteFolderId} currentTs itemId =
|
||||
runExceptT (getLocalChatItem db user noteFolderId itemId)
|
||||
>>= pure <$> safeToLocalItem currentTs itemId
|
||||
|
||||
safeToLocalItem :: UTCTime -> ChatItemId -> Either StoreError (CChatItem 'CTLocal) -> CChatItem 'CTLocal
|
||||
safeToLocalItem currentTs itemId = \case
|
||||
Right ci -> ci
|
||||
Left e@(SEBadChatItem _ (Just itemTs)) -> badLocalItem itemTs e
|
||||
Left e -> badLocalItem currentTs e
|
||||
where
|
||||
badLocalItem :: UTCTime -> StoreError -> CChatItem 'CTLocal
|
||||
badLocalItem ts e =
|
||||
let errorText = T.pack $ show e
|
||||
in CChatItem
|
||||
SMDSnd
|
||||
ChatItem
|
||||
{ chatDir = CILocalSnd,
|
||||
meta = dummyMeta itemId ts errorText,
|
||||
content = CIInvalidJSON errorText,
|
||||
formattedText = Nothing,
|
||||
quotedItem = Nothing,
|
||||
reactions = [],
|
||||
file = Nothing
|
||||
}
|
||||
|
||||
getLocalChatAfter_ :: DB.Connection -> User -> NoteFolder -> ChatItemId -> Int -> String -> IO (Chat 'CTLocal)
|
||||
getLocalChatAfter_ db user@User {userId} nf@NoteFolder {noteFolderId} afterChatItemId count search = do
|
||||
let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False}
|
||||
chatItemIds <- liftIO getLocalChatItemIdsAfter_
|
||||
chatItems <- mapM (getLocalChatItem db user noteFolderId) chatItemIds
|
||||
chatItemIds <- getLocalChatItemIdsAfter_
|
||||
currentTs <- getCurrentTime
|
||||
chatItems <- mapM (safeGetLocalItem db user nf currentTs) chatItemIds
|
||||
pure $ Chat (LocalChat nf) chatItems stats
|
||||
where
|
||||
getLocalChatItemIdsAfter_ :: IO [ChatItemId]
|
||||
@@ -1161,11 +1241,12 @@ getLocalChatAfter_ db user@User {userId} nf@NoteFolder {noteFolderId} afterChatI
|
||||
|]
|
||||
(userId, noteFolderId, search, afterChatItemId, count)
|
||||
|
||||
getLocalChatBefore_ :: DB.Connection -> User -> NoteFolder -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTLocal)
|
||||
getLocalChatBefore_ :: DB.Connection -> User -> NoteFolder -> ChatItemId -> Int -> String -> IO (Chat 'CTLocal)
|
||||
getLocalChatBefore_ db user@User {userId} nf@NoteFolder {noteFolderId} beforeChatItemId count search = do
|
||||
let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False}
|
||||
chatItemIds <- liftIO getLocalChatItemIdsBefore_
|
||||
chatItems <- mapM (getLocalChatItem db user noteFolderId) chatItemIds
|
||||
chatItemIds <- getLocalChatItemIdsBefore_
|
||||
currentTs <- getCurrentTime
|
||||
chatItems <- mapM (safeGetLocalItem db user nf currentTs) chatItemIds
|
||||
pure $ Chat (LocalChat nf) (reverse chatItems) stats
|
||||
where
|
||||
getLocalChatItemIdsBefore_ :: IO [ChatItemId]
|
||||
@@ -1188,7 +1269,7 @@ toChatItemRef = \case
|
||||
(itemId, Just contactId, Nothing, Nothing) -> Right (ChatRef CTDirect contactId, itemId)
|
||||
(itemId, Nothing, Just groupId, Nothing) -> Right (ChatRef CTGroup groupId, itemId)
|
||||
(itemId, Nothing, Nothing, Just folderId) -> Right (ChatRef CTLocal folderId, itemId)
|
||||
(itemId, _, _, _) -> Left $ SEBadChatItem itemId
|
||||
(itemId, _, _, _) -> Left $ SEBadChatItem itemId Nothing
|
||||
|
||||
updateDirectChatItemsRead :: DB.Connection -> User -> ContactId -> Maybe (ChatItemId, ChatItemId) -> IO ()
|
||||
updateDirectChatItemsRead db User {userId} contactId itemsRange_ = do
|
||||
@@ -1361,7 +1442,7 @@ toDirectChatItem currentTs (((itemId, itemTs, AMsgDirection msgDir, itemContentT
|
||||
cItem :: MsgDirectionI d => SMsgDirection d -> CIDirection 'CTDirect d -> CIStatus d -> CIContent d -> Maybe (CIFile d) -> CChatItem 'CTDirect
|
||||
cItem d chatDir ciStatus content file =
|
||||
CChatItem d ChatItem {chatDir, meta = ciMeta content ciStatus, content, formattedText = parseMaybeMarkdownList itemText, quotedItem = toDirectQuote quoteRow, reactions = [], file}
|
||||
badItem = Left $ SEBadChatItem itemId
|
||||
badItem = Left $ SEBadChatItem itemId (Just itemTs)
|
||||
ciMeta :: CIContent d -> CIStatus d -> CIMeta 'CTDirect d
|
||||
ciMeta content status =
|
||||
let itemDeleted' = case itemDeleted of
|
||||
@@ -1412,7 +1493,7 @@ toGroupChatItem currentTs userContactId (((itemId, itemTs, AMsgDirection msgDir,
|
||||
cItem :: MsgDirectionI d => SMsgDirection d -> CIDirection 'CTGroup d -> CIStatus d -> CIContent d -> Maybe (CIFile d) -> CChatItem 'CTGroup
|
||||
cItem d chatDir ciStatus content file =
|
||||
CChatItem d ChatItem {chatDir, meta = ciMeta content ciStatus, content, formattedText = parseMaybeMarkdownList itemText, quotedItem = toGroupQuote quoteRow quotedMember_, reactions = [], file}
|
||||
badItem = Left $ SEBadChatItem itemId
|
||||
badItem = Left $ SEBadChatItem itemId (Just itemTs)
|
||||
ciMeta :: CIContent d -> CIStatus d -> CIMeta 'CTGroup d
|
||||
ciMeta content status =
|
||||
let itemDeleted' = case itemDeleted of
|
||||
@@ -2085,6 +2166,12 @@ getChatItemByFileId db vr user@User {userId} fileId = do
|
||||
(userId, fileId)
|
||||
getAChatItem db vr user chatRef itemId
|
||||
|
||||
lookupChatItemByFileId :: DB.Connection -> VersionRange -> User -> Int64 -> ExceptT StoreError IO (Maybe AChatItem)
|
||||
lookupChatItemByFileId db vr user fileId = do
|
||||
fmap Just (getChatItemByFileId db vr user fileId) `catchError` \case
|
||||
SEChatItemNotFoundByFileId {} -> pure Nothing
|
||||
e -> throwError e
|
||||
|
||||
getChatItemByGroupId :: DB.Connection -> VersionRange -> User -> GroupId -> ExceptT StoreError IO AChatItem
|
||||
getChatItemByGroupId db vr user@User {userId} groupId = do
|
||||
(chatRef, itemId) <-
|
||||
@@ -2109,7 +2196,7 @@ getChatRefViaItemId db User {userId} itemId = do
|
||||
toChatRef = \case
|
||||
(Just contactId, Nothing) -> Right $ ChatRef CTDirect contactId
|
||||
(Nothing, Just groupId) -> Right $ ChatRef CTGroup groupId
|
||||
(_, _) -> Left $ SEBadChatItem itemId
|
||||
(_, _) -> Left $ SEBadChatItem itemId Nothing
|
||||
|
||||
getAChatItem :: DB.Connection -> VersionRange -> User -> ChatRef -> ChatItemId -> ExceptT StoreError IO AChatItem
|
||||
getAChatItem db vr user chatRef itemId = case chatRef of
|
||||
@@ -2145,11 +2232,6 @@ getChatItemVersions db itemId = do
|
||||
let formattedText = parseMaybeMarkdownList $ msgContentText msgContent
|
||||
in ChatItemVersion {chatItemVersionId, msgContent, formattedText, itemVersionTs, createdAt}
|
||||
|
||||
getDirectChatReactions_ :: DB.Connection -> Contact -> Chat 'CTDirect -> IO (Chat 'CTDirect)
|
||||
getDirectChatReactions_ db ct c@Chat {chatItems} = do
|
||||
chatItems' <- mapM (directCIWithReactions db ct) chatItems
|
||||
pure c {chatItems = chatItems'}
|
||||
|
||||
directCIWithReactions :: DB.Connection -> Contact -> CChatItem 'CTDirect -> IO (CChatItem 'CTDirect)
|
||||
directCIWithReactions db ct cci@(CChatItem md ci@ChatItem {meta = CIMeta {itemSharedMsgId}}) = case itemSharedMsgId of
|
||||
Just sharedMsgId -> do
|
||||
|
||||
@@ -98,6 +98,9 @@ import Simplex.Chat.Migrations.M20240102_note_folders
|
||||
import Simplex.Chat.Migrations.M20240104_members_profile_update
|
||||
import Simplex.Chat.Migrations.M20240115_block_member_for_all
|
||||
import Simplex.Chat.Migrations.M20240122_indexes
|
||||
import Simplex.Chat.Migrations.M20240214_redirect_file_id
|
||||
import Simplex.Chat.Migrations.M20240222_app_settings
|
||||
import Simplex.Chat.Migrations.M20240226_users_restrict
|
||||
import Simplex.Messaging.Agent.Store.SQLite.Migrations (Migration (..))
|
||||
|
||||
schemaMigrations :: [(String, Query, Maybe Query)]
|
||||
@@ -195,7 +198,10 @@ schemaMigrations =
|
||||
("20240102_note_folders", m20240102_note_folders, Just down_m20240102_note_folders),
|
||||
("20240104_members_profile_update", m20240104_members_profile_update, Just down_m20240104_members_profile_update),
|
||||
("20240115_block_member_for_all", m20240115_block_member_for_all, Just down_m20240115_block_member_for_all),
|
||||
("20240122_indexes", m20240122_indexes, Just down_m20240122_indexes)
|
||||
("20240122_indexes", m20240122_indexes, Just down_m20240122_indexes),
|
||||
("20240214_redirect_file_id", m20240214_redirect_file_id, Just down_m20240214_redirect_file_id),
|
||||
("20240222_app_settings", m20240222_app_settings, Just down_m20240222_app_settings),
|
||||
("20240226_users_restrict", m20240226_users_restrict, Just down_m20240226_users_restrict)
|
||||
]
|
||||
|
||||
-- | The list of migrations in ascending order by date
|
||||
|
||||
@@ -267,7 +267,7 @@ updateUserProfile db user p'
|
||||
"INSERT INTO display_names (local_display_name, ldn_base, user_id, created_at, updated_at) VALUES (?,?,?,?,?)"
|
||||
(newName, newName, userId, currentTs, currentTs)
|
||||
updateContactProfile_' db userId profileId p' currentTs
|
||||
updateContactLDN_ db userId userContactId localDisplayName newName currentTs
|
||||
updateContactLDN_ db user userContactId localDisplayName newName currentTs
|
||||
pure user {localDisplayName = newName, profile, fullPreferences, userMemberProfileUpdatedAt = userMemberProfileUpdatedAt'}
|
||||
where
|
||||
updateUserMemberProfileUpdatedAt_ currentTs
|
||||
@@ -388,6 +388,7 @@ deleteUserAddress db user@User {userId} = do
|
||||
JOIN user_contact_links uc USING (user_contact_link_id)
|
||||
WHERE uc.user_id = :user_id AND uc.local_display_name = '' AND uc.group_id IS NULL
|
||||
)
|
||||
AND local_display_name NOT IN (SELECT local_display_name FROM users WHERE user_id = :user_id)
|
||||
|]
|
||||
[":user_id" := userId]
|
||||
DB.executeNamed
|
||||
|
||||
@@ -92,11 +92,12 @@ data StoreError
|
||||
| SEUniqueID
|
||||
| SELargeMsg
|
||||
| SEInternalError {message :: String}
|
||||
| SEBadChatItem {itemId :: ChatItemId}
|
||||
| SEBadChatItem {itemId :: ChatItemId, itemTs :: Maybe ChatItemTs}
|
||||
| SEChatItemNotFound {itemId :: ChatItemId}
|
||||
| SEChatItemNotFoundByText {text :: Text}
|
||||
| SEChatItemSharedMsgIdNotFound {sharedMsgId :: SharedMsgId}
|
||||
| SEChatItemNotFoundByFileId {fileId :: FileTransferId}
|
||||
| SEChatItemNotFoundByContactId {contactId :: ContactId}
|
||||
| SEChatItemNotFoundByGroupId {groupId :: GroupId}
|
||||
| SEProfileNotFound {profileId :: Int64}
|
||||
| SEDuplicateGroupLink {groupInfo :: GroupInfo}
|
||||
@@ -110,6 +111,7 @@ data StoreError
|
||||
| SERemoteHostDuplicateCA
|
||||
| SERemoteCtrlNotFound {remoteCtrlId :: RemoteCtrlId}
|
||||
| SERemoteCtrlDuplicateCA
|
||||
| SEProhibitedDeleteUser {userId :: UserId, contactId :: ContactId}
|
||||
deriving (Show, Exception)
|
||||
|
||||
$(J.deriveJSON (sumTypeJSON $ dropPrefix "SE") ''StoreError)
|
||||
@@ -401,3 +403,33 @@ createWithRandomBytes' size gVar create = tryCreate 3
|
||||
|
||||
encodedRandomBytes :: TVar ChaChaDRG -> Int -> IO ByteString
|
||||
encodedRandomBytes gVar n = atomically $ B64.encode <$> C.randomBytes n gVar
|
||||
|
||||
assertNotUser :: DB.Connection -> User -> Contact -> ExceptT StoreError IO ()
|
||||
assertNotUser db User {userId} Contact {contactId, localDisplayName} = do
|
||||
r :: (Maybe Int64) <-
|
||||
-- This query checks that the foreign keys in the users table
|
||||
-- are not referencing the contact about to be deleted.
|
||||
-- With the current schema it would cause cascade delete of user,
|
||||
-- with mofified schema (in v5.6.0-beta.0) it would cause foreign key violation error.
|
||||
liftIO . maybeFirstRow fromOnly $
|
||||
DB.query
|
||||
db
|
||||
[sql|
|
||||
SELECT 1 FROM users
|
||||
WHERE (user_id = ? AND local_display_name = ?)
|
||||
OR contact_id = ?
|
||||
LIMIT 1
|
||||
|]
|
||||
(userId, localDisplayName, contactId)
|
||||
when (isJust r) $ throwError $ SEProhibitedDeleteUser userId contactId
|
||||
|
||||
safeDeleteLDN :: DB.Connection -> User -> ContactName -> IO ()
|
||||
safeDeleteLDN db User {userId} localDisplayName = do
|
||||
DB.execute
|
||||
db
|
||||
[sql|
|
||||
DELETE FROM display_names
|
||||
WHERE user_id = ? AND local_display_name = ?
|
||||
AND local_display_name NOT IN (SELECT local_display_name FROM users WHERE user_id = ?)
|
||||
|]
|
||||
(userId, localDisplayName, userId)
|
||||
|
||||
@@ -46,7 +46,7 @@ import Database.SQLite.Simple.ToField (ToField (..))
|
||||
import Simplex.Chat.Types.Preferences
|
||||
import Simplex.Chat.Types.Util
|
||||
import Simplex.FileTransfer.Description (FileDigest)
|
||||
import Simplex.Messaging.Agent.Protocol (ACommandTag (..), ACorrId, AParty (..), APartyCmdTag (..), ConnId, ConnectionMode (..), ConnectionRequestUri, InvitationId, SAEntity (..), UserId)
|
||||
import Simplex.Messaging.Agent.Protocol (ACommandTag (..), ACorrId, AParty (..), APartyCmdTag (..), ConnId, ConnectionMode (..), ConnectionRequestUri, InvitationId, RcvFileId, SAEntity (..), SndFileId, UserId)
|
||||
import Simplex.Messaging.Crypto.File (CryptoFileArgs (..))
|
||||
import Simplex.Messaging.Encoding.String
|
||||
import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, fromTextField_, sumTypeJSON, taggedObjectJSON)
|
||||
@@ -1142,7 +1142,7 @@ instance FromField AgentConnId where fromField f = AgentConnId <$> fromField f
|
||||
|
||||
instance ToField AgentConnId where toField (AgentConnId m) = toField m
|
||||
|
||||
newtype AgentSndFileId = AgentSndFileId ConnId
|
||||
newtype AgentSndFileId = AgentSndFileId SndFileId
|
||||
deriving (Eq, Show)
|
||||
|
||||
instance StrEncoding AgentSndFileId where
|
||||
@@ -1161,7 +1161,7 @@ instance FromField AgentSndFileId where fromField f = AgentSndFileId <$> fromFie
|
||||
|
||||
instance ToField AgentSndFileId where toField (AgentSndFileId m) = toField m
|
||||
|
||||
newtype AgentRcvFileId = AgentRcvFileId ConnId
|
||||
newtype AgentRcvFileId = AgentRcvFileId RcvFileId
|
||||
deriving (Eq, Show)
|
||||
|
||||
instance StrEncoding AgentRcvFileId where
|
||||
@@ -1210,6 +1210,7 @@ data FileTransfer
|
||||
data FileTransferMeta = FileTransferMeta
|
||||
{ fileId :: FileTransferId,
|
||||
xftpSndFile :: Maybe XFTPSndFile,
|
||||
xftpRedirectFor :: Maybe FileTransferId,
|
||||
fileName :: String,
|
||||
filePath :: String,
|
||||
fileSize :: Integer,
|
||||
|
||||
+37
-10
@@ -198,17 +198,24 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe
|
||||
CRGroupMemberUpdated {} -> []
|
||||
CRContactsMerged u intoCt mergedCt ct' -> ttyUser u $ viewContactsMerged intoCt mergedCt ct'
|
||||
CRReceivedContactRequest u UserContactRequest {localDisplayName = c, profile} -> ttyUser u $ viewReceivedContactRequest c profile
|
||||
CRRcvStandaloneFileCreated u ft -> ttyUser u $ receivingFileStandalone "started" ft
|
||||
CRRcvFileStart u ci -> ttyUser u $ receivingFile_' hu testView "started" ci
|
||||
CRRcvFileComplete u ci -> ttyUser u $ receivingFile_' hu testView "completed" ci
|
||||
CRRcvStandaloneFileComplete u _ ft -> ttyUser u $ receivingFileStandalone "completed" ft
|
||||
CRRcvFileSndCancelled u _ ft -> ttyUser u $ viewRcvFileSndCancelled ft
|
||||
CRRcvFileError u ci e -> ttyUser u $ receivingFile_' hu testView "error" ci <> [sShow e]
|
||||
CRRcvFileError u (Just ci) e _ -> ttyUser u $ receivingFile_' hu testView "error" ci <> [sShow e]
|
||||
CRRcvFileError u Nothing e ft -> ttyUser u $ receivingFileStandalone "error" ft <> [sShow e]
|
||||
CRSndFileStart u _ ft -> ttyUser u $ sendingFile_ "started" ft
|
||||
CRSndFileComplete u _ ft -> ttyUser u $ sendingFile_ "completed" ft
|
||||
CRSndStandaloneFileCreated u ft -> ttyUser u $ uploadingFileStandalone "started" ft
|
||||
CRSndFileStartXFTP {} -> []
|
||||
CRSndFileProgressXFTP {} -> []
|
||||
CRSndFileRedirectStartXFTP u ft ftRedirect -> ttyUser u $ standaloneUploadRedirect ft ftRedirect
|
||||
CRSndStandaloneFileComplete u ft uris -> ttyUser u $ standaloneUploadComplete ft uris
|
||||
CRSndFileCompleteXFTP u ci _ -> ttyUser u $ uploadingFile "completed" ci
|
||||
CRSndFileCancelledXFTP {} -> []
|
||||
CRSndFileError u ci -> ttyUser u $ uploadingFile "error" ci
|
||||
CRSndFileError u Nothing ft -> ttyUser u $ uploadingFileStandalone "error" ft
|
||||
CRSndFileError u (Just ci) _ -> ttyUser u $ uploadingFile "error" ci
|
||||
CRSndFileRcvCancelled u _ ft@SndFileTransfer {recipientDisplayName = c} ->
|
||||
ttyUser u [ttyContact c <> " cancelled receiving " <> sndFile ft]
|
||||
CRContactConnecting u _ -> ttyUser u []
|
||||
@@ -283,7 +290,7 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe
|
||||
CRUserContactLinkSubError e -> ["user address error: " <> sShow e, "to delete your address: " <> highlight' "/da"]
|
||||
CRContactConnectionDeleted u PendingContactConnection {pccConnId} -> ttyUser u ["connection :" <> sShow pccConnId <> " deleted"]
|
||||
CRNtfTokenStatus status -> ["device token status: " <> plain (smpEncode status)]
|
||||
CRNtfToken _ status mode -> ["device token status: " <> plain (smpEncode status) <> ", notifications mode: " <> plain (strEncode mode)]
|
||||
CRNtfToken _ status mode srv -> ["device token status: " <> plain (smpEncode status) <> ", notifications mode: " <> plain (strEncode mode) <> ", server: " <> sShow srv]
|
||||
CRNtfMessages {} -> []
|
||||
CRNtfMessage {} -> []
|
||||
CRCurrentRemoteHost rhi_ ->
|
||||
@@ -378,6 +385,7 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe
|
||||
CRChatError u e -> ttyUser' u $ viewChatError logLevel testView e
|
||||
CRChatErrors u errs -> ttyUser' u $ concatMap (viewChatError logLevel testView) errs
|
||||
CRArchiveImported archiveErrs -> if null archiveErrs then ["ok"] else ["archive import errors: " <> plain (show archiveErrs)]
|
||||
CRAppSettings as -> ["app settings: " <> plain (LB.unpack $ J.encode as)]
|
||||
CRTimedAction _ _ -> []
|
||||
where
|
||||
ttyUser :: User -> [StyledString] -> [StyledString]
|
||||
@@ -1558,11 +1566,26 @@ sendingFile_ status ft@SndFileTransfer {recipientDisplayName = c} =
|
||||
[status <> " sending " <> sndFile ft <> " to " <> ttyContact c]
|
||||
|
||||
uploadingFile :: StyledString -> AChatItem -> [StyledString]
|
||||
uploadingFile status (AChatItem _ _ (DirectChat Contact {localDisplayName = c}) ChatItem {file = Just CIFile {fileId, fileName}, chatDir = CIDirectSnd}) =
|
||||
[status <> " uploading " <> fileTransferStr fileId fileName <> " for " <> ttyContact c]
|
||||
uploadingFile status (AChatItem _ _ (GroupChat g) ChatItem {file = Just CIFile {fileId, fileName}, chatDir = CIGroupSnd}) =
|
||||
[status <> " uploading " <> fileTransferStr fileId fileName <> " for " <> ttyGroup' g]
|
||||
uploadingFile status _ = [status <> " uploading file"] -- shouldn't happen
|
||||
uploadingFile status = \case
|
||||
AChatItem _ _ (DirectChat Contact {localDisplayName = c}) ChatItem {file = Just CIFile {fileId, fileName}, chatDir = CIDirectSnd} ->
|
||||
[status <> " uploading " <> fileTransferStr fileId fileName <> " for " <> ttyContact c]
|
||||
AChatItem _ _ (GroupChat g) ChatItem {file = Just CIFile {fileId, fileName}, chatDir = CIGroupSnd} ->
|
||||
[status <> " uploading " <> fileTransferStr fileId fileName <> " for " <> ttyGroup' g]
|
||||
_ -> [status <> " uploading file"]
|
||||
|
||||
uploadingFileStandalone :: StyledString -> FileTransferMeta -> [StyledString]
|
||||
uploadingFileStandalone status FileTransferMeta {fileId, fileName} = [status <> " standalone uploading " <> fileTransferStr fileId fileName]
|
||||
|
||||
standaloneUploadRedirect :: FileTransferMeta -> FileTransferMeta -> [StyledString]
|
||||
standaloneUploadRedirect FileTransferMeta {fileId, fileName} FileTransferMeta {fileId = redirectId} =
|
||||
[fileTransferStr fileId fileName <> " uploaded, preparing redirect file " <> sShow redirectId]
|
||||
|
||||
standaloneUploadComplete :: FileTransferMeta -> [Text] -> [StyledString]
|
||||
standaloneUploadComplete FileTransferMeta {fileId, fileName} = \case
|
||||
[] -> [fileTransferStr fileId fileName <> " upload complete."]
|
||||
uris ->
|
||||
fileTransferStr fileId fileName <> " upload complete. download with:"
|
||||
: map plain uris
|
||||
|
||||
sndFile :: SndFileTransfer -> StyledString
|
||||
sndFile SndFileTransfer {fileId, fileName} = fileTransferStr fileId fileName
|
||||
@@ -1608,7 +1631,11 @@ receivingFile_' hu testView status (AChatItem _ _ chat ChatItem {file = Just CIF
|
||||
highlight ("/get remote file " <> show rhId <> " " <> LB.unpack (J.encode RemoteFile {userId, fileId, sent = False, fileSource = f}))
|
||||
]
|
||||
_ -> []
|
||||
receivingFile_' _ _ status _ = [plain status <> " receiving file"] -- shouldn't happen
|
||||
receivingFile_' _ _ status _ = [plain status <> " receiving file"]
|
||||
|
||||
receivingFileStandalone :: String -> RcvFileTransfer -> [StyledString]
|
||||
receivingFileStandalone status RcvFileTransfer {fileId, fileInvitation = FileInvitation {fileName}} =
|
||||
[plain status <> " standalone receiving " <> fileTransferStr fileId fileName]
|
||||
|
||||
viewLocalFile :: StyledString -> CIFile d -> CurrentTime -> TimeZone -> CIMeta c d -> [StyledString]
|
||||
viewLocalFile to CIFile {fileId, fileSource} ts tz = case fileSource of
|
||||
@@ -1627,7 +1654,7 @@ fileFrom _ _ = ""
|
||||
|
||||
receivingFile_ :: StyledString -> RcvFileTransfer -> [StyledString]
|
||||
receivingFile_ status ft@RcvFileTransfer {senderDisplayName = c} =
|
||||
[status <> " receiving " <> rcvFile ft <> " from " <> ttyContact c]
|
||||
[status <> " receiving " <> rcvFile ft <> if c == "" then "" else " from " <> ttyContact c]
|
||||
|
||||
rcvFile :: RcvFileTransfer -> StyledString
|
||||
rcvFile RcvFileTransfer {fileId, fileInvitation = FileInvitation {fileName}} = fileTransferStr fileId fileName
|
||||
|
||||
+10
-9
@@ -15,6 +15,7 @@ import Control.Concurrent.STM
|
||||
import Control.Exception (bracket, bracket_)
|
||||
import Control.Monad
|
||||
import Control.Monad.Except
|
||||
import Control.Monad.Reader
|
||||
import Data.ByteArray (ScrubbedBytes)
|
||||
import Data.Functor (($>))
|
||||
import Data.List (dropWhileEnd, find)
|
||||
@@ -22,7 +23,7 @@ import Data.Maybe (isNothing)
|
||||
import qualified Data.Text as T
|
||||
import Network.Socket
|
||||
import Simplex.Chat
|
||||
import Simplex.Chat.Controller (ChatConfig (..), ChatController (..), ChatDatabase (..), ChatLogLevel (..))
|
||||
import Simplex.Chat.Controller (ChatCommand (..), ChatConfig (..), ChatController (..), ChatDatabase (..), ChatLogLevel (..))
|
||||
import Simplex.Chat.Core
|
||||
import Simplex.Chat.Options
|
||||
import Simplex.Chat.Store
|
||||
@@ -129,8 +130,7 @@ testCfg =
|
||||
{ agentConfig = testAgentCfg,
|
||||
showReceipts = False,
|
||||
testView = True,
|
||||
tbqSize = 16,
|
||||
xftpFileConfig = Nothing
|
||||
tbqSize = 16
|
||||
}
|
||||
|
||||
testAgentCfgVPrev :: AgentConfig
|
||||
@@ -146,9 +146,9 @@ testAgentCfgV1 :: AgentConfig
|
||||
testAgentCfgV1 =
|
||||
testAgentCfg
|
||||
{ smpClientVRange = v1Range,
|
||||
smpAgentVRange = v1Range,
|
||||
e2eEncryptVRange = v1Range,
|
||||
smpCfg = (smpCfg testAgentCfg) {serverVRange = v1Range}
|
||||
smpAgentVRange = versionToRange 2, -- duplexHandshakeSMPAgentVersion,
|
||||
e2eEncryptVRange = versionToRange 2, -- kdfX3DHE2EEncryptVersion,
|
||||
smpCfg = (smpCfg testAgentCfg) {serverVRange = versionToRange 4} -- batchCmdsSMPVersion
|
||||
}
|
||||
|
||||
testCfgVPrev :: ChatConfig
|
||||
@@ -166,7 +166,7 @@ testCfgV1 =
|
||||
}
|
||||
|
||||
prevRange :: VersionRange -> VersionRange
|
||||
prevRange vr = vr {maxVersion = maxVersion vr - 1}
|
||||
prevRange vr = vr {maxVersion = max (minVersion vr) (maxVersion vr - 1)}
|
||||
|
||||
v1Range :: VersionRange
|
||||
v1Range = mkVersionRange 1 1
|
||||
@@ -209,6 +209,7 @@ startTestChat_ db cfg opts user = do
|
||||
t <- withVirtualTerminal termSettings pure
|
||||
ct <- newChatTerminal t opts
|
||||
cc <- newChatController db (Just user) cfg opts False
|
||||
void $ execChatCommand' (SetTempFolder "tests/tmp/tmp") `runReaderT` cc
|
||||
chatAsync <- async . runSimplexChat opts user cc $ \_u cc' -> runChatTerminal ct cc' opts
|
||||
atomically . unless (maintenance opts) $ readTVar (agentAsync cc) >>= \a -> when (isNothing a) retry
|
||||
termQ <- newTQueueIO
|
||||
@@ -384,7 +385,7 @@ serverCfg =
|
||||
logStatsStartTime = 0,
|
||||
serverStatsLogFile = "tests/smp-server-stats.daily.log",
|
||||
serverStatsBackupFile = Nothing,
|
||||
smpServerVRange = supportedSMPServerVRange,
|
||||
smpServerVRange = supportedServerSMPRelayVRange,
|
||||
transportConfig = defaultTransportServerConfig,
|
||||
smpHandshakeTimeout = 1000000,
|
||||
controlPort = Nothing
|
||||
@@ -407,7 +408,7 @@ xftpServerConfig =
|
||||
storeLogFile = Just "tests/tmp/xftp-server-store.log",
|
||||
filesPath = xftpServerFiles,
|
||||
fileSizeQuota = Nothing,
|
||||
allowedChunkSizes = [kb 128, kb 256, mb 1, mb 4],
|
||||
allowedChunkSizes = [kb 64, kb 128, kb 256, mb 1, mb 4],
|
||||
allowNewFiles = True,
|
||||
newFileBasicAuth = Nothing,
|
||||
fileExpiration = Just defaultFileExpiration,
|
||||
|
||||
@@ -14,6 +14,9 @@ import Data.Aeson (ToJSON)
|
||||
import qualified Data.Aeson as J
|
||||
import qualified Data.ByteString.Char8 as B
|
||||
import qualified Data.ByteString.Lazy.Char8 as LB
|
||||
import qualified Data.Text as T
|
||||
import Simplex.Chat.AppSettings (defaultAppSettings)
|
||||
import qualified Simplex.Chat.AppSettings as AS
|
||||
import Simplex.Chat.Call
|
||||
import Simplex.Chat.Controller (ChatConfig (..))
|
||||
import Simplex.Chat.Options (ChatOpts (..))
|
||||
@@ -21,6 +24,7 @@ import Simplex.Chat.Protocol (supportedChatVRange)
|
||||
import Simplex.Chat.Store (agentStoreFile, chatStoreFile)
|
||||
import Simplex.Chat.Types (authErrDisableCount, sameVerificationCode, verificationCode)
|
||||
import qualified Simplex.Messaging.Crypto as C
|
||||
import Simplex.Messaging.Util (safeDecodeUtf8)
|
||||
import Simplex.Messaging.Version
|
||||
import System.Directory (copyFile, doesDirectoryExist, doesFileExist)
|
||||
import System.FilePath ((</>))
|
||||
@@ -84,8 +88,9 @@ chatDirectTests = do
|
||||
it "disabling chat item expiration doesn't disable it for other users" testDisableCIExpirationOnlyForOneUser
|
||||
it "both users have configured timed messages with contacts, messages expire, restart" testUsersTimedMessages
|
||||
it "user profile privacy: hide profiles and notificaitons" testUserPrivacy
|
||||
describe "chat item expiration" $ do
|
||||
it "set chat item TTL" testSetChatItemTTL
|
||||
describe "settings" $ do
|
||||
it "set chat item expiration TTL" testSetChatItemTTL
|
||||
it "save/get app settings" testAppSettings
|
||||
describe "connection switch" $ do
|
||||
it "switch contact to a different queue" testSwitchContact
|
||||
it "stop switching contact to a different queue" testAbortSwitchContact
|
||||
@@ -1067,7 +1072,7 @@ testChatWorking alice bob = do
|
||||
alice <# "bob> hello too"
|
||||
|
||||
testMaintenanceModeWithFiles :: HasCallStack => FilePath -> IO ()
|
||||
testMaintenanceModeWithFiles tmp = do
|
||||
testMaintenanceModeWithFiles tmp = withXFTPServer $ do
|
||||
withNewTestChat tmp "bob" bobProfile $ \bob -> do
|
||||
withNewTestChatOpts tmp testOpts {maintenance = True} "alice" aliceProfile $ \alice -> do
|
||||
alice ##> "/_start"
|
||||
@@ -1075,12 +1080,26 @@ testMaintenanceModeWithFiles tmp = do
|
||||
alice ##> "/_files_folder ./tests/tmp/alice_files"
|
||||
alice <## "ok"
|
||||
connectUsers alice bob
|
||||
startFileTransferWithDest' bob alice "test.jpg" "136.5 KiB / 139737 bytes" Nothing
|
||||
bob <## "completed sending file 1 (test.jpg) to alice"
|
||||
|
||||
bob #> "/f @alice ./tests/fixtures/test.jpg"
|
||||
bob <## "use /fc 1 to cancel sending"
|
||||
alice <# "bob> sends file test.jpg (136.5 KiB / 139737 bytes)"
|
||||
alice <## "use /fr 1 [<dir>/ | <path>] to receive it"
|
||||
bob <## "completed uploading file 1 (test.jpg) for alice"
|
||||
|
||||
alice ##> "/fr 1"
|
||||
alice
|
||||
<### [ "saving file 1 from bob to test.jpg",
|
||||
"started receiving file 1 (test.jpg) from bob"
|
||||
]
|
||||
alice <## "completed receiving file 1 (test.jpg) from bob"
|
||||
|
||||
src <- B.readFile "./tests/fixtures/test.jpg"
|
||||
B.readFile "./tests/tmp/alice_files/test.jpg" `shouldReturn` src
|
||||
dest <- B.readFile "./tests/tmp/alice_files/test.jpg"
|
||||
dest `shouldBe` src
|
||||
|
||||
threadDelay 500000
|
||||
|
||||
alice ##> "/_stop"
|
||||
alice <## "chat stopped"
|
||||
alice ##> "/_db export {\"archivePath\": \"./tests/tmp/alice-chat.zip\"}"
|
||||
@@ -1124,6 +1143,10 @@ testDatabaseEncryption tmp = do
|
||||
testChatWorking alice bob
|
||||
alice ##> "/_stop"
|
||||
alice <## "chat stopped"
|
||||
alice ##> "/db test key wrongkey"
|
||||
alice <## "error opening database after encryption: wrong passphrase or invalid database file"
|
||||
alice ##> "/db test key mykey"
|
||||
alice <## "ok"
|
||||
alice ##> "/db key wrongkey nextkey"
|
||||
alice <## "error encrypting database: wrong passphrase or invalid database file"
|
||||
alice ##> "/db key mykey nextkey"
|
||||
@@ -2177,6 +2200,24 @@ testSetChatItemTTL =
|
||||
alice #$> ("/ttl none", id, "ok")
|
||||
alice #$> ("/ttl", id, "old messages are not being deleted")
|
||||
|
||||
testAppSettings :: HasCallStack => FilePath -> IO ()
|
||||
testAppSettings tmp =
|
||||
withNewTestChat tmp "alice" aliceProfile $ \alice -> do
|
||||
let settings = T.unpack . safeDecodeUtf8 . LB.toStrict $ J.encode defaultAppSettings
|
||||
settingsApp = T.unpack . safeDecodeUtf8 . LB.toStrict $ J.encode defaultAppSettings {AS.webrtcICEServers = Just ["non-default.value.com"]}
|
||||
-- app-provided defaults
|
||||
alice ##> ("/_get app settings " <> settingsApp)
|
||||
alice <## ("app settings: " <> settingsApp)
|
||||
-- parser defaults fallback
|
||||
alice ##> "/_get app settings"
|
||||
alice <## ("app settings: " <> settings)
|
||||
-- store
|
||||
alice ##> ("/_save app settings " <> settingsApp)
|
||||
alice <## "ok"
|
||||
-- read back
|
||||
alice ##> "/_get app settings"
|
||||
alice <## ("app settings: " <> settingsApp)
|
||||
|
||||
testSwitchContact :: HasCallStack => FilePath -> IO ()
|
||||
testSwitchContact =
|
||||
testChat2 aliceProfile bobProfile $
|
||||
|
||||
+287
-864
File diff suppressed because it is too large
Load Diff
@@ -11,7 +11,7 @@ import Control.Monad (void, when)
|
||||
import qualified Data.ByteString as B
|
||||
import Data.List (isInfixOf)
|
||||
import qualified Data.Text as T
|
||||
import Simplex.Chat.Controller (ChatConfig (..), XFTPFileConfig (..))
|
||||
import Simplex.Chat.Controller (ChatConfig (..))
|
||||
import Simplex.Chat.Protocol (supportedChatVRange)
|
||||
import Simplex.Chat.Store (agentStoreFile, chatStoreFile)
|
||||
import Simplex.Chat.Types (GroupMemberRole (..))
|
||||
@@ -4321,7 +4321,7 @@ testGroupMsgForwardDeletion =
|
||||
|
||||
testGroupMsgForwardFile :: HasCallStack => FilePath -> IO ()
|
||||
testGroupMsgForwardFile =
|
||||
testChatCfg3 cfg aliceProfile bobProfile cathProfile $
|
||||
testChat3 aliceProfile bobProfile cathProfile $
|
||||
\alice bob cath -> withXFTPServer $ do
|
||||
setupGroupForwarding3 "team" alice bob cath
|
||||
|
||||
@@ -4343,8 +4343,6 @@ testGroupMsgForwardFile =
|
||||
src <- B.readFile "./tests/fixtures/test.jpg"
|
||||
dest <- B.readFile "./tests/tmp/test.jpg"
|
||||
dest `shouldBe` src
|
||||
where
|
||||
cfg = testCfg {xftpFileConfig = Just $ XFTPFileConfig {minFileSize = 0}, tempDir = Just "./tests/tmp"}
|
||||
|
||||
testGroupMsgForwardChangeRole :: HasCallStack => FilePath -> IO ()
|
||||
testGroupMsgForwardChangeRole =
|
||||
@@ -4577,7 +4575,7 @@ testGroupHistoryPreferenceOff =
|
||||
|
||||
testGroupHistoryHostFile :: HasCallStack => FilePath -> IO ()
|
||||
testGroupHistoryHostFile =
|
||||
testChatCfg3 cfg aliceProfile bobProfile cathProfile $
|
||||
testChat3 aliceProfile bobProfile cathProfile $
|
||||
\alice bob cath -> withXFTPServer $ do
|
||||
createGroup2 "team" alice bob
|
||||
|
||||
@@ -4613,12 +4611,10 @@ testGroupHistoryHostFile =
|
||||
src <- B.readFile "./tests/fixtures/test.jpg"
|
||||
dest <- B.readFile "./tests/tmp/test.jpg"
|
||||
dest `shouldBe` src
|
||||
where
|
||||
cfg = testCfg {xftpFileConfig = Just $ XFTPFileConfig {minFileSize = 0}, tempDir = Just "./tests/tmp"}
|
||||
|
||||
testGroupHistoryMemberFile :: HasCallStack => FilePath -> IO ()
|
||||
testGroupHistoryMemberFile =
|
||||
testChatCfg3 cfg aliceProfile bobProfile cathProfile $
|
||||
testChat3 aliceProfile bobProfile cathProfile $
|
||||
\alice bob cath -> withXFTPServer $ do
|
||||
createGroup2 "team" alice bob
|
||||
|
||||
@@ -4654,8 +4650,6 @@ testGroupHistoryMemberFile =
|
||||
src <- B.readFile "./tests/fixtures/test.jpg"
|
||||
dest <- B.readFile "./tests/tmp/test.jpg"
|
||||
dest `shouldBe` src
|
||||
where
|
||||
cfg = testCfg {xftpFileConfig = Just $ XFTPFileConfig {minFileSize = 0}, tempDir = Just "./tests/tmp"}
|
||||
|
||||
testGroupHistoryLargeFile :: HasCallStack => FilePath -> IO ()
|
||||
testGroupHistoryLargeFile =
|
||||
@@ -4713,11 +4707,11 @@ testGroupHistoryLargeFile =
|
||||
destCath <- B.readFile "./tests/tmp/testfile_2"
|
||||
destCath `shouldBe` src
|
||||
where
|
||||
cfg = testCfg {xftpDescrPartSize = 200, xftpFileConfig = Just $ XFTPFileConfig {minFileSize = 0}, tempDir = Just "./tests/tmp"}
|
||||
cfg = testCfg {xftpDescrPartSize = 200}
|
||||
|
||||
testGroupHistoryMultipleFiles :: HasCallStack => FilePath -> IO ()
|
||||
testGroupHistoryMultipleFiles =
|
||||
testChatCfg3 cfg aliceProfile bobProfile cathProfile $
|
||||
testChat3 aliceProfile bobProfile cathProfile $
|
||||
\alice bob cath -> withXFTPServer $ do
|
||||
xftpCLI ["rand", "./tests/tmp/testfile_bob", "2mb"] `shouldReturn` ["File created: " <> "./tests/tmp/testfile_bob"]
|
||||
xftpCLI ["rand", "./tests/tmp/testfile_alice", "1mb"] `shouldReturn` ["File created: " <> "./tests/tmp/testfile_alice"]
|
||||
@@ -4794,12 +4788,10 @@ testGroupHistoryMultipleFiles =
|
||||
`shouldContain` [ ((0, "hi alice"), Just "./tests/tmp/testfile_bob_1"),
|
||||
((0, "hey bob"), Just "./tests/tmp/testfile_alice_1")
|
||||
]
|
||||
where
|
||||
cfg = testCfg {xftpFileConfig = Just $ XFTPFileConfig {minFileSize = 0}, tempDir = Just "./tests/tmp"}
|
||||
|
||||
testGroupHistoryFileCancel :: HasCallStack => FilePath -> IO ()
|
||||
testGroupHistoryFileCancel =
|
||||
testChatCfg3 cfg aliceProfile bobProfile cathProfile $
|
||||
testChat3 aliceProfile bobProfile cathProfile $
|
||||
\alice bob cath -> withXFTPServer $ do
|
||||
xftpCLI ["rand", "./tests/tmp/testfile_bob", "2mb"] `shouldReturn` ["File created: " <> "./tests/tmp/testfile_bob"]
|
||||
xftpCLI ["rand", "./tests/tmp/testfile_alice", "1mb"] `shouldReturn` ["File created: " <> "./tests/tmp/testfile_alice"]
|
||||
@@ -4851,12 +4843,10 @@ testGroupHistoryFileCancel =
|
||||
bob <## "#team: alice added cath (Catherine) to the group (connecting...)"
|
||||
bob <## "#team: new member cath is connected"
|
||||
]
|
||||
where
|
||||
cfg = testCfg {xftpFileConfig = Just $ XFTPFileConfig {minFileSize = 0}, tempDir = Just "./tests/tmp"}
|
||||
|
||||
testGroupHistoryFileCancelNoText :: HasCallStack => FilePath -> IO ()
|
||||
testGroupHistoryFileCancelNoText =
|
||||
testChatCfg3 cfg aliceProfile bobProfile cathProfile $
|
||||
testChat3 aliceProfile bobProfile cathProfile $
|
||||
\alice bob cath -> withXFTPServer $ do
|
||||
xftpCLI ["rand", "./tests/tmp/testfile_bob", "2mb"] `shouldReturn` ["File created: " <> "./tests/tmp/testfile_bob"]
|
||||
xftpCLI ["rand", "./tests/tmp/testfile_alice", "1mb"] `shouldReturn` ["File created: " <> "./tests/tmp/testfile_alice"]
|
||||
@@ -4912,8 +4902,6 @@ testGroupHistoryFileCancelNoText =
|
||||
bob <## "#team: alice added cath (Catherine) to the group (connecting...)"
|
||||
bob <## "#team: new member cath is connected"
|
||||
]
|
||||
where
|
||||
cfg = testCfg {xftpFileConfig = Just $ XFTPFileConfig {minFileSize = 0}, tempDir = Just "./tests/tmp"}
|
||||
|
||||
testGroupHistoryQuotes :: HasCallStack => FilePath -> IO ()
|
||||
testGroupHistoryQuotes =
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user