Compare commits

..

2 Commits

Author SHA1 Message Date
Evgeny Poberezkin 00deb8ff91 update simplexmq 2024-02-22 17:14:58 +00:00
Evgeny Poberezkin c4155d636c core: ntf server test (#3819) 2024-02-22 17:03:21 +00:00
95 changed files with 836 additions and 3169 deletions
+16 -25
View File
@@ -1,41 +1,32 @@
ARG TAG=22.04
FROM ubuntu:focal AS build
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 curl and simplex-chat-related dependencies
RUN apt-get update && apt-get install -y curl git build-essential libgmp3-dev zlib1g-dev libssl-dev
# Install ghcup
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"
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 "${BOOTSTRAP_HASKELL_GHC_VERSION}" && \
ghcup set cabal "${BOOTSTRAP_HASKELL_CABAL_VERSION}"
RUN ghcup set ghc 9.6.3 && \
ghcup set cabal 3.10.1.0
COPY . /project
WORKDIR /project
# Adjust PATH
ENV PATH="/root/.cabal/bin:/root/.ghcup/bin:$PATH"
# Adjust build
RUN cp ./scripts/cabal.project.local.linux ./cabal.project.local
# Compile simplex-chat
RUN cabal update
RUN cabal build exe:simplex-chat
RUN cabal install
# 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 /project/simplex-chat /
COPY --from=build /root/.cabal/bin/simplex-chat /
+4 -52
View File
@@ -34,8 +34,6 @@ 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)
@@ -52,28 +50,16 @@ 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 {
@@ -149,11 +135,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)
}
@@ -177,40 +163,6 @@ 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") }
}
-2
View File
@@ -80,7 +80,6 @@ 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?
@@ -90,7 +89,6 @@ 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
+5 -5
View File
@@ -406,14 +406,14 @@ func apiDeleteMemberChatItem(groupId: Int64, groupMemberId: Int64, itemId: Int64
throw r
}
func apiGetNtfToken() -> (DeviceToken?, NtfTknStatus?, NotificationsMode, String?) {
func apiGetNtfToken() -> (DeviceToken?, NtfTknStatus?, NotificationsMode) {
let r = chatSendCmdSync(.apiGetNtfToken)
switch r {
case let .ntfToken(token, status, ntfMode, ntfServer): return (token, status, ntfMode, ntfServer)
case .chatCmdError(_, .errorAgent(.CMD(.PROHIBITED))): return (nil, nil, .off, nil)
case let .ntfToken(token, status, ntfMode): return (token, status, ntfMode)
case .chatCmdError(_, .errorAgent(.CMD(.PROHIBITED))): return (nil, nil, .off)
default:
logger.debug("apiGetNtfToken response: \(String(describing: r))")
return (nil, nil, .off, nil)
return (nil, nil, .off)
}
}
@@ -1302,7 +1302,7 @@ func startChat(refreshInvitations: Bool = true) throws {
if (refreshInvitations) {
try refreshCallInvitations()
}
(m.savedToken, m.tokenStatus, m.notificationMode, m.notificationServer) = apiGetNtfToken()
(m.savedToken, m.tokenStatus, m.notificationMode) = apiGetNtfToken()
// deviceToken is set when AppDelegate.application(didRegisterForRemoteNotificationsWithDeviceToken:) is called,
// when it is called before startChat
if let token = m.deviceToken {
+33 -93
View File
@@ -12,67 +12,49 @@ 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: .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))
}
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)
}
}
if let call = m.activeCall, let client = client, (!pipShown || !call.supportsVideo) {
ActiveCallOverlay(call: call, client: client)
}
}
if let call = m.activeCall, let client = client {
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(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)
.background(.black)
.preferredColorScheme(.dark)
}
private func createWebRTCClient() {
@@ -87,8 +69,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):
@@ -108,7 +90,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))")
}
@@ -140,15 +122,13 @@ 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 {
@@ -160,7 +140,6 @@ struct ActiveCallView: View {
case let .connected(connectionInfo):
call.callState = .connected
call.connectionInfo = connectionInfo
call.connectedAt = .now
case .ended:
closeCallView(client)
call.callState = .ended
@@ -174,7 +153,6 @@ struct ActiveCallView: View {
case .end:
closeCallView(client)
m.activeCall = nil
m.activeCallViewIsCollapsed = false
default: ()
}
case let .error(message):
@@ -203,7 +181,7 @@ struct ActiveCallOverlay: View {
VStack {
switch call.localMedia {
case .video:
videoCallInfoView(call)
callInfoView(call, .leading)
.foregroundColor(.white)
.opacity(0.8)
.padding()
@@ -230,25 +208,16 @@ struct ActiveCallOverlay: View {
.frame(maxWidth: .infinity, alignment: .center)
case .audio:
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)
VStack {
ProfileImage(imageStr: call.contact.profile.image)
.scaledToFit()
.frame(width: 192, height: 192)
callInfoView(call, .center)
}
.foregroundColor(.white)
.opacity(0.8)
.padding()
.frame(maxHeight: .infinity)
Spacer()
@@ -266,12 +235,12 @@ struct ActiveCallOverlay: View {
.frame(maxWidth: .infinity)
}
private func audioCallInfoView(_ call: Call) -> some View {
private func callInfoView(_ call: Call, _ alignment: Alignment) -> some View {
VStack {
Text(call.contact.chatViewName)
.lineLimit(1)
.font(.title)
.frame(maxWidth: .infinity, alignment: .center)
.frame(maxWidth: .infinity, alignment: alignment)
Group {
Text(call.callState.text)
HStack {
@@ -282,36 +251,7 @@ struct ActiveCallOverlay: View {
}
}
.font(.subheadline)
.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)
.frame(maxWidth: .infinity, alignment: alignment)
}
}
@@ -92,7 +92,6 @@ class CallManager {
if case .ended = call.callState {
logger.debug("CallManager.endCall: call ended")
m.activeCall = nil
m.activeCallViewIsCollapsed = false
m.showCallView = false
completed()
} else {
@@ -101,7 +100,6 @@ class CallManager {
await m.callCommand.processCommand(.end)
await MainActor.run {
m.activeCall = nil
m.activeCallViewIsCollapsed = false
m.showCallView = false
completed()
}
@@ -6,20 +6,14 @@
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?>, activeCallViewIsCollapsed: Binding<Bool>, pipShown: Binding<Bool>) {
init(client: WebRTCClient, activeCall: Binding<WebRTCClient.Call?>) {
self.client = client
self.activeCall = activeCall
self._activeCallViewIsCollapsed = activeCallViewIsCollapsed
self._pipShown = pipShown
}
func makeUIView(context: Context) -> UIView {
@@ -29,120 +23,12 @@ 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
}
}
}
@@ -150,14 +36,11 @@ 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?>, pipShown: Binding<Bool>) {
init(client: WebRTCClient, activeCall: Binding<WebRTCClient.Call?>, localRendererAspectRatio: Binding<CGFloat?>) {
self.client = client
self.activeCall = activeCall
self.localRendererAspectRatio = localRendererAspectRatio
self._pipShown = pipShown
}
func makeUIView(context: Context) -> UIView {
@@ -167,18 +50,12 @@ 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)
}
}
-2
View File
@@ -28,7 +28,6 @@ class Call: ObservableObject, Equatable {
@Published var speakerEnabled = false
@Published var videoEnabled: Bool
@Published var connectionInfo: ConnectionInfo?
@Published var connectedAt: Date? = nil
init(
direction: CallDirection,
@@ -60,7 +59,6 @@ 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,10 +331,6 @@ 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
@@ -414,7 +410,6 @@ 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,9 +29,6 @@ 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,9 +120,6 @@ struct CIVideoView: View {
showFullScreenPlayer = urlDecrypted != nil
}
}
.onChange(of: m.activeCallViewIsCollapsed) { _ in
showFullScreenPlayer = false
}
if !decryptionInProgress {
Button {
decrypt(file: file) {
@@ -171,9 +168,6 @@ struct CIVideoView: View {
default: ()
}
}
.onChange(of: m.activeCallViewIsCollapsed) { _ in
showFullScreenPlayer = false
}
if !videoPlaying {
Button {
m.stopPreviousRecPlay = url
+4 -20
View File
@@ -161,15 +161,11 @@ struct ChatView: View {
HStack {
let callsPrefEnabled = contact.mergedPreferences.calls.enabled.forUser
if callsPrefEnabled {
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)
}
callButton(contact, .audio, imageName: "phone")
.disabled(!contact.ready || !contact.active)
}
Menu {
if callsPrefEnabled && chatModel.activeCall == nil {
if callsPrefEnabled {
Button {
CallController.shared.startCall(contact, .video)
} label: {
@@ -426,19 +422,7 @@ 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,29 +234,39 @@ struct GroupChatInfoView: View {
Spacer()
memberInfo(member)
}
// revert from this:
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.canBeRemoved(groupInfo: groupInfo) {
removeSwipe(member, blockSwipe(member, v))
} else {
if !member.blockedByAdmin {
blockSwipe(member, v)
} else {
v
}
blockSwipe(member, 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,11 +168,24 @@ struct GroupMemberInfoView: View {
}
}
if groupInfo.membership.memberRole >= .admin {
adminDestructiveSection(member)
} else {
nonAdminBlockSection(member)
// revert from this:
Section {
if member.memberSettings.showMessages {
blockMemberButton(member)
} else {
unblockMemberButton(member)
}
if member.canBeRemoved(groupInfo: groupInfo) {
removeMemberButton(member)
}
}
// revert to this: vvv
// if groupInfo.membership.memberRole >= .admin {
// adminDestructiveSection(member)
// } else {
// nonAdminBlockSection(member)
// }
// ^^^
if developerTools {
Section("For console") {
@@ -76,10 +76,6 @@ struct NotificationsView: View {
Text(m.notificationPreview.label)
}
}
if let server = m.notificationServer {
smpServers("Push server", [server])
}
} header: {
Text("Push notifications")
} footer: {
@@ -91,9 +87,6 @@ struct NotificationsView: View {
}
}
.disabled(legacyDatabase)
.onAppear {
(m.savedToken, m.tokenStatus, m.notificationMode, m.notificationServer) = apiGetNtfToken()
}
}
private func notificationAlert(_ alert: NotificationAlert, _ token: DeviceToken) -> Alert {
@@ -132,7 +125,6 @@ struct NotificationsView: View {
m.tokenStatus = .new
notificationMode = .off
m.notificationMode = .off
m.notificationServer = nil
}
} catch let error {
await MainActor.run {
@@ -143,13 +135,11 @@ struct NotificationsView: View {
}
default:
do {
let _ = try await apiRegisterToken(token: token, notificationMode: mode)
let (_, tknStatus, ntfMode, ntfServer) = apiGetNtfToken()
let status = try await apiRegisterToken(token: token, notificationMode: mode)
await MainActor.run {
m.tokenStatus = tknStatus
notificationMode = ntfMode
m.notificationMode = ntfMode
m.notificationServer = ntfServer
m.tokenStatus = status
notificationMode = mode
m.notificationMode = mode
}
} catch let error {
await MainActor.run {
+32 -32
View File
@@ -90,11 +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 */; };
5CB1CE922B86660100963938 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CB1CE8D2B86660100963938 /* libgmp.a */; };
5CB1CE932B86660100963938 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CB1CE8E2B86660100963938 /* libgmpxx.a */; };
5CB1CE942B86660100963938 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CB1CE8F2B86660100963938 /* libffi.a */; };
5CB1CE952B86660100963938 /* libHSsimplex-chat-5.5.5.0-7lQXtpK7ThcLvpQEItJUcV-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CB1CE902B86660100963938 /* libHSsimplex-chat-5.5.5.0-7lQXtpK7ThcLvpQEItJUcV-ghc9.6.3.a */; };
5CB1CE962B86660100963938 /* libHSsimplex-chat-5.5.5.0-7lQXtpK7ThcLvpQEItJUcV.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CB1CE912B86660100963938 /* libHSsimplex-chat-5.5.5.0-7lQXtpK7ThcLvpQEItJUcV.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 */; };
@@ -372,11 +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>"; };
5CB1CE8D2B86660100963938 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
5CB1CE8E2B86660100963938 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; };
5CB1CE8F2B86660100963938 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; };
5CB1CE902B86660100963938 /* libHSsimplex-chat-5.5.5.0-7lQXtpK7ThcLvpQEItJUcV-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.5.5.0-7lQXtpK7ThcLvpQEItJUcV-ghc9.6.3.a"; sourceTree = "<group>"; };
5CB1CE912B86660100963938 /* libHSsimplex-chat-5.5.5.0-7lQXtpK7ThcLvpQEItJUcV.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.5.5.0-7lQXtpK7ThcLvpQEItJUcV.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 = (
5CB1CE932B86660100963938 /* libgmpxx.a in Frameworks */,
5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */,
5CB1CEA02B8771DB00963938 /* libgmp.a in Frameworks */,
5CB1CE9E2B8771DB00963938 /* libgmpxx.a in Frameworks */,
5CB1CE9C2B8771DB00963938 /* libffi.a in Frameworks */,
5CB1CE962B86660100963938 /* libHSsimplex-chat-5.5.5.0-7lQXtpK7ThcLvpQEItJUcV.a in Frameworks */,
5CB1CE922B86660100963938 /* libgmp.a in Frameworks */,
5CB1CE952B86660100963938 /* libHSsimplex-chat-5.5.5.0-7lQXtpK7ThcLvpQEItJUcV-ghc9.6.3.a in Frameworks */,
5CB1CE942B86660100963938 /* libffi.a in Frameworks */,
5CE2BA94284534BB00EC33A6 /* libz.tbd 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 = (
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 */,
5CB1CE8F2B86660100963938 /* libffi.a */,
5CB1CE8D2B86660100963938 /* libgmp.a */,
5CB1CE8E2B86660100963938 /* libgmpxx.a */,
5CB1CE902B86660100963938 /* libHSsimplex-chat-5.5.5.0-7lQXtpK7ThcLvpQEItJUcV-ghc9.6.3.a */,
5CB1CE912B86660100963938 /* libHSsimplex-chat-5.5.5.0-7lQXtpK7ThcLvpQEItJUcV.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 = 199;
CURRENT_PROJECT_VERSION = 200;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
ENABLE_PREVIEWS = YES;
@@ -1531,7 +1531,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 5.5.4;
MARKETING_VERSION = 5.5.5;
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 = 199;
CURRENT_PROJECT_VERSION = 200;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
ENABLE_PREVIEWS = YES;
@@ -1574,7 +1574,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 5.5.4;
MARKETING_VERSION = 5.5.5;
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 = 199;
CURRENT_PROJECT_VERSION = 200;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
GENERATE_INFOPLIST_FILE = YES;
@@ -1646,7 +1646,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 5.5.4;
MARKETING_VERSION = 5.5.5;
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 = 199;
CURRENT_PROJECT_VERSION = 200;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
GENERATE_INFOPLIST_FILE = YES;
@@ -1678,7 +1678,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 5.5.4;
MARKETING_VERSION = 5.5.5;
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 = 199;
CURRENT_PROJECT_VERSION = 200;
DEFINES_MODULE = YES;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
DYLIB_COMPATIBILITY_VERSION = 1;
@@ -1721,7 +1721,7 @@
"$(inherited)",
"$(PROJECT_DIR)/Libraries/sim",
);
MARKETING_VERSION = 5.5.4;
MARKETING_VERSION = 5.5.5;
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 = 199;
CURRENT_PROJECT_VERSION = 200;
DEFINES_MODULE = YES;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
DYLIB_COMPATIBILITY_VERSION = 1;
@@ -1767,7 +1767,7 @@
"$(inherited)",
"$(PROJECT_DIR)/Libraries/sim",
);
MARKETING_VERSION = 5.5.4;
MARKETING_VERSION = 5.5.5;
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat;
PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)";
SDKROOT = iphoneos;
+2 -2
View File
@@ -606,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, ntfServer: String)
case ntfToken(token: DeviceToken, status: NtfTknStatus, ntfMode: NotificationsMode)
case ntfMessages(user_: User?, connEntity_: ConnectionEntity?, msgTs: Date?, ntfMessages: [NtfMsgInfo])
case ntfMessage(user: UserRef, connEntity: ConnectionEntity, ntfMessage: NtfMsgInfo)
case contactConnectionDeleted(user: UserRef, connection: PendingContactConnection)
@@ -905,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, ntfServer): return "token: \(token)\nstatus: \(status.rawValue)\nntfMode: \(ntfMode.rawValue)\nntfServer: \(ntfServer)"
case let .ntfToken(token, status, ntfMode): return "token: \(token)\nstatus: \(status.rawValue)\nntfMode: \(ntfMode.rawValue)"
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))
+1 -1
View File
@@ -2268,7 +2268,7 @@ public struct ChatItem: Identifiable, Decodable {
case .rcvDirectEvent(rcvDirectEvent: let rcvDirectEvent):
switch rcvDirectEvent {
case .contactDeleted: return false
case .profileUpdated: return false
case .profileUpdated: return true
}
case .rcvGroupEvent(rcvGroupEvent: let rcvGroupEvent):
switch rcvGroupEvent {
@@ -103,14 +103,11 @@
</intent-filter>
</activity-alias>
<activity android:name=".views.call.CallActivity"
<activity android:name=".views.call.IncomingCallActivity"
android:showOnLockScreen="true"
android:exported="false"
android:launchMode="singleInstance"
android:supportsPictureInPicture="true"
android:autoRemoveFromRecents="true"
android:screenOrientation="portrait"
android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation"/>
android:launchMode="singleTask"/>
<provider
android:name="androidx.core.content.FileProvider"
@@ -136,18 +133,6 @@
android:stopWithTask="false"></service>
<!-- SimplexService restart on reboot -->
<service
android:name=".CallService"
android:enabled="true"
android:exported="false"
android:stopWithTask="false"/>
<receiver
android:name=".CallService$CallActionReceiver"
android:enabled="true"
android:exported="false" />
<receiver
android:name=".SimplexService$StartReceiver"
android:enabled="true"
@@ -1,176 +0,0 @@
package chat.simplex.app
import android.app.*
import android.content.*
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.os.*
import androidx.compose.ui.graphics.asAndroidBitmap
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import chat.simplex.app.model.NtfManager.EndCallAction
import chat.simplex.app.views.call.CallActivity
import chat.simplex.common.model.NotificationPreviewMode
import chat.simplex.common.platform.*
import chat.simplex.common.views.call.CallState
import chat.simplex.common.views.helpers.*
import chat.simplex.res.MR
import kotlinx.datetime.Instant
class CallService: Service() {
private var wakeLock: PowerManager.WakeLock? = null
private var notificationManager: NotificationManager? = null
private var serviceNotification: Notification? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
Log.d(TAG, "onStartCommand startId: $startId")
if (intent != null) {
val action = intent.action
Log.d(TAG, "intent action $action")
when (action) {
Action.START.name -> startService()
else -> Log.e(TAG, "No action in the intent")
}
} else {
Log.d(TAG, "null intent. Probably restarted by the system.")
}
startForeground(CALL_SERVICE_ID, serviceNotification)
return START_STICKY
}
override fun onCreate() {
super.onCreate()
Log.d(TAG, "Call service created")
notificationManager = createNotificationChannel()
updateNotification()
startForeground(CALL_SERVICE_ID, serviceNotification)
}
override fun onDestroy() {
Log.d(TAG, "Call service destroyed")
try {
wakeLock?.let {
while (it.isHeld) it.release() // release all, in case acquired more than once
}
wakeLock = null
} catch (e: Exception) {
Log.d(TAG, "Exception while releasing wakelock: ${e.message}")
}
super.onDestroy()
}
private fun startService() {
Log.d(TAG, "CallService startService")
if (wakeLock != null) return
wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager).run {
newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, WAKE_LOCK_TAG).apply {
acquire()
}
}
}
fun updateNotification() {
val call = chatModel.activeCall.value
val previewMode = appPreferences.notificationPreviewMode.get()
val title = if (previewMode == NotificationPreviewMode.HIDDEN.name)
generalGetString(MR.strings.notification_preview_somebody)
else
call?.contact?.profile?.displayName ?: ""
val text = generalGetString(if (call?.supportsVideo() == true) MR.strings.call_service_notification_video_call else MR.strings.call_service_notification_audio_call)
val image = call?.contact?.image
val largeIcon = if (image == null || previewMode == NotificationPreviewMode.HIDDEN.name)
BitmapFactory.decodeResource(resources, R.drawable.icon)
else
base64ToBitmap(image).asAndroidBitmap()
serviceNotification = createNotification(title, text, largeIcon, call?.connectedAt)
startForeground(CALL_SERVICE_ID, serviceNotification)
}
private fun createNotificationChannel(): NotificationManager? {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val channel = NotificationChannel(CALL_NOTIFICATION_CHANNEL_ID, CALL_NOTIFICATION_CHANNEL_NAME, NotificationManager.IMPORTANCE_DEFAULT)
notificationManager.createNotificationChannel(channel)
return notificationManager
}
return null
}
private fun createNotification(title: String, text: String, icon: Bitmap, connectedAt: Instant? = null): Notification {
val pendingIntent: PendingIntent = Intent(this, CallActivity::class.java).let { notificationIntent ->
PendingIntent.getActivity(this, 0, notificationIntent, PendingIntent.FLAG_IMMUTABLE)
}
val endCallPendingIntent: PendingIntent = Intent(this, CallActionReceiver::class.java).let { notificationIntent ->
notificationIntent.setAction(EndCallAction)
PendingIntent.getBroadcast(this, 1, notificationIntent, PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_IMMUTABLE)
}
val builder = NotificationCompat.Builder(this, CALL_NOTIFICATION_CHANNEL_ID)
.setSmallIcon(R.drawable.ntf_icon)
.setLargeIcon(icon.clipToCircle())
.setColor(0x88FFFF)
.setContentTitle(title)
.setContentText(text)
.setContentIntent(pendingIntent)
.setSilent(true)
.addAction(R.drawable.ntf_icon, generalGetString(MR.strings.call_service_notification_end_call), endCallPendingIntent)
if (connectedAt != null) {
builder.setUsesChronometer(true)
builder.setWhen(connectedAt.epochSeconds * 1000)
}
return builder.build()
}
override fun onBind(intent: Intent): IBinder {
return CallServiceBinder()
}
inner class CallServiceBinder : Binder() {
fun getService() = this@CallService
}
enum class Action {
START,
}
class CallActionReceiver: BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
when (intent?.action) {
EndCallAction -> {
val call = chatModel.activeCall.value
if (call != null) {
withBGApi {
chatModel.callManager.endCall(call)
}
}
}
else -> {
Log.e(TAG, "Unknown action. Make sure you provided an action")
}
}
}
}
companion object {
const val TAG = "CALL_SERVICE"
const val CALL_NOTIFICATION_CHANNEL_ID = "chat.simplex.app.CALL_SERVICE_NOTIFICATION"
const val CALL_NOTIFICATION_CHANNEL_NAME = "SimpleX Chat call service"
const val CALL_SERVICE_ID = 6788
const val WAKE_LOCK_TAG = "CallService::lock"
fun startService(): Intent {
Log.d(TAG, "CallService start")
return Intent(androidAppContext, CallService::class.java).also {
it.action = Action.START.name
ContextCompat.startForegroundService(androidAppContext, it)
}
}
fun stopService() {
androidAppContext.stopService(Intent(androidAppContext, CallService::class.java))
}
}
}
@@ -1,15 +1,14 @@
package chat.simplex.app
import android.app.*
import android.app.Application
import android.content.Context
import androidx.compose.ui.platform.ClipboardManager
import chat.simplex.common.platform.Log
import android.content.Intent
import android.app.UiModeManager
import android.os.*
import androidx.lifecycle.*
import androidx.work.*
import chat.simplex.app.model.NtfManager
import chat.simplex.app.model.NtfManager.AcceptCallAction
import chat.simplex.app.views.call.CallActivity
import chat.simplex.common.helpers.APPLICATION_ID
import chat.simplex.common.helpers.requiresIgnoringBattery
import chat.simplex.common.model.*
@@ -19,7 +18,6 @@ import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.CurrentColors
import chat.simplex.common.ui.theme.DefaultTheme
import chat.simplex.common.views.call.RcvCallInvitation
import chat.simplex.common.views.call.activeCallDestroyWebView
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.onboarding.OnboardingStage
import com.jakewharton.processphoenix.ProcessPhoenix
@@ -186,10 +184,6 @@ class SimplexApp: Application(), LifecycleEventObserver {
SimplexService.safeStopService()
}
override fun androidCallServiceSafeStop() {
CallService.stopService()
}
override fun androidNotificationsModeChanged(mode: NotificationsMode) {
if (mode.requiresIgnoringBattery && !SimplexService.isBackgroundAllowed()) {
appPrefs.backgroundServiceNoticeShown.set(false)
@@ -260,28 +254,6 @@ class SimplexApp: Application(), LifecycleEventObserver {
uiModeManager.setApplicationNightMode(mode)
}
override fun androidStartCallActivity(acceptCall: Boolean, remoteHostId: Long?, chatId: ChatId?) {
val context = mainActivity.get() ?: return
val intent = Intent(context, CallActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION)
if (acceptCall) {
intent.setAction(AcceptCallAction)
.putExtra("remoteHostId", remoteHostId)
.putExtra("chatId", chatId)
}
intent.flags += Intent.FLAG_ACTIVITY_BROUGHT_TO_FRONT
context.startActivity(intent)
}
override fun androidPictureInPictureAllowed(): Boolean {
val appOps = androidAppContext.getSystemService(Context.APP_OPS_SERVICE) as AppOpsManager
return appOps.checkOpNoThrow(AppOpsManager.OPSTR_PICTURE_IN_PICTURE, Process.myUid(), packageName) == AppOpsManager.MODE_ALLOWED
}
override fun androidCallEnded() {
activeCallDestroyWebView()
}
override suspend fun androidAskToAllowBackgroundCalls(): Boolean {
if (SimplexService.isBackgroundRestricted()) {
val userChoice: CompletableDeferred<Boolean> = CompletableDeferred()
@@ -34,13 +34,12 @@ import kotlin.system.exitProcess
class SimplexService: Service() {
private var wakeLock: PowerManager.WakeLock? = null
private var isCheckingNewMessages = false
private var isStartingService = false
private var notificationManager: NotificationManager? = null
private var serviceNotification: Notification? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
Log.d(TAG, "onStartCommand startId: $startId")
isServiceStarting = false
if (intent != null) {
val action = intent.action
Log.d(TAG, "intent action $action")
@@ -72,7 +71,6 @@ class SimplexService: Service() {
stopForeground(true)
stopSelf()
} else {
isServiceStarting = false
isServiceStarted = true
// In case of self-destruct is enabled the initialization process will not start in SimplexApp, Let's start it here
if (DatabaseUtils.ksSelfDestructPassword.get() != null && chatModel.chatDbStatus.value == null) {
@@ -91,7 +89,6 @@ class SimplexService: Service() {
} catch (e: Exception) {
Log.d(TAG, "Exception while releasing wakelock: ${e.message}")
}
isServiceStarting = false
isServiceStarted = false
stopAfterStart = false
saveServiceState(this, ServiceState.STOPPED)
@@ -104,9 +101,9 @@ class SimplexService: Service() {
private fun startService() {
Log.d(TAG, "SimplexService startService")
if (wakeLock != null || isCheckingNewMessages) return
if (wakeLock != null || isStartingService) return
val self = this
isCheckingNewMessages = true
isStartingService = true
withLongRunningApi {
val chatController = ChatController
waitDbMigrationEnds(chatController)
@@ -126,7 +123,7 @@ class SimplexService: Service() {
}
}
} finally {
isCheckingNewMessages = false
isStartingService = false
}
}
}
@@ -265,7 +262,6 @@ class SimplexService: Service() {
private const val SHARED_PREFS_SERVICE_STATE = "SIMPLEX_SERVICE_STATE"
private const val WORK_NAME_ONCE = "ServiceStartWorkerOnce"
var isServiceStarting = false
var isServiceStarted = false
private var stopAfterStart = false
@@ -285,7 +281,7 @@ class SimplexService: Service() {
fun safeStopService() {
if (isServiceStarted) {
androidAppContext.stopService(Intent(androidAppContext, SimplexService::class.java))
} else if (isServiceStarting) {
} else {
stopAfterStart = true
}
}
@@ -295,7 +291,6 @@ class SimplexService: Service() {
withContext(Dispatchers.IO) {
Intent(androidAppContext, SimplexService::class.java).also {
it.action = action.name
isServiceStarting = true
ContextCompat.startForegroundService(androidAppContext, it)
}
}
@@ -13,7 +13,7 @@ import androidx.compose.ui.graphics.asAndroidBitmap
import androidx.core.app.*
import chat.simplex.app.*
import chat.simplex.app.TAG
import chat.simplex.app.views.call.CallActivity
import chat.simplex.app.views.call.IncomingCallActivity
import chat.simplex.app.views.call.getKeyguardManager
import chat.simplex.common.views.helpers.*
import chat.simplex.common.model.*
@@ -33,7 +33,6 @@ object NtfManager {
const val CallChannel: String = "chat.simplex.app.CALL_NOTIFICATION_2"
const val AcceptCallAction: String = "chat.simplex.app.ACCEPT_CALL"
const val RejectCallAction: String = "chat.simplex.app.REJECT_CALL"
const val EndCallAction: String = "chat.simplex.app.END_CALL"
const val CallNotificationId: Int = -1
private const val UserIdKey: String = "userId"
private const val ChatIdKey: String = "chatId"
@@ -158,7 +157,7 @@ object NtfManager {
val screenOff = displayManager.displays.all { it.state != Display.STATE_ON }
var ntfBuilder =
if ((keyguardManager.isKeyguardLocked || screenOff) && appPreferences.callOnLockScreen.get() != CallOnLockScreen.DISABLE) {
val fullScreenIntent = Intent(context, CallActivity::class.java)
val fullScreenIntent = Intent(context, IncomingCallActivity::class.java)
val fullScreenPendingIntent = PendingIntent.getActivity(context, 0, fullScreenIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
NotificationCompat.Builder(context, CallChannel)
.setFullScreenIntent(fullScreenPendingIntent, true)
@@ -1,18 +1,17 @@
package chat.simplex.app.views.call
import android.app.*
import android.content.*
import android.content.res.Configuration
import android.graphics.Rect
import android.os.*
import android.util.Rational
import android.view.*
import android.app.Activity
import android.app.KeyguardManager
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.Bundle
import chat.simplex.common.platform.Log
import android.view.WindowManager
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.trackPipAnimationHintView
import androidx.compose.desktop.ui.tooling.preview.Preview
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
@@ -23,115 +22,33 @@ import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.Lifecycle
import chat.simplex.app.*
import chat.simplex.app.R
import chat.simplex.app.TAG
import chat.simplex.app.model.NtfManager
import chat.simplex.app.model.NtfManager.AcceptCallAction
import chat.simplex.common.model.*
import chat.simplex.common.platform.*
import chat.simplex.app.model.NtfManager.OpenChatAction
import chat.simplex.common.platform.ntfManager
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.call.*
import chat.simplex.common.views.helpers.*
import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.stringResource
import kotlinx.coroutines.launch
import kotlinx.datetime.Clock
import java.lang.ref.WeakReference
import chat.simplex.common.platform.chatModel as m
class CallActivity: ComponentActivity(), ServiceConnection {
var boundService: CallService? = null
class IncomingCallActivity: ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
callActivity = WeakReference(this)
when (intent?.action) {
AcceptCallAction -> {
val remoteHostId = intent.getLongExtra("remoteHostId", -1).takeIf { it != -1L }
val chatId = intent.getStringExtra("chatId")
val invitation = (m.callInvitations.values + m.activeCallInvitation.value).lastOrNull {
it?.remoteHostId == remoteHostId && it?.contact?.id == chatId
}
if (invitation != null) {
m.callManager.acceptIncomingCall(invitation = invitation)
}
}
}
setContent { CallActivityView() }
if (isOnLockScreenNow()) {
unlockForIncomingCall()
}
setContent { IncomingCallActivityView(ChatModel) }
unlockForIncomingCall()
}
override fun onDestroy() {
super.onDestroy()
if (isOnLockScreenNow()) {
lockAfterIncomingCall()
}
try {
unbindService(this)
} catch (e: Exception) {
Log.i(TAG, "Unable to unbind service: " + e.stackTraceToString())
}
}
private fun isOnLockScreenNow() = getKeyguardManager(this).isKeyguardLocked
fun setPipParams(video: Boolean, sourceRectHint: Rect? = null, viewRatio: Rational? = null) {
// By manually specifying source rect we exclude empty background while toggling PiP
val builder = PictureInPictureParams.Builder()
.setAspectRatio(viewRatio)
.setSourceRectHint(sourceRectHint)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
builder.setAutoEnterEnabled(video)
}
setPictureInPictureParams(builder.build())
}
override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean, newConfig: Configuration) {
super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig)
m.activeCallViewIsCollapsed.value = isInPictureInPictureMode
val layoutType = if (!isInPictureInPictureMode) {
LayoutType.Default
} else {
LayoutType.RemoteVideo
}
m.callCommand.add(WCallCommand.Layout(layoutType))
}
override fun onBackPressed() {
if (isOnLockScreenNow()) {
super.onBackPressed()
} else {
m.activeCallViewIsCollapsed.value = true
}
}
override fun onPictureInPictureRequested(): Boolean {
Log.d(TAG, "Requested picture-in-picture from the system")
return super.onPictureInPictureRequested()
}
override fun onUserLeaveHint() {
// On Android 12+ PiP is enabled automatically when a user hides the app
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.R && callSupportsVideo() && platform.androidPictureInPictureAllowed()) {
enterPictureInPictureMode()
}
}
override fun onResume() {
super.onResume()
m.activeCallViewIsCollapsed.value = false
lockAfterIncomingCall()
}
private fun unlockForIncomingCall() {
@@ -155,23 +72,6 @@ class CallActivity: ComponentActivity(), ServiceConnection {
}
}
fun startServiceAndBind() {
/**
* On Android 12 there is a bug that prevents starting activity after pressing back button
* (the error says that it denies to start activity in background).
* Workaround is to bind to a service
* */
bindService(CallService.startService(), this, 0)
}
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
boundService = (service as CallService.CallServiceBinder).getService()
}
override fun onServiceDisconnected(name: ComponentName?) {
boundService = null
}
companion object {
const val activityFlags = WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON or WindowManager.LayoutParams.FLAG_ALLOW_LOCK_WHILE_SCREEN_ON
}
@@ -180,96 +80,38 @@ class CallActivity: ComponentActivity(), ServiceConnection {
fun getKeyguardManager(context: Context): KeyguardManager =
context.getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager
private fun callSupportsVideo() = m.activeCall.value?.supportsVideo() == true || m.activeCallInvitation.value?.callType?.media == CallMediaType.Video
@Composable
fun CallActivityView() {
fun IncomingCallActivityView(m: ChatModel) {
val switchingCall = m.switchingCall.value
val invitation = m.activeCallInvitation.value
val call = remember { m.activeCall }.value
val call = m.activeCall.value
val showCallView = m.showCallView.value
val activity = LocalContext.current as CallActivity
LaunchedEffect(Unit) {
snapshotFlow { m.activeCallViewIsCollapsed.value }
.collect { collapsed ->
when {
collapsed -> {
if (!platform.androidPictureInPictureAllowed() || !callSupportsVideo()) {
activity.moveTaskToBack(true)
activity.startActivity(Intent(activity, MainActivity::class.java))
} else if (!activity.isInPictureInPictureMode && activity.lifecycle.currentState == Lifecycle.State.RESUMED) {
// User pressed back button, show MainActivity
activity.startActivity(Intent(activity, MainActivity::class.java))
activity.enterPictureInPictureMode()
}
}
callSupportsVideo() && !platform.androidPictureInPictureAllowed() -> {
// PiP disabled by user
platform.androidStartCallActivity(false)
}
activity.isInPictureInPictureMode -> {
platform.androidStartCallActivity(false)
}
}
}
}
SimpleXTheme {
var prevCall by remember { mutableStateOf(call) }
KeyChangeEffect(m.activeCall.value) {
if (m.activeCall.value != null) {
prevCall = m.activeCall.value
activity.boundService?.updateNotification()
}
}
Box(Modifier.background(Color.Black)) {
if (call != null) {
val view = LocalView.current
ActiveCallView()
if (callSupportsVideo()) {
val scope = rememberCoroutineScope()
LaunchedEffect(Unit) {
scope.launch {
activity.setPipParams(callSupportsVideo(), viewRatio = Rational(view.width, view.height))
activity.trackPipAnimationHintView(view)
}
}
}
} else if (prevCall != null) {
prevCall?.let { ActiveCallOverlayDisabled(it) }
}
if (invitation != null) {
if (call == null) {
Surface(
Modifier
.fillMaxSize(),
color = MaterialTheme.colors.background,
contentColor = LocalContentColor.current
) {
IncomingCallLockScreenAlert(invitation, m)
}
} else {
IncomingCallAlertView(invitation, m)
}
}
}
}
LaunchedEffect(call == null) {
if (call != null) {
activity.startServiceAndBind()
}
}
val activity = LocalContext.current as Activity
LaunchedEffect(invitation, call, switchingCall, showCallView) {
if (!switchingCall && invitation == null && (!showCallView || call == null)) {
Log.d(TAG, "CallActivityView: finishing activity")
Log.d(TAG, "IncomingCallActivityView: finishing activity")
activity.finish()
}
}
SimpleXTheme {
Surface(
Modifier
.fillMaxSize(),
color = MaterialTheme.colors.background,
contentColor = LocalContentColor.current
) {
if (showCallView) {
Box {
ActiveCallView()
if (invitation != null) IncomingCallAlertView(invitation, m)
}
} else if (invitation != null) {
IncomingCallLockScreenAlert(invitation, m)
}
}
}
}
/**
* Related to lockscreen
* */
@Composable
fun IncomingCallLockScreenAlert(invitation: RcvCallInvitation, chatModel: ChatModel) {
val cm = chatModel.callManager
@@ -293,7 +135,7 @@ fun IncomingCallLockScreenAlert(invitation: RcvCallInvitation, chatModel: ChatMo
acceptCall = { cm.acceptIncomingCall(invitation = invitation) },
openApp = {
val intent = Intent(context, MainActivity::class.java)
.setAction(NtfManager.OpenChatAction)
.setAction(OpenChatAction)
.putExtra("userId", invitation.user.userId)
.putExtra("chatId", invitation.contact.id)
context.startActivity(intent)
@@ -4,7 +4,6 @@ import android.annotation.SuppressLint
import android.content.Context
import android.net.LocalServerSocket
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.fragment.app.FragmentActivity
import chat.simplex.common.*
import chat.simplex.common.platform.*
@@ -26,8 +25,7 @@ val defaultLocale: Locale = Locale.getDefault()
@SuppressLint("StaticFieldLeak")
lateinit var androidAppContext: Context
var mainActivity: WeakReference<FragmentActivity> = WeakReference(null)
var callActivity: WeakReference<ComponentActivity> = WeakReference(null)
lateinit var mainActivity: WeakReference<FragmentActivity>
fun initHaskell() {
val socketName = "chat.simplex.app.local.socket.address.listen.native.cmd2" + Random.nextLong(100000)
@@ -61,16 +61,6 @@ 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"
@@ -28,7 +28,6 @@ import androidx.compose.ui.platform.LocalLifecycleOwner
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.Lifecycle
@@ -51,30 +50,20 @@ import kotlinx.datetime.Clock
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
// Should be destroy()'ed and set as null when call is ended. Otherwise, it will be a leak
@SuppressLint("StaticFieldLeak")
private var staticWebView: WebView? = null
// WebView methods must be called on Main thread
fun activeCallDestroyWebView() = withApi {
// Stop it when call ended
platform.androidCallServiceSafeStop()
staticWebView?.destroy()
staticWebView = null
Log.d(TAG, "CallView: webview was destroyed")
}
@SuppressLint("SourceLockedOrientationActivity")
@Composable
actual fun ActiveCallView() {
val chatModel = ChatModel
BackHandler(onBack = {
val call = chatModel.activeCall.value
if (call != null) withBGApi { chatModel.callManager.endCall(call) }
})
val audioViaBluetooth = rememberSaveable { mutableStateOf(false) }
val proximityLock = remember {
val pm = (androidAppContext.getSystemService(Context.POWER_SERVICE) as PowerManager)
if (pm.isWakeLockLevelSupported(PROXIMITY_SCREEN_OFF_WAKE_LOCK)) {
pm.newWakeLock(PROXIMITY_SCREEN_OFF_WAKE_LOCK, androidAppContext.packageName + ":proximityLock")
} else {
null
}
val ntfModeService = remember { chatModel.controller.appPrefs.notificationsMode.get() == NotificationsMode.SERVICE }
LaunchedEffect(Unit) {
// Start service when call happening since it's not already started.
// It's needed to prevent Android from shutting down a microphone after a minute or so when screen is off
if (!ntfModeService) platform.androidServiceStart()
}
DisposableEffect(Unit) {
val am = androidAppContext.getSystemService(Context.AUDIO_SERVICE) as AudioManager
@@ -104,24 +93,22 @@ actual fun ActiveCallView() {
}
}
am.registerAudioDeviceCallback(audioCallback, null)
val pm = (androidAppContext.getSystemService(Context.POWER_SERVICE) as PowerManager)
val proximityLock = if (pm.isWakeLockLevelSupported(PROXIMITY_SCREEN_OFF_WAKE_LOCK)) {
pm.newWakeLock(PROXIMITY_SCREEN_OFF_WAKE_LOCK, androidAppContext.packageName + ":proximityLock")
} else {
null
}
proximityLock?.acquire()
onDispose {
// Stop it when call ended
if (!ntfModeService) platform.androidServiceSafeStop()
dropAudioManagerOverrides()
am.unregisterAudioDeviceCallback(audioCallback)
if (proximityLock?.isHeld == true) {
proximityLock.release()
}
}
}
LaunchedEffect(chatModel.activeCallViewIsCollapsed.value) {
if (chatModel.activeCallViewIsCollapsed.value) {
if (proximityLock?.isHeld == true) proximityLock.release()
} else {
delay(1000)
if (proximityLock?.isHeld == false) proximityLock.acquire()
proximityLock?.release()
}
}
val scope = rememberCoroutineScope()
val call = chatModel.activeCall.value
Box(Modifier.fillMaxSize()) {
WebRTCView(chatModel.callCommand) { apiMsg ->
Log.d(TAG, "received from WebRTCView: $apiMsg")
@@ -133,15 +120,15 @@ actual fun ActiveCallView() {
is WCallResponse.Capabilities -> withBGApi {
val callType = CallType(call.localMedia, r.capabilities)
chatModel.controller.apiSendCallInvitation(callRh, call.contact, callType)
updateActiveCall(call) { it.copy(callState = CallState.InvitationSent, localCapabilities = r.capabilities) }
chatModel.activeCall.value = call.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)
updateActiveCall(call) { it.copy(callState = CallState.OfferSent, localCapabilities = r.capabilities) }
chatModel.activeCall.value = call.copy(callState = CallState.OfferSent, localCapabilities = r.capabilities)
}
is WCallResponse.Answer -> withBGApi {
chatModel.controller.apiSendCallAnswer(callRh, call.contact, r.answer, r.iceCandidates)
updateActiveCall(call) { it.copy(callState = CallState.Negotiated) }
chatModel.activeCall.value = call.copy(callState = CallState.Negotiated)
}
is WCallResponse.Ice -> withBGApi {
chatModel.controller.apiSendCallExtraInfo(callRh, call.contact, r.iceCandidates)
@@ -150,7 +137,7 @@ actual fun ActiveCallView() {
try {
val callStatus = json.decodeFromString<WebRTCCallStatus>("\"${r.state.connectionState}\"")
if (callStatus == WebRTCCallStatus.Connected) {
updateActiveCall(call) { it.copy(callState = CallState.Connected, connectedAt = Clock.System.now()) }
chatModel.activeCall.value = call.copy(callState = CallState.Connected, connectedAt = Clock.System.now())
setCallSound(call.soundSpeaker, audioViaBluetooth)
}
withBGApi { chatModel.controller.apiCallStatus(callRh, call.contact, callStatus) }
@@ -158,7 +145,7 @@ actual fun ActiveCallView() {
Log.d(TAG,"call status ${r.state.connectionState} not used")
}
is WCallResponse.Connected -> {
updateActiveCall(call) { it.copy(callState = CallState.Connected, connectionInfo = r.connectionInfo) }
chatModel.activeCall.value = call.copy(callState = CallState.Connected, connectionInfo = r.connectionInfo)
scope.launch {
setCallSound(call.soundSpeaker, audioViaBluetooth)
}
@@ -167,29 +154,27 @@ actual fun ActiveCallView() {
withBGApi { chatModel.callManager.endCall(call) }
}
is WCallResponse.Ended -> {
updateActiveCall(call) { it.copy(callState = CallState.Ended) }
chatModel.activeCall.value = call.copy(callState = CallState.Ended)
withBGApi { chatModel.callManager.endCall(call) }
chatModel.showCallView.value = false
}
is WCallResponse.Ok -> when (val cmd = apiMsg.command) {
is WCallCommand.Answer ->
updateActiveCall(call) { it.copy(callState = CallState.Negotiated) }
chatModel.activeCall.value = call.copy(callState = CallState.Negotiated)
is WCallCommand.Media -> {
updateActiveCall(call) {
when (cmd.media) {
CallMediaType.Video -> it.copy(videoEnabled = cmd.enable)
CallMediaType.Audio -> it.copy(audioEnabled = cmd.enable)
}
when (cmd.media) {
CallMediaType.Video -> chatModel.activeCall.value = call.copy(videoEnabled = cmd.enable)
CallMediaType.Audio -> chatModel.activeCall.value = call.copy(audioEnabled = cmd.enable)
}
}
is WCallCommand.Camera -> {
updateActiveCall(call) { it.copy(localCamera = cmd.camera) }
chatModel.activeCall.value = call.copy(localCamera = cmd.camera)
if (!call.audioEnabled) {
chatModel.callCommand.add(WCallCommand.Media(CallMediaType.Audio, enable = false))
}
}
is WCallCommand.End -> {
withBGApi { chatModel.callManager.endCall(call) }
}
is WCallCommand.End ->
chatModel.showCallView.value = false
else -> {}
}
is WCallResponse.Error -> {
@@ -198,16 +183,8 @@ actual fun ActiveCallView() {
}
}
}
val showOverlay = when {
call == null -> false
!platform.androidPictureInPictureAllowed() -> true
!call.supportsVideo() -> true
!chatModel.activeCallViewIsCollapsed.value -> true
else -> false
}
if (call != null && showOverlay) {
ActiveCallOverlay(call, chatModel, audioViaBluetooth)
}
val call = chatModel.activeCall.value
if (call != null) ActiveCallOverlay(call, chatModel, audioViaBluetooth)
}
val context = LocalContext.current
@@ -252,20 +229,6 @@ private fun ActiveCallOverlay(call: Call, chatModel: ChatModel, audioViaBluetoot
)
}
@Composable
fun ActiveCallOverlayDisabled(call: Call) {
ActiveCallOverlayLayout(
call = call,
speakerCanBeEnabled = false,
enabled = false,
dismiss = {},
toggleAudio = {},
toggleVideo = {},
toggleSound = {},
flipCamera = {}
)
}
private fun setCallSound(speaker: Boolean, audioViaBluetooth: MutableState<Boolean>) {
val am = androidAppContext.getSystemService(Context.AUDIO_SERVICE) as AudioManager
Log.d(TAG, "setCallSound: set audio mode, speaker enabled: $speaker")
@@ -308,69 +271,59 @@ private fun dropAudioManagerOverrides() {
private fun ActiveCallOverlayLayout(
call: Call,
speakerCanBeEnabled: Boolean,
enabled: Boolean = true,
dismiss: () -> Unit,
toggleAudio: () -> Unit,
toggleVideo: () -> Unit,
toggleSound: () -> Unit,
flipCamera: () -> Unit
) {
Column {
val media = call.peerMedia ?: call.localMedia
CloseSheetBar({ chatModel.activeCallViewIsCollapsed.value = true }, true, tintColor = Color(0xFFFFFFD8)) {
if (media == CallMediaType.Video) {
Text(call.contact.chatViewName, Modifier.fillMaxWidth().padding(end = DEFAULT_PADDING), color = Color(0xFFFFFFD8), style = MaterialTheme.typography.h2, overflow = TextOverflow.Ellipsis, maxLines = 1)
}
}
Column(Modifier.padding(horizontal = DEFAULT_PADDING)) {
when (media) {
CallMediaType.Video -> {
VideoCallInfoView(call)
Box(Modifier.fillMaxWidth().fillMaxHeight().weight(1f), contentAlignment = Alignment.BottomCenter) {
DisabledBackgroundCallsButton()
Column(Modifier.padding(DEFAULT_PADDING)) {
when (call.peerMedia ?: call.localMedia) {
CallMediaType.Video -> {
CallInfoView(call, alignment = Alignment.Start)
Box(Modifier.fillMaxWidth().fillMaxHeight().weight(1f), contentAlignment = Alignment.BottomCenter) {
DisabledBackgroundCallsButton()
}
Row(Modifier.fillMaxWidth().padding(horizontal = 6.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
ToggleAudioButton(call, toggleAudio)
Spacer(Modifier.size(40.dp))
IconButton(onClick = dismiss) {
Icon(painterResource(MR.images.ic_call_end_filled), stringResource(MR.strings.icon_descr_hang_up), tint = Color.Red, modifier = Modifier.size(64.dp))
}
Row(Modifier.fillMaxWidth().padding(horizontal = 6.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
ToggleAudioButton(call, enabled, toggleAudio)
Spacer(Modifier.size(40.dp))
IconButton(onClick = dismiss, enabled = enabled) {
Icon(painterResource(MR.images.ic_call_end_filled), stringResource(MR.strings.icon_descr_hang_up), tint = if (enabled) Color.Red else MaterialTheme.colors.secondary, modifier = Modifier.size(64.dp))
}
if (call.videoEnabled) {
ControlButton(call, painterResource(MR.images.ic_flip_camera_android_filled), MR.strings.icon_descr_flip_camera, enabled, flipCamera)
ControlButton(call, painterResource(MR.images.ic_videocam_filled), MR.strings.icon_descr_video_off, enabled, toggleVideo)
} else {
Spacer(Modifier.size(48.dp))
ControlButton(call, painterResource(MR.images.ic_videocam_off), MR.strings.icon_descr_video_on, enabled, toggleVideo)
}
if (call.videoEnabled) {
ControlButton(call, painterResource(MR.images.ic_flip_camera_android_filled), MR.strings.icon_descr_flip_camera, flipCamera)
ControlButton(call, painterResource(MR.images.ic_videocam_filled), MR.strings.icon_descr_video_off, toggleVideo)
} else {
Spacer(Modifier.size(48.dp))
ControlButton(call, painterResource(MR.images.ic_videocam_off), MR.strings.icon_descr_video_on, toggleVideo)
}
}
CallMediaType.Audio -> {
Spacer(Modifier.fillMaxHeight().weight(1f))
Column(
Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
ProfileImage(size = 192.dp, image = call.contact.profile.image)
AudioCallInfoView(call)
}
Box(Modifier.fillMaxWidth().fillMaxHeight().weight(1f), contentAlignment = Alignment.BottomCenter) {
DisabledBackgroundCallsButton()
}
Box(Modifier.fillMaxWidth().padding(bottom = DEFAULT_BOTTOM_PADDING), contentAlignment = Alignment.CenterStart) {
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
IconButton(onClick = dismiss, enabled = enabled) {
Icon(painterResource(MR.images.ic_call_end_filled), stringResource(MR.strings.icon_descr_hang_up), tint = if (enabled) Color.Red else MaterialTheme.colors.secondary, modifier = Modifier.size(64.dp))
}
}
CallMediaType.Audio -> {
Spacer(Modifier.fillMaxHeight().weight(1f))
Column(
Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
ProfileImage(size = 192.dp, image = call.contact.profile.image)
CallInfoView(call, alignment = Alignment.CenterHorizontally)
}
Box(Modifier.fillMaxWidth().fillMaxHeight().weight(1f), contentAlignment = Alignment.BottomCenter) {
DisabledBackgroundCallsButton()
}
Box(Modifier.fillMaxWidth().padding(bottom = DEFAULT_BOTTOM_PADDING), contentAlignment = Alignment.CenterStart) {
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
IconButton(onClick = dismiss) {
Icon(painterResource(MR.images.ic_call_end_filled), stringResource(MR.strings.icon_descr_hang_up), tint = Color.Red, modifier = Modifier.size(64.dp))
}
Box(Modifier.padding(start = 32.dp)) {
ToggleAudioButton(call, enabled, toggleAudio)
}
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.CenterEnd) {
Box(Modifier.padding(end = 32.dp)) {
ToggleSoundButton(call, speakerCanBeEnabled && enabled, toggleSound)
}
}
Box(Modifier.padding(start = 32.dp)) {
ToggleAudioButton(call, toggleAudio)
}
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.CenterEnd) {
Box(Modifier.padding(end = 32.dp)) {
ToggleSoundButton(call, speakerCanBeEnabled, toggleSound)
}
}
}
@@ -380,7 +333,7 @@ private fun ActiveCallOverlayLayout(
}
@Composable
private fun ControlButton(call: Call, icon: Painter, iconText: StringResource, enabled: Boolean = true, action: () -> Unit) {
private fun ControlButton(call: Call, icon: Painter, iconText: StringResource, action: () -> Unit, enabled: Boolean = true) {
if (call.hasMedia) {
IconButton(onClick = action, enabled = enabled) {
Icon(icon, stringResource(iconText), tint = if (enabled) Color(0xFFFFFFD8) else MaterialTheme.colors.secondary, modifier = Modifier.size(40.dp))
@@ -391,26 +344,28 @@ private fun ControlButton(call: Call, icon: Painter, iconText: StringResource, e
}
@Composable
private fun ToggleAudioButton(call: Call, enabled: Boolean = true, toggleAudio: () -> Unit) {
private fun ToggleAudioButton(call: Call, toggleAudio: () -> Unit) {
if (call.audioEnabled) {
ControlButton(call, painterResource(MR.images.ic_mic), MR.strings.icon_descr_audio_off, enabled, toggleAudio)
ControlButton(call, painterResource(MR.images.ic_mic), MR.strings.icon_descr_audio_off, toggleAudio)
} else {
ControlButton(call, painterResource(MR.images.ic_mic_off), MR.strings.icon_descr_audio_on, enabled, toggleAudio)
ControlButton(call, painterResource(MR.images.ic_mic_off), MR.strings.icon_descr_audio_on, toggleAudio)
}
}
@Composable
private fun ToggleSoundButton(call: Call, enabled: Boolean, toggleSound: () -> Unit) {
if (call.soundSpeaker) {
ControlButton(call, painterResource(MR.images.ic_volume_up), MR.strings.icon_descr_speaker_off, enabled, toggleSound)
ControlButton(call, painterResource(MR.images.ic_volume_up), MR.strings.icon_descr_speaker_off, toggleSound, enabled)
} else {
ControlButton(call, painterResource(MR.images.ic_volume_down), MR.strings.icon_descr_speaker_on, enabled, toggleSound)
ControlButton(call, painterResource(MR.images.ic_volume_down), MR.strings.icon_descr_speaker_on, toggleSound, enabled)
}
}
@Composable
fun AudioCallInfoView(call: Call) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
fun CallInfoView(call: Call, alignment: Alignment.Horizontal) {
@Composable fun InfoText(text: String, style: TextStyle = MaterialTheme.typography.body2) =
Text(text, color = Color(0xFFFFFFD8), style = style)
Column(horizontalAlignment = alignment) {
InfoText(call.contact.chatViewName, style = MaterialTheme.typography.h2)
InfoText(call.callState.text)
@@ -420,21 +375,6 @@ fun AudioCallInfoView(call: Call) {
}
}
@Composable
fun VideoCallInfoView(call: Call) {
Column(horizontalAlignment = Alignment.Start) {
InfoText(call.callState.text)
val connInfo = call.connectionInfo
val connInfoText = if (connInfo == null) "" else " (${connInfo.text})"
InfoText(call.encryptionStatus + connInfoText)
}
}
@Composable
fun InfoText(text: String, modifier: Modifier = Modifier, style: TextStyle = MaterialTheme.typography.body2) =
Text(text, modifier, color = Color(0xFFFFFFD8), style = style)
@Composable
private fun DisabledBackgroundCallsButton() {
var show by remember { mutableStateOf(!platform.androidIsBackgroundCallAllowed()) }
@@ -512,6 +452,7 @@ private fun DisabledBackgroundCallsButton() {
@Composable
fun WebRTCView(callCommand: SnapshotStateList<WCallCommand>, onResponse: (WVAPIMessage) -> Unit) {
val scope = rememberCoroutineScope()
val webView = remember { mutableStateOf<WebView?>(null) }
val permissionsState = rememberMultiplePermissionsState(
permissions = listOf(
@@ -534,10 +475,10 @@ fun WebRTCView(callCommand: SnapshotStateList<WCallCommand>, onResponse: (WVAPIM
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
val wv = webView.value
if (wv != null) processCommand(wv, WCallCommand.End)
lifecycleOwner.lifecycle.removeObserver(observer)
// val wv = webView.value
// if (wv != null) processCommand(wv, WCallCommand.End)
// webView.value?.destroy()
webView.value?.destroy()
webView.value = null
}
}
@@ -564,7 +505,7 @@ fun WebRTCView(callCommand: SnapshotStateList<WCallCommand>, onResponse: (WVAPIM
Box(Modifier.fillMaxSize()) {
AndroidView(
factory = { AndroidViewContext ->
(staticWebView ?: WebView(androidAppContext)).apply {
WebView(AndroidViewContext).apply {
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT,
@@ -589,11 +530,7 @@ fun WebRTCView(callCommand: SnapshotStateList<WCallCommand>, onResponse: (WVAPIM
webViewSettings.javaScriptEnabled = true
webViewSettings.mediaPlaybackRequiresUserGesture = false
webViewSettings.cacheMode = WebSettings.LOAD_NO_CACHE
if (staticWebView == null) {
this.loadUrl("file:android_asset/www/android/call.html")
} else {
webView.value = this
}
this.loadUrl("file:android_asset/www/android/call.html")
}
}
) { /* WebView */ }
@@ -617,15 +554,6 @@ 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,
@@ -638,7 +566,6 @@ private class LocalContentWebViewClient(val webView: MutableState<WebView?>, pri
super.onPageFinished(view, url)
view.evaluateJavascript("sendMessageToNative = (msg) => WebRTCInterface.postMessage(JSON.stringify(msg))", null)
webView.value = view
staticWebView = view
Log.d(TAG, "WebRTCView: webview ready")
// for debugging
// view.evaluateJavascript("sendMessageToNative = ({resp}) => WebRTCInterface.postMessage(JSON.stringify({command: resp}))", null)
@@ -652,7 +579,6 @@ fun PreviewActiveCallOverlayVideo() {
ActiveCallOverlayLayout(
call = Call(
remoteHostId = null,
userProfile = Profile.sampleData,
contact = Contact.sampleData,
callState = CallState.Negotiated,
localMedia = CallMediaType.Video,
@@ -679,7 +605,6 @@ fun PreviewActiveCallOverlayAudio() {
ActiveCallOverlayLayout(
call = Call(
remoteHostId = null,
userProfile = Profile.sampleData,
contact = Contact.sampleData,
callState = CallState.Negotiated,
localMedia = CallMediaType.Audio,
@@ -1,112 +1,8 @@
package chat.simplex.common.views.chatlist
import android.app.Activity
import androidx.compose.foundation.*
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.*
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.*
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.common.ANDROID_CALL_TOP_PADDING
import chat.simplex.common.model.durationText
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.call.*
import chat.simplex.common.views.helpers.*
import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.painterResource
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.datetime.Clock
private val CALL_INTERACTIVE_AREA_HEIGHT = 74.dp
private val CALL_TOP_OFFSET = (-10).dp
private val CALL_TOP_GREEN_LINE_HEIGHT = ANDROID_CALL_TOP_PADDING - CALL_TOP_OFFSET
private val CALL_BOTTOM_ICON_OFFSET = (-15).dp
private val CALL_BOTTOM_ICON_HEIGHT = CALL_INTERACTIVE_AREA_HEIGHT + CALL_BOTTOM_ICON_OFFSET
@Composable
actual fun ActiveCallInteractiveArea(call: Call, newChatSheetState: MutableStateFlow<AnimatedViewState>) {
val onClick = { platform.androidStartCallActivity(false) }
Box(Modifier.offset(y = CALL_TOP_OFFSET).height(CALL_INTERACTIVE_AREA_HEIGHT)) {
val source = remember { MutableInteractionSource() }
val indication = rememberRipple(bounded = true, 3000.dp)
Box(Modifier.height(CALL_TOP_GREEN_LINE_HEIGHT).clickable(onClick = onClick, indication = indication, interactionSource = source)) {
GreenLine(call)
}
Box(
Modifier
.offset(y = CALL_BOTTOM_ICON_OFFSET)
.size(CALL_BOTTOM_ICON_HEIGHT)
.background(SimplexGreen, CircleShape)
.clip(CircleShape)
.clickable(onClick = onClick, indication = indication, interactionSource = source)
.align(Alignment.BottomCenter),
contentAlignment = Alignment.Center
) {
val media = call.peerMedia ?: call.localMedia
if (media == CallMediaType.Video) {
Icon(painterResource(MR.images.ic_videocam_filled), null, Modifier.size(27.dp).offset(x = 2.5.dp, y = 2.dp), tint = Color.White)
} else {
Icon(painterResource(MR.images.ic_call_filled), null, Modifier.size(27.dp).offset(x = -0.5.dp, y = 2.dp), tint = Color.White)
}
}
}
}
@Composable
private fun GreenLine(call: Call) {
Row(
Modifier
.fillMaxSize()
.background(SimplexGreen)
.padding(top = -CALL_TOP_OFFSET)
.padding(horizontal = DEFAULT_PADDING),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
ContactName(call.contact.displayName)
Spacer(Modifier.weight(1f))
CallDuration(call)
}
val window = (LocalContext.current as Activity).window
DisposableEffect(Unit) {
window.statusBarColor = SimplexGreen.toArgb()
onDispose {
window.statusBarColor = Color.Black.toArgb()
}
}
}
@Composable
private fun ContactName(name: String) {
Text(name, Modifier.width(windowWidth() * 0.35f), color = Color.White, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
@Composable
private fun CallDuration(call: Call) {
val connectedAt = call.connectedAt
if (connectedAt != null) {
val time = remember { mutableStateOf(durationText(0)) }
LaunchedEffect(Unit) {
while (true) {
time.value = durationText((Clock.System.now() - connectedAt).inWholeSeconds.toInt())
delay(250)
}
}
val text = time.value
val sp40Or50 = with(LocalDensity.current) { if (text.length >= 6) 60.sp.toDp() else 42.sp.toDp() }
val offset = with(LocalDensity.current) { 7.sp.toDp() }
Text(text, Modifier.offset(x = offset).widthIn(min = sp40Or50), color = Color.White)
}
}
actual fun DesktopActiveCallOverlayLayout(newChatSheetState: MutableStateFlow<AnimatedViewState>) {}
@@ -8,39 +8,13 @@ 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
// non-moving GC is broken on windows with GHC 9.4-9.6.3
non_moving_gc = 0;
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
#endif
if (non_moving_gc)
argv[argc++] = "-xn";
// wrap args as expected by RTS
char **pargv = argv;
hs_init_with_rtsopts(&argc, &pargv);
}
@@ -1,19 +1,16 @@
package chat.simplex.common
import androidx.compose.animation.core.Animatable
import androidx.compose.foundation.*
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.*
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.unit.dp
import chat.simplex.common.views.usersettings.SetDeliveryReceiptsView
@@ -23,7 +20,8 @@ import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.CreateFirstProfile
import chat.simplex.common.views.helpers.SimpleButton
import chat.simplex.common.views.SplashView
import chat.simplex.common.views.call.*
import chat.simplex.common.views.call.ActiveCallView
import chat.simplex.common.views.call.IncomingCallAlertView
import chat.simplex.common.views.chat.ChatView
import chat.simplex.common.views.chatlist.*
import chat.simplex.common.views.database.DatabaseErrorView
@@ -171,17 +169,7 @@ fun MainScreen() {
}
} else {
if (chatModel.showCallView.value) {
if (appPlatform.isAndroid) {
LaunchedEffect(Unit) {
// This if prevents running the activity in the following condition:
// - the activity already started before and was destroyed by collapsing active call (start audio call, press back button, go to a launcher)
if (!chatModel.activeCallViewIsCollapsed.value) {
platform.androidStartCallActivity(false)
}
}
} else {
ActiveCallView()
}
ActiveCallView()
} else {
// It's needed for privacy settings toggle, so it can be shown even if the app is passcode unlocked
ModalManager.fullscreen.showPasscodeInView()
@@ -218,13 +206,9 @@ fun MainScreen() {
}
}
val ANDROID_CALL_TOP_PADDING = 40.dp
@Composable
fun AndroidScreen(settingsState: SettingsViewState) {
BoxWithConstraints {
val call = remember { chatModel.activeCall} .value
val showCallArea = call != null && call.callState != CallState.WaitCapabilities && call.callState != CallState.InvitationAccepted
var currentChatId by rememberSaveable { mutableStateOf(chatModel.chatId.value) }
val offset = remember { Animatable(if (chatModel.chatId.value == null) 0f else maxWidth.value) }
Box(
@@ -232,7 +216,6 @@ fun AndroidScreen(settingsState: SettingsViewState) {
.graphicsLayer {
translationX = -offset.value.dp.toPx()
}
.padding(top = if (showCallArea) ANDROID_CALL_TOP_PADDING else 0.dp)
) {
StartPartOfScreen(settingsState)
}
@@ -259,17 +242,11 @@ fun AndroidScreen(settingsState: SettingsViewState) {
}
}
}
Box(Modifier
.graphicsLayer { translationX = maxWidth.toPx() - offset.value.dp.toPx() }
.padding(top = if (showCallArea) ANDROID_CALL_TOP_PADDING else 0.dp)
) Box2@{
Box(Modifier.graphicsLayer { translationX = maxWidth.toPx() - offset.value.dp.toPx() }) Box2@{
currentChatId?.let {
ChatView(it, chatModel, onComposed)
}
}
if (call != null && showCallArea) {
ActiveCallInteractiveArea(call, remember { MutableStateFlow(AnimatedViewState.GONE) })
}
}
}
@@ -96,7 +96,6 @@ object ChatModel {
val activeCallInvitation = mutableStateOf<RcvCallInvitation?>(null)
val activeCall = mutableStateOf<Call?>(null)
val activeCallViewIsVisible = mutableStateOf<Boolean>(false)
val activeCallViewIsCollapsed = mutableStateOf<Boolean>(false)
val callCommand = mutableStateListOf<WCallCommand>()
val showCallView = mutableStateOf(false)
val switchingCall = mutableStateOf(false)
@@ -1822,7 +1821,7 @@ data class ChatItem (
is CIContent.SndGroupInvitation -> false
is CIContent.RcvDirectEventContent -> when (content.rcvDirectEvent) {
is RcvDirectEvent.ContactDeleted -> false
is RcvDirectEvent.ProfileUpdated -> false
is RcvDirectEvent.ProfileUpdated -> true
}
is CIContent.RcvGroupEventContent -> when (content.rcvGroupEvent) {
is RcvGroupEvent.MemberAdded -> false
@@ -1908,8 +1908,10 @@ object ChatController {
if (invitation != null) {
chatModel.callManager.reportCallRemoteEnded(invitation = invitation)
}
withCall(r, r.contact) { call ->
withBGApi { chatModel.callManager.endCall(call) }
withCall(r, r.contact) { _ ->
chatModel.callCommand.add(WCallCommand.End)
chatModel.activeCall.value = null
chatModel.showCallView.value = false
}
}
is CR.ContactSwitch ->
@@ -1,21 +1,16 @@
package chat.simplex.common.platform
import chat.simplex.common.model.ChatId
import chat.simplex.common.model.NotificationsMode
interface PlatformInterface {
suspend fun androidServiceStart() {}
fun androidServiceSafeStop() {}
fun androidCallServiceSafeStop() {}
fun androidNotificationsModeChanged(mode: NotificationsMode) {}
fun androidChatStartedAfterBeingOff() {}
fun androidChatStopped() {}
fun androidChatInitializedAndStarted() {}
fun androidIsBackgroundCallAllowed(): Boolean = true
fun androidSetNightModeIfSupported() {}
fun androidStartCallActivity(acceptCall: Boolean, remoteHostId: Long? = null, chatId: ChatId? = null) {}
fun androidPictureInPictureAllowed(): Boolean = true
fun androidCallEnded() {}
suspend fun androidAskToAllowBackgroundCalls(): Boolean = true
}
/**
@@ -1,6 +1,6 @@
package chat.simplex.common.views.call
import chat.simplex.common.model.*
import chat.simplex.common.model.ChatModel
import chat.simplex.common.platform.*
import chat.simplex.common.views.helpers.withBGApi
import kotlinx.datetime.Clock
@@ -23,29 +23,27 @@ class CallManager(val chatModel: ChatModel) {
}
}
fun acceptIncomingCall(invitation: RcvCallInvitation) = withBGApi {
fun acceptIncomingCall(invitation: RcvCallInvitation) {
val call = chatModel.activeCall.value
val contactInfo = chatModel.controller.apiContactInfo(invitation.remoteHostId, invitation.contact.contactId)
val profile = contactInfo?.second ?: invitation.user.profile.toProfile()
// In case the same contact calling while previous call didn't end yet (abnormal ending of call from the other side)
if (call == null || (call.remoteHostId == invitation.remoteHostId && call.contact.id == invitation.contact.id)) {
justAcceptIncomingCall(invitation = invitation, profile)
if (call == null) {
justAcceptIncomingCall(invitation = invitation)
} else {
chatModel.switchingCall.value = true
try {
endCall(call = call)
justAcceptIncomingCall(invitation = invitation, profile)
} finally {
chatModel.switchingCall.value = false
withBGApi {
chatModel.switchingCall.value = true
try {
endCall(call = call)
justAcceptIncomingCall(invitation = invitation)
} finally {
chatModel.switchingCall.value = false
}
}
}
}
private fun justAcceptIncomingCall(invitation: RcvCallInvitation, userProfile: Profile) {
private fun justAcceptIncomingCall(invitation: RcvCallInvitation) {
with (chatModel) {
activeCall.value = Call(
remoteHostId = invitation.remoteHostId,
userProfile = userProfile,
contact = invitation.contact,
callState = CallState.InvitationAccepted,
localMedia = invitation.callType.media,
@@ -70,23 +68,17 @@ class CallManager(val chatModel: ChatModel) {
}
suspend fun endCall(call: Call) {
with(chatModel) {
// If there is active call currently and it's with other contact, don't interrupt it
if (activeCall.value != null && !(activeCall.value?.remoteHostId == call.remoteHostId && activeCall.value?.contact?.id == call.contact.id)) return
// Don't destroy WebView if you plan to accept next call right after this one
if (!switchingCall.value) {
showCallView.value = false
activeCall.value = null
activeCallViewIsCollapsed.value = false
platform.androidCallEnded()
}
with (chatModel) {
if (call.callState == CallState.Ended) {
Log.d(TAG, "CallManager.endCall: call ended")
activeCall.value = null
showCallView.value = false
} else {
Log.d(TAG, "CallManager.endCall: ending call...")
//callCommand.add(WCallCommand.End)
callCommand.add(WCallCommand.End)
showCallView.value = false
controller.apiEndCall(call.remoteHostId, call.contact)
activeCall.value = null
}
}
}
@@ -7,11 +7,11 @@ import kotlinx.datetime.Instant
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import java.net.URI
import java.util.*
import kotlin.collections.ArrayList
data class Call(
val remoteHostId: Long?,
val userProfile: Profile,
val contact: Contact,
val callState: CallState,
val localMedia: CallMediaType,
@@ -23,7 +23,7 @@ data class Call(
val soundSpeaker: Boolean = localMedia == CallMediaType.Video,
var localCamera: VideoCamera = VideoCamera.User,
val connectionInfo: ConnectionInfo? = null,
var connectedAt: Instant? = null,
var connectedAt: Instant? = null
) {
val encrypted: Boolean get() = localEncrypted && sharedKey != null
val localEncrypted: Boolean get() = localCapabilities?.encryption ?: false
@@ -36,9 +36,6 @@ data class Call(
}
val hasMedia: Boolean get() = callState == CallState.OfferSent || callState == CallState.Negotiated || callState == CallState.Connected
fun supportsVideo(): Boolean = peerMedia == CallMediaType.Video || localMedia == CallMediaType.Video
}
enum class CallState {
@@ -78,7 +75,6 @@ sealed class WCallCommand {
@Serializable @SerialName("media") data class Media(val media: CallMediaType, val enable: Boolean): WCallCommand()
@Serializable @SerialName("camera") data class Camera(val camera: VideoCamera): WCallCommand()
@Serializable @SerialName("description") data class Description(val state: String, val description: String): WCallCommand()
@Serializable @SerialName("layout") data class Layout(val layout: LayoutType): WCallCommand()
@Serializable @SerialName("end") object End: WCallCommand()
}
@@ -171,13 +167,6 @@ enum class VideoCamera {
val flipped: VideoCamera get() = if (this == User) Environment else User
}
@Serializable
enum class LayoutType {
@SerialName("default") Default,
@SerialName("localVideo") LocalVideo,
@SerialName("remoteVideo") RemoteVideo
}
@Serializable
data class ConnectionState(
val connectionState: String,
@@ -301,9 +301,7 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId:
withBGApi {
val cInfo = chat.chatInfo
if (cInfo is ChatInfo.Direct) {
val contactInfo = chatModel.controller.apiContactInfo(chat.remoteHostId, cInfo.contact.contactId)
val profile = contactInfo?.second ?: chatModel.currentUser.value?.profile?.toProfile() ?: return@withBGApi
chatModel.activeCall.value = Call(remoteHostId = chatRh, contact = cInfo.contact, callState = CallState.WaitCapabilities, localMedia = media, userProfile = profile)
chatModel.activeCall.value = Call(remoteHostId = chatRh, contact = cInfo.contact, callState = CallState.WaitCapabilities, localMedia = media)
chatModel.showCallView.value = true
chatModel.callCommand.add(WCallCommand.Capabilities(media))
}
@@ -675,7 +673,7 @@ fun ChatInfoToolbar(
}
}
}
} else if (activeCall?.contact?.id == chat.id && appPlatform.isDesktop) {
} else if (activeCall?.contact?.id == chat.id) {
barButtons.add {
val call = remember { chatModel.activeCall }.value
val connectedAt = call?.connectedAt
@@ -424,47 +424,69 @@ private fun MemberVerifiedShield() {
@Composable
private fun DropDownMenuForMember(rhId: Long?, member: GroupMember, groupInfo: GroupInfo, showMenu: MutableState<Boolean>) {
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
})
}
}
// 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
})
}
} 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
})
}
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
@@ -387,11 +387,25 @@ fun GroupMemberInfoLayout(
}
}
if (groupInfo.membership.memberRole >= GroupMemberRole.Admin) {
AdminDestructiveSection()
} else {
NonAdminBlockSection()
// revert from this:
SectionDividerSpaced(maxBottomPadding = false)
SectionView {
if (member.memberSettings.showMessages) {
BlockMemberButton(blockMember)
} else {
UnblockMemberButton(unblockMember)
}
if (member.canBeRemoved(groupInfo)) {
RemoveMemberButton(removeMember)
}
}
// revert to this: vvv
// if (groupInfo.membership.memberRole >= GroupMemberRole.Admin) {
// AdminDestructiveSection()
// } else {
// NonAdminBlockSection()
// }
// ^^^
if (developerTools) {
SectionDividerSpaced()
@@ -29,7 +29,6 @@ import chat.simplex.common.views.onboarding.WhatsNewView
import chat.simplex.common.views.onboarding.shouldShowWhatsNew
import chat.simplex.common.views.usersettings.SettingsView
import chat.simplex.common.platform.*
import chat.simplex.common.views.call.Call
import chat.simplex.common.views.newchat.*
import chat.simplex.res.MR
import kotlinx.coroutines.*
@@ -122,12 +121,7 @@ fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerf
}
}
if (searchText.value.text.isEmpty()) {
if (appPlatform.isDesktop) {
val call = remember { chatModel.activeCall }.value
if (call != null) {
ActiveCallInteractiveArea(call, newChatSheetState)
}
}
DesktopActiveCallOverlayLayout(newChatSheetState)
// TODO disable this button and sheet for the duration of the switch
tryOrShowError("NewChatSheet", error = {}) {
NewChatSheet(chatModel, newChatSheetState, stopped, hideNewChatSheet)
@@ -320,7 +314,7 @@ private fun ToggleFilterDisabledButton() {
}
@Composable
expect fun ActiveCallInteractiveArea(call: Call, newChatSheetState: MutableStateFlow<AnimatedViewState>)
expect fun DesktopActiveCallOverlayLayout(newChatSheetState: MutableStateFlow<AnimatedViewState>)
fun connectIfOpenedViaUri(rhId: Long?, uri: URI, chatModel: ChatModel) {
Log.d(TAG, "connectIfOpenedViaUri: opened via link")
@@ -85,7 +85,7 @@ private fun ShareListToolbar(chatModel: ChatModel, userPickerState: MutableState
userPickerState.value = AnimatedViewState.VISIBLE
}
}
else -> NavigationButtonBack(onButtonClicked = { chatModel.sharedContent.value = null })
else -> NavigationButtonBack { chatModel.sharedContent.value = null }
}
}
if (chatModel.chats.size >= 8) {
@@ -143,7 +143,7 @@ private fun ShareList(chatModel: ChatModel, search: String) {
}
val chats by remember(search) {
derivedStateOf {
if (search.isEmpty()) chatModel.chats.toList().filter { it.chatInfo.ready } else chatModel.chats.toList().filter { it.chatInfo.ready }.filter(filter)
if (search.isEmpty()) chatModel.chats.filter { it.chatInfo.ready } else chatModel.chats.filter { it.chatInfo.ready }.filter(filter)
}
}
LazyColumn(
@@ -18,7 +18,7 @@ import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.painterResource
@Composable
fun CloseSheetBar(close: (() -> Unit)?, showClose: Boolean = true, tintColor: Color = if (close != null) MaterialTheme.colors.primary else MaterialTheme.colors.secondary, endButtons: @Composable RowScope.() -> Unit = {}) {
fun CloseSheetBar(close: (() -> Unit)?, showClose: Boolean = true, endButtons: @Composable RowScope.() -> Unit = {}) {
Column(
Modifier
.fillMaxWidth()
@@ -35,7 +35,7 @@ fun CloseSheetBar(close: (() -> Unit)?, showClose: Boolean = true, tintColor: Co
verticalAlignment = Alignment.CenterVertically
) {
if (showClose) {
NavigationButtonBack(tintColor = tintColor, onButtonClicked = close)
NavigationButtonBack(onButtonClicked = close)
} else {
Spacer(Modifier)
}
@@ -44,10 +44,10 @@ fun DefaultTopAppBar(
}
@Composable
fun NavigationButtonBack(onButtonClicked: (() -> Unit)?, tintColor: Color = if (onButtonClicked != null) MaterialTheme.colors.primary else MaterialTheme.colors.secondary) {
fun NavigationButtonBack(onButtonClicked: (() -> Unit)?) {
IconButton(onButtonClicked ?: {}, enabled = onButtonClicked != null) {
Icon(
painterResource(MR.images.ic_arrow_back_ios_new), stringResource(MR.strings.back), tint = tintColor
painterResource(MR.images.ic_arrow_back_ios_new), stringResource(MR.strings.back), tint = if (onButtonClicked != null) MaterialTheme.colors.primary else MaterialTheme.colors.secondary
)
}
}
@@ -29,7 +29,7 @@ fun ModalView(
}
Surface(Modifier.fillMaxSize(), contentColor = LocalContentColor.current) {
Column(if (background != MaterialTheme.colors.background) Modifier.background(background) else Modifier.themedBackground()) {
CloseSheetBar(close, showClose, endButtons = endButtons)
CloseSheetBar(close, showClose, endButtons)
Box(modifier) { content() }
}
}
@@ -411,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.getMimeDecoder().decode(this.trimEnd { it == '\n' || it == ' ' })
// fun String.toByteArrayFromBase64(): ByteArray = Base64.getDecoder().decode(this.trimEnd { it == '\n' || it == ' ' })
expect fun String.toByteArrayFromBase64ForPassphrase(): ByteArray
val LongRange.Companion.saver
@@ -177,9 +177,6 @@
<!-- SimpleX Chat foreground Service -->
<string name="simplex_service_notification_title">SimpleX Chat service</string>
<string name="simplex_service_notification_text">Receiving messages…</string>
<string name="call_service_notification_audio_call">Audio call</string>
<string name="call_service_notification_video_call">Video call</string>
<string name="call_service_notification_end_call">End call</string>
<string name="hide_notification">Hide</string>
<!-- Notification channels -->
@@ -804,10 +801,6 @@
<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>
@@ -8,7 +8,6 @@
<body>
<video
id="remote-video-stream"
class="inline"
autoplay
playsinline
poster="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAQAAAAnOwc2AAAAEUlEQVR42mNk+M+AARiHsiAAcCIKAYwFoQ8AAAAASUVORK5CYII="
@@ -16,7 +15,6 @@
></video>
<video
id="local-video-stream"
class="inline"
muted
autoplay
playsinline
@@ -5,14 +5,14 @@ body {
background-color: black;
}
#remote-video-stream.inline {
#remote-video-stream {
position: absolute;
width: 100%;
height: 100%;
object-fit: cover;
}
#local-video-stream.inline {
#local-video-stream {
position: absolute;
width: 30%;
max-width: 30%;
@@ -23,20 +23,6 @@ body {
right: 0;
}
#remote-video-stream.fullscreen {
position: absolute;
height: 100%;
width: 100%;
object-fit: cover;
}
#local-video-stream.fullscreen {
position: absolute;
height: 100%;
width: 100%;
object-fit: cover;
}
*::-webkit-media-controls {
display: none !important;
-webkit-appearance: none !important;
@@ -11,12 +11,6 @@ var VideoCamera;
VideoCamera["User"] = "user";
VideoCamera["Environment"] = "environment";
})(VideoCamera || (VideoCamera = {}));
var LayoutType;
(function (LayoutType) {
LayoutType["Default"] = "default";
LayoutType["LocalVideo"] = "localVideo";
LayoutType["RemoteVideo"] = "remoteVideo";
})(LayoutType || (LayoutType = {}));
// for debugging
// var sendMessageToNative = ({resp}: WVApiMessage) => console.log(JSON.stringify({command: resp}))
var sendMessageToNative = (msg) => console.log(JSON.stringify(msg));
@@ -325,10 +319,6 @@ const processCommand = (function () {
localizedDescription = command.description;
resp = { type: "ok" };
break;
case "layout":
changeLayout(command.layout);
resp = { type: "ok" };
break;
case "end":
endCall();
resp = { type: "ok" };
@@ -617,28 +607,6 @@ function toggleMedia(s, media) {
}
return res;
}
function changeLayout(layout) {
const local = document.getElementById("local-video-stream");
const remote = document.getElementById("remote-video-stream");
switch (layout) {
case LayoutType.Default:
local.className = "inline";
remote.className = "inline";
local.style.visibility = "visible";
remote.style.visibility = "visible";
break;
case LayoutType.LocalVideo:
local.className = "fullscreen";
local.style.visibility = "visible";
remote.style.visibility = "hidden";
break;
case LayoutType.RemoteVideo:
remote.className = "fullscreen";
local.style.visibility = "hidden";
remote.style.visibility = "visible";
break;
}
}
// Cryptography function - it is loaded both in the main window and in worker context (if the worker is used)
function callCryptoFunction() {
const initialPlainTextRequired = {
@@ -9,7 +9,6 @@
<body>
<video
id="remote-video-stream"
class="inline"
autoplay
playsinline
poster="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAQAAAAnOwc2AAAAEUlEQVR42mNk+M+AARiHsiAAcCIKAYwFoQ8AAAAASUVORK5CYII="
@@ -17,7 +16,6 @@
></video>
<video
id="local-video-stream"
class="inline"
muted
autoplay
playsinline
@@ -5,14 +5,14 @@ body {
background-color: black;
}
#remote-video-stream.inline {
#remote-video-stream {
position: absolute;
width: 100%;
height: 100%;
object-fit: cover;
}
#local-video-stream.inline {
#local-video-stream {
position: absolute;
width: 20%;
max-width: 20%;
@@ -23,20 +23,6 @@ body {
right: 0;
}
#remote-video-stream.fullscreen {
position: absolute;
height: 100%;
width: 100%;
object-fit: cover;
}
#local-video-stream.fullscreen {
position: absolute;
height: 100%;
width: 100%;
object-fit: cover;
}
*::-webkit-media-controls {
display: none !important;
-webkit-appearance: none !important;
@@ -17,14 +17,14 @@ import javax.imageio.stream.MemoryCacheImageOutputStream
import kotlin.math.sqrt
private fun errorBitmap(): ImageBitmap =
ImageIO.read(ByteArrayInputStream(Base64.getMimeDecoder().decode("iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAAXNSR0IArs4c6QAAAKVJREFUeF7t1kENACEUQ0FQhnVQ9lfGO+xggITQdvbMzArPey+8fa3tAfwAEdABZQspQStgBssEcgAIkSAJkiAJljtEgiRIgmUCSZAESZAESZAEyx0iQRIkwTKBJEiCv5fgvTd1wDmn7QAP4AeIgA4oW0gJWgEzWCZwbQ7gAA7ggLKFOIADOKBMIAeAEAmSIAmSYLlDJEiCJFgmkARJkARJ8N8S/ADTZUewBvnTOQAAAABJRU5ErkJggg=="))).toComposeImageBitmap()
ImageIO.read(ByteArrayInputStream(Base64.getDecoder().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.getMimeDecoder().decode(imageString))).toComposeImageBitmap()
ImageIO.read(ByteArrayInputStream(Base64.getDecoder().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: Exception) {
} catch (e: IOException) {
Log.e(TAG, "resizeImageToStrSize error: $e")
throw e
}
@@ -146,21 +146,8 @@ 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 {
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()
}
uriHandler.openUri("http://${SERVER_HOST}:$SERVER_PORT/simplex/call/")
startServer(onResponse)
}
fun processCommand(cmd: WCallCommand) {
@@ -3,6 +3,7 @@ package chat.simplex.common.views.chatlist
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.*
@@ -12,7 +13,6 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.call.Call
import chat.simplex.common.views.call.CallMediaType
import chat.simplex.common.views.chat.item.ItemAction
import chat.simplex.common.views.helpers.*
@@ -22,9 +22,10 @@ import dev.icerock.moko.resources.compose.stringResource
import kotlinx.coroutines.flow.MutableStateFlow
@Composable
actual fun ActiveCallInteractiveArea(call: Call, newChatSheetState: MutableStateFlow<AnimatedViewState>) {
// if (call.callState == CallState.Connected && !newChatSheetState.collectAsState().value.isVisible()) {
if (!newChatSheetState.collectAsState().value.isVisible()) {
actual fun DesktopActiveCallOverlayLayout(newChatSheetState: MutableStateFlow<AnimatedViewState>) {
val call = remember { chatModel.activeCall}.value
// if (call?.callState == CallState.Connected && !newChatSheetState.collectAsState().value.isVisible()) {
if (call != null && !newChatSheetState.collectAsState().value.isVisible()) {
val showMenu = remember { mutableStateOf(false) }
val media = call.peerMedia ?: call.localMedia
CompositionLocalProvider(
+1 -1
View File
@@ -12,7 +12,7 @@ constraints: zip +disable-bzip2 +disable-zstd
source-repository-package
type: git
location: https://github.com/simplex-chat/simplexmq.git
tag: 246a0d10c22ebe02af2eb34773b77cce10247459
tag: b4c23520c73786b7c3ff539ff80b37f07ffe68d4
source-repository-package
type: git
+2 -59
View File
@@ -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 Marketplace](https://www.linode.com/marketplace/apps/simplex-chat/simplex-chat/)
- [Linode StackScript](https://github.com/simplex-chat/simplexmq#deploy-smp-server-on-linode)
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 && chmod +x /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
```
- Compiling from source:
@@ -417,63 +417,6 @@ 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.
+1 -61
View File
@@ -24,7 +24,6 @@ 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:
@@ -33,7 +32,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 && chmod +x /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
```
- Compiling from source:
@@ -419,65 +418,6 @@ 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).
+8 -20
View File
@@ -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.PQRct = PQKEM-ENC(state.PQRr, state.PQRss) // encapsulated additional shared secret
state.PQRenc_ss = PQKEM-ENC(state.PQRr.encaps, 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.PQRct = None
state.PQRenc_ss = 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,16 +162,6 @@ 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
@@ -180,16 +170,16 @@ def DHRatchetPQ2HE(state, header):
state.HKr = state.NHKr
state.DHRr = header.dh
// save new encapsulation key from header
state.PQRr = header.kem
state.PQRr = header.encaps
// decapsulate shared secret from header - KEM #2
ss = PQKEM-DEC(state.PQRs.private, header.ct)
ss = PQKEM-DEC(state.PQRs.decaps, header.enc_ss)
// 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.PQRct = PQKEM-ENC(state.PQRr, state.PQRss) // encapsulated additional shared secret KEM #1
state.PQRenc_ss = PQKEM-ENC(state.PQRr.encaps, 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)
@@ -211,7 +201,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, or compressing the current JSON encoding, e.g. with zstd algorithm.
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.
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.
@@ -219,8 +209,6 @@ 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,33 +108,3 @@ 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)
-130
View File
@@ -1,130 +0,0 @@
# 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.
@@ -1,38 +0,0 @@
# 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
```
-60
View File
@@ -1,60 +0,0 @@
# 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
}
```
-98
View File
@@ -1,98 +0,0 @@
# 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
@@ -8,7 +8,6 @@
<body>
<video
id="remote-video-stream"
class="inline"
autoplay
playsinline
poster="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAQAAAAnOwc2AAAAEUlEQVR42mNk+M+AARiHsiAAcCIKAYwFoQ8AAAAASUVORK5CYII="
@@ -16,7 +15,6 @@
></video>
<video
id="local-video-stream"
class="inline"
muted
autoplay
playsinline
@@ -5,14 +5,14 @@ body {
background-color: black;
}
#remote-video-stream.inline {
#remote-video-stream {
position: absolute;
width: 100%;
height: 100%;
object-fit: cover;
}
#local-video-stream.inline {
#local-video-stream {
position: absolute;
width: 30%;
max-width: 30%;
@@ -23,20 +23,6 @@ body {
right: 0;
}
#remote-video-stream.fullscreen {
position: absolute;
height: 100%;
width: 100%;
object-fit: cover;
}
#local-video-stream.fullscreen {
position: absolute;
height: 100%;
width: 100%;
object-fit: cover;
}
*::-webkit-media-controls {
display: none !important;
-webkit-appearance: none !important;
+1 -40
View File
@@ -16,7 +16,6 @@ type WCallCommand =
| WCEnableMedia
| WCToggleCamera
| WCDescription
| WCLayout
| WCEndCall
type WCallResponse =
@@ -32,7 +31,7 @@ type WCallResponse =
| WRError
| WCAcceptOffer
type WCallCommandTag = "capabilities" | "start" | "offer" | "answer" | "ice" | "media" | "camera" | "description" | "layout" | "end"
type WCallCommandTag = "capabilities" | "start" | "offer" | "answer" | "ice" | "media" | "camera" | "description" | "end"
type WCallResponseTag = "capabilities" | "offer" | "answer" | "ice" | "connection" | "connected" | "end" | "ended" | "ok" | "error"
@@ -46,12 +45,6 @@ enum VideoCamera {
Environment = "environment",
}
enum LayoutType {
Default = "default",
LocalVideo = "localVideo",
RemoteVideo = "remoteVideo",
}
interface IWCallCommand {
type: WCallCommandTag
}
@@ -122,11 +115,6 @@ interface WCDescription extends IWCallCommand {
description: string
}
interface WCLayout extends IWCallCommand {
type: "layout"
layout: LayoutType
}
interface WRCapabilities extends IWCallResponse {
type: "capabilities"
capabilities: CallCapabilities
@@ -527,10 +515,6 @@ const processCommand = (function () {
localizedDescription = command.description
resp = {type: "ok"}
break
case "layout":
changeLayout(command.layout)
resp = {type: "ok"}
break
case "end":
endCall()
resp = {type: "ok"}
@@ -840,29 +824,6 @@ function toggleMedia(s: MediaStream, media: CallMediaType): boolean {
return res
}
function changeLayout(layout: LayoutType) {
const local = document.getElementById("local-video-stream")!
const remote = document.getElementById("remote-video-stream")!
switch (layout) {
case LayoutType.Default:
local.className = "inline"
remote.className = "inline"
local.style.visibility = "visible"
remote.style.visibility = "visible"
break
case LayoutType.LocalVideo:
local.className = "fullscreen"
local.style.visibility = "visible"
remote.style.visibility = "hidden"
break
case LayoutType.RemoteVideo:
remote.className = "fullscreen"
local.style.visibility = "hidden"
remote.style.visibility = "visible"
break
}
}
type TransformFrameFunc = (key: CryptoKey) => (frame: RTCEncodedVideoFrame, controller: TransformStreamDefaultController) => Promise<void>
interface CallCrypto {
@@ -9,7 +9,6 @@
<body>
<video
id="remote-video-stream"
class="inline"
autoplay
playsinline
poster="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAQAAAAnOwc2AAAAEUlEQVR42mNk+M+AARiHsiAAcCIKAYwFoQ8AAAAASUVORK5CYII="
@@ -17,7 +16,6 @@
></video>
<video
id="local-video-stream"
class="inline"
muted
autoplay
playsinline
@@ -5,14 +5,14 @@ body {
background-color: black;
}
#remote-video-stream.inline {
#remote-video-stream {
position: absolute;
width: 100%;
height: 100%;
object-fit: cover;
}
#local-video-stream.inline {
#local-video-stream {
position: absolute;
width: 20%;
max-width: 20%;
@@ -23,20 +23,6 @@ body {
right: 0;
}
#remote-video-stream.fullscreen {
position: absolute;
height: 100%;
width: 100%;
object-fit: cover;
}
#local-video-stream.fullscreen {
position: absolute;
height: 100%;
width: 100%;
object-fit: cover;
}
*::-webkit-media-controls {
display: none !important;
-webkit-appearance: none !important;
+1 -1
View File
@@ -103,7 +103,7 @@ build() {
for arch in $arches; do
tag_full="$(git tag --points-at HEAD | head -n1)"
tag_full="$(git tag --points-at HEAD)"
tag_version="${tag_full%%-*}"
if [ "$arch" = "armv7a" ] && [ -n "$tag_full" ] ; then
+1 -1
View File
@@ -1,5 +1,5 @@
{
"https://github.com/simplex-chat/simplexmq.git"."246a0d10c22ebe02af2eb34773b77cce10247459" = "0kx5swx1g9jimg7ks008nqzvkyx5x9irjkjwvgwrd3km5g0wnzf4";
"https://github.com/simplex-chat/simplexmq.git"."b4c23520c73786b7c3ff539ff80b37f07ffe68d4" = "171i6b0jhp97qr6va22j2z57ibjn0nd5wfifi7bqa67mjhc16lwp";
"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";
-5
View File
@@ -26,7 +26,6 @@ flag swift
library
exposed-modules:
Simplex.Chat
Simplex.Chat.AppSettings
Simplex.Chat.Archive
Simplex.Chat.Bot
Simplex.Chat.Bot.KnownContacts
@@ -134,9 +133,6 @@ 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
@@ -152,7 +148,6 @@ 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
+185 -329
View File
@@ -6,7 +6,6 @@
{-# LANGUAGE MultiWayIf #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE PatternSynonyms #-}
{-# LANGUAGE RankNTypes #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TupleSections #-}
@@ -68,7 +67,6 @@ import Simplex.Chat.Protocol
import Simplex.Chat.Remote
import Simplex.Chat.Remote.Types
import Simplex.Chat.Store
import Simplex.Chat.Store.AppSettings
import Simplex.Chat.Store.Connections
import Simplex.Chat.Store.Direct
import Simplex.Chat.Store.Files
@@ -81,10 +79,9 @@ import Simplex.Chat.Types
import Simplex.Chat.Types.Preferences
import Simplex.Chat.Types.Util
import Simplex.Chat.Util (encryptFile, shuffle)
import Simplex.FileTransfer.Client.Main (maxFileSize, maxFileSizeHard)
import Simplex.FileTransfer.Client.Main (maxFileSize)
import Simplex.FileTransfer.Client.Presets (defaultXFTPServers)
import Simplex.FileTransfer.Description (FileDescriptionURI (..), ValidFileDescription)
import qualified Simplex.FileTransfer.Description as FD
import Simplex.FileTransfer.Description (ValidFileDescription)
import Simplex.FileTransfer.Protocol (FileParty (..), FilePartyI)
import Simplex.Messaging.Agent as Agent
import Simplex.Messaging.Agent.Client (AgentStatsKey (..), SubInfo (..), agentClientStore, getAgentWorkersDetails, getAgentWorkersSummary, temporaryAgentError)
@@ -105,7 +102,6 @@ import Simplex.Messaging.Encoding.String
import Simplex.Messaging.Parsers (base64P)
import Simplex.Messaging.Protocol (AProtoServerWithAuth (..), AProtocolType (..), EntityId, ErrorType (..), MsgBody, MsgFlags (..), NtfServer, ProtoServerWithAuth, ProtocolTypeI, SProtocolType (..), SubscriptionMode (..), UserProtocol, userProtocol)
import qualified Simplex.Messaging.Protocol as SMP
import Simplex.Messaging.ServiceScheme (ServiceScheme (..))
import qualified Simplex.Messaging.TMap as TM
import Simplex.Messaging.Transport.Client (defaultSocksProxy)
import Simplex.Messaging.Util
@@ -173,10 +169,7 @@ _defaultSMPServers =
]
_defaultNtfServers :: [NtfServer]
_defaultNtfServers =
[ "ntf://KmpZNNXiVZJx_G2T7jRUmDFxWXM3OAnunz3uLT0tqAA=@ntf3.simplex.im,pxculznuryunjdvtvh6s6szmanyadumpbmvevgdpe4wk5c65unyt4yid.onion",
"ntf://CJ5o7X6fCxj2FFYRU2KuCo70y4jSqz7td2HYhLnXWbU=@ntf4.simplex.im,wtvuhdj26jwprmomnyfu5wfuq2hjkzfcc72u44vi6gdhrwxldt6xauad.onion"
]
_defaultNtfServers = ["ntf://FB-Uop7RTaZZEG0ZLD2CIaTjsPh-Fw0zFAnb7QyA8Ks=@ntf2.simplex.im,ntg7jdjy2i3qbib3sykiho3enekwiaqg3icctliqhtqcg6jmoh6cxiad.onion"]
maxImageSize :: Integer
maxImageSize = 261120 * 2 -- auto-receive on mobiles
@@ -598,11 +591,8 @@ processChatCommand' vr = \case
fileErrs <- importArchive cfg
setStoreChanged
pure $ CRArchiveImported fileErrs
APISaveAppSettings as -> withStore' (`saveAppSettings` as) >> ok_
APIGetAppSettings platformDefaults -> CRAppSettings <$> withStore' (`getAppSettings` platformDefaults)
APIDeleteStorage -> withStoreChanged deleteStorage
APIStorageEncryption cfg -> withStoreChanged $ sqlCipherExport cfg
TestStorageEncryption key -> sqlCipherTestKey key >> ok_
ExecChatStoreSQL query -> CRSQLResult <$> withStore' (`execSQL` query)
ExecAgentStoreSQL query -> CRSQLResult <$> withAgent (`execAgentStoreSQL` query)
SlowSQLQueries -> do
@@ -716,18 +706,28 @@ processChatCommand' vr = \case
CTContactConnection -> pure $ chatCmdError (Just user) "not supported"
where
xftpSndFileTransfer :: User -> CryptoFile -> Integer -> Int -> ContactOrGroup -> m (FileInvitation, CIFile 'MDSnd)
xftpSndFileTransfer user file fileSize n contactOrGroup = do
(fInv, ciFile, ft) <- xftpSndFileTransfer_ user file fileSize n $ Just contactOrGroup
xftpSndFileTransfer user file@(CryptoFile filePath cfArgs) fileSize n contactOrGroup = do
let fileName = takeFileName filePath
fileDescr = FileDescr {fileDescrText = "", fileDescrPartNo = 0, fileDescrComplete = False}
fInv = xftpFileInvitation fileName fileSize fileDescr
fsFilePath <- toFSFilePath filePath
let srcFile = CryptoFile fsFilePath cfArgs
aFileId <- withAgent $ \a -> xftpSendFile a (aUserId user) srcFile (roundedFDCount n)
-- TODO CRSndFileStart event for XFTP
chSize <- asks $ fileChunkSize . config
ft@FileTransferMeta {fileId} <- withStore' $ \db -> createSndFileTransferXFTP db user contactOrGroup file fInv (AgentSndFileId aFileId) chSize
let fileSource = Just $ CryptoFile filePath cfArgs
ciFile = CIFile {fileId, fileName, fileSize, fileSource, fileStatus = CIFSSndStored, fileProtocol = FPXFTP}
case contactOrGroup of
CGContact Contact {activeConn} -> forM_ activeConn $ \conn ->
withStore' $ \db -> createSndFTDescrXFTP db user Nothing conn ft dummyFileDescr
withStore' $ \db -> createSndFTDescrXFTP db user Nothing conn ft fileDescr
CGGroup (Group _ ms) -> forM_ ms $ \m -> saveMemberFD m `catchChatError` (toView . CRChatError (Just user))
where
-- we are not sending files to pending members, same as with inline files
saveMemberFD m@GroupMember {activeConn = Just conn@Connection {connStatus}} =
when ((connStatus == ConnReady || connStatus == ConnSndReady) && not (connDisabled conn)) $
withStore' $
\db -> createSndFTDescrXFTP db user (Just m) conn ft dummyFileDescr
\db -> createSndFTDescrXFTP db user (Just m) conn ft fileDescr
saveMemberFD _ = pure ()
pure (fInv, ciFile)
APICreateChatItem folderId (ComposedMessage file_ quotedItemId_ mc) -> withUser $ \user -> do
@@ -939,12 +939,11 @@ processChatCommand' vr = \case
ct <- withStore $ \db -> getContact db user chatId
filesInfo <- withStore' $ \db -> getContactFileInfo db user ct
withChatLock "deleteChat direct" . procCmd $ do
cancelFilesInProgress user filesInfo
deleteFilesLocally filesInfo
let doSendDel = contactReady ct && contactActive ct && notify
when doSendDel $ void (sendDirectContactMessage ct XDirectDel) `catchChatError` const (pure ())
deleteFilesAndConns user filesInfo
when (contactReady ct && contactActive ct && notify) $
void (sendDirectContactMessage ct XDirectDel) `catchChatError` const (pure ())
contactConnIds <- map aConnId <$> withStore' (\db -> getContactConnections db userId ct)
deleteAgentConnectionsAsync' user contactConnIds doSendDel
deleteAgentConnectionsAsync user contactConnIds
-- functions below are called in separate transactions to prevent crashes on android
-- (possibly, race condition on integrity check?)
withStore' $ \db -> deleteContactConnectionsAndFiles db userId ct
@@ -963,12 +962,10 @@ processChatCommand' vr = \case
unless canDelete $ throwChatError $ CEGroupUserRole gInfo GROwner
filesInfo <- withStore' $ \db -> getGroupFileInfo db user gInfo
withChatLock "deleteChat group" . procCmd $ do
cancelFilesInProgress user filesInfo
deleteFilesLocally filesInfo
let doSendDel = memberActive membership && isOwner
when doSendDel . void $ sendGroupMessage' user gInfo members XGrpDel
deleteFilesAndConns user filesInfo
when (memberActive membership && isOwner) . void $ sendGroupMessage' user gInfo members XGrpDel
deleteGroupLinkIfExists user gInfo
deleteMembersConnections' user members doSendDel
deleteMembersConnections user members
updateCIGroupInvitationStatus user gInfo CIGISRejected `catchChatError` \_ -> pure ()
-- functions below are called in separate transactions to prevent crashes on android
-- (possibly, race condition on integrity check?)
@@ -976,40 +973,37 @@ processChatCommand' vr = \case
withStore' $ \db -> deleteGroupItemsAndMembers db user gInfo members
withStore' $ \db -> deleteGroup db user gInfo
let contactIds = mapMaybe memberContactId members
(errs1, (errs2, connIds)) <- second unzip . partitionEithers <$> withStoreBatch (\db -> map (deleteUnusedContact db) contactIds)
let errs = errs1 <> mapMaybe (fmap ChatErrorStore) errs2
unless (null errs) $ toView $ CRChatErrors (Just user) errs
deleteAgentConnectionsAsync user $ concat connIds
deleteAgentConnectionsAsync user . concat =<< mapM deleteUnusedContact contactIds
pure $ CRGroupDeletedUser user gInfo
where
deleteUnusedContact :: DB.Connection -> ContactId -> IO (Either ChatError (Maybe StoreError, [ConnId]))
deleteUnusedContact db contactId = runExceptT . withExceptT ChatErrorStore $ do
ct <- getContact db user contactId
ifM
((directOrUsed ct ||) . isJust <$> liftIO (checkContactHasGroups db user ct))
(pure (Nothing, []))
(getConnections ct)
deleteUnusedContact :: ContactId -> m [ConnId]
deleteUnusedContact contactId =
(withStore (\db -> getContact db user contactId) >>= delete)
`catchChatError` (\e -> toView (CRChatError (Just user) e) $> [])
where
getConnections :: Contact -> ExceptT StoreError IO (Maybe StoreError, [ConnId])
getConnections ct = do
conns <- liftIO $ getContactConnections db userId ct
e_ <- (setContactDeleted db user ct $> Nothing) `catchStoreError` (pure . Just)
pure (e_, map aConnId conns)
delete ct
| directOrUsed ct = pure []
| otherwise =
withStore' (\db -> checkContactHasGroups db user ct) >>= \case
Just _ -> pure []
Nothing -> do
conns <- withStore' $ \db -> getContactConnections db userId ct
withStore (\db -> setContactDeleted db user ct)
`catchChatError` (toView . CRChatError (Just user))
pure $ map aConnId conns
CTLocal -> pure $ chatCmdError (Just user) "not supported"
CTContactRequest -> pure $ chatCmdError (Just user) "not supported"
APIClearChat (ChatRef cType chatId) -> withUser $ \user@User {userId} -> case cType of
CTDirect -> do
ct <- withStore $ \db -> getContact db user chatId
filesInfo <- withStore' $ \db -> getContactFileInfo db user ct
cancelFilesInProgress user filesInfo
deleteFilesLocally filesInfo
deleteFilesAndConns user filesInfo
withStore' $ \db -> deleteContactCIs db user ct
pure $ CRChatCleared user (AChatInfo SCTDirect $ DirectChat ct)
CTGroup -> do
gInfo <- withStore $ \db -> getGroupInfo db vr user chatId
filesInfo <- withStore' $ \db -> getGroupFileInfo db user gInfo
cancelFilesInProgress user filesInfo
deleteFilesLocally filesInfo
deleteFilesAndConns user filesInfo
withStore' $ \db -> deleteGroupCIs db user gInfo
membersToDelete <- withStore' $ \db -> getGroupMembersForExpiration db user gInfo
forM_ membersToDelete $ \m -> withStore' $ \db -> deleteGroupMember db user m
@@ -1018,7 +1012,7 @@ processChatCommand' vr = \case
nf <- withStore $ \db -> getNoteFolder db user chatId
filesInfo <- withStore' $ \db -> getNoteFolderFileInfo db user nf
withChatLock "clearChat local" . procCmd $ do
deleteFilesLocally filesInfo
mapM_ (deleteFile user) filesInfo
withStore' $ \db -> deleteNoteFolderFiles db userId nf
withStore' $ \db -> deleteNoteFolderCIs db user nf
pure $ CRChatCleared user (AChatInfo SCTLocal $ LocalChat nf)
@@ -1697,22 +1691,20 @@ processChatCommand' vr = \case
(msg, _) <- sendGroupMessage user gInfo members $ XGrpMemDel mId
ci <- saveSndChatItem user (CDGroupSnd gInfo) msg (CISndGroupEvent $ SGEMemberDeleted memberId (fromLocalProfile memberProfile))
toView $ CRNewChatItem user (AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci)
deleteMemberConnection' user m True
deleteMemberConnection user m
-- undeleted "member connected" chat item will prevent deletion of member record
deleteOrUpdateMemberRecord user m
pure $ CRUserDeletedMember user gInfo m {memberStatus = GSMemRemoved}
APILeaveGroup groupId -> withUser $ \user@User {userId} -> do
Group gInfo@GroupInfo {membership} members <- withStore $ \db -> getGroup db vr user groupId
filesInfo <- withStore' $ \db -> getGroupFileInfo db user gInfo
withChatLock "leaveGroup" . procCmd $ do
cancelFilesInProgress user filesInfo
(msg, _) <- sendGroupMessage' user gInfo members XGrpLeave
ci <- saveSndChatItem user (CDGroupSnd gInfo) msg (CISndGroupEvent SGEUserLeft)
toView $ CRNewChatItem user (AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci)
-- TODO delete direct connections that were unused
deleteGroupLinkIfExists user gInfo
-- member records are not deleted to keep history
deleteMembersConnections' user members True
deleteMembersConnections user members
withStore' $ \db -> updateGroupMemberStatus db userId membership GSMemLeft
pure $ CRLeftMemberUser user gInfo {membership = membership {memberStatus = GSMemLeft}}
APIListMembers groupId -> withUser $ \user ->
@@ -1900,16 +1892,16 @@ processChatCommand' vr = \case
| otherwise -> do
fileAgentConnIds <- cancelSndFile user ftm fts True
deleteAgentConnectionsAsync user fileAgentConnIds
withStore (\db -> liftIO $ lookupChatRefByFileId db user fileId) >>= \case
Nothing -> pure ()
Just (ChatRef CTDirect contactId) -> do
(contact, sharedMsgId) <- withStore $ \db -> (,) <$> getContact db user contactId <*> getSharedMsgIdByFileId db userId fileId
sharedMsgId <- withStore $ \db -> getSharedMsgIdByFileId db userId fileId
withStore (\db -> getChatRefByFileId db user fileId) >>= \case
ChatRef CTDirect contactId -> do
contact <- withStore $ \db -> getContact db user contactId
void . sendDirectContactMessage contact $ XFileCancel sharedMsgId
Just (ChatRef CTGroup groupId) -> do
(Group gInfo ms, sharedMsgId) <- withStore $ \db -> (,) <$> getGroup db vr user groupId <*> getSharedMsgIdByFileId db userId fileId
ChatRef CTGroup groupId -> do
Group gInfo ms <- withStore $ \db -> getGroup db vr user groupId
void . sendGroupMessage user gInfo ms $ XFileCancel sharedMsgId
Just _ -> throwChatError $ CEFileInternal "invalid chat ref for file transfer"
ci <- withStore $ \db -> lookupChatItemByFileId db vr user fileId
_ -> throwChatError $ CEFileInternal "invalid chat ref for file transfer"
ci <- withStore $ \db -> getChatItemByFileId db vr user fileId
pure $ CRSndFileCancelled user ci ftm fts
where
fileCancelledOrCompleteSMP SndFileTransfer {fileStatus = s} =
@@ -1920,7 +1912,7 @@ processChatCommand' vr = \case
| otherwise -> case xftpRcvFile of
Nothing -> do
cancelRcvFileTransfer user ftr >>= mapM_ (deleteAgentConnectionAsync user)
ci <- withStore $ \db -> lookupChatItemByFileId db vr user fileId
ci <- withStore $ \db -> getChatItemByFileId db vr user fileId
pure $ CRRcvFileCancelled user ci ftr
Just XFTPRcvFile {agentRcvFileId} -> do
forM_ (liveRcvFileTransferPath ftr) $ \filePath -> do
@@ -1933,21 +1925,18 @@ processChatCommand' vr = \case
updateCIFileStatus db user fileId CIFSRcvInvitation
updateRcvFileStatus db fileId FSNew
updateRcvFileAgentId db fileId Nothing
lookupChatItemByFileId db vr user fileId
getChatItemByFileId db vr user fileId
pure $ CRRcvFileCancelled user ci ftr
FileStatus fileId -> withUser $ \user -> do
withStore (\db -> lookupChatItemByFileId db vr user fileId) >>= \case
Nothing -> do
ci@(AChatItem _ _ _ ChatItem {file}) <- withStore $ \db -> getChatItemByFileId db vr user fileId
case file of
Just CIFile {fileProtocol = FPLocal} ->
throwChatError $ CECommandError "not supported for local files"
Just CIFile {fileProtocol = FPXFTP} ->
pure $ CRFileTransferStatusXFTP user ci
_ -> do
fileStatus <- withStore $ \db -> getFileTransferProgress db user fileId
pure $ CRFileTransferStatus user fileStatus
Just ci@(AChatItem _ _ _ ChatItem {file}) -> case file of
Just CIFile {fileProtocol = FPLocal} ->
throwChatError $ CECommandError "not supported for local files"
Just CIFile {fileProtocol = FPXFTP} ->
pure $ CRFileTransferStatusXFTP user ci
_ -> do
fileStatus <- withStore $ \db -> getFileTransferProgress db user fileId
pure $ CRFileTransferStatus user fileStatus
ShowProfile -> withUser $ \user@User {profile} -> pure $ CRUserProfile user (fromLocalProfile profile)
UpdateProfile displayName fullName -> withUser $ \user@User {profile} -> do
let p = (fromLocalProfile profile :: Profile) {displayName = displayName, fullName = fullName}
@@ -2002,15 +1991,6 @@ processChatCommand' vr = \case
StopRemoteCtrl -> withUser_ $ stopRemoteCtrl >> ok_
ListRemoteCtrls -> withUser_ $ CRRemoteCtrlList <$> listRemoteCtrls
DeleteRemoteCtrl rc -> withUser_ $ deleteRemoteCtrl rc >> ok_
APIUploadStandaloneFile userId file@CryptoFile {filePath} -> withUserId userId $ \user -> do
fsFilePath <- toFSFilePath filePath
fileSize <- liftIO $ CF.getFileContentsSize file {filePath = fsFilePath}
when (fileSize > toInteger maxFileSizeHard) $ throwChatError $ CEFileSize filePath
(_, _, fileTransferMeta) <- xftpSndFileTransfer_ user file fileSize 1 Nothing
pure CRSndStandaloneFileCreated {user, fileTransferMeta}
APIDownloadStandaloneFile userId uri file -> withUserId userId $ \user -> do
ft <- receiveViaURI user uri file
pure $ CRRcvStandaloneFileCreated user ft
QuitChat -> liftIO exitSuccess
ShowVersion -> do
-- simplexmqCommitQ makes iOS builds crash m(
@@ -2360,8 +2340,7 @@ processChatCommand' vr = \case
deleteChatUser :: User -> Bool -> m ChatResponse
deleteChatUser user delSMPQueues = do
filesInfo <- withStore' (`getUserFileInfo` user)
cancelFilesInProgress user filesInfo
deleteFilesLocally filesInfo
forM_ filesInfo $ \fileInfo -> deleteFile user fileInfo
withAgent $ \a -> deleteUser a (aUserId user) delSMPQueues
withStore' (`deleteUserRecord` user)
when (activeUser user) $ chatWriteVar currentUser Nothing
@@ -2398,7 +2377,7 @@ processChatCommand' vr = \case
where
cReqSchemas :: (ConnReqInvitation, ConnReqInvitation)
cReqSchemas =
( CRInvitationUri crData {crScheme = SSSimplex} e2e,
( CRInvitationUri crData {crScheme = CRSSimplex} e2e,
CRInvitationUri crData {crScheme = simplexChat} e2e
)
connectPlan user (ACR SCMContact (CRContactUri crData)) = do
@@ -2443,7 +2422,7 @@ processChatCommand' vr = \case
where
cReqSchemas :: (ConnReqContact, ConnReqContact)
cReqSchemas =
( CRContactUri crData {crScheme = SSSimplex},
( CRContactUri crData {crScheme = CRSSimplex},
CRContactUri crData {crScheme = simplexChat}
)
cReqHashes :: (ConnReqUriHash, ConnReqUriHash)
@@ -2569,72 +2548,50 @@ setAllExpireCIFlags b = do
keys <- M.keys <$> readTVar expireFlags
forM_ keys $ \k -> TM.insert k b expireFlags
cancelFilesInProgress :: forall m. ChatMonad m => User -> [CIFileInfo] -> m ()
cancelFilesInProgress user filesInfo = do
let filesInfo' = filter (not . fileEnded) filesInfo
(sfs, rfs) <- splitFTTypes <$> withStoreBatch (\db -> map (getFT db) filesInfo')
forM_ rfs $ \RcvFileTransfer {fileId} -> closeFileHandle fileId rcvFiles `catchChatError` \_ -> pure ()
void . withStoreBatch' $ \db -> map (updateSndFileCancelled db) sfs
void . withStoreBatch' $ \db -> map (updateRcvFileCancelled db) rfs
let xsfIds = mapMaybe (\(FileTransferMeta {fileId, xftpSndFile}, _) -> (,fileId) <$> xftpSndFile) sfs
xrfIds = mapMaybe (\RcvFileTransfer {fileId, xftpRcvFile} -> (,fileId) <$> xftpRcvFile) rfs
agentXFTPDeleteSndFilesRemote user xsfIds
agentXFTPDeleteRcvFiles xrfIds
let smpSFConnIds = concatMap (\(ft, sfts) -> mapMaybe (smpSndFileConnId ft) sfts) sfs
smpRFConnIds = mapMaybe smpRcvFileConnId rfs
deleteAgentConnectionsAsync user smpSFConnIds
deleteAgentConnectionsAsync user smpRFConnIds
where
fileEnded CIFileInfo {fileStatus} = case fileStatus of
Just (AFS _ status) -> ciFileEnded status
Nothing -> True
getFT :: DB.Connection -> CIFileInfo -> IO (Either ChatError FileTransfer)
getFT db CIFileInfo {fileId} = runExceptT . withExceptT ChatErrorStore $ getFileTransfer db user fileId
updateSndFileCancelled :: DB.Connection -> (FileTransferMeta, [SndFileTransfer]) -> IO ()
updateSndFileCancelled db (FileTransferMeta {fileId}, sfts) = do
updateFileCancelled db user fileId CIFSSndCancelled
forM_ sfts updateSndFTCancelled
where
updateSndFTCancelled :: SndFileTransfer -> IO ()
updateSndFTCancelled ft = unless (sndFTEnded ft) $ do
updateSndFileStatus db ft FSCancelled
deleteSndFileChunks db ft
updateRcvFileCancelled :: DB.Connection -> RcvFileTransfer -> IO ()
updateRcvFileCancelled db ft@RcvFileTransfer {fileId} = do
updateFileCancelled db user fileId CIFSRcvCancelled
updateRcvFileStatus db fileId FSCancelled
deleteRcvFileChunks db ft
splitFTTypes :: [Either ChatError FileTransfer] -> ([(FileTransferMeta, [SndFileTransfer])], [RcvFileTransfer])
splitFTTypes = foldr addFT ([], []) . rights
where
addFT f (sfs, rfs) = case f of
FTSnd ft@FileTransferMeta {cancelled} sfts | not cancelled -> ((ft, sfts) : sfs, rfs)
FTRcv ft@RcvFileTransfer {cancelled} | not cancelled -> (sfs, ft : rfs)
_ -> (sfs, rfs)
smpSndFileConnId :: FileTransferMeta -> SndFileTransfer -> Maybe ConnId
smpSndFileConnId FileTransferMeta {xftpSndFile} sft@SndFileTransfer {agentConnId = AgentConnId acId, fileInline}
| isNothing xftpSndFile && isNothing fileInline && not (sndFTEnded sft) = Just acId
| otherwise = Nothing
smpRcvFileConnId :: RcvFileTransfer -> Maybe ConnId
smpRcvFileConnId ft@RcvFileTransfer {xftpRcvFile, rcvFileInline}
| isNothing xftpRcvFile && isNothing rcvFileInline = liveRcvFileTransferConnId ft
| otherwise = Nothing
sndFTEnded SndFileTransfer {fileStatus} = fileStatus == FSCancelled || fileStatus == FSComplete
deleteFilesAndConns :: ChatMonad m => User -> [CIFileInfo] -> m ()
deleteFilesAndConns user filesInfo = do
connIds <- mapM (deleteFile user) filesInfo
deleteAgentConnectionsAsync user $ concat connIds
deleteFilesLocally :: forall m. ChatMonad m => [CIFileInfo] -> m ()
deleteFilesLocally files =
withFilesFolder $ \filesFolder ->
liftIO . forM_ files $ \CIFileInfo {filePath} ->
mapM_ (delete . (filesFolder </>)) filePath
deleteFile :: ChatMonad m => User -> CIFileInfo -> m [ConnId]
deleteFile user fileInfo = deleteFile' user fileInfo False
deleteFile' :: forall m. ChatMonad m => User -> CIFileInfo -> Bool -> m [ConnId]
deleteFile' user ciFileInfo@CIFileInfo {filePath} sendCancel = do
aConnIds <- cancelFile' user ciFileInfo sendCancel
forM_ filePath $ \fPath ->
deleteFileLocally fPath `catchChatError` (toView . CRChatError (Just user))
pure aConnIds
deleteFileLocally :: forall m. ChatMonad m => FilePath -> m ()
deleteFileLocally fPath =
withFilesFolder $ \filesFolder -> liftIO $ do
let fsFilePath = filesFolder </> fPath
removeFile fsFilePath `catchAll` \_ ->
removePathForcibly fsFilePath `catchAll_` pure ()
where
delete :: FilePath -> IO ()
delete fPath =
removeFile fPath `catchAll` \_ ->
removePathForcibly fPath `catchAll_` pure ()
-- perform an action only if filesFolder is set (i.e. on mobile devices)
withFilesFolder :: (FilePath -> m ()) -> m ()
withFilesFolder action = asks filesFolder >>= readTVarIO >>= mapM_ action
cancelFile' :: forall m. ChatMonad m => User -> CIFileInfo -> Bool -> m [ConnId]
cancelFile' user CIFileInfo {fileId, fileStatus} sendCancel =
case fileStatus of
Just fStatus -> cancel' fStatus `catchChatError` (\e -> toView (CRChatError (Just user) e) $> [])
Nothing -> pure []
where
cancel' :: ACIFileStatus -> m [ConnId]
cancel' (AFS dir status) =
if ciFileEnded status
then pure []
else case dir of
SMDSnd -> do
(ftm@FileTransferMeta {cancelled}, fts) <- withStore (\db -> getSndFileTransfer db user fileId)
if cancelled then pure [] else cancelSndFile user ftm fts sendCancel
SMDRcv -> do
ft@RcvFileTransfer {cancelled} <- withStore (\db -> getRcvFileTransfer db user fileId)
if cancelled then pure [] else maybeToList <$> cancelRcvFileTransfer user ft
updateCallItemStatus :: ChatMonad m => User -> Contact -> Call -> WebRTCCallStatus -> Maybe MessageId -> m ()
updateCallItemStatus user ct Call {chatItemId} receivedStatus msgId_ = do
aciContent_ <- callStatusItemContent user ct chatItemId receivedStatus
@@ -2773,19 +2730,6 @@ receiveViaCompleteFD user fileId RcvFileDescr {fileDescrText, fileDescrComplete}
startReceivingFile user fileId
withStoreCtx' (Just "receiveViaCompleteFD, updateRcvFileAgentId") $ \db -> updateRcvFileAgentId db fileId (Just $ AgentRcvFileId aFileId)
receiveViaURI :: ChatMonad m => User -> FileDescriptionURI -> CryptoFile -> m RcvFileTransfer
receiveViaURI user@User {userId} FileDescriptionURI {description} cf@CryptoFile {cryptoArgs} = do
fileId <- withStore $ \db -> createRcvStandaloneFileTransfer db userId cf fileSize chunkSize
aFileId <- withAgent $ \a -> xftpReceiveFile a (aUserId user) description cryptoArgs
withStore $ \db -> do
liftIO $ do
updateRcvFileStatus db fileId FSConnected
updateCIFileStatus db user fileId $ CIFSRcvTransfer 0 1
updateRcvFileAgentId db fileId (Just $ AgentRcvFileId aFileId)
getRcvFileTransfer db user fileId
where
FD.ValidFileDescription FD.FileDescription {size = FD.FileSize fileSize, chunkSize = FD.FileSize chunkSize} = description
startReceivingFile :: ChatMonad m => User -> FileTransferId -> m ()
startReceivingFile user fileId = do
vr <- chatVersionRange
@@ -3198,15 +3142,13 @@ expireChatItems user@User {userId} ttl sync = do
processContact expirationDate ct = do
waitChatStartedAndActivated
filesInfo <- withStoreCtx' (Just "processContact, getContactExpiredFileInfo") $ \db -> getContactExpiredFileInfo db user ct expirationDate
cancelFilesInProgress user filesInfo
deleteFilesLocally filesInfo
deleteFilesAndConns user filesInfo
withStoreCtx' (Just "processContact, deleteContactExpiredCIs") $ \db -> deleteContactExpiredCIs db user ct expirationDate
processGroup :: UTCTime -> UTCTime -> GroupInfo -> m ()
processGroup expirationDate createdAtCutoff gInfo = do
waitChatStartedAndActivated
filesInfo <- withStoreCtx' (Just "processGroup, getGroupExpiredFileInfo") $ \db -> getGroupExpiredFileInfo db user gInfo expirationDate createdAtCutoff
cancelFilesInProgress user filesInfo
deleteFilesLocally filesInfo
deleteFilesAndConns user filesInfo
withStoreCtx' (Just "processGroup, deleteGroupExpiredCIs") $ \db -> deleteGroupExpiredCIs db user gInfo expirationDate createdAtCutoff
membersToDelete <- withStoreCtx' (Just "processGroup, getGroupMembersForExpiration") $ \db -> getGroupMembersForExpiration db user gInfo
forM_ membersToDelete $ \m -> withStoreCtx' (Just "processGroup, deleteGroupMember") $ \db -> deleteGroupMember db user m
@@ -3253,7 +3195,7 @@ processAgentMsgSndFile _corrId aFileId msg =
where
process :: User -> m ()
process user = do
(ft@FileTransferMeta {fileId, xftpRedirectFor, cancelled}, sfts) <- withStore $ \db -> do
(ft@FileTransferMeta {fileId, cancelled}, sfts) <- withStore $ \db -> do
fileId <- getXFTPSndFileDBId db user $ AgentSndFileId aFileId
getSndFileTransfer db user fileId
vr <- chatVersionRange
@@ -3262,76 +3204,61 @@ processAgentMsgSndFile _corrId aFileId msg =
let status = CIFSSndTransfer {sndProgress, sndTotal}
ci <- withStore $ \db -> do
liftIO $ updateCIFileStatus db user fileId status
lookupChatItemByFileId db vr user fileId
getChatItemByFileId db vr user fileId
toView $ CRSndFileProgressXFTP user ci ft sndProgress sndTotal
SFDONE sndDescr rfds -> do
withStore' $ \db -> setSndFTPrivateSndDescr db user fileId (fileDescrText sndDescr)
ci <- withStore $ \db -> lookupChatItemByFileId db vr user fileId
case ci of
Nothing -> do
withAgent (`xftpDeleteSndFileInternal` aFileId)
withStore' $ \db -> createExtraSndFTDescrs db user fileId (map fileDescrText rfds)
case mapMaybe fileDescrURI rfds of
[] -> case rfds of
[] -> logError "File sent without receiver descriptions" -- should not happen
(rfd : _) -> xftpSndFileRedirect user fileId rfd >>= toView . CRSndFileRedirectStartXFTP user ft
uris -> do
ft' <- maybe (pure ft) (\fId -> withStore $ \db -> getFileTransferMeta db user fId) xftpRedirectFor
toView $ CRSndStandaloneFileComplete user ft' uris
Just (AChatItem _ d cInfo _ci@ChatItem {meta = CIMeta {itemSharedMsgId = msgId_, itemDeleted}}) ->
case (msgId_, itemDeleted) of
(Just sharedMsgId, Nothing) -> do
when (length rfds < length sfts) $ throwChatError $ CEInternalError "not enough XFTP file descriptions to send"
-- TODO either update database status or move to SFPROG
toView $ CRSndFileProgressXFTP user ci ft 1 1
case (rfds, sfts, d, cInfo) of
(rfd : extraRFDs, sft : _, SMDSnd, DirectChat ct) -> do
withStore' $ \db -> createExtraSndFTDescrs db user fileId (map fileDescrText extraRFDs)
msgDeliveryId <- sendFileDescription sft rfd sharedMsgId $ sendDirectContactMessage ct
withStore' $ \db -> updateSndFTDeliveryXFTP db sft msgDeliveryId
withAgent (`xftpDeleteSndFileInternal` aFileId)
(_, _, SMDSnd, GroupChat g@GroupInfo {groupId}) -> do
ms <- withStore' $ \db -> getGroupMembers db user g
let rfdsMemberFTs = zip rfds $ memberFTs ms
extraRFDs = drop (length rfdsMemberFTs) rfds
withStore' $ \db -> createExtraSndFTDescrs db user fileId (map fileDescrText extraRFDs)
forM_ rfdsMemberFTs $ \mt -> sendToMember mt `catchChatError` (toView . CRChatError (Just user))
ci' <- withStore $ \db -> do
liftIO $ updateCIFileStatus db user fileId CIFSSndComplete
getChatItemByFileId db vr user fileId
withAgent (`xftpDeleteSndFileInternal` aFileId)
toView $ CRSndFileCompleteXFTP user ci' ft
ci@(AChatItem _ d cInfo _ci@ChatItem {meta = CIMeta {itemSharedMsgId = msgId_, itemDeleted}}) <-
withStore $ \db -> getChatItemByFileId db vr user fileId
case (msgId_, itemDeleted) of
(Just sharedMsgId, Nothing) -> do
when (length rfds < length sfts) $ throwChatError $ CEInternalError "not enough XFTP file descriptions to send"
-- TODO either update database status or move to SFPROG
toView $ CRSndFileProgressXFTP user ci ft 1 1
case (rfds, sfts, d, cInfo) of
(rfd : extraRFDs, sft : _, SMDSnd, DirectChat ct) -> do
withStore' $ \db -> createExtraSndFTDescrs db user fileId (map fileDescrText extraRFDs)
msgDeliveryId <- sendFileDescription sft rfd sharedMsgId $ sendDirectContactMessage ct
withStore' $ \db -> updateSndFTDeliveryXFTP db sft msgDeliveryId
withAgent (`xftpDeleteSndFileInternal` aFileId)
(_, _, SMDSnd, GroupChat g@GroupInfo {groupId}) -> do
ms <- withStore' $ \db -> getGroupMembers db user g
let rfdsMemberFTs = zip rfds $ memberFTs ms
extraRFDs = drop (length rfdsMemberFTs) rfds
withStore' $ \db -> createExtraSndFTDescrs db user fileId (map fileDescrText extraRFDs)
forM_ rfdsMemberFTs $ \mt -> sendToMember mt `catchChatError` (toView . CRChatError (Just user))
ci' <- withStore $ \db -> do
liftIO $ updateCIFileStatus db user fileId CIFSSndComplete
getChatItemByFileId db vr user fileId
withAgent (`xftpDeleteSndFileInternal` aFileId)
toView $ CRSndFileCompleteXFTP user ci' ft
where
memberFTs :: [GroupMember] -> [(Connection, SndFileTransfer)]
memberFTs ms = M.elems $ M.intersectionWith (,) (M.fromList mConns') (M.fromList sfts')
where
memberFTs :: [GroupMember] -> [(Connection, SndFileTransfer)]
memberFTs ms = M.elems $ M.intersectionWith (,) (M.fromList mConns') (M.fromList sfts')
where
mConns' = mapMaybe useMember ms
sfts' = mapMaybe (\sft@SndFileTransfer {groupMemberId} -> (,sft) <$> groupMemberId) sfts
useMember GroupMember {groupMemberId, activeConn = Just conn@Connection {connStatus}}
| (connStatus == ConnReady || connStatus == ConnSndReady) && not (connDisabled conn) = Just (groupMemberId, conn)
| otherwise = Nothing
useMember _ = Nothing
sendToMember :: (ValidFileDescription 'FRecipient, (Connection, SndFileTransfer)) -> m ()
sendToMember (rfd, (conn, sft)) =
void $ sendFileDescription sft rfd sharedMsgId $ \msg' -> sendDirectMessage conn msg' $ GroupId groupId
_ -> pure ()
_ -> pure () -- TODO error?
mConns' = mapMaybe useMember ms
sfts' = mapMaybe (\sft@SndFileTransfer {groupMemberId} -> (,sft) <$> groupMemberId) sfts
useMember GroupMember {groupMemberId, activeConn = Just conn@Connection {connStatus}}
| (connStatus == ConnReady || connStatus == ConnSndReady) && not (connDisabled conn) = Just (groupMemberId, conn)
| otherwise = Nothing
useMember _ = Nothing
sendToMember :: (ValidFileDescription 'FRecipient, (Connection, SndFileTransfer)) -> m ()
sendToMember (rfd, (conn, sft)) =
void $ sendFileDescription sft rfd sharedMsgId $ \msg' -> sendDirectMessage conn msg' $ GroupId groupId
_ -> pure ()
_ -> pure () -- TODO error?
SFERR e
| temporaryAgentError e ->
throwChatError $ CEXFTPSndFile fileId (AgentSndFileId aFileId) e
| otherwise -> do
ci <- withStore $ \db -> do
liftIO $ updateFileCancelled db user fileId CIFSSndError
lookupChatItemByFileId db vr user fileId
getChatItemByFileId db vr user fileId
withAgent (`xftpDeleteSndFileInternal` aFileId)
toView $ CRSndFileError user ci ft
toView $ CRSndFileError user ci
where
fileDescrText :: FilePartyI p => ValidFileDescription p -> T.Text
fileDescrText = safeDecodeUtf8 . strEncode
fileDescrURI :: ValidFileDescription 'FRecipient -> Maybe T.Text
fileDescrURI vfd = if T.length uri < FD.qrSizeLimit then Just uri else Nothing
where
uri = decodeLatin1 . strEncode $ FD.fileDescriptionURI vfd
sendFileDescription :: SndFileTransfer -> ValidFileDescription 'FRecipient -> SharedMsgId -> (ChatMsgEvent 'Json -> m (SndMessage, Int64)) -> m Int64
sendFileDescription sft rfd msgId sendMsg = do
let rfdText = fileDescrText rfd
@@ -3379,30 +3306,30 @@ processAgentMsgRcvFile _corrId aFileId msg =
let status = CIFSRcvTransfer {rcvProgress, rcvTotal}
ci <- withStore $ \db -> do
liftIO $ updateCIFileStatus db user fileId status
lookupChatItemByFileId db vr user fileId
toView $ CRRcvFileProgressXFTP user ci rcvProgress rcvTotal ft
getChatItemByFileId db vr user fileId
toView $ CRRcvFileProgressXFTP user ci rcvProgress rcvTotal
RFDONE xftpPath ->
case liveRcvFileTransferPath ft of
Nothing -> throwChatError $ CEInternalError "no target path for received XFTP file"
Just targetPath -> do
fsTargetPath <- toFSFilePath targetPath
renameFile xftpPath fsTargetPath
ci_ <- withStore $ \db -> do
ci <- withStore $ \db -> do
liftIO $ do
updateRcvFileStatus db fileId FSComplete
updateCIFileStatus db user fileId CIFSRcvComplete
lookupChatItemByFileId db vr user fileId
getChatItemByFileId db vr user fileId
agentXFTPDeleteRcvFile aFileId fileId
toView $ maybe (CRRcvStandaloneFileComplete user fsTargetPath ft) (CRRcvFileComplete user) ci_
toView $ CRRcvFileComplete user ci
RFERR e
| temporaryAgentError e ->
throwChatError $ CEXFTPRcvFile fileId (AgentRcvFileId aFileId) e
| otherwise -> do
ci <- withStore $ \db -> do
liftIO $ updateFileCancelled db user fileId CIFSRcvError
lookupChatItemByFileId db vr user fileId
getChatItemByFileId db vr user fileId
agentXFTPDeleteRcvFile aFileId fileId
toView $ CRRcvFileError user ci e ft
toView $ CRRcvFileError user ci e
processAgentMessageConn :: forall m. ChatMonad m => VersionRange -> User -> ACorrId -> ConnId -> ACommand 'Agent 'AEConn -> m ()
processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = do
@@ -3610,15 +3537,18 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage =
processErr cryptoErr = do
let e@(mde, n) = agentMsgDecryptError cryptoErr
ci_ <- withStore $ \db ->
getDirectChatItemLast db user contactId
getDirectChatItemsLast db user contactId 1 ""
>>= liftIO
. mapM (\(ci, content') -> updateDirectChatItem' db user contactId ci content' False Nothing)
. mdeUpdatedCI e
. (mdeUpdatedCI e <=< headMaybe)
case ci_ of
Just ci -> toView $ CRChatItemUpdated user (AChatItem SCTDirect SMDRcv (DirectChat ct) ci)
_ -> do
toView $ CRContactRatchetSync user ct (RatchetSyncProgress rss cStats)
createInternalChatItem user (CDDirectRcv ct) (CIRcvDecryptionError mde n) Nothing
headMaybe = \case
x : _ -> Just x
_ -> Nothing
ratchetSyncEventItem ct' = do
toView $ CRContactRatchetSync user ct' (RatchetSyncProgress rss cStats)
createInternalChatItem user (CDDirectRcv ct') (CIRcvConnEvent $ RCERatchetSync rss) Nothing
@@ -4078,10 +4008,10 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage =
case err of
SMP SMP.AUTH -> unless (fileStatus == FSCancelled) $ do
ci <- withStore $ \db -> do
liftIO (lookupChatRefByFileId db user fileId) >>= \case
Just (ChatRef CTDirect _) -> liftIO $ updateFileCancelled db user fileId CIFSSndCancelled
getChatRefByFileId db user fileId >>= \case
ChatRef CTDirect _ -> liftIO $ updateFileCancelled db user fileId CIFSSndCancelled
_ -> pure ()
lookupChatItemByFileId db vr user fileId
getChatItemByFileId db vr user fileId
toView $ CRSndFileRcvCancelled user ci ft
_ -> throwChatError $ CEFileSend fileId err
MSG meta _ _ -> withAckMessage' agentConnId conn meta $ pure ()
@@ -5867,23 +5797,17 @@ closeFileHandle fileId files = do
liftIO $ mapM_ hClose h_ `catchAll_` pure ()
deleteMembersConnections :: ChatMonad m => User -> [GroupMember] -> m ()
deleteMembersConnections user members = deleteMembersConnections' user members False
deleteMembersConnections' :: ChatMonad m => User -> [GroupMember] -> Bool -> m ()
deleteMembersConnections' user members waitDelivery = do
deleteMembersConnections user members = do
let memberConns =
filter (\Connection {connStatus} -> connStatus /= ConnDeleted) $
mapMaybe (\GroupMember {activeConn} -> activeConn) members
deleteAgentConnectionsAsync' user (map aConnId memberConns) waitDelivery
void . withStoreBatch' $ \db -> map (\conn -> updateConnectionStatus db conn ConnDeleted) memberConns
deleteAgentConnectionsAsync user $ map aConnId memberConns
forM_ memberConns $ \conn -> withStore' $ \db -> updateConnectionStatus db conn ConnDeleted
deleteMemberConnection :: ChatMonad m => User -> GroupMember -> m ()
deleteMemberConnection user mem = deleteMemberConnection' user mem False
deleteMemberConnection' :: ChatMonad m => User -> GroupMember -> Bool -> m ()
deleteMemberConnection' user GroupMember {activeConn} waitDelivery = do
deleteMemberConnection user GroupMember {activeConn} = do
forM_ activeConn $ \conn -> do
deleteAgentConnectionAsync' user (aConnId conn) waitDelivery
deleteAgentConnectionAsync user $ aConnId conn
withStore' $ \db -> updateConnectionStatus db conn ConnDeleted
deleteOrUpdateMemberRecord :: ChatMonad m => User -> GroupMember -> m ()
@@ -6193,19 +6117,18 @@ deleteGroupCI user gInfo ci@ChatItem {file} byUser timed byGroupMember_ deletedT
gItem = AChatItem SCTGroup msgDirection (GroupChat gInfo)
deleteLocalCI :: (ChatMonad m, MsgDirectionI d) => User -> NoteFolder -> ChatItem 'CTLocal d -> Bool -> Bool -> m ChatResponse
deleteLocalCI user nf ci@ChatItem {file = file_} byUser timed = do
forM_ file_ $ \file -> do
let filesInfo = [mkCIFileInfo file]
deleteFilesLocally filesInfo
deleteLocalCI user nf ci@ChatItem {file} byUser timed = do
forM_ file $ \CIFile {fileSource} -> do
forM_ (CF.filePath <$> fileSource) $ \fPath ->
deleteFileLocally fPath `catchChatError` (toView . CRChatError (Just user))
withStore' $ \db -> deleteLocalChatItem db user nf ci
pure $ CRChatItemDeleted user (AChatItem SCTLocal msgDirection (LocalChat nf) ci) Nothing byUser timed
deleteCIFile :: (ChatMonad m, MsgDirectionI d) => User -> Maybe (CIFile d) -> m ()
deleteCIFile user file_ =
forM_ file_ $ \file -> do
let filesInfo = [mkCIFileInfo file]
cancelFilesInProgress user filesInfo
deleteFilesLocally filesInfo
fileAgentConnIds <- deleteFile' user (mkCIFileInfo file) True
deleteAgentConnectionsAsync user fileAgentConnIds
markDirectCIDeleted :: (ChatMonad m, MsgDirectionI d) => User -> Contact -> ChatItem 'CTDirect d -> MessageId -> Bool -> UTCTime -> m ChatResponse
markDirectCIDeleted user ct ci@ChatItem {file} msgId byUser deletedTs = do
@@ -6226,8 +6149,8 @@ markGroupCIDeleted user gInfo ci@ChatItem {file} msgId byUser byGroupMember_ del
cancelCIFile :: (ChatMonad m, MsgDirectionI d) => User -> Maybe (CIFile d) -> m ()
cancelCIFile user file_ =
forM_ file_ $ \file -> do
let filesInfo = [mkCIFileInfo file]
cancelFilesInProgress user filesInfo
fileAgentConnIds <- cancelFile' user (mkCIFileInfo file) True
deleteAgentConnectionsAsync user fileAgentConnIds
createAgentConnectionAsync :: forall m c. (ChatMonad m, ConnectionModeI c) => User -> CommandFunction -> Bool -> SConnectionMode c -> SubscriptionMode -> m (CommandId, ConnId)
createAgentConnectionAsync user cmdFunction enableNtfs cMode subMode = do
@@ -6256,62 +6179,26 @@ agentAcceptContactAsync user enableNtfs invId msg subMode = do
pure (cmdId, connId)
deleteAgentConnectionAsync :: ChatMonad m => User -> ConnId -> m ()
deleteAgentConnectionAsync user acId = deleteAgentConnectionAsync' user acId False
deleteAgentConnectionAsync' :: ChatMonad m => User -> ConnId -> Bool -> m ()
deleteAgentConnectionAsync' user acId waitDelivery = do
withAgent (\a -> deleteConnectionAsync a waitDelivery acId) `catchChatError` (toView . CRChatError (Just user))
deleteAgentConnectionAsync user acId =
withAgent (`deleteConnectionAsync` acId) `catchChatError` (toView . CRChatError (Just user))
deleteAgentConnectionsAsync :: ChatMonad m => User -> [ConnId] -> m ()
deleteAgentConnectionsAsync user acIds = deleteAgentConnectionsAsync' user acIds False
deleteAgentConnectionsAsync' :: ChatMonad m => User -> [ConnId] -> Bool -> m ()
deleteAgentConnectionsAsync' _ [] _ = pure ()
deleteAgentConnectionsAsync' user acIds waitDelivery = do
withAgent (\a -> deleteConnectionsAsync a waitDelivery acIds) `catchChatError` (toView . CRChatError (Just user))
deleteAgentConnectionsAsync _ [] = pure ()
deleteAgentConnectionsAsync user acIds =
withAgent (`deleteConnectionsAsync` acIds) `catchChatError` (toView . CRChatError (Just user))
agentXFTPDeleteRcvFile :: ChatMonad m => RcvFileId -> FileTransferId -> m ()
agentXFTPDeleteRcvFile aFileId fileId = do
withAgent (`xftpDeleteRcvFile` aFileId)
withStore' $ \db -> setRcvFTAgentDeleted db fileId
agentXFTPDeleteRcvFiles :: ChatMonad m => [(XFTPRcvFile, FileTransferId)] -> m ()
agentXFTPDeleteRcvFiles rcvFiles = do
let rcvFiles' = filter (not . agentRcvFileDeleted . fst) rcvFiles
rfIds = mapMaybe fileIds rcvFiles'
withAgent $ \a -> xftpDeleteRcvFiles a (map fst rfIds)
void . withStoreBatch' $ \db -> map (setRcvFTAgentDeleted db . snd) rfIds
where
fileIds :: (XFTPRcvFile, FileTransferId) -> Maybe (RcvFileId, FileTransferId)
fileIds (XFTPRcvFile {agentRcvFileId = Just (AgentRcvFileId aFileId)}, fileId) = Just (aFileId, fileId)
fileIds _ = Nothing
agentXFTPDeleteSndFileRemote :: ChatMonad m => User -> XFTPSndFile -> FileTransferId -> m ()
agentXFTPDeleteSndFileRemote user xsf fileId =
agentXFTPDeleteSndFilesRemote user [(xsf, fileId)]
agentXFTPDeleteSndFilesRemote :: forall m. ChatMonad m => User -> [(XFTPSndFile, FileTransferId)] -> m ()
agentXFTPDeleteSndFilesRemote user sndFiles = do
(_errs, redirects) <- partitionEithers <$> withStoreBatch' (\db -> map (lookupFileTransferRedirectMeta db user . snd) sndFiles)
let redirects' = mapMaybe mapRedirectMeta $ concat redirects
sndFilesAll = redirects' <> sndFiles
sndFilesAll' = filter (not . agentSndFileDeleted . fst) sndFilesAll
sndFilesAll'' <- catMaybes <$> mapM sndFileDescr sndFilesAll'
let sfs = map (\(XFTPSndFile {agentSndFileId = AgentSndFileId aFileId}, sfd, _) -> (aFileId, sfd)) sndFilesAll''
withAgent $ \a -> xftpDeleteSndFilesRemote a (aUserId user) sfs
void . withStoreBatch' $ \db -> map (setSndFTAgentDeleted db user . (\(_, _, fId) -> fId)) sndFilesAll''
where
mapRedirectMeta :: FileTransferMeta -> Maybe (XFTPSndFile, FileTransferId)
mapRedirectMeta FileTransferMeta {fileId = fileId, xftpSndFile = Just sndFileRedirect} = Just (sndFileRedirect, fileId)
mapRedirectMeta _ = Nothing
sndFileDescr :: (XFTPSndFile, FileTransferId) -> m (Maybe (XFTPSndFile, ValidFileDescription 'FSender, FileTransferId))
sndFileDescr (xsf@XFTPSndFile {privateSndFileDescr}, fileId) =
join <$> forM privateSndFileDescr parseSndDescr
where
parseSndDescr sfdText =
tryChatError (parseFileDescription sfdText) >>= \case
Left _ -> pure Nothing
Right sd -> pure $ Just (xsf, sd, fileId)
agentXFTPDeleteSndFileRemote user XFTPSndFile {agentSndFileId = AgentSndFileId aFileId, privateSndFileDescr, agentSndFileDeleted} fileId =
unless agentSndFileDeleted $
forM_ privateSndFileDescr $ \sfdText -> do
sd <- parseFileDescription sfdText
withAgent $ \a -> xftpDeleteSndFileRemote a (aUserId user) aFileId sd
withStore' $ \db -> setSndFTAgentDeleted db user fileId
userProfileToSend :: User -> Maybe Profile -> Maybe Contact -> Bool -> Profile
userProfileToSend user@User {profile = p} incognitoProfile ct inGroup = do
@@ -6541,9 +6428,6 @@ chatCommandP =
"/db encrypt " *> (APIStorageEncryption . dbEncryptionConfig "" <$> dbKeyP),
"/db key " *> (APIStorageEncryption <$> (dbEncryptionConfig <$> dbKeyP <* A.space <*> dbKeyP)),
"/db decrypt " *> (APIStorageEncryption . (`dbEncryptionConfig` "") <$> dbKeyP),
"/db test key " *> (TestStorageEncryption <$> dbKeyP),
"/_save app settings" *> (APISaveAppSettings <$> jsonP),
"/_get app settings" *> (APIGetAppSettings <$> optional (A.space *> jsonP)),
"/sql chat " *> (ExecChatStoreSQL <$> textP),
"/sql agent " *> (ExecAgentStoreSQL <$> textP),
"/sql slow" $> SlowSQLQueries,
@@ -6782,8 +6666,6 @@ chatCommandP =
"/list remote ctrls" $> ListRemoteCtrls,
"/stop remote ctrl" $> StopRemoteCtrl,
"/delete remote ctrl " *> (DeleteRemoteCtrl <$> A.decimal),
"/_upload " *> (APIUploadStandaloneFile <$> A.decimal <* A.space <*> cryptoFileP),
"/_download " *> (APIDownloadStandaloneFile <$> A.decimal <* A.space <*> strP_ <*> cryptoFileP),
("/quit" <|> "/q" <|> "/exit") $> QuitChat,
("/version" <|> "/v") $> ShowVersion,
"/debug locks" $> DebugLocks,
@@ -6963,29 +6845,3 @@ mkValidName = reverse . dropWhile isSpace . fst3 . foldl' addChar ("", '\NUL', 0
| isPunctuation prev = validFirstChar || isSpace c || (punct < 3 && isPunctuation c)
| otherwise = validFirstChar || isSpace c || isMark c || isPunctuation c
validFirstChar = isLetter c || isNumber c || isSymbol c
xftpSndFileTransfer_ :: ChatMonad m => User -> CryptoFile -> Integer -> Int -> Maybe ContactOrGroup -> m (FileInvitation, CIFile 'MDSnd, FileTransferMeta)
xftpSndFileTransfer_ user file@(CryptoFile filePath cfArgs) fileSize n contactOrGroup_ = do
let fileName = takeFileName filePath
fInv = xftpFileInvitation fileName fileSize dummyFileDescr
fsFilePath <- toFSFilePath filePath
let srcFile = CryptoFile fsFilePath cfArgs
aFileId <- withAgent $ \a -> xftpSendFile a (aUserId user) srcFile (roundedFDCount n)
-- TODO CRSndFileStart event for XFTP
chSize <- asks $ fileChunkSize . config
ft@FileTransferMeta {fileId} <- withStore' $ \db -> createSndFileTransferXFTP db user contactOrGroup_ file fInv (AgentSndFileId aFileId) Nothing chSize
let fileSource = Just $ CryptoFile filePath cfArgs
ciFile = CIFile {fileId, fileName, fileSize, fileSource, fileStatus = CIFSSndStored, fileProtocol = FPXFTP}
pure (fInv, ciFile, ft)
xftpSndFileRedirect :: ChatMonad m => User -> FileTransferId -> ValidFileDescription 'FRecipient -> m FileTransferMeta
xftpSndFileRedirect user ftId vfd = do
let fileName = "redirect.yaml"
file = CryptoFile fileName Nothing
fInv = xftpFileInvitation fileName (fromIntegral $ B.length $ strEncode vfd) dummyFileDescr
aFileId <- withAgent $ \a -> xftpSendDescription a (aUserId user) vfd (roundedFDCount 1)
chSize <- asks $ fileChunkSize . config
withStore' $ \db -> createSndFileTransferXFTP db user Nothing file fInv (AgentSndFileId aFileId) (Just ftId) chSize
dummyFileDescr :: FileDescr
dummyFileDescr = FileDescr {fileDescrText = "", fileDescrPartNo = 0, fileDescrComplete = False}
-190
View File
@@ -1,190 +0,0 @@
{-# 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)
+20 -35
View File
@@ -9,7 +9,6 @@ module Simplex.Chat.Archive
importArchive,
deleteStorage,
sqlCipherExport,
sqlCipherTestKey,
archiveFilesFolder,
)
where
@@ -21,7 +20,6 @@ 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
@@ -149,8 +147,19 @@ 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 key') DBErrorOpen
withDB (exported f) (`SQL.exec` testSQL) 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
@@ -158,38 +167,14 @@ sqlCipherExport DBEncryptionConfig {currentKey = DBEncryptionKey key, newKey = D
"SELECT sqlcipher_export('exported');",
"DETACH DATABASE exported;"
]
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
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)]
withDBs :: Monad m => (FilePath -> m b) -> StorageFiles -> m b
action `withDBs` StorageFiles {chatStore, agentStore} = action (dbFilePath chatStore) >> action (dbFilePath agentStore)
+13 -34
View File
@@ -49,7 +49,6 @@ 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
@@ -60,7 +59,6 @@ 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)
@@ -246,11 +244,8 @@ data ChatCommand
| APIExportArchive ArchiveConfig
| ExportArchive
| APIImportArchive ArchiveConfig
| APISaveAppSettings AppSettings
| APIGetAppSettings (Maybe AppSettings)
| APIDeleteStorage
| APIStorageEncryption DBEncryptionConfig
| TestStorageEncryption DBEncryptionKey
| ExecChatStoreSQL Text
| ExecAgentStoreSQL Text
| SlowSQLQueries
@@ -453,8 +448,6 @@ 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
@@ -594,26 +587,21 @@ data ChatResponse
| CRRcvFileAccepted {user :: User, chatItem :: AChatItem}
| CRRcvFileAcceptedSndCancelled {user :: User, rcvFileTransfer :: RcvFileTransfer}
| CRRcvFileDescrNotReady {user :: User, chatItem :: AChatItem}
| 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}
| CRRcvFileStart {user :: User, chatItem :: AChatItem}
| CRRcvFileProgressXFTP {user :: User, chatItem :: AChatItem, receivedSize :: Int64, totalSize :: Int64}
| CRRcvFileComplete {user :: User, chatItem :: AChatItem}
| CRRcvStandaloneFileComplete {user :: User, targetPath :: FilePath, rcvFileTransfer :: RcvFileTransfer}
| CRRcvFileCancelled {user :: User, chatItem_ :: Maybe AChatItem, rcvFileTransfer :: RcvFileTransfer}
| CRRcvFileCancelled {user :: User, chatItem :: AChatItem, rcvFileTransfer :: RcvFileTransfer}
| CRRcvFileSndCancelled {user :: User, chatItem :: AChatItem, rcvFileTransfer :: RcvFileTransfer}
| CRRcvFileError {user :: User, chatItem_ :: Maybe AChatItem, agentError :: AgentErrorType, rcvFileTransfer :: RcvFileTransfer}
| CRRcvFileError {user :: User, chatItem :: AChatItem, agentError :: AgentErrorType}
| CRSndFileStart {user :: User, chatItem :: AChatItem, sndFileTransfer :: SndFileTransfer}
| CRSndFileComplete {user :: User, chatItem :: AChatItem, sndFileTransfer :: SndFileTransfer}
| 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}
| 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}
| CRSndFileCompleteXFTP {user :: User, chatItem :: AChatItem, fileTransferMeta :: FileTransferMeta}
| CRSndStandaloneFileComplete {user :: User, fileTransferMeta :: FileTransferMeta, rcvURIs :: [Text]}
| CRSndFileCancelledXFTP {user :: User, chatItem_ :: Maybe AChatItem, fileTransferMeta :: FileTransferMeta}
| CRSndFileError {user :: User, chatItem_ :: Maybe AChatItem, fileTransferMeta :: FileTransferMeta}
| CRSndFileCancelledXFTP {user :: User, chatItem :: AChatItem, fileTransferMeta :: FileTransferMeta}
| CRSndFileError {user :: User, chatItem :: AChatItem}
| CRUserProfileUpdated {user :: User, fromProfile :: Profile, toProfile :: Profile, updateSummary :: UserProfileUpdateSummary}
| CRUserProfileImage {user :: User, profile :: Profile}
| CRContactAliasUpdated {user :: User, toContact :: Contact}
@@ -679,7 +667,7 @@ data ChatResponse
| CRUserContactLinkSubscribed -- TODO delete
| CRUserContactLinkSubError {chatError :: ChatError} -- TODO delete
| CRNtfTokenStatus {status :: NtfTknStatus}
| CRNtfToken {token :: DeviceToken, status :: NtfTknStatus, ntfMode :: NotificationsMode, ntfServer :: NtfServer}
| CRNtfToken {token :: DeviceToken, status :: NtfTknStatus, ntfMode :: NotificationsMode}
| CRNtfMessages {user_ :: Maybe User, connEntity_ :: Maybe ConnectionEntity, msgTs :: Maybe UTCTime, ntfMessages :: [NtfMsgInfo]}
| CRNtfMessage {user :: User, connEntity :: ConnectionEntity, ntfMessage :: NtfMsgInfo}
| CRContactConnectionDeleted {user :: User, connection :: PendingContactConnection}
@@ -714,7 +702,6 @@ 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)
@@ -948,8 +935,8 @@ data NtfMsgInfo = NtfMsgInfo {msgId :: Text, msgTs :: UTCTime}
ntfMsgInfo :: SMPMsgMeta -> NtfMsgInfo
ntfMsgInfo SMPMsgMeta {msgId, msgTs} = NtfMsgInfo {msgId = decodeLatin1 $ strEncode msgId, msgTs = systemToUTCTime msgTs}
crNtfToken :: (DeviceToken, NtfTknStatus, NotificationsMode, NtfServer) -> ChatResponse
crNtfToken (token, status, ntfMode, ntfServer) = CRNtfToken {token, status, ntfMode, ntfServer}
crNtfToken :: (DeviceToken, NtfTknStatus, NotificationsMode) -> ChatResponse
crNtfToken (token, status, ntfMode) = CRNtfToken {token, status, ntfMode}
data SwitchProgress = SwitchProgress
{ queueDirection :: QueueDirection,
@@ -1252,14 +1239,6 @@ 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
+3 -4
View File
@@ -30,11 +30,10 @@ 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 (..), ConnReqUriData (..), ConnectionRequestUri (..), SMPQueue (..))
import Simplex.Messaging.Agent.Protocol (AConnectionRequestUri (..), ConnReqScheme (..), 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
@@ -232,10 +231,10 @@ markdownP = mconcat <$> A.many' fragmentP
simplexUriFormat :: AConnectionRequestUri -> Format
simplexUriFormat = \case
ACR _ (CRContactUri crData) ->
let uri = safeDecodeUtf8 . strEncode $ CRContactUri crData {crScheme = SSSimplex}
let uri = safeDecodeUtf8 . strEncode $ CRContactUri crData {crScheme = CRSSimplex}
in SimplexLink (linkType' crData) uri $ uriHosts crData
ACR _ (CRInvitationUri crData e2e) ->
let uri = safeDecodeUtf8 . strEncode $ CRInvitationUri crData {crScheme = SSSimplex} e2e
let uri = safeDecodeUtf8 . strEncode $ CRInvitationUri crData {crScheme = CRSSimplex} e2e
in SimplexLink XLInvitation uri $ uriHosts crData
where
uriHosts ConnReqUriData {crSmpQueues} = L.map (safeDecodeUtf8 . strEncode) $ sconcat $ L.map (host . qServer) crSmpQueues
-18
View File
@@ -360,24 +360,6 @@ 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
+2 -2
View File
@@ -139,7 +139,7 @@ data CIContent (d :: MsgDirection) where
CISndModerated :: CIContent 'MDSnd
CIRcvModerated :: CIContent 'MDRcv
CIRcvBlocked :: CIContent 'MDRcv
CIInvalidJSON :: Text -> CIContent d -- this is also used for logical database errors, e.g. SEBadChatItem
CIInvalidJSON :: Text -> CIContent d
-- ^ 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 {} -> False
RDEProfileUpdated {} -> True
CIRcvGroupEvent rge -> case rge of
RGEMemberAdded {} -> False
RGEMemberConnected -> False
@@ -1,22 +0,0 @@
{-# 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;
|]
@@ -1,20 +0,0 @@
{-# 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;
|]
@@ -1,30 +0,0 @@
{-# 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;
|]
+3 -6
View File
@@ -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 RESTRICT
contact_id INTEGER NOT NULL UNIQUE REFERENCES contacts ON DELETE CASCADE
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 RESTRICT
ON DELETE CASCADE
ON UPDATE CASCADE
DEFERRABLE INITIALLY DEFERRED
);
@@ -193,8 +193,7 @@ 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,
redirect_file_id INTEGER REFERENCES files ON DELETE CASCADE
note_folder_id INTEGER DEFAULT NULL REFERENCES note_folders ON DELETE CASCADE
);
CREATE TABLE snd_files(
file_id INTEGER NOT NULL REFERENCES files ON DELETE CASCADE,
@@ -562,7 +561,6 @@ 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
@@ -856,4 +854,3 @@ 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);
-22
View File
@@ -1,22 +0,0 @@
{-# 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_)
+17 -50
View File
@@ -38,7 +38,6 @@ module Simplex.Chat.Store.Files
getGroupFileIdBySharedMsgId,
getDirectFileIdBySharedMsgId,
getChatRefByFileId,
lookupChatRefByFileId,
updateSndFileStatus,
createSndFileChunk,
updateSndFileChunkMsg,
@@ -46,7 +45,6 @@ module Simplex.Chat.Store.Files
deleteSndFileChunks,
createRcvFileTransfer,
createRcvGroupFileTransfer,
createRcvStandaloneFileTransfer,
appendRcvFD,
getRcvFileDescrByRcvFileId,
getRcvFileDescrBySndFileId,
@@ -71,7 +69,6 @@ module Simplex.Chat.Store.Files
getFileTransfer,
getFileTransferProgress,
getFileTransferMeta,
lookupFileTransferRedirectMeta,
getSndFileTransfer,
getSndFileTransfers,
getContactFileInfo,
@@ -88,14 +85,12 @@ 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)
@@ -191,7 +186,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, xftpRedirectFor = Nothing, fileName, filePath, fileSize, fileInline, chunkSize, cancelled = False}
pure FileTransferMeta {fileId, xftpSndFile = 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
@@ -264,16 +259,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 -> 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
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
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, 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))
"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))
fileId <- insertedRowId db
pure FileTransferMeta {fileId, xftpSndFile, xftpRedirectFor, fileName, filePath, fileSize, fileInline = Nothing, chunkSize, cancelled = False}
pure FileTransferMeta {fileId, xftpSndFile, 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
@@ -408,14 +403,11 @@ getDirectFileIdBySharedMsgId db User {userId} Contact {contactId} sharedMsgId =
(userId, contactId, sharedMsgId)
getChatRefByFileId :: DB.Connection -> User -> Int64 -> ExceptT StoreError IO ChatRef
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
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"
where
getChatRef =
DB.query
@@ -526,23 +518,6 @@ 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
@@ -669,9 +644,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_ <|> standaloneName_ of
case contactName_ <|> memberName_ of
Nothing -> throwError $ SERcvFileInvalid fileId
Just name ->
Just name -> do
case fileStatus' of
FSNew -> pure $ ft name RFSNew
FSAccepted -> ft name . RFSAccepted <$> rfi
@@ -679,9 +654,6 @@ 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
@@ -916,22 +888,17 @@ 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, redirect_file_id
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
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, Maybe FileTransferId) -> FileTransferMeta
fileTransferMeta (fileName, fileSize, chunkSize, filePath, fileKey, fileNonce, fileInline, aSndFileId_, agentSndFileDeleted, privateSndFileDescr, cancelled_, xftpRedirectFor) =
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_) =
let cryptoArgs = CFArgs <$> fileKey <*> fileNonce
xftpSndFile = (\fId -> XFTPSndFile {agentSndFileId = fId, privateSndFileDescr, agentSndFileDeleted, cryptoArgs}) <$> aSndFileId_
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
in FileTransferMeta {fileId, xftpSndFile, fileName, fileSize, chunkSize, filePath, fileInline, cancelled = fromMaybe False cancelled_}
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
+95 -177
View File
@@ -39,7 +39,7 @@ module Simplex.Chat.Store.Messages
getDirectChat,
getGroupChat,
getLocalChat,
getDirectChatItemLast,
getDirectChatItemsLast,
getAllChatItems,
getAChatItem,
updateDirectChatItem,
@@ -92,7 +92,6 @@ module Simplex.Chat.Store.Messages
getLocalChatItemIdByText,
getLocalChatItemIdByText',
getChatItemByFileId,
lookupChatItemByFileId,
getChatItemByGroupId,
updateDirectChatItemStatus,
getTimedItems,
@@ -126,7 +125,6 @@ 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, (:.) (..))
@@ -830,7 +828,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 (Just itemTs)
badItem = Left $ SEBadChatItem itemId
ciMeta :: CIContent d -> CIStatus d -> CIMeta 'CTLocal d
ciMeta content status =
let itemDeleted' = case itemDeleted of
@@ -924,118 +922,97 @@ 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 $ case pagination of
liftIO . getDirectChatReactions_ db ct =<< 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
-- the last items in reverse order (the last item in the conversation is the first in the returned list)
getDirectChatLast_ :: DB.Connection -> User -> Contact -> Int -> String -> IO (Chat 'CTDirect)
getDirectChatLast_ db user@User {userId} ct@Contact {contactId} count search = do
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}
chatItemIds <- getDirectChatItemIdsLast_
currentTs <- getCurrentTime
chatItems <- mapM (safeGetDirectItem db user ct currentTs) chatItemIds
chatItems <- getDirectChatItemsLast db user contactId count search
pure $ Chat (DirectChat ct) (reverse chatItems) stats
where
getDirectChatItemIdsLast_ :: IO [ChatItemId]
getDirectChatItemIdsLast_ =
map fromOnly
<$> DB.query
db
[sql|
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_
-- 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
chatItems <- mapM (safeGetDirectItem db user ct currentTs) chatItemIds
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
let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False}
chatItems <- ExceptT getDirectChatItemsAfter_
pure $ Chat (DirectChat ct) chatItems stats
where
getDirectChatItemIdsAfter_ :: IO [ChatItemId]
getDirectChatItemIdsAfter_ =
map fromOnly
getDirectChatItemsAfter_ :: IO (Either StoreError [CChatItem 'CTDirect])
getDirectChatItemsAfter_ = do
currentTs <- getCurrentTime
mapM (toDirectChatItem currentTs)
<$> 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
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
LIMIT ?
|]
(userId, contactId, search, afterChatItemId, count)
getDirectChatBefore_ :: DB.Connection -> User -> Contact -> ChatItemId -> Int -> String -> IO (Chat 'CTDirect)
getDirectChatBefore_ db user@User {userId} ct@Contact {contactId} beforeChatItemId count search = do
getDirectChatBefore_ :: DB.Connection -> User -> Contact -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTDirect)
getDirectChatBefore_ db User {userId} ct@Contact {contactId} beforeChatItemId count search = do
let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False}
chatItemIds <- getDirectChatItemsIdsBefore_
currentTs <- getCurrentTime
chatItems <- mapM (safeGetDirectItem db user ct currentTs) chatItemIds
chatItems <- ExceptT getDirectChatItemsBefore_
pure $ Chat (DirectChat ct) (reverse chatItems) stats
where
getDirectChatItemsIdsBefore_ :: IO [ChatItemId]
getDirectChatItemsIdsBefore_ =
map fromOnly
getDirectChatItemsBefore_ :: IO (Either StoreError [CChatItem 'CTDirect])
getDirectChatItemsBefore_ = do
currentTs <- getCurrentTime
mapM (toDirectChatItem currentTs)
<$> 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 DESC, chat_item_id DESC
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
LIMIT ?
|]
(userId, contactId, search, beforeChatItemId, count)
@@ -1045,16 +1022,15 @@ getGroupChat db vr user groupId pagination search_ = do
let search = fromMaybe "" search_
g <- getGroupInfo db vr user groupId
case pagination of
CPLast count -> liftIO $ getGroupChatLast_ db user g count search
CPLast count -> 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 -> IO (Chat 'CTGroup)
getGroupChatLast_ :: DB.Connection -> User -> GroupInfo -> Int -> String -> ExceptT StoreError IO (Chat 'CTGroup)
getGroupChatLast_ db user@User {userId} g@GroupInfo {groupId} count search = do
let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False}
chatItemIds <- getGroupChatItemIdsLast_
currentTs <- getCurrentTime
chatItems <- mapM (safeGetGroupItem db user g currentTs) chatItemIds
chatItemIds <- liftIO getGroupChatItemIdsLast_
chatItems <- mapM (getGroupCIWithReactions db user g) chatItemIds
pure $ Chat (GroupChat g) (reverse chatItems) stats
where
getGroupChatItemIdsLast_ :: IO [ChatItemId]
@@ -1071,32 +1047,6 @@ 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 <-
@@ -1118,8 +1068,7 @@ 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)
currentTs <- liftIO getCurrentTime
chatItems <- liftIO $ mapM (safeGetGroupItem db user g currentTs) chatItemIds
chatItems <- mapM (getGroupCIWithReactions db user g) chatItemIds
pure $ Chat (GroupChat g) chatItems stats
where
getGroupChatItemIdsAfter_ :: UTCTime -> IO [ChatItemId]
@@ -1142,8 +1091,7 @@ 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)
currentTs <- liftIO getCurrentTime
chatItems <- liftIO $ mapM (safeGetGroupItem db user g currentTs) chatItemIds
chatItems <- mapM (getGroupCIWithReactions db user g) chatItemIds
pure $ Chat (GroupChat g) (reverse chatItems) stats
where
getGroupChatItemIdsBefore_ :: UTCTime -> IO [ChatItemId]
@@ -1165,17 +1113,16 @@ getLocalChat :: DB.Connection -> User -> Int64 -> ChatPagination -> Maybe String
getLocalChat db user folderId pagination search_ = do
let search = fromMaybe "" search_
nf <- getNoteFolder db user folderId
liftIO $ case pagination of
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 -> IO (Chat 'CTLocal)
getLocalChatLast_ :: DB.Connection -> User -> NoteFolder -> Int -> String -> ExceptT StoreError IO (Chat 'CTLocal)
getLocalChatLast_ db user@User {userId} nf@NoteFolder {noteFolderId} count search = do
let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False}
chatItemIds <- getLocalChatItemIdsLast_
currentTs <- getCurrentTime
chatItems <- mapM (safeGetLocalItem db user nf currentTs) chatItemIds
chatItemIds <- liftIO getLocalChatItemIdsLast_
chatItems <- mapM (getLocalChatItem db user noteFolderId) chatItemIds
pure $ Chat (LocalChat nf) (reverse chatItems) stats
where
getLocalChatItemIdsLast_ :: IO [ChatItemId]
@@ -1192,38 +1139,11 @@ getLocalChatLast_ db user@User {userId} nf@NoteFolder {noteFolderId} count searc
|]
(userId, noteFolderId, search, count)
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.Connection -> User -> NoteFolder -> ChatItemId -> Int -> String -> ExceptT StoreError 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 <- getLocalChatItemIdsAfter_
currentTs <- getCurrentTime
chatItems <- mapM (safeGetLocalItem db user nf currentTs) chatItemIds
chatItemIds <- liftIO getLocalChatItemIdsAfter_
chatItems <- mapM (getLocalChatItem db user noteFolderId) chatItemIds
pure $ Chat (LocalChat nf) chatItems stats
where
getLocalChatItemIdsAfter_ :: IO [ChatItemId]
@@ -1241,12 +1161,11 @@ getLocalChatAfter_ db user@User {userId} nf@NoteFolder {noteFolderId} afterChatI
|]
(userId, noteFolderId, search, afterChatItemId, count)
getLocalChatBefore_ :: DB.Connection -> User -> NoteFolder -> ChatItemId -> Int -> String -> IO (Chat 'CTLocal)
getLocalChatBefore_ :: DB.Connection -> User -> NoteFolder -> ChatItemId -> Int -> String -> ExceptT StoreError 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 <- getLocalChatItemIdsBefore_
currentTs <- getCurrentTime
chatItems <- mapM (safeGetLocalItem db user nf currentTs) chatItemIds
chatItemIds <- liftIO getLocalChatItemIdsBefore_
chatItems <- mapM (getLocalChatItem db user noteFolderId) chatItemIds
pure $ Chat (LocalChat nf) (reverse chatItems) stats
where
getLocalChatItemIdsBefore_ :: IO [ChatItemId]
@@ -1269,7 +1188,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 Nothing
(itemId, _, _, _) -> Left $ SEBadChatItem itemId
updateDirectChatItemsRead :: DB.Connection -> User -> ContactId -> Maybe (ChatItemId, ChatItemId) -> IO ()
updateDirectChatItemsRead db User {userId} contactId itemsRange_ = do
@@ -1442,7 +1361,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 (Just itemTs)
badItem = Left $ SEBadChatItem itemId
ciMeta :: CIContent d -> CIStatus d -> CIMeta 'CTDirect d
ciMeta content status =
let itemDeleted' = case itemDeleted of
@@ -1493,7 +1412,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 (Just itemTs)
badItem = Left $ SEBadChatItem itemId
ciMeta :: CIContent d -> CIStatus d -> CIMeta 'CTGroup d
ciMeta content status =
let itemDeleted' = case itemDeleted of
@@ -2166,12 +2085,6 @@ 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) <-
@@ -2196,7 +2109,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 Nothing
(_, _) -> Left $ SEBadChatItem itemId
getAChatItem :: DB.Connection -> VersionRange -> User -> ChatRef -> ChatItemId -> ExceptT StoreError IO AChatItem
getAChatItem db vr user chatRef itemId = case chatRef of
@@ -2232,6 +2145,11 @@ 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
+1 -7
View File
@@ -98,9 +98,6 @@ 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)]
@@ -198,10 +195,7 @@ 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),
("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)
("20240122_indexes", m20240122_indexes, Just down_m20240122_indexes)
]
-- | The list of migrations in ascending order by date
+1 -2
View File
@@ -92,12 +92,11 @@ data StoreError
| SEUniqueID
| SELargeMsg
| SEInternalError {message :: String}
| SEBadChatItem {itemId :: ChatItemId, itemTs :: Maybe ChatItemTs}
| SEBadChatItem {itemId :: ChatItemId}
| SEChatItemNotFound {itemId :: ChatItemId}
| SEChatItemNotFoundByText {text :: Text}
| SEChatItemSharedMsgIdNotFound {sharedMsgId :: SharedMsgId}
| SEChatItemNotFoundByFileId {fileId :: FileTransferId}
| SEChatItemNotFoundByContactId {contactId :: ContactId}
| SEChatItemNotFoundByGroupId {groupId :: GroupId}
| SEProfileNotFound {profileId :: Int64}
| SEDuplicateGroupLink {groupInfo :: GroupInfo}
+3 -4
View File
@@ -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, RcvFileId, SAEntity (..), SndFileId, UserId)
import Simplex.Messaging.Agent.Protocol (ACommandTag (..), ACorrId, AParty (..), APartyCmdTag (..), ConnId, ConnectionMode (..), ConnectionRequestUri, InvitationId, SAEntity (..), 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 SndFileId
newtype AgentSndFileId = AgentSndFileId ConnId
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 RcvFileId
newtype AgentRcvFileId = AgentRcvFileId ConnId
deriving (Eq, Show)
instance StrEncoding AgentRcvFileId where
@@ -1210,7 +1210,6 @@ data FileTransfer
data FileTransferMeta = FileTransferMeta
{ fileId :: FileTransferId,
xftpSndFile :: Maybe XFTPSndFile,
xftpRedirectFor :: Maybe FileTransferId,
fileName :: String,
filePath :: String,
fileSize :: Integer,
+10 -37
View File
@@ -198,24 +198,17 @@ 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 (Just ci) e _ -> ttyUser u $ receivingFile_' hu testView "error" ci <> [sShow e]
CRRcvFileError u Nothing e ft -> ttyUser u $ receivingFileStandalone "error" ft <> [sShow e]
CRRcvFileError u ci e -> ttyUser u $ receivingFile_' hu testView "error" ci <> [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 Nothing ft -> ttyUser u $ uploadingFileStandalone "error" ft
CRSndFileError u (Just ci) _ -> ttyUser u $ uploadingFile "error" ci
CRSndFileError u ci -> ttyUser u $ uploadingFile "error" ci
CRSndFileRcvCancelled u _ ft@SndFileTransfer {recipientDisplayName = c} ->
ttyUser u [ttyContact c <> " cancelled receiving " <> sndFile ft]
CRContactConnecting u _ -> ttyUser u []
@@ -290,7 +283,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 srv -> ["device token status: " <> plain (smpEncode status) <> ", notifications mode: " <> plain (strEncode mode) <> ", server: " <> sShow srv]
CRNtfToken _ status mode -> ["device token status: " <> plain (smpEncode status) <> ", notifications mode: " <> plain (strEncode mode)]
CRNtfMessages {} -> []
CRNtfMessage {} -> []
CRCurrentRemoteHost rhi_ ->
@@ -385,7 +378,6 @@ 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]
@@ -1566,26 +1558,11 @@ sendingFile_ status ft@SndFileTransfer {recipientDisplayName = c} =
[status <> " sending " <> sndFile ft <> " to " <> ttyContact c]
uploadingFile :: StyledString -> AChatItem -> [StyledString]
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
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
sndFile :: SndFileTransfer -> StyledString
sndFile SndFileTransfer {fileId, fileName} = fileTransferStr fileId fileName
@@ -1631,11 +1608,7 @@ 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"]
receivingFileStandalone :: String -> RcvFileTransfer -> [StyledString]
receivingFileStandalone status RcvFileTransfer {fileId, fileInvitation = FileInvitation {fileName}} =
[plain status <> " standalone receiving " <> fileTransferStr fileId fileName]
receivingFile_' _ _ status _ = [plain status <> " receiving file"] -- shouldn't happen
viewLocalFile :: StyledString -> CIFile d -> CurrentTime -> TimeZone -> CIMeta c d -> [StyledString]
viewLocalFile to CIFile {fileId, fileSource} ts tz = case fileSource of
@@ -1654,7 +1627,7 @@ fileFrom _ _ = ""
receivingFile_ :: StyledString -> RcvFileTransfer -> [StyledString]
receivingFile_ status ft@RcvFileTransfer {senderDisplayName = c} =
[status <> " receiving " <> rcvFile ft <> if c == "" then "" else " from " <> ttyContact c]
[status <> " receiving " <> rcvFile ft <> " from " <> ttyContact c]
rcvFile :: RcvFileTransfer -> StyledString
rcvFile RcvFileTransfer {fileId, fileInvitation = FileInvitation {fileName}} = fileTransferStr fileId fileName
+6 -6
View File
@@ -146,9 +146,9 @@ testAgentCfgV1 :: AgentConfig
testAgentCfgV1 =
testAgentCfg
{ smpClientVRange = v1Range,
smpAgentVRange = versionToRange 2, -- duplexHandshakeSMPAgentVersion,
e2eEncryptVRange = versionToRange 2, -- kdfX3DHE2EEncryptVersion,
smpCfg = (smpCfg testAgentCfg) {serverVRange = versionToRange 4} -- batchCmdsSMPVersion
smpAgentVRange = v1Range,
e2eEncryptVRange = v1Range,
smpCfg = (smpCfg testAgentCfg) {serverVRange = v1Range}
}
testCfgVPrev :: ChatConfig
@@ -166,7 +166,7 @@ testCfgV1 =
}
prevRange :: VersionRange -> VersionRange
prevRange vr = vr {maxVersion = max (minVersion vr) (maxVersion vr - 1)}
prevRange vr = vr {maxVersion = maxVersion vr - 1}
v1Range :: VersionRange
v1Range = mkVersionRange 1 1
@@ -385,7 +385,7 @@ serverCfg =
logStatsStartTime = 0,
serverStatsLogFile = "tests/smp-server-stats.daily.log",
serverStatsBackupFile = Nothing,
smpServerVRange = supportedServerSMPRelayVRange,
smpServerVRange = supportedSMPServerVRange,
transportConfig = defaultTransportServerConfig,
smpHandshakeTimeout = 1000000,
controlPort = Nothing
@@ -408,7 +408,7 @@ xftpServerConfig =
storeLogFile = Just "tests/tmp/xftp-server-store.log",
filesPath = xftpServerFiles,
fileSizeQuota = Nothing,
allowedChunkSizes = [kb 64, kb 128, kb 256, mb 1, mb 4],
allowedChunkSizes = [kb 128, kb 256, mb 1, mb 4],
allowNewFiles = True,
newFileBasicAuth = Nothing,
fileExpiration = Just defaultFileExpiration,
+2 -29
View File
@@ -14,9 +14,6 @@ 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 (..))
@@ -24,7 +21,6 @@ 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 ((</>))
@@ -88,9 +84,8 @@ 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 "settings" $ do
it "set chat item expiration TTL" testSetChatItemTTL
it "save/get app settings" testAppSettings
describe "chat item expiration" $ do
it "set chat item TTL" testSetChatItemTTL
describe "connection switch" $ do
it "switch contact to a different queue" testSwitchContact
it "stop switching contact to a different queue" testAbortSwitchContact
@@ -1143,10 +1138,6 @@ 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"
@@ -2200,24 +2191,6 @@ 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 $
+3 -147
View File
@@ -9,7 +9,6 @@ import ChatClient
import ChatTests.Utils
import Control.Concurrent (threadDelay)
import Control.Concurrent.Async (concurrently_)
import Control.Logger.Simple
import qualified Data.Aeson as J
import qualified Data.ByteString.Char8 as B
import qualified Data.ByteString.Lazy.Char8 as LB
@@ -20,6 +19,7 @@ import Simplex.Chat.Options (ChatOpts (..))
import Simplex.FileTransfer.Server.Env (XFTPServerConfig (..))
import Simplex.Messaging.Crypto.File (CryptoFile (..), CryptoFileArgs (..))
import Simplex.Messaging.Encoding.String
import Simplex.Messaging.Util (unlessM)
import System.Directory (copyFile, createDirectoryIfMissing, doesFileExist, getFileSize)
import Test.Hspec hiding (it)
@@ -50,12 +50,6 @@ chatFileTests = do
it "cancel receiving file, repeat receive" testXFTPCancelRcvRepeat
it "should accept file automatically with CLI option" testAutoAcceptFile
it "should prohibit file transfers in groups based on preference" testProhibitFiles
describe "file transfer over XFTP without chat items" $ do
it "send and receive small standalone file" testXFTPStandaloneSmall
it "send and receive large standalone file" testXFTPStandaloneLarge
it "send and receive large standalone file using relative paths" testXFTPStandaloneRelativePaths
xit "removes sent file from server" testXFTPStandaloneCancelSnd -- no error shown in tests
it "removes received temporary files" testXFTPStandaloneCancelRcv
runTestMessageWithFile :: HasCallStack => FilePath -> IO ()
runTestMessageWithFile = testChat2 aliceProfile bobProfile $ \alice bob -> withXFTPServer $ do
@@ -844,143 +838,5 @@ testProhibitFiles =
(bob </)
(cath </)
testXFTPStandaloneSmall :: HasCallStack => FilePath -> IO ()
testXFTPStandaloneSmall = testChat2 aliceProfile aliceDesktopProfile $ \src dst -> do
withXFTPServer $ do
logNote "sending"
src ##> "/_upload 1 ./tests/fixtures/test.jpg"
src <## "started standalone uploading file 1 (test.jpg)"
-- silent progress events
threadDelay 250000
src <## "file 1 (test.jpg) upload complete. download with:"
-- file description fits, enjoy the direct URIs
_uri1 <- getTermLine src
_uri2 <- getTermLine src
uri3 <- getTermLine src
_uri4 <- getTermLine src
logNote "receiving"
let dstFile = "./tests/tmp/test.jpg"
dst ##> ("/_download 1 " <> uri3 <> " " <> dstFile)
dst <## "started standalone receiving file 1 (test.jpg)"
-- silent progress events
threadDelay 250000
dst <## "completed standalone receiving file 1 (test.jpg)"
srcBody <- B.readFile "./tests/fixtures/test.jpg"
B.readFile dstFile `shouldReturn` srcBody
testXFTPStandaloneLarge :: HasCallStack => FilePath -> IO ()
testXFTPStandaloneLarge = testChat2 aliceProfile aliceDesktopProfile $ \src dst -> do
withXFTPServer $ do
xftpCLI ["rand", "./tests/tmp/testfile.in", "17mb"] `shouldReturn` ["File created: " <> "./tests/tmp/testfile.in"]
logNote "sending"
src ##> "/_upload 1 ./tests/tmp/testfile.in"
src <## "started standalone uploading file 1 (testfile.in)"
-- silent progress events
threadDelay 250000
src <## "file 1 (testfile.in) uploaded, preparing redirect file 2"
src <## "file 1 (testfile.in) upload complete. download with:"
uri <- getTermLine src
_uri2 <- getTermLine src
_uri3 <- getTermLine src
_uri4 <- getTermLine src
logNote "receiving"
let dstFile = "./tests/tmp/testfile.out"
dst ##> ("/_download 1 " <> uri <> " " <> dstFile)
dst <## "started standalone receiving file 1 (testfile.out)"
-- silent progress events
threadDelay 250000
dst <## "completed standalone receiving file 1 (testfile.out)"
srcBody <- B.readFile "./tests/tmp/testfile.in"
B.readFile dstFile `shouldReturn` srcBody
testXFTPStandaloneCancelSnd :: HasCallStack => FilePath -> IO ()
testXFTPStandaloneCancelSnd = testChat2 aliceProfile aliceDesktopProfile $ \src dst -> do
withXFTPServer $ do
xftpCLI ["rand", "./tests/tmp/testfile.in", "17mb"] `shouldReturn` ["File created: " <> "./tests/tmp/testfile.in"]
logNote "sending"
src ##> "/_upload 1 ./tests/tmp/testfile.in"
src <## "started standalone uploading file 1 (testfile.in)"
-- silent progress events
threadDelay 250000
src <## "file 1 (testfile.in) uploaded, preparing redirect file 2"
src <## "file 1 (testfile.in) upload complete. download with:"
uri <- getTermLine src
_uri2 <- getTermLine src
_uri3 <- getTermLine src
_uri4 <- getTermLine src
logNote "cancelling"
src ##> "/fc 1"
src <## "cancelled sending file 1 (testfile.in)"
threadDelay 1000000
logNote "trying to receive cancelled"
dst ##> ("/_download 1 " <> uri <> " " <> "./tests/tmp/should.not.extist")
dst <## "started standalone receiving file 1 (should.not.extist)"
threadDelay 100000
logWarn "no error?"
dst <## "error receiving file 1 (should.not.extist)"
dst <## "INTERNAL {internalErr = \"XFTP {xftpErr = AUTH}\"}"
testXFTPStandaloneRelativePaths :: HasCallStack => FilePath -> IO ()
testXFTPStandaloneRelativePaths = testChat2 aliceProfile aliceDesktopProfile $ \src dst -> do
withXFTPServer $ do
logNote "sending"
src #$> ("/_files_folder ./tests/tmp/src_files", id, "ok")
src #$> ("/_temp_folder ./tests/tmp/src_xftp_temp", id, "ok")
xftpCLI ["rand", "./tests/tmp/src_files/testfile.in", "17mb"] `shouldReturn` ["File created: " <> "./tests/tmp/src_files/testfile.in"]
src ##> "/_upload 1 testfile.in"
src <## "started standalone uploading file 1 (testfile.in)"
-- silent progress events
threadDelay 250000
src <## "file 1 (testfile.in) uploaded, preparing redirect file 2"
src <## "file 1 (testfile.in) upload complete. download with:"
uri <- getTermLine src
_uri2 <- getTermLine src
_uri3 <- getTermLine src
_uri4 <- getTermLine src
logNote "receiving"
dst #$> ("/_files_folder ./tests/tmp/dst_files", id, "ok")
dst #$> ("/_temp_folder ./tests/tmp/dst_xftp_temp", id, "ok")
dst ##> ("/_download 1 " <> uri <> " testfile.out")
dst <## "started standalone receiving file 1 (testfile.out)"
-- silent progress events
threadDelay 250000
dst <## "completed standalone receiving file 1 (testfile.out)"
srcBody <- B.readFile "./tests/tmp/src_files/testfile.in"
B.readFile "./tests/tmp/dst_files/testfile.out" `shouldReturn` srcBody
testXFTPStandaloneCancelRcv :: HasCallStack => FilePath -> IO ()
testXFTPStandaloneCancelRcv = testChat2 aliceProfile aliceDesktopProfile $ \src dst -> do
withXFTPServer $ do
xftpCLI ["rand", "./tests/tmp/testfile.in", "17mb"] `shouldReturn` ["File created: " <> "./tests/tmp/testfile.in"]
logNote "sending"
src ##> "/_upload 1 ./tests/tmp/testfile.in"
src <## "started standalone uploading file 1 (testfile.in)"
-- silent progress events
threadDelay 250000
src <## "file 1 (testfile.in) uploaded, preparing redirect file 2"
src <## "file 1 (testfile.in) upload complete. download with:"
uri <- getTermLine src
_uri2 <- getTermLine src
_uri3 <- getTermLine src
_uri4 <- getTermLine src
logNote "receiving"
let dstFile = "./tests/tmp/testfile.out"
dst ##> ("/_download 1 " <> uri <> " " <> dstFile)
dst <## "started standalone receiving file 1 (testfile.out)"
threadDelay 25000 -- give workers some time to avoid internal errors from starting tasks
logNote "cancelling"
dst ##> "/fc 1"
dst <## "cancelled receiving file 1 (testfile.out)"
threadDelay 25000
doesFileExist dstFile `shouldReturn` False
waitFileExists :: HasCallStack => FilePath -> IO ()
waitFileExists f = unlessM (doesFileExist f) $ waitFileExists f
+1 -1
View File
@@ -151,7 +151,7 @@ testFiles tmp = withNewTestChat tmp "alice" aliceProfile $ \alice -> do
alice ##> "/clear *"
alice ##> "/fs 1"
alice <## "file 1 not found"
alice <## "chat db error: SEChatItemNotFoundByFileId {fileId = 1}"
alice ##> "/tail"
doesFileExist stored `shouldReturn` False
+3 -3
View File
@@ -82,9 +82,9 @@ versionTestMatrix2 runTest = do
it "prev" $ testChatCfg2 testCfgVPrev aliceProfile bobProfile runTest
it "prev to curr" $ runTestCfg2 testCfg testCfgVPrev runTest
it "curr to prev" $ runTestCfg2 testCfgVPrev testCfg runTest
it "old (1st supported)" $ testChatCfg2 testCfgV1 aliceProfile bobProfile runTest
it "old to curr" $ runTestCfg2 testCfg testCfgV1 runTest
it "curr to old" $ runTestCfg2 testCfgV1 testCfg runTest
it "v1" $ testChatCfg2 testCfgV1 aliceProfile bobProfile runTest
it "v1 to v2" $ runTestCfg2 testCfg testCfgV1 runTest
it "v2 to v1" $ runTestCfg2 testCfgV1 testCfg runTest
versionTestMatrix3 :: (HasCallStack => TestCC -> TestCC -> TestCC -> IO ()) -> SpecWith FilePath
versionTestMatrix3 runTest = do
+3 -3
View File
@@ -153,13 +153,13 @@ textWithUri = describe "text with Uri" do
parseMarkdown "_https://simplex.chat" `shouldBe` "_https://simplex.chat"
parseMarkdown "this is _https://simplex.chat" `shouldBe` "this is _https://simplex.chat"
it "SimpleX links" do
let inv = "/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D"
let inv = "/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D"
parseMarkdown ("https://simplex.chat" <> inv) `shouldBe` simplexLink XLInvitation ("simplex:" <> inv) ["smp.simplex.im"] ("https://simplex.chat" <> inv)
parseMarkdown ("simplex:" <> inv) `shouldBe` simplexLink XLInvitation ("simplex:" <> inv) ["smp.simplex.im"] ("simplex:" <> inv)
parseMarkdown ("https://example.com" <> inv) `shouldBe` simplexLink XLInvitation ("simplex:" <> inv) ["smp.simplex.im"] ("https://example.com" <> inv)
let ct = "/contact#/?v=2&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D"
let ct = "/contact#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D"
parseMarkdown ("https://simplex.chat" <> ct) `shouldBe` simplexLink XLContact ("simplex:" <> ct) ["smp.simplex.im"] ("https://simplex.chat" <> ct)
let gr = "/contact#/?v=2&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FWHV0YU1sYlU7NqiEHkHDB6gxO1ofTync%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAWbebOqVYuBXaiqHcXYjEHCpYi6VzDlu6CVaijDTmsQU%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22mL-7Divb94GGmGmRBef5Dg%3D%3D%22%7D"
let gr = "/contact#/?v=1-2&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FWHV0YU1sYlU7NqiEHkHDB6gxO1ofTync%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAWbebOqVYuBXaiqHcXYjEHCpYi6VzDlu6CVaijDTmsQU%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22mL-7Divb94GGmGmRBef5Dg%3D%3D%22%7D"
parseMarkdown ("https://simplex.chat" <> gr) `shouldBe` simplexLink XLGroup ("simplex:" <> gr) ["smp4.simplex.im", "o5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion"] ("https://simplex.chat" <> gr)
email :: Text -> Markdown
+11 -12
View File
@@ -15,7 +15,6 @@ import Simplex.Messaging.Agent.Protocol
import qualified Simplex.Messaging.Crypto as C
import Simplex.Messaging.Crypto.Ratchet
import Simplex.Messaging.Protocol (supportedSMPClientVRange)
import Simplex.Messaging.ServiceScheme
import Simplex.Messaging.Version
import Test.Hspec
@@ -38,7 +37,7 @@ queue =
connReqData :: ConnReqUriData
connReqData =
ConnReqUriData
{ crScheme = SSSimplex,
{ crScheme = CRSSimplex,
crAgentVRange = mkVersionRange 1 1,
crSmpQueues = [queue],
crClientData = Nothing
@@ -192,7 +191,7 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do
"{\"v\":\"1\",\"event\":\"x.msg.deleted\",\"params\":{}}"
#==# XMsgDeleted
it "x.file" $
"{\"v\":\"1\",\"event\":\"x.file\",\"params\":{\"file\":{\"fileConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"fileSize\":12345,\"fileName\":\"photo.jpg\"}}}"
"{\"v\":\"1\",\"event\":\"x.file\",\"params\":{\"file\":{\"fileConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"fileSize\":12345,\"fileName\":\"photo.jpg\"}}}"
#==# XFile FileInvitation {fileName = "photo.jpg", fileSize = 12345, fileDigest = Nothing, fileConnReq = Just testConnReq, fileInline = Nothing, fileDescr = Nothing}
it "x.file without file invitation" $
"{\"v\":\"1\",\"event\":\"x.file\",\"params\":{\"file\":{\"fileSize\":12345,\"fileName\":\"photo.jpg\"}}}"
@@ -201,7 +200,7 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do
"{\"v\":\"1\",\"event\":\"x.file.acpt\",\"params\":{\"fileName\":\"photo.jpg\"}}"
#==# XFileAcpt "photo.jpg"
it "x.file.acpt.inv" $
"{\"v\":\"1\",\"event\":\"x.file.acpt.inv\",\"params\":{\"msgId\":\"AQIDBA==\",\"fileName\":\"photo.jpg\",\"fileConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"}}"
"{\"v\":\"1\",\"event\":\"x.file.acpt.inv\",\"params\":{\"msgId\":\"AQIDBA==\",\"fileName\":\"photo.jpg\",\"fileConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"}}"
#==# XFileAcptInv (SharedMsgId "\1\2\3\4") (Just testConnReq) "photo.jpg"
it "x.file.acpt.inv" $
"{\"v\":\"1\",\"event\":\"x.file.acpt.inv\",\"params\":{\"msgId\":\"AQIDBA==\",\"fileName\":\"photo.jpg\"}}"
@@ -228,10 +227,10 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do
"{\"v\":\"1\",\"event\":\"x.contact\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}"
==# XContact testProfile Nothing
it "x.grp.inv" $
"{\"v\":\"1\",\"event\":\"x.grp.inv\",\"params\":{\"groupInvitation\":{\"connRequest\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"invitedMember\":{\"memberRole\":\"member\",\"memberId\":\"BQYHCA==\"},\"groupProfile\":{\"fullName\":\"Team\",\"displayName\":\"team\",\"groupPreferences\":{\"reactions\":{\"enable\":\"on\"},\"voice\":{\"enable\":\"on\"}}},\"fromMember\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\"}}}}"
"{\"v\":\"1\",\"event\":\"x.grp.inv\",\"params\":{\"groupInvitation\":{\"connRequest\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"invitedMember\":{\"memberRole\":\"member\",\"memberId\":\"BQYHCA==\"},\"groupProfile\":{\"fullName\":\"Team\",\"displayName\":\"team\",\"groupPreferences\":{\"reactions\":{\"enable\":\"on\"},\"voice\":{\"enable\":\"on\"}}},\"fromMember\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\"}}}}"
#==# XGrpInv GroupInvitation {fromMember = MemberIdRole (MemberId "\1\2\3\4") GRAdmin, invitedMember = MemberIdRole (MemberId "\5\6\7\8") GRMember, connRequest = testConnReq, groupProfile = testGroupProfile, groupLinkId = Nothing}
it "x.grp.inv with group link id" $
"{\"v\":\"1\",\"event\":\"x.grp.inv\",\"params\":{\"groupInvitation\":{\"connRequest\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"invitedMember\":{\"memberRole\":\"member\",\"memberId\":\"BQYHCA==\"},\"groupProfile\":{\"fullName\":\"Team\",\"displayName\":\"team\",\"groupPreferences\":{\"reactions\":{\"enable\":\"on\"},\"voice\":{\"enable\":\"on\"}}},\"fromMember\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\"}, \"groupLinkId\":\"AQIDBA==\"}}}"
"{\"v\":\"1\",\"event\":\"x.grp.inv\",\"params\":{\"groupInvitation\":{\"connRequest\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"invitedMember\":{\"memberRole\":\"member\",\"memberId\":\"BQYHCA==\"},\"groupProfile\":{\"fullName\":\"Team\",\"displayName\":\"team\",\"groupPreferences\":{\"reactions\":{\"enable\":\"on\"},\"voice\":{\"enable\":\"on\"}}},\"fromMember\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\"}, \"groupLinkId\":\"AQIDBA==\"}}}"
#==# XGrpInv GroupInvitation {fromMember = MemberIdRole (MemberId "\1\2\3\4") GRAdmin, invitedMember = MemberIdRole (MemberId "\5\6\7\8") GRMember, connRequest = testConnReq, groupProfile = testGroupProfile, groupLinkId = Just $ GroupLinkId "\1\2\3\4"}
it "x.grp.acpt without incognito profile" $
"{\"v\":\"1\",\"event\":\"x.grp.acpt\",\"params\":{\"memberId\":\"AQIDBA==\"}}"
@@ -252,16 +251,16 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do
"{\"v\":\"1\",\"event\":\"x.grp.mem.intro\",\"params\":{\"memberRestrictions\":{\"restriction\":\"blocked\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}"
#==# XGrpMemIntro MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Nothing, profile = testProfile} (Just MemberRestrictions {restriction = MRSBlocked})
it "x.grp.mem.inv" $
"{\"v\":\"1\",\"event\":\"x.grp.mem.inv\",\"params\":{\"memberId\":\"AQIDBA==\",\"memberIntro\":{\"directConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"}}}"
"{\"v\":\"1\",\"event\":\"x.grp.mem.inv\",\"params\":{\"memberId\":\"AQIDBA==\",\"memberIntro\":{\"directConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"}}}"
#==# XGrpMemInv (MemberId "\1\2\3\4") IntroInvitation {groupConnReq = testConnReq, directConnReq = Just testConnReq}
it "x.grp.mem.inv w/t directConnReq" $
"{\"v\":\"1\",\"event\":\"x.grp.mem.inv\",\"params\":{\"memberId\":\"AQIDBA==\",\"memberIntro\":{\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"}}}"
"{\"v\":\"1\",\"event\":\"x.grp.mem.inv\",\"params\":{\"memberId\":\"AQIDBA==\",\"memberIntro\":{\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"}}}"
#==# XGrpMemInv (MemberId "\1\2\3\4") IntroInvitation {groupConnReq = testConnReq, directConnReq = Nothing}
it "x.grp.mem.fwd" $
"{\"v\":\"1\",\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"directConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}"
"{\"v\":\"1\",\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"directConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}"
#==# XGrpMemFwd MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Nothing, profile = testProfile} IntroInvitation {groupConnReq = testConnReq, directConnReq = Just testConnReq}
it "x.grp.mem.fwd with member chat version range and w/t directConnReq" $
"{\"v\":\"1\",\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-7\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}"
"{\"v\":\"1\",\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-7\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}"
#==# XGrpMemFwd MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Just $ ChatVersionRange supportedChatVRange, profile = testProfile} IntroInvitation {groupConnReq = testConnReq, directConnReq = Nothing}
it "x.grp.mem.info" $
"{\"v\":\"1\",\"event\":\"x.grp.mem.info\",\"params\":{\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}"
@@ -282,10 +281,10 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do
"{\"v\":\"1\",\"event\":\"x.grp.del\",\"params\":{}}"
==# XGrpDel
it "x.grp.direct.inv" $
"{\"v\":\"1\",\"event\":\"x.grp.direct.inv\",\"params\":{\"connReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\", \"content\":{\"text\":\"hello\",\"type\":\"text\"}}}"
"{\"v\":\"1\",\"event\":\"x.grp.direct.inv\",\"params\":{\"connReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\", \"content\":{\"text\":\"hello\",\"type\":\"text\"}}}"
#==# XGrpDirectInv testConnReq (Just $ MCText "hello")
it "x.grp.direct.inv without content" $
"{\"v\":\"1\",\"event\":\"x.grp.direct.inv\",\"params\":{\"connReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"}}"
"{\"v\":\"1\",\"event\":\"x.grp.direct.inv\",\"params\":{\"connReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"}}"
#==# XGrpDirectInv testConnReq Nothing
-- it "x.grp.msg.forward"
-- $ "{\"v\":\"1\",\"event\":\"x.grp.msg.forward\",\"params\":{\"msgForward\":{\"memberId\":\"AQIDBA==\",\"msg\":\"{\"v\":\"1\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"}}}\",\"msgTs\":\"1970-01-01T00:00:01.000000001Z\"}}}"