Compare commits
36 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
c27973d202 | ||
|
51a2e09714 | ||
|
ec8ae9febe | ||
|
e37654772f | ||
|
b7709c59d3 | ||
|
395654098c | ||
|
2d643e8d29 | ||
|
4e9703f0ff | ||
|
b0b249a56a | ||
|
92c89632d4 | ||
|
d54b453b49 | ||
|
73de74d7e9 | ||
|
654a7885c3 | ||
|
daf67c0456 | ||
|
e361bcf140 | ||
|
5de9087207 | ||
|
364b62320b | ||
|
d83a6b7133 | ||
|
cd21a74b83 | ||
|
e3df7945d5 | ||
|
bb1620d7d2 | ||
|
edc5a4c31b | ||
|
4260c20012 | ||
|
1a7efbc333 | ||
|
e4984cb38d | ||
|
dfa9775d7e | ||
|
e39544dd24 | ||
|
71bcfc2848 | ||
|
3d8d84f978 | ||
|
f4ae60756c | ||
|
eedc1b2860 | ||
|
24a35698dc | ||
|
7e37155938 | ||
|
c8b38183c9 | ||
|
90a866ca56 | ||
|
5da8aef794 |
45
Dockerfile
45
Dockerfile
@ -1,32 +1,41 @@
|
|||||||
FROM ubuntu:focal AS build
|
ARG TAG=22.04
|
||||||
|
|
||||||
# Install curl and simplex-chat-related dependencies
|
FROM ubuntu:${TAG} AS build
|
||||||
RUN apt-get update && apt-get install -y curl git build-essential libgmp3-dev zlib1g-dev libssl-dev
|
|
||||||
|
### Build stage
|
||||||
|
|
||||||
|
# Install curl and git and simplex-chat dependencies
|
||||||
|
RUN apt-get update && apt-get install -y curl git build-essential libgmp3-dev zlib1g-dev llvm-12 llvm-12-dev libnuma-dev libssl-dev
|
||||||
|
|
||||||
|
# Specify bootstrap Haskell versions
|
||||||
|
ENV BOOTSTRAP_HASKELL_GHC_VERSION=9.6.3
|
||||||
|
ENV BOOTSTRAP_HASKELL_CABAL_VERSION=3.10.1.0
|
||||||
|
|
||||||
# Install ghcup
|
# Install ghcup
|
||||||
RUN a=$(arch); curl https://downloads.haskell.org/~ghcup/$a-linux-ghcup -o /usr/bin/ghcup && \
|
RUN curl --proto '=https' --tlsv1.2 -sSf https://get-ghcup.haskell.org | BOOTSTRAP_HASKELL_NONINTERACTIVE=1 sh
|
||||||
chmod +x /usr/bin/ghcup
|
|
||||||
|
|
||||||
# Install ghc
|
|
||||||
RUN ghcup install ghc 9.6.3
|
|
||||||
# Install cabal
|
|
||||||
RUN ghcup install cabal 3.10.1.0
|
|
||||||
# Set both as default
|
|
||||||
RUN ghcup set ghc 9.6.3 && \
|
|
||||||
ghcup set cabal 3.10.1.0
|
|
||||||
|
|
||||||
COPY . /project
|
|
||||||
WORKDIR /project
|
|
||||||
|
|
||||||
# Adjust PATH
|
# Adjust PATH
|
||||||
ENV PATH="/root/.cabal/bin:/root/.ghcup/bin:$PATH"
|
ENV PATH="/root/.cabal/bin:/root/.ghcup/bin:$PATH"
|
||||||
|
|
||||||
|
# Set both as default
|
||||||
|
RUN ghcup set ghc "${BOOTSTRAP_HASKELL_GHC_VERSION}" && \
|
||||||
|
ghcup set cabal "${BOOTSTRAP_HASKELL_CABAL_VERSION}"
|
||||||
|
|
||||||
|
COPY . /project
|
||||||
|
WORKDIR /project
|
||||||
|
|
||||||
# Adjust build
|
# Adjust build
|
||||||
RUN cp ./scripts/cabal.project.local.linux ./cabal.project.local
|
RUN cp ./scripts/cabal.project.local.linux ./cabal.project.local
|
||||||
|
|
||||||
# Compile simplex-chat
|
# Compile simplex-chat
|
||||||
RUN cabal update
|
RUN cabal update
|
||||||
RUN cabal install
|
RUN cabal build exe:simplex-chat
|
||||||
|
|
||||||
|
# Strip the binary from debug symbols to reduce size
|
||||||
|
RUN bin=$(find /project/dist-newstyle -name "simplex-chat" -type f -executable) && \
|
||||||
|
mv "$bin" ./ && \
|
||||||
|
strip ./simplex-chat
|
||||||
|
|
||||||
|
# Copy compiled app from build stage
|
||||||
FROM scratch AS export-stage
|
FROM scratch AS export-stage
|
||||||
COPY --from=build /root/.cabal/bin/simplex-chat /
|
COPY --from=build /project/simplex-chat /
|
||||||
|
@ -34,6 +34,8 @@ struct ContentView: View {
|
|||||||
@State private var waitingForOrPassedAuth = true
|
@State private var waitingForOrPassedAuth = true
|
||||||
@State private var chatListActionSheet: ChatListActionSheet? = nil
|
@State private var chatListActionSheet: ChatListActionSheet? = nil
|
||||||
|
|
||||||
|
private let callTopPadding: CGFloat = 50
|
||||||
|
|
||||||
private enum ChatListActionSheet: Identifiable {
|
private enum ChatListActionSheet: Identifiable {
|
||||||
case planAndConnectSheet(sheet: PlanAndConnectActionSheet)
|
case planAndConnectSheet(sheet: PlanAndConnectActionSheet)
|
||||||
|
|
||||||
@ -50,16 +52,28 @@ struct ContentView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
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.
|
// 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() }
|
// i.e. with separate branches like this settings are closed: `if prefPerformLA { ... contentView() ... } else { contentView() }
|
||||||
if !prefPerformLA || accessAuthenticated {
|
if !prefPerformLA || accessAuthenticated {
|
||||||
contentView()
|
contentView()
|
||||||
|
.padding(.top, showCallArea ? callTopPadding : 0)
|
||||||
} else {
|
} else {
|
||||||
lockButton()
|
lockButton()
|
||||||
|
.padding(.top, showCallArea ? callTopPadding : 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if showCallArea, let call = chatModel.activeCall {
|
||||||
|
VStack {
|
||||||
|
activeCallInteractiveArea(call)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if chatModel.showCallView, let call = chatModel.activeCall {
|
if chatModel.showCallView, let call = chatModel.activeCall {
|
||||||
callView(call)
|
callView(call)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !showSettings, let la = chatModel.laRequest {
|
if !showSettings, let la = chatModel.laRequest {
|
||||||
LocalAuthView(authRequest: la)
|
LocalAuthView(authRequest: la)
|
||||||
.onDisappear {
|
.onDisappear {
|
||||||
@ -135,11 +149,11 @@ struct ContentView: View {
|
|||||||
if case .onboardingComplete = step,
|
if case .onboardingComplete = step,
|
||||||
chatModel.currentUser != nil {
|
chatModel.currentUser != nil {
|
||||||
mainView()
|
mainView()
|
||||||
.actionSheet(item: $chatListActionSheet) { sheet in
|
.actionSheet(item: $chatListActionSheet) { sheet in
|
||||||
switch sheet {
|
switch sheet {
|
||||||
case let .planAndConnectSheet(sheet): return planAndConnectActionSheet(sheet, dismiss: false)
|
case let .planAndConnectSheet(sheet): return planAndConnectActionSheet(sheet, dismiss: false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
OnboardingView(onboarding: step)
|
OnboardingView(onboarding: step)
|
||||||
}
|
}
|
||||||
@ -163,6 +177,40 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ViewBuilder private func activeCallInteractiveArea(_ call: Call) -> some View {
|
||||||
|
HStack {
|
||||||
|
Text(call.contact.displayName).font(.body).foregroundColor(.white)
|
||||||
|
Spacer()
|
||||||
|
CallDuration(call: call)
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
.frame(height: callTopPadding - 10)
|
||||||
|
.background(Color(uiColor: UIColor(red: 47/255, green: 208/255, blue: 88/255, alpha: 1)))
|
||||||
|
.onTapGesture {
|
||||||
|
chatModel.activeCallViewIsCollapsed = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct CallDuration: View {
|
||||||
|
let call: Call
|
||||||
|
@State var text: String = ""
|
||||||
|
@State var timer: Timer? = nil
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Text(text).frame(minWidth: text.count <= 5 ? 52 : 77, alignment: .leading).offset(x: 4).font(.body).foregroundColor(.white)
|
||||||
|
.onAppear {
|
||||||
|
timer = Timer.scheduledTimer(withTimeInterval: 0.3, repeats: true) { timer in
|
||||||
|
if let connectedAt = call.connectedAt {
|
||||||
|
text = durationText(Int(Date.now.timeIntervalSince1970 - connectedAt.timeIntervalSince1970))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onDisappear {
|
||||||
|
_ = timer?.invalidate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func lockButton() -> some View {
|
private func lockButton() -> some View {
|
||||||
Button(action: authenticateContentViewAccess) { Label("Unlock", systemImage: "lock") }
|
Button(action: authenticateContentViewAccess) { Label("Unlock", systemImage: "lock") }
|
||||||
}
|
}
|
||||||
|
@ -80,6 +80,7 @@ final class ChatModel: ObservableObject {
|
|||||||
@Published var tokenRegistered = false
|
@Published var tokenRegistered = false
|
||||||
@Published var tokenStatus: NtfTknStatus?
|
@Published var tokenStatus: NtfTknStatus?
|
||||||
@Published var notificationMode = NotificationsMode.off
|
@Published var notificationMode = NotificationsMode.off
|
||||||
|
@Published var notificationServer: String?
|
||||||
@Published var notificationPreview: NotificationPreviewMode = ntfPreviewModeGroupDefault.get()
|
@Published var notificationPreview: NotificationPreviewMode = ntfPreviewModeGroupDefault.get()
|
||||||
// pending notification actions
|
// pending notification actions
|
||||||
@Published var ntfContactRequest: NTFContactRequest?
|
@Published var ntfContactRequest: NTFContactRequest?
|
||||||
@ -89,6 +90,7 @@ final class ChatModel: ObservableObject {
|
|||||||
@Published var activeCall: Call?
|
@Published var activeCall: Call?
|
||||||
let callCommand: WebRTCCommandProcessor = WebRTCCommandProcessor()
|
let callCommand: WebRTCCommandProcessor = WebRTCCommandProcessor()
|
||||||
@Published var showCallView = false
|
@Published var showCallView = false
|
||||||
|
@Published var activeCallViewIsCollapsed = false
|
||||||
// remote desktop
|
// remote desktop
|
||||||
@Published var remoteCtrlSession: RemoteCtrlSession?
|
@Published var remoteCtrlSession: RemoteCtrlSession?
|
||||||
// currently showing invitation
|
// currently showing invitation
|
||||||
|
@ -406,14 +406,14 @@ func apiDeleteMemberChatItem(groupId: Int64, groupMemberId: Int64, itemId: Int64
|
|||||||
throw r
|
throw r
|
||||||
}
|
}
|
||||||
|
|
||||||
func apiGetNtfToken() -> (DeviceToken?, NtfTknStatus?, NotificationsMode) {
|
func apiGetNtfToken() -> (DeviceToken?, NtfTknStatus?, NotificationsMode, String?) {
|
||||||
let r = chatSendCmdSync(.apiGetNtfToken)
|
let r = chatSendCmdSync(.apiGetNtfToken)
|
||||||
switch r {
|
switch r {
|
||||||
case let .ntfToken(token, status, ntfMode): return (token, status, ntfMode)
|
case let .ntfToken(token, status, ntfMode, ntfServer): return (token, status, ntfMode, ntfServer)
|
||||||
case .chatCmdError(_, .errorAgent(.CMD(.PROHIBITED))): return (nil, nil, .off)
|
case .chatCmdError(_, .errorAgent(.CMD(.PROHIBITED))): return (nil, nil, .off, nil)
|
||||||
default:
|
default:
|
||||||
logger.debug("apiGetNtfToken response: \(String(describing: r))")
|
logger.debug("apiGetNtfToken response: \(String(describing: r))")
|
||||||
return (nil, nil, .off)
|
return (nil, nil, .off, nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1302,7 +1302,7 @@ func startChat(refreshInvitations: Bool = true) throws {
|
|||||||
if (refreshInvitations) {
|
if (refreshInvitations) {
|
||||||
try refreshCallInvitations()
|
try refreshCallInvitations()
|
||||||
}
|
}
|
||||||
(m.savedToken, m.tokenStatus, m.notificationMode) = apiGetNtfToken()
|
(m.savedToken, m.tokenStatus, m.notificationMode, m.notificationServer) = apiGetNtfToken()
|
||||||
// deviceToken is set when AppDelegate.application(didRegisterForRemoteNotificationsWithDeviceToken:) is called,
|
// deviceToken is set when AppDelegate.application(didRegisterForRemoteNotificationsWithDeviceToken:) is called,
|
||||||
// when it is called before startChat
|
// when it is called before startChat
|
||||||
if let token = m.deviceToken {
|
if let token = m.deviceToken {
|
||||||
|
@ -12,49 +12,67 @@ import SimpleXChat
|
|||||||
|
|
||||||
struct ActiveCallView: View {
|
struct ActiveCallView: View {
|
||||||
@EnvironmentObject var m: ChatModel
|
@EnvironmentObject var m: ChatModel
|
||||||
|
@Environment(\.colorScheme) var colorScheme
|
||||||
@ObservedObject var call: Call
|
@ObservedObject var call: Call
|
||||||
@Environment(\.scenePhase) var scenePhase
|
@Environment(\.scenePhase) var scenePhase
|
||||||
@State private var client: WebRTCClient? = nil
|
@State private var client: WebRTCClient? = nil
|
||||||
@State private var activeCall: WebRTCClient.Call? = nil
|
@State private var activeCall: WebRTCClient.Call? = nil
|
||||||
@State private var localRendererAspectRatio: CGFloat? = nil
|
@State private var localRendererAspectRatio: CGFloat? = nil
|
||||||
@Binding var canConnectCall: Bool
|
@Binding var canConnectCall: Bool
|
||||||
|
@State var prevColorScheme: ColorScheme = .dark
|
||||||
|
@State var pipShown = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack(alignment: .bottom) {
|
ZStack(alignment: .topLeading) {
|
||||||
if let client = client, [call.peerMedia, call.localMedia].contains(.video), activeCall != nil {
|
ZStack(alignment: .bottom) {
|
||||||
GeometryReader { g in
|
if let client = client, [call.peerMedia, call.localMedia].contains(.video), activeCall != nil {
|
||||||
let width = g.size.width * 0.3
|
GeometryReader { g in
|
||||||
ZStack(alignment: .topTrailing) {
|
let width = g.size.width * 0.3
|
||||||
CallViewRemote(client: client, activeCall: $activeCall)
|
ZStack(alignment: .topTrailing) {
|
||||||
CallViewLocal(client: client, activeCall: $activeCall, localRendererAspectRatio: $localRendererAspectRatio)
|
CallViewRemote(client: client, activeCall: $activeCall, activeCallViewIsCollapsed: $m.activeCallViewIsCollapsed, pipShown: $pipShown)
|
||||||
.cornerRadius(10)
|
CallViewLocal(client: client, activeCall: $activeCall, localRendererAspectRatio: $localRendererAspectRatio, pipShown: $pipShown)
|
||||||
.frame(width: width, height: width / (localRendererAspectRatio ?? 1))
|
.cornerRadius(10)
|
||||||
.padding([.top, .trailing], 17)
|
.frame(width: width, height: width / (localRendererAspectRatio ?? 1))
|
||||||
|
.padding([.top, .trailing], 17)
|
||||||
|
ZStack(alignment: .center) {
|
||||||
|
// For some reason, when the view in GeometryReader and ZStack is visible, it steals clicks on a back button, so showing something on top like this with background color helps (.clear color doesn't work)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.background(Color.primary.opacity(0.000001))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
if let call = m.activeCall, let client = client, (!pipShown || !call.supportsVideo) {
|
||||||
if let call = m.activeCall, let client = client {
|
ActiveCallOverlay(call: call, client: client)
|
||||||
ActiveCallOverlay(call: call, client: client)
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.allowsHitTesting(!m.activeCallViewIsCollapsed)
|
||||||
|
.opacity(m.activeCallViewIsCollapsed ? 0 : 1)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
logger.debug("ActiveCallView: appear client is nil \(client == nil), scenePhase \(String(describing: scenePhase)), canConnectCall \(canConnectCall)")
|
logger.debug("ActiveCallView: appear client is nil \(client == nil), scenePhase \(String(describing: scenePhase)), canConnectCall \(canConnectCall)")
|
||||||
AppDelegate.keepScreenOn(true)
|
AppDelegate.keepScreenOn(true)
|
||||||
createWebRTCClient()
|
createWebRTCClient()
|
||||||
dismissAllSheets()
|
dismissAllSheets()
|
||||||
|
hideKeyboard()
|
||||||
|
prevColorScheme = colorScheme
|
||||||
}
|
}
|
||||||
.onChange(of: canConnectCall) { _ in
|
.onChange(of: canConnectCall) { _ in
|
||||||
logger.debug("ActiveCallView: canConnectCall changed to \(canConnectCall)")
|
logger.debug("ActiveCallView: canConnectCall changed to \(canConnectCall)")
|
||||||
createWebRTCClient()
|
createWebRTCClient()
|
||||||
}
|
}
|
||||||
|
.onChange(of: m.activeCallViewIsCollapsed) { _ in
|
||||||
|
hideKeyboard()
|
||||||
|
}
|
||||||
.onDisappear {
|
.onDisappear {
|
||||||
logger.debug("ActiveCallView: disappear")
|
logger.debug("ActiveCallView: disappear")
|
||||||
Task { await m.callCommand.setClient(nil) }
|
Task { await m.callCommand.setClient(nil) }
|
||||||
AppDelegate.keepScreenOn(false)
|
AppDelegate.keepScreenOn(false)
|
||||||
client?.endCall()
|
client?.endCall()
|
||||||
}
|
}
|
||||||
.background(.black)
|
.background(m.activeCallViewIsCollapsed ? .clear : .black)
|
||||||
.preferredColorScheme(.dark)
|
// Quite a big delay when opening/closing the view when a scheme changes (globally) this way. It's not needed when CallKit is used since status bar is green with white text on it
|
||||||
|
.preferredColorScheme(m.activeCallViewIsCollapsed || CallController.useCallKit() ? prevColorScheme : .dark)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func createWebRTCClient() {
|
private func createWebRTCClient() {
|
||||||
@ -69,8 +87,8 @@ struct ActiveCallView: View {
|
|||||||
@MainActor
|
@MainActor
|
||||||
private func processRtcMessage(msg: WVAPIMessage) {
|
private func processRtcMessage(msg: WVAPIMessage) {
|
||||||
if call == m.activeCall,
|
if call == m.activeCall,
|
||||||
let call = m.activeCall,
|
let call = m.activeCall,
|
||||||
let client = client {
|
let client = client {
|
||||||
logger.debug("ActiveCallView: response \(msg.resp.respType)")
|
logger.debug("ActiveCallView: response \(msg.resp.respType)")
|
||||||
switch msg.resp {
|
switch msg.resp {
|
||||||
case let .capabilities(capabilities):
|
case let .capabilities(capabilities):
|
||||||
@ -90,7 +108,7 @@ struct ActiveCallView: View {
|
|||||||
Task {
|
Task {
|
||||||
do {
|
do {
|
||||||
try await apiSendCallOffer(call.contact, offer, iceCandidates,
|
try await apiSendCallOffer(call.contact, offer, iceCandidates,
|
||||||
media: call.localMedia, capabilities: capabilities)
|
media: call.localMedia, capabilities: capabilities)
|
||||||
} catch {
|
} catch {
|
||||||
logger.error("apiSendCallOffer \(responseError(error))")
|
logger.error("apiSendCallOffer \(responseError(error))")
|
||||||
}
|
}
|
||||||
@ -122,13 +140,15 @@ struct ActiveCallView: View {
|
|||||||
if let callStatus = WebRTCCallStatus.init(rawValue: state.connectionState),
|
if let callStatus = WebRTCCallStatus.init(rawValue: state.connectionState),
|
||||||
case .connected = callStatus {
|
case .connected = callStatus {
|
||||||
call.direction == .outgoing
|
call.direction == .outgoing
|
||||||
? CallController.shared.reportOutgoingCall(call: call, connectedAt: nil)
|
? CallController.shared.reportOutgoingCall(call: call, connectedAt: nil)
|
||||||
: CallController.shared.reportIncomingCall(call: call, connectedAt: nil)
|
: CallController.shared.reportIncomingCall(call: call, connectedAt: nil)
|
||||||
call.callState = .connected
|
call.callState = .connected
|
||||||
|
call.connectedAt = .now
|
||||||
}
|
}
|
||||||
if state.connectionState == "closed" {
|
if state.connectionState == "closed" {
|
||||||
closeCallView(client)
|
closeCallView(client)
|
||||||
m.activeCall = nil
|
m.activeCall = nil
|
||||||
|
m.activeCallViewIsCollapsed = false
|
||||||
}
|
}
|
||||||
Task {
|
Task {
|
||||||
do {
|
do {
|
||||||
@ -140,6 +160,7 @@ struct ActiveCallView: View {
|
|||||||
case let .connected(connectionInfo):
|
case let .connected(connectionInfo):
|
||||||
call.callState = .connected
|
call.callState = .connected
|
||||||
call.connectionInfo = connectionInfo
|
call.connectionInfo = connectionInfo
|
||||||
|
call.connectedAt = .now
|
||||||
case .ended:
|
case .ended:
|
||||||
closeCallView(client)
|
closeCallView(client)
|
||||||
call.callState = .ended
|
call.callState = .ended
|
||||||
@ -153,6 +174,7 @@ struct ActiveCallView: View {
|
|||||||
case .end:
|
case .end:
|
||||||
closeCallView(client)
|
closeCallView(client)
|
||||||
m.activeCall = nil
|
m.activeCall = nil
|
||||||
|
m.activeCallViewIsCollapsed = false
|
||||||
default: ()
|
default: ()
|
||||||
}
|
}
|
||||||
case let .error(message):
|
case let .error(message):
|
||||||
@ -181,7 +203,7 @@ struct ActiveCallOverlay: View {
|
|||||||
VStack {
|
VStack {
|
||||||
switch call.localMedia {
|
switch call.localMedia {
|
||||||
case .video:
|
case .video:
|
||||||
callInfoView(call, .leading)
|
videoCallInfoView(call)
|
||||||
.foregroundColor(.white)
|
.foregroundColor(.white)
|
||||||
.opacity(0.8)
|
.opacity(0.8)
|
||||||
.padding()
|
.padding()
|
||||||
@ -208,16 +230,25 @@ struct ActiveCallOverlay: View {
|
|||||||
.frame(maxWidth: .infinity, alignment: .center)
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
|
|
||||||
case .audio:
|
case .audio:
|
||||||
VStack {
|
ZStack(alignment: .topLeading) {
|
||||||
ProfileImage(imageStr: call.contact.profile.image)
|
Button {
|
||||||
.scaledToFit()
|
chatModel.activeCallViewIsCollapsed = true
|
||||||
.frame(width: 192, height: 192)
|
} label: {
|
||||||
callInfoView(call, .center)
|
Label("Back", systemImage: "chevron.left")
|
||||||
|
.padding()
|
||||||
|
.foregroundColor(.white.opacity(0.8))
|
||||||
|
}
|
||||||
|
VStack {
|
||||||
|
ProfileImage(imageStr: call.contact.profile.image)
|
||||||
|
.scaledToFit()
|
||||||
|
.frame(width: 192, height: 192)
|
||||||
|
audioCallInfoView(call)
|
||||||
|
}
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.opacity(0.8)
|
||||||
|
.padding()
|
||||||
|
.frame(maxHeight: .infinity)
|
||||||
}
|
}
|
||||||
.foregroundColor(.white)
|
|
||||||
.opacity(0.8)
|
|
||||||
.padding()
|
|
||||||
.frame(maxHeight: .infinity)
|
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
@ -235,12 +266,12 @@ struct ActiveCallOverlay: View {
|
|||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func callInfoView(_ call: Call, _ alignment: Alignment) -> some View {
|
private func audioCallInfoView(_ call: Call) -> some View {
|
||||||
VStack {
|
VStack {
|
||||||
Text(call.contact.chatViewName)
|
Text(call.contact.chatViewName)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.font(.title)
|
.font(.title)
|
||||||
.frame(maxWidth: .infinity, alignment: alignment)
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
Group {
|
Group {
|
||||||
Text(call.callState.text)
|
Text(call.callState.text)
|
||||||
HStack {
|
HStack {
|
||||||
@ -251,7 +282,36 @@ struct ActiveCallOverlay: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.frame(maxWidth: .infinity, alignment: alignment)
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func videoCallInfoView(_ call: Call) -> some View {
|
||||||
|
VStack {
|
||||||
|
Button {
|
||||||
|
chatModel.activeCallViewIsCollapsed = true
|
||||||
|
} label: {
|
||||||
|
HStack(alignment: .center, spacing: 16) {
|
||||||
|
Image(systemName: "chevron.left")
|
||||||
|
.resizable()
|
||||||
|
.frame(width: 10, height: 18)
|
||||||
|
Text(call.contact.chatViewName)
|
||||||
|
.lineLimit(1)
|
||||||
|
.font(.title)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Group {
|
||||||
|
Text(call.callState.text)
|
||||||
|
HStack {
|
||||||
|
Text(call.encryptionStatus)
|
||||||
|
if let connInfo = call.connectionInfo {
|
||||||
|
Text("(") + Text(connInfo.text) + Text(")")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.font(.subheadline)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -92,6 +92,7 @@ class CallManager {
|
|||||||
if case .ended = call.callState {
|
if case .ended = call.callState {
|
||||||
logger.debug("CallManager.endCall: call ended")
|
logger.debug("CallManager.endCall: call ended")
|
||||||
m.activeCall = nil
|
m.activeCall = nil
|
||||||
|
m.activeCallViewIsCollapsed = false
|
||||||
m.showCallView = false
|
m.showCallView = false
|
||||||
completed()
|
completed()
|
||||||
} else {
|
} else {
|
||||||
@ -100,6 +101,7 @@ class CallManager {
|
|||||||
await m.callCommand.processCommand(.end)
|
await m.callCommand.processCommand(.end)
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
m.activeCall = nil
|
m.activeCall = nil
|
||||||
|
m.activeCallViewIsCollapsed = false
|
||||||
m.showCallView = false
|
m.showCallView = false
|
||||||
completed()
|
completed()
|
||||||
}
|
}
|
||||||
|
@ -6,14 +6,20 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import WebRTC
|
import WebRTC
|
||||||
import SimpleXChat
|
import SimpleXChat
|
||||||
|
import AVKit
|
||||||
|
|
||||||
struct CallViewRemote: UIViewRepresentable {
|
struct CallViewRemote: UIViewRepresentable {
|
||||||
var client: WebRTCClient
|
var client: WebRTCClient
|
||||||
var activeCall: Binding<WebRTCClient.Call?>
|
var activeCall: Binding<WebRTCClient.Call?>
|
||||||
|
@State var enablePip: (Bool) -> Void = {_ in }
|
||||||
|
@Binding var activeCallViewIsCollapsed: Bool
|
||||||
|
@Binding var pipShown: Bool
|
||||||
|
|
||||||
init(client: WebRTCClient, activeCall: Binding<WebRTCClient.Call?>) {
|
init(client: WebRTCClient, activeCall: Binding<WebRTCClient.Call?>, activeCallViewIsCollapsed: Binding<Bool>, pipShown: Binding<Bool>) {
|
||||||
self.client = client
|
self.client = client
|
||||||
self.activeCall = activeCall
|
self.activeCall = activeCall
|
||||||
|
self._activeCallViewIsCollapsed = activeCallViewIsCollapsed
|
||||||
|
self._pipShown = pipShown
|
||||||
}
|
}
|
||||||
|
|
||||||
func makeUIView(context: Context) -> UIView {
|
func makeUIView(context: Context) -> UIView {
|
||||||
@ -23,12 +29,120 @@ struct CallViewRemote: UIViewRepresentable {
|
|||||||
remoteRenderer.videoContentMode = .scaleAspectFill
|
remoteRenderer.videoContentMode = .scaleAspectFill
|
||||||
client.addRemoteRenderer(call, remoteRenderer)
|
client.addRemoteRenderer(call, remoteRenderer)
|
||||||
addSubviewAndResize(remoteRenderer, into: view)
|
addSubviewAndResize(remoteRenderer, into: view)
|
||||||
|
|
||||||
|
if AVPictureInPictureController.isPictureInPictureSupported() {
|
||||||
|
makeViewWithRTCRenderer(call, remoteRenderer, view, context)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return view
|
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) {
|
func updateUIView(_ view: UIView, context: Context) {
|
||||||
logger.debug("CallView.updateUIView remote")
|
logger.debug("CallView.updateUIView remote")
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
if activeCallViewIsCollapsed != pipShown {
|
||||||
|
enablePip(activeCallViewIsCollapsed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Coordinator
|
||||||
|
class Coordinator: NSObject, AVPictureInPictureControllerDelegate {
|
||||||
|
var pipController: AVPictureInPictureController? = nil
|
||||||
|
var willShowHide: (Bool) -> Void = { _ in }
|
||||||
|
var didShowHide: (Bool) -> Void = { _ in }
|
||||||
|
|
||||||
|
func pictureInPictureControllerWillStartPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) {
|
||||||
|
willShowHide(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func pictureInPictureControllerDidStartPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) {
|
||||||
|
didShowHide(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, failedToStartPictureInPictureWithError error: Error) {
|
||||||
|
logger.error("PiP failed to start: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func pictureInPictureControllerWillStopPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) {
|
||||||
|
willShowHide(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func pictureInPictureControllerDidStopPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) {
|
||||||
|
didShowHide(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
pipController?.stopPictureInPicture()
|
||||||
|
pipController?.canStartPictureInPictureAutomaticallyFromInline = false
|
||||||
|
pipController?.contentSource = nil
|
||||||
|
pipController?.delegate = nil
|
||||||
|
pipController = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class SampleBufferVideoCallView: UIView {
|
||||||
|
override class var layerClass: AnyClass {
|
||||||
|
get { return AVSampleBufferDisplayLayer.self }
|
||||||
|
}
|
||||||
|
|
||||||
|
var sampleBufferDisplayLayer: AVSampleBufferDisplayLayer {
|
||||||
|
return layer as! AVSampleBufferDisplayLayer
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -36,11 +150,14 @@ struct CallViewLocal: UIViewRepresentable {
|
|||||||
var client: WebRTCClient
|
var client: WebRTCClient
|
||||||
var activeCall: Binding<WebRTCClient.Call?>
|
var activeCall: Binding<WebRTCClient.Call?>
|
||||||
var localRendererAspectRatio: Binding<CGFloat?>
|
var localRendererAspectRatio: Binding<CGFloat?>
|
||||||
|
@State var pipStateChanged: (Bool) -> Void = {_ in }
|
||||||
|
@Binding var pipShown: Bool
|
||||||
|
|
||||||
init(client: WebRTCClient, activeCall: Binding<WebRTCClient.Call?>, localRendererAspectRatio: Binding<CGFloat?>) {
|
init(client: WebRTCClient, activeCall: Binding<WebRTCClient.Call?>, localRendererAspectRatio: Binding<CGFloat?>, pipShown: Binding<Bool>) {
|
||||||
self.client = client
|
self.client = client
|
||||||
self.activeCall = activeCall
|
self.activeCall = activeCall
|
||||||
self.localRendererAspectRatio = localRendererAspectRatio
|
self.localRendererAspectRatio = localRendererAspectRatio
|
||||||
|
self._pipShown = pipShown
|
||||||
}
|
}
|
||||||
|
|
||||||
func makeUIView(context: Context) -> UIView {
|
func makeUIView(context: Context) -> UIView {
|
||||||
@ -50,12 +167,18 @@ struct CallViewLocal: UIViewRepresentable {
|
|||||||
client.addLocalRenderer(call, localRenderer)
|
client.addLocalRenderer(call, localRenderer)
|
||||||
client.startCaptureLocalVideo(call)
|
client.startCaptureLocalVideo(call)
|
||||||
addSubviewAndResize(localRenderer, into: view)
|
addSubviewAndResize(localRenderer, into: view)
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
pipStateChanged = { shown in
|
||||||
|
localRenderer.isHidden = shown
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return view
|
return view
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateUIView(_ view: UIView, context: Context) {
|
func updateUIView(_ view: UIView, context: Context) {
|
||||||
logger.debug("CallView.updateUIView local")
|
logger.debug("CallView.updateUIView local")
|
||||||
|
pipStateChanged(pipShown)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -28,6 +28,7 @@ class Call: ObservableObject, Equatable {
|
|||||||
@Published var speakerEnabled = false
|
@Published var speakerEnabled = false
|
||||||
@Published var videoEnabled: Bool
|
@Published var videoEnabled: Bool
|
||||||
@Published var connectionInfo: ConnectionInfo?
|
@Published var connectionInfo: ConnectionInfo?
|
||||||
|
@Published var connectedAt: Date? = nil
|
||||||
|
|
||||||
init(
|
init(
|
||||||
direction: CallDirection,
|
direction: CallDirection,
|
||||||
@ -59,6 +60,7 @@ class Call: ObservableObject, Equatable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
var hasMedia: Bool { get { callState == .offerSent || callState == .negotiated || callState == .connected } }
|
var hasMedia: Bool { get { callState == .offerSent || callState == .negotiated || callState == .connected } }
|
||||||
|
var supportsVideo: Bool { get { peerMedia == .video || localMedia == .video } }
|
||||||
}
|
}
|
||||||
|
|
||||||
enum CallDirection {
|
enum CallDirection {
|
||||||
|
@ -331,6 +331,10 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
|
|||||||
activeCall.remoteStream?.add(renderer)
|
activeCall.remoteStream?.add(renderer)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func removeRemoteRenderer(_ activeCall: Call, _ renderer: RTCVideoRenderer) {
|
||||||
|
activeCall.remoteStream?.remove(renderer)
|
||||||
|
}
|
||||||
|
|
||||||
func startCaptureLocalVideo(_ activeCall: Call) {
|
func startCaptureLocalVideo(_ activeCall: Call) {
|
||||||
#if targetEnvironment(simulator)
|
#if targetEnvironment(simulator)
|
||||||
guard
|
guard
|
||||||
@ -410,6 +414,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
|
|||||||
guard let call = activeCall.wrappedValue else { return }
|
guard let call = activeCall.wrappedValue else { return }
|
||||||
logger.debug("WebRTCClient: ending the call")
|
logger.debug("WebRTCClient: ending the call")
|
||||||
activeCall.wrappedValue = nil
|
activeCall.wrappedValue = nil
|
||||||
|
(call.localCamera as? RTCCameraVideoCapturer)?.stopCapture()
|
||||||
call.connection.close()
|
call.connection.close()
|
||||||
call.connection.delegate = nil
|
call.connection.delegate = nil
|
||||||
call.frameEncryptor?.delegate = nil
|
call.frameEncryptor?.delegate = nil
|
||||||
|
@ -29,6 +29,9 @@ struct CIImageView: View {
|
|||||||
FullScreenMediaView(chatItem: chatItem, image: uiImage, showView: $showFullScreenImage, scrollProxy: scrollProxy)
|
FullScreenMediaView(chatItem: chatItem, image: uiImage, showView: $showFullScreenImage, scrollProxy: scrollProxy)
|
||||||
}
|
}
|
||||||
.onTapGesture { showFullScreenImage = true }
|
.onTapGesture { showFullScreenImage = true }
|
||||||
|
.onChange(of: m.activeCallViewIsCollapsed) { _ in
|
||||||
|
showFullScreenImage = false
|
||||||
|
}
|
||||||
} else if let data = Data(base64Encoded: dropImagePrefix(image)),
|
} else if let data = Data(base64Encoded: dropImagePrefix(image)),
|
||||||
let uiImage = UIImage(data: data) {
|
let uiImage = UIImage(data: data) {
|
||||||
imageView(uiImage)
|
imageView(uiImage)
|
||||||
|
@ -120,6 +120,9 @@ struct CIVideoView: View {
|
|||||||
showFullScreenPlayer = urlDecrypted != nil
|
showFullScreenPlayer = urlDecrypted != nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.onChange(of: m.activeCallViewIsCollapsed) { _ in
|
||||||
|
showFullScreenPlayer = false
|
||||||
|
}
|
||||||
if !decryptionInProgress {
|
if !decryptionInProgress {
|
||||||
Button {
|
Button {
|
||||||
decrypt(file: file) {
|
decrypt(file: file) {
|
||||||
@ -168,6 +171,9 @@ struct CIVideoView: View {
|
|||||||
default: ()
|
default: ()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.onChange(of: m.activeCallViewIsCollapsed) { _ in
|
||||||
|
showFullScreenPlayer = false
|
||||||
|
}
|
||||||
if !videoPlaying {
|
if !videoPlaying {
|
||||||
Button {
|
Button {
|
||||||
m.stopPreviousRecPlay = url
|
m.stopPreviousRecPlay = url
|
||||||
|
@ -161,11 +161,15 @@ struct ChatView: View {
|
|||||||
HStack {
|
HStack {
|
||||||
let callsPrefEnabled = contact.mergedPreferences.calls.enabled.forUser
|
let callsPrefEnabled = contact.mergedPreferences.calls.enabled.forUser
|
||||||
if callsPrefEnabled {
|
if callsPrefEnabled {
|
||||||
callButton(contact, .audio, imageName: "phone")
|
if chatModel.activeCall == nil {
|
||||||
.disabled(!contact.ready || !contact.active)
|
callButton(contact, .audio, imageName: "phone")
|
||||||
|
.disabled(!contact.ready || !contact.active)
|
||||||
|
} else if let call = chatModel.activeCall, call.contact.id == cInfo.id {
|
||||||
|
endCallButton(call)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Menu {
|
Menu {
|
||||||
if callsPrefEnabled {
|
if callsPrefEnabled && chatModel.activeCall == nil {
|
||||||
Button {
|
Button {
|
||||||
CallController.shared.startCall(contact, .video)
|
CallController.shared.startCall(contact, .video)
|
||||||
} label: {
|
} label: {
|
||||||
@ -422,7 +426,19 @@ struct ChatView: View {
|
|||||||
Image(systemName: imageName)
|
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 {
|
private func searchButton() -> some View {
|
||||||
Button {
|
Button {
|
||||||
searchMode = true
|
searchMode = true
|
||||||
|
@ -234,39 +234,29 @@ struct GroupChatInfoView: View {
|
|||||||
Spacer()
|
Spacer()
|
||||||
memberInfo(member)
|
memberInfo(member)
|
||||||
}
|
}
|
||||||
|
|
||||||
// revert from this:
|
|
||||||
if user {
|
if user {
|
||||||
v
|
v
|
||||||
} else if member.canBeRemoved(groupInfo: groupInfo) {
|
} else if groupInfo.membership.memberRole >= .admin {
|
||||||
removeSwipe(member, blockSwipe(member, v))
|
// 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 {
|
} else {
|
||||||
blockSwipe(member, v)
|
if !member.blockedByAdmin {
|
||||||
|
blockSwipe(member, v)
|
||||||
|
} else {
|
||||||
|
v
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// revert to this: vvv
|
|
||||||
// if user {
|
|
||||||
// v
|
|
||||||
// } else if groupInfo.membership.memberRole >= .admin {
|
|
||||||
// // TODO if there are more actions, refactor with lists of swipeActions
|
|
||||||
// let canBlockForAll = member.canBlockForAll(groupInfo: groupInfo)
|
|
||||||
// let canRemove = member.canBeRemoved(groupInfo: groupInfo)
|
|
||||||
// if canBlockForAll && canRemove {
|
|
||||||
// removeSwipe(member, blockForAllSwipe(member, v))
|
|
||||||
// } else if canBlockForAll {
|
|
||||||
// blockForAllSwipe(member, v)
|
|
||||||
// } else if canRemove {
|
|
||||||
// removeSwipe(member, v)
|
|
||||||
// } else {
|
|
||||||
// v
|
|
||||||
// }
|
|
||||||
// } else {
|
|
||||||
// if !member.blockedByAdmin {
|
|
||||||
// blockSwipe(member, v)
|
|
||||||
// } else {
|
|
||||||
// v
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// ^^^
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder private func memberInfo(_ member: GroupMember) -> some View {
|
@ViewBuilder private func memberInfo(_ member: GroupMember) -> some View {
|
||||||
|
@ -168,24 +168,11 @@ struct GroupMemberInfoView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// revert from this:
|
if groupInfo.membership.memberRole >= .admin {
|
||||||
Section {
|
adminDestructiveSection(member)
|
||||||
if member.memberSettings.showMessages {
|
} else {
|
||||||
blockMemberButton(member)
|
nonAdminBlockSection(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 {
|
if developerTools {
|
||||||
Section("For console") {
|
Section("For console") {
|
||||||
|
@ -76,6 +76,10 @@ struct NotificationsView: View {
|
|||||||
Text(m.notificationPreview.label)
|
Text(m.notificationPreview.label)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let server = m.notificationServer {
|
||||||
|
smpServers("Push server", [server])
|
||||||
|
}
|
||||||
} header: {
|
} header: {
|
||||||
Text("Push notifications")
|
Text("Push notifications")
|
||||||
} footer: {
|
} footer: {
|
||||||
@ -87,6 +91,9 @@ struct NotificationsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.disabled(legacyDatabase)
|
.disabled(legacyDatabase)
|
||||||
|
.onAppear {
|
||||||
|
(m.savedToken, m.tokenStatus, m.notificationMode, m.notificationServer) = apiGetNtfToken()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func notificationAlert(_ alert: NotificationAlert, _ token: DeviceToken) -> Alert {
|
private func notificationAlert(_ alert: NotificationAlert, _ token: DeviceToken) -> Alert {
|
||||||
@ -125,6 +132,7 @@ struct NotificationsView: View {
|
|||||||
m.tokenStatus = .new
|
m.tokenStatus = .new
|
||||||
notificationMode = .off
|
notificationMode = .off
|
||||||
m.notificationMode = .off
|
m.notificationMode = .off
|
||||||
|
m.notificationServer = nil
|
||||||
}
|
}
|
||||||
} catch let error {
|
} catch let error {
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
@ -135,11 +143,13 @@ struct NotificationsView: View {
|
|||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
do {
|
do {
|
||||||
let status = try await apiRegisterToken(token: token, notificationMode: mode)
|
let _ = try await apiRegisterToken(token: token, notificationMode: mode)
|
||||||
|
let (_, tknStatus, ntfMode, ntfServer) = apiGetNtfToken()
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
m.tokenStatus = status
|
m.tokenStatus = tknStatus
|
||||||
notificationMode = mode
|
notificationMode = ntfMode
|
||||||
m.notificationMode = mode
|
m.notificationMode = ntfMode
|
||||||
|
m.notificationServer = ntfServer
|
||||||
}
|
}
|
||||||
} catch let error {
|
} catch let error {
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
|
@ -90,11 +90,11 @@
|
|||||||
5CB0BA90282713D900B3292C /* SimpleXInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB0BA8F282713D900B3292C /* SimpleXInfo.swift */; };
|
5CB0BA90282713D900B3292C /* SimpleXInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB0BA8F282713D900B3292C /* SimpleXInfo.swift */; };
|
||||||
5CB0BA92282713FD00B3292C /* CreateProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB0BA91282713FD00B3292C /* CreateProfile.swift */; };
|
5CB0BA92282713FD00B3292C /* CreateProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB0BA91282713FD00B3292C /* CreateProfile.swift */; };
|
||||||
5CB0BA9A2827FD8800B3292C /* HowItWorks.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB0BA992827FD8800B3292C /* HowItWorks.swift */; };
|
5CB0BA9A2827FD8800B3292C /* HowItWorks.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB0BA992827FD8800B3292C /* HowItWorks.swift */; };
|
||||||
5CB1CE922B86660100963938 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CB1CE8D2B86660100963938 /* libgmp.a */; };
|
5CB1CE9C2B8771DB00963938 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CB1CE972B8771DB00963938 /* libffi.a */; };
|
||||||
5CB1CE932B86660100963938 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CB1CE8E2B86660100963938 /* libgmpxx.a */; };
|
5CB1CE9D2B8771DB00963938 /* libHSsimplex-chat-5.5.5.0-4Xh8aGOq2uNGBj7gMJTaKI.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CB1CE982B8771DB00963938 /* libHSsimplex-chat-5.5.5.0-4Xh8aGOq2uNGBj7gMJTaKI.a */; };
|
||||||
5CB1CE942B86660100963938 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CB1CE8F2B86660100963938 /* libffi.a */; };
|
5CB1CE9E2B8771DB00963938 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CB1CE992B8771DB00963938 /* libgmpxx.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 */; };
|
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 */; };
|
||||||
5CB1CE962B86660100963938 /* libHSsimplex-chat-5.5.5.0-7lQXtpK7ThcLvpQEItJUcV.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CB1CE912B86660100963938 /* libHSsimplex-chat-5.5.5.0-7lQXtpK7ThcLvpQEItJUcV.a */; };
|
5CB1CEA02B8771DB00963938 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CB1CE9B2B8771DB00963938 /* libgmp.a */; };
|
||||||
5CB2084F28DA4B4800D024EC /* RTCServers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB2084E28DA4B4800D024EC /* RTCServers.swift */; };
|
5CB2084F28DA4B4800D024EC /* RTCServers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB2084E28DA4B4800D024EC /* RTCServers.swift */; };
|
||||||
5CB346E52868AA7F001FD2EF /* SuspendChat.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB346E42868AA7F001FD2EF /* SuspendChat.swift */; };
|
5CB346E52868AA7F001FD2EF /* SuspendChat.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB346E42868AA7F001FD2EF /* SuspendChat.swift */; };
|
||||||
5CB346E72868D76D001FD2EF /* NotificationsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB346E62868D76D001FD2EF /* NotificationsView.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>"; };
|
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>"; };
|
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>"; };
|
5CB0BA992827FD8800B3292C /* HowItWorks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HowItWorks.swift; sourceTree = "<group>"; };
|
||||||
5CB1CE8D2B86660100963938 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
|
5CB1CE972B8771DB00963938 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; };
|
||||||
5CB1CE8E2B86660100963938 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.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>"; };
|
||||||
5CB1CE8F2B86660100963938 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; };
|
5CB1CE992B8771DB00963938 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.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>"; };
|
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>"; };
|
||||||
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>"; };
|
5CB1CE9B2B8771DB00963938 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
|
||||||
5CB2084E28DA4B4800D024EC /* RTCServers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RTCServers.swift; sourceTree = "<group>"; };
|
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>"; };
|
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>"; };
|
5CB346E42868AA7F001FD2EF /* SuspendChat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuspendChat.swift; sourceTree = "<group>"; };
|
||||||
@ -514,13 +514,13 @@
|
|||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
5CB1CE932B86660100963938 /* libgmpxx.a in Frameworks */,
|
|
||||||
5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */,
|
5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */,
|
||||||
5CB1CE962B86660100963938 /* libHSsimplex-chat-5.5.5.0-7lQXtpK7ThcLvpQEItJUcV.a in Frameworks */,
|
5CB1CEA02B8771DB00963938 /* libgmp.a in Frameworks */,
|
||||||
5CB1CE922B86660100963938 /* libgmp.a in Frameworks */,
|
5CB1CE9E2B8771DB00963938 /* libgmpxx.a in Frameworks */,
|
||||||
5CB1CE952B86660100963938 /* libHSsimplex-chat-5.5.5.0-7lQXtpK7ThcLvpQEItJUcV-ghc9.6.3.a in Frameworks */,
|
5CB1CE9C2B8771DB00963938 /* libffi.a in Frameworks */,
|
||||||
5CB1CE942B86660100963938 /* libffi.a in Frameworks */,
|
|
||||||
5CE2BA94284534BB00EC33A6 /* libz.tbd 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;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@ -582,11 +582,11 @@
|
|||||||
5C764E5C279C70B7000C6508 /* Libraries */ = {
|
5C764E5C279C70B7000C6508 /* Libraries */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
5CB1CE8F2B86660100963938 /* libffi.a */,
|
5CB1CE972B8771DB00963938 /* libffi.a */,
|
||||||
5CB1CE8D2B86660100963938 /* libgmp.a */,
|
5CB1CE9B2B8771DB00963938 /* libgmp.a */,
|
||||||
5CB1CE8E2B86660100963938 /* libgmpxx.a */,
|
5CB1CE992B8771DB00963938 /* libgmpxx.a */,
|
||||||
5CB1CE902B86660100963938 /* libHSsimplex-chat-5.5.5.0-7lQXtpK7ThcLvpQEItJUcV-ghc9.6.3.a */,
|
5CB1CE9A2B8771DB00963938 /* libHSsimplex-chat-5.5.5.0-4Xh8aGOq2uNGBj7gMJTaKI-ghc9.6.3.a */,
|
||||||
5CB1CE912B86660100963938 /* libHSsimplex-chat-5.5.5.0-7lQXtpK7ThcLvpQEItJUcV.a */,
|
5CB1CE982B8771DB00963938 /* libHSsimplex-chat-5.5.5.0-4Xh8aGOq2uNGBj7gMJTaKI.a */,
|
||||||
);
|
);
|
||||||
path = Libraries;
|
path = Libraries;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@ -1509,7 +1509,7 @@
|
|||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements";
|
CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 200;
|
CURRENT_PROJECT_VERSION = 199;
|
||||||
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
@ -1531,7 +1531,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 5.5.5;
|
MARKETING_VERSION = 5.5.4;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app;
|
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app;
|
||||||
PRODUCT_NAME = SimpleX;
|
PRODUCT_NAME = SimpleX;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
@ -1552,7 +1552,7 @@
|
|||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements";
|
CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 200;
|
CURRENT_PROJECT_VERSION = 199;
|
||||||
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
@ -1574,7 +1574,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 5.5.5;
|
MARKETING_VERSION = 5.5.4;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app;
|
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app;
|
||||||
PRODUCT_NAME = SimpleX;
|
PRODUCT_NAME = SimpleX;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
@ -1633,7 +1633,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements";
|
CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements";
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 200;
|
CURRENT_PROJECT_VERSION = 199;
|
||||||
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
@ -1646,7 +1646,7 @@
|
|||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 5.5.5;
|
MARKETING_VERSION = 5.5.4;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE";
|
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE";
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
@ -1665,7 +1665,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements";
|
CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements";
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 200;
|
CURRENT_PROJECT_VERSION = 199;
|
||||||
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
@ -1678,7 +1678,7 @@
|
|||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 5.5.5;
|
MARKETING_VERSION = 5.5.4;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE";
|
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE";
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
@ -1697,7 +1697,7 @@
|
|||||||
APPLICATION_EXTENSION_API_ONLY = YES;
|
APPLICATION_EXTENSION_API_ONLY = YES;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 200;
|
CURRENT_PROJECT_VERSION = 199;
|
||||||
DEFINES_MODULE = YES;
|
DEFINES_MODULE = YES;
|
||||||
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
||||||
DYLIB_COMPATIBILITY_VERSION = 1;
|
DYLIB_COMPATIBILITY_VERSION = 1;
|
||||||
@ -1721,7 +1721,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"$(PROJECT_DIR)/Libraries/sim",
|
"$(PROJECT_DIR)/Libraries/sim",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 5.5.5;
|
MARKETING_VERSION = 5.5.4;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat;
|
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)";
|
PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)";
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
@ -1743,7 +1743,7 @@
|
|||||||
APPLICATION_EXTENSION_API_ONLY = YES;
|
APPLICATION_EXTENSION_API_ONLY = YES;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 200;
|
CURRENT_PROJECT_VERSION = 199;
|
||||||
DEFINES_MODULE = YES;
|
DEFINES_MODULE = YES;
|
||||||
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
||||||
DYLIB_COMPATIBILITY_VERSION = 1;
|
DYLIB_COMPATIBILITY_VERSION = 1;
|
||||||
@ -1767,7 +1767,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"$(PROJECT_DIR)/Libraries/sim",
|
"$(PROJECT_DIR)/Libraries/sim",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 5.5.5;
|
MARKETING_VERSION = 5.5.4;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat;
|
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)";
|
PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)";
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
|
@ -606,7 +606,7 @@ public enum ChatResponse: Decodable, Error {
|
|||||||
case callEnded(user: UserRef, contact: Contact)
|
case callEnded(user: UserRef, contact: Contact)
|
||||||
case callInvitations(callInvitations: [RcvCallInvitation])
|
case callInvitations(callInvitations: [RcvCallInvitation])
|
||||||
case ntfTokenStatus(status: NtfTknStatus)
|
case ntfTokenStatus(status: NtfTknStatus)
|
||||||
case ntfToken(token: DeviceToken, status: NtfTknStatus, ntfMode: NotificationsMode)
|
case ntfToken(token: DeviceToken, status: NtfTknStatus, ntfMode: NotificationsMode, ntfServer: String)
|
||||||
case ntfMessages(user_: User?, connEntity_: ConnectionEntity?, msgTs: Date?, ntfMessages: [NtfMsgInfo])
|
case ntfMessages(user_: User?, connEntity_: ConnectionEntity?, msgTs: Date?, ntfMessages: [NtfMsgInfo])
|
||||||
case ntfMessage(user: UserRef, connEntity: ConnectionEntity, ntfMessage: NtfMsgInfo)
|
case ntfMessage(user: UserRef, connEntity: ConnectionEntity, ntfMessage: NtfMsgInfo)
|
||||||
case contactConnectionDeleted(user: UserRef, connection: PendingContactConnection)
|
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 .callEnded(u, contact): return withUser(u, "contact: \(contact.id)")
|
||||||
case let .callInvitations(invs): return String(describing: invs)
|
case let .callInvitations(invs): return String(describing: invs)
|
||||||
case let .ntfTokenStatus(status): return String(describing: status)
|
case let .ntfTokenStatus(status): return String(describing: status)
|
||||||
case let .ntfToken(token, status, ntfMode): return "token: \(token)\nstatus: \(status.rawValue)\nntfMode: \(ntfMode.rawValue)"
|
case let .ntfToken(token, status, ntfMode, ntfServer): return "token: \(token)\nstatus: \(status.rawValue)\nntfMode: \(ntfMode.rawValue)\nntfServer: \(ntfServer)"
|
||||||
case let .ntfMessages(u, connEntity, msgTs, ntfMessages): return withUser(u, "connEntity: \(String(describing: connEntity))\nmsgTs: \(String(describing: msgTs))\nntfMessages: \(String(describing: ntfMessages))")
|
case let .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 .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))
|
case let .contactConnectionDeleted(u, connection): return withUser(u, String(describing: connection))
|
||||||
|
@ -2268,7 +2268,7 @@ public struct ChatItem: Identifiable, Decodable {
|
|||||||
case .rcvDirectEvent(rcvDirectEvent: let rcvDirectEvent):
|
case .rcvDirectEvent(rcvDirectEvent: let rcvDirectEvent):
|
||||||
switch rcvDirectEvent {
|
switch rcvDirectEvent {
|
||||||
case .contactDeleted: return false
|
case .contactDeleted: return false
|
||||||
case .profileUpdated: return true
|
case .profileUpdated: return false
|
||||||
}
|
}
|
||||||
case .rcvGroupEvent(rcvGroupEvent: let rcvGroupEvent):
|
case .rcvGroupEvent(rcvGroupEvent: let rcvGroupEvent):
|
||||||
switch rcvGroupEvent {
|
switch rcvGroupEvent {
|
||||||
|
@ -103,11 +103,14 @@
|
|||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity-alias>
|
</activity-alias>
|
||||||
|
|
||||||
|
<activity android:name=".views.call.CallActivity"
|
||||||
<activity android:name=".views.call.IncomingCallActivity"
|
|
||||||
android:showOnLockScreen="true"
|
android:showOnLockScreen="true"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:launchMode="singleTask"/>
|
android:launchMode="singleInstance"
|
||||||
|
android:supportsPictureInPicture="true"
|
||||||
|
android:autoRemoveFromRecents="true"
|
||||||
|
android:screenOrientation="portrait"
|
||||||
|
android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation"/>
|
||||||
|
|
||||||
<provider
|
<provider
|
||||||
android:name="androidx.core.content.FileProvider"
|
android:name="androidx.core.content.FileProvider"
|
||||||
@ -133,6 +136,18 @@
|
|||||||
android:stopWithTask="false"></service>
|
android:stopWithTask="false"></service>
|
||||||
|
|
||||||
<!-- SimplexService restart on reboot -->
|
<!-- 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
|
<receiver
|
||||||
android:name=".SimplexService$StartReceiver"
|
android:name=".SimplexService$StartReceiver"
|
||||||
android:enabled="true"
|
android:enabled="true"
|
||||||
|
@ -0,0 +1,176 @@
|
|||||||
|
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,14 +1,15 @@
|
|||||||
package chat.simplex.app
|
package chat.simplex.app
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.*
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.compose.ui.platform.ClipboardManager
|
|
||||||
import chat.simplex.common.platform.Log
|
import chat.simplex.common.platform.Log
|
||||||
import android.app.UiModeManager
|
import android.content.Intent
|
||||||
import android.os.*
|
import android.os.*
|
||||||
import androidx.lifecycle.*
|
import androidx.lifecycle.*
|
||||||
import androidx.work.*
|
import androidx.work.*
|
||||||
import chat.simplex.app.model.NtfManager
|
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.APPLICATION_ID
|
||||||
import chat.simplex.common.helpers.requiresIgnoringBattery
|
import chat.simplex.common.helpers.requiresIgnoringBattery
|
||||||
import chat.simplex.common.model.*
|
import chat.simplex.common.model.*
|
||||||
@ -18,6 +19,7 @@ import chat.simplex.common.platform.*
|
|||||||
import chat.simplex.common.ui.theme.CurrentColors
|
import chat.simplex.common.ui.theme.CurrentColors
|
||||||
import chat.simplex.common.ui.theme.DefaultTheme
|
import chat.simplex.common.ui.theme.DefaultTheme
|
||||||
import chat.simplex.common.views.call.RcvCallInvitation
|
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.helpers.*
|
||||||
import chat.simplex.common.views.onboarding.OnboardingStage
|
import chat.simplex.common.views.onboarding.OnboardingStage
|
||||||
import com.jakewharton.processphoenix.ProcessPhoenix
|
import com.jakewharton.processphoenix.ProcessPhoenix
|
||||||
@ -184,6 +186,10 @@ class SimplexApp: Application(), LifecycleEventObserver {
|
|||||||
SimplexService.safeStopService()
|
SimplexService.safeStopService()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun androidCallServiceSafeStop() {
|
||||||
|
CallService.stopService()
|
||||||
|
}
|
||||||
|
|
||||||
override fun androidNotificationsModeChanged(mode: NotificationsMode) {
|
override fun androidNotificationsModeChanged(mode: NotificationsMode) {
|
||||||
if (mode.requiresIgnoringBattery && !SimplexService.isBackgroundAllowed()) {
|
if (mode.requiresIgnoringBattery && !SimplexService.isBackgroundAllowed()) {
|
||||||
appPrefs.backgroundServiceNoticeShown.set(false)
|
appPrefs.backgroundServiceNoticeShown.set(false)
|
||||||
@ -254,6 +260,28 @@ class SimplexApp: Application(), LifecycleEventObserver {
|
|||||||
uiModeManager.setApplicationNightMode(mode)
|
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 {
|
override suspend fun androidAskToAllowBackgroundCalls(): Boolean {
|
||||||
if (SimplexService.isBackgroundRestricted()) {
|
if (SimplexService.isBackgroundRestricted()) {
|
||||||
val userChoice: CompletableDeferred<Boolean> = CompletableDeferred()
|
val userChoice: CompletableDeferred<Boolean> = CompletableDeferred()
|
||||||
|
@ -34,12 +34,13 @@ import kotlin.system.exitProcess
|
|||||||
|
|
||||||
class SimplexService: Service() {
|
class SimplexService: Service() {
|
||||||
private var wakeLock: PowerManager.WakeLock? = null
|
private var wakeLock: PowerManager.WakeLock? = null
|
||||||
private var isStartingService = false
|
private var isCheckingNewMessages = false
|
||||||
private var notificationManager: NotificationManager? = null
|
private var notificationManager: NotificationManager? = null
|
||||||
private var serviceNotification: Notification? = null
|
private var serviceNotification: Notification? = null
|
||||||
|
|
||||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
Log.d(TAG, "onStartCommand startId: $startId")
|
Log.d(TAG, "onStartCommand startId: $startId")
|
||||||
|
isServiceStarting = false
|
||||||
if (intent != null) {
|
if (intent != null) {
|
||||||
val action = intent.action
|
val action = intent.action
|
||||||
Log.d(TAG, "intent action $action")
|
Log.d(TAG, "intent action $action")
|
||||||
@ -71,6 +72,7 @@ class SimplexService: Service() {
|
|||||||
stopForeground(true)
|
stopForeground(true)
|
||||||
stopSelf()
|
stopSelf()
|
||||||
} else {
|
} else {
|
||||||
|
isServiceStarting = false
|
||||||
isServiceStarted = true
|
isServiceStarted = true
|
||||||
// In case of self-destruct is enabled the initialization process will not start in SimplexApp, Let's start it here
|
// 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) {
|
if (DatabaseUtils.ksSelfDestructPassword.get() != null && chatModel.chatDbStatus.value == null) {
|
||||||
@ -89,6 +91,7 @@ class SimplexService: Service() {
|
|||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.d(TAG, "Exception while releasing wakelock: ${e.message}")
|
Log.d(TAG, "Exception while releasing wakelock: ${e.message}")
|
||||||
}
|
}
|
||||||
|
isServiceStarting = false
|
||||||
isServiceStarted = false
|
isServiceStarted = false
|
||||||
stopAfterStart = false
|
stopAfterStart = false
|
||||||
saveServiceState(this, ServiceState.STOPPED)
|
saveServiceState(this, ServiceState.STOPPED)
|
||||||
@ -101,9 +104,9 @@ class SimplexService: Service() {
|
|||||||
|
|
||||||
private fun startService() {
|
private fun startService() {
|
||||||
Log.d(TAG, "SimplexService startService")
|
Log.d(TAG, "SimplexService startService")
|
||||||
if (wakeLock != null || isStartingService) return
|
if (wakeLock != null || isCheckingNewMessages) return
|
||||||
val self = this
|
val self = this
|
||||||
isStartingService = true
|
isCheckingNewMessages = true
|
||||||
withLongRunningApi {
|
withLongRunningApi {
|
||||||
val chatController = ChatController
|
val chatController = ChatController
|
||||||
waitDbMigrationEnds(chatController)
|
waitDbMigrationEnds(chatController)
|
||||||
@ -123,7 +126,7 @@ class SimplexService: Service() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
isStartingService = false
|
isCheckingNewMessages = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -262,6 +265,7 @@ class SimplexService: Service() {
|
|||||||
private const val SHARED_PREFS_SERVICE_STATE = "SIMPLEX_SERVICE_STATE"
|
private const val SHARED_PREFS_SERVICE_STATE = "SIMPLEX_SERVICE_STATE"
|
||||||
private const val WORK_NAME_ONCE = "ServiceStartWorkerOnce"
|
private const val WORK_NAME_ONCE = "ServiceStartWorkerOnce"
|
||||||
|
|
||||||
|
var isServiceStarting = false
|
||||||
var isServiceStarted = false
|
var isServiceStarted = false
|
||||||
private var stopAfterStart = false
|
private var stopAfterStart = false
|
||||||
|
|
||||||
@ -281,7 +285,7 @@ class SimplexService: Service() {
|
|||||||
fun safeStopService() {
|
fun safeStopService() {
|
||||||
if (isServiceStarted) {
|
if (isServiceStarted) {
|
||||||
androidAppContext.stopService(Intent(androidAppContext, SimplexService::class.java))
|
androidAppContext.stopService(Intent(androidAppContext, SimplexService::class.java))
|
||||||
} else {
|
} else if (isServiceStarting) {
|
||||||
stopAfterStart = true
|
stopAfterStart = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -291,6 +295,7 @@ class SimplexService: Service() {
|
|||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
Intent(androidAppContext, SimplexService::class.java).also {
|
Intent(androidAppContext, SimplexService::class.java).also {
|
||||||
it.action = action.name
|
it.action = action.name
|
||||||
|
isServiceStarting = true
|
||||||
ContextCompat.startForegroundService(androidAppContext, it)
|
ContextCompat.startForegroundService(androidAppContext, it)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -13,7 +13,7 @@ import androidx.compose.ui.graphics.asAndroidBitmap
|
|||||||
import androidx.core.app.*
|
import androidx.core.app.*
|
||||||
import chat.simplex.app.*
|
import chat.simplex.app.*
|
||||||
import chat.simplex.app.TAG
|
import chat.simplex.app.TAG
|
||||||
import chat.simplex.app.views.call.IncomingCallActivity
|
import chat.simplex.app.views.call.CallActivity
|
||||||
import chat.simplex.app.views.call.getKeyguardManager
|
import chat.simplex.app.views.call.getKeyguardManager
|
||||||
import chat.simplex.common.views.helpers.*
|
import chat.simplex.common.views.helpers.*
|
||||||
import chat.simplex.common.model.*
|
import chat.simplex.common.model.*
|
||||||
@ -33,6 +33,7 @@ object NtfManager {
|
|||||||
const val CallChannel: String = "chat.simplex.app.CALL_NOTIFICATION_2"
|
const val CallChannel: String = "chat.simplex.app.CALL_NOTIFICATION_2"
|
||||||
const val AcceptCallAction: String = "chat.simplex.app.ACCEPT_CALL"
|
const val AcceptCallAction: String = "chat.simplex.app.ACCEPT_CALL"
|
||||||
const val RejectCallAction: String = "chat.simplex.app.REJECT_CALL"
|
const val RejectCallAction: String = "chat.simplex.app.REJECT_CALL"
|
||||||
|
const val EndCallAction: String = "chat.simplex.app.END_CALL"
|
||||||
const val CallNotificationId: Int = -1
|
const val CallNotificationId: Int = -1
|
||||||
private const val UserIdKey: String = "userId"
|
private const val UserIdKey: String = "userId"
|
||||||
private const val ChatIdKey: String = "chatId"
|
private const val ChatIdKey: String = "chatId"
|
||||||
@ -157,7 +158,7 @@ object NtfManager {
|
|||||||
val screenOff = displayManager.displays.all { it.state != Display.STATE_ON }
|
val screenOff = displayManager.displays.all { it.state != Display.STATE_ON }
|
||||||
var ntfBuilder =
|
var ntfBuilder =
|
||||||
if ((keyguardManager.isKeyguardLocked || screenOff) && appPreferences.callOnLockScreen.get() != CallOnLockScreen.DISABLE) {
|
if ((keyguardManager.isKeyguardLocked || screenOff) && appPreferences.callOnLockScreen.get() != CallOnLockScreen.DISABLE) {
|
||||||
val fullScreenIntent = Intent(context, IncomingCallActivity::class.java)
|
val fullScreenIntent = Intent(context, CallActivity::class.java)
|
||||||
val fullScreenPendingIntent = PendingIntent.getActivity(context, 0, fullScreenIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
|
val fullScreenPendingIntent = PendingIntent.getActivity(context, 0, fullScreenIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
|
||||||
NotificationCompat.Builder(context, CallChannel)
|
NotificationCompat.Builder(context, CallChannel)
|
||||||
.setFullScreenIntent(fullScreenPendingIntent, true)
|
.setFullScreenIntent(fullScreenPendingIntent, true)
|
||||||
|
@ -1,17 +1,18 @@
|
|||||||
package chat.simplex.app.views.call
|
package chat.simplex.app.views.call
|
||||||
|
|
||||||
import android.app.Activity
|
import android.app.*
|
||||||
import android.app.KeyguardManager
|
import android.content.*
|
||||||
import android.content.Context
|
import android.content.res.Configuration
|
||||||
import android.content.Intent
|
import android.graphics.Rect
|
||||||
import android.os.Build
|
import android.os.*
|
||||||
import android.os.Bundle
|
import android.util.Rational
|
||||||
import chat.simplex.common.platform.Log
|
import android.view.*
|
||||||
import android.view.WindowManager
|
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.activity.trackPipAnimationHintView
|
||||||
import androidx.compose.desktop.ui.tooling.preview.Preview
|
import androidx.compose.desktop.ui.tooling.preview.Preview
|
||||||
import androidx.compose.foundation.Image
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.*
|
import androidx.compose.material.*
|
||||||
@ -22,33 +23,115 @@ import androidx.compose.ui.draw.scale
|
|||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.painter.Painter
|
import androidx.compose.ui.graphics.painter.Painter
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalView
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
import chat.simplex.app.*
|
import chat.simplex.app.*
|
||||||
import chat.simplex.app.R
|
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.model.*
|
||||||
import chat.simplex.app.model.NtfManager.OpenChatAction
|
import chat.simplex.common.platform.*
|
||||||
import chat.simplex.common.platform.ntfManager
|
|
||||||
import chat.simplex.common.ui.theme.*
|
import chat.simplex.common.ui.theme.*
|
||||||
import chat.simplex.common.views.call.*
|
import chat.simplex.common.views.call.*
|
||||||
import chat.simplex.common.views.helpers.*
|
import chat.simplex.common.views.helpers.*
|
||||||
import chat.simplex.res.MR
|
import chat.simplex.res.MR
|
||||||
import dev.icerock.moko.resources.compose.stringResource
|
import dev.icerock.moko.resources.compose.stringResource
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.datetime.Clock
|
import kotlinx.datetime.Clock
|
||||||
|
import java.lang.ref.WeakReference
|
||||||
|
import chat.simplex.common.platform.chatModel as m
|
||||||
|
|
||||||
class IncomingCallActivity: ComponentActivity() {
|
class CallActivity: ComponentActivity(), ServiceConnection {
|
||||||
|
|
||||||
|
var boundService: CallService? = null
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
setContent { IncomingCallActivityView(ChatModel) }
|
callActivity = WeakReference(this)
|
||||||
unlockForIncomingCall()
|
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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
lockAfterIncomingCall()
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun unlockForIncomingCall() {
|
private fun unlockForIncomingCall() {
|
||||||
@ -72,6 +155,23 @@ class IncomingCallActivity: ComponentActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
companion object {
|
||||||
const val activityFlags = WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON or WindowManager.LayoutParams.FLAG_ALLOW_LOCK_WHILE_SCREEN_ON
|
const val activityFlags = WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON or WindowManager.LayoutParams.FLAG_ALLOW_LOCK_WHILE_SCREEN_ON
|
||||||
}
|
}
|
||||||
@ -80,38 +180,96 @@ class IncomingCallActivity: ComponentActivity() {
|
|||||||
fun getKeyguardManager(context: Context): KeyguardManager =
|
fun getKeyguardManager(context: Context): KeyguardManager =
|
||||||
context.getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager
|
context.getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager
|
||||||
|
|
||||||
|
private fun callSupportsVideo() = m.activeCall.value?.supportsVideo() == true || m.activeCallInvitation.value?.callType?.media == CallMediaType.Video
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun IncomingCallActivityView(m: ChatModel) {
|
fun CallActivityView() {
|
||||||
val switchingCall = m.switchingCall.value
|
val switchingCall = m.switchingCall.value
|
||||||
val invitation = m.activeCallInvitation.value
|
val invitation = m.activeCallInvitation.value
|
||||||
val call = m.activeCall.value
|
val call = remember { m.activeCall }.value
|
||||||
val showCallView = m.showCallView.value
|
val showCallView = m.showCallView.value
|
||||||
val activity = LocalContext.current as Activity
|
val activity = LocalContext.current as CallActivity
|
||||||
LaunchedEffect(invitation, call, switchingCall, showCallView) {
|
LaunchedEffect(Unit) {
|
||||||
if (!switchingCall && invitation == null && (!showCallView || call == null)) {
|
snapshotFlow { m.activeCallViewIsCollapsed.value }
|
||||||
Log.d(TAG, "IncomingCallActivityView: finishing activity")
|
.collect { collapsed ->
|
||||||
activity.finish()
|
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 {
|
SimpleXTheme {
|
||||||
Surface(
|
var prevCall by remember { mutableStateOf(call) }
|
||||||
Modifier
|
KeyChangeEffect(m.activeCall.value) {
|
||||||
.fillMaxSize(),
|
if (m.activeCall.value != null) {
|
||||||
color = MaterialTheme.colors.background,
|
prevCall = m.activeCall.value
|
||||||
contentColor = LocalContentColor.current
|
activity.boundService?.updateNotification()
|
||||||
) {
|
}
|
||||||
if (showCallView) {
|
}
|
||||||
Box {
|
Box(Modifier.background(Color.Black)) {
|
||||||
ActiveCallView()
|
if (call != null) {
|
||||||
if (invitation != null) IncomingCallAlertView(invitation, m)
|
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)
|
||||||
}
|
}
|
||||||
} else if (invitation != null) {
|
|
||||||
IncomingCallLockScreenAlert(invitation, m)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
LaunchedEffect(call == null) {
|
||||||
|
if (call != null) {
|
||||||
|
activity.startServiceAndBind()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LaunchedEffect(invitation, call, switchingCall, showCallView) {
|
||||||
|
if (!switchingCall && invitation == null && (!showCallView || call == null)) {
|
||||||
|
Log.d(TAG, "CallActivityView: finishing activity")
|
||||||
|
activity.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Related to lockscreen
|
||||||
|
* */
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun IncomingCallLockScreenAlert(invitation: RcvCallInvitation, chatModel: ChatModel) {
|
fun IncomingCallLockScreenAlert(invitation: RcvCallInvitation, chatModel: ChatModel) {
|
||||||
val cm = chatModel.callManager
|
val cm = chatModel.callManager
|
||||||
@ -135,7 +293,7 @@ fun IncomingCallLockScreenAlert(invitation: RcvCallInvitation, chatModel: ChatMo
|
|||||||
acceptCall = { cm.acceptIncomingCall(invitation = invitation) },
|
acceptCall = { cm.acceptIncomingCall(invitation = invitation) },
|
||||||
openApp = {
|
openApp = {
|
||||||
val intent = Intent(context, MainActivity::class.java)
|
val intent = Intent(context, MainActivity::class.java)
|
||||||
.setAction(OpenChatAction)
|
.setAction(NtfManager.OpenChatAction)
|
||||||
.putExtra("userId", invitation.user.userId)
|
.putExtra("userId", invitation.user.userId)
|
||||||
.putExtra("chatId", invitation.contact.id)
|
.putExtra("chatId", invitation.contact.id)
|
||||||
context.startActivity(intent)
|
context.startActivity(intent)
|
@ -4,6 +4,7 @@ import android.annotation.SuppressLint
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.net.LocalServerSocket
|
import android.net.LocalServerSocket
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.fragment.app.FragmentActivity
|
import androidx.fragment.app.FragmentActivity
|
||||||
import chat.simplex.common.*
|
import chat.simplex.common.*
|
||||||
import chat.simplex.common.platform.*
|
import chat.simplex.common.platform.*
|
||||||
@ -25,7 +26,8 @@ val defaultLocale: Locale = Locale.getDefault()
|
|||||||
|
|
||||||
@SuppressLint("StaticFieldLeak")
|
@SuppressLint("StaticFieldLeak")
|
||||||
lateinit var androidAppContext: Context
|
lateinit var androidAppContext: Context
|
||||||
lateinit var mainActivity: WeakReference<FragmentActivity>
|
var mainActivity: WeakReference<FragmentActivity> = WeakReference(null)
|
||||||
|
var callActivity: WeakReference<ComponentActivity> = WeakReference(null)
|
||||||
|
|
||||||
fun initHaskell() {
|
fun initHaskell() {
|
||||||
val socketName = "chat.simplex.app.local.socket.address.listen.native.cmd2" + Random.nextLong(100000)
|
val socketName = "chat.simplex.app.local.socket.address.listen.native.cmd2" + Random.nextLong(100000)
|
||||||
|
@ -61,6 +61,16 @@ actual fun cropToSquare(image: ImageBitmap): ImageBitmap {
|
|||||||
return Bitmap.createBitmap(image.asAndroidBitmap(), xOffset, yOffset, side, side).asImageBitmap()
|
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 {
|
actual fun compressImageStr(bitmap: ImageBitmap): String {
|
||||||
val usePng = bitmap.hasAlpha()
|
val usePng = bitmap.hasAlpha()
|
||||||
val ext = if (usePng) "png" else "jpg"
|
val ext = if (usePng) "png" else "jpg"
|
||||||
|
@ -28,6 +28,7 @@ import androidx.compose.ui.platform.LocalLifecycleOwner
|
|||||||
import dev.icerock.moko.resources.compose.painterResource
|
import dev.icerock.moko.resources.compose.painterResource
|
||||||
import dev.icerock.moko.resources.compose.stringResource
|
import dev.icerock.moko.resources.compose.stringResource
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.viewinterop.AndroidView
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
@ -50,20 +51,30 @@ import kotlinx.datetime.Clock
|
|||||||
import kotlinx.serialization.decodeFromString
|
import kotlinx.serialization.decodeFromString
|
||||||
import kotlinx.serialization.encodeToString
|
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")
|
@SuppressLint("SourceLockedOrientationActivity")
|
||||||
@Composable
|
@Composable
|
||||||
actual fun ActiveCallView() {
|
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 audioViaBluetooth = rememberSaveable { mutableStateOf(false) }
|
||||||
val ntfModeService = remember { chatModel.controller.appPrefs.notificationsMode.get() == NotificationsMode.SERVICE }
|
val proximityLock = remember {
|
||||||
LaunchedEffect(Unit) {
|
val pm = (androidAppContext.getSystemService(Context.POWER_SERVICE) as PowerManager)
|
||||||
// Start service when call happening since it's not already started.
|
if (pm.isWakeLockLevelSupported(PROXIMITY_SCREEN_OFF_WAKE_LOCK)) {
|
||||||
// It's needed to prevent Android from shutting down a microphone after a minute or so when screen is off
|
pm.newWakeLock(PROXIMITY_SCREEN_OFF_WAKE_LOCK, androidAppContext.packageName + ":proximityLock")
|
||||||
if (!ntfModeService) platform.androidServiceStart()
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
DisposableEffect(Unit) {
|
DisposableEffect(Unit) {
|
||||||
val am = androidAppContext.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
val am = androidAppContext.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||||
@ -93,22 +104,24 @@ actual fun ActiveCallView() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
am.registerAudioDeviceCallback(audioCallback, null)
|
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 {
|
onDispose {
|
||||||
// Stop it when call ended
|
|
||||||
if (!ntfModeService) platform.androidServiceSafeStop()
|
|
||||||
dropAudioManagerOverrides()
|
dropAudioManagerOverrides()
|
||||||
am.unregisterAudioDeviceCallback(audioCallback)
|
am.unregisterAudioDeviceCallback(audioCallback)
|
||||||
proximityLock?.release()
|
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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
val call = chatModel.activeCall.value
|
||||||
Box(Modifier.fillMaxSize()) {
|
Box(Modifier.fillMaxSize()) {
|
||||||
WebRTCView(chatModel.callCommand) { apiMsg ->
|
WebRTCView(chatModel.callCommand) { apiMsg ->
|
||||||
Log.d(TAG, "received from WebRTCView: $apiMsg")
|
Log.d(TAG, "received from WebRTCView: $apiMsg")
|
||||||
@ -120,15 +133,15 @@ actual fun ActiveCallView() {
|
|||||||
is WCallResponse.Capabilities -> withBGApi {
|
is WCallResponse.Capabilities -> withBGApi {
|
||||||
val callType = CallType(call.localMedia, r.capabilities)
|
val callType = CallType(call.localMedia, r.capabilities)
|
||||||
chatModel.controller.apiSendCallInvitation(callRh, call.contact, callType)
|
chatModel.controller.apiSendCallInvitation(callRh, call.contact, callType)
|
||||||
chatModel.activeCall.value = call.copy(callState = CallState.InvitationSent, localCapabilities = r.capabilities)
|
updateActiveCall(call) { it.copy(callState = CallState.InvitationSent, localCapabilities = r.capabilities) }
|
||||||
}
|
}
|
||||||
is WCallResponse.Offer -> withBGApi {
|
is WCallResponse.Offer -> withBGApi {
|
||||||
chatModel.controller.apiSendCallOffer(callRh, call.contact, r.offer, r.iceCandidates, call.localMedia, r.capabilities)
|
chatModel.controller.apiSendCallOffer(callRh, call.contact, r.offer, r.iceCandidates, call.localMedia, r.capabilities)
|
||||||
chatModel.activeCall.value = call.copy(callState = CallState.OfferSent, localCapabilities = r.capabilities)
|
updateActiveCall(call) { it.copy(callState = CallState.OfferSent, localCapabilities = r.capabilities) }
|
||||||
}
|
}
|
||||||
is WCallResponse.Answer -> withBGApi {
|
is WCallResponse.Answer -> withBGApi {
|
||||||
chatModel.controller.apiSendCallAnswer(callRh, call.contact, r.answer, r.iceCandidates)
|
chatModel.controller.apiSendCallAnswer(callRh, call.contact, r.answer, r.iceCandidates)
|
||||||
chatModel.activeCall.value = call.copy(callState = CallState.Negotiated)
|
updateActiveCall(call) { it.copy(callState = CallState.Negotiated) }
|
||||||
}
|
}
|
||||||
is WCallResponse.Ice -> withBGApi {
|
is WCallResponse.Ice -> withBGApi {
|
||||||
chatModel.controller.apiSendCallExtraInfo(callRh, call.contact, r.iceCandidates)
|
chatModel.controller.apiSendCallExtraInfo(callRh, call.contact, r.iceCandidates)
|
||||||
@ -137,7 +150,7 @@ actual fun ActiveCallView() {
|
|||||||
try {
|
try {
|
||||||
val callStatus = json.decodeFromString<WebRTCCallStatus>("\"${r.state.connectionState}\"")
|
val callStatus = json.decodeFromString<WebRTCCallStatus>("\"${r.state.connectionState}\"")
|
||||||
if (callStatus == WebRTCCallStatus.Connected) {
|
if (callStatus == WebRTCCallStatus.Connected) {
|
||||||
chatModel.activeCall.value = call.copy(callState = CallState.Connected, connectedAt = Clock.System.now())
|
updateActiveCall(call) { it.copy(callState = CallState.Connected, connectedAt = Clock.System.now()) }
|
||||||
setCallSound(call.soundSpeaker, audioViaBluetooth)
|
setCallSound(call.soundSpeaker, audioViaBluetooth)
|
||||||
}
|
}
|
||||||
withBGApi { chatModel.controller.apiCallStatus(callRh, call.contact, callStatus) }
|
withBGApi { chatModel.controller.apiCallStatus(callRh, call.contact, callStatus) }
|
||||||
@ -145,7 +158,7 @@ actual fun ActiveCallView() {
|
|||||||
Log.d(TAG,"call status ${r.state.connectionState} not used")
|
Log.d(TAG,"call status ${r.state.connectionState} not used")
|
||||||
}
|
}
|
||||||
is WCallResponse.Connected -> {
|
is WCallResponse.Connected -> {
|
||||||
chatModel.activeCall.value = call.copy(callState = CallState.Connected, connectionInfo = r.connectionInfo)
|
updateActiveCall(call) { it.copy(callState = CallState.Connected, connectionInfo = r.connectionInfo) }
|
||||||
scope.launch {
|
scope.launch {
|
||||||
setCallSound(call.soundSpeaker, audioViaBluetooth)
|
setCallSound(call.soundSpeaker, audioViaBluetooth)
|
||||||
}
|
}
|
||||||
@ -154,27 +167,29 @@ actual fun ActiveCallView() {
|
|||||||
withBGApi { chatModel.callManager.endCall(call) }
|
withBGApi { chatModel.callManager.endCall(call) }
|
||||||
}
|
}
|
||||||
is WCallResponse.Ended -> {
|
is WCallResponse.Ended -> {
|
||||||
chatModel.activeCall.value = call.copy(callState = CallState.Ended)
|
updateActiveCall(call) { it.copy(callState = CallState.Ended) }
|
||||||
withBGApi { chatModel.callManager.endCall(call) }
|
withBGApi { chatModel.callManager.endCall(call) }
|
||||||
chatModel.showCallView.value = false
|
|
||||||
}
|
}
|
||||||
is WCallResponse.Ok -> when (val cmd = apiMsg.command) {
|
is WCallResponse.Ok -> when (val cmd = apiMsg.command) {
|
||||||
is WCallCommand.Answer ->
|
is WCallCommand.Answer ->
|
||||||
chatModel.activeCall.value = call.copy(callState = CallState.Negotiated)
|
updateActiveCall(call) { it.copy(callState = CallState.Negotiated) }
|
||||||
is WCallCommand.Media -> {
|
is WCallCommand.Media -> {
|
||||||
when (cmd.media) {
|
updateActiveCall(call) {
|
||||||
CallMediaType.Video -> chatModel.activeCall.value = call.copy(videoEnabled = cmd.enable)
|
when (cmd.media) {
|
||||||
CallMediaType.Audio -> chatModel.activeCall.value = call.copy(audioEnabled = cmd.enable)
|
CallMediaType.Video -> it.copy(videoEnabled = cmd.enable)
|
||||||
|
CallMediaType.Audio -> it.copy(audioEnabled = cmd.enable)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
is WCallCommand.Camera -> {
|
is WCallCommand.Camera -> {
|
||||||
chatModel.activeCall.value = call.copy(localCamera = cmd.camera)
|
updateActiveCall(call) { it.copy(localCamera = cmd.camera) }
|
||||||
if (!call.audioEnabled) {
|
if (!call.audioEnabled) {
|
||||||
chatModel.callCommand.add(WCallCommand.Media(CallMediaType.Audio, enable = false))
|
chatModel.callCommand.add(WCallCommand.Media(CallMediaType.Audio, enable = false))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
is WCallCommand.End ->
|
is WCallCommand.End -> {
|
||||||
chatModel.showCallView.value = false
|
withBGApi { chatModel.callManager.endCall(call) }
|
||||||
|
}
|
||||||
else -> {}
|
else -> {}
|
||||||
}
|
}
|
||||||
is WCallResponse.Error -> {
|
is WCallResponse.Error -> {
|
||||||
@ -183,8 +198,16 @@ actual fun ActiveCallView() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val call = chatModel.activeCall.value
|
val showOverlay = when {
|
||||||
if (call != null) ActiveCallOverlay(call, chatModel, audioViaBluetooth)
|
call == null -> false
|
||||||
|
!platform.androidPictureInPictureAllowed() -> true
|
||||||
|
!call.supportsVideo() -> true
|
||||||
|
!chatModel.activeCallViewIsCollapsed.value -> true
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
if (call != null && showOverlay) {
|
||||||
|
ActiveCallOverlay(call, chatModel, audioViaBluetooth)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
@ -229,6 +252,20 @@ 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>) {
|
private fun setCallSound(speaker: Boolean, audioViaBluetooth: MutableState<Boolean>) {
|
||||||
val am = androidAppContext.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
val am = androidAppContext.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||||
Log.d(TAG, "setCallSound: set audio mode, speaker enabled: $speaker")
|
Log.d(TAG, "setCallSound: set audio mode, speaker enabled: $speaker")
|
||||||
@ -271,59 +308,69 @@ private fun dropAudioManagerOverrides() {
|
|||||||
private fun ActiveCallOverlayLayout(
|
private fun ActiveCallOverlayLayout(
|
||||||
call: Call,
|
call: Call,
|
||||||
speakerCanBeEnabled: Boolean,
|
speakerCanBeEnabled: Boolean,
|
||||||
|
enabled: Boolean = true,
|
||||||
dismiss: () -> Unit,
|
dismiss: () -> Unit,
|
||||||
toggleAudio: () -> Unit,
|
toggleAudio: () -> Unit,
|
||||||
toggleVideo: () -> Unit,
|
toggleVideo: () -> Unit,
|
||||||
toggleSound: () -> Unit,
|
toggleSound: () -> Unit,
|
||||||
flipCamera: () -> Unit
|
flipCamera: () -> Unit
|
||||||
) {
|
) {
|
||||||
Column(Modifier.padding(DEFAULT_PADDING)) {
|
Column {
|
||||||
when (call.peerMedia ?: call.localMedia) {
|
val media = call.peerMedia ?: call.localMedia
|
||||||
CallMediaType.Video -> {
|
CloseSheetBar({ chatModel.activeCallViewIsCollapsed.value = true }, true, tintColor = Color(0xFFFFFFD8)) {
|
||||||
CallInfoView(call, alignment = Alignment.Start)
|
if (media == CallMediaType.Video) {
|
||||||
Box(Modifier.fillMaxWidth().fillMaxHeight().weight(1f), contentAlignment = Alignment.BottomCenter) {
|
Text(call.contact.chatViewName, Modifier.fillMaxWidth().padding(end = DEFAULT_PADDING), color = Color(0xFFFFFFD8), style = MaterialTheme.typography.h2, overflow = TextOverflow.Ellipsis, maxLines = 1)
|
||||||
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))
|
|
||||||
}
|
|
||||||
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.padding(horizontal = DEFAULT_PADDING)) {
|
||||||
Column(
|
when (media) {
|
||||||
Modifier.fillMaxWidth(),
|
CallMediaType.Video -> {
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
VideoCallInfoView(call)
|
||||||
verticalArrangement = Arrangement.Center
|
Box(Modifier.fillMaxWidth().fillMaxHeight().weight(1f), contentAlignment = Alignment.BottomCenter) {
|
||||||
) {
|
DisabledBackgroundCallsButton()
|
||||||
ProfileImage(size = 192.dp, image = call.contact.profile.image)
|
}
|
||||||
CallInfoView(call, alignment = Alignment.CenterHorizontally)
|
Row(Modifier.fillMaxWidth().padding(horizontal = 6.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
|
||||||
}
|
ToggleAudioButton(call, enabled, toggleAudio)
|
||||||
Box(Modifier.fillMaxWidth().fillMaxHeight().weight(1f), contentAlignment = Alignment.BottomCenter) {
|
Spacer(Modifier.size(40.dp))
|
||||||
DisabledBackgroundCallsButton()
|
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))
|
||||||
Box(Modifier.fillMaxWidth().padding(bottom = DEFAULT_BOTTOM_PADDING), contentAlignment = Alignment.CenterStart) {
|
}
|
||||||
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
|
if (call.videoEnabled) {
|
||||||
IconButton(onClick = dismiss) {
|
ControlButton(call, painterResource(MR.images.ic_flip_camera_android_filled), MR.strings.icon_descr_flip_camera, enabled, flipCamera)
|
||||||
Icon(painterResource(MR.images.ic_call_end_filled), stringResource(MR.strings.icon_descr_hang_up), tint = Color.Red, modifier = Modifier.size(64.dp))
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Box(Modifier.padding(start = 32.dp)) {
|
}
|
||||||
ToggleAudioButton(call, toggleAudio)
|
|
||||||
|
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(), contentAlignment = Alignment.CenterEnd) {
|
Box(Modifier.fillMaxWidth().fillMaxHeight().weight(1f), contentAlignment = Alignment.BottomCenter) {
|
||||||
Box(Modifier.padding(end = 32.dp)) {
|
DisabledBackgroundCallsButton()
|
||||||
ToggleSoundButton(call, speakerCanBeEnabled, toggleSound)
|
}
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -333,7 +380,7 @@ private fun ActiveCallOverlayLayout(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ControlButton(call: Call, icon: Painter, iconText: StringResource, action: () -> Unit, enabled: Boolean = true) {
|
private fun ControlButton(call: Call, icon: Painter, iconText: StringResource, enabled: Boolean = true, action: () -> Unit) {
|
||||||
if (call.hasMedia) {
|
if (call.hasMedia) {
|
||||||
IconButton(onClick = action, enabled = enabled) {
|
IconButton(onClick = action, enabled = enabled) {
|
||||||
Icon(icon, stringResource(iconText), tint = if (enabled) Color(0xFFFFFFD8) else MaterialTheme.colors.secondary, modifier = Modifier.size(40.dp))
|
Icon(icon, stringResource(iconText), tint = if (enabled) Color(0xFFFFFFD8) else MaterialTheme.colors.secondary, modifier = Modifier.size(40.dp))
|
||||||
@ -344,28 +391,26 @@ private fun ControlButton(call: Call, icon: Painter, iconText: StringResource, a
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ToggleAudioButton(call: Call, toggleAudio: () -> Unit) {
|
private fun ToggleAudioButton(call: Call, enabled: Boolean = true, toggleAudio: () -> Unit) {
|
||||||
if (call.audioEnabled) {
|
if (call.audioEnabled) {
|
||||||
ControlButton(call, painterResource(MR.images.ic_mic), MR.strings.icon_descr_audio_off, toggleAudio)
|
ControlButton(call, painterResource(MR.images.ic_mic), MR.strings.icon_descr_audio_off, enabled, toggleAudio)
|
||||||
} else {
|
} else {
|
||||||
ControlButton(call, painterResource(MR.images.ic_mic_off), MR.strings.icon_descr_audio_on, toggleAudio)
|
ControlButton(call, painterResource(MR.images.ic_mic_off), MR.strings.icon_descr_audio_on, enabled, toggleAudio)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ToggleSoundButton(call: Call, enabled: Boolean, toggleSound: () -> Unit) {
|
private fun ToggleSoundButton(call: Call, enabled: Boolean, toggleSound: () -> Unit) {
|
||||||
if (call.soundSpeaker) {
|
if (call.soundSpeaker) {
|
||||||
ControlButton(call, painterResource(MR.images.ic_volume_up), MR.strings.icon_descr_speaker_off, toggleSound, enabled)
|
ControlButton(call, painterResource(MR.images.ic_volume_up), MR.strings.icon_descr_speaker_off, enabled, toggleSound)
|
||||||
} else {
|
} else {
|
||||||
ControlButton(call, painterResource(MR.images.ic_volume_down), MR.strings.icon_descr_speaker_on, toggleSound, enabled)
|
ControlButton(call, painterResource(MR.images.ic_volume_down), MR.strings.icon_descr_speaker_on, enabled, toggleSound)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun CallInfoView(call: Call, alignment: Alignment.Horizontal) {
|
fun AudioCallInfoView(call: Call) {
|
||||||
@Composable fun InfoText(text: String, style: TextStyle = MaterialTheme.typography.body2) =
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
Text(text, color = Color(0xFFFFFFD8), style = style)
|
|
||||||
Column(horizontalAlignment = alignment) {
|
|
||||||
InfoText(call.contact.chatViewName, style = MaterialTheme.typography.h2)
|
InfoText(call.contact.chatViewName, style = MaterialTheme.typography.h2)
|
||||||
InfoText(call.callState.text)
|
InfoText(call.callState.text)
|
||||||
|
|
||||||
@ -375,6 +420,21 @@ fun CallInfoView(call: Call, alignment: Alignment.Horizontal) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@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
|
@Composable
|
||||||
private fun DisabledBackgroundCallsButton() {
|
private fun DisabledBackgroundCallsButton() {
|
||||||
var show by remember { mutableStateOf(!platform.androidIsBackgroundCallAllowed()) }
|
var show by remember { mutableStateOf(!platform.androidIsBackgroundCallAllowed()) }
|
||||||
@ -452,7 +512,6 @@ private fun DisabledBackgroundCallsButton() {
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun WebRTCView(callCommand: SnapshotStateList<WCallCommand>, onResponse: (WVAPIMessage) -> Unit) {
|
fun WebRTCView(callCommand: SnapshotStateList<WCallCommand>, onResponse: (WVAPIMessage) -> Unit) {
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
val webView = remember { mutableStateOf<WebView?>(null) }
|
val webView = remember { mutableStateOf<WebView?>(null) }
|
||||||
val permissionsState = rememberMultiplePermissionsState(
|
val permissionsState = rememberMultiplePermissionsState(
|
||||||
permissions = listOf(
|
permissions = listOf(
|
||||||
@ -475,10 +534,10 @@ fun WebRTCView(callCommand: SnapshotStateList<WCallCommand>, onResponse: (WVAPIM
|
|||||||
}
|
}
|
||||||
lifecycleOwner.lifecycle.addObserver(observer)
|
lifecycleOwner.lifecycle.addObserver(observer)
|
||||||
onDispose {
|
onDispose {
|
||||||
val wv = webView.value
|
|
||||||
if (wv != null) processCommand(wv, WCallCommand.End)
|
|
||||||
lifecycleOwner.lifecycle.removeObserver(observer)
|
lifecycleOwner.lifecycle.removeObserver(observer)
|
||||||
webView.value?.destroy()
|
// val wv = webView.value
|
||||||
|
// if (wv != null) processCommand(wv, WCallCommand.End)
|
||||||
|
// webView.value?.destroy()
|
||||||
webView.value = null
|
webView.value = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -505,7 +564,7 @@ fun WebRTCView(callCommand: SnapshotStateList<WCallCommand>, onResponse: (WVAPIM
|
|||||||
Box(Modifier.fillMaxSize()) {
|
Box(Modifier.fillMaxSize()) {
|
||||||
AndroidView(
|
AndroidView(
|
||||||
factory = { AndroidViewContext ->
|
factory = { AndroidViewContext ->
|
||||||
WebView(AndroidViewContext).apply {
|
(staticWebView ?: WebView(androidAppContext)).apply {
|
||||||
layoutParams = ViewGroup.LayoutParams(
|
layoutParams = ViewGroup.LayoutParams(
|
||||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
@ -530,7 +589,11 @@ fun WebRTCView(callCommand: SnapshotStateList<WCallCommand>, onResponse: (WVAPIM
|
|||||||
webViewSettings.javaScriptEnabled = true
|
webViewSettings.javaScriptEnabled = true
|
||||||
webViewSettings.mediaPlaybackRequiresUserGesture = false
|
webViewSettings.mediaPlaybackRequiresUserGesture = false
|
||||||
webViewSettings.cacheMode = WebSettings.LOAD_NO_CACHE
|
webViewSettings.cacheMode = WebSettings.LOAD_NO_CACHE
|
||||||
this.loadUrl("file:android_asset/www/android/call.html")
|
if (staticWebView == null) {
|
||||||
|
this.loadUrl("file:android_asset/www/android/call.html")
|
||||||
|
} else {
|
||||||
|
webView.value = this
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
) { /* WebView */ }
|
) { /* WebView */ }
|
||||||
@ -554,6 +617,15 @@ class WebRTCInterface(private val onResponse: (WVAPIMessage) -> Unit) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun updateActiveCall(initial: Call, transform: (Call) -> Call) {
|
||||||
|
val activeCall = chatModel.activeCall.value
|
||||||
|
if (activeCall != null && activeCall.contact.apiId == initial.contact.apiId) {
|
||||||
|
chatModel.activeCall.value = transform(activeCall)
|
||||||
|
} else {
|
||||||
|
Log.d(TAG, "withActiveCall: ignoring, not in call with the contact ${activeCall?.contact?.id}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private class LocalContentWebViewClient(val webView: MutableState<WebView?>, private val assetLoader: WebViewAssetLoader) : WebViewClientCompat() {
|
private class LocalContentWebViewClient(val webView: MutableState<WebView?>, private val assetLoader: WebViewAssetLoader) : WebViewClientCompat() {
|
||||||
override fun shouldInterceptRequest(
|
override fun shouldInterceptRequest(
|
||||||
view: WebView,
|
view: WebView,
|
||||||
@ -566,6 +638,7 @@ private class LocalContentWebViewClient(val webView: MutableState<WebView?>, pri
|
|||||||
super.onPageFinished(view, url)
|
super.onPageFinished(view, url)
|
||||||
view.evaluateJavascript("sendMessageToNative = (msg) => WebRTCInterface.postMessage(JSON.stringify(msg))", null)
|
view.evaluateJavascript("sendMessageToNative = (msg) => WebRTCInterface.postMessage(JSON.stringify(msg))", null)
|
||||||
webView.value = view
|
webView.value = view
|
||||||
|
staticWebView = view
|
||||||
Log.d(TAG, "WebRTCView: webview ready")
|
Log.d(TAG, "WebRTCView: webview ready")
|
||||||
// for debugging
|
// for debugging
|
||||||
// view.evaluateJavascript("sendMessageToNative = ({resp}) => WebRTCInterface.postMessage(JSON.stringify({command: resp}))", null)
|
// view.evaluateJavascript("sendMessageToNative = ({resp}) => WebRTCInterface.postMessage(JSON.stringify({command: resp}))", null)
|
||||||
@ -579,6 +652,7 @@ fun PreviewActiveCallOverlayVideo() {
|
|||||||
ActiveCallOverlayLayout(
|
ActiveCallOverlayLayout(
|
||||||
call = Call(
|
call = Call(
|
||||||
remoteHostId = null,
|
remoteHostId = null,
|
||||||
|
userProfile = Profile.sampleData,
|
||||||
contact = Contact.sampleData,
|
contact = Contact.sampleData,
|
||||||
callState = CallState.Negotiated,
|
callState = CallState.Negotiated,
|
||||||
localMedia = CallMediaType.Video,
|
localMedia = CallMediaType.Video,
|
||||||
@ -605,6 +679,7 @@ fun PreviewActiveCallOverlayAudio() {
|
|||||||
ActiveCallOverlayLayout(
|
ActiveCallOverlayLayout(
|
||||||
call = Call(
|
call = Call(
|
||||||
remoteHostId = null,
|
remoteHostId = null,
|
||||||
|
userProfile = Profile.sampleData,
|
||||||
contact = Contact.sampleData,
|
contact = Contact.sampleData,
|
||||||
callState = CallState.Negotiated,
|
callState = CallState.Negotiated,
|
||||||
localMedia = CallMediaType.Audio,
|
localMedia = CallMediaType.Audio,
|
||||||
|
@ -1,8 +1,112 @@
|
|||||||
package chat.simplex.common.views.chatlist
|
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.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.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.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
|
@Composable
|
||||||
actual fun DesktopActiveCallOverlayLayout(newChatSheetState: MutableStateFlow<AnimatedViewState>) {}
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -1,16 +1,19 @@
|
|||||||
package chat.simplex.common
|
package chat.simplex.common
|
||||||
|
|
||||||
import androidx.compose.animation.core.Animatable
|
import androidx.compose.animation.core.Animatable
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.*
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.material.*
|
import androidx.compose.material.*
|
||||||
|
import androidx.compose.material.ripple.rememberRipple
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.draw.clipToBounds
|
import androidx.compose.ui.draw.clipToBounds
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import chat.simplex.common.views.usersettings.SetDeliveryReceiptsView
|
import chat.simplex.common.views.usersettings.SetDeliveryReceiptsView
|
||||||
@ -20,8 +23,7 @@ import chat.simplex.common.ui.theme.*
|
|||||||
import chat.simplex.common.views.CreateFirstProfile
|
import chat.simplex.common.views.CreateFirstProfile
|
||||||
import chat.simplex.common.views.helpers.SimpleButton
|
import chat.simplex.common.views.helpers.SimpleButton
|
||||||
import chat.simplex.common.views.SplashView
|
import chat.simplex.common.views.SplashView
|
||||||
import chat.simplex.common.views.call.ActiveCallView
|
import chat.simplex.common.views.call.*
|
||||||
import chat.simplex.common.views.call.IncomingCallAlertView
|
|
||||||
import chat.simplex.common.views.chat.ChatView
|
import chat.simplex.common.views.chat.ChatView
|
||||||
import chat.simplex.common.views.chatlist.*
|
import chat.simplex.common.views.chatlist.*
|
||||||
import chat.simplex.common.views.database.DatabaseErrorView
|
import chat.simplex.common.views.database.DatabaseErrorView
|
||||||
@ -169,7 +171,17 @@ fun MainScreen() {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (chatModel.showCallView.value) {
|
if (chatModel.showCallView.value) {
|
||||||
ActiveCallView()
|
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()
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// It's needed for privacy settings toggle, so it can be shown even if the app is passcode unlocked
|
// It's needed for privacy settings toggle, so it can be shown even if the app is passcode unlocked
|
||||||
ModalManager.fullscreen.showPasscodeInView()
|
ModalManager.fullscreen.showPasscodeInView()
|
||||||
@ -206,9 +218,13 @@ fun MainScreen() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val ANDROID_CALL_TOP_PADDING = 40.dp
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun AndroidScreen(settingsState: SettingsViewState) {
|
fun AndroidScreen(settingsState: SettingsViewState) {
|
||||||
BoxWithConstraints {
|
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) }
|
var currentChatId by rememberSaveable { mutableStateOf(chatModel.chatId.value) }
|
||||||
val offset = remember { Animatable(if (chatModel.chatId.value == null) 0f else maxWidth.value) }
|
val offset = remember { Animatable(if (chatModel.chatId.value == null) 0f else maxWidth.value) }
|
||||||
Box(
|
Box(
|
||||||
@ -216,6 +232,7 @@ fun AndroidScreen(settingsState: SettingsViewState) {
|
|||||||
.graphicsLayer {
|
.graphicsLayer {
|
||||||
translationX = -offset.value.dp.toPx()
|
translationX = -offset.value.dp.toPx()
|
||||||
}
|
}
|
||||||
|
.padding(top = if (showCallArea) ANDROID_CALL_TOP_PADDING else 0.dp)
|
||||||
) {
|
) {
|
||||||
StartPartOfScreen(settingsState)
|
StartPartOfScreen(settingsState)
|
||||||
}
|
}
|
||||||
@ -242,11 +259,17 @@ fun AndroidScreen(settingsState: SettingsViewState) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Box(Modifier.graphicsLayer { translationX = maxWidth.toPx() - offset.value.dp.toPx() }) Box2@{
|
Box(Modifier
|
||||||
|
.graphicsLayer { translationX = maxWidth.toPx() - offset.value.dp.toPx() }
|
||||||
|
.padding(top = if (showCallArea) ANDROID_CALL_TOP_PADDING else 0.dp)
|
||||||
|
) Box2@{
|
||||||
currentChatId?.let {
|
currentChatId?.let {
|
||||||
ChatView(it, chatModel, onComposed)
|
ChatView(it, chatModel, onComposed)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (call != null && showCallArea) {
|
||||||
|
ActiveCallInteractiveArea(call, remember { MutableStateFlow(AnimatedViewState.GONE) })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -96,6 +96,7 @@ object ChatModel {
|
|||||||
val activeCallInvitation = mutableStateOf<RcvCallInvitation?>(null)
|
val activeCallInvitation = mutableStateOf<RcvCallInvitation?>(null)
|
||||||
val activeCall = mutableStateOf<Call?>(null)
|
val activeCall = mutableStateOf<Call?>(null)
|
||||||
val activeCallViewIsVisible = mutableStateOf<Boolean>(false)
|
val activeCallViewIsVisible = mutableStateOf<Boolean>(false)
|
||||||
|
val activeCallViewIsCollapsed = mutableStateOf<Boolean>(false)
|
||||||
val callCommand = mutableStateListOf<WCallCommand>()
|
val callCommand = mutableStateListOf<WCallCommand>()
|
||||||
val showCallView = mutableStateOf(false)
|
val showCallView = mutableStateOf(false)
|
||||||
val switchingCall = mutableStateOf(false)
|
val switchingCall = mutableStateOf(false)
|
||||||
@ -1821,7 +1822,7 @@ data class ChatItem (
|
|||||||
is CIContent.SndGroupInvitation -> false
|
is CIContent.SndGroupInvitation -> false
|
||||||
is CIContent.RcvDirectEventContent -> when (content.rcvDirectEvent) {
|
is CIContent.RcvDirectEventContent -> when (content.rcvDirectEvent) {
|
||||||
is RcvDirectEvent.ContactDeleted -> false
|
is RcvDirectEvent.ContactDeleted -> false
|
||||||
is RcvDirectEvent.ProfileUpdated -> true
|
is RcvDirectEvent.ProfileUpdated -> false
|
||||||
}
|
}
|
||||||
is CIContent.RcvGroupEventContent -> when (content.rcvGroupEvent) {
|
is CIContent.RcvGroupEventContent -> when (content.rcvGroupEvent) {
|
||||||
is RcvGroupEvent.MemberAdded -> false
|
is RcvGroupEvent.MemberAdded -> false
|
||||||
|
@ -1908,10 +1908,8 @@ object ChatController {
|
|||||||
if (invitation != null) {
|
if (invitation != null) {
|
||||||
chatModel.callManager.reportCallRemoteEnded(invitation = invitation)
|
chatModel.callManager.reportCallRemoteEnded(invitation = invitation)
|
||||||
}
|
}
|
||||||
withCall(r, r.contact) { _ ->
|
withCall(r, r.contact) { call ->
|
||||||
chatModel.callCommand.add(WCallCommand.End)
|
withBGApi { chatModel.callManager.endCall(call) }
|
||||||
chatModel.activeCall.value = null
|
|
||||||
chatModel.showCallView.value = false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
is CR.ContactSwitch ->
|
is CR.ContactSwitch ->
|
||||||
|
@ -1,16 +1,21 @@
|
|||||||
package chat.simplex.common.platform
|
package chat.simplex.common.platform
|
||||||
|
|
||||||
|
import chat.simplex.common.model.ChatId
|
||||||
import chat.simplex.common.model.NotificationsMode
|
import chat.simplex.common.model.NotificationsMode
|
||||||
|
|
||||||
interface PlatformInterface {
|
interface PlatformInterface {
|
||||||
suspend fun androidServiceStart() {}
|
suspend fun androidServiceStart() {}
|
||||||
fun androidServiceSafeStop() {}
|
fun androidServiceSafeStop() {}
|
||||||
|
fun androidCallServiceSafeStop() {}
|
||||||
fun androidNotificationsModeChanged(mode: NotificationsMode) {}
|
fun androidNotificationsModeChanged(mode: NotificationsMode) {}
|
||||||
fun androidChatStartedAfterBeingOff() {}
|
fun androidChatStartedAfterBeingOff() {}
|
||||||
fun androidChatStopped() {}
|
fun androidChatStopped() {}
|
||||||
fun androidChatInitializedAndStarted() {}
|
fun androidChatInitializedAndStarted() {}
|
||||||
fun androidIsBackgroundCallAllowed(): Boolean = true
|
fun androidIsBackgroundCallAllowed(): Boolean = true
|
||||||
fun androidSetNightModeIfSupported() {}
|
fun androidSetNightModeIfSupported() {}
|
||||||
|
fun androidStartCallActivity(acceptCall: Boolean, remoteHostId: Long? = null, chatId: ChatId? = null) {}
|
||||||
|
fun androidPictureInPictureAllowed(): Boolean = true
|
||||||
|
fun androidCallEnded() {}
|
||||||
suspend fun androidAskToAllowBackgroundCalls(): Boolean = true
|
suspend fun androidAskToAllowBackgroundCalls(): Boolean = true
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
package chat.simplex.common.views.call
|
package chat.simplex.common.views.call
|
||||||
|
|
||||||
import chat.simplex.common.model.ChatModel
|
import chat.simplex.common.model.*
|
||||||
import chat.simplex.common.platform.*
|
import chat.simplex.common.platform.*
|
||||||
import chat.simplex.common.views.helpers.withBGApi
|
import chat.simplex.common.views.helpers.withBGApi
|
||||||
import kotlinx.datetime.Clock
|
import kotlinx.datetime.Clock
|
||||||
@ -23,27 +23,29 @@ class CallManager(val chatModel: ChatModel) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun acceptIncomingCall(invitation: RcvCallInvitation) {
|
fun acceptIncomingCall(invitation: RcvCallInvitation) = withBGApi {
|
||||||
val call = chatModel.activeCall.value
|
val call = chatModel.activeCall.value
|
||||||
if (call == null) {
|
val contactInfo = chatModel.controller.apiContactInfo(invitation.remoteHostId, invitation.contact.contactId)
|
||||||
justAcceptIncomingCall(invitation = invitation)
|
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)
|
||||||
} else {
|
} else {
|
||||||
withBGApi {
|
chatModel.switchingCall.value = true
|
||||||
chatModel.switchingCall.value = true
|
try {
|
||||||
try {
|
endCall(call = call)
|
||||||
endCall(call = call)
|
justAcceptIncomingCall(invitation = invitation, profile)
|
||||||
justAcceptIncomingCall(invitation = invitation)
|
} finally {
|
||||||
} finally {
|
chatModel.switchingCall.value = false
|
||||||
chatModel.switchingCall.value = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun justAcceptIncomingCall(invitation: RcvCallInvitation) {
|
private fun justAcceptIncomingCall(invitation: RcvCallInvitation, userProfile: Profile) {
|
||||||
with (chatModel) {
|
with (chatModel) {
|
||||||
activeCall.value = Call(
|
activeCall.value = Call(
|
||||||
remoteHostId = invitation.remoteHostId,
|
remoteHostId = invitation.remoteHostId,
|
||||||
|
userProfile = userProfile,
|
||||||
contact = invitation.contact,
|
contact = invitation.contact,
|
||||||
callState = CallState.InvitationAccepted,
|
callState = CallState.InvitationAccepted,
|
||||||
localMedia = invitation.callType.media,
|
localMedia = invitation.callType.media,
|
||||||
@ -68,17 +70,23 @@ class CallManager(val chatModel: ChatModel) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun endCall(call: Call) {
|
suspend fun endCall(call: Call) {
|
||||||
with (chatModel) {
|
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()
|
||||||
|
}
|
||||||
if (call.callState == CallState.Ended) {
|
if (call.callState == CallState.Ended) {
|
||||||
Log.d(TAG, "CallManager.endCall: call ended")
|
Log.d(TAG, "CallManager.endCall: call ended")
|
||||||
activeCall.value = null
|
|
||||||
showCallView.value = false
|
|
||||||
} else {
|
} else {
|
||||||
Log.d(TAG, "CallManager.endCall: ending call...")
|
Log.d(TAG, "CallManager.endCall: ending call...")
|
||||||
callCommand.add(WCallCommand.End)
|
//callCommand.add(WCallCommand.End)
|
||||||
showCallView.value = false
|
|
||||||
controller.apiEndCall(call.remoteHostId, call.contact)
|
controller.apiEndCall(call.remoteHostId, call.contact)
|
||||||
activeCall.value = null
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -7,11 +7,11 @@ import kotlinx.datetime.Instant
|
|||||||
import kotlinx.serialization.SerialName
|
import kotlinx.serialization.SerialName
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import java.net.URI
|
import java.net.URI
|
||||||
import java.util.*
|
|
||||||
import kotlin.collections.ArrayList
|
import kotlin.collections.ArrayList
|
||||||
|
|
||||||
data class Call(
|
data class Call(
|
||||||
val remoteHostId: Long?,
|
val remoteHostId: Long?,
|
||||||
|
val userProfile: Profile,
|
||||||
val contact: Contact,
|
val contact: Contact,
|
||||||
val callState: CallState,
|
val callState: CallState,
|
||||||
val localMedia: CallMediaType,
|
val localMedia: CallMediaType,
|
||||||
@ -23,7 +23,7 @@ data class Call(
|
|||||||
val soundSpeaker: Boolean = localMedia == CallMediaType.Video,
|
val soundSpeaker: Boolean = localMedia == CallMediaType.Video,
|
||||||
var localCamera: VideoCamera = VideoCamera.User,
|
var localCamera: VideoCamera = VideoCamera.User,
|
||||||
val connectionInfo: ConnectionInfo? = null,
|
val connectionInfo: ConnectionInfo? = null,
|
||||||
var connectedAt: Instant? = null
|
var connectedAt: Instant? = null,
|
||||||
) {
|
) {
|
||||||
val encrypted: Boolean get() = localEncrypted && sharedKey != null
|
val encrypted: Boolean get() = localEncrypted && sharedKey != null
|
||||||
val localEncrypted: Boolean get() = localCapabilities?.encryption ?: false
|
val localEncrypted: Boolean get() = localCapabilities?.encryption ?: false
|
||||||
@ -36,6 +36,9 @@ data class Call(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val hasMedia: Boolean get() = callState == CallState.OfferSent || callState == CallState.Negotiated || callState == CallState.Connected
|
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 {
|
enum class CallState {
|
||||||
@ -75,6 +78,7 @@ sealed class WCallCommand {
|
|||||||
@Serializable @SerialName("media") data class Media(val media: CallMediaType, val enable: Boolean): 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("camera") data class Camera(val camera: VideoCamera): WCallCommand()
|
||||||
@Serializable @SerialName("description") data class Description(val state: String, val description: String): 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()
|
@Serializable @SerialName("end") object End: WCallCommand()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -167,6 +171,13 @@ enum class VideoCamera {
|
|||||||
val flipped: VideoCamera get() = if (this == User) Environment else User
|
val flipped: VideoCamera get() = if (this == User) Environment else User
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
enum class LayoutType {
|
||||||
|
@SerialName("default") Default,
|
||||||
|
@SerialName("localVideo") LocalVideo,
|
||||||
|
@SerialName("remoteVideo") RemoteVideo
|
||||||
|
}
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class ConnectionState(
|
data class ConnectionState(
|
||||||
val connectionState: String,
|
val connectionState: String,
|
||||||
|
@ -301,7 +301,9 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId:
|
|||||||
withBGApi {
|
withBGApi {
|
||||||
val cInfo = chat.chatInfo
|
val cInfo = chat.chatInfo
|
||||||
if (cInfo is ChatInfo.Direct) {
|
if (cInfo is ChatInfo.Direct) {
|
||||||
chatModel.activeCall.value = Call(remoteHostId = chatRh, contact = cInfo.contact, callState = CallState.WaitCapabilities, localMedia = media)
|
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.showCallView.value = true
|
chatModel.showCallView.value = true
|
||||||
chatModel.callCommand.add(WCallCommand.Capabilities(media))
|
chatModel.callCommand.add(WCallCommand.Capabilities(media))
|
||||||
}
|
}
|
||||||
@ -673,7 +675,7 @@ fun ChatInfoToolbar(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (activeCall?.contact?.id == chat.id) {
|
} else if (activeCall?.contact?.id == chat.id && appPlatform.isDesktop) {
|
||||||
barButtons.add {
|
barButtons.add {
|
||||||
val call = remember { chatModel.activeCall }.value
|
val call = remember { chatModel.activeCall }.value
|
||||||
val connectedAt = call?.connectedAt
|
val connectedAt = call?.connectedAt
|
||||||
|
@ -424,69 +424,47 @@ private fun MemberVerifiedShield() {
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun DropDownMenuForMember(rhId: Long?, member: GroupMember, groupInfo: GroupInfo, showMenu: MutableState<Boolean>) {
|
private fun DropDownMenuForMember(rhId: Long?, member: GroupMember, groupInfo: GroupInfo, showMenu: MutableState<Boolean>) {
|
||||||
// revert from this:
|
if (groupInfo.membership.memberRole >= GroupMemberRole.Admin) {
|
||||||
DefaultDropdownMenu(showMenu) {
|
val canBlockForAll = member.canBlockForAll(groupInfo)
|
||||||
if (member.canBeRemoved(groupInfo)) {
|
val canRemove = member.canBeRemoved(groupInfo)
|
||||||
ItemAction(stringResource(MR.strings.remove_member_button), painterResource(MR.images.ic_delete), color = MaterialTheme.colors.error, onClick = {
|
if (canBlockForAll || canRemove) {
|
||||||
removeMemberAlert(rhId, groupInfo, member)
|
DefaultDropdownMenu(showMenu) {
|
||||||
showMenu.value = false
|
if (canBlockForAll) {
|
||||||
})
|
if (member.blockedByAdmin) {
|
||||||
|
ItemAction(stringResource(MR.strings.unblock_for_all), painterResource(MR.images.ic_do_not_touch), onClick = {
|
||||||
|
unblockForAllAlert(rhId, groupInfo, member)
|
||||||
|
showMenu.value = false
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
ItemAction(stringResource(MR.strings.block_for_all), painterResource(MR.images.ic_back_hand), color = MaterialTheme.colors.error, onClick = {
|
||||||
|
blockForAllAlert(rhId, groupInfo, member)
|
||||||
|
showMenu.value = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (canRemove) {
|
||||||
|
ItemAction(stringResource(MR.strings.remove_member_button), painterResource(MR.images.ic_delete), color = MaterialTheme.colors.error, onClick = {
|
||||||
|
removeMemberAlert(rhId, groupInfo, member)
|
||||||
|
showMenu.value = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (member.memberSettings.showMessages) {
|
} else if (!member.blockedByAdmin) {
|
||||||
ItemAction(stringResource(MR.strings.block_member_button), painterResource(MR.images.ic_back_hand), color = MaterialTheme.colors.error, onClick = {
|
DefaultDropdownMenu(showMenu) {
|
||||||
blockMemberAlert(rhId, groupInfo, member)
|
if (member.memberSettings.showMessages) {
|
||||||
showMenu.value = false
|
ItemAction(stringResource(MR.strings.block_member_button), painterResource(MR.images.ic_back_hand), color = MaterialTheme.colors.error, onClick = {
|
||||||
})
|
blockMemberAlert(rhId, groupInfo, member)
|
||||||
} else {
|
showMenu.value = false
|
||||||
ItemAction(stringResource(MR.strings.unblock_member_button), painterResource(MR.images.ic_do_not_touch), onClick = {
|
})
|
||||||
unblockMemberAlert(rhId, groupInfo, member)
|
} else {
|
||||||
showMenu.value = false
|
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
|
@Composable
|
||||||
|
@ -387,25 +387,11 @@ fun GroupMemberInfoLayout(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// revert from this:
|
if (groupInfo.membership.memberRole >= GroupMemberRole.Admin) {
|
||||||
SectionDividerSpaced(maxBottomPadding = false)
|
AdminDestructiveSection()
|
||||||
SectionView {
|
} else {
|
||||||
if (member.memberSettings.showMessages) {
|
NonAdminBlockSection()
|
||||||
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) {
|
if (developerTools) {
|
||||||
SectionDividerSpaced()
|
SectionDividerSpaced()
|
||||||
|
@ -29,6 +29,7 @@ import chat.simplex.common.views.onboarding.WhatsNewView
|
|||||||
import chat.simplex.common.views.onboarding.shouldShowWhatsNew
|
import chat.simplex.common.views.onboarding.shouldShowWhatsNew
|
||||||
import chat.simplex.common.views.usersettings.SettingsView
|
import chat.simplex.common.views.usersettings.SettingsView
|
||||||
import chat.simplex.common.platform.*
|
import chat.simplex.common.platform.*
|
||||||
|
import chat.simplex.common.views.call.Call
|
||||||
import chat.simplex.common.views.newchat.*
|
import chat.simplex.common.views.newchat.*
|
||||||
import chat.simplex.res.MR
|
import chat.simplex.res.MR
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
@ -121,7 +122,12 @@ fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerf
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (searchText.value.text.isEmpty()) {
|
if (searchText.value.text.isEmpty()) {
|
||||||
DesktopActiveCallOverlayLayout(newChatSheetState)
|
if (appPlatform.isDesktop) {
|
||||||
|
val call = remember { chatModel.activeCall }.value
|
||||||
|
if (call != null) {
|
||||||
|
ActiveCallInteractiveArea(call, newChatSheetState)
|
||||||
|
}
|
||||||
|
}
|
||||||
// TODO disable this button and sheet for the duration of the switch
|
// TODO disable this button and sheet for the duration of the switch
|
||||||
tryOrShowError("NewChatSheet", error = {}) {
|
tryOrShowError("NewChatSheet", error = {}) {
|
||||||
NewChatSheet(chatModel, newChatSheetState, stopped, hideNewChatSheet)
|
NewChatSheet(chatModel, newChatSheetState, stopped, hideNewChatSheet)
|
||||||
@ -314,7 +320,7 @@ private fun ToggleFilterDisabledButton() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
expect fun DesktopActiveCallOverlayLayout(newChatSheetState: MutableStateFlow<AnimatedViewState>)
|
expect fun ActiveCallInteractiveArea(call: Call, newChatSheetState: MutableStateFlow<AnimatedViewState>)
|
||||||
|
|
||||||
fun connectIfOpenedViaUri(rhId: Long?, uri: URI, chatModel: ChatModel) {
|
fun connectIfOpenedViaUri(rhId: Long?, uri: URI, chatModel: ChatModel) {
|
||||||
Log.d(TAG, "connectIfOpenedViaUri: opened via link")
|
Log.d(TAG, "connectIfOpenedViaUri: opened via link")
|
||||||
|
@ -85,7 +85,7 @@ private fun ShareListToolbar(chatModel: ChatModel, userPickerState: MutableState
|
|||||||
userPickerState.value = AnimatedViewState.VISIBLE
|
userPickerState.value = AnimatedViewState.VISIBLE
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else -> NavigationButtonBack { chatModel.sharedContent.value = null }
|
else -> NavigationButtonBack(onButtonClicked = { chatModel.sharedContent.value = null })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (chatModel.chats.size >= 8) {
|
if (chatModel.chats.size >= 8) {
|
||||||
@ -143,7 +143,7 @@ private fun ShareList(chatModel: ChatModel, search: String) {
|
|||||||
}
|
}
|
||||||
val chats by remember(search) {
|
val chats by remember(search) {
|
||||||
derivedStateOf {
|
derivedStateOf {
|
||||||
if (search.isEmpty()) chatModel.chats.filter { it.chatInfo.ready } else chatModel.chats.filter { it.chatInfo.ready }.filter(filter)
|
if (search.isEmpty()) chatModel.chats.toList().filter { it.chatInfo.ready } else chatModel.chats.toList().filter { it.chatInfo.ready }.filter(filter)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
|
@ -18,7 +18,7 @@ import chat.simplex.res.MR
|
|||||||
import dev.icerock.moko.resources.compose.painterResource
|
import dev.icerock.moko.resources.compose.painterResource
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun CloseSheetBar(close: (() -> Unit)?, showClose: Boolean = true, endButtons: @Composable RowScope.() -> Unit = {}) {
|
fun CloseSheetBar(close: (() -> Unit)?, showClose: Boolean = true, tintColor: Color = if (close != null) MaterialTheme.colors.primary else MaterialTheme.colors.secondary, endButtons: @Composable RowScope.() -> Unit = {}) {
|
||||||
Column(
|
Column(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@ -35,7 +35,7 @@ fun CloseSheetBar(close: (() -> Unit)?, showClose: Boolean = true, endButtons: @
|
|||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
if (showClose) {
|
if (showClose) {
|
||||||
NavigationButtonBack(onButtonClicked = close)
|
NavigationButtonBack(tintColor = tintColor, onButtonClicked = close)
|
||||||
} else {
|
} else {
|
||||||
Spacer(Modifier)
|
Spacer(Modifier)
|
||||||
}
|
}
|
||||||
|
@ -44,10 +44,10 @@ fun DefaultTopAppBar(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun NavigationButtonBack(onButtonClicked: (() -> Unit)?) {
|
fun NavigationButtonBack(onButtonClicked: (() -> Unit)?, tintColor: Color = if (onButtonClicked != null) MaterialTheme.colors.primary else MaterialTheme.colors.secondary) {
|
||||||
IconButton(onButtonClicked ?: {}, enabled = onButtonClicked != null) {
|
IconButton(onButtonClicked ?: {}, enabled = onButtonClicked != null) {
|
||||||
Icon(
|
Icon(
|
||||||
painterResource(MR.images.ic_arrow_back_ios_new), stringResource(MR.strings.back), tint = if (onButtonClicked != null) MaterialTheme.colors.primary else MaterialTheme.colors.secondary
|
painterResource(MR.images.ic_arrow_back_ios_new), stringResource(MR.strings.back), tint = tintColor
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -29,7 +29,7 @@ fun ModalView(
|
|||||||
}
|
}
|
||||||
Surface(Modifier.fillMaxSize(), contentColor = LocalContentColor.current) {
|
Surface(Modifier.fillMaxSize(), contentColor = LocalContentColor.current) {
|
||||||
Column(if (background != MaterialTheme.colors.background) Modifier.background(background) else Modifier.themedBackground()) {
|
Column(if (background != MaterialTheme.colors.background) Modifier.background(background) else Modifier.themedBackground()) {
|
||||||
CloseSheetBar(close, showClose, endButtons)
|
CloseSheetBar(close, showClose, endButtons = endButtons)
|
||||||
Box(modifier) { content() }
|
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
|
// Android's default implementation that was used before multiplatform, adds non-needed characters at the end of string
|
||||||
// which can be bypassed by:
|
// which can be bypassed by:
|
||||||
// fun String.toByteArrayFromBase64(): ByteArray = Base64.getDecoder().decode(this.trimEnd { it == '\n' || it == ' ' })
|
// fun String.toByteArrayFromBase64(): ByteArray = Base64.getMimeDecoder().decode(this.trimEnd { it == '\n' || it == ' ' })
|
||||||
expect fun String.toByteArrayFromBase64ForPassphrase(): ByteArray
|
expect fun String.toByteArrayFromBase64ForPassphrase(): ByteArray
|
||||||
|
|
||||||
val LongRange.Companion.saver
|
val LongRange.Companion.saver
|
||||||
|
@ -177,6 +177,9 @@
|
|||||||
<!-- SimpleX Chat foreground Service -->
|
<!-- SimpleX Chat foreground Service -->
|
||||||
<string name="simplex_service_notification_title">SimpleX Chat service</string>
|
<string name="simplex_service_notification_title">SimpleX Chat service</string>
|
||||||
<string name="simplex_service_notification_text">Receiving messages…</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>
|
<string name="hide_notification">Hide</string>
|
||||||
|
|
||||||
<!-- Notification channels -->
|
<!-- Notification channels -->
|
||||||
@ -801,6 +804,10 @@
|
|||||||
<string name="callstate_connected">connected</string>
|
<string name="callstate_connected">connected</string>
|
||||||
<string name="callstate_ended">ended</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 -->
|
<!-- SimpleXInfo -->
|
||||||
<string name="next_generation_of_private_messaging">The next generation of private messaging</string>
|
<string name="next_generation_of_private_messaging">The next generation of private messaging</string>
|
||||||
<string name="privacy_redefined">Privacy redefined</string>
|
<string name="privacy_redefined">Privacy redefined</string>
|
||||||
|
@ -8,6 +8,7 @@
|
|||||||
<body>
|
<body>
|
||||||
<video
|
<video
|
||||||
id="remote-video-stream"
|
id="remote-video-stream"
|
||||||
|
class="inline"
|
||||||
autoplay
|
autoplay
|
||||||
playsinline
|
playsinline
|
||||||
poster="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAQAAAAnOwc2AAAAEUlEQVR42mNk+M+AARiHsiAAcCIKAYwFoQ8AAAAASUVORK5CYII="
|
poster="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAQAAAAnOwc2AAAAEUlEQVR42mNk+M+AARiHsiAAcCIKAYwFoQ8AAAAASUVORK5CYII="
|
||||||
@ -15,6 +16,7 @@
|
|||||||
></video>
|
></video>
|
||||||
<video
|
<video
|
||||||
id="local-video-stream"
|
id="local-video-stream"
|
||||||
|
class="inline"
|
||||||
muted
|
muted
|
||||||
autoplay
|
autoplay
|
||||||
playsinline
|
playsinline
|
||||||
|
@ -5,14 +5,14 @@ body {
|
|||||||
background-color: black;
|
background-color: black;
|
||||||
}
|
}
|
||||||
|
|
||||||
#remote-video-stream {
|
#remote-video-stream.inline {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
}
|
}
|
||||||
|
|
||||||
#local-video-stream {
|
#local-video-stream.inline {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
width: 30%;
|
width: 30%;
|
||||||
max-width: 30%;
|
max-width: 30%;
|
||||||
@ -23,6 +23,20 @@ body {
|
|||||||
right: 0;
|
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 {
|
*::-webkit-media-controls {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
-webkit-appearance: none !important;
|
-webkit-appearance: none !important;
|
||||||
|
@ -11,6 +11,12 @@ var VideoCamera;
|
|||||||
VideoCamera["User"] = "user";
|
VideoCamera["User"] = "user";
|
||||||
VideoCamera["Environment"] = "environment";
|
VideoCamera["Environment"] = "environment";
|
||||||
})(VideoCamera || (VideoCamera = {}));
|
})(VideoCamera || (VideoCamera = {}));
|
||||||
|
var LayoutType;
|
||||||
|
(function (LayoutType) {
|
||||||
|
LayoutType["Default"] = "default";
|
||||||
|
LayoutType["LocalVideo"] = "localVideo";
|
||||||
|
LayoutType["RemoteVideo"] = "remoteVideo";
|
||||||
|
})(LayoutType || (LayoutType = {}));
|
||||||
// for debugging
|
// for debugging
|
||||||
// var sendMessageToNative = ({resp}: WVApiMessage) => console.log(JSON.stringify({command: resp}))
|
// var sendMessageToNative = ({resp}: WVApiMessage) => console.log(JSON.stringify({command: resp}))
|
||||||
var sendMessageToNative = (msg) => console.log(JSON.stringify(msg));
|
var sendMessageToNative = (msg) => console.log(JSON.stringify(msg));
|
||||||
@ -319,6 +325,10 @@ const processCommand = (function () {
|
|||||||
localizedDescription = command.description;
|
localizedDescription = command.description;
|
||||||
resp = { type: "ok" };
|
resp = { type: "ok" };
|
||||||
break;
|
break;
|
||||||
|
case "layout":
|
||||||
|
changeLayout(command.layout);
|
||||||
|
resp = { type: "ok" };
|
||||||
|
break;
|
||||||
case "end":
|
case "end":
|
||||||
endCall();
|
endCall();
|
||||||
resp = { type: "ok" };
|
resp = { type: "ok" };
|
||||||
@ -607,6 +617,28 @@ function toggleMedia(s, media) {
|
|||||||
}
|
}
|
||||||
return res;
|
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)
|
// Cryptography function - it is loaded both in the main window and in worker context (if the worker is used)
|
||||||
function callCryptoFunction() {
|
function callCryptoFunction() {
|
||||||
const initialPlainTextRequired = {
|
const initialPlainTextRequired = {
|
||||||
|
@ -9,6 +9,7 @@
|
|||||||
<body>
|
<body>
|
||||||
<video
|
<video
|
||||||
id="remote-video-stream"
|
id="remote-video-stream"
|
||||||
|
class="inline"
|
||||||
autoplay
|
autoplay
|
||||||
playsinline
|
playsinline
|
||||||
poster="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAQAAAAnOwc2AAAAEUlEQVR42mNk+M+AARiHsiAAcCIKAYwFoQ8AAAAASUVORK5CYII="
|
poster="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAQAAAAnOwc2AAAAEUlEQVR42mNk+M+AARiHsiAAcCIKAYwFoQ8AAAAASUVORK5CYII="
|
||||||
@ -16,6 +17,7 @@
|
|||||||
></video>
|
></video>
|
||||||
<video
|
<video
|
||||||
id="local-video-stream"
|
id="local-video-stream"
|
||||||
|
class="inline"
|
||||||
muted
|
muted
|
||||||
autoplay
|
autoplay
|
||||||
playsinline
|
playsinline
|
||||||
|
@ -5,14 +5,14 @@ body {
|
|||||||
background-color: black;
|
background-color: black;
|
||||||
}
|
}
|
||||||
|
|
||||||
#remote-video-stream {
|
#remote-video-stream.inline {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
}
|
}
|
||||||
|
|
||||||
#local-video-stream {
|
#local-video-stream.inline {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
width: 20%;
|
width: 20%;
|
||||||
max-width: 20%;
|
max-width: 20%;
|
||||||
@ -23,6 +23,20 @@ body {
|
|||||||
right: 0;
|
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 {
|
*::-webkit-media-controls {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
-webkit-appearance: none !important;
|
-webkit-appearance: none !important;
|
||||||
|
@ -17,14 +17,14 @@ import javax.imageio.stream.MemoryCacheImageOutputStream
|
|||||||
import kotlin.math.sqrt
|
import kotlin.math.sqrt
|
||||||
|
|
||||||
private fun errorBitmap(): ImageBitmap =
|
private fun errorBitmap(): ImageBitmap =
|
||||||
ImageIO.read(ByteArrayInputStream(Base64.getDecoder().decode("iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAAXNSR0IArs4c6QAAAKVJREFUeF7t1kENACEUQ0FQhnVQ9lfGO+xggITQdvbMzArPey+8fa3tAfwAEdABZQspQStgBssEcgAIkSAJkiAJljtEgiRIgmUCSZAESZAESZAEyx0iQRIkwTKBJEiCv5fgvTd1wDmn7QAP4AeIgA4oW0gJWgEzWCZwbQ7gAA7ggLKFOIADOKBMIAeAEAmSIAmSYLlDJEiCJFgmkARJkARJ8N8S/ADTZUewBvnTOQAAAABJRU5ErkJggg=="))).toComposeImageBitmap()
|
ImageIO.read(ByteArrayInputStream(Base64.getMimeDecoder().decode("iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAAXNSR0IArs4c6QAAAKVJREFUeF7t1kENACEUQ0FQhnVQ9lfGO+xggITQdvbMzArPey+8fa3tAfwAEdABZQspQStgBssEcgAIkSAJkiAJljtEgiRIgmUCSZAESZAESZAEyx0iQRIkwTKBJEiCv5fgvTd1wDmn7QAP4AeIgA4oW0gJWgEzWCZwbQ7gAA7ggLKFOIADOKBMIAeAEAmSIAmSYLlDJEiCJFgmkARJkARJ8N8S/ADTZUewBvnTOQAAAABJRU5ErkJggg=="))).toComposeImageBitmap()
|
||||||
|
|
||||||
actual fun base64ToBitmap(base64ImageString: String): ImageBitmap {
|
actual fun base64ToBitmap(base64ImageString: String): ImageBitmap {
|
||||||
val imageString = base64ImageString
|
val imageString = base64ImageString
|
||||||
.removePrefix("data:image/png;base64,")
|
.removePrefix("data:image/png;base64,")
|
||||||
.removePrefix("data:image/jpg;base64,")
|
.removePrefix("data:image/jpg;base64,")
|
||||||
return try {
|
return try {
|
||||||
ImageIO.read(ByteArrayInputStream(Base64.getDecoder().decode(imageString))).toComposeImageBitmap()
|
ImageIO.read(ByteArrayInputStream(Base64.getMimeDecoder().decode(imageString))).toComposeImageBitmap()
|
||||||
} catch (e: IOException) {
|
} catch (e: IOException) {
|
||||||
Log.e(TAG, "base64ToBitmap error: $e")
|
Log.e(TAG, "base64ToBitmap error: $e")
|
||||||
errorBitmap()
|
errorBitmap()
|
||||||
@ -77,7 +77,7 @@ actual fun compressImageStr(bitmap: ImageBitmap): String {
|
|||||||
return try {
|
return try {
|
||||||
val encoded = Base64.getEncoder().encodeToString(compressImageData(bitmap, usePng).toByteArray())
|
val encoded = Base64.getEncoder().encodeToString(compressImageData(bitmap, usePng).toByteArray())
|
||||||
"data:image/$ext;base64,$encoded"
|
"data:image/$ext;base64,$encoded"
|
||||||
} catch (e: IOException) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "resizeImageToStrSize error: $e")
|
Log.e(TAG, "resizeImageToStrSize error: $e")
|
||||||
throw e
|
throw e
|
||||||
}
|
}
|
||||||
|
@ -146,8 +146,21 @@ private fun SendStateUpdates() {
|
|||||||
@Composable
|
@Composable
|
||||||
fun WebRTCController(callCommand: SnapshotStateList<WCallCommand>, onResponse: (WVAPIMessage) -> Unit) {
|
fun WebRTCController(callCommand: SnapshotStateList<WCallCommand>, onResponse: (WVAPIMessage) -> Unit) {
|
||||||
val uriHandler = LocalUriHandler.current
|
val uriHandler = LocalUriHandler.current
|
||||||
|
val endCall = {
|
||||||
|
val call = chatModel.activeCall.value
|
||||||
|
if (call != null) withBGApi { chatModel.callManager.endCall(call) }
|
||||||
|
}
|
||||||
val server = remember {
|
val server = remember {
|
||||||
uriHandler.openUri("http://${SERVER_HOST}:$SERVER_PORT/simplex/call/")
|
try {
|
||||||
|
uriHandler.openUri("http://${SERVER_HOST}:$SERVER_PORT/simplex/call/")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Unable to open browser: ${e.stackTraceToString()}")
|
||||||
|
AlertManager.shared.showAlertMsg(
|
||||||
|
title = generalGetString(MR.strings.unable_to_open_browser_title),
|
||||||
|
text = generalGetString(MR.strings.unable_to_open_browser_desc)
|
||||||
|
)
|
||||||
|
endCall()
|
||||||
|
}
|
||||||
startServer(onResponse)
|
startServer(onResponse)
|
||||||
}
|
}
|
||||||
fun processCommand(cmd: WCallCommand) {
|
fun processCommand(cmd: WCallCommand) {
|
||||||
|
@ -3,7 +3,6 @@ package chat.simplex.common.views.chatlist
|
|||||||
import androidx.compose.foundation.*
|
import androidx.compose.foundation.*
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material.Icon
|
import androidx.compose.material.Icon
|
||||||
import androidx.compose.material.MaterialTheme
|
import androidx.compose.material.MaterialTheme
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
@ -13,6 +12,7 @@ import androidx.compose.ui.graphics.Color
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import chat.simplex.common.platform.*
|
import chat.simplex.common.platform.*
|
||||||
import chat.simplex.common.ui.theme.*
|
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.call.CallMediaType
|
||||||
import chat.simplex.common.views.chat.item.ItemAction
|
import chat.simplex.common.views.chat.item.ItemAction
|
||||||
import chat.simplex.common.views.helpers.*
|
import chat.simplex.common.views.helpers.*
|
||||||
@ -22,10 +22,9 @@ import dev.icerock.moko.resources.compose.stringResource
|
|||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
actual fun DesktopActiveCallOverlayLayout(newChatSheetState: MutableStateFlow<AnimatedViewState>) {
|
actual fun ActiveCallInteractiveArea(call: Call, newChatSheetState: MutableStateFlow<AnimatedViewState>) {
|
||||||
val call = remember { chatModel.activeCall}.value
|
// if (call.callState == CallState.Connected && !newChatSheetState.collectAsState().value.isVisible()) {
|
||||||
// if (call?.callState == CallState.Connected && !newChatSheetState.collectAsState().value.isVisible()) {
|
if (!newChatSheetState.collectAsState().value.isVisible()) {
|
||||||
if (call != null && !newChatSheetState.collectAsState().value.isVisible()) {
|
|
||||||
val showMenu = remember { mutableStateOf(false) }
|
val showMenu = remember { mutableStateOf(false) }
|
||||||
val media = call.peerMedia ?: call.localMedia
|
val media = call.peerMedia ?: call.localMedia
|
||||||
CompositionLocalProvider(
|
CompositionLocalProvider(
|
||||||
|
@ -12,7 +12,7 @@ constraints: zip +disable-bzip2 +disable-zstd
|
|||||||
source-repository-package
|
source-repository-package
|
||||||
type: git
|
type: git
|
||||||
location: https://github.com/simplex-chat/simplexmq.git
|
location: https://github.com/simplex-chat/simplexmq.git
|
||||||
tag: 32c94df040b7921584a4685a814818daec3bf209
|
tag: 050a921fbbdf21690cab7765bf6237fdc5a419cb
|
||||||
|
|
||||||
source-repository-package
|
source-repository-package
|
||||||
type: git
|
type: git
|
||||||
|
@ -24,7 +24,7 @@ _Please note_: when you change the servers in the app configuration, it only aff
|
|||||||
- Semi-automatic deployment:
|
- Semi-automatic deployment:
|
||||||
- [Offical installation script](https://github.com/simplex-chat/simplexmq#using-installation-script)
|
- [Offical installation script](https://github.com/simplex-chat/simplexmq#using-installation-script)
|
||||||
- [Docker container](https://github.com/simplex-chat/simplexmq#using-docker)
|
- [Docker container](https://github.com/simplex-chat/simplexmq#using-docker)
|
||||||
- [Linode StackScript](https://github.com/simplex-chat/simplexmq#deploy-smp-server-on-linode)
|
- [Linode Marketplace](https://www.linode.com/marketplace/apps/simplex-chat/simplex-chat/)
|
||||||
|
|
||||||
Manual installation requires some preliminary actions:
|
Manual installation requires some preliminary actions:
|
||||||
|
|
||||||
@ -33,7 +33,7 @@ Manual installation requires some preliminary actions:
|
|||||||
- Using offical binaries:
|
- Using offical binaries:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
curl -L https://github.com/simplex-chat/simplexmq/releases/latest/download/smp-server-ubuntu-20_04-x86-64 -o /usr/local/bin/smp-server
|
curl -L https://github.com/simplex-chat/simplexmq/releases/latest/download/smp-server-ubuntu-20_04-x86-64 -o /usr/local/bin/smp-server && chmod +x /usr/local/bin/smp-server
|
||||||
```
|
```
|
||||||
|
|
||||||
- Compiling from source:
|
- Compiling from source:
|
||||||
@ -417,6 +417,63 @@ To import `csv` to `Grafana` one should:
|
|||||||
|
|
||||||
For further documentation, see: [CSV Data Source for Grafana - Documentation](https://grafana.github.io/grafana-csv-datasource/)
|
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
|
### 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.
|
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.
|
||||||
|
@ -24,6 +24,7 @@ XFTP is a new file transfer protocol focussed on meta-data protection - it is ba
|
|||||||
- Semi-automatic deployment:
|
- Semi-automatic deployment:
|
||||||
- [Offical installation script](https://github.com/simplex-chat/simplexmq#using-installation-script)
|
- [Offical installation script](https://github.com/simplex-chat/simplexmq#using-installation-script)
|
||||||
- [Docker container](https://github.com/simplex-chat/simplexmq#using-docker)
|
- [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:
|
Manual installation requires some preliminary actions:
|
||||||
|
|
||||||
@ -32,7 +33,7 @@ Manual installation requires some preliminary actions:
|
|||||||
- Using offical binaries:
|
- Using offical binaries:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
curl -L https://github.com/simplex-chat/simplexmq/releases/latest/download/xftp-server-ubuntu-20_04-x86-64 -o /usr/local/bin/xftp-server
|
curl -L https://github.com/simplex-chat/simplexmq/releases/latest/download/xftp-server-ubuntu-20_04-x86-64 -o /usr/local/bin/xftp-server && chmod +x /usr/local/bin/xftp-server
|
||||||
```
|
```
|
||||||
|
|
||||||
- Compiling from source:
|
- Compiling from source:
|
||||||
@ -418,6 +419,65 @@ To import `csv` to `Grafana` one should:
|
|||||||
|
|
||||||
For further documentation, see: [CSV Data Source for Grafana - Documentation](https://grafana.github.io/grafana-csv-datasource/)
|
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
|
### 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).
|
Please see: [SMP Server: Configuring the app to use the server](./SERVER.md#configuring-the-app-to-use-the-server).
|
||||||
|
@ -82,7 +82,7 @@ def RatchetInitAlicePQ2HE(state, SK, bob_dh_public_key, shared_hka, shared_nhkb,
|
|||||||
state.PQRs = GENERATE_PQKEM()
|
state.PQRs = GENERATE_PQKEM()
|
||||||
state.PQRr = bob_pq_kem_encapsulation_key
|
state.PQRr = bob_pq_kem_encapsulation_key
|
||||||
state.PQRss = random // shared secret for KEM
|
state.PQRss = random // shared secret for KEM
|
||||||
state.PQRenc_ss = PQKEM-ENC(state.PQRr.encaps, state.PQRss) // encapsulated additional shared secret
|
state.PQRct = PQKEM-ENC(state.PQRr, state.PQRss) // encapsulated additional shared secret
|
||||||
// above added for KEM
|
// above added for KEM
|
||||||
// below augments DH key agreement with PQ shared secret
|
// 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)
|
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.PQRs = bob_pq_kem_key_pair
|
||||||
state.PQRr = None
|
state.PQRr = None
|
||||||
state.PQRss = None
|
state.PQRss = None
|
||||||
state.PQRenc_ss = None
|
state.PQRct = None
|
||||||
// above added for KEM
|
// above added for KEM
|
||||||
state.RK = SK
|
state.RK = SK
|
||||||
state.CKs = None
|
state.CKs = None
|
||||||
@ -132,10 +132,10 @@ def RatchetEncryptPQ2HE(state, plaintext, AD):
|
|||||||
// encapsulation key from PQRs and encapsulated shared secret is added to header
|
// encapsulation key from PQRs and encapsulated shared secret is added to header
|
||||||
header = HEADER_PQ2(
|
header = HEADER_PQ2(
|
||||||
dh = state.DHRs.public,
|
dh = state.DHRs.public,
|
||||||
|
kem = state.PQRs.public, // added for KEM #2
|
||||||
|
ct = state.PQRct // added for KEM #1
|
||||||
pn = state.PN,
|
pn = state.PN,
|
||||||
n = state.Ns,
|
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)
|
enc_header = HENCRYPT(state.HKs, header)
|
||||||
state.Ns += 1
|
state.Ns += 1
|
||||||
@ -162,6 +162,16 @@ def RatchetDecryptPQ2HE(state, enc_header, ciphertext, AD):
|
|||||||
state.Nr += 1
|
state.Nr += 1
|
||||||
return DECRYPT(mk, ciphertext, CONCAT(AD, enc_header))
|
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):
|
def DHRatchetPQ2HE(state, header):
|
||||||
state.PN = state.Ns
|
state.PN = state.Ns
|
||||||
state.Ns = 0
|
state.Ns = 0
|
||||||
@ -170,16 +180,16 @@ def DHRatchetPQ2HE(state, header):
|
|||||||
state.HKr = state.NHKr
|
state.HKr = state.NHKr
|
||||||
state.DHRr = header.dh
|
state.DHRr = header.dh
|
||||||
// save new encapsulation key from header
|
// save new encapsulation key from header
|
||||||
state.PQRr = header.encaps
|
state.PQRr = header.kem
|
||||||
// decapsulate shared secret from header - KEM #2
|
// decapsulate shared secret from header - KEM #2
|
||||||
ss = PQKEM-DEC(state.PQRs.decaps, header.enc_ss)
|
ss = PQKEM-DEC(state.PQRs.private, header.ct)
|
||||||
// use decapsulated shared secret with receiving ratchet
|
// 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.RK, state.CKr, state.NHKr = KDF_RK_HE(state.RK, DH(state.DHRs, state.DHRr) || ss)
|
||||||
state.DHRs = GENERATE_DH()
|
state.DHRs = GENERATE_DH()
|
||||||
// below is added for KEM
|
// below is added for KEM
|
||||||
state.PQRs = GENERATE_PQKEM() // generate new PQ key pair
|
state.PQRs = GENERATE_PQKEM() // generate new PQ key pair
|
||||||
state.PQRss = random // shared secret for KEM
|
state.PQRss = random // shared secret for KEM
|
||||||
state.PQRenc_ss = PQKEM-ENC(state.PQRr.encaps, state.PQRss) // encapsulated additional shared secret KEM #1
|
state.PQRct = PQKEM-ENC(state.PQRr, state.PQRss) // encapsulated additional shared secret KEM #1
|
||||||
// above is added for KEM
|
// above is added for KEM
|
||||||
// use new shared secret with sending ratchet
|
// 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)
|
state.RK, state.CKs, state.NHKs = KDF_RK_HE(state.RK, DH(state.DHRs, state.DHRr) || state.PQRss)
|
||||||
@ -201,7 +211,7 @@ The main downside is the absense of performance-efficient implementation for aar
|
|||||||
|
|
||||||
## Implementation considerations for SimpleX Chat
|
## Implementation considerations for SimpleX Chat
|
||||||
|
|
||||||
As SimpleX Chat pads messages to a fixed size, using 16kb transport blocks, the size increase introduced by this scheme will not cause additional traffic in most cases. For large texts it may require additional messages to be sent. Similarly, for media previews it may require either reducing the preview size (and quality) or sending additional messages.
|
As SimpleX Chat pads messages to a fixed size, using 16kb transport blocks, the size increase introduced by this scheme will not cause additional traffic in most cases. For large texts it may require additional messages to be sent. Similarly, for media previews it may require either reducing the preview size (and quality), or sending additional messages, or compressing the current JSON encoding, e.g. with zstd algorithm.
|
||||||
|
|
||||||
That might be the primary reason why this scheme was not adopted by Signal, as it would have resulted in substantial traffic growth – to the best of our knowledge, Signal messages are not padded to a fixed size.
|
That might be the primary reason why this scheme was not adopted by Signal, as it would have resulted in substantial traffic growth – to the best of our knowledge, Signal messages are not padded to a fixed size.
|
||||||
|
|
||||||
@ -209,6 +219,8 @@ Sharing the initial keys in case of SimpleX Chat it is equivalent to sharing the
|
|||||||
|
|
||||||
It is possible to postpone sharing the encapsulation key until the first message from Alice (confirmation message in SMP protocol), the party sending connection request. The upside here is that the invitation link size would not increase. The downside is that the user profile shared in this confirmation will not be encrypted with PQ-resistant algorithm. To mitigate it, the hadnshake protocol can be modified to postpone sending the user profile until the second message from Alice (HELLO message in SMP protocol).
|
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
|
## 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.
|
If chosen PQ KEM proves secure against quantum computer attacks, then the proposed augmented double ratchet will also be secure against quantum computer attack, including break-in recovery property, while keeping deniability and forward secrecy, because the [same proof](https://eprint.iacr.org/2016/1013.pdf) as for double ratchet algorithm would hold here, provided KEM is secure.
|
||||||
|
@ -108,3 +108,33 @@ Sending member builds messages history starting starting from requested/remember
|
|||||||
\***
|
\***
|
||||||
|
|
||||||
Same XGrpMsgHistory protocol event could be sent by host to new members, after sending introductions.
|
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
docs/rfcs/2024-02-12-database-migration.md
Normal file
130
docs/rfcs/2024-02-12-database-migration.md
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
# Database migration and other operations
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Migrating database to another device is very complex for most people - it is multi-step and error-prone.
|
||||||
|
|
||||||
|
In addition to that, any database operation is confusing as it requires stopping chat.
|
||||||
|
|
||||||
|
## Solution
|
||||||
|
|
||||||
|
Let users migrate database to another device by scanning QR code.
|
||||||
|
|
||||||
|
Simplify other database operations by removing the need to compose multiple actions, stop chat, etc.
|
||||||
|
|
||||||
|
To support it, we already added the way to represent the file as link/QR code (by uploading file description to XFTP, and supporting "recursive" descriptions).
|
||||||
|
|
||||||
|
There will be these actions in the Database settings (no stop/start chat toggle):
|
||||||
|
|
||||||
|
- Export database.
|
||||||
|
- Import database.
|
||||||
|
- Migrate from another device.
|
||||||
|
- Set passphrase (or Change passphrase if it was set).
|
||||||
|
- Remove passphrase from device / Store passphrase on the device.
|
||||||
|
|
||||||
|
Stop chat toggle will be moved to dev tools.
|
||||||
|
|
||||||
|
Migrate to another device will be available in the top part of the settings,
|
||||||
|
|
||||||
|
|
||||||
|
### Database export
|
||||||
|
|
||||||
|
Currently, it requires these steps:
|
||||||
|
|
||||||
|
1. Open Database settings.
|
||||||
|
2. Stop chat (many users don't understand it).
|
||||||
|
3. Tap "Export database" in settings.
|
||||||
|
4. Look at the alert that says "set passphrase".
|
||||||
|
5. Tap Ok.
|
||||||
|
6. Tap Set passphrase.
|
||||||
|
7. Enter passphrase and confirm.
|
||||||
|
8. Exit back to Database settings.
|
||||||
|
9. Tap "Export database" again.
|
||||||
|
10. Choose file location and save.
|
||||||
|
11. Tap "New archive".
|
||||||
|
12. Remove exported archive.
|
||||||
|
|
||||||
|
These steps are all very confusing, and if they were to stay as composable steps, they belong to dev tools.
|
||||||
|
|
||||||
|
Instead we can offer these simple steps:
|
||||||
|
|
||||||
|
1. Open Database settings.
|
||||||
|
2. Tap "Export database".
|
||||||
|
3. Alert will appear saying: "The chat will stop, and you will need to set (or verify) database passphrase. Continue?".
|
||||||
|
4. Tap "Ok".
|
||||||
|
5. Enter passphrase and confirm in the window that appears (or verify if it was already set, possibly allowing to skip this step).
|
||||||
|
7. Choose whether to save file or upload to XFTP and generate link.
|
||||||
|
8. File: choose file location and save.
|
||||||
|
Link: show upload progress and then show link to copy.
|
||||||
|
9. Alert will appear saying: "Database exported!", exported archive will be automatically removed.
|
||||||
|
|
||||||
|
So instead of asking users to understand the required sequence of steps, we will guide them through the required process.
|
||||||
|
|
||||||
|
### Database import
|
||||||
|
|
||||||
|
1. Open Database settings.
|
||||||
|
2. Tap "Import database".
|
||||||
|
3. Alert will appear saying: "The chat will stop, you will import?".
|
||||||
|
4. File: choose file location and tap "Import".
|
||||||
|
Link: paste link (or scan QR code) and tap "Import".
|
||||||
|
5. Confirm to replace database.
|
||||||
|
6. Start chat automatically once imported.
|
||||||
|
|
||||||
|
### Set or change passphrase
|
||||||
|
|
||||||
|
1. Open Database settings.
|
||||||
|
2. Tap "Set passphrase" or "Change passphrase" (if it was set).
|
||||||
|
3. Choose - store passphrase on the device or enter it every time the app starts.
|
||||||
|
|
||||||
|
### Remove / store passphrase from the device
|
||||||
|
|
||||||
|
To remove:
|
||||||
|
|
||||||
|
1. Open Database settings.
|
||||||
|
2. Tap "Remove passphrase".
|
||||||
|
3. Confirm to remove passphrase in alert.
|
||||||
|
4. Button is replaced with Store.
|
||||||
|
|
||||||
|
To store:
|
||||||
|
|
||||||
|
1. Open Database settings.
|
||||||
|
2. Tap "Store passphrase".
|
||||||
|
3. Enter current passphrase - it is verified.
|
||||||
|
4. Button is replaced with Remove.
|
||||||
|
|
||||||
|
### Migrate database to / from another device
|
||||||
|
|
||||||
|
#### User experience
|
||||||
|
|
||||||
|
This function is the most important, and it should be available from the main section in settings, under "Use from desktop" (or under "Link from mobile" on desktop).
|
||||||
|
|
||||||
|
On the receiving device it will be available via Database settings and also on the Onboarding screen, so users don't need to create a profile.
|
||||||
|
|
||||||
|
The steps are:
|
||||||
|
|
||||||
|
On the source device:
|
||||||
|
1. Tap "Migrate to another device".
|
||||||
|
2. The chat will stop showing "Stopping chat" to the user.
|
||||||
|
3. If passphrase was:
|
||||||
|
- not set: make user set it in a separate screen.
|
||||||
|
- set: make user verify it.
|
||||||
|
5. Show the screen to confirm the upload.
|
||||||
|
6. Upload progress (full screen circular progress showing the share, with the %s and total/uploaded size) will be shown.
|
||||||
|
7. Once upload is completed, show QR code (with option to copy link), instruct to tap "Migrate from another device" on the receiving device.
|
||||||
|
|
||||||
|
On the receiving device:
|
||||||
|
2. Tap "Migrate from another device".
|
||||||
|
2. The chat will stop (if not from Onboarding) showing "Stopping chat" to the user.
|
||||||
|
4. Scan QR code (with option to paste link on desktop only).
|
||||||
|
5. Show similar download progress, but probably in reversed direction - design TBC.
|
||||||
|
6. Once download is completed, show "Replace the current database" (if not from Onboarding).
|
||||||
|
7. Once imported, start chat automatically, and once chat started show "Tap remove database on source device".
|
||||||
|
|
||||||
|
On the source device:
|
||||||
|
1. Tap "Remove database" on the showing screen (this should also remove uploaded file).
|
||||||
|
|
||||||
|
#### Implementation considerations
|
||||||
|
|
||||||
|
The latest updates allow uploading and downloading XFTP files without messages.
|
||||||
|
|
||||||
|
So to perform the above, the second instance of the chat controller will be required, that probably requires supporting additional/optional chat controller parameter in the APIs that are required for that process.
|
38
docs/rfcs/2024-02-13-inactive-group-members-2.md
Normal file
38
docs/rfcs/2024-02-13-inactive-group-members-2.md
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
# Inactive group members (simplified)
|
||||||
|
|
||||||
|
[Original doc](./2023-11-21-inactive-group-members.md)
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Groups traffic is higher than necessary due to sending messages to inactive group members.
|
||||||
|
|
||||||
|
## Solution
|
||||||
|
|
||||||
|
### Improve connection deletion
|
||||||
|
|
||||||
|
- When leaving or deleting group, batch db operations to optimize performance.
|
||||||
|
- In agent - fix race where connection can be deleted while it has remaining pending messages.
|
||||||
|
- Current agent logic is to immediately delete connection if it has no rcv queues left.
|
||||||
|
- Simplest should be to make a smart version of `deleteConn` for this improvement, checking `snd_messages` table for remaining messages, and keep connection around in case there are.
|
||||||
|
- While this may improve delivery of group leave and delete messages, it may as well have undesirable side effects for other use cases, as any pending messages will be sent prior to deleting connection. For example, user sends several messages on bad network, decides to delete contact, messages are still delivered when user is on good network before deletion, even though this contradicts user's intent and messages hadn't left user's device at the time of deletion. Considering this race when it happens is identical to simply leaving groups by deleting app, or deleting user profile only locally, it may be a bad idea to affect regular contact deletion for this use case.
|
||||||
|
|
||||||
|
### Track member inactivity
|
||||||
|
|
||||||
|
- Mark members as inactive on QUOTA errors, reset as active on QCONT
|
||||||
|
- track `group_members.inactive` flag per group member
|
||||||
|
- on SMP.QUOTA error agent to notify client with ERR CONN QUOTA (new ConnectionErrorType QUOTA)
|
||||||
|
- on receiving QCONT agent to notify client (new event)
|
||||||
|
- apart from QCONT, reset on any message or receipt
|
||||||
|
- Don't send to member if inactive
|
||||||
|
- don't send only content messages (x.msg.new, etc.) and always send messages altering group state?
|
||||||
|
- or don't send any messages?
|
||||||
|
- Track number of skipped messages per member and first skipped message
|
||||||
|
- count `group_members.skipped_msg_cnt`
|
||||||
|
- only count messages of same types/criteria that are included into history
|
||||||
|
- track `group_members.skipped_first_shared_msg_id` (only content or including service messages?)
|
||||||
|
- Send XGrpMsgSkipped before next message
|
||||||
|
- check `skipped_msg_cnt` > 0 and `skipped_first_shared_msg_id` is not null to only send once, reset after sending
|
||||||
|
|
||||||
|
```haskell
|
||||||
|
XGrpMsgSkipped :: SharedMsgId -> Int64 -> ChatMsgEvent 'Json -- from, count
|
||||||
|
```
|
60
docs/rfcs/2024-02-19-settings.md
Normal file
60
docs/rfcs/2024-02-19-settings.md
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
# Migrating app settings to another device
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
This is related to simplified database migration UX in the [previous RFC](./2024-02-12-database-migration.md).
|
||||||
|
|
||||||
|
Currently, when database is imported after the onboarding is complete, users can configure the app prior to the import.
|
||||||
|
|
||||||
|
Some of the settings are particularly important for privacy and security:
|
||||||
|
- SOCKS proxy settings
|
||||||
|
- Automatic image etc. downloads
|
||||||
|
- Link previews
|
||||||
|
|
||||||
|
With the new UX, the chat will start automatically, without giving users a chance to configure the app. That means that we have to migrate settings to a new device as well, as part of the archive.
|
||||||
|
|
||||||
|
## Solution
|
||||||
|
|
||||||
|
There are several possible approaches:
|
||||||
|
- put settings to the database via the API
|
||||||
|
- save settings as some file with cross-platform format (e.g. JSON or YAML or properties used on desktop).
|
||||||
|
|
||||||
|
The second approach seems much simpler than maintaining the settings in the database.
|
||||||
|
|
||||||
|
If we save a file, then there are two options:
|
||||||
|
- native apps maintain cross-platform schemas for this file, support any JSON and parse it in a safe way (so that even invalid or incorrect JSON - e.g., array instead of object - or invalid types in some properties do not cause the failure of properties that are correct).
|
||||||
|
- this schema and type will be maintained in the core library, that will be responsible for storing and reading the settings and passing to native UI as correct record of a given type.
|
||||||
|
|
||||||
|
The downside of the second approach is that addition of any property that needs to be migrated will have to be done on any change in either of the platforms. The downside of the first approach is that neither app platform will be self-sufficient any more, and not only iOS/Android would have to take into account code, but also each other code.
|
||||||
|
|
||||||
|
If we go with the second approach, there will be these types:
|
||||||
|
|
||||||
|
```haskell
|
||||||
|
data AppSettings = AppSettings
|
||||||
|
{ networkConfig :: NetworkConfig, -- existing type in Haskell and all UIs
|
||||||
|
privacyConfig :: PrivacyConfig -- new type, etc.
|
||||||
|
-- ... additional properties after the initial release should be added as Maybe, as all extensions
|
||||||
|
}
|
||||||
|
|
||||||
|
data ArchiveConfig = ArchiveConfig
|
||||||
|
{ -- existing properties
|
||||||
|
archivePath :: FilePath,
|
||||||
|
disableCompression :: Maybe Bool,
|
||||||
|
parentTempDirectory :: Maybe FilePath,
|
||||||
|
-- new property
|
||||||
|
appSettings :: AppSettings
|
||||||
|
-- for export, these settings will contain the settings passed from the UI and will be saved to JSON file as simplex_v1_settings.json in the archive
|
||||||
|
-- for import, these settings will contain the defaults that will be used if some property or subproperty is missing in JSON
|
||||||
|
}
|
||||||
|
|
||||||
|
-- importArchive :: ChatMonad m => ArchiveConfig -> m [ArchiveError] -- current type
|
||||||
|
importArchive :: ChatMonad m => ArchiveConfig -> m ArchiveImportResult -- new type
|
||||||
|
|
||||||
|
-- | CRArchiveImported {archiveErrors :: [ArchiveError]} -- current type
|
||||||
|
| CRArchiveImported {importResult :: ArchiveImportResult} -- new type
|
||||||
|
|
||||||
|
data ArchiveImportResult = ArchiveImportResult
|
||||||
|
{ archiveErrors :: [ArchiveError],
|
||||||
|
appSettings :: Maybe AppSettings
|
||||||
|
}
|
||||||
|
```
|
@ -8,6 +8,7 @@
|
|||||||
<body>
|
<body>
|
||||||
<video
|
<video
|
||||||
id="remote-video-stream"
|
id="remote-video-stream"
|
||||||
|
class="inline"
|
||||||
autoplay
|
autoplay
|
||||||
playsinline
|
playsinline
|
||||||
poster="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAQAAAAnOwc2AAAAEUlEQVR42mNk+M+AARiHsiAAcCIKAYwFoQ8AAAAASUVORK5CYII="
|
poster="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAQAAAAnOwc2AAAAEUlEQVR42mNk+M+AARiHsiAAcCIKAYwFoQ8AAAAASUVORK5CYII="
|
||||||
@ -15,6 +16,7 @@
|
|||||||
></video>
|
></video>
|
||||||
<video
|
<video
|
||||||
id="local-video-stream"
|
id="local-video-stream"
|
||||||
|
class="inline"
|
||||||
muted
|
muted
|
||||||
autoplay
|
autoplay
|
||||||
playsinline
|
playsinline
|
||||||
|
@ -5,14 +5,14 @@ body {
|
|||||||
background-color: black;
|
background-color: black;
|
||||||
}
|
}
|
||||||
|
|
||||||
#remote-video-stream {
|
#remote-video-stream.inline {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
}
|
}
|
||||||
|
|
||||||
#local-video-stream {
|
#local-video-stream.inline {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
width: 30%;
|
width: 30%;
|
||||||
max-width: 30%;
|
max-width: 30%;
|
||||||
@ -23,6 +23,20 @@ body {
|
|||||||
right: 0;
|
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 {
|
*::-webkit-media-controls {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
-webkit-appearance: none !important;
|
-webkit-appearance: none !important;
|
||||||
|
@ -16,6 +16,7 @@ type WCallCommand =
|
|||||||
| WCEnableMedia
|
| WCEnableMedia
|
||||||
| WCToggleCamera
|
| WCToggleCamera
|
||||||
| WCDescription
|
| WCDescription
|
||||||
|
| WCLayout
|
||||||
| WCEndCall
|
| WCEndCall
|
||||||
|
|
||||||
type WCallResponse =
|
type WCallResponse =
|
||||||
@ -31,7 +32,7 @@ type WCallResponse =
|
|||||||
| WRError
|
| WRError
|
||||||
| WCAcceptOffer
|
| WCAcceptOffer
|
||||||
|
|
||||||
type WCallCommandTag = "capabilities" | "start" | "offer" | "answer" | "ice" | "media" | "camera" | "description" | "end"
|
type WCallCommandTag = "capabilities" | "start" | "offer" | "answer" | "ice" | "media" | "camera" | "description" | "layout" | "end"
|
||||||
|
|
||||||
type WCallResponseTag = "capabilities" | "offer" | "answer" | "ice" | "connection" | "connected" | "end" | "ended" | "ok" | "error"
|
type WCallResponseTag = "capabilities" | "offer" | "answer" | "ice" | "connection" | "connected" | "end" | "ended" | "ok" | "error"
|
||||||
|
|
||||||
@ -45,6 +46,12 @@ enum VideoCamera {
|
|||||||
Environment = "environment",
|
Environment = "environment",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum LayoutType {
|
||||||
|
Default = "default",
|
||||||
|
LocalVideo = "localVideo",
|
||||||
|
RemoteVideo = "remoteVideo",
|
||||||
|
}
|
||||||
|
|
||||||
interface IWCallCommand {
|
interface IWCallCommand {
|
||||||
type: WCallCommandTag
|
type: WCallCommandTag
|
||||||
}
|
}
|
||||||
@ -115,6 +122,11 @@ interface WCDescription extends IWCallCommand {
|
|||||||
description: string
|
description: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface WCLayout extends IWCallCommand {
|
||||||
|
type: "layout"
|
||||||
|
layout: LayoutType
|
||||||
|
}
|
||||||
|
|
||||||
interface WRCapabilities extends IWCallResponse {
|
interface WRCapabilities extends IWCallResponse {
|
||||||
type: "capabilities"
|
type: "capabilities"
|
||||||
capabilities: CallCapabilities
|
capabilities: CallCapabilities
|
||||||
@ -515,6 +527,10 @@ const processCommand = (function () {
|
|||||||
localizedDescription = command.description
|
localizedDescription = command.description
|
||||||
resp = {type: "ok"}
|
resp = {type: "ok"}
|
||||||
break
|
break
|
||||||
|
case "layout":
|
||||||
|
changeLayout(command.layout)
|
||||||
|
resp = {type: "ok"}
|
||||||
|
break
|
||||||
case "end":
|
case "end":
|
||||||
endCall()
|
endCall()
|
||||||
resp = {type: "ok"}
|
resp = {type: "ok"}
|
||||||
@ -824,6 +840,29 @@ function toggleMedia(s: MediaStream, media: CallMediaType): boolean {
|
|||||||
return res
|
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>
|
type TransformFrameFunc = (key: CryptoKey) => (frame: RTCEncodedVideoFrame, controller: TransformStreamDefaultController) => Promise<void>
|
||||||
|
|
||||||
interface CallCrypto {
|
interface CallCrypto {
|
||||||
|
@ -9,6 +9,7 @@
|
|||||||
<body>
|
<body>
|
||||||
<video
|
<video
|
||||||
id="remote-video-stream"
|
id="remote-video-stream"
|
||||||
|
class="inline"
|
||||||
autoplay
|
autoplay
|
||||||
playsinline
|
playsinline
|
||||||
poster="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAQAAAAnOwc2AAAAEUlEQVR42mNk+M+AARiHsiAAcCIKAYwFoQ8AAAAASUVORK5CYII="
|
poster="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAQAAAAnOwc2AAAAEUlEQVR42mNk+M+AARiHsiAAcCIKAYwFoQ8AAAAASUVORK5CYII="
|
||||||
@ -16,6 +17,7 @@
|
|||||||
></video>
|
></video>
|
||||||
<video
|
<video
|
||||||
id="local-video-stream"
|
id="local-video-stream"
|
||||||
|
class="inline"
|
||||||
muted
|
muted
|
||||||
autoplay
|
autoplay
|
||||||
playsinline
|
playsinline
|
||||||
|
@ -5,14 +5,14 @@ body {
|
|||||||
background-color: black;
|
background-color: black;
|
||||||
}
|
}
|
||||||
|
|
||||||
#remote-video-stream {
|
#remote-video-stream.inline {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
}
|
}
|
||||||
|
|
||||||
#local-video-stream {
|
#local-video-stream.inline {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
width: 20%;
|
width: 20%;
|
||||||
max-width: 20%;
|
max-width: 20%;
|
||||||
@ -23,6 +23,20 @@ body {
|
|||||||
right: 0;
|
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 {
|
*::-webkit-media-controls {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
-webkit-appearance: none !important;
|
-webkit-appearance: none !important;
|
||||||
|
@ -103,7 +103,7 @@ build() {
|
|||||||
|
|
||||||
for arch in $arches; do
|
for arch in $arches; do
|
||||||
|
|
||||||
tag_full="$(git tag --points-at HEAD)"
|
tag_full="$(git tag --points-at HEAD | head -n1)"
|
||||||
tag_version="${tag_full%%-*}"
|
tag_version="${tag_full%%-*}"
|
||||||
|
|
||||||
if [ "$arch" = "armv7a" ] && [ -n "$tag_full" ] ; then
|
if [ "$arch" = "armv7a" ] && [ -n "$tag_full" ] ; then
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"https://github.com/simplex-chat/simplexmq.git"."32c94df040b7921584a4685a814818daec3bf209" = "0bfyzra8x67zwqr7g8hkglxpy503qwn0xni0sjnbjmvh7wlh6pyz";
|
"https://github.com/simplex-chat/simplexmq.git"."050a921fbbdf21690cab7765bf6237fdc5a419cb" = "0bc8x3pv3l6wjcfx06yhyydf2amaw5jjax2wcbgbxzrhqz10xf1v";
|
||||||
"https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38";
|
"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/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d";
|
||||||
"https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl";
|
"https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl";
|
||||||
|
@ -26,6 +26,7 @@ flag swift
|
|||||||
library
|
library
|
||||||
exposed-modules:
|
exposed-modules:
|
||||||
Simplex.Chat
|
Simplex.Chat
|
||||||
|
Simplex.Chat.AppSettings
|
||||||
Simplex.Chat.Archive
|
Simplex.Chat.Archive
|
||||||
Simplex.Chat.Bot
|
Simplex.Chat.Bot
|
||||||
Simplex.Chat.Bot.KnownContacts
|
Simplex.Chat.Bot.KnownContacts
|
||||||
@ -133,6 +134,9 @@ library
|
|||||||
Simplex.Chat.Migrations.M20240104_members_profile_update
|
Simplex.Chat.Migrations.M20240104_members_profile_update
|
||||||
Simplex.Chat.Migrations.M20240115_block_member_for_all
|
Simplex.Chat.Migrations.M20240115_block_member_for_all
|
||||||
Simplex.Chat.Migrations.M20240122_indexes
|
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
|
||||||
Simplex.Chat.Mobile.File
|
Simplex.Chat.Mobile.File
|
||||||
Simplex.Chat.Mobile.Shared
|
Simplex.Chat.Mobile.Shared
|
||||||
@ -148,6 +152,7 @@ library
|
|||||||
Simplex.Chat.Remote.Transport
|
Simplex.Chat.Remote.Transport
|
||||||
Simplex.Chat.Remote.Types
|
Simplex.Chat.Remote.Types
|
||||||
Simplex.Chat.Store
|
Simplex.Chat.Store
|
||||||
|
Simplex.Chat.Store.AppSettings
|
||||||
Simplex.Chat.Store.Connections
|
Simplex.Chat.Store.Connections
|
||||||
Simplex.Chat.Store.Direct
|
Simplex.Chat.Store.Direct
|
||||||
Simplex.Chat.Store.Files
|
Simplex.Chat.Store.Files
|
||||||
|
@ -6,6 +6,7 @@
|
|||||||
{-# LANGUAGE MultiWayIf #-}
|
{-# LANGUAGE MultiWayIf #-}
|
||||||
{-# LANGUAGE NamedFieldPuns #-}
|
{-# LANGUAGE NamedFieldPuns #-}
|
||||||
{-# LANGUAGE OverloadedStrings #-}
|
{-# LANGUAGE OverloadedStrings #-}
|
||||||
|
{-# LANGUAGE PatternSynonyms #-}
|
||||||
{-# LANGUAGE RankNTypes #-}
|
{-# LANGUAGE RankNTypes #-}
|
||||||
{-# LANGUAGE ScopedTypeVariables #-}
|
{-# LANGUAGE ScopedTypeVariables #-}
|
||||||
{-# LANGUAGE TupleSections #-}
|
{-# LANGUAGE TupleSections #-}
|
||||||
@ -67,6 +68,7 @@ import Simplex.Chat.Protocol
|
|||||||
import Simplex.Chat.Remote
|
import Simplex.Chat.Remote
|
||||||
import Simplex.Chat.Remote.Types
|
import Simplex.Chat.Remote.Types
|
||||||
import Simplex.Chat.Store
|
import Simplex.Chat.Store
|
||||||
|
import Simplex.Chat.Store.AppSettings
|
||||||
import Simplex.Chat.Store.Connections
|
import Simplex.Chat.Store.Connections
|
||||||
import Simplex.Chat.Store.Direct
|
import Simplex.Chat.Store.Direct
|
||||||
import Simplex.Chat.Store.Files
|
import Simplex.Chat.Store.Files
|
||||||
@ -81,7 +83,8 @@ import Simplex.Chat.Types.Util
|
|||||||
import Simplex.Chat.Util (encryptFile, shuffle)
|
import Simplex.Chat.Util (encryptFile, shuffle)
|
||||||
import Simplex.FileTransfer.Client.Main (maxFileSize)
|
import Simplex.FileTransfer.Client.Main (maxFileSize)
|
||||||
import Simplex.FileTransfer.Client.Presets (defaultXFTPServers)
|
import Simplex.FileTransfer.Client.Presets (defaultXFTPServers)
|
||||||
import Simplex.FileTransfer.Description (ValidFileDescription)
|
import Simplex.FileTransfer.Description (FileDescriptionURI (..), ValidFileDescription)
|
||||||
|
import qualified Simplex.FileTransfer.Description as FD
|
||||||
import Simplex.FileTransfer.Protocol (FileParty (..), FilePartyI)
|
import Simplex.FileTransfer.Protocol (FileParty (..), FilePartyI)
|
||||||
import Simplex.Messaging.Agent as Agent
|
import Simplex.Messaging.Agent as Agent
|
||||||
import Simplex.Messaging.Agent.Client (AgentStatsKey (..), SubInfo (..), agentClientStore, getAgentWorkersDetails, getAgentWorkersSummary, temporaryAgentError)
|
import Simplex.Messaging.Agent.Client (AgentStatsKey (..), SubInfo (..), agentClientStore, getAgentWorkersDetails, getAgentWorkersSummary, temporaryAgentError)
|
||||||
@ -102,6 +105,7 @@ import Simplex.Messaging.Encoding.String
|
|||||||
import Simplex.Messaging.Parsers (base64P)
|
import Simplex.Messaging.Parsers (base64P)
|
||||||
import Simplex.Messaging.Protocol (AProtoServerWithAuth (..), AProtocolType (..), EntityId, ErrorType (..), MsgBody, MsgFlags (..), NtfServer, ProtoServerWithAuth, ProtocolTypeI, SProtocolType (..), SubscriptionMode (..), UserProtocol, userProtocol)
|
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 qualified Simplex.Messaging.Protocol as SMP
|
||||||
|
import Simplex.Messaging.ServiceScheme (ServiceScheme (..))
|
||||||
import qualified Simplex.Messaging.TMap as TM
|
import qualified Simplex.Messaging.TMap as TM
|
||||||
import Simplex.Messaging.Transport.Client (defaultSocksProxy)
|
import Simplex.Messaging.Transport.Client (defaultSocksProxy)
|
||||||
import Simplex.Messaging.Util
|
import Simplex.Messaging.Util
|
||||||
@ -169,7 +173,10 @@ _defaultSMPServers =
|
|||||||
]
|
]
|
||||||
|
|
||||||
_defaultNtfServers :: [NtfServer]
|
_defaultNtfServers :: [NtfServer]
|
||||||
_defaultNtfServers = ["ntf://FB-Uop7RTaZZEG0ZLD2CIaTjsPh-Fw0zFAnb7QyA8Ks=@ntf2.simplex.im,ntg7jdjy2i3qbib3sykiho3enekwiaqg3icctliqhtqcg6jmoh6cxiad.onion"]
|
_defaultNtfServers =
|
||||||
|
[ "ntf://KmpZNNXiVZJx_G2T7jRUmDFxWXM3OAnunz3uLT0tqAA=@ntf3.simplex.im,pxculznuryunjdvtvh6s6szmanyadumpbmvevgdpe4wk5c65unyt4yid.onion",
|
||||||
|
"ntf://CJ5o7X6fCxj2FFYRU2KuCo70y4jSqz7td2HYhLnXWbU=@ntf4.simplex.im,wtvuhdj26jwprmomnyfu5wfuq2hjkzfcc72u44vi6gdhrwxldt6xauad.onion"
|
||||||
|
]
|
||||||
|
|
||||||
maxImageSize :: Integer
|
maxImageSize :: Integer
|
||||||
maxImageSize = 261120 * 2 -- auto-receive on mobiles
|
maxImageSize = 261120 * 2 -- auto-receive on mobiles
|
||||||
@ -591,8 +598,11 @@ processChatCommand' vr = \case
|
|||||||
fileErrs <- importArchive cfg
|
fileErrs <- importArchive cfg
|
||||||
setStoreChanged
|
setStoreChanged
|
||||||
pure $ CRArchiveImported fileErrs
|
pure $ CRArchiveImported fileErrs
|
||||||
|
APISaveAppSettings as -> withStore' (`saveAppSettings` as) >> ok_
|
||||||
|
APIGetAppSettings platformDefaults -> CRAppSettings <$> withStore' (`getAppSettings` platformDefaults)
|
||||||
APIDeleteStorage -> withStoreChanged deleteStorage
|
APIDeleteStorage -> withStoreChanged deleteStorage
|
||||||
APIStorageEncryption cfg -> withStoreChanged $ sqlCipherExport cfg
|
APIStorageEncryption cfg -> withStoreChanged $ sqlCipherExport cfg
|
||||||
|
TestStorageEncryption key -> sqlCipherTestKey key >> ok_
|
||||||
ExecChatStoreSQL query -> CRSQLResult <$> withStore' (`execSQL` query)
|
ExecChatStoreSQL query -> CRSQLResult <$> withStore' (`execSQL` query)
|
||||||
ExecAgentStoreSQL query -> CRSQLResult <$> withAgent (`execAgentStoreSQL` query)
|
ExecAgentStoreSQL query -> CRSQLResult <$> withAgent (`execAgentStoreSQL` query)
|
||||||
SlowSQLQueries -> do
|
SlowSQLQueries -> do
|
||||||
@ -706,28 +716,18 @@ processChatCommand' vr = \case
|
|||||||
CTContactConnection -> pure $ chatCmdError (Just user) "not supported"
|
CTContactConnection -> pure $ chatCmdError (Just user) "not supported"
|
||||||
where
|
where
|
||||||
xftpSndFileTransfer :: User -> CryptoFile -> Integer -> Int -> ContactOrGroup -> m (FileInvitation, CIFile 'MDSnd)
|
xftpSndFileTransfer :: User -> CryptoFile -> Integer -> Int -> ContactOrGroup -> m (FileInvitation, CIFile 'MDSnd)
|
||||||
xftpSndFileTransfer user file@(CryptoFile filePath cfArgs) fileSize n contactOrGroup = do
|
xftpSndFileTransfer user file fileSize n contactOrGroup = do
|
||||||
let fileName = takeFileName filePath
|
(fInv, ciFile, ft) <- xftpSndFileTransfer_ user file fileSize n $ Just contactOrGroup
|
||||||
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
|
case contactOrGroup of
|
||||||
CGContact Contact {activeConn} -> forM_ activeConn $ \conn ->
|
CGContact Contact {activeConn} -> forM_ activeConn $ \conn ->
|
||||||
withStore' $ \db -> createSndFTDescrXFTP db user Nothing conn ft fileDescr
|
withStore' $ \db -> createSndFTDescrXFTP db user Nothing conn ft dummyFileDescr
|
||||||
CGGroup (Group _ ms) -> forM_ ms $ \m -> saveMemberFD m `catchChatError` (toView . CRChatError (Just user))
|
CGGroup (Group _ ms) -> forM_ ms $ \m -> saveMemberFD m `catchChatError` (toView . CRChatError (Just user))
|
||||||
where
|
where
|
||||||
-- we are not sending files to pending members, same as with inline files
|
-- we are not sending files to pending members, same as with inline files
|
||||||
saveMemberFD m@GroupMember {activeConn = Just conn@Connection {connStatus}} =
|
saveMemberFD m@GroupMember {activeConn = Just conn@Connection {connStatus}} =
|
||||||
when ((connStatus == ConnReady || connStatus == ConnSndReady) && not (connDisabled conn)) $
|
when ((connStatus == ConnReady || connStatus == ConnSndReady) && not (connDisabled conn)) $
|
||||||
withStore' $
|
withStore' $
|
||||||
\db -> createSndFTDescrXFTP db user (Just m) conn ft fileDescr
|
\db -> createSndFTDescrXFTP db user (Just m) conn ft dummyFileDescr
|
||||||
saveMemberFD _ = pure ()
|
saveMemberFD _ = pure ()
|
||||||
pure (fInv, ciFile)
|
pure (fInv, ciFile)
|
||||||
APICreateChatItem folderId (ComposedMessage file_ quotedItemId_ mc) -> withUser $ \user -> do
|
APICreateChatItem folderId (ComposedMessage file_ quotedItemId_ mc) -> withUser $ \user -> do
|
||||||
@ -939,7 +939,8 @@ processChatCommand' vr = \case
|
|||||||
ct <- withStore $ \db -> getContact db user chatId
|
ct <- withStore $ \db -> getContact db user chatId
|
||||||
filesInfo <- withStore' $ \db -> getContactFileInfo db user ct
|
filesInfo <- withStore' $ \db -> getContactFileInfo db user ct
|
||||||
withChatLock "deleteChat direct" . procCmd $ do
|
withChatLock "deleteChat direct" . procCmd $ do
|
||||||
deleteFilesAndConns user filesInfo
|
cancelFilesInProgress user filesInfo
|
||||||
|
deleteFilesLocally filesInfo
|
||||||
when (contactReady ct && contactActive ct && notify) $
|
when (contactReady ct && contactActive ct && notify) $
|
||||||
void (sendDirectContactMessage ct XDirectDel) `catchChatError` const (pure ())
|
void (sendDirectContactMessage ct XDirectDel) `catchChatError` const (pure ())
|
||||||
contactConnIds <- map aConnId <$> withStore' (\db -> getContactConnections db userId ct)
|
contactConnIds <- map aConnId <$> withStore' (\db -> getContactConnections db userId ct)
|
||||||
@ -962,7 +963,8 @@ processChatCommand' vr = \case
|
|||||||
unless canDelete $ throwChatError $ CEGroupUserRole gInfo GROwner
|
unless canDelete $ throwChatError $ CEGroupUserRole gInfo GROwner
|
||||||
filesInfo <- withStore' $ \db -> getGroupFileInfo db user gInfo
|
filesInfo <- withStore' $ \db -> getGroupFileInfo db user gInfo
|
||||||
withChatLock "deleteChat group" . procCmd $ do
|
withChatLock "deleteChat group" . procCmd $ do
|
||||||
deleteFilesAndConns user filesInfo
|
cancelFilesInProgress user filesInfo
|
||||||
|
deleteFilesLocally filesInfo
|
||||||
when (memberActive membership && isOwner) . void $ sendGroupMessage' user gInfo members XGrpDel
|
when (memberActive membership && isOwner) . void $ sendGroupMessage' user gInfo members XGrpDel
|
||||||
deleteGroupLinkIfExists user gInfo
|
deleteGroupLinkIfExists user gInfo
|
||||||
deleteMembersConnections user members
|
deleteMembersConnections user members
|
||||||
@ -973,37 +975,40 @@ processChatCommand' vr = \case
|
|||||||
withStore' $ \db -> deleteGroupItemsAndMembers db user gInfo members
|
withStore' $ \db -> deleteGroupItemsAndMembers db user gInfo members
|
||||||
withStore' $ \db -> deleteGroup db user gInfo
|
withStore' $ \db -> deleteGroup db user gInfo
|
||||||
let contactIds = mapMaybe memberContactId members
|
let contactIds = mapMaybe memberContactId members
|
||||||
deleteAgentConnectionsAsync user . concat =<< mapM deleteUnusedContact contactIds
|
(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
|
||||||
pure $ CRGroupDeletedUser user gInfo
|
pure $ CRGroupDeletedUser user gInfo
|
||||||
where
|
where
|
||||||
deleteUnusedContact :: ContactId -> m [ConnId]
|
deleteUnusedContact :: DB.Connection -> ContactId -> IO (Either ChatError (Maybe StoreError, [ConnId]))
|
||||||
deleteUnusedContact contactId =
|
deleteUnusedContact db contactId = runExceptT . withExceptT ChatErrorStore $ do
|
||||||
(withStore (\db -> getContact db user contactId) >>= delete)
|
ct <- getContact db user contactId
|
||||||
`catchChatError` (\e -> toView (CRChatError (Just user) e) $> [])
|
ifM
|
||||||
|
((directOrUsed ct ||) . isJust <$> liftIO (checkContactHasGroups db user ct))
|
||||||
|
(pure (Nothing, []))
|
||||||
|
(getConnections ct)
|
||||||
where
|
where
|
||||||
delete ct
|
getConnections :: Contact -> ExceptT StoreError IO (Maybe StoreError, [ConnId])
|
||||||
| directOrUsed ct = pure []
|
getConnections ct = do
|
||||||
| otherwise =
|
conns <- liftIO $ getContactConnections db userId ct
|
||||||
withStore' (\db -> checkContactHasGroups db user ct) >>= \case
|
e_ <- (setContactDeleted db user ct $> Nothing) `catchStoreError` (pure . Just)
|
||||||
Just _ -> pure []
|
pure (e_, map aConnId conns)
|
||||||
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"
|
CTLocal -> pure $ chatCmdError (Just user) "not supported"
|
||||||
CTContactRequest -> pure $ chatCmdError (Just user) "not supported"
|
CTContactRequest -> pure $ chatCmdError (Just user) "not supported"
|
||||||
APIClearChat (ChatRef cType chatId) -> withUser $ \user@User {userId} -> case cType of
|
APIClearChat (ChatRef cType chatId) -> withUser $ \user@User {userId} -> case cType of
|
||||||
CTDirect -> do
|
CTDirect -> do
|
||||||
ct <- withStore $ \db -> getContact db user chatId
|
ct <- withStore $ \db -> getContact db user chatId
|
||||||
filesInfo <- withStore' $ \db -> getContactFileInfo db user ct
|
filesInfo <- withStore' $ \db -> getContactFileInfo db user ct
|
||||||
deleteFilesAndConns user filesInfo
|
cancelFilesInProgress user filesInfo
|
||||||
|
deleteFilesLocally filesInfo
|
||||||
withStore' $ \db -> deleteContactCIs db user ct
|
withStore' $ \db -> deleteContactCIs db user ct
|
||||||
pure $ CRChatCleared user (AChatInfo SCTDirect $ DirectChat ct)
|
pure $ CRChatCleared user (AChatInfo SCTDirect $ DirectChat ct)
|
||||||
CTGroup -> do
|
CTGroup -> do
|
||||||
gInfo <- withStore $ \db -> getGroupInfo db vr user chatId
|
gInfo <- withStore $ \db -> getGroupInfo db vr user chatId
|
||||||
filesInfo <- withStore' $ \db -> getGroupFileInfo db user gInfo
|
filesInfo <- withStore' $ \db -> getGroupFileInfo db user gInfo
|
||||||
deleteFilesAndConns user filesInfo
|
cancelFilesInProgress user filesInfo
|
||||||
|
deleteFilesLocally filesInfo
|
||||||
withStore' $ \db -> deleteGroupCIs db user gInfo
|
withStore' $ \db -> deleteGroupCIs db user gInfo
|
||||||
membersToDelete <- withStore' $ \db -> getGroupMembersForExpiration db user gInfo
|
membersToDelete <- withStore' $ \db -> getGroupMembersForExpiration db user gInfo
|
||||||
forM_ membersToDelete $ \m -> withStore' $ \db -> deleteGroupMember db user m
|
forM_ membersToDelete $ \m -> withStore' $ \db -> deleteGroupMember db user m
|
||||||
@ -1012,7 +1017,7 @@ processChatCommand' vr = \case
|
|||||||
nf <- withStore $ \db -> getNoteFolder db user chatId
|
nf <- withStore $ \db -> getNoteFolder db user chatId
|
||||||
filesInfo <- withStore' $ \db -> getNoteFolderFileInfo db user nf
|
filesInfo <- withStore' $ \db -> getNoteFolderFileInfo db user nf
|
||||||
withChatLock "clearChat local" . procCmd $ do
|
withChatLock "clearChat local" . procCmd $ do
|
||||||
mapM_ (deleteFile user) filesInfo
|
deleteFilesLocally filesInfo
|
||||||
withStore' $ \db -> deleteNoteFolderFiles db userId nf
|
withStore' $ \db -> deleteNoteFolderFiles db userId nf
|
||||||
withStore' $ \db -> deleteNoteFolderCIs db user nf
|
withStore' $ \db -> deleteNoteFolderCIs db user nf
|
||||||
pure $ CRChatCleared user (AChatInfo SCTLocal $ LocalChat nf)
|
pure $ CRChatCleared user (AChatInfo SCTLocal $ LocalChat nf)
|
||||||
@ -1173,9 +1178,8 @@ processChatCommand' vr = \case
|
|||||||
ok user
|
ok user
|
||||||
SetUserProtoServers serversConfig -> withUser $ \User {userId} ->
|
SetUserProtoServers serversConfig -> withUser $ \User {userId} ->
|
||||||
processChatCommand $ APISetUserProtoServers userId serversConfig
|
processChatCommand $ APISetUserProtoServers userId serversConfig
|
||||||
APITestProtoServer userId srv@(AProtoServerWithAuth p server) -> withUserId userId $ \user ->
|
APITestProtoServer userId srv@(AProtoServerWithAuth _ server) -> withUserId userId $ \user ->
|
||||||
withServerProtocol p $
|
CRServerTestResult user srv <$> withAgent (\a -> testProtocolServer a (aUserId user) server)
|
||||||
CRServerTestResult user srv <$> withAgent (\a -> testProtocolServer a (aUserId user) server)
|
|
||||||
TestProtoServer srv -> withUser $ \User {userId} ->
|
TestProtoServer srv -> withUser $ \User {userId} ->
|
||||||
processChatCommand $ APITestProtoServer userId srv
|
processChatCommand $ APITestProtoServer userId srv
|
||||||
APISetChatItemTTL userId newTTL_ -> withUserId userId $ \user ->
|
APISetChatItemTTL userId newTTL_ -> withUserId userId $ \user ->
|
||||||
@ -1698,7 +1702,9 @@ processChatCommand' vr = \case
|
|||||||
pure $ CRUserDeletedMember user gInfo m {memberStatus = GSMemRemoved}
|
pure $ CRUserDeletedMember user gInfo m {memberStatus = GSMemRemoved}
|
||||||
APILeaveGroup groupId -> withUser $ \user@User {userId} -> do
|
APILeaveGroup groupId -> withUser $ \user@User {userId} -> do
|
||||||
Group gInfo@GroupInfo {membership} members <- withStore $ \db -> getGroup db vr user groupId
|
Group gInfo@GroupInfo {membership} members <- withStore $ \db -> getGroup db vr user groupId
|
||||||
|
filesInfo <- withStore' $ \db -> getGroupFileInfo db user gInfo
|
||||||
withChatLock "leaveGroup" . procCmd $ do
|
withChatLock "leaveGroup" . procCmd $ do
|
||||||
|
cancelFilesInProgress user filesInfo
|
||||||
(msg, _) <- sendGroupMessage' user gInfo members XGrpLeave
|
(msg, _) <- sendGroupMessage' user gInfo members XGrpLeave
|
||||||
ci <- saveSndChatItem user (CDGroupSnd gInfo) msg (CISndGroupEvent SGEUserLeft)
|
ci <- saveSndChatItem user (CDGroupSnd gInfo) msg (CISndGroupEvent SGEUserLeft)
|
||||||
toView $ CRNewChatItem user (AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci)
|
toView $ CRNewChatItem user (AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci)
|
||||||
@ -1893,16 +1899,16 @@ processChatCommand' vr = \case
|
|||||||
| otherwise -> do
|
| otherwise -> do
|
||||||
fileAgentConnIds <- cancelSndFile user ftm fts True
|
fileAgentConnIds <- cancelSndFile user ftm fts True
|
||||||
deleteAgentConnectionsAsync user fileAgentConnIds
|
deleteAgentConnectionsAsync user fileAgentConnIds
|
||||||
sharedMsgId <- withStore $ \db -> getSharedMsgIdByFileId db userId fileId
|
withStore (\db -> liftIO $ lookupChatRefByFileId db user fileId) >>= \case
|
||||||
withStore (\db -> getChatRefByFileId db user fileId) >>= \case
|
Nothing -> pure ()
|
||||||
ChatRef CTDirect contactId -> do
|
Just (ChatRef CTDirect contactId) -> do
|
||||||
contact <- withStore $ \db -> getContact db user contactId
|
(contact, sharedMsgId) <- withStore $ \db -> (,) <$> getContact db user contactId <*> getSharedMsgIdByFileId db userId fileId
|
||||||
void . sendDirectContactMessage contact $ XFileCancel sharedMsgId
|
void . sendDirectContactMessage contact $ XFileCancel sharedMsgId
|
||||||
ChatRef CTGroup groupId -> do
|
Just (ChatRef CTGroup groupId) -> do
|
||||||
Group gInfo ms <- withStore $ \db -> getGroup db vr user groupId
|
(Group gInfo ms, sharedMsgId) <- withStore $ \db -> (,) <$> getGroup db vr user groupId <*> getSharedMsgIdByFileId db userId fileId
|
||||||
void . sendGroupMessage user gInfo ms $ XFileCancel sharedMsgId
|
void . sendGroupMessage user gInfo ms $ XFileCancel sharedMsgId
|
||||||
_ -> throwChatError $ CEFileInternal "invalid chat ref for file transfer"
|
Just _ -> throwChatError $ CEFileInternal "invalid chat ref for file transfer"
|
||||||
ci <- withStore $ \db -> getChatItemByFileId db vr user fileId
|
ci <- withStore $ \db -> lookupChatItemByFileId db vr user fileId
|
||||||
pure $ CRSndFileCancelled user ci ftm fts
|
pure $ CRSndFileCancelled user ci ftm fts
|
||||||
where
|
where
|
||||||
fileCancelledOrCompleteSMP SndFileTransfer {fileStatus = s} =
|
fileCancelledOrCompleteSMP SndFileTransfer {fileStatus = s} =
|
||||||
@ -1913,7 +1919,7 @@ processChatCommand' vr = \case
|
|||||||
| otherwise -> case xftpRcvFile of
|
| otherwise -> case xftpRcvFile of
|
||||||
Nothing -> do
|
Nothing -> do
|
||||||
cancelRcvFileTransfer user ftr >>= mapM_ (deleteAgentConnectionAsync user)
|
cancelRcvFileTransfer user ftr >>= mapM_ (deleteAgentConnectionAsync user)
|
||||||
ci <- withStore $ \db -> getChatItemByFileId db vr user fileId
|
ci <- withStore $ \db -> lookupChatItemByFileId db vr user fileId
|
||||||
pure $ CRRcvFileCancelled user ci ftr
|
pure $ CRRcvFileCancelled user ci ftr
|
||||||
Just XFTPRcvFile {agentRcvFileId} -> do
|
Just XFTPRcvFile {agentRcvFileId} -> do
|
||||||
forM_ (liveRcvFileTransferPath ftr) $ \filePath -> do
|
forM_ (liveRcvFileTransferPath ftr) $ \filePath -> do
|
||||||
@ -1926,18 +1932,21 @@ processChatCommand' vr = \case
|
|||||||
updateCIFileStatus db user fileId CIFSRcvInvitation
|
updateCIFileStatus db user fileId CIFSRcvInvitation
|
||||||
updateRcvFileStatus db fileId FSNew
|
updateRcvFileStatus db fileId FSNew
|
||||||
updateRcvFileAgentId db fileId Nothing
|
updateRcvFileAgentId db fileId Nothing
|
||||||
getChatItemByFileId db vr user fileId
|
lookupChatItemByFileId db vr user fileId
|
||||||
pure $ CRRcvFileCancelled user ci ftr
|
pure $ CRRcvFileCancelled user ci ftr
|
||||||
FileStatus fileId -> withUser $ \user -> do
|
FileStatus fileId -> withUser $ \user -> do
|
||||||
ci@(AChatItem _ _ _ ChatItem {file}) <- withStore $ \db -> getChatItemByFileId db vr user fileId
|
withStore (\db -> lookupChatItemByFileId db vr user fileId) >>= \case
|
||||||
case file of
|
Nothing -> do
|
||||||
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
|
fileStatus <- withStore $ \db -> getFileTransferProgress db user fileId
|
||||||
pure $ CRFileTransferStatus user fileStatus
|
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)
|
ShowProfile -> withUser $ \user@User {profile} -> pure $ CRUserProfile user (fromLocalProfile profile)
|
||||||
UpdateProfile displayName fullName -> withUser $ \user@User {profile} -> do
|
UpdateProfile displayName fullName -> withUser $ \user@User {profile} -> do
|
||||||
let p = (fromLocalProfile profile :: Profile) {displayName = displayName, fullName = fullName}
|
let p = (fromLocalProfile profile :: Profile) {displayName = displayName, fullName = fullName}
|
||||||
@ -1992,6 +2001,14 @@ processChatCommand' vr = \case
|
|||||||
StopRemoteCtrl -> withUser_ $ stopRemoteCtrl >> ok_
|
StopRemoteCtrl -> withUser_ $ stopRemoteCtrl >> ok_
|
||||||
ListRemoteCtrls -> withUser_ $ CRRemoteCtrlList <$> listRemoteCtrls
|
ListRemoteCtrls -> withUser_ $ CRRemoteCtrlList <$> listRemoteCtrls
|
||||||
DeleteRemoteCtrl rc -> withUser_ $ deleteRemoteCtrl rc >> ok_
|
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}
|
||||||
|
(_, _, 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
|
QuitChat -> liftIO exitSuccess
|
||||||
ShowVersion -> do
|
ShowVersion -> do
|
||||||
-- simplexmqCommitQ makes iOS builds crash m(
|
-- simplexmqCommitQ makes iOS builds crash m(
|
||||||
@ -2341,7 +2358,8 @@ processChatCommand' vr = \case
|
|||||||
deleteChatUser :: User -> Bool -> m ChatResponse
|
deleteChatUser :: User -> Bool -> m ChatResponse
|
||||||
deleteChatUser user delSMPQueues = do
|
deleteChatUser user delSMPQueues = do
|
||||||
filesInfo <- withStore' (`getUserFileInfo` user)
|
filesInfo <- withStore' (`getUserFileInfo` user)
|
||||||
forM_ filesInfo $ \fileInfo -> deleteFile user fileInfo
|
cancelFilesInProgress user filesInfo
|
||||||
|
deleteFilesLocally filesInfo
|
||||||
withAgent $ \a -> deleteUser a (aUserId user) delSMPQueues
|
withAgent $ \a -> deleteUser a (aUserId user) delSMPQueues
|
||||||
withStore' (`deleteUserRecord` user)
|
withStore' (`deleteUserRecord` user)
|
||||||
when (activeUser user) $ chatWriteVar currentUser Nothing
|
when (activeUser user) $ chatWriteVar currentUser Nothing
|
||||||
@ -2378,7 +2396,7 @@ processChatCommand' vr = \case
|
|||||||
where
|
where
|
||||||
cReqSchemas :: (ConnReqInvitation, ConnReqInvitation)
|
cReqSchemas :: (ConnReqInvitation, ConnReqInvitation)
|
||||||
cReqSchemas =
|
cReqSchemas =
|
||||||
( CRInvitationUri crData {crScheme = CRSSimplex} e2e,
|
( CRInvitationUri crData {crScheme = SSSimplex} e2e,
|
||||||
CRInvitationUri crData {crScheme = simplexChat} e2e
|
CRInvitationUri crData {crScheme = simplexChat} e2e
|
||||||
)
|
)
|
||||||
connectPlan user (ACR SCMContact (CRContactUri crData)) = do
|
connectPlan user (ACR SCMContact (CRContactUri crData)) = do
|
||||||
@ -2423,7 +2441,7 @@ processChatCommand' vr = \case
|
|||||||
where
|
where
|
||||||
cReqSchemas :: (ConnReqContact, ConnReqContact)
|
cReqSchemas :: (ConnReqContact, ConnReqContact)
|
||||||
cReqSchemas =
|
cReqSchemas =
|
||||||
( CRContactUri crData {crScheme = CRSSimplex},
|
( CRContactUri crData {crScheme = SSSimplex},
|
||||||
CRContactUri crData {crScheme = simplexChat}
|
CRContactUri crData {crScheme = simplexChat}
|
||||||
)
|
)
|
||||||
cReqHashes :: (ConnReqUriHash, ConnReqUriHash)
|
cReqHashes :: (ConnReqUriHash, ConnReqUriHash)
|
||||||
@ -2549,50 +2567,72 @@ setAllExpireCIFlags b = do
|
|||||||
keys <- M.keys <$> readTVar expireFlags
|
keys <- M.keys <$> readTVar expireFlags
|
||||||
forM_ keys $ \k -> TM.insert k b expireFlags
|
forM_ keys $ \k -> TM.insert k b expireFlags
|
||||||
|
|
||||||
deleteFilesAndConns :: ChatMonad m => User -> [CIFileInfo] -> m ()
|
cancelFilesInProgress :: forall m. ChatMonad m => User -> [CIFileInfo] -> m ()
|
||||||
deleteFilesAndConns user filesInfo = do
|
cancelFilesInProgress user filesInfo = do
|
||||||
connIds <- mapM (deleteFile user) filesInfo
|
let filesInfo' = filter (not . fileEnded) filesInfo
|
||||||
deleteAgentConnectionsAsync user $ concat connIds
|
(sfs, rfs) <- splitFTTypes <$> withStoreBatch (\db -> map (getFT db) filesInfo')
|
||||||
|
forM_ rfs $ \RcvFileTransfer {fileId} -> closeFileHandle fileId rcvFiles `catchChatError` \_ -> pure ()
|
||||||
deleteFile :: ChatMonad m => User -> CIFileInfo -> m [ConnId]
|
void . withStoreBatch' $ \db -> map (updateSndFileCancelled db) sfs
|
||||||
deleteFile user fileInfo = deleteFile' user fileInfo False
|
void . withStoreBatch' $ \db -> map (updateRcvFileCancelled db) rfs
|
||||||
|
let xsfIds = mapMaybe (\(FileTransferMeta {fileId, xftpSndFile}, _) -> (,fileId) <$> xftpSndFile) sfs
|
||||||
deleteFile' :: forall m. ChatMonad m => User -> CIFileInfo -> Bool -> m [ConnId]
|
xrfIds = mapMaybe (\RcvFileTransfer {fileId, xftpRcvFile} -> (,fileId) <$> xftpRcvFile) rfs
|
||||||
deleteFile' user ciFileInfo@CIFileInfo {filePath} sendCancel = do
|
agentXFTPDeleteSndFilesRemote user xsfIds
|
||||||
aConnIds <- cancelFile' user ciFileInfo sendCancel
|
agentXFTPDeleteRcvFiles xrfIds
|
||||||
forM_ filePath $ \fPath ->
|
let smpSFConnIds = concatMap (\(ft, sfts) -> mapMaybe (smpSndFileConnId ft) sfts) sfs
|
||||||
deleteFileLocally fPath `catchChatError` (toView . CRChatError (Just user))
|
smpRFConnIds = mapMaybe smpRcvFileConnId rfs
|
||||||
pure aConnIds
|
deleteAgentConnectionsAsync user smpSFConnIds
|
||||||
|
deleteAgentConnectionsAsync user smpRFConnIds
|
||||||
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
|
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
|
||||||
|
|
||||||
|
deleteFilesLocally :: forall m. ChatMonad m => [CIFileInfo] -> m ()
|
||||||
|
deleteFilesLocally files =
|
||||||
|
withFilesFolder $ \filesFolder ->
|
||||||
|
liftIO . forM_ files $ \CIFileInfo {filePath} ->
|
||||||
|
mapM_ (delete . (filesFolder </>)) filePath
|
||||||
|
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)
|
-- perform an action only if filesFolder is set (i.e. on mobile devices)
|
||||||
withFilesFolder :: (FilePath -> m ()) -> m ()
|
withFilesFolder :: (FilePath -> m ()) -> m ()
|
||||||
withFilesFolder action = asks filesFolder >>= readTVarIO >>= mapM_ action
|
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 :: ChatMonad m => User -> Contact -> Call -> WebRTCCallStatus -> Maybe MessageId -> m ()
|
||||||
updateCallItemStatus user ct Call {chatItemId} receivedStatus msgId_ = do
|
updateCallItemStatus user ct Call {chatItemId} receivedStatus msgId_ = do
|
||||||
aciContent_ <- callStatusItemContent user ct chatItemId receivedStatus
|
aciContent_ <- callStatusItemContent user ct chatItemId receivedStatus
|
||||||
@ -2731,6 +2771,19 @@ receiveViaCompleteFD user fileId RcvFileDescr {fileDescrText, fileDescrComplete}
|
|||||||
startReceivingFile user fileId
|
startReceivingFile user fileId
|
||||||
withStoreCtx' (Just "receiveViaCompleteFD, updateRcvFileAgentId") $ \db -> updateRcvFileAgentId db fileId (Just $ AgentRcvFileId aFileId)
|
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 :: ChatMonad m => User -> FileTransferId -> m ()
|
||||||
startReceivingFile user fileId = do
|
startReceivingFile user fileId = do
|
||||||
vr <- chatVersionRange
|
vr <- chatVersionRange
|
||||||
@ -3143,13 +3196,15 @@ expireChatItems user@User {userId} ttl sync = do
|
|||||||
processContact expirationDate ct = do
|
processContact expirationDate ct = do
|
||||||
waitChatStartedAndActivated
|
waitChatStartedAndActivated
|
||||||
filesInfo <- withStoreCtx' (Just "processContact, getContactExpiredFileInfo") $ \db -> getContactExpiredFileInfo db user ct expirationDate
|
filesInfo <- withStoreCtx' (Just "processContact, getContactExpiredFileInfo") $ \db -> getContactExpiredFileInfo db user ct expirationDate
|
||||||
deleteFilesAndConns user filesInfo
|
cancelFilesInProgress user filesInfo
|
||||||
|
deleteFilesLocally filesInfo
|
||||||
withStoreCtx' (Just "processContact, deleteContactExpiredCIs") $ \db -> deleteContactExpiredCIs db user ct expirationDate
|
withStoreCtx' (Just "processContact, deleteContactExpiredCIs") $ \db -> deleteContactExpiredCIs db user ct expirationDate
|
||||||
processGroup :: UTCTime -> UTCTime -> GroupInfo -> m ()
|
processGroup :: UTCTime -> UTCTime -> GroupInfo -> m ()
|
||||||
processGroup expirationDate createdAtCutoff gInfo = do
|
processGroup expirationDate createdAtCutoff gInfo = do
|
||||||
waitChatStartedAndActivated
|
waitChatStartedAndActivated
|
||||||
filesInfo <- withStoreCtx' (Just "processGroup, getGroupExpiredFileInfo") $ \db -> getGroupExpiredFileInfo db user gInfo expirationDate createdAtCutoff
|
filesInfo <- withStoreCtx' (Just "processGroup, getGroupExpiredFileInfo") $ \db -> getGroupExpiredFileInfo db user gInfo expirationDate createdAtCutoff
|
||||||
deleteFilesAndConns user filesInfo
|
cancelFilesInProgress user filesInfo
|
||||||
|
deleteFilesLocally filesInfo
|
||||||
withStoreCtx' (Just "processGroup, deleteGroupExpiredCIs") $ \db -> deleteGroupExpiredCIs db user gInfo expirationDate createdAtCutoff
|
withStoreCtx' (Just "processGroup, deleteGroupExpiredCIs") $ \db -> deleteGroupExpiredCIs db user gInfo expirationDate createdAtCutoff
|
||||||
membersToDelete <- withStoreCtx' (Just "processGroup, getGroupMembersForExpiration") $ \db -> getGroupMembersForExpiration db user gInfo
|
membersToDelete <- withStoreCtx' (Just "processGroup, getGroupMembersForExpiration") $ \db -> getGroupMembersForExpiration db user gInfo
|
||||||
forM_ membersToDelete $ \m -> withStoreCtx' (Just "processGroup, deleteGroupMember") $ \db -> deleteGroupMember db user m
|
forM_ membersToDelete $ \m -> withStoreCtx' (Just "processGroup, deleteGroupMember") $ \db -> deleteGroupMember db user m
|
||||||
@ -3196,7 +3251,7 @@ processAgentMsgSndFile _corrId aFileId msg =
|
|||||||
where
|
where
|
||||||
process :: User -> m ()
|
process :: User -> m ()
|
||||||
process user = do
|
process user = do
|
||||||
(ft@FileTransferMeta {fileId, cancelled}, sfts) <- withStore $ \db -> do
|
(ft@FileTransferMeta {fileId, xftpRedirectFor, cancelled}, sfts) <- withStore $ \db -> do
|
||||||
fileId <- getXFTPSndFileDBId db user $ AgentSndFileId aFileId
|
fileId <- getXFTPSndFileDBId db user $ AgentSndFileId aFileId
|
||||||
getSndFileTransfer db user fileId
|
getSndFileTransfer db user fileId
|
||||||
vr <- chatVersionRange
|
vr <- chatVersionRange
|
||||||
@ -3205,61 +3260,76 @@ processAgentMsgSndFile _corrId aFileId msg =
|
|||||||
let status = CIFSSndTransfer {sndProgress, sndTotal}
|
let status = CIFSSndTransfer {sndProgress, sndTotal}
|
||||||
ci <- withStore $ \db -> do
|
ci <- withStore $ \db -> do
|
||||||
liftIO $ updateCIFileStatus db user fileId status
|
liftIO $ updateCIFileStatus db user fileId status
|
||||||
getChatItemByFileId db vr user fileId
|
lookupChatItemByFileId db vr user fileId
|
||||||
toView $ CRSndFileProgressXFTP user ci ft sndProgress sndTotal
|
toView $ CRSndFileProgressXFTP user ci ft sndProgress sndTotal
|
||||||
SFDONE sndDescr rfds -> do
|
SFDONE sndDescr rfds -> do
|
||||||
withStore' $ \db -> setSndFTPrivateSndDescr db user fileId (fileDescrText sndDescr)
|
withStore' $ \db -> setSndFTPrivateSndDescr db user fileId (fileDescrText sndDescr)
|
||||||
ci@(AChatItem _ d cInfo _ci@ChatItem {meta = CIMeta {itemSharedMsgId = msgId_, itemDeleted}}) <-
|
ci <- withStore $ \db -> lookupChatItemByFileId db vr user fileId
|
||||||
withStore $ \db -> getChatItemByFileId db vr user fileId
|
case ci of
|
||||||
case (msgId_, itemDeleted) of
|
Nothing -> do
|
||||||
(Just sharedMsgId, Nothing) -> do
|
withAgent (`xftpDeleteSndFileInternal` aFileId)
|
||||||
when (length rfds < length sfts) $ throwChatError $ CEInternalError "not enough XFTP file descriptions to send"
|
withStore' $ \db -> createExtraSndFTDescrs db user fileId (map fileDescrText rfds)
|
||||||
-- TODO either update database status or move to SFPROG
|
case mapMaybe fileDescrURI rfds of
|
||||||
toView $ CRSndFileProgressXFTP user ci ft 1 1
|
[] -> case rfds of
|
||||||
case (rfds, sfts, d, cInfo) of
|
[] -> logError "File sent without receiver descriptions" -- should not happen
|
||||||
(rfd : extraRFDs, sft : _, SMDSnd, DirectChat ct) -> do
|
(rfd : _) -> xftpSndFileRedirect user fileId rfd >>= toView . CRSndFileRedirectStartXFTP user ft
|
||||||
withStore' $ \db -> createExtraSndFTDescrs db user fileId (map fileDescrText extraRFDs)
|
uris -> do
|
||||||
msgDeliveryId <- sendFileDescription sft rfd sharedMsgId $ sendDirectContactMessage ct
|
ft' <- maybe (pure ft) (\fId -> withStore $ \db -> getFileTransferMeta db user fId) xftpRedirectFor
|
||||||
withStore' $ \db -> updateSndFTDeliveryXFTP db sft msgDeliveryId
|
toView $ CRSndStandaloneFileComplete user ft' uris
|
||||||
withAgent (`xftpDeleteSndFileInternal` aFileId)
|
Just (AChatItem _ d cInfo _ci@ChatItem {meta = CIMeta {itemSharedMsgId = msgId_, itemDeleted}}) ->
|
||||||
(_, _, SMDSnd, GroupChat g@GroupInfo {groupId}) -> do
|
case (msgId_, itemDeleted) of
|
||||||
ms <- withStore' $ \db -> getGroupMembers db user g
|
(Just sharedMsgId, Nothing) -> do
|
||||||
let rfdsMemberFTs = zip rfds $ memberFTs ms
|
when (length rfds < length sfts) $ throwChatError $ CEInternalError "not enough XFTP file descriptions to send"
|
||||||
extraRFDs = drop (length rfdsMemberFTs) rfds
|
-- TODO either update database status or move to SFPROG
|
||||||
withStore' $ \db -> createExtraSndFTDescrs db user fileId (map fileDescrText extraRFDs)
|
toView $ CRSndFileProgressXFTP user ci ft 1 1
|
||||||
forM_ rfdsMemberFTs $ \mt -> sendToMember mt `catchChatError` (toView . CRChatError (Just user))
|
case (rfds, sfts, d, cInfo) of
|
||||||
ci' <- withStore $ \db -> do
|
(rfd : extraRFDs, sft : _, SMDSnd, DirectChat ct) -> do
|
||||||
liftIO $ updateCIFileStatus db user fileId CIFSSndComplete
|
withStore' $ \db -> createExtraSndFTDescrs db user fileId (map fileDescrText extraRFDs)
|
||||||
getChatItemByFileId db vr user fileId
|
msgDeliveryId <- sendFileDescription sft rfd sharedMsgId $ sendDirectContactMessage ct
|
||||||
withAgent (`xftpDeleteSndFileInternal` aFileId)
|
withStore' $ \db -> updateSndFTDeliveryXFTP db sft msgDeliveryId
|
||||||
toView $ CRSndFileCompleteXFTP user ci' ft
|
withAgent (`xftpDeleteSndFileInternal` aFileId)
|
||||||
where
|
(_, _, SMDSnd, GroupChat g@GroupInfo {groupId}) -> do
|
||||||
memberFTs :: [GroupMember] -> [(Connection, SndFileTransfer)]
|
ms <- withStore' $ \db -> getGroupMembers db user g
|
||||||
memberFTs ms = M.elems $ M.intersectionWith (,) (M.fromList mConns') (M.fromList sfts')
|
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
|
where
|
||||||
mConns' = mapMaybe useMember ms
|
memberFTs :: [GroupMember] -> [(Connection, SndFileTransfer)]
|
||||||
sfts' = mapMaybe (\sft@SndFileTransfer {groupMemberId} -> (,sft) <$> groupMemberId) sfts
|
memberFTs ms = M.elems $ M.intersectionWith (,) (M.fromList mConns') (M.fromList sfts')
|
||||||
useMember GroupMember {groupMemberId, activeConn = Just conn@Connection {connStatus}}
|
where
|
||||||
| (connStatus == ConnReady || connStatus == ConnSndReady) && not (connDisabled conn) = Just (groupMemberId, conn)
|
mConns' = mapMaybe useMember ms
|
||||||
| otherwise = Nothing
|
sfts' = mapMaybe (\sft@SndFileTransfer {groupMemberId} -> (,sft) <$> groupMemberId) sfts
|
||||||
useMember _ = Nothing
|
useMember GroupMember {groupMemberId, activeConn = Just conn@Connection {connStatus}}
|
||||||
sendToMember :: (ValidFileDescription 'FRecipient, (Connection, SndFileTransfer)) -> m ()
|
| (connStatus == ConnReady || connStatus == ConnSndReady) && not (connDisabled conn) = Just (groupMemberId, conn)
|
||||||
sendToMember (rfd, (conn, sft)) =
|
| otherwise = Nothing
|
||||||
void $ sendFileDescription sft rfd sharedMsgId $ \msg' -> sendDirectMessage conn msg' $ GroupId groupId
|
useMember _ = Nothing
|
||||||
_ -> pure ()
|
sendToMember :: (ValidFileDescription 'FRecipient, (Connection, SndFileTransfer)) -> m ()
|
||||||
_ -> pure () -- TODO error?
|
sendToMember (rfd, (conn, sft)) =
|
||||||
|
void $ sendFileDescription sft rfd sharedMsgId $ \msg' -> sendDirectMessage conn msg' $ GroupId groupId
|
||||||
|
_ -> pure ()
|
||||||
|
_ -> pure () -- TODO error?
|
||||||
SFERR e
|
SFERR e
|
||||||
| temporaryAgentError e ->
|
| temporaryAgentError e ->
|
||||||
throwChatError $ CEXFTPSndFile fileId (AgentSndFileId aFileId) e
|
throwChatError $ CEXFTPSndFile fileId (AgentSndFileId aFileId) e
|
||||||
| otherwise -> do
|
| otherwise -> do
|
||||||
ci <- withStore $ \db -> do
|
ci <- withStore $ \db -> do
|
||||||
liftIO $ updateFileCancelled db user fileId CIFSSndError
|
liftIO $ updateFileCancelled db user fileId CIFSSndError
|
||||||
getChatItemByFileId db vr user fileId
|
lookupChatItemByFileId db vr user fileId
|
||||||
withAgent (`xftpDeleteSndFileInternal` aFileId)
|
withAgent (`xftpDeleteSndFileInternal` aFileId)
|
||||||
toView $ CRSndFileError user ci
|
toView $ CRSndFileError user ci ft
|
||||||
where
|
where
|
||||||
fileDescrText :: FilePartyI p => ValidFileDescription p -> T.Text
|
fileDescrText :: FilePartyI p => ValidFileDescription p -> T.Text
|
||||||
fileDescrText = safeDecodeUtf8 . strEncode
|
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 :: SndFileTransfer -> ValidFileDescription 'FRecipient -> SharedMsgId -> (ChatMsgEvent 'Json -> m (SndMessage, Int64)) -> m Int64
|
||||||
sendFileDescription sft rfd msgId sendMsg = do
|
sendFileDescription sft rfd msgId sendMsg = do
|
||||||
let rfdText = fileDescrText rfd
|
let rfdText = fileDescrText rfd
|
||||||
@ -3307,30 +3377,30 @@ processAgentMsgRcvFile _corrId aFileId msg =
|
|||||||
let status = CIFSRcvTransfer {rcvProgress, rcvTotal}
|
let status = CIFSRcvTransfer {rcvProgress, rcvTotal}
|
||||||
ci <- withStore $ \db -> do
|
ci <- withStore $ \db -> do
|
||||||
liftIO $ updateCIFileStatus db user fileId status
|
liftIO $ updateCIFileStatus db user fileId status
|
||||||
getChatItemByFileId db vr user fileId
|
lookupChatItemByFileId db vr user fileId
|
||||||
toView $ CRRcvFileProgressXFTP user ci rcvProgress rcvTotal
|
toView $ CRRcvFileProgressXFTP user ci rcvProgress rcvTotal ft
|
||||||
RFDONE xftpPath ->
|
RFDONE xftpPath ->
|
||||||
case liveRcvFileTransferPath ft of
|
case liveRcvFileTransferPath ft of
|
||||||
Nothing -> throwChatError $ CEInternalError "no target path for received XFTP file"
|
Nothing -> throwChatError $ CEInternalError "no target path for received XFTP file"
|
||||||
Just targetPath -> do
|
Just targetPath -> do
|
||||||
fsTargetPath <- toFSFilePath targetPath
|
fsTargetPath <- toFSFilePath targetPath
|
||||||
renameFile xftpPath fsTargetPath
|
renameFile xftpPath fsTargetPath
|
||||||
ci <- withStore $ \db -> do
|
ci_ <- withStore $ \db -> do
|
||||||
liftIO $ do
|
liftIO $ do
|
||||||
updateRcvFileStatus db fileId FSComplete
|
updateRcvFileStatus db fileId FSComplete
|
||||||
updateCIFileStatus db user fileId CIFSRcvComplete
|
updateCIFileStatus db user fileId CIFSRcvComplete
|
||||||
getChatItemByFileId db vr user fileId
|
lookupChatItemByFileId db vr user fileId
|
||||||
agentXFTPDeleteRcvFile aFileId fileId
|
agentXFTPDeleteRcvFile aFileId fileId
|
||||||
toView $ CRRcvFileComplete user ci
|
toView $ maybe (CRRcvStandaloneFileComplete user fsTargetPath ft) (CRRcvFileComplete user) ci_
|
||||||
RFERR e
|
RFERR e
|
||||||
| temporaryAgentError e ->
|
| temporaryAgentError e ->
|
||||||
throwChatError $ CEXFTPRcvFile fileId (AgentRcvFileId aFileId) e
|
throwChatError $ CEXFTPRcvFile fileId (AgentRcvFileId aFileId) e
|
||||||
| otherwise -> do
|
| otherwise -> do
|
||||||
ci <- withStore $ \db -> do
|
ci <- withStore $ \db -> do
|
||||||
liftIO $ updateFileCancelled db user fileId CIFSRcvError
|
liftIO $ updateFileCancelled db user fileId CIFSRcvError
|
||||||
getChatItemByFileId db vr user fileId
|
lookupChatItemByFileId db vr user fileId
|
||||||
agentXFTPDeleteRcvFile aFileId fileId
|
agentXFTPDeleteRcvFile aFileId fileId
|
||||||
toView $ CRRcvFileError user ci e
|
toView $ CRRcvFileError user ci e ft
|
||||||
|
|
||||||
processAgentMessageConn :: forall m. ChatMonad m => VersionRange -> User -> ACorrId -> ConnId -> ACommand 'Agent 'AEConn -> m ()
|
processAgentMessageConn :: forall m. ChatMonad m => VersionRange -> User -> ACorrId -> ConnId -> ACommand 'Agent 'AEConn -> m ()
|
||||||
processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = do
|
processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = do
|
||||||
@ -3538,18 +3608,15 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage =
|
|||||||
processErr cryptoErr = do
|
processErr cryptoErr = do
|
||||||
let e@(mde, n) = agentMsgDecryptError cryptoErr
|
let e@(mde, n) = agentMsgDecryptError cryptoErr
|
||||||
ci_ <- withStore $ \db ->
|
ci_ <- withStore $ \db ->
|
||||||
getDirectChatItemsLast db user contactId 1 ""
|
getDirectChatItemLast db user contactId
|
||||||
>>= liftIO
|
>>= liftIO
|
||||||
. mapM (\(ci, content') -> updateDirectChatItem' db user contactId ci content' False Nothing)
|
. mapM (\(ci, content') -> updateDirectChatItem' db user contactId ci content' False Nothing)
|
||||||
. (mdeUpdatedCI e <=< headMaybe)
|
. mdeUpdatedCI e
|
||||||
case ci_ of
|
case ci_ of
|
||||||
Just ci -> toView $ CRChatItemUpdated user (AChatItem SCTDirect SMDRcv (DirectChat ct) ci)
|
Just ci -> toView $ CRChatItemUpdated user (AChatItem SCTDirect SMDRcv (DirectChat ct) ci)
|
||||||
_ -> do
|
_ -> do
|
||||||
toView $ CRContactRatchetSync user ct (RatchetSyncProgress rss cStats)
|
toView $ CRContactRatchetSync user ct (RatchetSyncProgress rss cStats)
|
||||||
createInternalChatItem user (CDDirectRcv ct) (CIRcvDecryptionError mde n) Nothing
|
createInternalChatItem user (CDDirectRcv ct) (CIRcvDecryptionError mde n) Nothing
|
||||||
headMaybe = \case
|
|
||||||
x : _ -> Just x
|
|
||||||
_ -> Nothing
|
|
||||||
ratchetSyncEventItem ct' = do
|
ratchetSyncEventItem ct' = do
|
||||||
toView $ CRContactRatchetSync user ct' (RatchetSyncProgress rss cStats)
|
toView $ CRContactRatchetSync user ct' (RatchetSyncProgress rss cStats)
|
||||||
createInternalChatItem user (CDDirectRcv ct') (CIRcvConnEvent $ RCERatchetSync rss) Nothing
|
createInternalChatItem user (CDDirectRcv ct') (CIRcvConnEvent $ RCERatchetSync rss) Nothing
|
||||||
@ -4009,10 +4076,10 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage =
|
|||||||
case err of
|
case err of
|
||||||
SMP SMP.AUTH -> unless (fileStatus == FSCancelled) $ do
|
SMP SMP.AUTH -> unless (fileStatus == FSCancelled) $ do
|
||||||
ci <- withStore $ \db -> do
|
ci <- withStore $ \db -> do
|
||||||
getChatRefByFileId db user fileId >>= \case
|
liftIO (lookupChatRefByFileId db user fileId) >>= \case
|
||||||
ChatRef CTDirect _ -> liftIO $ updateFileCancelled db user fileId CIFSSndCancelled
|
Just (ChatRef CTDirect _) -> liftIO $ updateFileCancelled db user fileId CIFSSndCancelled
|
||||||
_ -> pure ()
|
_ -> pure ()
|
||||||
getChatItemByFileId db vr user fileId
|
lookupChatItemByFileId db vr user fileId
|
||||||
toView $ CRSndFileRcvCancelled user ci ft
|
toView $ CRSndFileRcvCancelled user ci ft
|
||||||
_ -> throwChatError $ CEFileSend fileId err
|
_ -> throwChatError $ CEFileSend fileId err
|
||||||
MSG meta _ _ -> withAckMessage' agentConnId conn meta $ pure ()
|
MSG meta _ _ -> withAckMessage' agentConnId conn meta $ pure ()
|
||||||
@ -5803,7 +5870,7 @@ deleteMembersConnections user members = do
|
|||||||
filter (\Connection {connStatus} -> connStatus /= ConnDeleted) $
|
filter (\Connection {connStatus} -> connStatus /= ConnDeleted) $
|
||||||
mapMaybe (\GroupMember {activeConn} -> activeConn) members
|
mapMaybe (\GroupMember {activeConn} -> activeConn) members
|
||||||
deleteAgentConnectionsAsync user $ map aConnId memberConns
|
deleteAgentConnectionsAsync user $ map aConnId memberConns
|
||||||
forM_ memberConns $ \conn -> withStore' $ \db -> updateConnectionStatus db conn ConnDeleted
|
void . withStoreBatch' $ \db -> map (\conn -> updateConnectionStatus db conn ConnDeleted) memberConns
|
||||||
|
|
||||||
deleteMemberConnection :: ChatMonad m => User -> GroupMember -> m ()
|
deleteMemberConnection :: ChatMonad m => User -> GroupMember -> m ()
|
||||||
deleteMemberConnection user GroupMember {activeConn} = do
|
deleteMemberConnection user GroupMember {activeConn} = do
|
||||||
@ -6118,18 +6185,19 @@ deleteGroupCI user gInfo ci@ChatItem {file} byUser timed byGroupMember_ deletedT
|
|||||||
gItem = AChatItem SCTGroup msgDirection (GroupChat gInfo)
|
gItem = AChatItem SCTGroup msgDirection (GroupChat gInfo)
|
||||||
|
|
||||||
deleteLocalCI :: (ChatMonad m, MsgDirectionI d) => User -> NoteFolder -> ChatItem 'CTLocal d -> Bool -> Bool -> m ChatResponse
|
deleteLocalCI :: (ChatMonad m, MsgDirectionI d) => User -> NoteFolder -> ChatItem 'CTLocal d -> Bool -> Bool -> m ChatResponse
|
||||||
deleteLocalCI user nf ci@ChatItem {file} byUser timed = do
|
deleteLocalCI user nf ci@ChatItem {file = file_} byUser timed = do
|
||||||
forM_ file $ \CIFile {fileSource} -> do
|
forM_ file_ $ \file -> do
|
||||||
forM_ (CF.filePath <$> fileSource) $ \fPath ->
|
let filesInfo = [mkCIFileInfo file]
|
||||||
deleteFileLocally fPath `catchChatError` (toView . CRChatError (Just user))
|
deleteFilesLocally filesInfo
|
||||||
withStore' $ \db -> deleteLocalChatItem db user nf ci
|
withStore' $ \db -> deleteLocalChatItem db user nf ci
|
||||||
pure $ CRChatItemDeleted user (AChatItem SCTLocal msgDirection (LocalChat nf) ci) Nothing byUser timed
|
pure $ CRChatItemDeleted user (AChatItem SCTLocal msgDirection (LocalChat nf) ci) Nothing byUser timed
|
||||||
|
|
||||||
deleteCIFile :: (ChatMonad m, MsgDirectionI d) => User -> Maybe (CIFile d) -> m ()
|
deleteCIFile :: (ChatMonad m, MsgDirectionI d) => User -> Maybe (CIFile d) -> m ()
|
||||||
deleteCIFile user file_ =
|
deleteCIFile user file_ =
|
||||||
forM_ file_ $ \file -> do
|
forM_ file_ $ \file -> do
|
||||||
fileAgentConnIds <- deleteFile' user (mkCIFileInfo file) True
|
let filesInfo = [mkCIFileInfo file]
|
||||||
deleteAgentConnectionsAsync user fileAgentConnIds
|
cancelFilesInProgress user filesInfo
|
||||||
|
deleteFilesLocally filesInfo
|
||||||
|
|
||||||
markDirectCIDeleted :: (ChatMonad m, MsgDirectionI d) => User -> Contact -> ChatItem 'CTDirect d -> MessageId -> Bool -> UTCTime -> m ChatResponse
|
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
|
markDirectCIDeleted user ct ci@ChatItem {file} msgId byUser deletedTs = do
|
||||||
@ -6150,8 +6218,8 @@ markGroupCIDeleted user gInfo ci@ChatItem {file} msgId byUser byGroupMember_ del
|
|||||||
cancelCIFile :: (ChatMonad m, MsgDirectionI d) => User -> Maybe (CIFile d) -> m ()
|
cancelCIFile :: (ChatMonad m, MsgDirectionI d) => User -> Maybe (CIFile d) -> m ()
|
||||||
cancelCIFile user file_ =
|
cancelCIFile user file_ =
|
||||||
forM_ file_ $ \file -> do
|
forM_ file_ $ \file -> do
|
||||||
fileAgentConnIds <- cancelFile' user (mkCIFileInfo file) True
|
let filesInfo = [mkCIFileInfo file]
|
||||||
deleteAgentConnectionsAsync user fileAgentConnIds
|
cancelFilesInProgress user filesInfo
|
||||||
|
|
||||||
createAgentConnectionAsync :: forall m c. (ChatMonad m, ConnectionModeI c) => User -> CommandFunction -> Bool -> SConnectionMode c -> SubscriptionMode -> m (CommandId, ConnId)
|
createAgentConnectionAsync :: forall m c. (ChatMonad m, ConnectionModeI c) => User -> CommandFunction -> Bool -> SConnectionMode c -> SubscriptionMode -> m (CommandId, ConnId)
|
||||||
createAgentConnectionAsync user cmdFunction enableNtfs cMode subMode = do
|
createAgentConnectionAsync user cmdFunction enableNtfs cMode subMode = do
|
||||||
@ -6193,13 +6261,43 @@ agentXFTPDeleteRcvFile aFileId fileId = do
|
|||||||
withAgent (`xftpDeleteRcvFile` aFileId)
|
withAgent (`xftpDeleteRcvFile` aFileId)
|
||||||
withStore' $ \db -> setRcvFTAgentDeleted db fileId
|
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 :: ChatMonad m => User -> XFTPSndFile -> FileTransferId -> m ()
|
||||||
agentXFTPDeleteSndFileRemote user XFTPSndFile {agentSndFileId = AgentSndFileId aFileId, privateSndFileDescr, agentSndFileDeleted} fileId =
|
agentXFTPDeleteSndFileRemote user xsf fileId =
|
||||||
unless agentSndFileDeleted $
|
agentXFTPDeleteSndFilesRemote user [(xsf, fileId)]
|
||||||
forM_ privateSndFileDescr $ \sfdText -> do
|
|
||||||
sd <- parseFileDescription sfdText
|
agentXFTPDeleteSndFilesRemote :: forall m. ChatMonad m => User -> [(XFTPSndFile, FileTransferId)] -> m ()
|
||||||
withAgent $ \a -> xftpDeleteSndFileRemote a (aUserId user) aFileId sd
|
agentXFTPDeleteSndFilesRemote user sndFiles = do
|
||||||
withStore' $ \db -> setSndFTAgentDeleted db user fileId
|
(_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)
|
||||||
|
|
||||||
userProfileToSend :: User -> Maybe Profile -> Maybe Contact -> Bool -> Profile
|
userProfileToSend :: User -> Maybe Profile -> Maybe Contact -> Bool -> Profile
|
||||||
userProfileToSend user@User {profile = p} incognitoProfile ct inGroup = do
|
userProfileToSend user@User {profile = p} incognitoProfile ct inGroup = do
|
||||||
@ -6429,6 +6527,9 @@ chatCommandP =
|
|||||||
"/db encrypt " *> (APIStorageEncryption . dbEncryptionConfig "" <$> dbKeyP),
|
"/db encrypt " *> (APIStorageEncryption . dbEncryptionConfig "" <$> dbKeyP),
|
||||||
"/db key " *> (APIStorageEncryption <$> (dbEncryptionConfig <$> dbKeyP <* A.space <*> dbKeyP)),
|
"/db key " *> (APIStorageEncryption <$> (dbEncryptionConfig <$> dbKeyP <* A.space <*> dbKeyP)),
|
||||||
"/db decrypt " *> (APIStorageEncryption . (`dbEncryptionConfig` "") <$> 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 chat " *> (ExecChatStoreSQL <$> textP),
|
||||||
"/sql agent " *> (ExecAgentStoreSQL <$> textP),
|
"/sql agent " *> (ExecAgentStoreSQL <$> textP),
|
||||||
"/sql slow" $> SlowSQLQueries,
|
"/sql slow" $> SlowSQLQueries,
|
||||||
@ -6486,6 +6587,7 @@ chatCommandP =
|
|||||||
"/_server test " *> (APITestProtoServer <$> A.decimal <* A.space <*> strP),
|
"/_server test " *> (APITestProtoServer <$> A.decimal <* A.space <*> strP),
|
||||||
"/smp test " *> (TestProtoServer . AProtoServerWithAuth SPSMP <$> strP),
|
"/smp test " *> (TestProtoServer . AProtoServerWithAuth SPSMP <$> strP),
|
||||||
"/xftp test " *> (TestProtoServer . AProtoServerWithAuth SPXFTP <$> strP),
|
"/xftp test " *> (TestProtoServer . AProtoServerWithAuth SPXFTP <$> strP),
|
||||||
|
"/ntf test " *> (TestProtoServer . AProtoServerWithAuth SPNTF <$> strP),
|
||||||
"/_servers " *> (APISetUserProtoServers <$> A.decimal <* A.space <*> srvCfgP),
|
"/_servers " *> (APISetUserProtoServers <$> A.decimal <* A.space <*> srvCfgP),
|
||||||
"/smp " *> (SetUserProtoServers . APSC SPSMP . ProtoServersConfig . map toServerCfg <$> protocolServersP),
|
"/smp " *> (SetUserProtoServers . APSC SPSMP . ProtoServersConfig . map toServerCfg <$> protocolServersP),
|
||||||
"/smp default" $> SetUserProtoServers (APSC SPSMP $ ProtoServersConfig []),
|
"/smp default" $> SetUserProtoServers (APSC SPSMP $ ProtoServersConfig []),
|
||||||
@ -6666,6 +6768,8 @@ chatCommandP =
|
|||||||
"/list remote ctrls" $> ListRemoteCtrls,
|
"/list remote ctrls" $> ListRemoteCtrls,
|
||||||
"/stop remote ctrl" $> StopRemoteCtrl,
|
"/stop remote ctrl" $> StopRemoteCtrl,
|
||||||
"/delete remote ctrl " *> (DeleteRemoteCtrl <$> A.decimal),
|
"/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,
|
("/quit" <|> "/q" <|> "/exit") $> QuitChat,
|
||||||
("/version" <|> "/v") $> ShowVersion,
|
("/version" <|> "/v") $> ShowVersion,
|
||||||
"/debug locks" $> DebugLocks,
|
"/debug locks" $> DebugLocks,
|
||||||
@ -6845,3 +6949,29 @@ mkValidName = reverse . dropWhile isSpace . fst3 . foldl' addChar ("", '\NUL', 0
|
|||||||
| isPunctuation prev = validFirstChar || isSpace c || (punct < 3 && isPunctuation c)
|
| isPunctuation prev = validFirstChar || isSpace c || (punct < 3 && isPunctuation c)
|
||||||
| otherwise = validFirstChar || isSpace c || isMark c || isPunctuation c
|
| otherwise = validFirstChar || isSpace c || isMark c || isPunctuation c
|
||||||
validFirstChar = isLetter c || isNumber c || isSymbol 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
src/Simplex/Chat/AppSettings.hs
Normal file
190
src/Simplex/Chat/AppSettings.hs
Normal file
@ -0,0 +1,190 @@
|
|||||||
|
{-# LANGUAGE NamedFieldPuns #-}
|
||||||
|
{-# LANGUAGE OverloadedStrings #-}
|
||||||
|
{-# LANGUAGE StrictData #-}
|
||||||
|
{-# LANGUAGE TemplateHaskell #-}
|
||||||
|
|
||||||
|
module Simplex.Chat.AppSettings where
|
||||||
|
|
||||||
|
import Control.Applicative ((<|>))
|
||||||
|
import Data.Aeson (FromJSON (..), (.:?))
|
||||||
|
import qualified Data.Aeson as J
|
||||||
|
import qualified Data.Aeson.TH as JQ
|
||||||
|
import Data.Maybe (fromMaybe)
|
||||||
|
import Data.Text (Text)
|
||||||
|
import Simplex.Messaging.Client (NetworkConfig, defaultNetworkConfig)
|
||||||
|
import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON)
|
||||||
|
import Simplex.Messaging.Util (catchAll_)
|
||||||
|
|
||||||
|
data AppPlatform = APIOS | APAndroid | APDesktop deriving (Show)
|
||||||
|
|
||||||
|
data NotificationMode = NMOff | NMPeriodic | NMInstant deriving (Show)
|
||||||
|
|
||||||
|
data NotificationPreviewMode = NPMHidden | NPMContact | NPMMessage deriving (Show)
|
||||||
|
|
||||||
|
data LockScreenCalls = LSCDisable | LSCShow | LSCAccept deriving (Show)
|
||||||
|
|
||||||
|
data AppSettings = AppSettings
|
||||||
|
{ appPlatform :: Maybe AppPlatform,
|
||||||
|
networkConfig :: Maybe NetworkConfig,
|
||||||
|
privacyEncryptLocalFiles :: Maybe Bool,
|
||||||
|
privacyAcceptImages :: Maybe Bool,
|
||||||
|
privacyLinkPreviews :: Maybe Bool,
|
||||||
|
privacyShowChatPreviews :: Maybe Bool,
|
||||||
|
privacySaveLastDraft :: Maybe Bool,
|
||||||
|
privacyProtectScreen :: Maybe Bool,
|
||||||
|
notificationMode :: Maybe NotificationMode,
|
||||||
|
notificationPreviewMode :: Maybe NotificationPreviewMode,
|
||||||
|
webrtcPolicyRelay :: Maybe Bool,
|
||||||
|
webrtcICEServers :: Maybe [Text],
|
||||||
|
confirmRemoteSessions :: Maybe Bool,
|
||||||
|
connectRemoteViaMulticast :: Maybe Bool,
|
||||||
|
connectRemoteViaMulticastAuto :: Maybe Bool,
|
||||||
|
developerTools :: Maybe Bool,
|
||||||
|
confirmDBUpgrades :: Maybe Bool,
|
||||||
|
androidCallOnLockScreen :: Maybe LockScreenCalls,
|
||||||
|
iosCallKitEnabled :: Maybe Bool,
|
||||||
|
iosCallKitCallsInRecents :: Maybe Bool
|
||||||
|
}
|
||||||
|
deriving (Show)
|
||||||
|
|
||||||
|
defaultAppSettings :: AppSettings
|
||||||
|
defaultAppSettings =
|
||||||
|
AppSettings
|
||||||
|
{ appPlatform = Nothing,
|
||||||
|
networkConfig = Just defaultNetworkConfig,
|
||||||
|
privacyEncryptLocalFiles = Just True,
|
||||||
|
privacyAcceptImages = Just True,
|
||||||
|
privacyLinkPreviews = Just True,
|
||||||
|
privacyShowChatPreviews = Just True,
|
||||||
|
privacySaveLastDraft = Just True,
|
||||||
|
privacyProtectScreen = Just False,
|
||||||
|
notificationMode = Just NMInstant,
|
||||||
|
notificationPreviewMode = Just NPMMessage,
|
||||||
|
webrtcPolicyRelay = Just True,
|
||||||
|
webrtcICEServers = Just [],
|
||||||
|
confirmRemoteSessions = Just False,
|
||||||
|
connectRemoteViaMulticast = Just True,
|
||||||
|
connectRemoteViaMulticastAuto = Just True,
|
||||||
|
developerTools = Just False,
|
||||||
|
confirmDBUpgrades = Just False,
|
||||||
|
androidCallOnLockScreen = Just LSCShow,
|
||||||
|
iosCallKitEnabled = Just True,
|
||||||
|
iosCallKitCallsInRecents = Just False
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultParseAppSettings :: AppSettings
|
||||||
|
defaultParseAppSettings =
|
||||||
|
AppSettings
|
||||||
|
{ appPlatform = Nothing,
|
||||||
|
networkConfig = Nothing,
|
||||||
|
privacyEncryptLocalFiles = Nothing,
|
||||||
|
privacyAcceptImages = Nothing,
|
||||||
|
privacyLinkPreviews = Nothing,
|
||||||
|
privacyShowChatPreviews = Nothing,
|
||||||
|
privacySaveLastDraft = Nothing,
|
||||||
|
privacyProtectScreen = Nothing,
|
||||||
|
notificationMode = Nothing,
|
||||||
|
notificationPreviewMode = Nothing,
|
||||||
|
webrtcPolicyRelay = Nothing,
|
||||||
|
webrtcICEServers = Nothing,
|
||||||
|
confirmRemoteSessions = Nothing,
|
||||||
|
connectRemoteViaMulticast = Nothing,
|
||||||
|
connectRemoteViaMulticastAuto = Nothing,
|
||||||
|
developerTools = Nothing,
|
||||||
|
confirmDBUpgrades = Nothing,
|
||||||
|
androidCallOnLockScreen = Nothing,
|
||||||
|
iosCallKitEnabled = Nothing,
|
||||||
|
iosCallKitCallsInRecents = Nothing
|
||||||
|
}
|
||||||
|
|
||||||
|
combineAppSettings :: AppSettings -> AppSettings -> AppSettings
|
||||||
|
combineAppSettings platformDefaults storedSettings =
|
||||||
|
AppSettings
|
||||||
|
{ appPlatform = p appPlatform,
|
||||||
|
networkConfig = p networkConfig,
|
||||||
|
privacyEncryptLocalFiles = p privacyEncryptLocalFiles,
|
||||||
|
privacyAcceptImages = p privacyAcceptImages,
|
||||||
|
privacyLinkPreviews = p privacyLinkPreviews,
|
||||||
|
privacyShowChatPreviews = p privacyShowChatPreviews,
|
||||||
|
privacySaveLastDraft = p privacySaveLastDraft,
|
||||||
|
privacyProtectScreen = p privacyProtectScreen,
|
||||||
|
notificationMode = p notificationMode,
|
||||||
|
notificationPreviewMode = p notificationPreviewMode,
|
||||||
|
webrtcPolicyRelay = p webrtcPolicyRelay,
|
||||||
|
webrtcICEServers = p webrtcICEServers,
|
||||||
|
confirmRemoteSessions = p confirmRemoteSessions,
|
||||||
|
connectRemoteViaMulticast = p connectRemoteViaMulticast,
|
||||||
|
connectRemoteViaMulticastAuto = p connectRemoteViaMulticastAuto,
|
||||||
|
developerTools = p developerTools,
|
||||||
|
confirmDBUpgrades = p confirmDBUpgrades,
|
||||||
|
iosCallKitEnabled = p iosCallKitEnabled,
|
||||||
|
iosCallKitCallsInRecents = p iosCallKitCallsInRecents,
|
||||||
|
androidCallOnLockScreen = p androidCallOnLockScreen
|
||||||
|
}
|
||||||
|
where
|
||||||
|
p :: (AppSettings -> Maybe a) -> Maybe a
|
||||||
|
p sel = sel storedSettings <|> sel platformDefaults <|> sel defaultAppSettings
|
||||||
|
|
||||||
|
$(JQ.deriveJSON (enumJSON $ dropPrefix "AP") ''AppPlatform)
|
||||||
|
|
||||||
|
$(JQ.deriveJSON (enumJSON $ dropPrefix "NM") ''NotificationMode)
|
||||||
|
|
||||||
|
$(JQ.deriveJSON (enumJSON $ dropPrefix "NPM") ''NotificationPreviewMode)
|
||||||
|
|
||||||
|
$(JQ.deriveJSON (enumJSON $ dropPrefix "LSC") ''LockScreenCalls)
|
||||||
|
|
||||||
|
$(JQ.deriveToJSON defaultJSON ''AppSettings)
|
||||||
|
|
||||||
|
instance FromJSON AppSettings where
|
||||||
|
parseJSON (J.Object v) = do
|
||||||
|
appPlatform <- p "appPlatform"
|
||||||
|
networkConfig <- p "networkConfig"
|
||||||
|
privacyEncryptLocalFiles <- p "privacyEncryptLocalFiles"
|
||||||
|
privacyAcceptImages <- p "privacyAcceptImages"
|
||||||
|
privacyLinkPreviews <- p "privacyLinkPreviews"
|
||||||
|
privacyShowChatPreviews <- p "privacyShowChatPreviews"
|
||||||
|
privacySaveLastDraft <- p "privacySaveLastDraft"
|
||||||
|
privacyProtectScreen <- p "privacyProtectScreen"
|
||||||
|
notificationMode <- p "notificationMode"
|
||||||
|
notificationPreviewMode <- p "notificationPreviewMode"
|
||||||
|
webrtcPolicyRelay <- p "webrtcPolicyRelay"
|
||||||
|
webrtcICEServers <- p "webrtcICEServers"
|
||||||
|
confirmRemoteSessions <- p "confirmRemoteSessions"
|
||||||
|
connectRemoteViaMulticast <- p "connectRemoteViaMulticast"
|
||||||
|
connectRemoteViaMulticastAuto <- p "connectRemoteViaMulticastAuto"
|
||||||
|
developerTools <- p "developerTools"
|
||||||
|
confirmDBUpgrades <- p "confirmDBUpgrades"
|
||||||
|
iosCallKitEnabled <- p "iosCallKitEnabled"
|
||||||
|
iosCallKitCallsInRecents <- p "iosCallKitCallsInRecents"
|
||||||
|
androidCallOnLockScreen <- p "androidCallOnLockScreen"
|
||||||
|
pure
|
||||||
|
AppSettings
|
||||||
|
{ appPlatform,
|
||||||
|
networkConfig,
|
||||||
|
privacyEncryptLocalFiles,
|
||||||
|
privacyAcceptImages,
|
||||||
|
privacyLinkPreviews,
|
||||||
|
privacyShowChatPreviews,
|
||||||
|
privacySaveLastDraft,
|
||||||
|
privacyProtectScreen,
|
||||||
|
notificationMode,
|
||||||
|
notificationPreviewMode,
|
||||||
|
webrtcPolicyRelay,
|
||||||
|
webrtcICEServers,
|
||||||
|
confirmRemoteSessions,
|
||||||
|
connectRemoteViaMulticast,
|
||||||
|
connectRemoteViaMulticastAuto,
|
||||||
|
developerTools,
|
||||||
|
confirmDBUpgrades,
|
||||||
|
iosCallKitEnabled,
|
||||||
|
iosCallKitCallsInRecents,
|
||||||
|
androidCallOnLockScreen
|
||||||
|
}
|
||||||
|
where
|
||||||
|
p key = v .:? key <|> pure Nothing
|
||||||
|
parseJSON _ = pure defaultParseAppSettings
|
||||||
|
|
||||||
|
readAppSettings :: FilePath -> Maybe AppSettings -> IO AppSettings
|
||||||
|
readAppSettings f platformDefaults =
|
||||||
|
combineAppSettings (fromMaybe defaultAppSettings platformDefaults) . fromMaybe defaultParseAppSettings
|
||||||
|
<$> (J.decodeFileStrict f `catchAll_` pure Nothing)
|
@ -9,6 +9,7 @@ module Simplex.Chat.Archive
|
|||||||
importArchive,
|
importArchive,
|
||||||
deleteStorage,
|
deleteStorage,
|
||||||
sqlCipherExport,
|
sqlCipherExport,
|
||||||
|
sqlCipherTestKey,
|
||||||
archiveFilesFolder,
|
archiveFilesFolder,
|
||||||
)
|
)
|
||||||
where
|
where
|
||||||
@ -20,6 +21,7 @@ import Control.Monad.Reader
|
|||||||
import qualified Data.ByteArray as BA
|
import qualified Data.ByteArray as BA
|
||||||
import Data.Functor (($>))
|
import Data.Functor (($>))
|
||||||
import Data.Maybe (fromMaybe)
|
import Data.Maybe (fromMaybe)
|
||||||
|
import Data.Text (Text)
|
||||||
import qualified Data.Text as T
|
import qualified Data.Text as T
|
||||||
import qualified Database.SQLite3 as SQL
|
import qualified Database.SQLite3 as SQL
|
||||||
import Simplex.Chat.Controller
|
import Simplex.Chat.Controller
|
||||||
@ -147,19 +149,8 @@ sqlCipherExport DBEncryptionConfig {currentKey = DBEncryptionKey key, newKey = D
|
|||||||
atomically $ writeTVar dbKey $ storeKey key' (fromMaybe False keepKey)
|
atomically $ writeTVar dbKey $ storeKey key' (fromMaybe False keepKey)
|
||||||
export f = do
|
export f = do
|
||||||
withDB f (`SQL.exec` exportSQL) DBErrorExport
|
withDB f (`SQL.exec` exportSQL) DBErrorExport
|
||||||
withDB (exported f) (`SQL.exec` testSQL) DBErrorOpen
|
withDB (exported f) (`SQL.exec` testSQL key') DBErrorOpen
|
||||||
where
|
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 =
|
exportSQL =
|
||||||
T.unlines $
|
T.unlines $
|
||||||
keySQL key
|
keySQL key
|
||||||
@ -167,14 +158,38 @@ sqlCipherExport DBEncryptionConfig {currentKey = DBEncryptionKey key, newKey = D
|
|||||||
"SELECT sqlcipher_export('exported');",
|
"SELECT sqlcipher_export('exported');",
|
||||||
"DETACH DATABASE exported;"
|
"DETACH DATABASE exported;"
|
||||||
]
|
]
|
||||||
testSQL =
|
|
||||||
T.unlines $
|
withDB :: forall a m. ChatMonad m => FilePath -> (SQL.Database -> IO a) -> (SQLiteError -> DatabaseError) -> m ()
|
||||||
keySQL key'
|
withDB f' a err =
|
||||||
<> [ "PRAGMA foreign_keys = ON;",
|
liftIO (bracket (SQL.open $ T.pack f') SQL.close a $> Nothing)
|
||||||
"PRAGMA secure_delete = ON;",
|
`catch` checkSQLError
|
||||||
"SELECT count(*) FROM sqlite_master;"
|
`catch` (\(e :: SomeException) -> sqliteError' e)
|
||||||
]
|
>>= mapM_ (throwDBError . err)
|
||||||
keySQL k = ["PRAGMA key = " <> keyString k <> ";" | not (BA.null k)]
|
where
|
||||||
|
checkSQLError e = case SQL.sqlError e of
|
||||||
|
SQL.ErrorNotADatabase -> pure $ Just SQLiteErrorNotADatabase
|
||||||
|
_ -> sqliteError' e
|
||||||
|
sqliteError' :: Show e => e -> m (Maybe SQLiteError)
|
||||||
|
sqliteError' = pure . Just . SQLiteError . show
|
||||||
|
|
||||||
|
testSQL :: BA.ScrubbedBytes -> Text
|
||||||
|
testSQL k =
|
||||||
|
T.unlines $
|
||||||
|
keySQL k
|
||||||
|
<> [ "PRAGMA foreign_keys = ON;",
|
||||||
|
"PRAGMA secure_delete = ON;",
|
||||||
|
"SELECT count(*) FROM sqlite_master;"
|
||||||
|
]
|
||||||
|
|
||||||
|
keySQL :: BA.ScrubbedBytes -> [Text]
|
||||||
|
keySQL k = ["PRAGMA key = " <> keyString k <> ";" | not (BA.null k)]
|
||||||
|
|
||||||
|
sqlCipherTestKey :: forall m. ChatMonad m => DBEncryptionKey -> m ()
|
||||||
|
sqlCipherTestKey (DBEncryptionKey key) = do
|
||||||
|
fs <- storageFiles
|
||||||
|
testKey `withDBs` fs
|
||||||
|
where
|
||||||
|
testKey f = withDB f (`SQL.exec` testSQL key) DBErrorOpen
|
||||||
|
|
||||||
withDBs :: Monad m => (FilePath -> m b) -> StorageFiles -> m b
|
withDBs :: Monad m => (FilePath -> m b) -> StorageFiles -> m b
|
||||||
action `withDBs` StorageFiles {chatStore, agentStore} = action (dbFilePath chatStore) >> action (dbFilePath agentStore)
|
action `withDBs` StorageFiles {chatStore, agentStore} = action (dbFilePath chatStore) >> action (dbFilePath agentStore)
|
||||||
|
@ -49,6 +49,7 @@ import Data.Word (Word16)
|
|||||||
import Language.Haskell.TH (Exp, Q, runIO)
|
import Language.Haskell.TH (Exp, Q, runIO)
|
||||||
import Numeric.Natural
|
import Numeric.Natural
|
||||||
import qualified Paths_simplex_chat as SC
|
import qualified Paths_simplex_chat as SC
|
||||||
|
import Simplex.Chat.AppSettings
|
||||||
import Simplex.Chat.Call
|
import Simplex.Chat.Call
|
||||||
import Simplex.Chat.Markdown (MarkdownList)
|
import Simplex.Chat.Markdown (MarkdownList)
|
||||||
import Simplex.Chat.Messages
|
import Simplex.Chat.Messages
|
||||||
@ -59,6 +60,7 @@ import Simplex.Chat.Remote.Types
|
|||||||
import Simplex.Chat.Store (AutoAccept, StoreError (..), UserContactLink, UserMsgReceiptSettings)
|
import Simplex.Chat.Store (AutoAccept, StoreError (..), UserContactLink, UserMsgReceiptSettings)
|
||||||
import Simplex.Chat.Types
|
import Simplex.Chat.Types
|
||||||
import Simplex.Chat.Types.Preferences
|
import Simplex.Chat.Types.Preferences
|
||||||
|
import Simplex.FileTransfer.Description (FileDescriptionURI)
|
||||||
import Simplex.Messaging.Agent (AgentClient, SubscriptionsInfo)
|
import Simplex.Messaging.Agent (AgentClient, SubscriptionsInfo)
|
||||||
import Simplex.Messaging.Agent.Client (AgentLocks, AgentWorkersDetails (..), AgentWorkersSummary (..), ProtocolTestFailure)
|
import Simplex.Messaging.Agent.Client (AgentLocks, AgentWorkersDetails (..), AgentWorkersSummary (..), ProtocolTestFailure)
|
||||||
import Simplex.Messaging.Agent.Env.SQLite (AgentConfig, NetworkConfig)
|
import Simplex.Messaging.Agent.Env.SQLite (AgentConfig, NetworkConfig)
|
||||||
@ -244,8 +246,11 @@ data ChatCommand
|
|||||||
| APIExportArchive ArchiveConfig
|
| APIExportArchive ArchiveConfig
|
||||||
| ExportArchive
|
| ExportArchive
|
||||||
| APIImportArchive ArchiveConfig
|
| APIImportArchive ArchiveConfig
|
||||||
|
| APISaveAppSettings AppSettings
|
||||||
|
| APIGetAppSettings (Maybe AppSettings)
|
||||||
| APIDeleteStorage
|
| APIDeleteStorage
|
||||||
| APIStorageEncryption DBEncryptionConfig
|
| APIStorageEncryption DBEncryptionConfig
|
||||||
|
| TestStorageEncryption DBEncryptionKey
|
||||||
| ExecChatStoreSQL Text
|
| ExecChatStoreSQL Text
|
||||||
| ExecAgentStoreSQL Text
|
| ExecAgentStoreSQL Text
|
||||||
| SlowSQLQueries
|
| SlowSQLQueries
|
||||||
@ -448,6 +453,8 @@ data ChatCommand
|
|||||||
| ListRemoteCtrls
|
| ListRemoteCtrls
|
||||||
| StopRemoteCtrl -- Stop listening for announcements or terminate an active session
|
| StopRemoteCtrl -- Stop listening for announcements or terminate an active session
|
||||||
| DeleteRemoteCtrl RemoteCtrlId -- Remove all local data associated with a remote controller session
|
| DeleteRemoteCtrl RemoteCtrlId -- Remove all local data associated with a remote controller session
|
||||||
|
| APIUploadStandaloneFile UserId CryptoFile
|
||||||
|
| APIDownloadStandaloneFile UserId FileDescriptionURI CryptoFile
|
||||||
| QuitChat
|
| QuitChat
|
||||||
| ShowVersion
|
| ShowVersion
|
||||||
| DebugLocks
|
| DebugLocks
|
||||||
@ -587,21 +594,26 @@ data ChatResponse
|
|||||||
| CRRcvFileAccepted {user :: User, chatItem :: AChatItem}
|
| CRRcvFileAccepted {user :: User, chatItem :: AChatItem}
|
||||||
| CRRcvFileAcceptedSndCancelled {user :: User, rcvFileTransfer :: RcvFileTransfer}
|
| CRRcvFileAcceptedSndCancelled {user :: User, rcvFileTransfer :: RcvFileTransfer}
|
||||||
| CRRcvFileDescrNotReady {user :: User, chatItem :: AChatItem}
|
| CRRcvFileDescrNotReady {user :: User, chatItem :: AChatItem}
|
||||||
| CRRcvFileStart {user :: User, chatItem :: AChatItem}
|
| CRRcvStandaloneFileCreated {user :: User, rcvFileTransfer :: RcvFileTransfer} -- returned by _download
|
||||||
| CRRcvFileProgressXFTP {user :: User, chatItem :: AChatItem, receivedSize :: Int64, totalSize :: Int64}
|
| CRRcvFileStart {user :: User, chatItem :: AChatItem} -- sent by chats
|
||||||
|
| CRRcvFileProgressXFTP {user :: User, chatItem_ :: Maybe AChatItem, receivedSize :: Int64, totalSize :: Int64, rcvFileTransfer :: RcvFileTransfer}
|
||||||
| CRRcvFileComplete {user :: User, chatItem :: AChatItem}
|
| CRRcvFileComplete {user :: User, chatItem :: AChatItem}
|
||||||
| CRRcvFileCancelled {user :: User, chatItem :: AChatItem, rcvFileTransfer :: RcvFileTransfer}
|
| CRRcvStandaloneFileComplete {user :: User, targetPath :: FilePath, rcvFileTransfer :: RcvFileTransfer}
|
||||||
|
| CRRcvFileCancelled {user :: User, chatItem_ :: Maybe AChatItem, rcvFileTransfer :: RcvFileTransfer}
|
||||||
| CRRcvFileSndCancelled {user :: User, chatItem :: AChatItem, rcvFileTransfer :: RcvFileTransfer}
|
| CRRcvFileSndCancelled {user :: User, chatItem :: AChatItem, rcvFileTransfer :: RcvFileTransfer}
|
||||||
| CRRcvFileError {user :: User, chatItem :: AChatItem, agentError :: AgentErrorType}
|
| CRRcvFileError {user :: User, chatItem_ :: Maybe AChatItem, agentError :: AgentErrorType, rcvFileTransfer :: RcvFileTransfer}
|
||||||
| CRSndFileStart {user :: User, chatItem :: AChatItem, sndFileTransfer :: SndFileTransfer}
|
| CRSndFileStart {user :: User, chatItem :: AChatItem, sndFileTransfer :: SndFileTransfer}
|
||||||
| CRSndFileComplete {user :: User, chatItem :: AChatItem, sndFileTransfer :: SndFileTransfer}
|
| CRSndFileComplete {user :: User, chatItem :: AChatItem, sndFileTransfer :: SndFileTransfer}
|
||||||
| CRSndFileRcvCancelled {user :: User, chatItem :: AChatItem, sndFileTransfer :: SndFileTransfer}
|
| CRSndFileRcvCancelled {user :: User, chatItem_ :: Maybe AChatItem, sndFileTransfer :: SndFileTransfer}
|
||||||
| CRSndFileCancelled {user :: User, chatItem :: AChatItem, fileTransferMeta :: FileTransferMeta, sndFileTransfers :: [SndFileTransfer]}
|
| CRSndFileCancelled {user :: User, chatItem_ :: Maybe AChatItem, fileTransferMeta :: FileTransferMeta, sndFileTransfers :: [SndFileTransfer]}
|
||||||
| CRSndFileStartXFTP {user :: User, chatItem :: AChatItem, fileTransferMeta :: FileTransferMeta}
|
| CRSndStandaloneFileCreated {user :: User, fileTransferMeta :: FileTransferMeta} -- returned by _upload
|
||||||
| CRSndFileProgressXFTP {user :: User, chatItem :: AChatItem, fileTransferMeta :: FileTransferMeta, sentSize :: Int64, totalSize :: Int64}
|
| CRSndFileStartXFTP {user :: User, chatItem :: AChatItem, fileTransferMeta :: FileTransferMeta} -- not used
|
||||||
|
| CRSndFileProgressXFTP {user :: User, chatItem_ :: Maybe AChatItem, fileTransferMeta :: FileTransferMeta, sentSize :: Int64, totalSize :: Int64}
|
||||||
|
| CRSndFileRedirectStartXFTP {user :: User, fileTransferMeta :: FileTransferMeta, redirectMeta :: FileTransferMeta}
|
||||||
| CRSndFileCompleteXFTP {user :: User, chatItem :: AChatItem, fileTransferMeta :: FileTransferMeta}
|
| CRSndFileCompleteXFTP {user :: User, chatItem :: AChatItem, fileTransferMeta :: FileTransferMeta}
|
||||||
| CRSndFileCancelledXFTP {user :: User, chatItem :: AChatItem, fileTransferMeta :: FileTransferMeta}
|
| CRSndStandaloneFileComplete {user :: User, fileTransferMeta :: FileTransferMeta, rcvURIs :: [Text]}
|
||||||
| CRSndFileError {user :: User, chatItem :: AChatItem}
|
| CRSndFileCancelledXFTP {user :: User, chatItem_ :: Maybe AChatItem, fileTransferMeta :: FileTransferMeta}
|
||||||
|
| CRSndFileError {user :: User, chatItem_ :: Maybe AChatItem, fileTransferMeta :: FileTransferMeta}
|
||||||
| CRUserProfileUpdated {user :: User, fromProfile :: Profile, toProfile :: Profile, updateSummary :: UserProfileUpdateSummary}
|
| CRUserProfileUpdated {user :: User, fromProfile :: Profile, toProfile :: Profile, updateSummary :: UserProfileUpdateSummary}
|
||||||
| CRUserProfileImage {user :: User, profile :: Profile}
|
| CRUserProfileImage {user :: User, profile :: Profile}
|
||||||
| CRContactAliasUpdated {user :: User, toContact :: Contact}
|
| CRContactAliasUpdated {user :: User, toContact :: Contact}
|
||||||
@ -667,7 +679,7 @@ data ChatResponse
|
|||||||
| CRUserContactLinkSubscribed -- TODO delete
|
| CRUserContactLinkSubscribed -- TODO delete
|
||||||
| CRUserContactLinkSubError {chatError :: ChatError} -- TODO delete
|
| CRUserContactLinkSubError {chatError :: ChatError} -- TODO delete
|
||||||
| CRNtfTokenStatus {status :: NtfTknStatus}
|
| CRNtfTokenStatus {status :: NtfTknStatus}
|
||||||
| CRNtfToken {token :: DeviceToken, status :: NtfTknStatus, ntfMode :: NotificationsMode}
|
| CRNtfToken {token :: DeviceToken, status :: NtfTknStatus, ntfMode :: NotificationsMode, ntfServer :: NtfServer}
|
||||||
| CRNtfMessages {user_ :: Maybe User, connEntity_ :: Maybe ConnectionEntity, msgTs :: Maybe UTCTime, ntfMessages :: [NtfMsgInfo]}
|
| CRNtfMessages {user_ :: Maybe User, connEntity_ :: Maybe ConnectionEntity, msgTs :: Maybe UTCTime, ntfMessages :: [NtfMsgInfo]}
|
||||||
| CRNtfMessage {user :: User, connEntity :: ConnectionEntity, ntfMessage :: NtfMsgInfo}
|
| CRNtfMessage {user :: User, connEntity :: ConnectionEntity, ntfMessage :: NtfMsgInfo}
|
||||||
| CRContactConnectionDeleted {user :: User, connection :: PendingContactConnection}
|
| CRContactConnectionDeleted {user :: User, connection :: PendingContactConnection}
|
||||||
@ -702,6 +714,7 @@ data ChatResponse
|
|||||||
| CRChatError {user_ :: Maybe User, chatError :: ChatError}
|
| CRChatError {user_ :: Maybe User, chatError :: ChatError}
|
||||||
| CRChatErrors {user_ :: Maybe User, chatErrors :: [ChatError]}
|
| CRChatErrors {user_ :: Maybe User, chatErrors :: [ChatError]}
|
||||||
| CRArchiveImported {archiveErrors :: [ArchiveError]}
|
| CRArchiveImported {archiveErrors :: [ArchiveError]}
|
||||||
|
| CRAppSettings {appSettings :: AppSettings}
|
||||||
| CRTimedAction {action :: String, durationMilliseconds :: Int64}
|
| CRTimedAction {action :: String, durationMilliseconds :: Int64}
|
||||||
deriving (Show)
|
deriving (Show)
|
||||||
|
|
||||||
@ -935,8 +948,8 @@ data NtfMsgInfo = NtfMsgInfo {msgId :: Text, msgTs :: UTCTime}
|
|||||||
ntfMsgInfo :: SMPMsgMeta -> NtfMsgInfo
|
ntfMsgInfo :: SMPMsgMeta -> NtfMsgInfo
|
||||||
ntfMsgInfo SMPMsgMeta {msgId, msgTs} = NtfMsgInfo {msgId = decodeLatin1 $ strEncode msgId, msgTs = systemToUTCTime msgTs}
|
ntfMsgInfo SMPMsgMeta {msgId, msgTs} = NtfMsgInfo {msgId = decodeLatin1 $ strEncode msgId, msgTs = systemToUTCTime msgTs}
|
||||||
|
|
||||||
crNtfToken :: (DeviceToken, NtfTknStatus, NotificationsMode) -> ChatResponse
|
crNtfToken :: (DeviceToken, NtfTknStatus, NotificationsMode, NtfServer) -> ChatResponse
|
||||||
crNtfToken (token, status, ntfMode) = CRNtfToken {token, status, ntfMode}
|
crNtfToken (token, status, ntfMode, ntfServer) = CRNtfToken {token, status, ntfMode, ntfServer}
|
||||||
|
|
||||||
data SwitchProgress = SwitchProgress
|
data SwitchProgress = SwitchProgress
|
||||||
{ queueDirection :: QueueDirection,
|
{ queueDirection :: QueueDirection,
|
||||||
@ -1239,6 +1252,14 @@ mkChatError :: SomeException -> ChatError
|
|||||||
mkChatError = ChatError . CEException . show
|
mkChatError = ChatError . CEException . show
|
||||||
{-# INLINE mkChatError #-}
|
{-# 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 :: Maybe User -> String -> ChatResponse
|
||||||
chatCmdError user = CRChatCmdError user . ChatError . CECommandError
|
chatCmdError user = CRChatCmdError user . ChatError . CECommandError
|
||||||
|
|
||||||
|
@ -30,10 +30,11 @@ import qualified Data.Text as T
|
|||||||
import Data.Text.Encoding (encodeUtf8)
|
import Data.Text.Encoding (encodeUtf8)
|
||||||
import Simplex.Chat.Types
|
import Simplex.Chat.Types
|
||||||
import Simplex.Chat.Types.Util
|
import Simplex.Chat.Types.Util
|
||||||
import Simplex.Messaging.Agent.Protocol (AConnectionRequestUri (..), ConnReqScheme (..), ConnReqUriData (..), ConnectionRequestUri (..), SMPQueue (..))
|
import Simplex.Messaging.Agent.Protocol (AConnectionRequestUri (..), ConnReqUriData (..), ConnectionRequestUri (..), SMPQueue (..))
|
||||||
import Simplex.Messaging.Encoding.String
|
import Simplex.Messaging.Encoding.String
|
||||||
import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, fstToLower, sumTypeJSON)
|
import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, fstToLower, sumTypeJSON)
|
||||||
import Simplex.Messaging.Protocol (ProtocolServer (..))
|
import Simplex.Messaging.Protocol (ProtocolServer (..))
|
||||||
|
import Simplex.Messaging.ServiceScheme (ServiceScheme (..))
|
||||||
import Simplex.Messaging.Util (safeDecodeUtf8)
|
import Simplex.Messaging.Util (safeDecodeUtf8)
|
||||||
import System.Console.ANSI.Types
|
import System.Console.ANSI.Types
|
||||||
import qualified Text.Email.Validate as Email
|
import qualified Text.Email.Validate as Email
|
||||||
@ -231,10 +232,10 @@ markdownP = mconcat <$> A.many' fragmentP
|
|||||||
simplexUriFormat :: AConnectionRequestUri -> Format
|
simplexUriFormat :: AConnectionRequestUri -> Format
|
||||||
simplexUriFormat = \case
|
simplexUriFormat = \case
|
||||||
ACR _ (CRContactUri crData) ->
|
ACR _ (CRContactUri crData) ->
|
||||||
let uri = safeDecodeUtf8 . strEncode $ CRContactUri crData {crScheme = CRSSimplex}
|
let uri = safeDecodeUtf8 . strEncode $ CRContactUri crData {crScheme = SSSimplex}
|
||||||
in SimplexLink (linkType' crData) uri $ uriHosts crData
|
in SimplexLink (linkType' crData) uri $ uriHosts crData
|
||||||
ACR _ (CRInvitationUri crData e2e) ->
|
ACR _ (CRInvitationUri crData e2e) ->
|
||||||
let uri = safeDecodeUtf8 . strEncode $ CRInvitationUri crData {crScheme = CRSSimplex} e2e
|
let uri = safeDecodeUtf8 . strEncode $ CRInvitationUri crData {crScheme = SSSimplex} e2e
|
||||||
in SimplexLink XLInvitation uri $ uriHosts crData
|
in SimplexLink XLInvitation uri $ uriHosts crData
|
||||||
where
|
where
|
||||||
uriHosts ConnReqUriData {crSmpQueues} = L.map (safeDecodeUtf8 . strEncode) $ sconcat $ L.map (host . qServer) crSmpQueues
|
uriHosts ConnReqUriData {crSmpQueues} = L.map (safeDecodeUtf8 . strEncode) $ sconcat $ L.map (host . qServer) crSmpQueues
|
||||||
|
@ -360,6 +360,24 @@ mkCIMeta itemId itemContent itemText itemStatus itemSharedMsgId itemDeleted item
|
|||||||
_ -> False
|
_ -> False
|
||||||
in CIMeta {itemId, itemTs, itemText, itemStatus, itemSharedMsgId, itemDeleted, itemEdited, itemTimed, itemLive, editable, forwardedByMember, createdAt, updatedAt}
|
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
|
data CITimed = CITimed
|
||||||
{ ttl :: Int, -- seconds
|
{ ttl :: Int, -- seconds
|
||||||
deleteAt :: Maybe UTCTime -- this is initially Nothing for received items, the timer starts when they are read
|
deleteAt :: Maybe UTCTime -- this is initially Nothing for received items, the timer starts when they are read
|
||||||
|
@ -139,7 +139,7 @@ data CIContent (d :: MsgDirection) where
|
|||||||
CISndModerated :: CIContent 'MDSnd
|
CISndModerated :: CIContent 'MDSnd
|
||||||
CIRcvModerated :: CIContent 'MDRcv
|
CIRcvModerated :: CIContent 'MDRcv
|
||||||
CIRcvBlocked :: CIContent 'MDRcv
|
CIRcvBlocked :: CIContent 'MDRcv
|
||||||
CIInvalidJSON :: Text -> CIContent d
|
CIInvalidJSON :: Text -> CIContent d -- this is also used for logical database errors, e.g. SEBadChatItem
|
||||||
-- ^ This type is used both in API and in DB, so we use different JSON encodings for the database and for the API
|
-- ^ 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
|
-- ! ^ Nested sum types also have to use different encodings for database and API
|
||||||
-- ! ^ to avoid breaking cross-platform compatibility, see RcvGroupEvent and SndGroupEvent
|
-- ! ^ to avoid breaking cross-platform compatibility, see RcvGroupEvent and SndGroupEvent
|
||||||
@ -172,7 +172,7 @@ ciRequiresAttention content = case msgDirection @d of
|
|||||||
CIRcvGroupInvitation {} -> True
|
CIRcvGroupInvitation {} -> True
|
||||||
CIRcvDirectEvent rde -> case rde of
|
CIRcvDirectEvent rde -> case rde of
|
||||||
RDEContactDeleted -> False
|
RDEContactDeleted -> False
|
||||||
RDEProfileUpdated {} -> True
|
RDEProfileUpdated {} -> False
|
||||||
CIRcvGroupEvent rge -> case rge of
|
CIRcvGroupEvent rge -> case rge of
|
||||||
RGEMemberAdded {} -> False
|
RGEMemberAdded {} -> False
|
||||||
RGEMemberConnected -> False
|
RGEMemberConnected -> False
|
||||||
|
22
src/Simplex/Chat/Migrations/M20240214_redirect_file_id.hs
Normal file
22
src/Simplex/Chat/Migrations/M20240214_redirect_file_id.hs
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
{-# LANGUAGE QuasiQuotes #-}
|
||||||
|
|
||||||
|
module Simplex.Chat.Migrations.M20240214_redirect_file_id where
|
||||||
|
|
||||||
|
import Database.SQLite.Simple (Query)
|
||||||
|
import Database.SQLite.Simple.QQ (sql)
|
||||||
|
|
||||||
|
m20240214_redirect_file_id :: Query
|
||||||
|
m20240214_redirect_file_id =
|
||||||
|
[sql|
|
||||||
|
ALTER TABLE files ADD COLUMN redirect_file_id INTEGER REFERENCES files ON DELETE CASCADE;
|
||||||
|
|
||||||
|
CREATE INDEX idx_files_redirect_file_id on files(redirect_file_id);
|
||||||
|
|]
|
||||||
|
|
||||||
|
down_m20240214_redirect_file_id :: Query
|
||||||
|
down_m20240214_redirect_file_id =
|
||||||
|
[sql|
|
||||||
|
DROP INDEX idx_files_redirect_file_id;
|
||||||
|
|
||||||
|
ALTER TABLE files DROP COLUMN redirect_file_id;
|
||||||
|
|]
|
20
src/Simplex/Chat/Migrations/M20240222_app_settings.hs
Normal file
20
src/Simplex/Chat/Migrations/M20240222_app_settings.hs
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
{-# LANGUAGE QuasiQuotes #-}
|
||||||
|
|
||||||
|
module Simplex.Chat.Migrations.M20240222_app_settings where
|
||||||
|
|
||||||
|
import Database.SQLite.Simple (Query)
|
||||||
|
import Database.SQLite.Simple.QQ (sql)
|
||||||
|
|
||||||
|
m20240222_app_settings :: Query
|
||||||
|
m20240222_app_settings =
|
||||||
|
[sql|
|
||||||
|
CREATE TABLE app_settings (
|
||||||
|
app_settings TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|]
|
||||||
|
|
||||||
|
down_m20240222_app_settings :: Query
|
||||||
|
down_m20240222_app_settings =
|
||||||
|
[sql|
|
||||||
|
DROP TABLE app_settings;
|
||||||
|
|]
|
30
src/Simplex/Chat/Migrations/M20240226_users_restrict.hs
Normal file
30
src/Simplex/Chat/Migrations/M20240226_users_restrict.hs
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
{-# LANGUAGE QuasiQuotes #-}
|
||||||
|
|
||||||
|
module Simplex.Chat.Migrations.M20240226_users_restrict where
|
||||||
|
|
||||||
|
import Database.SQLite.Simple (Query)
|
||||||
|
import Database.SQLite.Simple.QQ (sql)
|
||||||
|
|
||||||
|
m20240226_users_restrict :: Query
|
||||||
|
m20240226_users_restrict =
|
||||||
|
[sql|
|
||||||
|
PRAGMA writable_schema=1;
|
||||||
|
|
||||||
|
UPDATE sqlite_master
|
||||||
|
SET sql = replace(sql, 'ON DELETE CASCADE', 'ON DELETE RESTRICT')
|
||||||
|
WHERE name = 'users' AND type = 'table';
|
||||||
|
|
||||||
|
PRAGMA writable_schema=0;
|
||||||
|
|]
|
||||||
|
|
||||||
|
down_m20240226_users_restrict :: Query
|
||||||
|
down_m20240226_users_restrict =
|
||||||
|
[sql|
|
||||||
|
PRAGMA writable_schema=1;
|
||||||
|
|
||||||
|
UPDATE sqlite_master
|
||||||
|
SET sql = replace(sql, 'ON DELETE RESTRICT', 'ON DELETE CASCADE')
|
||||||
|
WHERE name = 'users' AND type = 'table';
|
||||||
|
|
||||||
|
PRAGMA writable_schema=0;
|
||||||
|
|]
|
@ -22,7 +22,7 @@ CREATE TABLE contact_profiles(
|
|||||||
);
|
);
|
||||||
CREATE TABLE users(
|
CREATE TABLE users(
|
||||||
user_id INTEGER PRIMARY KEY,
|
user_id INTEGER PRIMARY KEY,
|
||||||
contact_id INTEGER NOT NULL UNIQUE REFERENCES contacts ON DELETE CASCADE
|
contact_id INTEGER NOT NULL UNIQUE REFERENCES contacts ON DELETE RESTRICT
|
||||||
DEFERRABLE INITIALLY DEFERRED,
|
DEFERRABLE INITIALLY DEFERRED,
|
||||||
local_display_name TEXT NOT NULL UNIQUE,
|
local_display_name TEXT NOT NULL UNIQUE,
|
||||||
active_user INTEGER NOT NULL DEFAULT 0,
|
active_user INTEGER NOT NULL DEFAULT 0,
|
||||||
@ -37,7 +37,7 @@ CREATE TABLE users(
|
|||||||
user_member_profile_updated_at TEXT, -- 1 for active user
|
user_member_profile_updated_at TEXT, -- 1 for active user
|
||||||
FOREIGN KEY(user_id, local_display_name)
|
FOREIGN KEY(user_id, local_display_name)
|
||||||
REFERENCES display_names(user_id, local_display_name)
|
REFERENCES display_names(user_id, local_display_name)
|
||||||
ON DELETE CASCADE
|
ON DELETE RESTRICT
|
||||||
ON UPDATE CASCADE
|
ON UPDATE CASCADE
|
||||||
DEFERRABLE INITIALLY DEFERRED
|
DEFERRABLE INITIALLY DEFERRED
|
||||||
);
|
);
|
||||||
@ -193,7 +193,8 @@ CREATE TABLE files(
|
|||||||
protocol TEXT NOT NULL DEFAULT 'smp',
|
protocol TEXT NOT NULL DEFAULT 'smp',
|
||||||
file_crypto_key BLOB,
|
file_crypto_key BLOB,
|
||||||
file_crypto_nonce BLOB,
|
file_crypto_nonce BLOB,
|
||||||
note_folder_id INTEGER DEFAULT NULL REFERENCES note_folders ON DELETE CASCADE
|
note_folder_id INTEGER DEFAULT NULL REFERENCES note_folders ON DELETE CASCADE,
|
||||||
|
redirect_file_id INTEGER REFERENCES files ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
CREATE TABLE snd_files(
|
CREATE TABLE snd_files(
|
||||||
file_id INTEGER NOT NULL REFERENCES files ON DELETE CASCADE,
|
file_id INTEGER NOT NULL REFERENCES files ON DELETE CASCADE,
|
||||||
@ -561,6 +562,7 @@ CREATE TABLE note_folders(
|
|||||||
favorite INTEGER NOT NULL DEFAULT 0,
|
favorite INTEGER NOT NULL DEFAULT 0,
|
||||||
unread_chat 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(
|
CREATE INDEX contact_profiles_index ON contact_profiles(
|
||||||
display_name,
|
display_name,
|
||||||
full_name
|
full_name
|
||||||
@ -854,3 +856,4 @@ CREATE INDEX idx_chat_items_notes_item_status on chat_items(
|
|||||||
note_folder_id,
|
note_folder_id,
|
||||||
item_status
|
item_status
|
||||||
);
|
);
|
||||||
|
CREATE INDEX idx_files_redirect_file_id on files(redirect_file_id);
|
||||||
|
22
src/Simplex/Chat/Store/AppSettings.hs
Normal file
22
src/Simplex/Chat/Store/AppSettings.hs
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
{-# LANGUAGE OverloadedStrings #-}
|
||||||
|
|
||||||
|
module Simplex.Chat.Store.AppSettings where
|
||||||
|
|
||||||
|
import Control.Monad (join)
|
||||||
|
import Control.Monad.IO.Class (liftIO)
|
||||||
|
import qualified Data.Aeson as J
|
||||||
|
import Data.Maybe (fromMaybe)
|
||||||
|
import Database.SQLite.Simple (Only (..))
|
||||||
|
import Simplex.Chat.AppSettings (AppSettings (..), combineAppSettings, defaultAppSettings, defaultParseAppSettings)
|
||||||
|
import Simplex.Messaging.Agent.Store.SQLite (maybeFirstRow)
|
||||||
|
import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB
|
||||||
|
|
||||||
|
saveAppSettings :: DB.Connection -> AppSettings -> IO ()
|
||||||
|
saveAppSettings db appSettings = do
|
||||||
|
DB.execute_ db "DELETE FROM app_settings"
|
||||||
|
DB.execute db "INSERT INTO app_settings (app_settings) VALUES (?)" (Only $ J.encode appSettings)
|
||||||
|
|
||||||
|
getAppSettings :: DB.Connection -> Maybe AppSettings -> IO AppSettings
|
||||||
|
getAppSettings db platformDefaults = do
|
||||||
|
stored_ <- join <$> liftIO (maybeFirstRow (J.decodeStrict . fromOnly) $ DB.query_ db "SELECT app_settings FROM app_settings")
|
||||||
|
pure $ combineAppSettings (fromMaybe defaultAppSettings platformDefaults) (fromMaybe defaultParseAppSettings stored_)
|
@ -38,6 +38,7 @@ module Simplex.Chat.Store.Files
|
|||||||
getGroupFileIdBySharedMsgId,
|
getGroupFileIdBySharedMsgId,
|
||||||
getDirectFileIdBySharedMsgId,
|
getDirectFileIdBySharedMsgId,
|
||||||
getChatRefByFileId,
|
getChatRefByFileId,
|
||||||
|
lookupChatRefByFileId,
|
||||||
updateSndFileStatus,
|
updateSndFileStatus,
|
||||||
createSndFileChunk,
|
createSndFileChunk,
|
||||||
updateSndFileChunkMsg,
|
updateSndFileChunkMsg,
|
||||||
@ -45,6 +46,7 @@ module Simplex.Chat.Store.Files
|
|||||||
deleteSndFileChunks,
|
deleteSndFileChunks,
|
||||||
createRcvFileTransfer,
|
createRcvFileTransfer,
|
||||||
createRcvGroupFileTransfer,
|
createRcvGroupFileTransfer,
|
||||||
|
createRcvStandaloneFileTransfer,
|
||||||
appendRcvFD,
|
appendRcvFD,
|
||||||
getRcvFileDescrByRcvFileId,
|
getRcvFileDescrByRcvFileId,
|
||||||
getRcvFileDescrBySndFileId,
|
getRcvFileDescrBySndFileId,
|
||||||
@ -69,6 +71,7 @@ module Simplex.Chat.Store.Files
|
|||||||
getFileTransfer,
|
getFileTransfer,
|
||||||
getFileTransferProgress,
|
getFileTransferProgress,
|
||||||
getFileTransferMeta,
|
getFileTransferMeta,
|
||||||
|
lookupFileTransferRedirectMeta,
|
||||||
getSndFileTransfer,
|
getSndFileTransfer,
|
||||||
getSndFileTransfers,
|
getSndFileTransfers,
|
||||||
getContactFileInfo,
|
getContactFileInfo,
|
||||||
@ -85,12 +88,14 @@ import Control.Monad
|
|||||||
import Control.Monad.Except
|
import Control.Monad.Except
|
||||||
import Control.Monad.IO.Class
|
import Control.Monad.IO.Class
|
||||||
import Data.Either (rights)
|
import Data.Either (rights)
|
||||||
|
import Data.Functor ((<&>))
|
||||||
import Data.Int (Int64)
|
import Data.Int (Int64)
|
||||||
import Data.Maybe (fromMaybe, isJust, listToMaybe)
|
import Data.Maybe (fromMaybe, isJust, listToMaybe)
|
||||||
import Data.Text (Text)
|
import Data.Text (Text)
|
||||||
import Data.Time (addUTCTime)
|
import Data.Time (addUTCTime)
|
||||||
import Data.Time.Clock (UTCTime (..), getCurrentTime, nominalDay)
|
import Data.Time.Clock (UTCTime (..), getCurrentTime, nominalDay)
|
||||||
import Data.Type.Equality
|
import Data.Type.Equality
|
||||||
|
import Data.Word (Word32)
|
||||||
import Database.SQLite.Simple (Only (..), (:.) (..))
|
import Database.SQLite.Simple (Only (..), (:.) (..))
|
||||||
import Database.SQLite.Simple.QQ (sql)
|
import Database.SQLite.Simple.QQ (sql)
|
||||||
import Database.SQLite.Simple.ToField (ToField)
|
import Database.SQLite.Simple.ToField (ToField)
|
||||||
@ -186,7 +191,7 @@ createSndGroupFileTransfer db userId GroupInfo {groupId} filePath FileInvitation
|
|||||||
"INSERT INTO files (user_id, group_id, file_name, file_path, file_size, chunk_size, file_inline, ci_file_status, protocol, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?)"
|
"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))
|
((userId, groupId, fileName, filePath, fileSize, chunkSize) :. (fileInline, CIFSSndStored, FPSMP, currentTs, currentTs))
|
||||||
fileId <- insertedRowId db
|
fileId <- insertedRowId db
|
||||||
pure FileTransferMeta {fileId, xftpSndFile = Nothing, fileName, filePath, fileSize, fileInline, chunkSize, cancelled = False}
|
pure FileTransferMeta {fileId, xftpSndFile = Nothing, xftpRedirectFor = Nothing, fileName, filePath, fileSize, fileInline, chunkSize, cancelled = False}
|
||||||
|
|
||||||
createSndGroupFileTransferConnection :: DB.Connection -> User -> Int64 -> (CommandId, ConnId) -> GroupMember -> SubscriptionMode -> IO ()
|
createSndGroupFileTransferConnection :: DB.Connection -> User -> Int64 -> (CommandId, ConnId) -> GroupMember -> SubscriptionMode -> IO ()
|
||||||
createSndGroupFileTransferConnection db user@User {userId} fileId (cmdId, acId) GroupMember {groupMemberId} subMode = do
|
createSndGroupFileTransferConnection db user@User {userId} fileId (cmdId, acId) GroupMember {groupMemberId} subMode = do
|
||||||
@ -259,16 +264,16 @@ getSndFTViaMsgDelivery db User {userId} Connection {connId, agentConnId} agentMs
|
|||||||
(\n -> SndFileTransfer {fileId, fileStatus, fileName, fileSize, chunkSize, filePath, fileDescrId, fileInline, groupMemberId, recipientDisplayName = n, connId, agentConnId})
|
(\n -> SndFileTransfer {fileId, fileStatus, fileName, fileSize, chunkSize, filePath, fileDescrId, fileInline, groupMemberId, recipientDisplayName = n, connId, agentConnId})
|
||||||
<$> (contactName_ <|> memberName_)
|
<$> (contactName_ <|> memberName_)
|
||||||
|
|
||||||
createSndFileTransferXFTP :: DB.Connection -> User -> ContactOrGroup -> CryptoFile -> FileInvitation -> AgentSndFileId -> Integer -> IO FileTransferMeta
|
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 chunkSize = do
|
createSndFileTransferXFTP db User {userId} contactOrGroup_ (CryptoFile filePath cryptoArgs) FileInvitation {fileName, fileSize} agentSndFileId xftpRedirectFor chunkSize = do
|
||||||
currentTs <- getCurrentTime
|
currentTs <- getCurrentTime
|
||||||
let xftpSndFile = Just XFTPSndFile {agentSndFileId, privateSndFileDescr = Nothing, agentSndFileDeleted = False, cryptoArgs}
|
let xftpSndFile = Just XFTPSndFile {agentSndFileId, privateSndFileDescr = Nothing, agentSndFileDeleted = False, cryptoArgs}
|
||||||
DB.execute
|
DB.execute
|
||||||
db
|
db
|
||||||
"INSERT INTO files (contact_id, group_id, user_id, file_name, file_path, file_crypto_key, file_crypto_nonce, file_size, chunk_size, agent_snd_file_id, ci_file_status, protocol, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)"
|
"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 (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)"
|
||||||
(contactAndGroupIds contactOrGroup :. (userId, fileName, filePath, CF.fileKey <$> cryptoArgs, CF.fileNonce <$> cryptoArgs, fileSize, chunkSize, agentSndFileId, CIFSSndStored, FPXFTP, currentTs, currentTs))
|
(maybe (Nothing, Nothing) contactAndGroupIds contactOrGroup_ :. (userId, fileName, filePath, CF.fileKey <$> cryptoArgs, CF.fileNonce <$> cryptoArgs, fileSize, chunkSize) :. (xftpRedirectFor, agentSndFileId, CIFSSndStored, FPXFTP, currentTs, currentTs))
|
||||||
fileId <- insertedRowId db
|
fileId <- insertedRowId db
|
||||||
pure FileTransferMeta {fileId, xftpSndFile, fileName, filePath, fileSize, fileInline = Nothing, chunkSize, cancelled = False}
|
pure FileTransferMeta {fileId, xftpSndFile, xftpRedirectFor, fileName, filePath, fileSize, fileInline = Nothing, chunkSize, cancelled = False}
|
||||||
|
|
||||||
createSndFTDescrXFTP :: DB.Connection -> User -> Maybe GroupMember -> Connection -> FileTransferMeta -> FileDescr -> IO ()
|
createSndFTDescrXFTP :: DB.Connection -> User -> Maybe GroupMember -> Connection -> FileTransferMeta -> FileDescr -> IO ()
|
||||||
createSndFTDescrXFTP db User {userId} m Connection {connId} FileTransferMeta {fileId} FileDescr {fileDescrText, fileDescrPartNo, fileDescrComplete} = do
|
createSndFTDescrXFTP db User {userId} m Connection {connId} FileTransferMeta {fileId} FileDescr {fileDescrText, fileDescrPartNo, fileDescrComplete} = do
|
||||||
@ -403,11 +408,14 @@ getDirectFileIdBySharedMsgId db User {userId} Contact {contactId} sharedMsgId =
|
|||||||
(userId, contactId, sharedMsgId)
|
(userId, contactId, sharedMsgId)
|
||||||
|
|
||||||
getChatRefByFileId :: DB.Connection -> User -> Int64 -> ExceptT StoreError IO ChatRef
|
getChatRefByFileId :: DB.Connection -> User -> Int64 -> ExceptT StoreError IO ChatRef
|
||||||
getChatRefByFileId db User {userId} fileId =
|
getChatRefByFileId db user fileId = liftIO (lookupChatRefByFileId db user fileId) >>= maybe (throwError $ SEInternalError "could not retrieve chat ref by file id") pure
|
||||||
liftIO getChatRef >>= \case
|
|
||||||
[(Just contactId, Nothing)] -> pure $ ChatRef CTDirect contactId
|
lookupChatRefByFileId :: DB.Connection -> User -> Int64 -> IO (Maybe ChatRef)
|
||||||
[(Nothing, Just groupId)] -> pure $ ChatRef CTGroup groupId
|
lookupChatRefByFileId db User {userId} fileId =
|
||||||
_ -> throwError $ SEInternalError "could not retrieve chat ref by file id"
|
getChatRef <&> \case
|
||||||
|
[(Just contactId, Nothing)] -> Just $ ChatRef CTDirect contactId
|
||||||
|
[(Nothing, Just groupId)] -> Just $ ChatRef CTGroup groupId
|
||||||
|
_ -> Nothing
|
||||||
where
|
where
|
||||||
getChatRef =
|
getChatRef =
|
||||||
DB.query
|
DB.query
|
||||||
@ -518,6 +526,23 @@ createRcvGroupFileTransfer db userId GroupMember {groupId, groupMemberId, localD
|
|||||||
(fileId, FSNew, fileConnReq, fileInline, rcvFileInline, groupMemberId, rfdId, currentTs, currentTs)
|
(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}
|
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.Connection -> UserId -> UTCTime -> FileDescr -> ExceptT StoreError IO RcvFileDescr
|
||||||
createRcvFD_ db userId currentTs FileDescr {fileDescrText, fileDescrPartNo, fileDescrComplete} = do
|
createRcvFD_ db userId currentTs FileDescr {fileDescrText, fileDescrPartNo, fileDescrComplete} = do
|
||||||
when (fileDescrPartNo /= 0) $ throwError SERcvFileInvalidDescrPart
|
when (fileDescrPartNo /= 0) $ throwError SERcvFileInvalidDescrPart
|
||||||
@ -644,9 +669,9 @@ getRcvFileTransfer_ db userId fileId = do
|
|||||||
(FileStatus, Maybe ConnReqInvitation, Maybe Int64, String, Integer, Integer, Maybe Bool) :. (Maybe ContactName, Maybe ContactName, Maybe FilePath, Maybe C.SbKey, Maybe C.CbNonce, Maybe InlineFileMode, Maybe InlineFileMode, Maybe AgentRcvFileId, Bool) :. (Maybe Int64, Maybe AgentConnId) ->
|
(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
|
ExceptT StoreError IO RcvFileTransfer
|
||||||
rcvFileTransfer rfd_ ((fileStatus', fileConnReq, grpMemberId, fileName, fileSize, chunkSize, cancelled_) :. (contactName_, memberName_, filePath_, fileKey, fileNonce, fileInline, rcvFileInline, agentRcvFileId, agentRcvFileDeleted) :. (connId_, agentConnId_)) =
|
rcvFileTransfer rfd_ ((fileStatus', fileConnReq, grpMemberId, fileName, fileSize, chunkSize, cancelled_) :. (contactName_, memberName_, filePath_, fileKey, fileNonce, fileInline, rcvFileInline, agentRcvFileId, agentRcvFileDeleted) :. (connId_, agentConnId_)) =
|
||||||
case contactName_ <|> memberName_ of
|
case contactName_ <|> memberName_ <|> standaloneName_ of
|
||||||
Nothing -> throwError $ SERcvFileInvalid fileId
|
Nothing -> throwError $ SERcvFileInvalid fileId
|
||||||
Just name -> do
|
Just name ->
|
||||||
case fileStatus' of
|
case fileStatus' of
|
||||||
FSNew -> pure $ ft name RFSNew
|
FSNew -> pure $ ft name RFSNew
|
||||||
FSAccepted -> ft name . RFSAccepted <$> rfi
|
FSAccepted -> ft name . RFSAccepted <$> rfi
|
||||||
@ -654,6 +679,9 @@ getRcvFileTransfer_ db userId fileId = do
|
|||||||
FSComplete -> ft name . RFSComplete <$> rfi
|
FSComplete -> ft name . RFSComplete <$> rfi
|
||||||
FSCancelled -> ft name . RFSCancelled <$> rfi_
|
FSCancelled -> ft name . RFSCancelled <$> rfi_
|
||||||
where
|
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 =
|
ft senderDisplayName fileStatus =
|
||||||
let fileInvitation = FileInvitation {fileName, fileSize, fileDigest = Nothing, fileConnReq, fileInline, fileDescr = Nothing}
|
let fileInvitation = FileInvitation {fileName, fileSize, fileDigest = Nothing, fileConnReq, fileInline, fileDescr = Nothing}
|
||||||
cryptoArgs = CFArgs <$> fileKey <*> fileNonce
|
cryptoArgs = CFArgs <$> fileKey <*> fileNonce
|
||||||
@ -888,17 +916,22 @@ getFileTransferMeta_ db userId fileId =
|
|||||||
DB.query
|
DB.query
|
||||||
db
|
db
|
||||||
[sql|
|
[sql|
|
||||||
SELECT file_name, file_size, chunk_size, file_path, file_crypto_key, file_crypto_nonce, file_inline, agent_snd_file_id, agent_snd_file_deleted, private_snd_file_descr, cancelled
|
SELECT file_name, file_size, chunk_size, file_path, file_crypto_key, file_crypto_nonce, file_inline, agent_snd_file_id, agent_snd_file_deleted, private_snd_file_descr, cancelled, redirect_file_id
|
||||||
FROM files
|
FROM files
|
||||||
WHERE user_id = ? AND file_id = ?
|
WHERE user_id = ? AND file_id = ?
|
||||||
|]
|
|]
|
||||||
(userId, fileId)
|
(userId, fileId)
|
||||||
where
|
where
|
||||||
fileTransferMeta :: (String, Integer, Integer, FilePath, Maybe C.SbKey, Maybe C.CbNonce, Maybe InlineFileMode, Maybe AgentSndFileId, Bool, Maybe Text, Maybe Bool) -> FileTransferMeta
|
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_) =
|
fileTransferMeta (fileName, fileSize, chunkSize, filePath, fileKey, fileNonce, fileInline, aSndFileId_, agentSndFileDeleted, privateSndFileDescr, cancelled_, xftpRedirectFor) =
|
||||||
let cryptoArgs = CFArgs <$> fileKey <*> fileNonce
|
let cryptoArgs = CFArgs <$> fileKey <*> fileNonce
|
||||||
xftpSndFile = (\fId -> XFTPSndFile {agentSndFileId = fId, privateSndFileDescr, agentSndFileDeleted, cryptoArgs}) <$> aSndFileId_
|
xftpSndFile = (\fId -> XFTPSndFile {agentSndFileId = fId, privateSndFileDescr, agentSndFileDeleted, cryptoArgs}) <$> aSndFileId_
|
||||||
in FileTransferMeta {fileId, xftpSndFile, fileName, fileSize, chunkSize, filePath, fileInline, cancelled = fromMaybe False cancelled_}
|
in FileTransferMeta {fileId, xftpSndFile, xftpRedirectFor, fileName, fileSize, chunkSize, filePath, fileInline, cancelled = fromMaybe False cancelled_}
|
||||||
|
|
||||||
|
lookupFileTransferRedirectMeta :: DB.Connection -> User -> Int64 -> IO [FileTransferMeta]
|
||||||
|
lookupFileTransferRedirectMeta db User {userId} fileId = do
|
||||||
|
redirects <- DB.query db "SELECT file_id FROM files WHERE user_id = ? AND redirect_file_id = ?" (userId, fileId)
|
||||||
|
rights <$> mapM (runExceptT . getFileTransferMeta_ db userId . fromOnly) redirects
|
||||||
|
|
||||||
createLocalFile :: ToField (CIFileStatus d) => CIFileStatus d -> DB.Connection -> User -> NoteFolder -> ChatItemId -> UTCTime -> CryptoFile -> Integer -> Integer -> IO Int64
|
createLocalFile :: 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
|
createLocalFile fileStatus db User {userId} NoteFolder {noteFolderId} chatItemId itemTs CryptoFile {filePath, cryptoArgs} fileSize fileChunkSize = do
|
||||||
|
@ -39,7 +39,7 @@ module Simplex.Chat.Store.Messages
|
|||||||
getDirectChat,
|
getDirectChat,
|
||||||
getGroupChat,
|
getGroupChat,
|
||||||
getLocalChat,
|
getLocalChat,
|
||||||
getDirectChatItemsLast,
|
getDirectChatItemLast,
|
||||||
getAllChatItems,
|
getAllChatItems,
|
||||||
getAChatItem,
|
getAChatItem,
|
||||||
updateDirectChatItem,
|
updateDirectChatItem,
|
||||||
@ -92,6 +92,7 @@ module Simplex.Chat.Store.Messages
|
|||||||
getLocalChatItemIdByText,
|
getLocalChatItemIdByText,
|
||||||
getLocalChatItemIdByText',
|
getLocalChatItemIdByText',
|
||||||
getChatItemByFileId,
|
getChatItemByFileId,
|
||||||
|
lookupChatItemByFileId,
|
||||||
getChatItemByGroupId,
|
getChatItemByGroupId,
|
||||||
updateDirectChatItemStatus,
|
updateDirectChatItemStatus,
|
||||||
getTimedItems,
|
getTimedItems,
|
||||||
@ -125,6 +126,7 @@ import Data.List (sortBy)
|
|||||||
import Data.Maybe (fromMaybe, isJust, mapMaybe)
|
import Data.Maybe (fromMaybe, isJust, mapMaybe)
|
||||||
import Data.Ord (Down (..), comparing)
|
import Data.Ord (Down (..), comparing)
|
||||||
import Data.Text (Text)
|
import Data.Text (Text)
|
||||||
|
import qualified Data.Text as T
|
||||||
import Data.Time (addUTCTime)
|
import Data.Time (addUTCTime)
|
||||||
import Data.Time.Clock (UTCTime (..), getCurrentTime)
|
import Data.Time.Clock (UTCTime (..), getCurrentTime)
|
||||||
import Database.SQLite.Simple (NamedParam (..), Only (..), Query, (:.) (..))
|
import Database.SQLite.Simple (NamedParam (..), Only (..), Query, (:.) (..))
|
||||||
@ -828,7 +830,7 @@ toLocalChatItem currentTs ((itemId, itemTs, AMsgDirection msgDir, itemContentTex
|
|||||||
cItem :: MsgDirectionI d => SMsgDirection d -> CIDirection 'CTLocal d -> CIStatus d -> CIContent d -> Maybe (CIFile d) -> CChatItem 'CTLocal
|
cItem :: MsgDirectionI d => SMsgDirection d -> CIDirection 'CTLocal d -> CIStatus d -> CIContent d -> Maybe (CIFile d) -> CChatItem 'CTLocal
|
||||||
cItem d chatDir ciStatus content file =
|
cItem d chatDir ciStatus content file =
|
||||||
CChatItem d ChatItem {chatDir, meta = ciMeta content ciStatus, content, formattedText = parseMaybeMarkdownList itemText, quotedItem = Nothing, reactions = [], file}
|
CChatItem d ChatItem {chatDir, meta = ciMeta content ciStatus, content, formattedText = parseMaybeMarkdownList itemText, quotedItem = Nothing, reactions = [], file}
|
||||||
badItem = Left $ SEBadChatItem itemId
|
badItem = Left $ SEBadChatItem itemId (Just itemTs)
|
||||||
ciMeta :: CIContent d -> CIStatus d -> CIMeta 'CTLocal d
|
ciMeta :: CIContent d -> CIStatus d -> CIMeta 'CTLocal d
|
||||||
ciMeta content status =
|
ciMeta content status =
|
||||||
let itemDeleted' = case itemDeleted of
|
let itemDeleted' = case itemDeleted of
|
||||||
@ -922,97 +924,118 @@ getDirectChat :: DB.Connection -> User -> Int64 -> ChatPagination -> Maybe Strin
|
|||||||
getDirectChat db user contactId pagination search_ = do
|
getDirectChat db user contactId pagination search_ = do
|
||||||
let search = fromMaybe "" search_
|
let search = fromMaybe "" search_
|
||||||
ct <- getContact db user contactId
|
ct <- getContact db user contactId
|
||||||
liftIO . getDirectChatReactions_ db ct =<< case pagination of
|
liftIO $ case pagination of
|
||||||
CPLast count -> getDirectChatLast_ db user ct count search
|
CPLast count -> getDirectChatLast_ db user ct count search
|
||||||
CPAfter afterId count -> getDirectChatAfter_ db user ct afterId count search
|
CPAfter afterId count -> getDirectChatAfter_ db user ct afterId count search
|
||||||
CPBefore beforeId count -> getDirectChatBefore_ db user ct beforeId count search
|
CPBefore beforeId count -> getDirectChatBefore_ db user ct beforeId count search
|
||||||
|
|
||||||
getDirectChatLast_ :: DB.Connection -> User -> Contact -> Int -> String -> ExceptT StoreError IO (Chat 'CTDirect)
|
|
||||||
getDirectChatLast_ db user ct@Contact {contactId} count search = do
|
|
||||||
let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False}
|
|
||||||
chatItems <- getDirectChatItemsLast db user contactId count search
|
|
||||||
pure $ Chat (DirectChat ct) (reverse chatItems) stats
|
|
||||||
|
|
||||||
-- the last items in reverse order (the last item in the conversation is the first in the returned list)
|
-- 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]
|
getDirectChatLast_ :: DB.Connection -> User -> Contact -> Int -> String -> IO (Chat 'CTDirect)
|
||||||
getDirectChatItemsLast db User {userId} contactId count search = ExceptT $ do
|
getDirectChatLast_ db user@User {userId} ct@Contact {contactId} count search = do
|
||||||
currentTs <- getCurrentTime
|
|
||||||
mapM (toDirectChatItem currentTs)
|
|
||||||
<$> DB.query
|
|
||||||
db
|
|
||||||
[sql|
|
|
||||||
SELECT
|
|
||||||
-- ChatItem
|
|
||||||
i.chat_item_id, i.item_ts, i.item_sent, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_deleted_ts, i.item_edited, i.created_at, i.updated_at, i.timed_ttl, i.timed_delete_at, i.item_live,
|
|
||||||
-- CIFile
|
|
||||||
f.file_id, f.file_name, f.file_size, f.file_path, f.file_crypto_key, f.file_crypto_nonce, f.ci_file_status, f.protocol,
|
|
||||||
-- DirectQuote
|
|
||||||
ri.chat_item_id, i.quoted_shared_msg_id, i.quoted_sent_at, i.quoted_content, i.quoted_sent
|
|
||||||
FROM chat_items i
|
|
||||||
LEFT JOIN files f ON f.chat_item_id = i.chat_item_id
|
|
||||||
LEFT JOIN chat_items ri ON ri.user_id = i.user_id AND ri.contact_id = i.contact_id AND ri.shared_msg_id = i.quoted_shared_msg_id
|
|
||||||
WHERE i.user_id = ? AND i.contact_id = ? AND i.item_text LIKE '%' || ? || '%'
|
|
||||||
ORDER BY i.created_at DESC, i.chat_item_id DESC
|
|
||||||
LIMIT ?
|
|
||||||
|]
|
|
||||||
(userId, contactId, search, count)
|
|
||||||
|
|
||||||
getDirectChatAfter_ :: DB.Connection -> User -> Contact -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTDirect)
|
|
||||||
getDirectChatAfter_ db User {userId} ct@Contact {contactId} afterChatItemId count search = do
|
|
||||||
let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False}
|
let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False}
|
||||||
chatItems <- ExceptT getDirectChatItemsAfter_
|
chatItemIds <- getDirectChatItemIdsLast_
|
||||||
pure $ Chat (DirectChat ct) chatItems stats
|
currentTs <- getCurrentTime
|
||||||
|
chatItems <- mapM (safeGetDirectItem db user ct currentTs) chatItemIds
|
||||||
|
pure $ Chat (DirectChat ct) (reverse chatItems) stats
|
||||||
where
|
where
|
||||||
getDirectChatItemsAfter_ :: IO (Either StoreError [CChatItem 'CTDirect])
|
getDirectChatItemIdsLast_ :: IO [ChatItemId]
|
||||||
getDirectChatItemsAfter_ = do
|
getDirectChatItemIdsLast_ =
|
||||||
currentTs <- getCurrentTime
|
map fromOnly
|
||||||
mapM (toDirectChatItem currentTs)
|
|
||||||
<$> DB.query
|
<$> DB.query
|
||||||
db
|
db
|
||||||
[sql|
|
[sql|
|
||||||
SELECT
|
SELECT chat_item_id
|
||||||
-- ChatItem
|
FROM chat_items
|
||||||
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,
|
WHERE user_id = ? AND contact_id = ? AND item_text LIKE '%' || ? || '%'
|
||||||
-- CIFile
|
ORDER BY created_at DESC, chat_item_id DESC
|
||||||
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,
|
LIMIT ?
|
||||||
-- DirectQuote
|
|]
|
||||||
ri.chat_item_id, i.quoted_shared_msg_id, i.quoted_sent_at, i.quoted_content, i.quoted_sent
|
(userId, contactId, search, count)
|
||||||
FROM chat_items i
|
|
||||||
LEFT JOIN files f ON f.chat_item_id = i.chat_item_id
|
safeGetDirectItem :: DB.Connection -> User -> Contact -> UTCTime -> ChatItemId -> IO (CChatItem 'CTDirect)
|
||||||
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
|
safeGetDirectItem db user ct currentTs itemId =
|
||||||
WHERE i.user_id = ? AND i.contact_id = ? AND i.item_text LIKE '%' || ? || '%'
|
runExceptT (getDirectCIWithReactions db user ct itemId)
|
||||||
AND i.chat_item_id > ?
|
>>= pure <$> safeToDirectItem currentTs itemId
|
||||||
ORDER BY i.created_at ASC, i.chat_item_id ASC
|
|
||||||
|
safeToDirectItem :: UTCTime -> ChatItemId -> Either StoreError (CChatItem 'CTDirect) -> CChatItem 'CTDirect
|
||||||
|
safeToDirectItem currentTs itemId = \case
|
||||||
|
Right ci -> ci
|
||||||
|
Left e@(SEBadChatItem _ (Just itemTs)) -> badDirectItem itemTs e
|
||||||
|
Left e -> badDirectItem currentTs e
|
||||||
|
where
|
||||||
|
badDirectItem :: UTCTime -> StoreError -> CChatItem 'CTDirect
|
||||||
|
badDirectItem ts e =
|
||||||
|
let errorText = T.pack $ show e
|
||||||
|
in CChatItem
|
||||||
|
SMDSnd
|
||||||
|
ChatItem
|
||||||
|
{ chatDir = CIDirectSnd,
|
||||||
|
meta = dummyMeta itemId ts errorText,
|
||||||
|
content = CIInvalidJSON errorText,
|
||||||
|
formattedText = Nothing,
|
||||||
|
quotedItem = Nothing,
|
||||||
|
reactions = [],
|
||||||
|
file = Nothing
|
||||||
|
}
|
||||||
|
|
||||||
|
getDirectChatItemLast :: DB.Connection -> User -> ContactId -> ExceptT StoreError IO (CChatItem 'CTDirect)
|
||||||
|
getDirectChatItemLast db user@User {userId} contactId = do
|
||||||
|
chatItemId <-
|
||||||
|
ExceptT . firstRow fromOnly (SEChatItemNotFoundByContactId contactId) $
|
||||||
|
DB.query
|
||||||
|
db
|
||||||
|
[sql|
|
||||||
|
SELECT chat_item_id
|
||||||
|
FROM chat_items
|
||||||
|
WHERE user_id = ? AND contact_id = ?
|
||||||
|
ORDER BY created_at DESC, chat_item_id DESC
|
||||||
|
LIMIT 1
|
||||||
|
|]
|
||||||
|
(userId, contactId)
|
||||||
|
getDirectChatItem db user contactId chatItemId
|
||||||
|
|
||||||
|
getDirectChatAfter_ :: DB.Connection -> User -> Contact -> ChatItemId -> Int -> String -> IO (Chat 'CTDirect)
|
||||||
|
getDirectChatAfter_ db user@User {userId} ct@Contact {contactId} afterChatItemId count search = do
|
||||||
|
let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False}
|
||||||
|
chatItemIds <- getDirectChatItemIdsAfter_
|
||||||
|
currentTs <- getCurrentTime
|
||||||
|
chatItems <- mapM (safeGetDirectItem db user ct currentTs) chatItemIds
|
||||||
|
pure $ Chat (DirectChat ct) chatItems stats
|
||||||
|
where
|
||||||
|
getDirectChatItemIdsAfter_ :: IO [ChatItemId]
|
||||||
|
getDirectChatItemIdsAfter_ =
|
||||||
|
map fromOnly
|
||||||
|
<$> DB.query
|
||||||
|
db
|
||||||
|
[sql|
|
||||||
|
SELECT chat_item_id
|
||||||
|
FROM chat_items
|
||||||
|
WHERE user_id = ? AND contact_id = ? AND item_text LIKE '%' || ? || '%'
|
||||||
|
AND chat_item_id > ?
|
||||||
|
ORDER BY created_at ASC, chat_item_id ASC
|
||||||
LIMIT ?
|
LIMIT ?
|
||||||
|]
|
|]
|
||||||
(userId, contactId, search, afterChatItemId, count)
|
(userId, contactId, search, afterChatItemId, count)
|
||||||
|
|
||||||
getDirectChatBefore_ :: DB.Connection -> User -> Contact -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTDirect)
|
getDirectChatBefore_ :: DB.Connection -> User -> Contact -> ChatItemId -> Int -> String -> IO (Chat 'CTDirect)
|
||||||
getDirectChatBefore_ db User {userId} ct@Contact {contactId} beforeChatItemId count search = do
|
getDirectChatBefore_ db user@User {userId} ct@Contact {contactId} beforeChatItemId count search = do
|
||||||
let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False}
|
let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False}
|
||||||
chatItems <- ExceptT getDirectChatItemsBefore_
|
chatItemIds <- getDirectChatItemsIdsBefore_
|
||||||
|
currentTs <- getCurrentTime
|
||||||
|
chatItems <- mapM (safeGetDirectItem db user ct currentTs) chatItemIds
|
||||||
pure $ Chat (DirectChat ct) (reverse chatItems) stats
|
pure $ Chat (DirectChat ct) (reverse chatItems) stats
|
||||||
where
|
where
|
||||||
getDirectChatItemsBefore_ :: IO (Either StoreError [CChatItem 'CTDirect])
|
getDirectChatItemsIdsBefore_ :: IO [ChatItemId]
|
||||||
getDirectChatItemsBefore_ = do
|
getDirectChatItemsIdsBefore_ =
|
||||||
currentTs <- getCurrentTime
|
map fromOnly
|
||||||
mapM (toDirectChatItem currentTs)
|
|
||||||
<$> DB.query
|
<$> DB.query
|
||||||
db
|
db
|
||||||
[sql|
|
[sql|
|
||||||
SELECT
|
SELECT chat_item_id
|
||||||
-- ChatItem
|
FROM chat_items
|
||||||
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,
|
WHERE user_id = ? AND contact_id = ? AND item_text LIKE '%' || ? || '%'
|
||||||
-- CIFile
|
AND chat_item_id < ?
|
||||||
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,
|
ORDER BY created_at DESC, chat_item_id DESC
|
||||||
-- 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 ?
|
LIMIT ?
|
||||||
|]
|
|]
|
||||||
(userId, contactId, search, beforeChatItemId, count)
|
(userId, contactId, search, beforeChatItemId, count)
|
||||||
@ -1022,15 +1045,16 @@ getGroupChat db vr user groupId pagination search_ = do
|
|||||||
let search = fromMaybe "" search_
|
let search = fromMaybe "" search_
|
||||||
g <- getGroupInfo db vr user groupId
|
g <- getGroupInfo db vr user groupId
|
||||||
case pagination of
|
case pagination of
|
||||||
CPLast count -> getGroupChatLast_ db user g count search
|
CPLast count -> liftIO $ getGroupChatLast_ db user g count search
|
||||||
CPAfter afterId count -> getGroupChatAfter_ db user g afterId count search
|
CPAfter afterId count -> getGroupChatAfter_ db user g afterId count search
|
||||||
CPBefore beforeId count -> getGroupChatBefore_ db user g beforeId count search
|
CPBefore beforeId count -> getGroupChatBefore_ db user g beforeId count search
|
||||||
|
|
||||||
getGroupChatLast_ :: DB.Connection -> User -> GroupInfo -> Int -> String -> ExceptT StoreError IO (Chat 'CTGroup)
|
getGroupChatLast_ :: DB.Connection -> User -> GroupInfo -> Int -> String -> IO (Chat 'CTGroup)
|
||||||
getGroupChatLast_ db user@User {userId} g@GroupInfo {groupId} count search = do
|
getGroupChatLast_ db user@User {userId} g@GroupInfo {groupId} count search = do
|
||||||
let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False}
|
let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False}
|
||||||
chatItemIds <- liftIO getGroupChatItemIdsLast_
|
chatItemIds <- getGroupChatItemIdsLast_
|
||||||
chatItems <- mapM (getGroupCIWithReactions db user g) chatItemIds
|
currentTs <- getCurrentTime
|
||||||
|
chatItems <- mapM (safeGetGroupItem db user g currentTs) chatItemIds
|
||||||
pure $ Chat (GroupChat g) (reverse chatItems) stats
|
pure $ Chat (GroupChat g) (reverse chatItems) stats
|
||||||
where
|
where
|
||||||
getGroupChatItemIdsLast_ :: IO [ChatItemId]
|
getGroupChatItemIdsLast_ :: IO [ChatItemId]
|
||||||
@ -1047,6 +1071,32 @@ getGroupChatLast_ db user@User {userId} g@GroupInfo {groupId} count search = do
|
|||||||
|]
|
|]
|
||||||
(userId, groupId, search, count)
|
(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.Connection -> User -> GroupId -> GroupMemberId -> ExceptT StoreError IO (CChatItem 'CTGroup)
|
||||||
getGroupMemberChatItemLast db user@User {userId} groupId groupMemberId = do
|
getGroupMemberChatItemLast db user@User {userId} groupId groupMemberId = do
|
||||||
chatItemId <-
|
chatItemId <-
|
||||||
@ -1068,7 +1118,8 @@ getGroupChatAfter_ db user@User {userId} g@GroupInfo {groupId} afterChatItemId c
|
|||||||
let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False}
|
let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False}
|
||||||
afterChatItem <- getGroupChatItem db user groupId afterChatItemId
|
afterChatItem <- getGroupChatItem db user groupId afterChatItemId
|
||||||
chatItemIds <- liftIO $ getGroupChatItemIdsAfter_ (chatItemTs afterChatItem)
|
chatItemIds <- liftIO $ getGroupChatItemIdsAfter_ (chatItemTs afterChatItem)
|
||||||
chatItems <- mapM (getGroupCIWithReactions db user g) chatItemIds
|
currentTs <- liftIO getCurrentTime
|
||||||
|
chatItems <- liftIO $ mapM (safeGetGroupItem db user g currentTs) chatItemIds
|
||||||
pure $ Chat (GroupChat g) chatItems stats
|
pure $ Chat (GroupChat g) chatItems stats
|
||||||
where
|
where
|
||||||
getGroupChatItemIdsAfter_ :: UTCTime -> IO [ChatItemId]
|
getGroupChatItemIdsAfter_ :: UTCTime -> IO [ChatItemId]
|
||||||
@ -1091,7 +1142,8 @@ getGroupChatBefore_ db user@User {userId} g@GroupInfo {groupId} beforeChatItemId
|
|||||||
let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False}
|
let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False}
|
||||||
beforeChatItem <- getGroupChatItem db user groupId beforeChatItemId
|
beforeChatItem <- getGroupChatItem db user groupId beforeChatItemId
|
||||||
chatItemIds <- liftIO $ getGroupChatItemIdsBefore_ (chatItemTs beforeChatItem)
|
chatItemIds <- liftIO $ getGroupChatItemIdsBefore_ (chatItemTs beforeChatItem)
|
||||||
chatItems <- mapM (getGroupCIWithReactions db user g) chatItemIds
|
currentTs <- liftIO getCurrentTime
|
||||||
|
chatItems <- liftIO $ mapM (safeGetGroupItem db user g currentTs) chatItemIds
|
||||||
pure $ Chat (GroupChat g) (reverse chatItems) stats
|
pure $ Chat (GroupChat g) (reverse chatItems) stats
|
||||||
where
|
where
|
||||||
getGroupChatItemIdsBefore_ :: UTCTime -> IO [ChatItemId]
|
getGroupChatItemIdsBefore_ :: UTCTime -> IO [ChatItemId]
|
||||||
@ -1113,16 +1165,17 @@ getLocalChat :: DB.Connection -> User -> Int64 -> ChatPagination -> Maybe String
|
|||||||
getLocalChat db user folderId pagination search_ = do
|
getLocalChat db user folderId pagination search_ = do
|
||||||
let search = fromMaybe "" search_
|
let search = fromMaybe "" search_
|
||||||
nf <- getNoteFolder db user folderId
|
nf <- getNoteFolder db user folderId
|
||||||
case pagination of
|
liftIO $ case pagination of
|
||||||
CPLast count -> getLocalChatLast_ db user nf count search
|
CPLast count -> getLocalChatLast_ db user nf count search
|
||||||
CPAfter afterId count -> getLocalChatAfter_ db user nf afterId count search
|
CPAfter afterId count -> getLocalChatAfter_ db user nf afterId count search
|
||||||
CPBefore beforeId count -> getLocalChatBefore_ db user nf beforeId count search
|
CPBefore beforeId count -> getLocalChatBefore_ db user nf beforeId count search
|
||||||
|
|
||||||
getLocalChatLast_ :: DB.Connection -> User -> NoteFolder -> Int -> String -> ExceptT StoreError IO (Chat 'CTLocal)
|
getLocalChatLast_ :: DB.Connection -> User -> NoteFolder -> Int -> String -> IO (Chat 'CTLocal)
|
||||||
getLocalChatLast_ db user@User {userId} nf@NoteFolder {noteFolderId} count search = do
|
getLocalChatLast_ db user@User {userId} nf@NoteFolder {noteFolderId} count search = do
|
||||||
let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False}
|
let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False}
|
||||||
chatItemIds <- liftIO getLocalChatItemIdsLast_
|
chatItemIds <- getLocalChatItemIdsLast_
|
||||||
chatItems <- mapM (getLocalChatItem db user noteFolderId) chatItemIds
|
currentTs <- getCurrentTime
|
||||||
|
chatItems <- mapM (safeGetLocalItem db user nf currentTs) chatItemIds
|
||||||
pure $ Chat (LocalChat nf) (reverse chatItems) stats
|
pure $ Chat (LocalChat nf) (reverse chatItems) stats
|
||||||
where
|
where
|
||||||
getLocalChatItemIdsLast_ :: IO [ChatItemId]
|
getLocalChatItemIdsLast_ :: IO [ChatItemId]
|
||||||
@ -1139,11 +1192,38 @@ getLocalChatLast_ db user@User {userId} nf@NoteFolder {noteFolderId} count searc
|
|||||||
|]
|
|]
|
||||||
(userId, noteFolderId, search, count)
|
(userId, noteFolderId, search, count)
|
||||||
|
|
||||||
getLocalChatAfter_ :: DB.Connection -> User -> NoteFolder -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTLocal)
|
safeGetLocalItem :: DB.Connection -> User -> NoteFolder -> UTCTime -> ChatItemId -> IO (CChatItem 'CTLocal)
|
||||||
|
safeGetLocalItem db user NoteFolder {noteFolderId} currentTs itemId =
|
||||||
|
runExceptT (getLocalChatItem db user noteFolderId itemId)
|
||||||
|
>>= pure <$> safeToLocalItem currentTs itemId
|
||||||
|
|
||||||
|
safeToLocalItem :: UTCTime -> ChatItemId -> Either StoreError (CChatItem 'CTLocal) -> CChatItem 'CTLocal
|
||||||
|
safeToLocalItem currentTs itemId = \case
|
||||||
|
Right ci -> ci
|
||||||
|
Left e@(SEBadChatItem _ (Just itemTs)) -> badLocalItem itemTs e
|
||||||
|
Left e -> badLocalItem currentTs e
|
||||||
|
where
|
||||||
|
badLocalItem :: UTCTime -> StoreError -> CChatItem 'CTLocal
|
||||||
|
badLocalItem ts e =
|
||||||
|
let errorText = T.pack $ show e
|
||||||
|
in CChatItem
|
||||||
|
SMDSnd
|
||||||
|
ChatItem
|
||||||
|
{ chatDir = CILocalSnd,
|
||||||
|
meta = dummyMeta itemId ts errorText,
|
||||||
|
content = CIInvalidJSON errorText,
|
||||||
|
formattedText = Nothing,
|
||||||
|
quotedItem = Nothing,
|
||||||
|
reactions = [],
|
||||||
|
file = Nothing
|
||||||
|
}
|
||||||
|
|
||||||
|
getLocalChatAfter_ :: DB.Connection -> User -> NoteFolder -> ChatItemId -> Int -> String -> IO (Chat 'CTLocal)
|
||||||
getLocalChatAfter_ db user@User {userId} nf@NoteFolder {noteFolderId} afterChatItemId count search = do
|
getLocalChatAfter_ db user@User {userId} nf@NoteFolder {noteFolderId} afterChatItemId count search = do
|
||||||
let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False}
|
let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False}
|
||||||
chatItemIds <- liftIO getLocalChatItemIdsAfter_
|
chatItemIds <- getLocalChatItemIdsAfter_
|
||||||
chatItems <- mapM (getLocalChatItem db user noteFolderId) chatItemIds
|
currentTs <- getCurrentTime
|
||||||
|
chatItems <- mapM (safeGetLocalItem db user nf currentTs) chatItemIds
|
||||||
pure $ Chat (LocalChat nf) chatItems stats
|
pure $ Chat (LocalChat nf) chatItems stats
|
||||||
where
|
where
|
||||||
getLocalChatItemIdsAfter_ :: IO [ChatItemId]
|
getLocalChatItemIdsAfter_ :: IO [ChatItemId]
|
||||||
@ -1161,11 +1241,12 @@ getLocalChatAfter_ db user@User {userId} nf@NoteFolder {noteFolderId} afterChatI
|
|||||||
|]
|
|]
|
||||||
(userId, noteFolderId, search, afterChatItemId, count)
|
(userId, noteFolderId, search, afterChatItemId, count)
|
||||||
|
|
||||||
getLocalChatBefore_ :: DB.Connection -> User -> NoteFolder -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTLocal)
|
getLocalChatBefore_ :: DB.Connection -> User -> NoteFolder -> ChatItemId -> Int -> String -> IO (Chat 'CTLocal)
|
||||||
getLocalChatBefore_ db user@User {userId} nf@NoteFolder {noteFolderId} beforeChatItemId count search = do
|
getLocalChatBefore_ db user@User {userId} nf@NoteFolder {noteFolderId} beforeChatItemId count search = do
|
||||||
let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False}
|
let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False}
|
||||||
chatItemIds <- liftIO getLocalChatItemIdsBefore_
|
chatItemIds <- getLocalChatItemIdsBefore_
|
||||||
chatItems <- mapM (getLocalChatItem db user noteFolderId) chatItemIds
|
currentTs <- getCurrentTime
|
||||||
|
chatItems <- mapM (safeGetLocalItem db user nf currentTs) chatItemIds
|
||||||
pure $ Chat (LocalChat nf) (reverse chatItems) stats
|
pure $ Chat (LocalChat nf) (reverse chatItems) stats
|
||||||
where
|
where
|
||||||
getLocalChatItemIdsBefore_ :: IO [ChatItemId]
|
getLocalChatItemIdsBefore_ :: IO [ChatItemId]
|
||||||
@ -1188,7 +1269,7 @@ toChatItemRef = \case
|
|||||||
(itemId, Just contactId, Nothing, Nothing) -> Right (ChatRef CTDirect contactId, itemId)
|
(itemId, Just contactId, Nothing, Nothing) -> Right (ChatRef CTDirect contactId, itemId)
|
||||||
(itemId, Nothing, Just groupId, Nothing) -> Right (ChatRef CTGroup groupId, itemId)
|
(itemId, Nothing, Just groupId, Nothing) -> Right (ChatRef CTGroup groupId, itemId)
|
||||||
(itemId, Nothing, Nothing, Just folderId) -> Right (ChatRef CTLocal folderId, itemId)
|
(itemId, Nothing, Nothing, Just folderId) -> Right (ChatRef CTLocal folderId, itemId)
|
||||||
(itemId, _, _, _) -> Left $ SEBadChatItem itemId
|
(itemId, _, _, _) -> Left $ SEBadChatItem itemId Nothing
|
||||||
|
|
||||||
updateDirectChatItemsRead :: DB.Connection -> User -> ContactId -> Maybe (ChatItemId, ChatItemId) -> IO ()
|
updateDirectChatItemsRead :: DB.Connection -> User -> ContactId -> Maybe (ChatItemId, ChatItemId) -> IO ()
|
||||||
updateDirectChatItemsRead db User {userId} contactId itemsRange_ = do
|
updateDirectChatItemsRead db User {userId} contactId itemsRange_ = do
|
||||||
@ -1361,7 +1442,7 @@ toDirectChatItem currentTs (((itemId, itemTs, AMsgDirection msgDir, itemContentT
|
|||||||
cItem :: MsgDirectionI d => SMsgDirection d -> CIDirection 'CTDirect d -> CIStatus d -> CIContent d -> Maybe (CIFile d) -> CChatItem 'CTDirect
|
cItem :: MsgDirectionI d => SMsgDirection d -> CIDirection 'CTDirect d -> CIStatus d -> CIContent d -> Maybe (CIFile d) -> CChatItem 'CTDirect
|
||||||
cItem d chatDir ciStatus content file =
|
cItem d chatDir ciStatus content file =
|
||||||
CChatItem d ChatItem {chatDir, meta = ciMeta content ciStatus, content, formattedText = parseMaybeMarkdownList itemText, quotedItem = toDirectQuote quoteRow, reactions = [], file}
|
CChatItem d ChatItem {chatDir, meta = ciMeta content ciStatus, content, formattedText = parseMaybeMarkdownList itemText, quotedItem = toDirectQuote quoteRow, reactions = [], file}
|
||||||
badItem = Left $ SEBadChatItem itemId
|
badItem = Left $ SEBadChatItem itemId (Just itemTs)
|
||||||
ciMeta :: CIContent d -> CIStatus d -> CIMeta 'CTDirect d
|
ciMeta :: CIContent d -> CIStatus d -> CIMeta 'CTDirect d
|
||||||
ciMeta content status =
|
ciMeta content status =
|
||||||
let itemDeleted' = case itemDeleted of
|
let itemDeleted' = case itemDeleted of
|
||||||
@ -1412,7 +1493,7 @@ toGroupChatItem currentTs userContactId (((itemId, itemTs, AMsgDirection msgDir,
|
|||||||
cItem :: MsgDirectionI d => SMsgDirection d -> CIDirection 'CTGroup d -> CIStatus d -> CIContent d -> Maybe (CIFile d) -> CChatItem 'CTGroup
|
cItem :: MsgDirectionI d => SMsgDirection d -> CIDirection 'CTGroup d -> CIStatus d -> CIContent d -> Maybe (CIFile d) -> CChatItem 'CTGroup
|
||||||
cItem d chatDir ciStatus content file =
|
cItem d chatDir ciStatus content file =
|
||||||
CChatItem d ChatItem {chatDir, meta = ciMeta content ciStatus, content, formattedText = parseMaybeMarkdownList itemText, quotedItem = toGroupQuote quoteRow quotedMember_, reactions = [], file}
|
CChatItem d ChatItem {chatDir, meta = ciMeta content ciStatus, content, formattedText = parseMaybeMarkdownList itemText, quotedItem = toGroupQuote quoteRow quotedMember_, reactions = [], file}
|
||||||
badItem = Left $ SEBadChatItem itemId
|
badItem = Left $ SEBadChatItem itemId (Just itemTs)
|
||||||
ciMeta :: CIContent d -> CIStatus d -> CIMeta 'CTGroup d
|
ciMeta :: CIContent d -> CIStatus d -> CIMeta 'CTGroup d
|
||||||
ciMeta content status =
|
ciMeta content status =
|
||||||
let itemDeleted' = case itemDeleted of
|
let itemDeleted' = case itemDeleted of
|
||||||
@ -2085,6 +2166,12 @@ getChatItemByFileId db vr user@User {userId} fileId = do
|
|||||||
(userId, fileId)
|
(userId, fileId)
|
||||||
getAChatItem db vr user chatRef itemId
|
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.Connection -> VersionRange -> User -> GroupId -> ExceptT StoreError IO AChatItem
|
||||||
getChatItemByGroupId db vr user@User {userId} groupId = do
|
getChatItemByGroupId db vr user@User {userId} groupId = do
|
||||||
(chatRef, itemId) <-
|
(chatRef, itemId) <-
|
||||||
@ -2109,7 +2196,7 @@ getChatRefViaItemId db User {userId} itemId = do
|
|||||||
toChatRef = \case
|
toChatRef = \case
|
||||||
(Just contactId, Nothing) -> Right $ ChatRef CTDirect contactId
|
(Just contactId, Nothing) -> Right $ ChatRef CTDirect contactId
|
||||||
(Nothing, Just groupId) -> Right $ ChatRef CTGroup groupId
|
(Nothing, Just groupId) -> Right $ ChatRef CTGroup groupId
|
||||||
(_, _) -> Left $ SEBadChatItem itemId
|
(_, _) -> Left $ SEBadChatItem itemId Nothing
|
||||||
|
|
||||||
getAChatItem :: DB.Connection -> VersionRange -> User -> ChatRef -> ChatItemId -> ExceptT StoreError IO AChatItem
|
getAChatItem :: DB.Connection -> VersionRange -> User -> ChatRef -> ChatItemId -> ExceptT StoreError IO AChatItem
|
||||||
getAChatItem db vr user chatRef itemId = case chatRef of
|
getAChatItem db vr user chatRef itemId = case chatRef of
|
||||||
@ -2145,11 +2232,6 @@ getChatItemVersions db itemId = do
|
|||||||
let formattedText = parseMaybeMarkdownList $ msgContentText msgContent
|
let formattedText = parseMaybeMarkdownList $ msgContentText msgContent
|
||||||
in ChatItemVersion {chatItemVersionId, msgContent, formattedText, itemVersionTs, createdAt}
|
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.Connection -> Contact -> CChatItem 'CTDirect -> IO (CChatItem 'CTDirect)
|
||||||
directCIWithReactions db ct cci@(CChatItem md ci@ChatItem {meta = CIMeta {itemSharedMsgId}}) = case itemSharedMsgId of
|
directCIWithReactions db ct cci@(CChatItem md ci@ChatItem {meta = CIMeta {itemSharedMsgId}}) = case itemSharedMsgId of
|
||||||
Just sharedMsgId -> do
|
Just sharedMsgId -> do
|
||||||
|
@ -98,6 +98,9 @@ import Simplex.Chat.Migrations.M20240102_note_folders
|
|||||||
import Simplex.Chat.Migrations.M20240104_members_profile_update
|
import Simplex.Chat.Migrations.M20240104_members_profile_update
|
||||||
import Simplex.Chat.Migrations.M20240115_block_member_for_all
|
import Simplex.Chat.Migrations.M20240115_block_member_for_all
|
||||||
import Simplex.Chat.Migrations.M20240122_indexes
|
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 (..))
|
import Simplex.Messaging.Agent.Store.SQLite.Migrations (Migration (..))
|
||||||
|
|
||||||
schemaMigrations :: [(String, Query, Maybe Query)]
|
schemaMigrations :: [(String, Query, Maybe Query)]
|
||||||
@ -195,7 +198,10 @@ schemaMigrations =
|
|||||||
("20240102_note_folders", m20240102_note_folders, Just down_m20240102_note_folders),
|
("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),
|
("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),
|
("20240115_block_member_for_all", m20240115_block_member_for_all, Just down_m20240115_block_member_for_all),
|
||||||
("20240122_indexes", m20240122_indexes, Just down_m20240122_indexes)
|
("20240122_indexes", m20240122_indexes, Just down_m20240122_indexes),
|
||||||
|
("20240214_redirect_file_id", m20240214_redirect_file_id, Just down_m20240214_redirect_file_id),
|
||||||
|
("20240222_app_settings", m20240222_app_settings, Just down_m20240222_app_settings),
|
||||||
|
("20240226_users_restrict", m20240226_users_restrict, Just down_m20240226_users_restrict)
|
||||||
]
|
]
|
||||||
|
|
||||||
-- | The list of migrations in ascending order by date
|
-- | The list of migrations in ascending order by date
|
||||||
|
@ -92,11 +92,12 @@ data StoreError
|
|||||||
| SEUniqueID
|
| SEUniqueID
|
||||||
| SELargeMsg
|
| SELargeMsg
|
||||||
| SEInternalError {message :: String}
|
| SEInternalError {message :: String}
|
||||||
| SEBadChatItem {itemId :: ChatItemId}
|
| SEBadChatItem {itemId :: ChatItemId, itemTs :: Maybe ChatItemTs}
|
||||||
| SEChatItemNotFound {itemId :: ChatItemId}
|
| SEChatItemNotFound {itemId :: ChatItemId}
|
||||||
| SEChatItemNotFoundByText {text :: Text}
|
| SEChatItemNotFoundByText {text :: Text}
|
||||||
| SEChatItemSharedMsgIdNotFound {sharedMsgId :: SharedMsgId}
|
| SEChatItemSharedMsgIdNotFound {sharedMsgId :: SharedMsgId}
|
||||||
| SEChatItemNotFoundByFileId {fileId :: FileTransferId}
|
| SEChatItemNotFoundByFileId {fileId :: FileTransferId}
|
||||||
|
| SEChatItemNotFoundByContactId {contactId :: ContactId}
|
||||||
| SEChatItemNotFoundByGroupId {groupId :: GroupId}
|
| SEChatItemNotFoundByGroupId {groupId :: GroupId}
|
||||||
| SEProfileNotFound {profileId :: Int64}
|
| SEProfileNotFound {profileId :: Int64}
|
||||||
| SEDuplicateGroupLink {groupInfo :: GroupInfo}
|
| SEDuplicateGroupLink {groupInfo :: GroupInfo}
|
||||||
|
@ -46,7 +46,7 @@ import Database.SQLite.Simple.ToField (ToField (..))
|
|||||||
import Simplex.Chat.Types.Preferences
|
import Simplex.Chat.Types.Preferences
|
||||||
import Simplex.Chat.Types.Util
|
import Simplex.Chat.Types.Util
|
||||||
import Simplex.FileTransfer.Description (FileDigest)
|
import Simplex.FileTransfer.Description (FileDigest)
|
||||||
import Simplex.Messaging.Agent.Protocol (ACommandTag (..), ACorrId, AParty (..), APartyCmdTag (..), ConnId, ConnectionMode (..), ConnectionRequestUri, InvitationId, SAEntity (..), UserId)
|
import Simplex.Messaging.Agent.Protocol (ACommandTag (..), ACorrId, AParty (..), APartyCmdTag (..), ConnId, ConnectionMode (..), ConnectionRequestUri, InvitationId, RcvFileId, SAEntity (..), SndFileId, UserId)
|
||||||
import Simplex.Messaging.Crypto.File (CryptoFileArgs (..))
|
import Simplex.Messaging.Crypto.File (CryptoFileArgs (..))
|
||||||
import Simplex.Messaging.Encoding.String
|
import Simplex.Messaging.Encoding.String
|
||||||
import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, fromTextField_, sumTypeJSON, taggedObjectJSON)
|
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
|
instance ToField AgentConnId where toField (AgentConnId m) = toField m
|
||||||
|
|
||||||
newtype AgentSndFileId = AgentSndFileId ConnId
|
newtype AgentSndFileId = AgentSndFileId SndFileId
|
||||||
deriving (Eq, Show)
|
deriving (Eq, Show)
|
||||||
|
|
||||||
instance StrEncoding AgentSndFileId where
|
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
|
instance ToField AgentSndFileId where toField (AgentSndFileId m) = toField m
|
||||||
|
|
||||||
newtype AgentRcvFileId = AgentRcvFileId ConnId
|
newtype AgentRcvFileId = AgentRcvFileId RcvFileId
|
||||||
deriving (Eq, Show)
|
deriving (Eq, Show)
|
||||||
|
|
||||||
instance StrEncoding AgentRcvFileId where
|
instance StrEncoding AgentRcvFileId where
|
||||||
@ -1210,6 +1210,7 @@ data FileTransfer
|
|||||||
data FileTransferMeta = FileTransferMeta
|
data FileTransferMeta = FileTransferMeta
|
||||||
{ fileId :: FileTransferId,
|
{ fileId :: FileTransferId,
|
||||||
xftpSndFile :: Maybe XFTPSndFile,
|
xftpSndFile :: Maybe XFTPSndFile,
|
||||||
|
xftpRedirectFor :: Maybe FileTransferId,
|
||||||
fileName :: String,
|
fileName :: String,
|
||||||
filePath :: String,
|
filePath :: String,
|
||||||
fileSize :: Integer,
|
fileSize :: Integer,
|
||||||
|
@ -198,17 +198,24 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe
|
|||||||
CRGroupMemberUpdated {} -> []
|
CRGroupMemberUpdated {} -> []
|
||||||
CRContactsMerged u intoCt mergedCt ct' -> ttyUser u $ viewContactsMerged intoCt mergedCt ct'
|
CRContactsMerged u intoCt mergedCt ct' -> ttyUser u $ viewContactsMerged intoCt mergedCt ct'
|
||||||
CRReceivedContactRequest u UserContactRequest {localDisplayName = c, profile} -> ttyUser u $ viewReceivedContactRequest c profile
|
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
|
CRRcvFileStart u ci -> ttyUser u $ receivingFile_' hu testView "started" ci
|
||||||
CRRcvFileComplete u ci -> ttyUser u $ receivingFile_' hu testView "completed" 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
|
CRRcvFileSndCancelled u _ ft -> ttyUser u $ viewRcvFileSndCancelled ft
|
||||||
CRRcvFileError u ci e -> ttyUser u $ receivingFile_' hu testView "error" ci <> [sShow e]
|
CRRcvFileError u (Just ci) e _ -> ttyUser u $ receivingFile_' hu testView "error" ci <> [sShow e]
|
||||||
|
CRRcvFileError u Nothing e ft -> ttyUser u $ receivingFileStandalone "error" ft <> [sShow e]
|
||||||
CRSndFileStart u _ ft -> ttyUser u $ sendingFile_ "started" ft
|
CRSndFileStart u _ ft -> ttyUser u $ sendingFile_ "started" ft
|
||||||
CRSndFileComplete u _ ft -> ttyUser u $ sendingFile_ "completed" ft
|
CRSndFileComplete u _ ft -> ttyUser u $ sendingFile_ "completed" ft
|
||||||
|
CRSndStandaloneFileCreated u ft -> ttyUser u $ uploadingFileStandalone "started" ft
|
||||||
CRSndFileStartXFTP {} -> []
|
CRSndFileStartXFTP {} -> []
|
||||||
CRSndFileProgressXFTP {} -> []
|
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
|
CRSndFileCompleteXFTP u ci _ -> ttyUser u $ uploadingFile "completed" ci
|
||||||
CRSndFileCancelledXFTP {} -> []
|
CRSndFileCancelledXFTP {} -> []
|
||||||
CRSndFileError u ci -> ttyUser u $ uploadingFile "error" ci
|
CRSndFileError u Nothing ft -> ttyUser u $ uploadingFileStandalone "error" ft
|
||||||
|
CRSndFileError u (Just ci) _ -> ttyUser u $ uploadingFile "error" ci
|
||||||
CRSndFileRcvCancelled u _ ft@SndFileTransfer {recipientDisplayName = c} ->
|
CRSndFileRcvCancelled u _ ft@SndFileTransfer {recipientDisplayName = c} ->
|
||||||
ttyUser u [ttyContact c <> " cancelled receiving " <> sndFile ft]
|
ttyUser u [ttyContact c <> " cancelled receiving " <> sndFile ft]
|
||||||
CRContactConnecting u _ -> ttyUser u []
|
CRContactConnecting u _ -> ttyUser u []
|
||||||
@ -283,7 +290,7 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe
|
|||||||
CRUserContactLinkSubError e -> ["user address error: " <> sShow e, "to delete your address: " <> highlight' "/da"]
|
CRUserContactLinkSubError e -> ["user address error: " <> sShow e, "to delete your address: " <> highlight' "/da"]
|
||||||
CRContactConnectionDeleted u PendingContactConnection {pccConnId} -> ttyUser u ["connection :" <> sShow pccConnId <> " deleted"]
|
CRContactConnectionDeleted u PendingContactConnection {pccConnId} -> ttyUser u ["connection :" <> sShow pccConnId <> " deleted"]
|
||||||
CRNtfTokenStatus status -> ["device token status: " <> plain (smpEncode status)]
|
CRNtfTokenStatus status -> ["device token status: " <> plain (smpEncode status)]
|
||||||
CRNtfToken _ status mode -> ["device token status: " <> plain (smpEncode status) <> ", notifications mode: " <> plain (strEncode mode)]
|
CRNtfToken _ status mode srv -> ["device token status: " <> plain (smpEncode status) <> ", notifications mode: " <> plain (strEncode mode) <> ", server: " <> sShow srv]
|
||||||
CRNtfMessages {} -> []
|
CRNtfMessages {} -> []
|
||||||
CRNtfMessage {} -> []
|
CRNtfMessage {} -> []
|
||||||
CRCurrentRemoteHost rhi_ ->
|
CRCurrentRemoteHost rhi_ ->
|
||||||
@ -378,6 +385,7 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe
|
|||||||
CRChatError u e -> ttyUser' u $ viewChatError logLevel testView e
|
CRChatError u e -> ttyUser' u $ viewChatError logLevel testView e
|
||||||
CRChatErrors u errs -> ttyUser' u $ concatMap (viewChatError logLevel testView) errs
|
CRChatErrors u errs -> ttyUser' u $ concatMap (viewChatError logLevel testView) errs
|
||||||
CRArchiveImported archiveErrs -> if null archiveErrs then ["ok"] else ["archive import errors: " <> plain (show archiveErrs)]
|
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 _ _ -> []
|
CRTimedAction _ _ -> []
|
||||||
where
|
where
|
||||||
ttyUser :: User -> [StyledString] -> [StyledString]
|
ttyUser :: User -> [StyledString] -> [StyledString]
|
||||||
@ -1558,11 +1566,26 @@ sendingFile_ status ft@SndFileTransfer {recipientDisplayName = c} =
|
|||||||
[status <> " sending " <> sndFile ft <> " to " <> ttyContact c]
|
[status <> " sending " <> sndFile ft <> " to " <> ttyContact c]
|
||||||
|
|
||||||
uploadingFile :: StyledString -> AChatItem -> [StyledString]
|
uploadingFile :: StyledString -> AChatItem -> [StyledString]
|
||||||
uploadingFile status (AChatItem _ _ (DirectChat Contact {localDisplayName = c}) ChatItem {file = Just CIFile {fileId, fileName}, chatDir = CIDirectSnd}) =
|
uploadingFile status = \case
|
||||||
[status <> " uploading " <> fileTransferStr fileId fileName <> " for " <> ttyContact c]
|
AChatItem _ _ (DirectChat Contact {localDisplayName = c}) ChatItem {file = Just CIFile {fileId, fileName}, chatDir = CIDirectSnd} ->
|
||||||
uploadingFile status (AChatItem _ _ (GroupChat g) ChatItem {file = Just CIFile {fileId, fileName}, chatDir = CIGroupSnd}) =
|
[status <> " uploading " <> fileTransferStr fileId fileName <> " for " <> ttyContact c]
|
||||||
[status <> " uploading " <> fileTransferStr fileId fileName <> " for " <> ttyGroup' g]
|
AChatItem _ _ (GroupChat g) ChatItem {file = Just CIFile {fileId, fileName}, chatDir = CIGroupSnd} ->
|
||||||
uploadingFile status _ = [status <> " uploading file"] -- shouldn't happen
|
[status <> " uploading " <> fileTransferStr fileId fileName <> " for " <> ttyGroup' g]
|
||||||
|
_ -> [status <> " uploading file"]
|
||||||
|
|
||||||
|
uploadingFileStandalone :: StyledString -> FileTransferMeta -> [StyledString]
|
||||||
|
uploadingFileStandalone status FileTransferMeta {fileId, fileName} = [status <> " standalone uploading " <> fileTransferStr fileId fileName]
|
||||||
|
|
||||||
|
standaloneUploadRedirect :: FileTransferMeta -> FileTransferMeta -> [StyledString]
|
||||||
|
standaloneUploadRedirect FileTransferMeta {fileId, fileName} FileTransferMeta {fileId = redirectId} =
|
||||||
|
[fileTransferStr fileId fileName <> " uploaded, preparing redirect file " <> sShow redirectId]
|
||||||
|
|
||||||
|
standaloneUploadComplete :: FileTransferMeta -> [Text] -> [StyledString]
|
||||||
|
standaloneUploadComplete FileTransferMeta {fileId, fileName} = \case
|
||||||
|
[] -> [fileTransferStr fileId fileName <> " upload complete."]
|
||||||
|
uris ->
|
||||||
|
fileTransferStr fileId fileName <> " upload complete. download with:"
|
||||||
|
: map plain uris
|
||||||
|
|
||||||
sndFile :: SndFileTransfer -> StyledString
|
sndFile :: SndFileTransfer -> StyledString
|
||||||
sndFile SndFileTransfer {fileId, fileName} = fileTransferStr fileId fileName
|
sndFile SndFileTransfer {fileId, fileName} = fileTransferStr fileId fileName
|
||||||
@ -1608,7 +1631,11 @@ receivingFile_' hu testView status (AChatItem _ _ chat ChatItem {file = Just CIF
|
|||||||
highlight ("/get remote file " <> show rhId <> " " <> LB.unpack (J.encode RemoteFile {userId, fileId, sent = False, fileSource = f}))
|
highlight ("/get remote file " <> show rhId <> " " <> LB.unpack (J.encode RemoteFile {userId, fileId, sent = False, fileSource = f}))
|
||||||
]
|
]
|
||||||
_ -> []
|
_ -> []
|
||||||
receivingFile_' _ _ status _ = [plain status <> " receiving file"] -- shouldn't happen
|
receivingFile_' _ _ status _ = [plain status <> " receiving file"]
|
||||||
|
|
||||||
|
receivingFileStandalone :: String -> RcvFileTransfer -> [StyledString]
|
||||||
|
receivingFileStandalone status RcvFileTransfer {fileId, fileInvitation = FileInvitation {fileName}} =
|
||||||
|
[plain status <> " standalone receiving " <> fileTransferStr fileId fileName]
|
||||||
|
|
||||||
viewLocalFile :: StyledString -> CIFile d -> CurrentTime -> TimeZone -> CIMeta c d -> [StyledString]
|
viewLocalFile :: StyledString -> CIFile d -> CurrentTime -> TimeZone -> CIMeta c d -> [StyledString]
|
||||||
viewLocalFile to CIFile {fileId, fileSource} ts tz = case fileSource of
|
viewLocalFile to CIFile {fileId, fileSource} ts tz = case fileSource of
|
||||||
@ -1627,7 +1654,7 @@ fileFrom _ _ = ""
|
|||||||
|
|
||||||
receivingFile_ :: StyledString -> RcvFileTransfer -> [StyledString]
|
receivingFile_ :: StyledString -> RcvFileTransfer -> [StyledString]
|
||||||
receivingFile_ status ft@RcvFileTransfer {senderDisplayName = c} =
|
receivingFile_ status ft@RcvFileTransfer {senderDisplayName = c} =
|
||||||
[status <> " receiving " <> rcvFile ft <> " from " <> ttyContact c]
|
[status <> " receiving " <> rcvFile ft <> if c == "" then "" else " from " <> ttyContact c]
|
||||||
|
|
||||||
rcvFile :: RcvFileTransfer -> StyledString
|
rcvFile :: RcvFileTransfer -> StyledString
|
||||||
rcvFile RcvFileTransfer {fileId, fileInvitation = FileInvitation {fileName}} = fileTransferStr fileId fileName
|
rcvFile RcvFileTransfer {fileId, fileInvitation = FileInvitation {fileName}} = fileTransferStr fileId fileName
|
||||||
|
@ -146,9 +146,9 @@ testAgentCfgV1 :: AgentConfig
|
|||||||
testAgentCfgV1 =
|
testAgentCfgV1 =
|
||||||
testAgentCfg
|
testAgentCfg
|
||||||
{ smpClientVRange = v1Range,
|
{ smpClientVRange = v1Range,
|
||||||
smpAgentVRange = v1Range,
|
smpAgentVRange = versionToRange 2, -- duplexHandshakeSMPAgentVersion,
|
||||||
e2eEncryptVRange = v1Range,
|
e2eEncryptVRange = versionToRange 2, -- kdfX3DHE2EEncryptVersion,
|
||||||
smpCfg = (smpCfg testAgentCfg) {serverVRange = v1Range}
|
smpCfg = (smpCfg testAgentCfg) {serverVRange = versionToRange 4} -- batchCmdsSMPVersion
|
||||||
}
|
}
|
||||||
|
|
||||||
testCfgVPrev :: ChatConfig
|
testCfgVPrev :: ChatConfig
|
||||||
@ -166,7 +166,7 @@ testCfgV1 =
|
|||||||
}
|
}
|
||||||
|
|
||||||
prevRange :: VersionRange -> VersionRange
|
prevRange :: VersionRange -> VersionRange
|
||||||
prevRange vr = vr {maxVersion = maxVersion vr - 1}
|
prevRange vr = vr {maxVersion = max (minVersion vr) (maxVersion vr - 1)}
|
||||||
|
|
||||||
v1Range :: VersionRange
|
v1Range :: VersionRange
|
||||||
v1Range = mkVersionRange 1 1
|
v1Range = mkVersionRange 1 1
|
||||||
@ -385,7 +385,7 @@ serverCfg =
|
|||||||
logStatsStartTime = 0,
|
logStatsStartTime = 0,
|
||||||
serverStatsLogFile = "tests/smp-server-stats.daily.log",
|
serverStatsLogFile = "tests/smp-server-stats.daily.log",
|
||||||
serverStatsBackupFile = Nothing,
|
serverStatsBackupFile = Nothing,
|
||||||
smpServerVRange = supportedSMPServerVRange,
|
smpServerVRange = supportedServerSMPRelayVRange,
|
||||||
transportConfig = defaultTransportServerConfig,
|
transportConfig = defaultTransportServerConfig,
|
||||||
smpHandshakeTimeout = 1000000,
|
smpHandshakeTimeout = 1000000,
|
||||||
controlPort = Nothing
|
controlPort = Nothing
|
||||||
@ -408,7 +408,7 @@ xftpServerConfig =
|
|||||||
storeLogFile = Just "tests/tmp/xftp-server-store.log",
|
storeLogFile = Just "tests/tmp/xftp-server-store.log",
|
||||||
filesPath = xftpServerFiles,
|
filesPath = xftpServerFiles,
|
||||||
fileSizeQuota = Nothing,
|
fileSizeQuota = Nothing,
|
||||||
allowedChunkSizes = [kb 128, kb 256, mb 1, mb 4],
|
allowedChunkSizes = [kb 64, kb 128, kb 256, mb 1, mb 4],
|
||||||
allowNewFiles = True,
|
allowNewFiles = True,
|
||||||
newFileBasicAuth = Nothing,
|
newFileBasicAuth = Nothing,
|
||||||
fileExpiration = Just defaultFileExpiration,
|
fileExpiration = Just defaultFileExpiration,
|
||||||
|
@ -14,6 +14,9 @@ import Data.Aeson (ToJSON)
|
|||||||
import qualified Data.Aeson as J
|
import qualified Data.Aeson as J
|
||||||
import qualified Data.ByteString.Char8 as B
|
import qualified Data.ByteString.Char8 as B
|
||||||
import qualified Data.ByteString.Lazy.Char8 as LB
|
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.Call
|
||||||
import Simplex.Chat.Controller (ChatConfig (..))
|
import Simplex.Chat.Controller (ChatConfig (..))
|
||||||
import Simplex.Chat.Options (ChatOpts (..))
|
import Simplex.Chat.Options (ChatOpts (..))
|
||||||
@ -21,6 +24,7 @@ import Simplex.Chat.Protocol (supportedChatVRange)
|
|||||||
import Simplex.Chat.Store (agentStoreFile, chatStoreFile)
|
import Simplex.Chat.Store (agentStoreFile, chatStoreFile)
|
||||||
import Simplex.Chat.Types (authErrDisableCount, sameVerificationCode, verificationCode)
|
import Simplex.Chat.Types (authErrDisableCount, sameVerificationCode, verificationCode)
|
||||||
import qualified Simplex.Messaging.Crypto as C
|
import qualified Simplex.Messaging.Crypto as C
|
||||||
|
import Simplex.Messaging.Util (safeDecodeUtf8)
|
||||||
import Simplex.Messaging.Version
|
import Simplex.Messaging.Version
|
||||||
import System.Directory (copyFile, doesDirectoryExist, doesFileExist)
|
import System.Directory (copyFile, doesDirectoryExist, doesFileExist)
|
||||||
import System.FilePath ((</>))
|
import System.FilePath ((</>))
|
||||||
@ -84,8 +88,9 @@ chatDirectTests = do
|
|||||||
it "disabling chat item expiration doesn't disable it for other users" testDisableCIExpirationOnlyForOneUser
|
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 "both users have configured timed messages with contacts, messages expire, restart" testUsersTimedMessages
|
||||||
it "user profile privacy: hide profiles and notificaitons" testUserPrivacy
|
it "user profile privacy: hide profiles and notificaitons" testUserPrivacy
|
||||||
describe "chat item expiration" $ do
|
describe "settings" $ do
|
||||||
it "set chat item TTL" testSetChatItemTTL
|
it "set chat item expiration TTL" testSetChatItemTTL
|
||||||
|
it "save/get app settings" testAppSettings
|
||||||
describe "connection switch" $ do
|
describe "connection switch" $ do
|
||||||
it "switch contact to a different queue" testSwitchContact
|
it "switch contact to a different queue" testSwitchContact
|
||||||
it "stop switching contact to a different queue" testAbortSwitchContact
|
it "stop switching contact to a different queue" testAbortSwitchContact
|
||||||
@ -1138,6 +1143,10 @@ testDatabaseEncryption tmp = do
|
|||||||
testChatWorking alice bob
|
testChatWorking alice bob
|
||||||
alice ##> "/_stop"
|
alice ##> "/_stop"
|
||||||
alice <## "chat stopped"
|
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 ##> "/db key wrongkey nextkey"
|
||||||
alice <## "error encrypting database: wrong passphrase or invalid database file"
|
alice <## "error encrypting database: wrong passphrase or invalid database file"
|
||||||
alice ##> "/db key mykey nextkey"
|
alice ##> "/db key mykey nextkey"
|
||||||
@ -2191,6 +2200,24 @@ testSetChatItemTTL =
|
|||||||
alice #$> ("/ttl none", id, "ok")
|
alice #$> ("/ttl none", id, "ok")
|
||||||
alice #$> ("/ttl", id, "old messages are not being deleted")
|
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 :: HasCallStack => FilePath -> IO ()
|
||||||
testSwitchContact =
|
testSwitchContact =
|
||||||
testChat2 aliceProfile bobProfile $
|
testChat2 aliceProfile bobProfile $
|
||||||
|
@ -9,6 +9,7 @@ import ChatClient
|
|||||||
import ChatTests.Utils
|
import ChatTests.Utils
|
||||||
import Control.Concurrent (threadDelay)
|
import Control.Concurrent (threadDelay)
|
||||||
import Control.Concurrent.Async (concurrently_)
|
import Control.Concurrent.Async (concurrently_)
|
||||||
|
import Control.Logger.Simple
|
||||||
import qualified Data.Aeson as J
|
import qualified Data.Aeson as J
|
||||||
import qualified Data.ByteString.Char8 as B
|
import qualified Data.ByteString.Char8 as B
|
||||||
import qualified Data.ByteString.Lazy.Char8 as LB
|
import qualified Data.ByteString.Lazy.Char8 as LB
|
||||||
@ -19,7 +20,6 @@ import Simplex.Chat.Options (ChatOpts (..))
|
|||||||
import Simplex.FileTransfer.Server.Env (XFTPServerConfig (..))
|
import Simplex.FileTransfer.Server.Env (XFTPServerConfig (..))
|
||||||
import Simplex.Messaging.Crypto.File (CryptoFile (..), CryptoFileArgs (..))
|
import Simplex.Messaging.Crypto.File (CryptoFile (..), CryptoFileArgs (..))
|
||||||
import Simplex.Messaging.Encoding.String
|
import Simplex.Messaging.Encoding.String
|
||||||
import Simplex.Messaging.Util (unlessM)
|
|
||||||
import System.Directory (copyFile, createDirectoryIfMissing, doesFileExist, getFileSize)
|
import System.Directory (copyFile, createDirectoryIfMissing, doesFileExist, getFileSize)
|
||||||
import Test.Hspec hiding (it)
|
import Test.Hspec hiding (it)
|
||||||
|
|
||||||
@ -50,6 +50,12 @@ chatFileTests = do
|
|||||||
it "cancel receiving file, repeat receive" testXFTPCancelRcvRepeat
|
it "cancel receiving file, repeat receive" testXFTPCancelRcvRepeat
|
||||||
it "should accept file automatically with CLI option" testAutoAcceptFile
|
it "should accept file automatically with CLI option" testAutoAcceptFile
|
||||||
it "should prohibit file transfers in groups based on preference" testProhibitFiles
|
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 :: HasCallStack => FilePath -> IO ()
|
||||||
runTestMessageWithFile = testChat2 aliceProfile bobProfile $ \alice bob -> withXFTPServer $ do
|
runTestMessageWithFile = testChat2 aliceProfile bobProfile $ \alice bob -> withXFTPServer $ do
|
||||||
@ -838,5 +844,143 @@ testProhibitFiles =
|
|||||||
(bob </)
|
(bob </)
|
||||||
(cath </)
|
(cath </)
|
||||||
|
|
||||||
waitFileExists :: HasCallStack => FilePath -> IO ()
|
testXFTPStandaloneSmall :: HasCallStack => FilePath -> IO ()
|
||||||
waitFileExists f = unlessM (doesFileExist f) $ waitFileExists f
|
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
|
||||||
|
@ -151,7 +151,7 @@ testFiles tmp = withNewTestChat tmp "alice" aliceProfile $ \alice -> do
|
|||||||
|
|
||||||
alice ##> "/clear *"
|
alice ##> "/clear *"
|
||||||
alice ##> "/fs 1"
|
alice ##> "/fs 1"
|
||||||
alice <## "chat db error: SEChatItemNotFoundByFileId {fileId = 1}"
|
alice <## "file 1 not found"
|
||||||
alice ##> "/tail"
|
alice ##> "/tail"
|
||||||
doesFileExist stored `shouldReturn` False
|
doesFileExist stored `shouldReturn` False
|
||||||
|
|
||||||
|
@ -82,9 +82,9 @@ versionTestMatrix2 runTest = do
|
|||||||
it "prev" $ testChatCfg2 testCfgVPrev aliceProfile bobProfile runTest
|
it "prev" $ testChatCfg2 testCfgVPrev aliceProfile bobProfile runTest
|
||||||
it "prev to curr" $ runTestCfg2 testCfg testCfgVPrev runTest
|
it "prev to curr" $ runTestCfg2 testCfg testCfgVPrev runTest
|
||||||
it "curr to prev" $ runTestCfg2 testCfgVPrev testCfg runTest
|
it "curr to prev" $ runTestCfg2 testCfgVPrev testCfg runTest
|
||||||
it "v1" $ testChatCfg2 testCfgV1 aliceProfile bobProfile runTest
|
it "old (1st supported)" $ testChatCfg2 testCfgV1 aliceProfile bobProfile runTest
|
||||||
it "v1 to v2" $ runTestCfg2 testCfg testCfgV1 runTest
|
it "old to curr" $ runTestCfg2 testCfg testCfgV1 runTest
|
||||||
it "v2 to v1" $ runTestCfg2 testCfgV1 testCfg runTest
|
it "curr to old" $ runTestCfg2 testCfgV1 testCfg runTest
|
||||||
|
|
||||||
versionTestMatrix3 :: (HasCallStack => TestCC -> TestCC -> TestCC -> IO ()) -> SpecWith FilePath
|
versionTestMatrix3 :: (HasCallStack => TestCC -> TestCC -> TestCC -> IO ()) -> SpecWith FilePath
|
||||||
versionTestMatrix3 runTest = do
|
versionTestMatrix3 runTest = do
|
||||||
|
@ -153,13 +153,13 @@ textWithUri = describe "text with Uri" do
|
|||||||
parseMarkdown "_https://simplex.chat" `shouldBe` "_https://simplex.chat"
|
parseMarkdown "_https://simplex.chat" `shouldBe` "_https://simplex.chat"
|
||||||
parseMarkdown "this is _https://simplex.chat" `shouldBe` "this is _https://simplex.chat"
|
parseMarkdown "this is _https://simplex.chat" `shouldBe` "this is _https://simplex.chat"
|
||||||
it "SimpleX links" do
|
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%3D1-2%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%3D2%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 ("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 ("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)
|
parseMarkdown ("https://example.com" <> inv) `shouldBe` simplexLink XLInvitation ("simplex:" <> inv) ["smp.simplex.im"] ("https://example.com" <> inv)
|
||||||
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"
|
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"
|
||||||
parseMarkdown ("https://simplex.chat" <> ct) `shouldBe` simplexLink XLContact ("simplex:" <> ct) ["smp.simplex.im"] ("https://simplex.chat" <> ct)
|
parseMarkdown ("https://simplex.chat" <> ct) `shouldBe` simplexLink XLContact ("simplex:" <> ct) ["smp.simplex.im"] ("https://simplex.chat" <> ct)
|
||||||
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"
|
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"
|
||||||
parseMarkdown ("https://simplex.chat" <> gr) `shouldBe` simplexLink XLGroup ("simplex:" <> gr) ["smp4.simplex.im", "o5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion"] ("https://simplex.chat" <> gr)
|
parseMarkdown ("https://simplex.chat" <> gr) `shouldBe` simplexLink XLGroup ("simplex:" <> gr) ["smp4.simplex.im", "o5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion"] ("https://simplex.chat" <> gr)
|
||||||
|
|
||||||
email :: Text -> Markdown
|
email :: Text -> Markdown
|
||||||
|
@ -15,6 +15,7 @@ import Simplex.Messaging.Agent.Protocol
|
|||||||
import qualified Simplex.Messaging.Crypto as C
|
import qualified Simplex.Messaging.Crypto as C
|
||||||
import Simplex.Messaging.Crypto.Ratchet
|
import Simplex.Messaging.Crypto.Ratchet
|
||||||
import Simplex.Messaging.Protocol (supportedSMPClientVRange)
|
import Simplex.Messaging.Protocol (supportedSMPClientVRange)
|
||||||
|
import Simplex.Messaging.ServiceScheme
|
||||||
import Simplex.Messaging.Version
|
import Simplex.Messaging.Version
|
||||||
import Test.Hspec
|
import Test.Hspec
|
||||||
|
|
||||||
@ -37,7 +38,7 @@ queue =
|
|||||||
connReqData :: ConnReqUriData
|
connReqData :: ConnReqUriData
|
||||||
connReqData =
|
connReqData =
|
||||||
ConnReqUriData
|
ConnReqUriData
|
||||||
{ crScheme = CRSSimplex,
|
{ crScheme = SSSimplex,
|
||||||
crAgentVRange = mkVersionRange 1 1,
|
crAgentVRange = mkVersionRange 1 1,
|
||||||
crSmpQueues = [queue],
|
crSmpQueues = [queue],
|
||||||
crClientData = Nothing
|
crClientData = Nothing
|
||||||
@ -191,7 +192,7 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do
|
|||||||
"{\"v\":\"1\",\"event\":\"x.msg.deleted\",\"params\":{}}"
|
"{\"v\":\"1\",\"event\":\"x.msg.deleted\",\"params\":{}}"
|
||||||
#==# XMsgDeleted
|
#==# XMsgDeleted
|
||||||
it "x.file" $
|
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%3D1-2%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%3D2%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}
|
#==# XFile FileInvitation {fileName = "photo.jpg", fileSize = 12345, fileDigest = Nothing, fileConnReq = Just testConnReq, fileInline = Nothing, fileDescr = Nothing}
|
||||||
it "x.file without file invitation" $
|
it "x.file without file invitation" $
|
||||||
"{\"v\":\"1\",\"event\":\"x.file\",\"params\":{\"file\":{\"fileSize\":12345,\"fileName\":\"photo.jpg\"}}}"
|
"{\"v\":\"1\",\"event\":\"x.file\",\"params\":{\"file\":{\"fileSize\":12345,\"fileName\":\"photo.jpg\"}}}"
|
||||||
@ -200,7 +201,7 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do
|
|||||||
"{\"v\":\"1\",\"event\":\"x.file.acpt\",\"params\":{\"fileName\":\"photo.jpg\"}}"
|
"{\"v\":\"1\",\"event\":\"x.file.acpt\",\"params\":{\"fileName\":\"photo.jpg\"}}"
|
||||||
#==# XFileAcpt "photo.jpg"
|
#==# XFileAcpt "photo.jpg"
|
||||||
it "x.file.acpt.inv" $
|
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%3D1-2%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%3D2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"}}"
|
||||||
#==# XFileAcptInv (SharedMsgId "\1\2\3\4") (Just testConnReq) "photo.jpg"
|
#==# XFileAcptInv (SharedMsgId "\1\2\3\4") (Just testConnReq) "photo.jpg"
|
||||||
it "x.file.acpt.inv" $
|
it "x.file.acpt.inv" $
|
||||||
"{\"v\":\"1\",\"event\":\"x.file.acpt.inv\",\"params\":{\"msgId\":\"AQIDBA==\",\"fileName\":\"photo.jpg\"}}"
|
"{\"v\":\"1\",\"event\":\"x.file.acpt.inv\",\"params\":{\"msgId\":\"AQIDBA==\",\"fileName\":\"photo.jpg\"}}"
|
||||||
@ -227,10 +228,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\"}}}}}"
|
"{\"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
|
==# XContact testProfile Nothing
|
||||||
it "x.grp.inv" $
|
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%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==\"}}}}"
|
"{\"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==\"}}}}"
|
||||||
#==# XGrpInv GroupInvitation {fromMember = MemberIdRole (MemberId "\1\2\3\4") GRAdmin, invitedMember = MemberIdRole (MemberId "\5\6\7\8") GRMember, connRequest = testConnReq, groupProfile = testGroupProfile, groupLinkId = Nothing}
|
#==# 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" $
|
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%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==\"}}}"
|
"{\"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==\"}}}"
|
||||||
#==# 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"}
|
#==# 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" $
|
it "x.grp.acpt without incognito profile" $
|
||||||
"{\"v\":\"1\",\"event\":\"x.grp.acpt\",\"params\":{\"memberId\":\"AQIDBA==\"}}"
|
"{\"v\":\"1\",\"event\":\"x.grp.acpt\",\"params\":{\"memberId\":\"AQIDBA==\"}}"
|
||||||
@ -251,16 +252,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\"}}}}}}"
|
"{\"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})
|
#==# XGrpMemIntro MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Nothing, profile = testProfile} (Just MemberRestrictions {restriction = MRSBlocked})
|
||||||
it "x.grp.mem.inv" $
|
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%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\"}}}"
|
"{\"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\"}}}"
|
||||||
#==# XGrpMemInv (MemberId "\1\2\3\4") IntroInvitation {groupConnReq = testConnReq, directConnReq = Just testConnReq}
|
#==# XGrpMemInv (MemberId "\1\2\3\4") IntroInvitation {groupConnReq = testConnReq, directConnReq = Just testConnReq}
|
||||||
it "x.grp.mem.inv w/t directConnReq" $
|
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%3D1-2%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%3D2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"}}}"
|
||||||
#==# XGrpMemInv (MemberId "\1\2\3\4") IntroInvitation {groupConnReq = testConnReq, directConnReq = Nothing}
|
#==# XGrpMemInv (MemberId "\1\2\3\4") IntroInvitation {groupConnReq = testConnReq, directConnReq = Nothing}
|
||||||
it "x.grp.mem.fwd" $
|
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%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\"}}}}}}"
|
"{\"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\"}}}}}}"
|
||||||
#==# XGrpMemFwd MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Nothing, profile = testProfile} IntroInvitation {groupConnReq = testConnReq, directConnReq = Just testConnReq}
|
#==# 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" $
|
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%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\"}}}}}}"
|
"{\"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\"}}}}}}"
|
||||||
#==# XGrpMemFwd MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Just $ ChatVersionRange supportedChatVRange, profile = testProfile} IntroInvitation {groupConnReq = testConnReq, directConnReq = Nothing}
|
#==# 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" $
|
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\"}}}}}"
|
"{\"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\"}}}}}"
|
||||||
@ -281,10 +282,10 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do
|
|||||||
"{\"v\":\"1\",\"event\":\"x.grp.del\",\"params\":{}}"
|
"{\"v\":\"1\",\"event\":\"x.grp.del\",\"params\":{}}"
|
||||||
==# XGrpDel
|
==# XGrpDel
|
||||||
it "x.grp.direct.inv" $
|
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%3D1-2%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%3D2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\", \"content\":{\"text\":\"hello\",\"type\":\"text\"}}}"
|
||||||
#==# XGrpDirectInv testConnReq (Just $ MCText "hello")
|
#==# XGrpDirectInv testConnReq (Just $ MCText "hello")
|
||||||
it "x.grp.direct.inv without content" $
|
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%3D1-2%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%3D2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"}}"
|
||||||
#==# XGrpDirectInv testConnReq Nothing
|
#==# XGrpDirectInv testConnReq Nothing
|
||||||
-- it "x.grp.msg.forward"
|
-- 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\"}}}"
|
-- $ "{\"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\"}}}"
|
||||||
|
Loading…
Reference in New Issue
Block a user