Compare commits
25 Commits
stable
...
f/fix-user
Author | SHA1 | Date | |
---|---|---|---|
|
1603309e60 | ||
|
203d793cf0 | ||
|
acf6519e23 | ||
|
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
|
||||
RUN apt-get update && apt-get install -y curl git build-essential libgmp3-dev zlib1g-dev libssl-dev
|
||||
FROM ubuntu:${TAG} AS build
|
||||
|
||||
### Build stage
|
||||
|
||||
# Install curl and git and simplex-chat dependencies
|
||||
RUN apt-get update && apt-get install -y curl git build-essential libgmp3-dev zlib1g-dev llvm-12 llvm-12-dev libnuma-dev libssl-dev
|
||||
|
||||
# Specify bootstrap Haskell versions
|
||||
ENV BOOTSTRAP_HASKELL_GHC_VERSION=9.6.3
|
||||
ENV BOOTSTRAP_HASKELL_CABAL_VERSION=3.10.1.0
|
||||
|
||||
# Install ghcup
|
||||
RUN a=$(arch); curl https://downloads.haskell.org/~ghcup/$a-linux-ghcup -o /usr/bin/ghcup && \
|
||||
chmod +x /usr/bin/ghcup
|
||||
|
||||
# Install ghc
|
||||
RUN ghcup install ghc 9.6.3
|
||||
# Install cabal
|
||||
RUN ghcup install cabal 3.10.1.0
|
||||
# Set both as default
|
||||
RUN ghcup set ghc 9.6.3 && \
|
||||
ghcup set cabal 3.10.1.0
|
||||
|
||||
COPY . /project
|
||||
WORKDIR /project
|
||||
RUN curl --proto '=https' --tlsv1.2 -sSf https://get-ghcup.haskell.org | BOOTSTRAP_HASKELL_NONINTERACTIVE=1 sh
|
||||
|
||||
# Adjust PATH
|
||||
ENV PATH="/root/.cabal/bin:/root/.ghcup/bin:$PATH"
|
||||
|
||||
# Set both as default
|
||||
RUN ghcup set ghc "${BOOTSTRAP_HASKELL_GHC_VERSION}" && \
|
||||
ghcup set cabal "${BOOTSTRAP_HASKELL_CABAL_VERSION}"
|
||||
|
||||
COPY . /project
|
||||
WORKDIR /project
|
||||
|
||||
# Adjust build
|
||||
RUN cp ./scripts/cabal.project.local.linux ./cabal.project.local
|
||||
|
||||
# Compile simplex-chat
|
||||
RUN cabal update
|
||||
RUN cabal install
|
||||
RUN cabal build exe:simplex-chat
|
||||
|
||||
# Strip the binary from debug symbols to reduce size
|
||||
RUN bin=$(find /project/dist-newstyle -name "simplex-chat" -type f -executable) && \
|
||||
mv "$bin" ./ && \
|
||||
strip ./simplex-chat
|
||||
|
||||
# Copy compiled app from build stage
|
||||
FROM scratch AS export-stage
|
||||
COPY --from=build /root/.cabal/bin/simplex-chat /
|
||||
COPY --from=build /project/simplex-chat /
|
||||
|
@ -34,6 +34,8 @@ struct ContentView: View {
|
||||
@State private var waitingForOrPassedAuth = true
|
||||
@State private var chatListActionSheet: ChatListActionSheet? = nil
|
||||
|
||||
private let callTopPadding: CGFloat = 50
|
||||
|
||||
private enum ChatListActionSheet: Identifiable {
|
||||
case planAndConnectSheet(sheet: PlanAndConnectActionSheet)
|
||||
|
||||
@ -50,16 +52,28 @@ struct ContentView: View {
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
let showCallArea = chatModel.activeCall != nil && chatModel.activeCall?.callState != .waitCapabilities && chatModel.activeCall?.callState != .invitationAccepted
|
||||
// contentView() has to be in a single branch, so that enabling authentication doesn't trigger re-rendering and close settings.
|
||||
// i.e. with separate branches like this settings are closed: `if prefPerformLA { ... contentView() ... } else { contentView() }
|
||||
if !prefPerformLA || accessAuthenticated {
|
||||
contentView()
|
||||
.padding(.top, showCallArea ? callTopPadding : 0)
|
||||
} else {
|
||||
lockButton()
|
||||
.padding(.top, showCallArea ? callTopPadding : 0)
|
||||
}
|
||||
|
||||
if showCallArea, let call = chatModel.activeCall {
|
||||
VStack {
|
||||
activeCallInteractiveArea(call)
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
|
||||
if chatModel.showCallView, let call = chatModel.activeCall {
|
||||
callView(call)
|
||||
}
|
||||
|
||||
if !showSettings, let la = chatModel.laRequest {
|
||||
LocalAuthView(authRequest: la)
|
||||
.onDisappear {
|
||||
@ -135,11 +149,11 @@ struct ContentView: View {
|
||||
if case .onboardingComplete = step,
|
||||
chatModel.currentUser != nil {
|
||||
mainView()
|
||||
.actionSheet(item: $chatListActionSheet) { sheet in
|
||||
switch sheet {
|
||||
case let .planAndConnectSheet(sheet): return planAndConnectActionSheet(sheet, dismiss: false)
|
||||
.actionSheet(item: $chatListActionSheet) { sheet in
|
||||
switch sheet {
|
||||
case let .planAndConnectSheet(sheet): return planAndConnectActionSheet(sheet, dismiss: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
OnboardingView(onboarding: step)
|
||||
}
|
||||
@ -163,6 +177,40 @@ struct ContentView: View {
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func activeCallInteractiveArea(_ call: Call) -> some View {
|
||||
HStack {
|
||||
Text(call.contact.displayName).font(.body).foregroundColor(.white)
|
||||
Spacer()
|
||||
CallDuration(call: call)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.frame(height: callTopPadding - 10)
|
||||
.background(Color(uiColor: UIColor(red: 47/255, green: 208/255, blue: 88/255, alpha: 1)))
|
||||
.onTapGesture {
|
||||
chatModel.activeCallViewIsCollapsed = false
|
||||
}
|
||||
}
|
||||
|
||||
struct CallDuration: View {
|
||||
let call: Call
|
||||
@State var text: String = ""
|
||||
@State var timer: Timer? = nil
|
||||
|
||||
var body: some View {
|
||||
Text(text).frame(minWidth: text.count <= 5 ? 52 : 77, alignment: .leading).offset(x: 4).font(.body).foregroundColor(.white)
|
||||
.onAppear {
|
||||
timer = Timer.scheduledTimer(withTimeInterval: 0.3, repeats: true) { timer in
|
||||
if let connectedAt = call.connectedAt {
|
||||
text = durationText(Int(Date.now.timeIntervalSince1970 - connectedAt.timeIntervalSince1970))
|
||||
}
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
_ = timer?.invalidate()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func lockButton() -> some View {
|
||||
Button(action: authenticateContentViewAccess) { Label("Unlock", systemImage: "lock") }
|
||||
}
|
||||
|
@ -80,6 +80,7 @@ final class ChatModel: ObservableObject {
|
||||
@Published var tokenRegistered = false
|
||||
@Published var tokenStatus: NtfTknStatus?
|
||||
@Published var notificationMode = NotificationsMode.off
|
||||
@Published var notificationServer: String?
|
||||
@Published var notificationPreview: NotificationPreviewMode = ntfPreviewModeGroupDefault.get()
|
||||
// pending notification actions
|
||||
@Published var ntfContactRequest: NTFContactRequest?
|
||||
@ -89,6 +90,7 @@ final class ChatModel: ObservableObject {
|
||||
@Published var activeCall: Call?
|
||||
let callCommand: WebRTCCommandProcessor = WebRTCCommandProcessor()
|
||||
@Published var showCallView = false
|
||||
@Published var activeCallViewIsCollapsed = false
|
||||
// remote desktop
|
||||
@Published var remoteCtrlSession: RemoteCtrlSession?
|
||||
// currently showing invitation
|
||||
|
@ -412,14 +412,14 @@ func apiDeleteMemberChatItem(groupId: Int64, groupMemberId: Int64, itemId: Int64
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiGetNtfToken() -> (DeviceToken?, NtfTknStatus?, NotificationsMode) {
|
||||
func apiGetNtfToken() -> (DeviceToken?, NtfTknStatus?, NotificationsMode, String?) {
|
||||
let r = chatSendCmdSync(.apiGetNtfToken)
|
||||
switch r {
|
||||
case let .ntfToken(token, status, ntfMode): return (token, status, ntfMode)
|
||||
case .chatCmdError(_, .errorAgent(.CMD(.PROHIBITED))): return (nil, nil, .off)
|
||||
case let .ntfToken(token, status, ntfMode, ntfServer): return (token, status, ntfMode, ntfServer)
|
||||
case .chatCmdError(_, .errorAgent(.CMD(.PROHIBITED))): return (nil, nil, .off, nil)
|
||||
default:
|
||||
logger.debug("apiGetNtfToken response: \(String(describing: r))")
|
||||
return (nil, nil, .off)
|
||||
return (nil, nil, .off, nil)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1309,7 +1309,7 @@ func startChat(refreshInvitations: Bool = true) throws {
|
||||
if (refreshInvitations) {
|
||||
try refreshCallInvitations()
|
||||
}
|
||||
(m.savedToken, m.tokenStatus, m.notificationMode) = apiGetNtfToken()
|
||||
(m.savedToken, m.tokenStatus, m.notificationMode, m.notificationServer) = apiGetNtfToken()
|
||||
// deviceToken is set when AppDelegate.application(didRegisterForRemoteNotificationsWithDeviceToken:) is called,
|
||||
// when it is called before startChat
|
||||
if let token = m.deviceToken {
|
||||
|
@ -12,49 +12,67 @@ import SimpleXChat
|
||||
|
||||
struct ActiveCallView: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
@ObservedObject var call: Call
|
||||
@Environment(\.scenePhase) var scenePhase
|
||||
@State private var client: WebRTCClient? = nil
|
||||
@State private var activeCall: WebRTCClient.Call? = nil
|
||||
@State private var localRendererAspectRatio: CGFloat? = nil
|
||||
@Binding var canConnectCall: Bool
|
||||
@State var prevColorScheme: ColorScheme = .dark
|
||||
@State var pipShown = false
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .bottom) {
|
||||
if let client = client, [call.peerMedia, call.localMedia].contains(.video), activeCall != nil {
|
||||
GeometryReader { g in
|
||||
let width = g.size.width * 0.3
|
||||
ZStack(alignment: .topTrailing) {
|
||||
CallViewRemote(client: client, activeCall: $activeCall)
|
||||
CallViewLocal(client: client, activeCall: $activeCall, localRendererAspectRatio: $localRendererAspectRatio)
|
||||
.cornerRadius(10)
|
||||
.frame(width: width, height: width / (localRendererAspectRatio ?? 1))
|
||||
.padding([.top, .trailing], 17)
|
||||
ZStack(alignment: .topLeading) {
|
||||
ZStack(alignment: .bottom) {
|
||||
if let client = client, [call.peerMedia, call.localMedia].contains(.video), activeCall != nil {
|
||||
GeometryReader { g in
|
||||
let width = g.size.width * 0.3
|
||||
ZStack(alignment: .topTrailing) {
|
||||
CallViewRemote(client: client, activeCall: $activeCall, activeCallViewIsCollapsed: $m.activeCallViewIsCollapsed, pipShown: $pipShown)
|
||||
CallViewLocal(client: client, activeCall: $activeCall, localRendererAspectRatio: $localRendererAspectRatio, pipShown: $pipShown)
|
||||
.cornerRadius(10)
|
||||
.frame(width: width, height: width / (localRendererAspectRatio ?? 1))
|
||||
.padding([.top, .trailing], 17)
|
||||
ZStack(alignment: .center) {
|
||||
// For some reason, when the view in GeometryReader and ZStack is visible, it steals clicks on a back button, so showing something on top like this with background color helps (.clear color doesn't work)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Color.primary.opacity(0.000001))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let call = m.activeCall, let client = client {
|
||||
ActiveCallOverlay(call: call, client: client)
|
||||
if let call = m.activeCall, let client = client, (!pipShown || !call.supportsVideo) {
|
||||
ActiveCallOverlay(call: call, client: client)
|
||||
}
|
||||
}
|
||||
}
|
||||
.allowsHitTesting(!m.activeCallViewIsCollapsed)
|
||||
.opacity(m.activeCallViewIsCollapsed ? 0 : 1)
|
||||
.onAppear {
|
||||
logger.debug("ActiveCallView: appear client is nil \(client == nil), scenePhase \(String(describing: scenePhase)), canConnectCall \(canConnectCall)")
|
||||
AppDelegate.keepScreenOn(true)
|
||||
createWebRTCClient()
|
||||
dismissAllSheets()
|
||||
hideKeyboard()
|
||||
prevColorScheme = colorScheme
|
||||
}
|
||||
.onChange(of: canConnectCall) { _ in
|
||||
logger.debug("ActiveCallView: canConnectCall changed to \(canConnectCall)")
|
||||
createWebRTCClient()
|
||||
}
|
||||
.onChange(of: m.activeCallViewIsCollapsed) { _ in
|
||||
hideKeyboard()
|
||||
}
|
||||
.onDisappear {
|
||||
logger.debug("ActiveCallView: disappear")
|
||||
Task { await m.callCommand.setClient(nil) }
|
||||
AppDelegate.keepScreenOn(false)
|
||||
client?.endCall()
|
||||
}
|
||||
.background(.black)
|
||||
.preferredColorScheme(.dark)
|
||||
.background(m.activeCallViewIsCollapsed ? .clear : .black)
|
||||
// Quite a big delay when opening/closing the view when a scheme changes (globally) this way. It's not needed when CallKit is used since status bar is green with white text on it
|
||||
.preferredColorScheme(m.activeCallViewIsCollapsed || CallController.useCallKit() ? prevColorScheme : .dark)
|
||||
}
|
||||
|
||||
private func createWebRTCClient() {
|
||||
@ -69,8 +87,8 @@ struct ActiveCallView: View {
|
||||
@MainActor
|
||||
private func processRtcMessage(msg: WVAPIMessage) {
|
||||
if call == m.activeCall,
|
||||
let call = m.activeCall,
|
||||
let client = client {
|
||||
let call = m.activeCall,
|
||||
let client = client {
|
||||
logger.debug("ActiveCallView: response \(msg.resp.respType)")
|
||||
switch msg.resp {
|
||||
case let .capabilities(capabilities):
|
||||
@ -90,7 +108,7 @@ struct ActiveCallView: View {
|
||||
Task {
|
||||
do {
|
||||
try await apiSendCallOffer(call.contact, offer, iceCandidates,
|
||||
media: call.localMedia, capabilities: capabilities)
|
||||
media: call.localMedia, capabilities: capabilities)
|
||||
} catch {
|
||||
logger.error("apiSendCallOffer \(responseError(error))")
|
||||
}
|
||||
@ -122,13 +140,15 @@ struct ActiveCallView: View {
|
||||
if let callStatus = WebRTCCallStatus.init(rawValue: state.connectionState),
|
||||
case .connected = callStatus {
|
||||
call.direction == .outgoing
|
||||
? CallController.shared.reportOutgoingCall(call: call, connectedAt: nil)
|
||||
: CallController.shared.reportIncomingCall(call: call, connectedAt: nil)
|
||||
? CallController.shared.reportOutgoingCall(call: call, connectedAt: nil)
|
||||
: CallController.shared.reportIncomingCall(call: call, connectedAt: nil)
|
||||
call.callState = .connected
|
||||
call.connectedAt = .now
|
||||
}
|
||||
if state.connectionState == "closed" {
|
||||
closeCallView(client)
|
||||
m.activeCall = nil
|
||||
m.activeCallViewIsCollapsed = false
|
||||
}
|
||||
Task {
|
||||
do {
|
||||
@ -140,6 +160,7 @@ struct ActiveCallView: View {
|
||||
case let .connected(connectionInfo):
|
||||
call.callState = .connected
|
||||
call.connectionInfo = connectionInfo
|
||||
call.connectedAt = .now
|
||||
case .ended:
|
||||
closeCallView(client)
|
||||
call.callState = .ended
|
||||
@ -153,6 +174,7 @@ struct ActiveCallView: View {
|
||||
case .end:
|
||||
closeCallView(client)
|
||||
m.activeCall = nil
|
||||
m.activeCallViewIsCollapsed = false
|
||||
default: ()
|
||||
}
|
||||
case let .error(message):
|
||||
@ -181,7 +203,7 @@ struct ActiveCallOverlay: View {
|
||||
VStack {
|
||||
switch call.localMedia {
|
||||
case .video:
|
||||
callInfoView(call, .leading)
|
||||
videoCallInfoView(call)
|
||||
.foregroundColor(.white)
|
||||
.opacity(0.8)
|
||||
.padding()
|
||||
@ -208,16 +230,25 @@ struct ActiveCallOverlay: View {
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
|
||||
case .audio:
|
||||
VStack {
|
||||
ProfileImage(imageStr: call.contact.profile.image)
|
||||
.scaledToFit()
|
||||
.frame(width: 192, height: 192)
|
||||
callInfoView(call, .center)
|
||||
ZStack(alignment: .topLeading) {
|
||||
Button {
|
||||
chatModel.activeCallViewIsCollapsed = true
|
||||
} label: {
|
||||
Label("Back", systemImage: "chevron.left")
|
||||
.padding()
|
||||
.foregroundColor(.white.opacity(0.8))
|
||||
}
|
||||
VStack {
|
||||
ProfileImage(imageStr: call.contact.profile.image)
|
||||
.scaledToFit()
|
||||
.frame(width: 192, height: 192)
|
||||
audioCallInfoView(call)
|
||||
}
|
||||
.foregroundColor(.white)
|
||||
.opacity(0.8)
|
||||
.padding()
|
||||
.frame(maxHeight: .infinity)
|
||||
}
|
||||
.foregroundColor(.white)
|
||||
.opacity(0.8)
|
||||
.padding()
|
||||
.frame(maxHeight: .infinity)
|
||||
|
||||
Spacer()
|
||||
|
||||
@ -235,12 +266,12 @@ struct ActiveCallOverlay: View {
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
|
||||
private func callInfoView(_ call: Call, _ alignment: Alignment) -> some View {
|
||||
private func audioCallInfoView(_ call: Call) -> some View {
|
||||
VStack {
|
||||
Text(call.contact.chatViewName)
|
||||
.lineLimit(1)
|
||||
.font(.title)
|
||||
.frame(maxWidth: .infinity, alignment: alignment)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
Group {
|
||||
Text(call.callState.text)
|
||||
HStack {
|
||||
@ -251,7 +282,36 @@ struct ActiveCallOverlay: View {
|
||||
}
|
||||
}
|
||||
.font(.subheadline)
|
||||
.frame(maxWidth: .infinity, alignment: alignment)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
}
|
||||
}
|
||||
|
||||
private func videoCallInfoView(_ call: Call) -> some View {
|
||||
VStack {
|
||||
Button {
|
||||
chatModel.activeCallViewIsCollapsed = true
|
||||
} label: {
|
||||
HStack(alignment: .center, spacing: 16) {
|
||||
Image(systemName: "chevron.left")
|
||||
.resizable()
|
||||
.frame(width: 10, height: 18)
|
||||
Text(call.contact.chatViewName)
|
||||
.lineLimit(1)
|
||||
.font(.title)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
Group {
|
||||
Text(call.callState.text)
|
||||
HStack {
|
||||
Text(call.encryptionStatus)
|
||||
if let connInfo = call.connectionInfo {
|
||||
Text("(") + Text(connInfo.text) + Text(")")
|
||||
}
|
||||
}
|
||||
}
|
||||
.font(.subheadline)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -92,6 +92,7 @@ class CallManager {
|
||||
if case .ended = call.callState {
|
||||
logger.debug("CallManager.endCall: call ended")
|
||||
m.activeCall = nil
|
||||
m.activeCallViewIsCollapsed = false
|
||||
m.showCallView = false
|
||||
completed()
|
||||
} else {
|
||||
@ -100,6 +101,7 @@ class CallManager {
|
||||
await m.callCommand.processCommand(.end)
|
||||
await MainActor.run {
|
||||
m.activeCall = nil
|
||||
m.activeCallViewIsCollapsed = false
|
||||
m.showCallView = false
|
||||
completed()
|
||||
}
|
||||
|
@ -6,14 +6,20 @@
|
||||
import SwiftUI
|
||||
import WebRTC
|
||||
import SimpleXChat
|
||||
import AVKit
|
||||
|
||||
struct CallViewRemote: UIViewRepresentable {
|
||||
var client: WebRTCClient
|
||||
var activeCall: Binding<WebRTCClient.Call?>
|
||||
@State var enablePip: (Bool) -> Void = {_ in }
|
||||
@Binding var activeCallViewIsCollapsed: Bool
|
||||
@Binding var pipShown: Bool
|
||||
|
||||
init(client: WebRTCClient, activeCall: Binding<WebRTCClient.Call?>) {
|
||||
init(client: WebRTCClient, activeCall: Binding<WebRTCClient.Call?>, activeCallViewIsCollapsed: Binding<Bool>, pipShown: Binding<Bool>) {
|
||||
self.client = client
|
||||
self.activeCall = activeCall
|
||||
self._activeCallViewIsCollapsed = activeCallViewIsCollapsed
|
||||
self._pipShown = pipShown
|
||||
}
|
||||
|
||||
func makeUIView(context: Context) -> UIView {
|
||||
@ -23,12 +29,120 @@ struct CallViewRemote: UIViewRepresentable {
|
||||
remoteRenderer.videoContentMode = .scaleAspectFill
|
||||
client.addRemoteRenderer(call, remoteRenderer)
|
||||
addSubviewAndResize(remoteRenderer, into: view)
|
||||
|
||||
if AVPictureInPictureController.isPictureInPictureSupported() {
|
||||
makeViewWithRTCRenderer(call, remoteRenderer, view, context)
|
||||
}
|
||||
}
|
||||
return view
|
||||
}
|
||||
|
||||
func makeViewWithRTCRenderer(_ call: WebRTCClient.Call, _ remoteRenderer: RTCMTLVideoView, _ view: UIView, _ context: Context) {
|
||||
let pipRemoteRenderer = RTCMTLVideoView(frame: view.frame)
|
||||
pipRemoteRenderer.videoContentMode = .scaleAspectFill
|
||||
|
||||
let pipVideoCallViewController = AVPictureInPictureVideoCallViewController()
|
||||
pipVideoCallViewController.preferredContentSize = CGSize(width: 1080, height: 1920)
|
||||
addSubviewAndResize(pipRemoteRenderer, into: pipVideoCallViewController.view)
|
||||
let pipContentSource = AVPictureInPictureController.ContentSource(
|
||||
activeVideoCallSourceView: view,
|
||||
contentViewController: pipVideoCallViewController
|
||||
)
|
||||
|
||||
let pipController = AVPictureInPictureController(contentSource: pipContentSource)
|
||||
pipController.canStartPictureInPictureAutomaticallyFromInline = true
|
||||
pipController.delegate = context.coordinator
|
||||
context.coordinator.pipController = pipController
|
||||
context.coordinator.willShowHide = { show in
|
||||
if show {
|
||||
client.addRemoteRenderer(call, pipRemoteRenderer)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
||||
activeCallViewIsCollapsed = true
|
||||
}
|
||||
} else {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
|
||||
activeCallViewIsCollapsed = false
|
||||
}
|
||||
}
|
||||
}
|
||||
context.coordinator.didShowHide = { show in
|
||||
if show {
|
||||
remoteRenderer.isHidden = true
|
||||
} else {
|
||||
client.removeRemoteRenderer(call, pipRemoteRenderer)
|
||||
remoteRenderer.isHidden = false
|
||||
}
|
||||
pipShown = show
|
||||
}
|
||||
DispatchQueue.main.async {
|
||||
enablePip = { enable in
|
||||
if enable != pipShown /* pipController.isPictureInPictureActive */ {
|
||||
if enable {
|
||||
pipController.startPictureInPicture()
|
||||
} else {
|
||||
pipController.stopPictureInPicture()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator()
|
||||
}
|
||||
|
||||
func updateUIView(_ view: UIView, context: Context) {
|
||||
logger.debug("CallView.updateUIView remote")
|
||||
DispatchQueue.main.async {
|
||||
if activeCallViewIsCollapsed != pipShown {
|
||||
enablePip(activeCallViewIsCollapsed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Coordinator
|
||||
class Coordinator: NSObject, AVPictureInPictureControllerDelegate {
|
||||
var pipController: AVPictureInPictureController? = nil
|
||||
var willShowHide: (Bool) -> Void = { _ in }
|
||||
var didShowHide: (Bool) -> Void = { _ in }
|
||||
|
||||
func pictureInPictureControllerWillStartPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) {
|
||||
willShowHide(true)
|
||||
}
|
||||
|
||||
func pictureInPictureControllerDidStartPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) {
|
||||
didShowHide(true)
|
||||
}
|
||||
|
||||
func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, failedToStartPictureInPictureWithError error: Error) {
|
||||
logger.error("PiP failed to start: \(error.localizedDescription)")
|
||||
}
|
||||
|
||||
func pictureInPictureControllerWillStopPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) {
|
||||
willShowHide(false)
|
||||
}
|
||||
|
||||
func pictureInPictureControllerDidStopPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) {
|
||||
didShowHide(false)
|
||||
}
|
||||
|
||||
deinit {
|
||||
pipController?.stopPictureInPicture()
|
||||
pipController?.canStartPictureInPictureAutomaticallyFromInline = false
|
||||
pipController?.contentSource = nil
|
||||
pipController?.delegate = nil
|
||||
pipController = nil
|
||||
}
|
||||
}
|
||||
|
||||
class SampleBufferVideoCallView: UIView {
|
||||
override class var layerClass: AnyClass {
|
||||
get { return AVSampleBufferDisplayLayer.self }
|
||||
}
|
||||
|
||||
var sampleBufferDisplayLayer: AVSampleBufferDisplayLayer {
|
||||
return layer as! AVSampleBufferDisplayLayer
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -36,11 +150,14 @@ struct CallViewLocal: UIViewRepresentable {
|
||||
var client: WebRTCClient
|
||||
var activeCall: Binding<WebRTCClient.Call?>
|
||||
var localRendererAspectRatio: Binding<CGFloat?>
|
||||
@State var pipStateChanged: (Bool) -> Void = {_ in }
|
||||
@Binding var pipShown: Bool
|
||||
|
||||
init(client: WebRTCClient, activeCall: Binding<WebRTCClient.Call?>, localRendererAspectRatio: Binding<CGFloat?>) {
|
||||
init(client: WebRTCClient, activeCall: Binding<WebRTCClient.Call?>, localRendererAspectRatio: Binding<CGFloat?>, pipShown: Binding<Bool>) {
|
||||
self.client = client
|
||||
self.activeCall = activeCall
|
||||
self.localRendererAspectRatio = localRendererAspectRatio
|
||||
self._pipShown = pipShown
|
||||
}
|
||||
|
||||
func makeUIView(context: Context) -> UIView {
|
||||
@ -50,12 +167,18 @@ struct CallViewLocal: UIViewRepresentable {
|
||||
client.addLocalRenderer(call, localRenderer)
|
||||
client.startCaptureLocalVideo(call)
|
||||
addSubviewAndResize(localRenderer, into: view)
|
||||
DispatchQueue.main.async {
|
||||
pipStateChanged = { shown in
|
||||
localRenderer.isHidden = shown
|
||||
}
|
||||
}
|
||||
}
|
||||
return view
|
||||
}
|
||||
|
||||
func updateUIView(_ view: UIView, context: Context) {
|
||||
logger.debug("CallView.updateUIView local")
|
||||
pipStateChanged(pipShown)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -28,6 +28,7 @@ class Call: ObservableObject, Equatable {
|
||||
@Published var speakerEnabled = false
|
||||
@Published var videoEnabled: Bool
|
||||
@Published var connectionInfo: ConnectionInfo?
|
||||
@Published var connectedAt: Date? = nil
|
||||
|
||||
init(
|
||||
direction: CallDirection,
|
||||
@ -59,6 +60,7 @@ class Call: ObservableObject, Equatable {
|
||||
}
|
||||
}
|
||||
var hasMedia: Bool { get { callState == .offerSent || callState == .negotiated || callState == .connected } }
|
||||
var supportsVideo: Bool { get { peerMedia == .video || localMedia == .video } }
|
||||
}
|
||||
|
||||
enum CallDirection {
|
||||
|
@ -331,6 +331,10 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
|
||||
activeCall.remoteStream?.add(renderer)
|
||||
}
|
||||
|
||||
func removeRemoteRenderer(_ activeCall: Call, _ renderer: RTCVideoRenderer) {
|
||||
activeCall.remoteStream?.remove(renderer)
|
||||
}
|
||||
|
||||
func startCaptureLocalVideo(_ activeCall: Call) {
|
||||
#if targetEnvironment(simulator)
|
||||
guard
|
||||
@ -410,6 +414,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
|
||||
guard let call = activeCall.wrappedValue else { return }
|
||||
logger.debug("WebRTCClient: ending the call")
|
||||
activeCall.wrappedValue = nil
|
||||
(call.localCamera as? RTCCameraVideoCapturer)?.stopCapture()
|
||||
call.connection.close()
|
||||
call.connection.delegate = nil
|
||||
call.frameEncryptor?.delegate = nil
|
||||
|
@ -29,6 +29,9 @@ struct CIImageView: View {
|
||||
FullScreenMediaView(chatItem: chatItem, image: uiImage, showView: $showFullScreenImage, scrollProxy: scrollProxy)
|
||||
}
|
||||
.onTapGesture { showFullScreenImage = true }
|
||||
.onChange(of: m.activeCallViewIsCollapsed) { _ in
|
||||
showFullScreenImage = false
|
||||
}
|
||||
} else if let data = Data(base64Encoded: dropImagePrefix(image)),
|
||||
let uiImage = UIImage(data: data) {
|
||||
imageView(uiImage)
|
||||
|
@ -120,6 +120,9 @@ struct CIVideoView: View {
|
||||
showFullScreenPlayer = urlDecrypted != nil
|
||||
}
|
||||
}
|
||||
.onChange(of: m.activeCallViewIsCollapsed) { _ in
|
||||
showFullScreenPlayer = false
|
||||
}
|
||||
if !decryptionInProgress {
|
||||
Button {
|
||||
decrypt(file: file) {
|
||||
@ -168,6 +171,9 @@ struct CIVideoView: View {
|
||||
default: ()
|
||||
}
|
||||
}
|
||||
.onChange(of: m.activeCallViewIsCollapsed) { _ in
|
||||
showFullScreenPlayer = false
|
||||
}
|
||||
if !videoPlaying {
|
||||
Button {
|
||||
m.stopPreviousRecPlay = url
|
||||
|
@ -161,11 +161,15 @@ struct ChatView: View {
|
||||
HStack {
|
||||
let callsPrefEnabled = contact.mergedPreferences.calls.enabled.forUser
|
||||
if callsPrefEnabled {
|
||||
callButton(contact, .audio, imageName: "phone")
|
||||
.disabled(!contact.ready || !contact.active)
|
||||
if chatModel.activeCall == nil {
|
||||
callButton(contact, .audio, imageName: "phone")
|
||||
.disabled(!contact.ready || !contact.active)
|
||||
} else if let call = chatModel.activeCall, call.contact.id == cInfo.id {
|
||||
endCallButton(call)
|
||||
}
|
||||
}
|
||||
Menu {
|
||||
if callsPrefEnabled {
|
||||
if callsPrefEnabled && chatModel.activeCall == nil {
|
||||
Button {
|
||||
CallController.shared.startCall(contact, .video)
|
||||
} label: {
|
||||
@ -422,7 +426,19 @@ struct ChatView: View {
|
||||
Image(systemName: imageName)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func endCallButton(_ call: Call) -> some View {
|
||||
Button {
|
||||
if let uuid = call.callkitUUID {
|
||||
CallController.shared.endCall(callUUID: uuid)
|
||||
} else {
|
||||
CallController.shared.endCall(call: call) {}
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "phone.down.fill").tint(.red)
|
||||
}
|
||||
}
|
||||
|
||||
private func searchButton() -> some View {
|
||||
Button {
|
||||
searchMode = true
|
||||
|
@ -234,39 +234,29 @@ struct GroupChatInfoView: View {
|
||||
Spacer()
|
||||
memberInfo(member)
|
||||
}
|
||||
|
||||
// revert from this:
|
||||
|
||||
if user {
|
||||
v
|
||||
} else if member.canBeRemoved(groupInfo: groupInfo) {
|
||||
removeSwipe(member, blockSwipe(member, v))
|
||||
} else if groupInfo.membership.memberRole >= .admin {
|
||||
// TODO if there are more actions, refactor with lists of swipeActions
|
||||
let canBlockForAll = member.canBlockForAll(groupInfo: groupInfo)
|
||||
let canRemove = member.canBeRemoved(groupInfo: groupInfo)
|
||||
if canBlockForAll && canRemove {
|
||||
removeSwipe(member, blockForAllSwipe(member, v))
|
||||
} else if canBlockForAll {
|
||||
blockForAllSwipe(member, v)
|
||||
} else if canRemove {
|
||||
removeSwipe(member, v)
|
||||
} else {
|
||||
v
|
||||
}
|
||||
} else {
|
||||
blockSwipe(member, v)
|
||||
if !member.blockedByAdmin {
|
||||
blockSwipe(member, v)
|
||||
} else {
|
||||
v
|
||||
}
|
||||
}
|
||||
// revert to this: vvv
|
||||
// if user {
|
||||
// v
|
||||
// } else if groupInfo.membership.memberRole >= .admin {
|
||||
// // TODO if there are more actions, refactor with lists of swipeActions
|
||||
// let canBlockForAll = member.canBlockForAll(groupInfo: groupInfo)
|
||||
// let canRemove = member.canBeRemoved(groupInfo: groupInfo)
|
||||
// if canBlockForAll && canRemove {
|
||||
// removeSwipe(member, blockForAllSwipe(member, v))
|
||||
// } else if canBlockForAll {
|
||||
// blockForAllSwipe(member, v)
|
||||
// } else if canRemove {
|
||||
// removeSwipe(member, v)
|
||||
// } else {
|
||||
// v
|
||||
// }
|
||||
// } else {
|
||||
// if !member.blockedByAdmin {
|
||||
// blockSwipe(member, v)
|
||||
// } else {
|
||||
// v
|
||||
// }
|
||||
// }
|
||||
// ^^^
|
||||
}
|
||||
|
||||
@ViewBuilder private func memberInfo(_ member: GroupMember) -> some View {
|
||||
|
@ -168,24 +168,11 @@ struct GroupMemberInfoView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// revert from this:
|
||||
Section {
|
||||
if member.memberSettings.showMessages {
|
||||
blockMemberButton(member)
|
||||
} else {
|
||||
unblockMemberButton(member)
|
||||
}
|
||||
if member.canBeRemoved(groupInfo: groupInfo) {
|
||||
removeMemberButton(member)
|
||||
}
|
||||
if groupInfo.membership.memberRole >= .admin {
|
||||
adminDestructiveSection(member)
|
||||
} else {
|
||||
nonAdminBlockSection(member)
|
||||
}
|
||||
// revert to this: vvv
|
||||
// if groupInfo.membership.memberRole >= .admin {
|
||||
// adminDestructiveSection(member)
|
||||
// } else {
|
||||
// nonAdminBlockSection(member)
|
||||
// }
|
||||
// ^^^
|
||||
|
||||
if developerTools {
|
||||
Section("For console") {
|
||||
|
@ -76,6 +76,10 @@ struct NotificationsView: View {
|
||||
Text(m.notificationPreview.label)
|
||||
}
|
||||
}
|
||||
|
||||
if let server = m.notificationServer {
|
||||
smpServers("Push server", [server])
|
||||
}
|
||||
} header: {
|
||||
Text("Push notifications")
|
||||
} footer: {
|
||||
@ -87,6 +91,9 @@ struct NotificationsView: View {
|
||||
}
|
||||
}
|
||||
.disabled(legacyDatabase)
|
||||
.onAppear {
|
||||
(m.savedToken, m.tokenStatus, m.notificationMode, m.notificationServer) = apiGetNtfToken()
|
||||
}
|
||||
}
|
||||
|
||||
private func notificationAlert(_ alert: NotificationAlert, _ token: DeviceToken) -> Alert {
|
||||
@ -125,6 +132,7 @@ struct NotificationsView: View {
|
||||
m.tokenStatus = .new
|
||||
notificationMode = .off
|
||||
m.notificationMode = .off
|
||||
m.notificationServer = nil
|
||||
}
|
||||
} catch let error {
|
||||
await MainActor.run {
|
||||
@ -135,11 +143,13 @@ struct NotificationsView: View {
|
||||
}
|
||||
default:
|
||||
do {
|
||||
let status = try await apiRegisterToken(token: token, notificationMode: mode)
|
||||
let _ = try await apiRegisterToken(token: token, notificationMode: mode)
|
||||
let (_, tknStatus, ntfMode, ntfServer) = apiGetNtfToken()
|
||||
await MainActor.run {
|
||||
m.tokenStatus = status
|
||||
notificationMode = mode
|
||||
m.notificationMode = mode
|
||||
m.tokenStatus = tknStatus
|
||||
notificationMode = ntfMode
|
||||
m.notificationMode = ntfMode
|
||||
m.notificationServer = ntfServer
|
||||
}
|
||||
} catch let error {
|
||||
await MainActor.run {
|
||||
|
@ -29,11 +29,6 @@
|
||||
5C116CDC27AABE0400E66D01 /* ContactRequestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C116CDB27AABE0400E66D01 /* ContactRequestView.swift */; };
|
||||
5C13730B28156D2700F43030 /* ContactConnectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C13730A28156D2700F43030 /* ContactConnectionView.swift */; };
|
||||
5C1A4C1E27A715B700EAD5AD /* ChatItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C1A4C1D27A715B700EAD5AD /* ChatItemView.swift */; };
|
||||
5C29C3AF2B783F82003DF84C /* libHSsimplex-chat-5.5.3.0-AUrnxTuqxo1yzY63w39Bk-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C29C3AA2B783F82003DF84C /* libHSsimplex-chat-5.5.3.0-AUrnxTuqxo1yzY63w39Bk-ghc9.6.3.a */; };
|
||||
5C29C3B02B783F82003DF84C /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C29C3AB2B783F82003DF84C /* libgmpxx.a */; };
|
||||
5C29C3B12B783F82003DF84C /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C29C3AC2B783F82003DF84C /* libffi.a */; };
|
||||
5C29C3B22B783F82003DF84C /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C29C3AD2B783F82003DF84C /* libgmp.a */; };
|
||||
5C29C3B32B783F82003DF84C /* libHSsimplex-chat-5.5.3.0-AUrnxTuqxo1yzY63w39Bk.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C29C3AE2B783F82003DF84C /* libHSsimplex-chat-5.5.3.0-AUrnxTuqxo1yzY63w39Bk.a */; };
|
||||
5C2E260727A2941F00F70299 /* SimpleXAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C2E260627A2941F00F70299 /* SimpleXAPI.swift */; };
|
||||
5C2E260B27A30CFA00F70299 /* ChatListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C2E260A27A30CFA00F70299 /* ChatListView.swift */; };
|
||||
5C2E260F27A30FDC00F70299 /* ChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C2E260E27A30FDC00F70299 /* ChatView.swift */; };
|
||||
@ -95,6 +90,11 @@
|
||||
5CB0BA90282713D900B3292C /* SimpleXInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB0BA8F282713D900B3292C /* SimpleXInfo.swift */; };
|
||||
5CB0BA92282713FD00B3292C /* CreateProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB0BA91282713FD00B3292C /* CreateProfile.swift */; };
|
||||
5CB0BA9A2827FD8800B3292C /* HowItWorks.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB0BA992827FD8800B3292C /* HowItWorks.swift */; };
|
||||
5CB1CE882B8259EB00963938 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CB1CE832B8259EB00963938 /* libgmpxx.a */; };
|
||||
5CB1CE892B8259EB00963938 /* libHSsimplex-chat-5.5.3.0-1R6yZC1upSP6aXGrPWvhZE.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CB1CE842B8259EB00963938 /* libHSsimplex-chat-5.5.3.0-1R6yZC1upSP6aXGrPWvhZE.a */; };
|
||||
5CB1CE8A2B8259EB00963938 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CB1CE852B8259EB00963938 /* libffi.a */; };
|
||||
5CB1CE8B2B8259EB00963938 /* libHSsimplex-chat-5.5.3.0-1R6yZC1upSP6aXGrPWvhZE-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CB1CE862B8259EB00963938 /* libHSsimplex-chat-5.5.3.0-1R6yZC1upSP6aXGrPWvhZE-ghc9.6.3.a */; };
|
||||
5CB1CE8C2B8259EB00963938 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CB1CE872B8259EB00963938 /* libgmp.a */; };
|
||||
5CB2084F28DA4B4800D024EC /* RTCServers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB2084E28DA4B4800D024EC /* RTCServers.swift */; };
|
||||
5CB346E52868AA7F001FD2EF /* SuspendChat.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB346E42868AA7F001FD2EF /* SuspendChat.swift */; };
|
||||
5CB346E72868D76D001FD2EF /* NotificationsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB346E62868D76D001FD2EF /* NotificationsView.swift */; };
|
||||
@ -278,11 +278,6 @@
|
||||
5C245F3C2B501E98001CC39F /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
5C245F3D2B501F13001CC39F /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = "tr.lproj/SimpleX--iOS--InfoPlist.strings"; sourceTree = "<group>"; };
|
||||
5C245F3E2B501F13001CC39F /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/InfoPlist.strings; sourceTree = "<group>"; };
|
||||
5C29C3AA2B783F82003DF84C /* libHSsimplex-chat-5.5.3.0-AUrnxTuqxo1yzY63w39Bk-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.5.3.0-AUrnxTuqxo1yzY63w39Bk-ghc9.6.3.a"; sourceTree = "<group>"; };
|
||||
5C29C3AB2B783F82003DF84C /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; };
|
||||
5C29C3AC2B783F82003DF84C /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; };
|
||||
5C29C3AD2B783F82003DF84C /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
|
||||
5C29C3AE2B783F82003DF84C /* libHSsimplex-chat-5.5.3.0-AUrnxTuqxo1yzY63w39Bk.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.5.3.0-AUrnxTuqxo1yzY63w39Bk.a"; sourceTree = "<group>"; };
|
||||
5C2E260627A2941F00F70299 /* SimpleXAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpleXAPI.swift; sourceTree = "<group>"; };
|
||||
5C2E260A27A30CFA00F70299 /* ChatListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatListView.swift; sourceTree = "<group>"; };
|
||||
5C2E260E27A30FDC00F70299 /* ChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatView.swift; sourceTree = "<group>"; };
|
||||
@ -377,6 +372,11 @@
|
||||
5CB0BA8F282713D900B3292C /* SimpleXInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpleXInfo.swift; sourceTree = "<group>"; };
|
||||
5CB0BA91282713FD00B3292C /* CreateProfile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateProfile.swift; sourceTree = "<group>"; };
|
||||
5CB0BA992827FD8800B3292C /* HowItWorks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HowItWorks.swift; sourceTree = "<group>"; };
|
||||
5CB1CE832B8259EB00963938 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; };
|
||||
5CB1CE842B8259EB00963938 /* libHSsimplex-chat-5.5.3.0-1R6yZC1upSP6aXGrPWvhZE.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.5.3.0-1R6yZC1upSP6aXGrPWvhZE.a"; sourceTree = "<group>"; };
|
||||
5CB1CE852B8259EB00963938 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; };
|
||||
5CB1CE862B8259EB00963938 /* libHSsimplex-chat-5.5.3.0-1R6yZC1upSP6aXGrPWvhZE-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.5.3.0-1R6yZC1upSP6aXGrPWvhZE-ghc9.6.3.a"; sourceTree = "<group>"; };
|
||||
5CB1CE872B8259EB00963938 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
|
||||
5CB2084E28DA4B4800D024EC /* RTCServers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RTCServers.swift; sourceTree = "<group>"; };
|
||||
5CB2085428DE647400D024EC /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
5CB346E42868AA7F001FD2EF /* SuspendChat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuspendChat.swift; sourceTree = "<group>"; };
|
||||
@ -514,13 +514,13 @@
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
5C29C3B02B783F82003DF84C /* libgmpxx.a in Frameworks */,
|
||||
5C29C3B32B783F82003DF84C /* libHSsimplex-chat-5.5.3.0-AUrnxTuqxo1yzY63w39Bk.a in Frameworks */,
|
||||
5CB1CE882B8259EB00963938 /* libgmpxx.a in Frameworks */,
|
||||
5CB1CE8C2B8259EB00963938 /* libgmp.a in Frameworks */,
|
||||
5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */,
|
||||
5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */,
|
||||
5C29C3B12B783F82003DF84C /* libffi.a in Frameworks */,
|
||||
5C29C3B22B783F82003DF84C /* libgmp.a in Frameworks */,
|
||||
5C29C3AF2B783F82003DF84C /* libHSsimplex-chat-5.5.3.0-AUrnxTuqxo1yzY63w39Bk-ghc9.6.3.a in Frameworks */,
|
||||
5CB1CE8A2B8259EB00963938 /* libffi.a in Frameworks */,
|
||||
5CB1CE892B8259EB00963938 /* libHSsimplex-chat-5.5.3.0-1R6yZC1upSP6aXGrPWvhZE.a in Frameworks */,
|
||||
5CB1CE8B2B8259EB00963938 /* libHSsimplex-chat-5.5.3.0-1R6yZC1upSP6aXGrPWvhZE-ghc9.6.3.a in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@ -582,11 +582,11 @@
|
||||
5C764E5C279C70B7000C6508 /* Libraries */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
5C29C3AC2B783F82003DF84C /* libffi.a */,
|
||||
5C29C3AD2B783F82003DF84C /* libgmp.a */,
|
||||
5C29C3AB2B783F82003DF84C /* libgmpxx.a */,
|
||||
5C29C3AA2B783F82003DF84C /* libHSsimplex-chat-5.5.3.0-AUrnxTuqxo1yzY63w39Bk-ghc9.6.3.a */,
|
||||
5C29C3AE2B783F82003DF84C /* libHSsimplex-chat-5.5.3.0-AUrnxTuqxo1yzY63w39Bk.a */,
|
||||
5CB1CE852B8259EB00963938 /* libffi.a */,
|
||||
5CB1CE872B8259EB00963938 /* libgmp.a */,
|
||||
5CB1CE832B8259EB00963938 /* libgmpxx.a */,
|
||||
5CB1CE862B8259EB00963938 /* libHSsimplex-chat-5.5.3.0-1R6yZC1upSP6aXGrPWvhZE-ghc9.6.3.a */,
|
||||
5CB1CE842B8259EB00963938 /* libHSsimplex-chat-5.5.3.0-1R6yZC1upSP6aXGrPWvhZE.a */,
|
||||
);
|
||||
path = Libraries;
|
||||
sourceTree = "<group>";
|
||||
|
@ -613,7 +613,7 @@ public enum ChatResponse: Decodable, Error {
|
||||
case callEnded(user: UserRef, contact: Contact)
|
||||
case callInvitations(callInvitations: [RcvCallInvitation])
|
||||
case ntfTokenStatus(status: NtfTknStatus)
|
||||
case ntfToken(token: DeviceToken, status: NtfTknStatus, ntfMode: NotificationsMode)
|
||||
case ntfToken(token: DeviceToken, status: NtfTknStatus, ntfMode: NotificationsMode, ntfServer: String)
|
||||
case ntfMessages(user_: User?, connEntity_: ConnectionEntity?, msgTs: Date?, ntfMessages: [NtfMsgInfo])
|
||||
case ntfMessage(user: UserRef, connEntity: ConnectionEntity, ntfMessage: NtfMsgInfo)
|
||||
case contactConnectionDeleted(user: UserRef, connection: PendingContactConnection)
|
||||
@ -912,7 +912,7 @@ public enum ChatResponse: Decodable, Error {
|
||||
case let .callEnded(u, contact): return withUser(u, "contact: \(contact.id)")
|
||||
case let .callInvitations(invs): return String(describing: invs)
|
||||
case let .ntfTokenStatus(status): return String(describing: status)
|
||||
case let .ntfToken(token, status, ntfMode): return "token: \(token)\nstatus: \(status.rawValue)\nntfMode: \(ntfMode.rawValue)"
|
||||
case let .ntfToken(token, status, ntfMode, ntfServer): return "token: \(token)\nstatus: \(status.rawValue)\nntfMode: \(ntfMode.rawValue)\nntfServer: \(ntfServer)"
|
||||
case let .ntfMessages(u, connEntity, msgTs, ntfMessages): return withUser(u, "connEntity: \(String(describing: connEntity))\nmsgTs: \(String(describing: msgTs))\nntfMessages: \(String(describing: ntfMessages))")
|
||||
case let .ntfMessage(u, connEntity, ntfMessage): return withUser(u, "connEntity: \(String(describing: connEntity))\nntfMessage: \(String(describing: ntfMessage))")
|
||||
case let .contactConnectionDeleted(u, connection): return withUser(u, String(describing: connection))
|
||||
|
@ -103,11 +103,14 @@
|
||||
</intent-filter>
|
||||
</activity-alias>
|
||||
|
||||
|
||||
<activity android:name=".views.call.IncomingCallActivity"
|
||||
<activity android:name=".views.call.CallActivity"
|
||||
android:showOnLockScreen="true"
|
||||
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
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
@ -133,6 +136,18 @@
|
||||
android:stopWithTask="false"></service>
|
||||
|
||||
<!-- SimplexService restart on reboot -->
|
||||
|
||||
<service
|
||||
android:name=".CallService"
|
||||
android:enabled="true"
|
||||
android:exported="false"
|
||||
android:stopWithTask="false"/>
|
||||
|
||||
<receiver
|
||||
android:name=".CallService$CallActionReceiver"
|
||||
android:enabled="true"
|
||||
android:exported="false" />
|
||||
|
||||
<receiver
|
||||
android:name=".SimplexService$StartReceiver"
|
||||
android:enabled="true"
|
||||
|
@ -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
|
||||
|
||||
import android.app.Application
|
||||
import android.app.*
|
||||
import android.content.Context
|
||||
import androidx.compose.ui.platform.ClipboardManager
|
||||
import chat.simplex.common.platform.Log
|
||||
import android.app.UiModeManager
|
||||
import android.content.Intent
|
||||
import android.os.*
|
||||
import androidx.lifecycle.*
|
||||
import androidx.work.*
|
||||
import chat.simplex.app.model.NtfManager
|
||||
import chat.simplex.app.model.NtfManager.AcceptCallAction
|
||||
import chat.simplex.app.views.call.CallActivity
|
||||
import chat.simplex.common.helpers.APPLICATION_ID
|
||||
import chat.simplex.common.helpers.requiresIgnoringBattery
|
||||
import chat.simplex.common.model.*
|
||||
@ -18,6 +19,7 @@ import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.ui.theme.CurrentColors
|
||||
import chat.simplex.common.ui.theme.DefaultTheme
|
||||
import chat.simplex.common.views.call.RcvCallInvitation
|
||||
import chat.simplex.common.views.call.activeCallDestroyWebView
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.common.views.onboarding.OnboardingStage
|
||||
import com.jakewharton.processphoenix.ProcessPhoenix
|
||||
@ -184,6 +186,10 @@ class SimplexApp: Application(), LifecycleEventObserver {
|
||||
SimplexService.safeStopService()
|
||||
}
|
||||
|
||||
override fun androidCallServiceSafeStop() {
|
||||
CallService.stopService()
|
||||
}
|
||||
|
||||
override fun androidNotificationsModeChanged(mode: NotificationsMode) {
|
||||
if (mode.requiresIgnoringBattery && !SimplexService.isBackgroundAllowed()) {
|
||||
appPrefs.backgroundServiceNoticeShown.set(false)
|
||||
@ -254,6 +260,28 @@ class SimplexApp: Application(), LifecycleEventObserver {
|
||||
uiModeManager.setApplicationNightMode(mode)
|
||||
}
|
||||
|
||||
override fun androidStartCallActivity(acceptCall: Boolean, remoteHostId: Long?, chatId: ChatId?) {
|
||||
val context = mainActivity.get() ?: return
|
||||
val intent = Intent(context, CallActivity::class.java)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION)
|
||||
if (acceptCall) {
|
||||
intent.setAction(AcceptCallAction)
|
||||
.putExtra("remoteHostId", remoteHostId)
|
||||
.putExtra("chatId", chatId)
|
||||
}
|
||||
intent.flags += Intent.FLAG_ACTIVITY_BROUGHT_TO_FRONT
|
||||
context.startActivity(intent)
|
||||
}
|
||||
|
||||
override fun androidPictureInPictureAllowed(): Boolean {
|
||||
val appOps = androidAppContext.getSystemService(Context.APP_OPS_SERVICE) as AppOpsManager
|
||||
return appOps.checkOpNoThrow(AppOpsManager.OPSTR_PICTURE_IN_PICTURE, Process.myUid(), packageName) == AppOpsManager.MODE_ALLOWED
|
||||
}
|
||||
|
||||
override fun androidCallEnded() {
|
||||
activeCallDestroyWebView()
|
||||
}
|
||||
|
||||
override suspend fun androidAskToAllowBackgroundCalls(): Boolean {
|
||||
if (SimplexService.isBackgroundRestricted()) {
|
||||
val userChoice: CompletableDeferred<Boolean> = CompletableDeferred()
|
||||
|
@ -34,12 +34,13 @@ import kotlin.system.exitProcess
|
||||
|
||||
class SimplexService: Service() {
|
||||
private var wakeLock: PowerManager.WakeLock? = null
|
||||
private var isStartingService = false
|
||||
private var isCheckingNewMessages = false
|
||||
private var notificationManager: NotificationManager? = null
|
||||
private var serviceNotification: Notification? = null
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
Log.d(TAG, "onStartCommand startId: $startId")
|
||||
isServiceStarting = false
|
||||
if (intent != null) {
|
||||
val action = intent.action
|
||||
Log.d(TAG, "intent action $action")
|
||||
@ -71,6 +72,7 @@ class SimplexService: Service() {
|
||||
stopForeground(true)
|
||||
stopSelf()
|
||||
} else {
|
||||
isServiceStarting = false
|
||||
isServiceStarted = true
|
||||
// In case of self-destruct is enabled the initialization process will not start in SimplexApp, Let's start it here
|
||||
if (DatabaseUtils.ksSelfDestructPassword.get() != null && chatModel.chatDbStatus.value == null) {
|
||||
@ -89,6 +91,7 @@ class SimplexService: Service() {
|
||||
} catch (e: Exception) {
|
||||
Log.d(TAG, "Exception while releasing wakelock: ${e.message}")
|
||||
}
|
||||
isServiceStarting = false
|
||||
isServiceStarted = false
|
||||
stopAfterStart = false
|
||||
saveServiceState(this, ServiceState.STOPPED)
|
||||
@ -101,9 +104,9 @@ class SimplexService: Service() {
|
||||
|
||||
private fun startService() {
|
||||
Log.d(TAG, "SimplexService startService")
|
||||
if (wakeLock != null || isStartingService) return
|
||||
if (wakeLock != null || isCheckingNewMessages) return
|
||||
val self = this
|
||||
isStartingService = true
|
||||
isCheckingNewMessages = true
|
||||
withLongRunningApi {
|
||||
val chatController = ChatController
|
||||
waitDbMigrationEnds(chatController)
|
||||
@ -123,7 +126,7 @@ class SimplexService: Service() {
|
||||
}
|
||||
}
|
||||
} 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 WORK_NAME_ONCE = "ServiceStartWorkerOnce"
|
||||
|
||||
var isServiceStarting = false
|
||||
var isServiceStarted = false
|
||||
private var stopAfterStart = false
|
||||
|
||||
@ -281,7 +285,7 @@ class SimplexService: Service() {
|
||||
fun safeStopService() {
|
||||
if (isServiceStarted) {
|
||||
androidAppContext.stopService(Intent(androidAppContext, SimplexService::class.java))
|
||||
} else {
|
||||
} else if (isServiceStarting) {
|
||||
stopAfterStart = true
|
||||
}
|
||||
}
|
||||
@ -291,6 +295,7 @@ class SimplexService: Service() {
|
||||
withContext(Dispatchers.IO) {
|
||||
Intent(androidAppContext, SimplexService::class.java).also {
|
||||
it.action = action.name
|
||||
isServiceStarting = true
|
||||
ContextCompat.startForegroundService(androidAppContext, it)
|
||||
}
|
||||
}
|
||||
|
@ -13,7 +13,7 @@ import androidx.compose.ui.graphics.asAndroidBitmap
|
||||
import androidx.core.app.*
|
||||
import chat.simplex.app.*
|
||||
import chat.simplex.app.TAG
|
||||
import chat.simplex.app.views.call.IncomingCallActivity
|
||||
import chat.simplex.app.views.call.CallActivity
|
||||
import chat.simplex.app.views.call.getKeyguardManager
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.common.model.*
|
||||
@ -33,6 +33,7 @@ object NtfManager {
|
||||
const val CallChannel: String = "chat.simplex.app.CALL_NOTIFICATION_2"
|
||||
const val AcceptCallAction: String = "chat.simplex.app.ACCEPT_CALL"
|
||||
const val RejectCallAction: String = "chat.simplex.app.REJECT_CALL"
|
||||
const val EndCallAction: String = "chat.simplex.app.END_CALL"
|
||||
const val CallNotificationId: Int = -1
|
||||
private const val UserIdKey: String = "userId"
|
||||
private const val ChatIdKey: String = "chatId"
|
||||
@ -157,7 +158,7 @@ object NtfManager {
|
||||
val screenOff = displayManager.displays.all { it.state != Display.STATE_ON }
|
||||
var ntfBuilder =
|
||||
if ((keyguardManager.isKeyguardLocked || screenOff) && appPreferences.callOnLockScreen.get() != CallOnLockScreen.DISABLE) {
|
||||
val fullScreenIntent = Intent(context, 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)
|
||||
NotificationCompat.Builder(context, CallChannel)
|
||||
.setFullScreenIntent(fullScreenPendingIntent, true)
|
||||
|
@ -1,17 +1,18 @@
|
||||
package chat.simplex.app.views.call
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.KeyguardManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import chat.simplex.common.platform.Log
|
||||
import android.view.WindowManager
|
||||
import android.app.*
|
||||
import android.content.*
|
||||
import android.content.res.Configuration
|
||||
import android.graphics.Rect
|
||||
import android.os.*
|
||||
import android.util.Rational
|
||||
import android.view.*
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.trackPipAnimationHintView
|
||||
import androidx.compose.desktop.ui.tooling.preview.Preview
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.*
|
||||
@ -22,33 +23,115 @@ import androidx.compose.ui.draw.scale
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import chat.simplex.app.*
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.TAG
|
||||
import chat.simplex.app.model.NtfManager
|
||||
import chat.simplex.app.model.NtfManager.AcceptCallAction
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.app.model.NtfManager.OpenChatAction
|
||||
import chat.simplex.common.platform.ntfManager
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.common.views.call.*
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.res.MR
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
import kotlinx.coroutines.launch
|
||||
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?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContent { IncomingCallActivityView(ChatModel) }
|
||||
unlockForIncomingCall()
|
||||
callActivity = WeakReference(this)
|
||||
when (intent?.action) {
|
||||
AcceptCallAction -> {
|
||||
val remoteHostId = intent.getLongExtra("remoteHostId", -1).takeIf { it != -1L }
|
||||
val chatId = intent.getStringExtra("chatId")
|
||||
val invitation = (m.callInvitations.values + m.activeCallInvitation.value).lastOrNull {
|
||||
it?.remoteHostId == remoteHostId && it?.contact?.id == chatId
|
||||
}
|
||||
if (invitation != null) {
|
||||
m.callManager.acceptIncomingCall(invitation = invitation)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setContent { CallActivityView() }
|
||||
|
||||
if (isOnLockScreenNow()) {
|
||||
unlockForIncomingCall()
|
||||
}
|
||||
}
|
||||
|
||||
override fun 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() {
|
||||
@ -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 {
|
||||
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 =
|
||||
context.getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager
|
||||
|
||||
private fun callSupportsVideo() = m.activeCall.value?.supportsVideo() == true || m.activeCallInvitation.value?.callType?.media == CallMediaType.Video
|
||||
|
||||
@Composable
|
||||
fun IncomingCallActivityView(m: ChatModel) {
|
||||
fun CallActivityView() {
|
||||
val switchingCall = m.switchingCall.value
|
||||
val invitation = m.activeCallInvitation.value
|
||||
val call = m.activeCall.value
|
||||
val call = remember { m.activeCall }.value
|
||||
val showCallView = m.showCallView.value
|
||||
val activity = LocalContext.current as Activity
|
||||
LaunchedEffect(invitation, call, switchingCall, showCallView) {
|
||||
if (!switchingCall && invitation == null && (!showCallView || call == null)) {
|
||||
Log.d(TAG, "IncomingCallActivityView: finishing activity")
|
||||
activity.finish()
|
||||
}
|
||||
val activity = LocalContext.current as CallActivity
|
||||
LaunchedEffect(Unit) {
|
||||
snapshotFlow { m.activeCallViewIsCollapsed.value }
|
||||
.collect { collapsed ->
|
||||
when {
|
||||
collapsed -> {
|
||||
if (!platform.androidPictureInPictureAllowed() || !callSupportsVideo()) {
|
||||
activity.moveTaskToBack(true)
|
||||
activity.startActivity(Intent(activity, MainActivity::class.java))
|
||||
} else if (!activity.isInPictureInPictureMode && activity.lifecycle.currentState == Lifecycle.State.RESUMED) {
|
||||
// User pressed back button, show MainActivity
|
||||
activity.startActivity(Intent(activity, MainActivity::class.java))
|
||||
activity.enterPictureInPictureMode()
|
||||
}
|
||||
}
|
||||
callSupportsVideo() && !platform.androidPictureInPictureAllowed() -> {
|
||||
// PiP disabled by user
|
||||
platform.androidStartCallActivity(false)
|
||||
}
|
||||
activity.isInPictureInPictureMode -> {
|
||||
platform.androidStartCallActivity(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
SimpleXTheme {
|
||||
Surface(
|
||||
Modifier
|
||||
.fillMaxSize(),
|
||||
color = MaterialTheme.colors.background,
|
||||
contentColor = LocalContentColor.current
|
||||
) {
|
||||
if (showCallView) {
|
||||
Box {
|
||||
ActiveCallView()
|
||||
if (invitation != null) IncomingCallAlertView(invitation, m)
|
||||
var prevCall by remember { mutableStateOf(call) }
|
||||
KeyChangeEffect(m.activeCall.value) {
|
||||
if (m.activeCall.value != null) {
|
||||
prevCall = m.activeCall.value
|
||||
activity.boundService?.updateNotification()
|
||||
}
|
||||
}
|
||||
Box(Modifier.background(Color.Black)) {
|
||||
if (call != null) {
|
||||
val view = LocalView.current
|
||||
ActiveCallView()
|
||||
if (callSupportsVideo()) {
|
||||
val scope = rememberCoroutineScope()
|
||||
LaunchedEffect(Unit) {
|
||||
scope.launch {
|
||||
activity.setPipParams(callSupportsVideo(), viewRatio = Rational(view.width, view.height))
|
||||
activity.trackPipAnimationHintView(view)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (prevCall != null) {
|
||||
prevCall?.let { ActiveCallOverlayDisabled(it) }
|
||||
}
|
||||
if (invitation != null) {
|
||||
if (call == null) {
|
||||
Surface(
|
||||
Modifier
|
||||
.fillMaxSize(),
|
||||
color = MaterialTheme.colors.background,
|
||||
contentColor = LocalContentColor.current
|
||||
) {
|
||||
IncomingCallLockScreenAlert(invitation, m)
|
||||
}
|
||||
} else {
|
||||
IncomingCallAlertView(invitation, m)
|
||||
}
|
||||
} 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
|
||||
fun IncomingCallLockScreenAlert(invitation: RcvCallInvitation, chatModel: ChatModel) {
|
||||
val cm = chatModel.callManager
|
||||
@ -135,7 +293,7 @@ fun IncomingCallLockScreenAlert(invitation: RcvCallInvitation, chatModel: ChatMo
|
||||
acceptCall = { cm.acceptIncomingCall(invitation = invitation) },
|
||||
openApp = {
|
||||
val intent = Intent(context, MainActivity::class.java)
|
||||
.setAction(OpenChatAction)
|
||||
.setAction(NtfManager.OpenChatAction)
|
||||
.putExtra("userId", invitation.user.userId)
|
||||
.putExtra("chatId", invitation.contact.id)
|
||||
context.startActivity(intent)
|
@ -4,6 +4,7 @@ import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.net.LocalServerSocket
|
||||
import android.util.Log
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import chat.simplex.common.*
|
||||
import chat.simplex.common.platform.*
|
||||
@ -25,7 +26,8 @@ val defaultLocale: Locale = Locale.getDefault()
|
||||
|
||||
@SuppressLint("StaticFieldLeak")
|
||||
lateinit var androidAppContext: Context
|
||||
lateinit var mainActivity: WeakReference<FragmentActivity>
|
||||
var mainActivity: WeakReference<FragmentActivity> = WeakReference(null)
|
||||
var callActivity: WeakReference<ComponentActivity> = WeakReference(null)
|
||||
|
||||
fun initHaskell() {
|
||||
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()
|
||||
}
|
||||
|
||||
fun Bitmap.clipToCircle(): Bitmap {
|
||||
val circle = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
|
||||
val path = android.graphics.Path()
|
||||
path.addCircle(width / 2f, height / 2f, min(width, height) / 2f, android.graphics.Path.Direction.CCW)
|
||||
val canvas = android.graphics.Canvas(circle)
|
||||
canvas.clipPath(path)
|
||||
canvas.drawBitmap(this, 0f, 0f, null)
|
||||
return circle
|
||||
}
|
||||
|
||||
actual fun compressImageStr(bitmap: ImageBitmap): String {
|
||||
val usePng = bitmap.hasAlpha()
|
||||
val ext = if (usePng) "png" else "jpg"
|
||||
|
@ -28,6 +28,7 @@ import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||
import dev.icerock.moko.resources.compose.painterResource
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.lifecycle.Lifecycle
|
||||
@ -50,20 +51,30 @@ import kotlinx.datetime.Clock
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
|
||||
// Should be destroy()'ed and set as null when call is ended. Otherwise, it will be a leak
|
||||
@SuppressLint("StaticFieldLeak")
|
||||
private var staticWebView: WebView? = null
|
||||
|
||||
// WebView methods must be called on Main thread
|
||||
fun activeCallDestroyWebView() = withApi {
|
||||
// Stop it when call ended
|
||||
platform.androidCallServiceSafeStop()
|
||||
staticWebView?.destroy()
|
||||
staticWebView = null
|
||||
Log.d(TAG, "CallView: webview was destroyed")
|
||||
}
|
||||
|
||||
@SuppressLint("SourceLockedOrientationActivity")
|
||||
@Composable
|
||||
actual fun ActiveCallView() {
|
||||
val chatModel = ChatModel
|
||||
BackHandler(onBack = {
|
||||
val call = chatModel.activeCall.value
|
||||
if (call != null) withBGApi { chatModel.callManager.endCall(call) }
|
||||
})
|
||||
val audioViaBluetooth = rememberSaveable { mutableStateOf(false) }
|
||||
val ntfModeService = remember { chatModel.controller.appPrefs.notificationsMode.get() == NotificationsMode.SERVICE }
|
||||
LaunchedEffect(Unit) {
|
||||
// Start service when call happening since it's not already started.
|
||||
// It's needed to prevent Android from shutting down a microphone after a minute or so when screen is off
|
||||
if (!ntfModeService) platform.androidServiceStart()
|
||||
val proximityLock = remember {
|
||||
val pm = (androidAppContext.getSystemService(Context.POWER_SERVICE) as PowerManager)
|
||||
if (pm.isWakeLockLevelSupported(PROXIMITY_SCREEN_OFF_WAKE_LOCK)) {
|
||||
pm.newWakeLock(PROXIMITY_SCREEN_OFF_WAKE_LOCK, androidAppContext.packageName + ":proximityLock")
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
DisposableEffect(Unit) {
|
||||
val am = androidAppContext.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||
@ -93,22 +104,24 @@ actual fun ActiveCallView() {
|
||||
}
|
||||
}
|
||||
am.registerAudioDeviceCallback(audioCallback, null)
|
||||
val pm = (androidAppContext.getSystemService(Context.POWER_SERVICE) as PowerManager)
|
||||
val proximityLock = if (pm.isWakeLockLevelSupported(PROXIMITY_SCREEN_OFF_WAKE_LOCK)) {
|
||||
pm.newWakeLock(PROXIMITY_SCREEN_OFF_WAKE_LOCK, androidAppContext.packageName + ":proximityLock")
|
||||
} else {
|
||||
null
|
||||
}
|
||||
proximityLock?.acquire()
|
||||
onDispose {
|
||||
// Stop it when call ended
|
||||
if (!ntfModeService) platform.androidServiceSafeStop()
|
||||
dropAudioManagerOverrides()
|
||||
am.unregisterAudioDeviceCallback(audioCallback)
|
||||
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 call = chatModel.activeCall.value
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
WebRTCView(chatModel.callCommand) { apiMsg ->
|
||||
Log.d(TAG, "received from WebRTCView: $apiMsg")
|
||||
@ -120,15 +133,15 @@ actual fun ActiveCallView() {
|
||||
is WCallResponse.Capabilities -> withBGApi {
|
||||
val callType = CallType(call.localMedia, r.capabilities)
|
||||
chatModel.controller.apiSendCallInvitation(callRh, call.contact, callType)
|
||||
chatModel.activeCall.value = call.copy(callState = CallState.InvitationSent, localCapabilities = r.capabilities)
|
||||
updateActiveCall(call) { it.copy(callState = CallState.InvitationSent, localCapabilities = r.capabilities) }
|
||||
}
|
||||
is WCallResponse.Offer -> withBGApi {
|
||||
chatModel.controller.apiSendCallOffer(callRh, call.contact, r.offer, r.iceCandidates, call.localMedia, r.capabilities)
|
||||
chatModel.activeCall.value = call.copy(callState = CallState.OfferSent, localCapabilities = r.capabilities)
|
||||
updateActiveCall(call) { it.copy(callState = CallState.OfferSent, localCapabilities = r.capabilities) }
|
||||
}
|
||||
is WCallResponse.Answer -> withBGApi {
|
||||
chatModel.controller.apiSendCallAnswer(callRh, call.contact, r.answer, r.iceCandidates)
|
||||
chatModel.activeCall.value = call.copy(callState = CallState.Negotiated)
|
||||
updateActiveCall(call) { it.copy(callState = CallState.Negotiated) }
|
||||
}
|
||||
is WCallResponse.Ice -> withBGApi {
|
||||
chatModel.controller.apiSendCallExtraInfo(callRh, call.contact, r.iceCandidates)
|
||||
@ -137,7 +150,7 @@ actual fun ActiveCallView() {
|
||||
try {
|
||||
val callStatus = json.decodeFromString<WebRTCCallStatus>("\"${r.state.connectionState}\"")
|
||||
if (callStatus == WebRTCCallStatus.Connected) {
|
||||
chatModel.activeCall.value = call.copy(callState = CallState.Connected, connectedAt = Clock.System.now())
|
||||
updateActiveCall(call) { it.copy(callState = CallState.Connected, connectedAt = Clock.System.now()) }
|
||||
setCallSound(call.soundSpeaker, audioViaBluetooth)
|
||||
}
|
||||
withBGApi { chatModel.controller.apiCallStatus(callRh, call.contact, callStatus) }
|
||||
@ -145,7 +158,7 @@ actual fun ActiveCallView() {
|
||||
Log.d(TAG,"call status ${r.state.connectionState} not used")
|
||||
}
|
||||
is WCallResponse.Connected -> {
|
||||
chatModel.activeCall.value = call.copy(callState = CallState.Connected, connectionInfo = r.connectionInfo)
|
||||
updateActiveCall(call) { it.copy(callState = CallState.Connected, connectionInfo = r.connectionInfo) }
|
||||
scope.launch {
|
||||
setCallSound(call.soundSpeaker, audioViaBluetooth)
|
||||
}
|
||||
@ -154,27 +167,29 @@ actual fun ActiveCallView() {
|
||||
withBGApi { chatModel.callManager.endCall(call) }
|
||||
}
|
||||
is WCallResponse.Ended -> {
|
||||
chatModel.activeCall.value = call.copy(callState = CallState.Ended)
|
||||
updateActiveCall(call) { it.copy(callState = CallState.Ended) }
|
||||
withBGApi { chatModel.callManager.endCall(call) }
|
||||
chatModel.showCallView.value = false
|
||||
}
|
||||
is WCallResponse.Ok -> when (val cmd = apiMsg.command) {
|
||||
is WCallCommand.Answer ->
|
||||
chatModel.activeCall.value = call.copy(callState = CallState.Negotiated)
|
||||
updateActiveCall(call) { it.copy(callState = CallState.Negotiated) }
|
||||
is WCallCommand.Media -> {
|
||||
when (cmd.media) {
|
||||
CallMediaType.Video -> chatModel.activeCall.value = call.copy(videoEnabled = cmd.enable)
|
||||
CallMediaType.Audio -> chatModel.activeCall.value = call.copy(audioEnabled = cmd.enable)
|
||||
updateActiveCall(call) {
|
||||
when (cmd.media) {
|
||||
CallMediaType.Video -> it.copy(videoEnabled = cmd.enable)
|
||||
CallMediaType.Audio -> it.copy(audioEnabled = cmd.enable)
|
||||
}
|
||||
}
|
||||
}
|
||||
is WCallCommand.Camera -> {
|
||||
chatModel.activeCall.value = call.copy(localCamera = cmd.camera)
|
||||
updateActiveCall(call) { it.copy(localCamera = cmd.camera) }
|
||||
if (!call.audioEnabled) {
|
||||
chatModel.callCommand.add(WCallCommand.Media(CallMediaType.Audio, enable = false))
|
||||
}
|
||||
}
|
||||
is WCallCommand.End ->
|
||||
chatModel.showCallView.value = false
|
||||
is WCallCommand.End -> {
|
||||
withBGApi { chatModel.callManager.endCall(call) }
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
is WCallResponse.Error -> {
|
||||
@ -183,8 +198,16 @@ actual fun ActiveCallView() {
|
||||
}
|
||||
}
|
||||
}
|
||||
val call = chatModel.activeCall.value
|
||||
if (call != null) ActiveCallOverlay(call, chatModel, audioViaBluetooth)
|
||||
val showOverlay = when {
|
||||
call == null -> false
|
||||
!platform.androidPictureInPictureAllowed() -> true
|
||||
!call.supportsVideo() -> true
|
||||
!chatModel.activeCallViewIsCollapsed.value -> true
|
||||
else -> false
|
||||
}
|
||||
if (call != null && showOverlay) {
|
||||
ActiveCallOverlay(call, chatModel, audioViaBluetooth)
|
||||
}
|
||||
}
|
||||
|
||||
val 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>) {
|
||||
val am = androidAppContext.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||
Log.d(TAG, "setCallSound: set audio mode, speaker enabled: $speaker")
|
||||
@ -271,59 +308,69 @@ private fun dropAudioManagerOverrides() {
|
||||
private fun ActiveCallOverlayLayout(
|
||||
call: Call,
|
||||
speakerCanBeEnabled: Boolean,
|
||||
enabled: Boolean = true,
|
||||
dismiss: () -> Unit,
|
||||
toggleAudio: () -> Unit,
|
||||
toggleVideo: () -> Unit,
|
||||
toggleSound: () -> Unit,
|
||||
flipCamera: () -> Unit
|
||||
) {
|
||||
Column(Modifier.padding(DEFAULT_PADDING)) {
|
||||
when (call.peerMedia ?: call.localMedia) {
|
||||
CallMediaType.Video -> {
|
||||
CallInfoView(call, alignment = Alignment.Start)
|
||||
Box(Modifier.fillMaxWidth().fillMaxHeight().weight(1f), contentAlignment = Alignment.BottomCenter) {
|
||||
DisabledBackgroundCallsButton()
|
||||
}
|
||||
Row(Modifier.fillMaxWidth().padding(horizontal = 6.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
|
||||
ToggleAudioButton(call, toggleAudio)
|
||||
Spacer(Modifier.size(40.dp))
|
||||
IconButton(onClick = dismiss) {
|
||||
Icon(painterResource(MR.images.ic_call_end_filled), stringResource(MR.strings.icon_descr_hang_up), tint = Color.Red, modifier = Modifier.size(64.dp))
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
Column {
|
||||
val media = call.peerMedia ?: call.localMedia
|
||||
CloseSheetBar({ chatModel.activeCallViewIsCollapsed.value = true }, true, tintColor = Color(0xFFFFFFD8)) {
|
||||
if (media == CallMediaType.Video) {
|
||||
Text(call.contact.chatViewName, Modifier.fillMaxWidth().padding(end = DEFAULT_PADDING), color = Color(0xFFFFFFD8), style = MaterialTheme.typography.h2, overflow = TextOverflow.Ellipsis, maxLines = 1)
|
||||
}
|
||||
CallMediaType.Audio -> {
|
||||
Spacer(Modifier.fillMaxHeight().weight(1f))
|
||||
Column(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
ProfileImage(size = 192.dp, image = call.contact.profile.image)
|
||||
CallInfoView(call, alignment = Alignment.CenterHorizontally)
|
||||
}
|
||||
Box(Modifier.fillMaxWidth().fillMaxHeight().weight(1f), contentAlignment = Alignment.BottomCenter) {
|
||||
DisabledBackgroundCallsButton()
|
||||
}
|
||||
Box(Modifier.fillMaxWidth().padding(bottom = DEFAULT_BOTTOM_PADDING), contentAlignment = Alignment.CenterStart) {
|
||||
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||
IconButton(onClick = dismiss) {
|
||||
Icon(painterResource(MR.images.ic_call_end_filled), stringResource(MR.strings.icon_descr_hang_up), tint = Color.Red, modifier = Modifier.size(64.dp))
|
||||
}
|
||||
Column(Modifier.padding(horizontal = DEFAULT_PADDING)) {
|
||||
when (media) {
|
||||
CallMediaType.Video -> {
|
||||
VideoCallInfoView(call)
|
||||
Box(Modifier.fillMaxWidth().fillMaxHeight().weight(1f), contentAlignment = Alignment.BottomCenter) {
|
||||
DisabledBackgroundCallsButton()
|
||||
}
|
||||
Row(Modifier.fillMaxWidth().padding(horizontal = 6.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
|
||||
ToggleAudioButton(call, enabled, toggleAudio)
|
||||
Spacer(Modifier.size(40.dp))
|
||||
IconButton(onClick = dismiss, enabled = enabled) {
|
||||
Icon(painterResource(MR.images.ic_call_end_filled), stringResource(MR.strings.icon_descr_hang_up), tint = if (enabled) Color.Red else MaterialTheme.colors.secondary, modifier = Modifier.size(64.dp))
|
||||
}
|
||||
if (call.videoEnabled) {
|
||||
ControlButton(call, painterResource(MR.images.ic_flip_camera_android_filled), MR.strings.icon_descr_flip_camera, enabled, flipCamera)
|
||||
ControlButton(call, painterResource(MR.images.ic_videocam_filled), MR.strings.icon_descr_video_off, enabled, toggleVideo)
|
||||
} else {
|
||||
Spacer(Modifier.size(48.dp))
|
||||
ControlButton(call, painterResource(MR.images.ic_videocam_off), MR.strings.icon_descr_video_on, enabled, toggleVideo)
|
||||
}
|
||||
}
|
||||
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.padding(end = 32.dp)) {
|
||||
ToggleSoundButton(call, speakerCanBeEnabled, toggleSound)
|
||||
Box(Modifier.fillMaxWidth().fillMaxHeight().weight(1f), contentAlignment = Alignment.BottomCenter) {
|
||||
DisabledBackgroundCallsButton()
|
||||
}
|
||||
Box(Modifier.fillMaxWidth().padding(bottom = DEFAULT_BOTTOM_PADDING), contentAlignment = Alignment.CenterStart) {
|
||||
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||
IconButton(onClick = dismiss, enabled = enabled) {
|
||||
Icon(painterResource(MR.images.ic_call_end_filled), stringResource(MR.strings.icon_descr_hang_up), tint = if (enabled) Color.Red else MaterialTheme.colors.secondary, modifier = Modifier.size(64.dp))
|
||||
}
|
||||
}
|
||||
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
|
||||
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) {
|
||||
IconButton(onClick = action, enabled = enabled) {
|
||||
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
|
||||
private fun ToggleAudioButton(call: Call, toggleAudio: () -> Unit) {
|
||||
private fun ToggleAudioButton(call: Call, enabled: Boolean = true, toggleAudio: () -> Unit) {
|
||||
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 {
|
||||
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
|
||||
private fun ToggleSoundButton(call: Call, enabled: Boolean, toggleSound: () -> Unit) {
|
||||
if (call.soundSpeaker) {
|
||||
ControlButton(call, painterResource(MR.images.ic_volume_up), MR.strings.icon_descr_speaker_off, toggleSound, enabled)
|
||||
ControlButton(call, painterResource(MR.images.ic_volume_up), MR.strings.icon_descr_speaker_off, enabled, toggleSound)
|
||||
} 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
|
||||
fun CallInfoView(call: Call, alignment: Alignment.Horizontal) {
|
||||
@Composable fun InfoText(text: String, style: TextStyle = MaterialTheme.typography.body2) =
|
||||
Text(text, color = Color(0xFFFFFFD8), style = style)
|
||||
Column(horizontalAlignment = alignment) {
|
||||
fun AudioCallInfoView(call: Call) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
InfoText(call.contact.chatViewName, style = MaterialTheme.typography.h2)
|
||||
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
|
||||
private fun DisabledBackgroundCallsButton() {
|
||||
var show by remember { mutableStateOf(!platform.androidIsBackgroundCallAllowed()) }
|
||||
@ -452,7 +512,6 @@ private fun DisabledBackgroundCallsButton() {
|
||||
|
||||
@Composable
|
||||
fun WebRTCView(callCommand: SnapshotStateList<WCallCommand>, onResponse: (WVAPIMessage) -> Unit) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val webView = remember { mutableStateOf<WebView?>(null) }
|
||||
val permissionsState = rememberMultiplePermissionsState(
|
||||
permissions = listOf(
|
||||
@ -475,10 +534,10 @@ fun WebRTCView(callCommand: SnapshotStateList<WCallCommand>, onResponse: (WVAPIM
|
||||
}
|
||||
lifecycleOwner.lifecycle.addObserver(observer)
|
||||
onDispose {
|
||||
val wv = webView.value
|
||||
if (wv != null) processCommand(wv, WCallCommand.End)
|
||||
lifecycleOwner.lifecycle.removeObserver(observer)
|
||||
webView.value?.destroy()
|
||||
// val wv = webView.value
|
||||
// if (wv != null) processCommand(wv, WCallCommand.End)
|
||||
// webView.value?.destroy()
|
||||
webView.value = null
|
||||
}
|
||||
}
|
||||
@ -505,7 +564,7 @@ fun WebRTCView(callCommand: SnapshotStateList<WCallCommand>, onResponse: (WVAPIM
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
AndroidView(
|
||||
factory = { AndroidViewContext ->
|
||||
WebView(AndroidViewContext).apply {
|
||||
(staticWebView ?: WebView(androidAppContext)).apply {
|
||||
layoutParams = ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
@ -530,7 +589,11 @@ fun WebRTCView(callCommand: SnapshotStateList<WCallCommand>, onResponse: (WVAPIM
|
||||
webViewSettings.javaScriptEnabled = true
|
||||
webViewSettings.mediaPlaybackRequiresUserGesture = false
|
||||
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 */ }
|
||||
@ -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() {
|
||||
override fun shouldInterceptRequest(
|
||||
view: WebView,
|
||||
@ -566,6 +638,7 @@ private class LocalContentWebViewClient(val webView: MutableState<WebView?>, pri
|
||||
super.onPageFinished(view, url)
|
||||
view.evaluateJavascript("sendMessageToNative = (msg) => WebRTCInterface.postMessage(JSON.stringify(msg))", null)
|
||||
webView.value = view
|
||||
staticWebView = view
|
||||
Log.d(TAG, "WebRTCView: webview ready")
|
||||
// for debugging
|
||||
// view.evaluateJavascript("sendMessageToNative = ({resp}) => WebRTCInterface.postMessage(JSON.stringify({command: resp}))", null)
|
||||
@ -579,6 +652,7 @@ fun PreviewActiveCallOverlayVideo() {
|
||||
ActiveCallOverlayLayout(
|
||||
call = Call(
|
||||
remoteHostId = null,
|
||||
userProfile = Profile.sampleData,
|
||||
contact = Contact.sampleData,
|
||||
callState = CallState.Negotiated,
|
||||
localMedia = CallMediaType.Video,
|
||||
@ -605,6 +679,7 @@ fun PreviewActiveCallOverlayAudio() {
|
||||
ActiveCallOverlayLayout(
|
||||
call = Call(
|
||||
remoteHostId = null,
|
||||
userProfile = Profile.sampleData,
|
||||
contact = Contact.sampleData,
|
||||
callState = CallState.Negotiated,
|
||||
localMedia = CallMediaType.Audio,
|
||||
|
@ -1,8 +1,112 @@
|
||||
package chat.simplex.common.views.chatlist
|
||||
|
||||
import android.app.Activity
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.ripple.rememberRipple
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.platform.*
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.common.ANDROID_CALL_TOP_PADDING
|
||||
import chat.simplex.common.model.durationText
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.common.views.call.*
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.res.MR
|
||||
import dev.icerock.moko.resources.compose.painterResource
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.datetime.Clock
|
||||
|
||||
private val CALL_INTERACTIVE_AREA_HEIGHT = 74.dp
|
||||
private val CALL_TOP_OFFSET = (-10).dp
|
||||
private val CALL_TOP_GREEN_LINE_HEIGHT = ANDROID_CALL_TOP_PADDING - CALL_TOP_OFFSET
|
||||
private val CALL_BOTTOM_ICON_OFFSET = (-15).dp
|
||||
private val CALL_BOTTOM_ICON_HEIGHT = CALL_INTERACTIVE_AREA_HEIGHT + CALL_BOTTOM_ICON_OFFSET
|
||||
|
||||
@Composable
|
||||
actual fun 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
|
||||
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
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.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.common.views.usersettings.SetDeliveryReceiptsView
|
||||
@ -20,8 +23,7 @@ import chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.common.views.CreateFirstProfile
|
||||
import chat.simplex.common.views.helpers.SimpleButton
|
||||
import chat.simplex.common.views.SplashView
|
||||
import chat.simplex.common.views.call.ActiveCallView
|
||||
import chat.simplex.common.views.call.IncomingCallAlertView
|
||||
import chat.simplex.common.views.call.*
|
||||
import chat.simplex.common.views.chat.ChatView
|
||||
import chat.simplex.common.views.chatlist.*
|
||||
import chat.simplex.common.views.database.DatabaseErrorView
|
||||
@ -169,7 +171,17 @@ fun MainScreen() {
|
||||
}
|
||||
} else {
|
||||
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 {
|
||||
// It's needed for privacy settings toggle, so it can be shown even if the app is passcode unlocked
|
||||
ModalManager.fullscreen.showPasscodeInView()
|
||||
@ -206,9 +218,13 @@ fun MainScreen() {
|
||||
}
|
||||
}
|
||||
|
||||
val ANDROID_CALL_TOP_PADDING = 40.dp
|
||||
|
||||
@Composable
|
||||
fun AndroidScreen(settingsState: SettingsViewState) {
|
||||
BoxWithConstraints {
|
||||
val call = remember { chatModel.activeCall} .value
|
||||
val showCallArea = call != null && call.callState != CallState.WaitCapabilities && call.callState != CallState.InvitationAccepted
|
||||
var currentChatId by rememberSaveable { mutableStateOf(chatModel.chatId.value) }
|
||||
val offset = remember { Animatable(if (chatModel.chatId.value == null) 0f else maxWidth.value) }
|
||||
Box(
|
||||
@ -216,6 +232,7 @@ fun AndroidScreen(settingsState: SettingsViewState) {
|
||||
.graphicsLayer {
|
||||
translationX = -offset.value.dp.toPx()
|
||||
}
|
||||
.padding(top = if (showCallArea) ANDROID_CALL_TOP_PADDING else 0.dp)
|
||||
) {
|
||||
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 {
|
||||
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 activeCall = mutableStateOf<Call?>(null)
|
||||
val activeCallViewIsVisible = mutableStateOf<Boolean>(false)
|
||||
val activeCallViewIsCollapsed = mutableStateOf<Boolean>(false)
|
||||
val callCommand = mutableStateListOf<WCallCommand>()
|
||||
val showCallView = mutableStateOf(false)
|
||||
val switchingCall = mutableStateOf(false)
|
||||
|
@ -1914,10 +1914,8 @@ object ChatController {
|
||||
if (invitation != null) {
|
||||
chatModel.callManager.reportCallRemoteEnded(invitation = invitation)
|
||||
}
|
||||
withCall(r, r.contact) { _ ->
|
||||
chatModel.callCommand.add(WCallCommand.End)
|
||||
chatModel.activeCall.value = null
|
||||
chatModel.showCallView.value = false
|
||||
withCall(r, r.contact) { call ->
|
||||
withBGApi { chatModel.callManager.endCall(call) }
|
||||
}
|
||||
}
|
||||
is CR.ContactSwitch ->
|
||||
|
@ -1,16 +1,21 @@
|
||||
package chat.simplex.common.platform
|
||||
|
||||
import chat.simplex.common.model.ChatId
|
||||
import chat.simplex.common.model.NotificationsMode
|
||||
|
||||
interface PlatformInterface {
|
||||
suspend fun androidServiceStart() {}
|
||||
fun androidServiceSafeStop() {}
|
||||
fun androidCallServiceSafeStop() {}
|
||||
fun androidNotificationsModeChanged(mode: NotificationsMode) {}
|
||||
fun androidChatStartedAfterBeingOff() {}
|
||||
fun androidChatStopped() {}
|
||||
fun androidChatInitializedAndStarted() {}
|
||||
fun androidIsBackgroundCallAllowed(): Boolean = true
|
||||
fun androidSetNightModeIfSupported() {}
|
||||
fun androidStartCallActivity(acceptCall: Boolean, remoteHostId: Long? = null, chatId: ChatId? = null) {}
|
||||
fun androidPictureInPictureAllowed(): Boolean = true
|
||||
fun androidCallEnded() {}
|
||||
suspend fun androidAskToAllowBackgroundCalls(): Boolean = true
|
||||
}
|
||||
/**
|
||||
|
@ -1,6 +1,6 @@
|
||||
package chat.simplex.common.views.call
|
||||
|
||||
import chat.simplex.common.model.ChatModel
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.views.helpers.withBGApi
|
||||
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
|
||||
if (call == null) {
|
||||
justAcceptIncomingCall(invitation = invitation)
|
||||
val contactInfo = chatModel.controller.apiContactInfo(invitation.remoteHostId, invitation.contact.contactId)
|
||||
val profile = contactInfo?.second ?: invitation.user.profile.toProfile()
|
||||
// In case the same contact calling while previous call didn't end yet (abnormal ending of call from the other side)
|
||||
if (call == null || (call.remoteHostId == invitation.remoteHostId && call.contact.id == invitation.contact.id)) {
|
||||
justAcceptIncomingCall(invitation = invitation, profile)
|
||||
} else {
|
||||
withBGApi {
|
||||
chatModel.switchingCall.value = true
|
||||
try {
|
||||
endCall(call = call)
|
||||
justAcceptIncomingCall(invitation = invitation)
|
||||
} finally {
|
||||
chatModel.switchingCall.value = false
|
||||
}
|
||||
chatModel.switchingCall.value = true
|
||||
try {
|
||||
endCall(call = call)
|
||||
justAcceptIncomingCall(invitation = invitation, profile)
|
||||
} finally {
|
||||
chatModel.switchingCall.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun justAcceptIncomingCall(invitation: RcvCallInvitation) {
|
||||
private fun justAcceptIncomingCall(invitation: RcvCallInvitation, userProfile: Profile) {
|
||||
with (chatModel) {
|
||||
activeCall.value = Call(
|
||||
remoteHostId = invitation.remoteHostId,
|
||||
userProfile = userProfile,
|
||||
contact = invitation.contact,
|
||||
callState = CallState.InvitationAccepted,
|
||||
localMedia = invitation.callType.media,
|
||||
@ -68,17 +70,23 @@ class CallManager(val chatModel: ChatModel) {
|
||||
}
|
||||
|
||||
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) {
|
||||
Log.d(TAG, "CallManager.endCall: call ended")
|
||||
activeCall.value = null
|
||||
showCallView.value = false
|
||||
} else {
|
||||
Log.d(TAG, "CallManager.endCall: ending call...")
|
||||
callCommand.add(WCallCommand.End)
|
||||
showCallView.value = false
|
||||
//callCommand.add(WCallCommand.End)
|
||||
controller.apiEndCall(call.remoteHostId, call.contact)
|
||||
activeCall.value = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -7,11 +7,11 @@ import kotlinx.datetime.Instant
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.net.URI
|
||||
import java.util.*
|
||||
import kotlin.collections.ArrayList
|
||||
|
||||
data class Call(
|
||||
val remoteHostId: Long?,
|
||||
val userProfile: Profile,
|
||||
val contact: Contact,
|
||||
val callState: CallState,
|
||||
val localMedia: CallMediaType,
|
||||
@ -23,7 +23,7 @@ data class Call(
|
||||
val soundSpeaker: Boolean = localMedia == CallMediaType.Video,
|
||||
var localCamera: VideoCamera = VideoCamera.User,
|
||||
val connectionInfo: ConnectionInfo? = null,
|
||||
var connectedAt: Instant? = null
|
||||
var connectedAt: Instant? = null,
|
||||
) {
|
||||
val encrypted: Boolean get() = localEncrypted && sharedKey != null
|
||||
val localEncrypted: Boolean get() = localCapabilities?.encryption ?: false
|
||||
@ -36,6 +36,9 @@ data class Call(
|
||||
}
|
||||
|
||||
val hasMedia: Boolean get() = callState == CallState.OfferSent || callState == CallState.Negotiated || callState == CallState.Connected
|
||||
|
||||
fun supportsVideo(): Boolean = peerMedia == CallMediaType.Video || localMedia == CallMediaType.Video
|
||||
|
||||
}
|
||||
|
||||
enum class CallState {
|
||||
@ -75,6 +78,7 @@ sealed class WCallCommand {
|
||||
@Serializable @SerialName("media") data class Media(val media: CallMediaType, val enable: Boolean): WCallCommand()
|
||||
@Serializable @SerialName("camera") data class Camera(val camera: VideoCamera): WCallCommand()
|
||||
@Serializable @SerialName("description") data class Description(val state: String, val description: String): WCallCommand()
|
||||
@Serializable @SerialName("layout") data class Layout(val layout: LayoutType): WCallCommand()
|
||||
@Serializable @SerialName("end") object End: WCallCommand()
|
||||
}
|
||||
|
||||
@ -167,6 +171,13 @@ enum class VideoCamera {
|
||||
val flipped: VideoCamera get() = if (this == User) Environment else User
|
||||
}
|
||||
|
||||
@Serializable
|
||||
enum class LayoutType {
|
||||
@SerialName("default") Default,
|
||||
@SerialName("localVideo") LocalVideo,
|
||||
@SerialName("remoteVideo") RemoteVideo
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class ConnectionState(
|
||||
val connectionState: String,
|
||||
|
@ -301,7 +301,9 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId:
|
||||
withBGApi {
|
||||
val cInfo = chat.chatInfo
|
||||
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.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 {
|
||||
val call = remember { chatModel.activeCall }.value
|
||||
val connectedAt = call?.connectedAt
|
||||
|
@ -424,69 +424,47 @@ private fun MemberVerifiedShield() {
|
||||
|
||||
@Composable
|
||||
private fun DropDownMenuForMember(rhId: Long?, member: GroupMember, groupInfo: GroupInfo, showMenu: MutableState<Boolean>) {
|
||||
// revert from this:
|
||||
DefaultDropdownMenu(showMenu) {
|
||||
if (member.canBeRemoved(groupInfo)) {
|
||||
ItemAction(stringResource(MR.strings.remove_member_button), painterResource(MR.images.ic_delete), color = MaterialTheme.colors.error, onClick = {
|
||||
removeMemberAlert(rhId, groupInfo, member)
|
||||
showMenu.value = false
|
||||
})
|
||||
if (groupInfo.membership.memberRole >= GroupMemberRole.Admin) {
|
||||
val canBlockForAll = member.canBlockForAll(groupInfo)
|
||||
val canRemove = member.canBeRemoved(groupInfo)
|
||||
if (canBlockForAll || canRemove) {
|
||||
DefaultDropdownMenu(showMenu) {
|
||||
if (canBlockForAll) {
|
||||
if (member.blockedByAdmin) {
|
||||
ItemAction(stringResource(MR.strings.unblock_for_all), painterResource(MR.images.ic_do_not_touch), onClick = {
|
||||
unblockForAllAlert(rhId, groupInfo, member)
|
||||
showMenu.value = false
|
||||
})
|
||||
} else {
|
||||
ItemAction(stringResource(MR.strings.block_for_all), painterResource(MR.images.ic_back_hand), color = MaterialTheme.colors.error, onClick = {
|
||||
blockForAllAlert(rhId, groupInfo, member)
|
||||
showMenu.value = false
|
||||
})
|
||||
}
|
||||
}
|
||||
if (canRemove) {
|
||||
ItemAction(stringResource(MR.strings.remove_member_button), painterResource(MR.images.ic_delete), color = MaterialTheme.colors.error, onClick = {
|
||||
removeMemberAlert(rhId, groupInfo, member)
|
||||
showMenu.value = false
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
if (member.memberSettings.showMessages) {
|
||||
ItemAction(stringResource(MR.strings.block_member_button), painterResource(MR.images.ic_back_hand), color = MaterialTheme.colors.error, onClick = {
|
||||
blockMemberAlert(rhId, groupInfo, member)
|
||||
showMenu.value = false
|
||||
})
|
||||
} else {
|
||||
ItemAction(stringResource(MR.strings.unblock_member_button), painterResource(MR.images.ic_do_not_touch), onClick = {
|
||||
unblockMemberAlert(rhId, groupInfo, member)
|
||||
showMenu.value = false
|
||||
})
|
||||
} else if (!member.blockedByAdmin) {
|
||||
DefaultDropdownMenu(showMenu) {
|
||||
if (member.memberSettings.showMessages) {
|
||||
ItemAction(stringResource(MR.strings.block_member_button), painterResource(MR.images.ic_back_hand), color = MaterialTheme.colors.error, onClick = {
|
||||
blockMemberAlert(rhId, groupInfo, member)
|
||||
showMenu.value = false
|
||||
})
|
||||
} else {
|
||||
ItemAction(stringResource(MR.strings.unblock_member_button), painterResource(MR.images.ic_do_not_touch), onClick = {
|
||||
unblockMemberAlert(rhId, groupInfo, member)
|
||||
showMenu.value = false
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
// revert to this: vvv
|
||||
// if (groupInfo.membership.memberRole >= GroupMemberRole.Admin) {
|
||||
// val canBlockForAll = member.canBlockForAll(groupInfo)
|
||||
// val canRemove = member.canBeRemoved(groupInfo)
|
||||
// if (canBlockForAll || canRemove) {
|
||||
// DefaultDropdownMenu(showMenu) {
|
||||
// if (canBlockForAll) {
|
||||
// if (member.blockedByAdmin) {
|
||||
// ItemAction(stringResource(MR.strings.unblock_for_all), painterResource(MR.images.ic_do_not_touch), onClick = {
|
||||
// unblockForAllAlert(rhId, groupInfo, member)
|
||||
// showMenu.value = false
|
||||
// })
|
||||
// } else {
|
||||
// ItemAction(stringResource(MR.strings.block_for_all), painterResource(MR.images.ic_back_hand), color = MaterialTheme.colors.error, onClick = {
|
||||
// blockForAllAlert(rhId, groupInfo, member)
|
||||
// showMenu.value = false
|
||||
// })
|
||||
// }
|
||||
// }
|
||||
// if (canRemove) {
|
||||
// ItemAction(stringResource(MR.strings.remove_member_button), painterResource(MR.images.ic_delete), color = MaterialTheme.colors.error, onClick = {
|
||||
// removeMemberAlert(rhId, groupInfo, member)
|
||||
// showMenu.value = false
|
||||
// })
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// } else if (!member.blockedByAdmin) {
|
||||
// DefaultDropdownMenu(showMenu) {
|
||||
// if (member.memberSettings.showMessages) {
|
||||
// ItemAction(stringResource(MR.strings.block_member_button), painterResource(MR.images.ic_back_hand), color = MaterialTheme.colors.error, onClick = {
|
||||
// blockMemberAlert(rhId, groupInfo, member)
|
||||
// showMenu.value = false
|
||||
// })
|
||||
// } else {
|
||||
// ItemAction(stringResource(MR.strings.unblock_member_button), painterResource(MR.images.ic_do_not_touch), onClick = {
|
||||
// unblockMemberAlert(rhId, groupInfo, member)
|
||||
// showMenu.value = false
|
||||
// })
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// ^^^
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
@ -387,25 +387,11 @@ fun GroupMemberInfoLayout(
|
||||
}
|
||||
}
|
||||
|
||||
// revert from this:
|
||||
SectionDividerSpaced(maxBottomPadding = false)
|
||||
SectionView {
|
||||
if (member.memberSettings.showMessages) {
|
||||
BlockMemberButton(blockMember)
|
||||
} else {
|
||||
UnblockMemberButton(unblockMember)
|
||||
}
|
||||
if (member.canBeRemoved(groupInfo)) {
|
||||
RemoveMemberButton(removeMember)
|
||||
}
|
||||
if (groupInfo.membership.memberRole >= GroupMemberRole.Admin) {
|
||||
AdminDestructiveSection()
|
||||
} else {
|
||||
NonAdminBlockSection()
|
||||
}
|
||||
// revert to this: vvv
|
||||
// if (groupInfo.membership.memberRole >= GroupMemberRole.Admin) {
|
||||
// AdminDestructiveSection()
|
||||
// } else {
|
||||
// NonAdminBlockSection()
|
||||
// }
|
||||
// ^^^
|
||||
|
||||
if (developerTools) {
|
||||
SectionDividerSpaced()
|
||||
|
@ -29,6 +29,7 @@ import chat.simplex.common.views.onboarding.WhatsNewView
|
||||
import chat.simplex.common.views.onboarding.shouldShowWhatsNew
|
||||
import chat.simplex.common.views.usersettings.SettingsView
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.views.call.Call
|
||||
import chat.simplex.common.views.newchat.*
|
||||
import chat.simplex.res.MR
|
||||
import kotlinx.coroutines.*
|
||||
@ -121,7 +122,12 @@ fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerf
|
||||
}
|
||||
}
|
||||
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
|
||||
tryOrShowError("NewChatSheet", error = {}) {
|
||||
NewChatSheet(chatModel, newChatSheetState, stopped, hideNewChatSheet)
|
||||
@ -314,7 +320,7 @@ private fun ToggleFilterDisabledButton() {
|
||||
}
|
||||
|
||||
@Composable
|
||||
expect fun DesktopActiveCallOverlayLayout(newChatSheetState: MutableStateFlow<AnimatedViewState>)
|
||||
expect fun ActiveCallInteractiveArea(call: Call, newChatSheetState: MutableStateFlow<AnimatedViewState>)
|
||||
|
||||
fun connectIfOpenedViaUri(rhId: Long?, uri: URI, chatModel: ChatModel) {
|
||||
Log.d(TAG, "connectIfOpenedViaUri: opened via link")
|
||||
|
@ -85,7 +85,7 @@ private fun ShareListToolbar(chatModel: ChatModel, userPickerState: MutableState
|
||||
userPickerState.value = AnimatedViewState.VISIBLE
|
||||
}
|
||||
}
|
||||
else -> NavigationButtonBack { chatModel.sharedContent.value = null }
|
||||
else -> NavigationButtonBack(onButtonClicked = { chatModel.sharedContent.value = null })
|
||||
}
|
||||
}
|
||||
if (chatModel.chats.size >= 8) {
|
||||
@ -143,7 +143,7 @@ private fun ShareList(chatModel: ChatModel, search: String) {
|
||||
}
|
||||
val chats by remember(search) {
|
||||
derivedStateOf {
|
||||
if (search.isEmpty()) chatModel.chats.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(
|
||||
|
@ -18,7 +18,7 @@ import chat.simplex.res.MR
|
||||
import dev.icerock.moko.resources.compose.painterResource
|
||||
|
||||
@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(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
@ -35,7 +35,7 @@ fun CloseSheetBar(close: (() -> Unit)?, showClose: Boolean = true, endButtons: @
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
if (showClose) {
|
||||
NavigationButtonBack(onButtonClicked = close)
|
||||
NavigationButtonBack(tintColor = tintColor, onButtonClicked = close)
|
||||
} else {
|
||||
Spacer(Modifier)
|
||||
}
|
||||
|
@ -44,10 +44,10 @@ fun DefaultTopAppBar(
|
||||
}
|
||||
|
||||
@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) {
|
||||
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) {
|
||||
Column(if (background != MaterialTheme.colors.background) Modifier.background(background) else Modifier.themedBackground()) {
|
||||
CloseSheetBar(close, showClose, endButtons)
|
||||
CloseSheetBar(close, showClose, endButtons = endButtons)
|
||||
Box(modifier) { content() }
|
||||
}
|
||||
}
|
||||
|
@ -411,7 +411,7 @@ expect fun ByteArray.toBase64StringForPassphrase(): String
|
||||
|
||||
// Android's default implementation that was used before multiplatform, adds non-needed characters at the end of string
|
||||
// which can be bypassed by:
|
||||
// fun String.toByteArrayFromBase64(): ByteArray = Base64.getDecoder().decode(this.trimEnd { it == '\n' || it == ' ' })
|
||||
// fun String.toByteArrayFromBase64(): ByteArray = Base64.getMimeDecoder().decode(this.trimEnd { it == '\n' || it == ' ' })
|
||||
expect fun String.toByteArrayFromBase64ForPassphrase(): ByteArray
|
||||
|
||||
val LongRange.Companion.saver
|
||||
|
@ -177,6 +177,9 @@
|
||||
<!-- SimpleX Chat foreground Service -->
|
||||
<string name="simplex_service_notification_title">SimpleX Chat service</string>
|
||||
<string name="simplex_service_notification_text">Receiving messages…</string>
|
||||
<string name="call_service_notification_audio_call">Audio call</string>
|
||||
<string name="call_service_notification_video_call">Video call</string>
|
||||
<string name="call_service_notification_end_call">End call</string>
|
||||
<string name="hide_notification">Hide</string>
|
||||
|
||||
<!-- Notification channels -->
|
||||
@ -801,6 +804,10 @@
|
||||
<string name="callstate_connected">connected</string>
|
||||
<string name="callstate_ended">ended</string>
|
||||
|
||||
<!-- CallView -->
|
||||
<string name="unable_to_open_browser_title">Error opening browser</string>
|
||||
<string name="unable_to_open_browser_desc">The default web browser is required for calls. Please configure the default browser in the system, and share more information with the developers.</string>
|
||||
|
||||
<!-- SimpleXInfo -->
|
||||
<string name="next_generation_of_private_messaging">The next generation of private messaging</string>
|
||||
<string name="privacy_redefined">Privacy redefined</string>
|
||||
|
@ -8,6 +8,7 @@
|
||||
<body>
|
||||
<video
|
||||
id="remote-video-stream"
|
||||
class="inline"
|
||||
autoplay
|
||||
playsinline
|
||||
poster="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAQAAAAnOwc2AAAAEUlEQVR42mNk+M+AARiHsiAAcCIKAYwFoQ8AAAAASUVORK5CYII="
|
||||
@ -15,6 +16,7 @@
|
||||
></video>
|
||||
<video
|
||||
id="local-video-stream"
|
||||
class="inline"
|
||||
muted
|
||||
autoplay
|
||||
playsinline
|
||||
|
@ -5,14 +5,14 @@ body {
|
||||
background-color: black;
|
||||
}
|
||||
|
||||
#remote-video-stream {
|
||||
#remote-video-stream.inline {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
#local-video-stream {
|
||||
#local-video-stream.inline {
|
||||
position: absolute;
|
||||
width: 30%;
|
||||
max-width: 30%;
|
||||
@ -23,6 +23,20 @@ body {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
#remote-video-stream.fullscreen {
|
||||
position: absolute;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
#local-video-stream.fullscreen {
|
||||
position: absolute;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
*::-webkit-media-controls {
|
||||
display: none !important;
|
||||
-webkit-appearance: none !important;
|
||||
|
@ -11,6 +11,12 @@ var VideoCamera;
|
||||
VideoCamera["User"] = "user";
|
||||
VideoCamera["Environment"] = "environment";
|
||||
})(VideoCamera || (VideoCamera = {}));
|
||||
var LayoutType;
|
||||
(function (LayoutType) {
|
||||
LayoutType["Default"] = "default";
|
||||
LayoutType["LocalVideo"] = "localVideo";
|
||||
LayoutType["RemoteVideo"] = "remoteVideo";
|
||||
})(LayoutType || (LayoutType = {}));
|
||||
// for debugging
|
||||
// var sendMessageToNative = ({resp}: WVApiMessage) => console.log(JSON.stringify({command: resp}))
|
||||
var sendMessageToNative = (msg) => console.log(JSON.stringify(msg));
|
||||
@ -319,6 +325,10 @@ const processCommand = (function () {
|
||||
localizedDescription = command.description;
|
||||
resp = { type: "ok" };
|
||||
break;
|
||||
case "layout":
|
||||
changeLayout(command.layout);
|
||||
resp = { type: "ok" };
|
||||
break;
|
||||
case "end":
|
||||
endCall();
|
||||
resp = { type: "ok" };
|
||||
@ -607,6 +617,28 @@ function toggleMedia(s, media) {
|
||||
}
|
||||
return res;
|
||||
}
|
||||
function changeLayout(layout) {
|
||||
const local = document.getElementById("local-video-stream");
|
||||
const remote = document.getElementById("remote-video-stream");
|
||||
switch (layout) {
|
||||
case LayoutType.Default:
|
||||
local.className = "inline";
|
||||
remote.className = "inline";
|
||||
local.style.visibility = "visible";
|
||||
remote.style.visibility = "visible";
|
||||
break;
|
||||
case LayoutType.LocalVideo:
|
||||
local.className = "fullscreen";
|
||||
local.style.visibility = "visible";
|
||||
remote.style.visibility = "hidden";
|
||||
break;
|
||||
case LayoutType.RemoteVideo:
|
||||
remote.className = "fullscreen";
|
||||
local.style.visibility = "hidden";
|
||||
remote.style.visibility = "visible";
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Cryptography function - it is loaded both in the main window and in worker context (if the worker is used)
|
||||
function callCryptoFunction() {
|
||||
const initialPlainTextRequired = {
|
||||
|
@ -9,6 +9,7 @@
|
||||
<body>
|
||||
<video
|
||||
id="remote-video-stream"
|
||||
class="inline"
|
||||
autoplay
|
||||
playsinline
|
||||
poster="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAQAAAAnOwc2AAAAEUlEQVR42mNk+M+AARiHsiAAcCIKAYwFoQ8AAAAASUVORK5CYII="
|
||||
@ -16,6 +17,7 @@
|
||||
></video>
|
||||
<video
|
||||
id="local-video-stream"
|
||||
class="inline"
|
||||
muted
|
||||
autoplay
|
||||
playsinline
|
||||
|
@ -5,14 +5,14 @@ body {
|
||||
background-color: black;
|
||||
}
|
||||
|
||||
#remote-video-stream {
|
||||
#remote-video-stream.inline {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
#local-video-stream {
|
||||
#local-video-stream.inline {
|
||||
position: absolute;
|
||||
width: 20%;
|
||||
max-width: 20%;
|
||||
@ -23,6 +23,20 @@ body {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
#remote-video-stream.fullscreen {
|
||||
position: absolute;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
#local-video-stream.fullscreen {
|
||||
position: absolute;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
*::-webkit-media-controls {
|
||||
display: none !important;
|
||||
-webkit-appearance: none !important;
|
||||
|
@ -17,14 +17,14 @@ import javax.imageio.stream.MemoryCacheImageOutputStream
|
||||
import kotlin.math.sqrt
|
||||
|
||||
private fun errorBitmap(): ImageBitmap =
|
||||
ImageIO.read(ByteArrayInputStream(Base64.getDecoder().decode("iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAAXNSR0IArs4c6QAAAKVJREFUeF7t1kENACEUQ0FQhnVQ9lfGO+xggITQdvbMzArPey+8fa3tAfwAEdABZQspQStgBssEcgAIkSAJkiAJljtEgiRIgmUCSZAESZAESZAEyx0iQRIkwTKBJEiCv5fgvTd1wDmn7QAP4AeIgA4oW0gJWgEzWCZwbQ7gAA7ggLKFOIADOKBMIAeAEAmSIAmSYLlDJEiCJFgmkARJkARJ8N8S/ADTZUewBvnTOQAAAABJRU5ErkJggg=="))).toComposeImageBitmap()
|
||||
ImageIO.read(ByteArrayInputStream(Base64.getMimeDecoder().decode("iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAAXNSR0IArs4c6QAAAKVJREFUeF7t1kENACEUQ0FQhnVQ9lfGO+xggITQdvbMzArPey+8fa3tAfwAEdABZQspQStgBssEcgAIkSAJkiAJljtEgiRIgmUCSZAESZAESZAEyx0iQRIkwTKBJEiCv5fgvTd1wDmn7QAP4AeIgA4oW0gJWgEzWCZwbQ7gAA7ggLKFOIADOKBMIAeAEAmSIAmSYLlDJEiCJFgmkARJkARJ8N8S/ADTZUewBvnTOQAAAABJRU5ErkJggg=="))).toComposeImageBitmap()
|
||||
|
||||
actual fun base64ToBitmap(base64ImageString: String): ImageBitmap {
|
||||
val imageString = base64ImageString
|
||||
.removePrefix("data:image/png;base64,")
|
||||
.removePrefix("data:image/jpg;base64,")
|
||||
return try {
|
||||
ImageIO.read(ByteArrayInputStream(Base64.getDecoder().decode(imageString))).toComposeImageBitmap()
|
||||
ImageIO.read(ByteArrayInputStream(Base64.getMimeDecoder().decode(imageString))).toComposeImageBitmap()
|
||||
} catch (e: IOException) {
|
||||
Log.e(TAG, "base64ToBitmap error: $e")
|
||||
errorBitmap()
|
||||
@ -77,7 +77,7 @@ actual fun compressImageStr(bitmap: ImageBitmap): String {
|
||||
return try {
|
||||
val encoded = Base64.getEncoder().encodeToString(compressImageData(bitmap, usePng).toByteArray())
|
||||
"data:image/$ext;base64,$encoded"
|
||||
} catch (e: IOException) {
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "resizeImageToStrSize error: $e")
|
||||
throw e
|
||||
}
|
||||
|
@ -146,8 +146,21 @@ private fun SendStateUpdates() {
|
||||
@Composable
|
||||
fun WebRTCController(callCommand: SnapshotStateList<WCallCommand>, onResponse: (WVAPIMessage) -> Unit) {
|
||||
val uriHandler = LocalUriHandler.current
|
||||
val endCall = {
|
||||
val call = chatModel.activeCall.value
|
||||
if (call != null) withBGApi { chatModel.callManager.endCall(call) }
|
||||
}
|
||||
val server = remember {
|
||||
uriHandler.openUri("http://${SERVER_HOST}:$SERVER_PORT/simplex/call/")
|
||||
try {
|
||||
uriHandler.openUri("http://${SERVER_HOST}:$SERVER_PORT/simplex/call/")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Unable to open browser: ${e.stackTraceToString()}")
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(MR.strings.unable_to_open_browser_title),
|
||||
text = generalGetString(MR.strings.unable_to_open_browser_desc)
|
||||
)
|
||||
endCall()
|
||||
}
|
||||
startServer(onResponse)
|
||||
}
|
||||
fun processCommand(cmd: WCallCommand) {
|
||||
|
@ -3,7 +3,6 @@ package chat.simplex.common.views.chatlist
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.runtime.*
|
||||
@ -13,6 +12,7 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.common.views.call.Call
|
||||
import chat.simplex.common.views.call.CallMediaType
|
||||
import chat.simplex.common.views.chat.item.ItemAction
|
||||
import chat.simplex.common.views.helpers.*
|
||||
@ -22,10 +22,9 @@ import dev.icerock.moko.resources.compose.stringResource
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
||||
@Composable
|
||||
actual fun DesktopActiveCallOverlayLayout(newChatSheetState: MutableStateFlow<AnimatedViewState>) {
|
||||
val call = remember { chatModel.activeCall}.value
|
||||
// if (call?.callState == CallState.Connected && !newChatSheetState.collectAsState().value.isVisible()) {
|
||||
if (call != null && !newChatSheetState.collectAsState().value.isVisible()) {
|
||||
actual fun ActiveCallInteractiveArea(call: Call, newChatSheetState: MutableStateFlow<AnimatedViewState>) {
|
||||
// if (call.callState == CallState.Connected && !newChatSheetState.collectAsState().value.isVisible()) {
|
||||
if (!newChatSheetState.collectAsState().value.isVisible()) {
|
||||
val showMenu = remember { mutableStateOf(false) }
|
||||
val media = call.peerMedia ?: call.localMedia
|
||||
CompositionLocalProvider(
|
||||
|
@ -12,7 +12,7 @@ constraints: zip +disable-bzip2 +disable-zstd
|
||||
source-repository-package
|
||||
type: git
|
||||
location: https://github.com/simplex-chat/simplexmq.git
|
||||
tag: e64b6cba4b7e4107f78ae596ab2a6a28ef24ff78
|
||||
tag: caeeb2df9ccca29a6bb504886736502d081fba0e
|
||||
|
||||
source-repository-package
|
||||
type: git
|
||||
|
@ -108,3 +108,33 @@ Sending member builds messages history starting starting from requested/remember
|
||||
\***
|
||||
|
||||
Same XGrpMsgHistory protocol event could be sent by host to new members, after sending introductions.
|
||||
|
||||
---
|
||||
|
||||
Update 2024-02-12:
|
||||
|
||||
### Group "pings"
|
||||
|
||||
Alternatively to tracking unanswered messages counts per member, which is complex and in some cases as discussed above ineffective, group members could periodically send group wide pings indicating their active presence.
|
||||
|
||||
```haskell
|
||||
XGrpPing :: ChatMsgEvent 'Json
|
||||
```
|
||||
|
||||
Members track:
|
||||
|
||||
- inactive flag (as above - set on QUOTA errors as well)
|
||||
- last_snd_ts on group
|
||||
- last_rcv_ts on group member
|
||||
|
||||
Clients run a worker process for checking last_snd_ts in each of their groups, and send pings to groups on a periodic basis.
|
||||
|
||||
- part of cleanup manager or separate process?
|
||||
- on each worker step, for each group matching criteria to send ping, send ping with a random delay to reduce correlation between groups (spawn a separate thread with a random delay for each group)
|
||||
- criteria for sending ping: last_snd_ts earlier than group_ping_interval ago
|
||||
- configure group_ping_interval to, for example, 23 hours (so that if user opens app each day at same time client will match criteria to send pings daily)
|
||||
|
||||
Clients receiving pings:
|
||||
|
||||
- update last_rcv_ts
|
||||
- when sending a message to group, check only for timestamp difference (no unanswered snd msg count logic as above)
|
||||
|
@ -8,6 +8,7 @@
|
||||
<body>
|
||||
<video
|
||||
id="remote-video-stream"
|
||||
class="inline"
|
||||
autoplay
|
||||
playsinline
|
||||
poster="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAQAAAAnOwc2AAAAEUlEQVR42mNk+M+AARiHsiAAcCIKAYwFoQ8AAAAASUVORK5CYII="
|
||||
@ -15,6 +16,7 @@
|
||||
></video>
|
||||
<video
|
||||
id="local-video-stream"
|
||||
class="inline"
|
||||
muted
|
||||
autoplay
|
||||
playsinline
|
||||
|
@ -5,14 +5,14 @@ body {
|
||||
background-color: black;
|
||||
}
|
||||
|
||||
#remote-video-stream {
|
||||
#remote-video-stream.inline {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
#local-video-stream {
|
||||
#local-video-stream.inline {
|
||||
position: absolute;
|
||||
width: 30%;
|
||||
max-width: 30%;
|
||||
@ -23,6 +23,20 @@ body {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
#remote-video-stream.fullscreen {
|
||||
position: absolute;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
#local-video-stream.fullscreen {
|
||||
position: absolute;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
*::-webkit-media-controls {
|
||||
display: none !important;
|
||||
-webkit-appearance: none !important;
|
||||
|
@ -16,6 +16,7 @@ type WCallCommand =
|
||||
| WCEnableMedia
|
||||
| WCToggleCamera
|
||||
| WCDescription
|
||||
| WCLayout
|
||||
| WCEndCall
|
||||
|
||||
type WCallResponse =
|
||||
@ -31,7 +32,7 @@ type WCallResponse =
|
||||
| WRError
|
||||
| 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"
|
||||
|
||||
@ -45,6 +46,12 @@ enum VideoCamera {
|
||||
Environment = "environment",
|
||||
}
|
||||
|
||||
enum LayoutType {
|
||||
Default = "default",
|
||||
LocalVideo = "localVideo",
|
||||
RemoteVideo = "remoteVideo",
|
||||
}
|
||||
|
||||
interface IWCallCommand {
|
||||
type: WCallCommandTag
|
||||
}
|
||||
@ -115,6 +122,11 @@ interface WCDescription extends IWCallCommand {
|
||||
description: string
|
||||
}
|
||||
|
||||
interface WCLayout extends IWCallCommand {
|
||||
type: "layout"
|
||||
layout: LayoutType
|
||||
}
|
||||
|
||||
interface WRCapabilities extends IWCallResponse {
|
||||
type: "capabilities"
|
||||
capabilities: CallCapabilities
|
||||
@ -515,6 +527,10 @@ const processCommand = (function () {
|
||||
localizedDescription = command.description
|
||||
resp = {type: "ok"}
|
||||
break
|
||||
case "layout":
|
||||
changeLayout(command.layout)
|
||||
resp = {type: "ok"}
|
||||
break
|
||||
case "end":
|
||||
endCall()
|
||||
resp = {type: "ok"}
|
||||
@ -824,6 +840,29 @@ function toggleMedia(s: MediaStream, media: CallMediaType): boolean {
|
||||
return res
|
||||
}
|
||||
|
||||
function changeLayout(layout: LayoutType) {
|
||||
const local = document.getElementById("local-video-stream")!
|
||||
const remote = document.getElementById("remote-video-stream")!
|
||||
switch (layout) {
|
||||
case LayoutType.Default:
|
||||
local.className = "inline"
|
||||
remote.className = "inline"
|
||||
local.style.visibility = "visible"
|
||||
remote.style.visibility = "visible"
|
||||
break
|
||||
case LayoutType.LocalVideo:
|
||||
local.className = "fullscreen"
|
||||
local.style.visibility = "visible"
|
||||
remote.style.visibility = "hidden"
|
||||
break
|
||||
case LayoutType.RemoteVideo:
|
||||
remote.className = "fullscreen"
|
||||
local.style.visibility = "hidden"
|
||||
remote.style.visibility = "visible"
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
type TransformFrameFunc = (key: CryptoKey) => (frame: RTCEncodedVideoFrame, controller: TransformStreamDefaultController) => Promise<void>
|
||||
|
||||
interface CallCrypto {
|
||||
|
@ -9,6 +9,7 @@
|
||||
<body>
|
||||
<video
|
||||
id="remote-video-stream"
|
||||
class="inline"
|
||||
autoplay
|
||||
playsinline
|
||||
poster="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAQAAAAnOwc2AAAAEUlEQVR42mNk+M+AARiHsiAAcCIKAYwFoQ8AAAAASUVORK5CYII="
|
||||
@ -16,6 +17,7 @@
|
||||
></video>
|
||||
<video
|
||||
id="local-video-stream"
|
||||
class="inline"
|
||||
muted
|
||||
autoplay
|
||||
playsinline
|
||||
|
@ -5,14 +5,14 @@ body {
|
||||
background-color: black;
|
||||
}
|
||||
|
||||
#remote-video-stream {
|
||||
#remote-video-stream.inline {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
#local-video-stream {
|
||||
#local-video-stream.inline {
|
||||
position: absolute;
|
||||
width: 20%;
|
||||
max-width: 20%;
|
||||
@ -23,6 +23,20 @@ body {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
#remote-video-stream.fullscreen {
|
||||
position: absolute;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
#local-video-stream.fullscreen {
|
||||
position: absolute;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
*::-webkit-media-controls {
|
||||
display: none !important;
|
||||
-webkit-appearance: none !important;
|
||||
|
@ -103,7 +103,7 @@ build() {
|
||||
|
||||
for arch in $arches; do
|
||||
|
||||
tag_full="$(git tag --points-at HEAD)"
|
||||
tag_full="$(git tag --points-at HEAD | head -n1)"
|
||||
tag_version="${tag_full%%-*}"
|
||||
|
||||
if [ "$arch" = "armv7a" ] && [ -n "$tag_full" ] ; then
|
||||
|
@ -1,5 +1,5 @@
|
||||
{
|
||||
"https://github.com/simplex-chat/simplexmq.git"."e64b6cba4b7e4107f78ae596ab2a6a28ef24ff78" = "0fxgklq65bh2f4kx36vjicdxqmi88m91xs601hm81v5pn6kk0ppd";
|
||||
"https://github.com/simplex-chat/simplexmq.git"."caeeb2df9ccca29a6bb504886736502d081fba0e" = "187avx8h014fhik76qv1l0nifv6db6nrg9kjk2azqia21n4s2m38";
|
||||
"https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38";
|
||||
"https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d";
|
||||
"https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl";
|
||||
|
@ -102,6 +102,7 @@ import Simplex.Messaging.Encoding.String
|
||||
import Simplex.Messaging.Parsers (base64P)
|
||||
import Simplex.Messaging.Protocol (AProtoServerWithAuth (..), AProtocolType (..), EntityId, ErrorType (..), MsgBody, MsgFlags (..), NtfServer, ProtoServerWithAuth, ProtocolTypeI, SProtocolType (..), SubscriptionMode (..), UserProtocol, userProtocol)
|
||||
import qualified Simplex.Messaging.Protocol as SMP
|
||||
import Simplex.Messaging.ServiceScheme (ServiceScheme (..))
|
||||
import qualified Simplex.Messaging.TMap as TM
|
||||
import Simplex.Messaging.Transport.Client (defaultSocksProxy)
|
||||
import Simplex.Messaging.Util
|
||||
@ -171,7 +172,10 @@ _defaultSMPServers =
|
||||
]
|
||||
|
||||
_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 = 261120 * 2 -- auto-receive on mobiles
|
||||
@ -600,6 +604,7 @@ processChatCommand' vr = \case
|
||||
pure $ CRArchiveImported fileErrs
|
||||
APIDeleteStorage -> withStoreChanged deleteStorage
|
||||
APIStorageEncryption cfg -> withStoreChanged $ sqlCipherExport cfg
|
||||
TestStorageEncryption key -> withStoreChanged $ sqlCipherTestKey key
|
||||
ExecChatStoreSQL query -> CRSQLResult <$> withStore' (`execSQL` query)
|
||||
ExecAgentStoreSQL query -> CRSQLResult <$> withAgent (`execAgentStoreSQL` query)
|
||||
SlowSQLQueries -> do
|
||||
@ -1235,9 +1240,8 @@ processChatCommand' vr = \case
|
||||
ok user
|
||||
SetUserProtoServers serversConfig -> withUser $ \User {userId} ->
|
||||
processChatCommand $ APISetUserProtoServers userId serversConfig
|
||||
APITestProtoServer userId srv@(AProtoServerWithAuth p server) -> withUserId userId $ \user ->
|
||||
withServerProtocol p $
|
||||
CRServerTestResult user srv <$> withAgent (\a -> testProtocolServer a (aUserId user) server)
|
||||
APITestProtoServer userId srv@(AProtoServerWithAuth _ server) -> withUserId userId $ \user ->
|
||||
CRServerTestResult user srv <$> withAgent (\a -> testProtocolServer a (aUserId user) server)
|
||||
TestProtoServer srv -> withUser $ \User {userId} ->
|
||||
processChatCommand $ APITestProtoServer userId srv
|
||||
APISetChatItemTTL userId newTTL_ -> withUserId userId $ \user ->
|
||||
@ -2454,7 +2458,7 @@ processChatCommand' vr = \case
|
||||
where
|
||||
cReqSchemas :: (ConnReqInvitation, ConnReqInvitation)
|
||||
cReqSchemas =
|
||||
( CRInvitationUri crData {crScheme = CRSSimplex} e2e,
|
||||
( CRInvitationUri crData {crScheme = SSSimplex} e2e,
|
||||
CRInvitationUri crData {crScheme = simplexChat} e2e
|
||||
)
|
||||
connectPlan user (ACR SCMContact (CRContactUri crData)) = do
|
||||
@ -2499,7 +2503,7 @@ processChatCommand' vr = \case
|
||||
where
|
||||
cReqSchemas :: (ConnReqContact, ConnReqContact)
|
||||
cReqSchemas =
|
||||
( CRContactUri crData {crScheme = CRSSimplex},
|
||||
( CRContactUri crData {crScheme = SSSimplex},
|
||||
CRContactUri crData {crScheme = simplexChat}
|
||||
)
|
||||
cReqHashes :: (ConnReqUriHash, ConnReqUriHash)
|
||||
@ -6507,6 +6511,7 @@ chatCommandP =
|
||||
"/db encrypt " *> (APIStorageEncryption . dbEncryptionConfig "" <$> dbKeyP),
|
||||
"/db key " *> (APIStorageEncryption <$> (dbEncryptionConfig <$> dbKeyP <* A.space <*> dbKeyP)),
|
||||
"/db decrypt " *> (APIStorageEncryption . (`dbEncryptionConfig` "") <$> dbKeyP),
|
||||
"/db test key " *> (TestStorageEncryption <$> dbKeyP),
|
||||
"/sql chat " *> (ExecChatStoreSQL <$> textP),
|
||||
"/sql agent " *> (ExecAgentStoreSQL <$> textP),
|
||||
"/sql slow" $> SlowSQLQueries,
|
||||
@ -6564,6 +6569,7 @@ chatCommandP =
|
||||
"/_server test " *> (APITestProtoServer <$> A.decimal <* A.space <*> strP),
|
||||
"/smp test " *> (TestProtoServer . AProtoServerWithAuth SPSMP <$> strP),
|
||||
"/xftp test " *> (TestProtoServer . AProtoServerWithAuth SPXFTP <$> strP),
|
||||
"/ntf test " *> (TestProtoServer . AProtoServerWithAuth SPNTF <$> strP),
|
||||
"/_servers " *> (APISetUserProtoServers <$> A.decimal <* A.space <*> srvCfgP),
|
||||
"/smp " *> (SetUserProtoServers . APSC SPSMP . ProtoServersConfig . map toServerCfg <$> protocolServersP),
|
||||
"/smp default" $> SetUserProtoServers (APSC SPSMP $ ProtoServersConfig []),
|
||||
|
@ -9,6 +9,7 @@ module Simplex.Chat.Archive
|
||||
importArchive,
|
||||
deleteStorage,
|
||||
sqlCipherExport,
|
||||
sqlCipherTestKey,
|
||||
archiveFilesFolder,
|
||||
)
|
||||
where
|
||||
@ -20,6 +21,7 @@ import Control.Monad.Reader
|
||||
import qualified Data.ByteArray as BA
|
||||
import Data.Functor (($>))
|
||||
import Data.Maybe (fromMaybe)
|
||||
import Data.Text (Text)
|
||||
import qualified Data.Text as T
|
||||
import qualified Database.SQLite3 as SQL
|
||||
import Simplex.Chat.Controller
|
||||
@ -147,19 +149,8 @@ sqlCipherExport DBEncryptionConfig {currentKey = DBEncryptionKey key, newKey = D
|
||||
atomically $ writeTVar dbKey $ storeKey key' (fromMaybe False keepKey)
|
||||
export f = do
|
||||
withDB f (`SQL.exec` exportSQL) DBErrorExport
|
||||
withDB (exported f) (`SQL.exec` testSQL) DBErrorOpen
|
||||
withDB (exported f) (`SQL.exec` testSQL key') DBErrorOpen
|
||||
where
|
||||
withDB f' a err =
|
||||
liftIO (bracket (SQL.open $ T.pack f') SQL.close a $> Nothing)
|
||||
`catch` checkSQLError
|
||||
`catch` (\(e :: SomeException) -> sqliteError' e)
|
||||
>>= mapM_ (throwDBError . err)
|
||||
where
|
||||
checkSQLError e = case SQL.sqlError e of
|
||||
SQL.ErrorNotADatabase -> pure $ Just SQLiteErrorNotADatabase
|
||||
_ -> sqliteError' e
|
||||
sqliteError' :: Show e => e -> m (Maybe SQLiteError)
|
||||
sqliteError' = pure . Just . SQLiteError . show
|
||||
exportSQL =
|
||||
T.unlines $
|
||||
keySQL key
|
||||
@ -167,14 +158,38 @@ sqlCipherExport DBEncryptionConfig {currentKey = DBEncryptionKey key, newKey = D
|
||||
"SELECT sqlcipher_export('exported');",
|
||||
"DETACH DATABASE exported;"
|
||||
]
|
||||
testSQL =
|
||||
T.unlines $
|
||||
keySQL key'
|
||||
<> [ "PRAGMA foreign_keys = ON;",
|
||||
"PRAGMA secure_delete = ON;",
|
||||
"SELECT count(*) FROM sqlite_master;"
|
||||
]
|
||||
keySQL k = ["PRAGMA key = " <> keyString k <> ";" | not (BA.null k)]
|
||||
|
||||
withDB :: forall a m. ChatMonad m => FilePath -> (SQL.Database -> IO a) -> (SQLiteError -> DatabaseError) -> m ()
|
||||
withDB f' a err =
|
||||
liftIO (bracket (SQL.open $ T.pack f') SQL.close a $> Nothing)
|
||||
`catch` checkSQLError
|
||||
`catch` (\(e :: SomeException) -> sqliteError' e)
|
||||
>>= mapM_ (throwDBError . err)
|
||||
where
|
||||
checkSQLError e = case SQL.sqlError e of
|
||||
SQL.ErrorNotADatabase -> pure $ Just SQLiteErrorNotADatabase
|
||||
_ -> sqliteError' e
|
||||
sqliteError' :: Show e => e -> m (Maybe SQLiteError)
|
||||
sqliteError' = pure . Just . SQLiteError . show
|
||||
|
||||
testSQL :: BA.ScrubbedBytes -> Text
|
||||
testSQL k =
|
||||
T.unlines $
|
||||
keySQL k
|
||||
<> [ "PRAGMA foreign_keys = ON;",
|
||||
"PRAGMA secure_delete = ON;",
|
||||
"SELECT count(*) FROM sqlite_master;"
|
||||
]
|
||||
|
||||
keySQL :: BA.ScrubbedBytes -> [Text]
|
||||
keySQL k = ["PRAGMA key = " <> keyString k <> ";" | not (BA.null k)]
|
||||
|
||||
sqlCipherTestKey :: forall m. ChatMonad m => DBEncryptionKey -> m ()
|
||||
sqlCipherTestKey (DBEncryptionKey key) = do
|
||||
fs <- storageFiles
|
||||
testKey `withDBs` fs
|
||||
where
|
||||
testKey f = withDB f (`SQL.exec` testSQL key) DBErrorOpen
|
||||
|
||||
withDBs :: Monad m => (FilePath -> m b) -> StorageFiles -> m b
|
||||
action `withDBs` StorageFiles {chatStore, agentStore} = action (dbFilePath chatStore) >> action (dbFilePath agentStore)
|
||||
|
@ -250,6 +250,7 @@ data ChatCommand
|
||||
| APIImportArchive ArchiveConfig
|
||||
| APIDeleteStorage
|
||||
| APIStorageEncryption DBEncryptionConfig
|
||||
| TestStorageEncryption DBEncryptionKey
|
||||
| ExecChatStoreSQL Text
|
||||
| ExecAgentStoreSQL Text
|
||||
| SlowSQLQueries
|
||||
@ -672,7 +673,7 @@ data ChatResponse
|
||||
| CRUserContactLinkSubscribed -- TODO delete
|
||||
| CRUserContactLinkSubError {chatError :: ChatError} -- TODO delete
|
||||
| CRNtfTokenStatus {status :: NtfTknStatus}
|
||||
| CRNtfToken {token :: DeviceToken, status :: NtfTknStatus, ntfMode :: NotificationsMode}
|
||||
| CRNtfToken {token :: DeviceToken, status :: NtfTknStatus, ntfMode :: NotificationsMode, ntfServer :: NtfServer}
|
||||
| CRNtfMessages {user_ :: Maybe User, connEntity_ :: Maybe ConnectionEntity, msgTs :: Maybe UTCTime, ntfMessages :: [NtfMsgInfo]}
|
||||
| CRNtfMessage {user :: User, connEntity :: ConnectionEntity, ntfMessage :: NtfMsgInfo}
|
||||
| CRContactConnectionDeleted {user :: User, connection :: PendingContactConnection}
|
||||
@ -948,8 +949,8 @@ data NtfMsgInfo = NtfMsgInfo {msgId :: Text, msgTs :: UTCTime}
|
||||
ntfMsgInfo :: SMPMsgMeta -> NtfMsgInfo
|
||||
ntfMsgInfo SMPMsgMeta {msgId, msgTs} = NtfMsgInfo {msgId = decodeLatin1 $ strEncode msgId, msgTs = systemToUTCTime msgTs}
|
||||
|
||||
crNtfToken :: (DeviceToken, NtfTknStatus, NotificationsMode) -> ChatResponse
|
||||
crNtfToken (token, status, ntfMode) = CRNtfToken {token, status, ntfMode}
|
||||
crNtfToken :: (DeviceToken, NtfTknStatus, NotificationsMode, NtfServer) -> ChatResponse
|
||||
crNtfToken (token, status, ntfMode, ntfServer) = CRNtfToken {token, status, ntfMode, ntfServer}
|
||||
|
||||
data SwitchProgress = SwitchProgress
|
||||
{ queueDirection :: QueueDirection,
|
||||
|
@ -30,10 +30,11 @@ import qualified Data.Text as T
|
||||
import Data.Text.Encoding (encodeUtf8)
|
||||
import Simplex.Chat.Types
|
||||
import Simplex.Chat.Types.Util
|
||||
import Simplex.Messaging.Agent.Protocol (AConnectionRequestUri (..), ConnReqScheme (..), ConnReqUriData (..), ConnectionRequestUri (..), SMPQueue (..))
|
||||
import Simplex.Messaging.Agent.Protocol (AConnectionRequestUri (..), ConnReqUriData (..), ConnectionRequestUri (..), SMPQueue (..))
|
||||
import Simplex.Messaging.Encoding.String
|
||||
import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, fstToLower, sumTypeJSON)
|
||||
import Simplex.Messaging.Protocol (ProtocolServer (..))
|
||||
import Simplex.Messaging.ServiceScheme (ServiceScheme (..))
|
||||
import Simplex.Messaging.Util (safeDecodeUtf8)
|
||||
import System.Console.ANSI.Types
|
||||
import qualified Text.Email.Validate as Email
|
||||
@ -231,10 +232,10 @@ markdownP = mconcat <$> A.many' fragmentP
|
||||
simplexUriFormat :: AConnectionRequestUri -> Format
|
||||
simplexUriFormat = \case
|
||||
ACR _ (CRContactUri crData) ->
|
||||
let uri = safeDecodeUtf8 . strEncode $ CRContactUri crData {crScheme = CRSSimplex}
|
||||
let uri = safeDecodeUtf8 . strEncode $ CRContactUri crData {crScheme = SSSimplex}
|
||||
in SimplexLink (linkType' crData) uri $ uriHosts crData
|
||||
ACR _ (CRInvitationUri crData e2e) ->
|
||||
let uri = safeDecodeUtf8 . strEncode $ CRInvitationUri crData {crScheme = CRSSimplex} e2e
|
||||
let uri = safeDecodeUtf8 . strEncode $ CRInvitationUri crData {crScheme = SSSimplex} e2e
|
||||
in SimplexLink XLInvitation uri $ uriHosts crData
|
||||
where
|
||||
uriHosts ConnReqUriData {crSmpQueues} = L.map (safeDecodeUtf8 . strEncode) $ sconcat $ L.map (host . qServer) crSmpQueues
|
||||
|
@ -236,11 +236,18 @@ deleteContact db user@User {userId} Contact {contactId, localDisplayName, active
|
||||
if isNothing ctMember
|
||||
then do
|
||||
deleteContactProfile_ db userId contactId
|
||||
DB.execute db "DELETE FROM display_names WHERE user_id = ? AND local_display_name = ?" (userId, localDisplayName)
|
||||
DB.execute
|
||||
db
|
||||
[sql|
|
||||
DELETE FROM display_names
|
||||
WHERE user_id = ? AND local_display_name = ?
|
||||
AND local_display_name NOT IN (SELECT local_display_name FROM users)
|
||||
|]
|
||||
(userId, localDisplayName)
|
||||
else do
|
||||
currentTs <- getCurrentTime
|
||||
DB.execute db "UPDATE group_members SET contact_id = NULL, updated_at = ? WHERE user_id = ? AND contact_id = ?" (currentTs, userId, contactId)
|
||||
DB.execute db "DELETE FROM contacts WHERE user_id = ? AND contact_id = ?" (userId, contactId)
|
||||
DB.execute db "DELETE FROM contacts WHERE user_id = ? AND contact_id = ? AND is_user = 0" (userId, contactId)
|
||||
forM_ activeConn $ \Connection {customUserProfileId} ->
|
||||
forM_ customUserProfileId $ \profileId ->
|
||||
deleteUnusedIncognitoProfileById_ db user profileId
|
||||
@ -250,8 +257,15 @@ deleteContactWithoutGroups :: DB.Connection -> User -> Contact -> IO ()
|
||||
deleteContactWithoutGroups db user@User {userId} Contact {contactId, localDisplayName, activeConn} = do
|
||||
DB.execute db "DELETE FROM chat_items WHERE user_id = ? AND contact_id = ?" (userId, contactId)
|
||||
deleteContactProfile_ db userId contactId
|
||||
DB.execute db "DELETE FROM display_names WHERE user_id = ? AND local_display_name = ?" (userId, localDisplayName)
|
||||
DB.execute db "DELETE FROM contacts WHERE user_id = ? AND contact_id = ?" (userId, contactId)
|
||||
DB.execute
|
||||
db
|
||||
[sql|
|
||||
DELETE FROM display_names
|
||||
WHERE user_id = ? AND local_display_name = ?
|
||||
AND local_display_name NOT IN (SELECT local_display_name FROM users)
|
||||
|]
|
||||
(userId, localDisplayName)
|
||||
DB.execute db "DELETE FROM contacts WHERE user_id = ? AND contact_id = ? AND is_user = 0" (userId, contactId)
|
||||
forM_ activeConn $ \Connection {customUserProfileId} ->
|
||||
forM_ customUserProfileId $ \profileId ->
|
||||
deleteUnusedIncognitoProfileById_ db user profileId
|
||||
@ -259,7 +273,7 @@ deleteContactWithoutGroups db user@User {userId} Contact {contactId, localDispla
|
||||
setContactDeleted :: DB.Connection -> User -> Contact -> IO ()
|
||||
setContactDeleted db User {userId} Contact {contactId} = do
|
||||
currentTs <- getCurrentTime
|
||||
DB.execute db "UPDATE contacts SET deleted = 1, updated_at = ? WHERE user_id = ? AND contact_id = ?" (currentTs, userId, contactId)
|
||||
DB.execute db "UPDATE contacts SET deleted = 1, updated_at = ? WHERE user_id = ? AND contact_id = ? AND is_user = 0" (currentTs, userId, contactId)
|
||||
|
||||
getDeletedContacts :: DB.Connection -> User -> IO [Contact]
|
||||
getDeletedContacts db user@User {userId} = do
|
||||
@ -501,7 +515,14 @@ updateContactLDN_ db userId contactId displayName newName updatedAt = do
|
||||
db
|
||||
"UPDATE group_members SET local_display_name = ?, updated_at = ? WHERE user_id = ? AND contact_id = ?"
|
||||
(newName, updatedAt, userId, contactId)
|
||||
DB.execute db "DELETE FROM display_names WHERE local_display_name = ? AND user_id = ?" (displayName, userId)
|
||||
DB.execute
|
||||
db
|
||||
[sql|
|
||||
DELETE FROM display_names
|
||||
WHERE local_display_name = ? AND user_id = ?
|
||||
AND local_display_name NOT IN (SELECT local_display_name FROM users)
|
||||
|]
|
||||
(displayName, userId)
|
||||
|
||||
getContactByName :: DB.Connection -> User -> ContactName -> ExceptT StoreError IO Contact
|
||||
getContactByName db user localDisplayName = do
|
||||
@ -614,7 +635,14 @@ createOrUpdateContactRequest db user@User {userId} userContactLinkId invId (Vers
|
||||
WHERE user_id = ? AND contact_request_id = ?
|
||||
|]
|
||||
(invId, minV, maxV, ldn, currentTs, userId, cReqId)
|
||||
DB.execute db "DELETE FROM display_names WHERE local_display_name = ? AND user_id = ?" (oldLdn, userId)
|
||||
DB.execute
|
||||
db
|
||||
[sql|
|
||||
DELETE FROM display_names
|
||||
WHERE local_display_name = ? AND user_id = ?
|
||||
AND local_display_name NOT IN (SELECT local_display_name FROM users)
|
||||
|]
|
||||
(oldLdn, userId)
|
||||
where
|
||||
updateProfile currentTs =
|
||||
DB.execute
|
||||
@ -684,6 +712,7 @@ deleteContactRequest db User {userId} contactRequestId = do
|
||||
SELECT local_display_name FROM contact_requests
|
||||
WHERE user_id = ? AND contact_request_id = ?
|
||||
)
|
||||
AND local_display_name NOT IN (SELECT local_display_name FROM users)
|
||||
|]
|
||||
(userId, userId, contactRequestId)
|
||||
DB.execute db "DELETE FROM contact_requests WHERE user_id = ? AND contact_request_id = ?" (userId, contactRequestId)
|
||||
|
@ -225,6 +225,7 @@ deleteGroupLink db User {userId} GroupInfo {groupId} = do
|
||||
JOIN user_contact_links uc USING (user_contact_link_id)
|
||||
WHERE uc.user_id = ? AND uc.group_id = ?
|
||||
)
|
||||
AND local_display_name NOT IN (SELECT local_display_name FROM users)
|
||||
|]
|
||||
(userId, userId, groupId)
|
||||
DB.execute
|
||||
@ -586,7 +587,14 @@ deleteGroup :: DB.Connection -> User -> GroupInfo -> IO ()
|
||||
deleteGroup db user@User {userId} g@GroupInfo {groupId, localDisplayName} = do
|
||||
deleteGroupProfile_ db userId groupId
|
||||
DB.execute db "DELETE FROM groups WHERE user_id = ? AND group_id = ?" (userId, groupId)
|
||||
DB.execute db "DELETE FROM display_names WHERE user_id = ? AND local_display_name = ?" (userId, localDisplayName)
|
||||
DB.execute
|
||||
db
|
||||
[sql|
|
||||
DELETE FROM display_names
|
||||
WHERE user_id = ? AND local_display_name = ?
|
||||
AND local_display_name NOT IN (SELECT local_display_name FROM users)
|
||||
|]
|
||||
(userId, localDisplayName)
|
||||
forM_ (incognitoMembershipProfile g) $ deleteUnusedIncognitoProfileById_ db user . localProfileId
|
||||
|
||||
deleteGroupProfile_ :: DB.Connection -> UserId -> GroupId -> IO ()
|
||||
@ -1051,7 +1059,14 @@ cleanupMemberProfileAndName_ db User {userId} GroupMember {groupMemberId, member
|
||||
sameProfileMember :: (Maybe GroupMemberId) <- maybeFirstRow fromOnly $ DB.query db "SELECT group_member_id FROM group_members WHERE user_id = ? AND contact_profile_id = ? AND group_member_id != ? LIMIT 1" (userId, memberContactProfileId, groupMemberId)
|
||||
when (isNothing sameProfileMember) $ do
|
||||
DB.execute db "DELETE FROM contact_profiles WHERE user_id = ? AND contact_profile_id = ?" (userId, memberContactProfileId)
|
||||
DB.execute db "DELETE FROM display_names WHERE user_id = ? AND local_display_name = ?" (userId, localDisplayName)
|
||||
DB.execute
|
||||
db
|
||||
[sql|
|
||||
DELETE FROM display_names
|
||||
WHERE user_id = ? AND local_display_name = ?
|
||||
AND local_display_name NOT IN (SELECT local_display_name FROM users)
|
||||
|]
|
||||
(userId, localDisplayName)
|
||||
|
||||
deleteGroupMemberConnection :: DB.Connection -> User -> GroupMember -> IO ()
|
||||
deleteGroupMemberConnection db User {userId} GroupMember {groupMemberId} =
|
||||
@ -1361,7 +1376,14 @@ updateGroupProfile db User {userId} g@GroupInfo {groupId, localDisplayName, grou
|
||||
db
|
||||
"UPDATE groups SET local_display_name = ?, updated_at = ? WHERE user_id = ? AND group_id = ?"
|
||||
(ldn, currentTs, userId, groupId)
|
||||
DB.execute db "DELETE FROM display_names WHERE local_display_name = ? AND user_id = ?" (localDisplayName, userId)
|
||||
DB.execute
|
||||
db
|
||||
[sql|
|
||||
DELETE FROM display_names
|
||||
WHERE local_display_name = ? AND user_id = ?
|
||||
AND local_display_name NOT IN (SELECT local_display_name FROM users)
|
||||
|]
|
||||
(localDisplayName, userId)
|
||||
|
||||
getGroupInfo :: DB.Connection -> VersionRange -> User -> Int64 -> ExceptT StoreError IO GroupInfo
|
||||
getGroupInfo db vr User {userId, userContactId} groupId =
|
||||
@ -1464,7 +1486,7 @@ getMatchingContacts db user@User {userId} Contact {contactId, profile = LocalPro
|
||||
FROM contacts ct
|
||||
JOIN contact_profiles p ON ct.contact_profile_id = p.contact_profile_id
|
||||
WHERE ct.user_id = ? AND ct.contact_id != ?
|
||||
AND ct.contact_status = ? AND ct.deleted = 0
|
||||
AND ct.contact_status = ? AND ct.deleted = 0 AND ct.is_user = 0
|
||||
AND p.display_name = ? AND p.full_name = ?
|
||||
|]
|
||||
|
||||
@ -1502,7 +1524,7 @@ getMatchingMemberContacts db user@User {userId} GroupMember {memberProfile = Loc
|
||||
FROM contacts ct
|
||||
JOIN contact_profiles p ON ct.contact_profile_id = p.contact_profile_id
|
||||
WHERE ct.user_id = ?
|
||||
AND ct.contact_status = ? AND ct.deleted = 0
|
||||
AND ct.contact_status = ? AND ct.deleted = 0 AND ct.is_user = 0
|
||||
AND p.display_name = ? AND p.full_name = ?
|
||||
|]
|
||||
|
||||
@ -1656,7 +1678,7 @@ mergeContactRecords db user@User {userId} to@Contact {localDisplayName = keepLDN
|
||||
":updated_at" := currentTs
|
||||
]
|
||||
deleteContactProfile_ db userId fromContactId
|
||||
DB.execute db "DELETE FROM contacts WHERE contact_id = ? AND user_id = ?" (fromContactId, userId)
|
||||
DB.execute db "DELETE FROM contacts WHERE contact_id = ? AND user_id = ? AND is_user = 0" (fromContactId, userId)
|
||||
deleteUnusedDisplayName_ db userId fromLDN
|
||||
when (keepLDN /= toLDN && keepLDN == fromLDN) $
|
||||
DB.execute
|
||||
@ -2030,7 +2052,14 @@ updateMemberProfile db User {userId} m p'
|
||||
db
|
||||
"UPDATE group_members SET local_display_name = ?, updated_at = ? WHERE user_id = ? AND group_member_id = ?"
|
||||
(ldn, currentTs, userId, groupMemberId)
|
||||
DB.execute db "DELETE FROM display_names WHERE local_display_name = ? AND user_id = ?" (localDisplayName, userId)
|
||||
DB.execute
|
||||
db
|
||||
[sql|
|
||||
DELETE FROM display_names
|
||||
WHERE local_display_name = ? AND user_id = ?
|
||||
AND local_display_name NOT IN (SELECT local_display_name FROM users)
|
||||
|]
|
||||
(localDisplayName, userId)
|
||||
pure $ Right m {localDisplayName = ldn, memberProfile = profile}
|
||||
where
|
||||
GroupMember {groupMemberId, localDisplayName, memberProfile = LocalProfile {profileId, displayName, localAlias}} = m
|
||||
|
@ -388,6 +388,7 @@ deleteUserAddress db user@User {userId} = do
|
||||
JOIN user_contact_links uc USING (user_contact_link_id)
|
||||
WHERE uc.user_id = :user_id AND uc.local_display_name = '' AND uc.group_id IS NULL
|
||||
)
|
||||
AND local_display_name NOT IN (SELECT local_display_name FROM users)
|
||||
|]
|
||||
[":user_id" := userId]
|
||||
DB.executeNamed
|
||||
|
@ -283,7 +283,7 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe
|
||||
CRUserContactLinkSubError e -> ["user address error: " <> sShow e, "to delete your address: " <> highlight' "/da"]
|
||||
CRContactConnectionDeleted u PendingContactConnection {pccConnId} -> ttyUser u ["connection :" <> sShow pccConnId <> " deleted"]
|
||||
CRNtfTokenStatus status -> ["device token status: " <> plain (smpEncode status)]
|
||||
CRNtfToken _ status mode -> ["device token status: " <> plain (smpEncode status) <> ", notifications mode: " <> plain (strEncode mode)]
|
||||
CRNtfToken _ status mode srv -> ["device token status: " <> plain (smpEncode status) <> ", notifications mode: " <> plain (strEncode mode) <> ", server: " <> sShow srv]
|
||||
CRNtfMessages {} -> []
|
||||
CRNtfMessage {} -> []
|
||||
CRCurrentRemoteHost rhi_ ->
|
||||
|
@ -146,9 +146,9 @@ testAgentCfgV1 :: AgentConfig
|
||||
testAgentCfgV1 =
|
||||
testAgentCfg
|
||||
{ smpClientVRange = v1Range,
|
||||
smpAgentVRange = v1Range,
|
||||
e2eEncryptVRange = v1Range,
|
||||
smpCfg = (smpCfg testAgentCfg) {serverVRange = v1Range}
|
||||
smpAgentVRange = versionToRange 2, -- duplexHandshakeSMPAgentVersion,
|
||||
e2eEncryptVRange = versionToRange 2, -- kdfX3DHE2EEncryptVersion,
|
||||
smpCfg = (smpCfg testAgentCfg) {serverVRange = versionToRange 4} -- batchCmdsSMPVersion
|
||||
}
|
||||
|
||||
testCfgVPrev :: ChatConfig
|
||||
@ -166,7 +166,7 @@ testCfgV1 =
|
||||
}
|
||||
|
||||
prevRange :: VersionRange -> VersionRange
|
||||
prevRange vr = vr {maxVersion = maxVersion vr - 1}
|
||||
prevRange vr = vr {maxVersion = max (minVersion vr) (maxVersion vr - 1)}
|
||||
|
||||
v1Range :: VersionRange
|
||||
v1Range = mkVersionRange 1 1
|
||||
@ -384,7 +384,7 @@ serverCfg =
|
||||
logStatsStartTime = 0,
|
||||
serverStatsLogFile = "tests/smp-server-stats.daily.log",
|
||||
serverStatsBackupFile = Nothing,
|
||||
smpServerVRange = supportedSMPServerVRange,
|
||||
smpServerVRange = supportedServerSMPRelayVRange,
|
||||
transportConfig = defaultTransportServerConfig,
|
||||
smpHandshakeTimeout = 1000000,
|
||||
controlPort = Nothing
|
||||
@ -407,7 +407,7 @@ xftpServerConfig =
|
||||
storeLogFile = Just "tests/tmp/xftp-server-store.log",
|
||||
filesPath = xftpServerFiles,
|
||||
fileSizeQuota = Nothing,
|
||||
allowedChunkSizes = [kb 128, kb 256, mb 1, mb 4],
|
||||
allowedChunkSizes = [kb 64, kb 128, kb 256, mb 1, mb 4],
|
||||
allowNewFiles = True,
|
||||
newFileBasicAuth = Nothing,
|
||||
fileExpiration = Just defaultFileExpiration,
|
||||
|
@ -1124,6 +1124,10 @@ testDatabaseEncryption tmp = do
|
||||
testChatWorking alice bob
|
||||
alice ##> "/_stop"
|
||||
alice <## "chat stopped"
|
||||
alice ##> "/db test key wrongkey"
|
||||
alice <## "error opening database after encryption: wrong passphrase or invalid database file"
|
||||
alice ##> "/db test key mykey"
|
||||
alice <## "ok"
|
||||
alice ##> "/db key wrongkey nextkey"
|
||||
alice <## "error encrypting database: wrong passphrase or invalid database file"
|
||||
alice ##> "/db key mykey nextkey"
|
||||
|
@ -83,9 +83,9 @@ versionTestMatrix2 runTest = do
|
||||
it "prev" $ testChatCfg2 testCfgVPrev aliceProfile bobProfile runTest
|
||||
it "prev to curr" $ runTestCfg2 testCfg testCfgVPrev runTest
|
||||
it "curr to prev" $ runTestCfg2 testCfgVPrev testCfg runTest
|
||||
it "v1" $ testChatCfg2 testCfgV1 aliceProfile bobProfile runTest
|
||||
it "v1 to v2" $ runTestCfg2 testCfg testCfgV1 runTest
|
||||
it "v2 to v1" $ runTestCfg2 testCfgV1 testCfg runTest
|
||||
it "old (1st supported)" $ testChatCfg2 testCfgV1 aliceProfile bobProfile runTest
|
||||
it "old to curr" $ runTestCfg2 testCfg testCfgV1 runTest
|
||||
it "curr to old" $ runTestCfg2 testCfgV1 testCfg runTest
|
||||
|
||||
versionTestMatrix3 :: (HasCallStack => TestCC -> TestCC -> TestCC -> IO ()) -> SpecWith FilePath
|
||||
versionTestMatrix3 runTest = do
|
||||
|
@ -153,13 +153,13 @@ textWithUri = describe "text with Uri" do
|
||||
parseMarkdown "_https://simplex.chat" `shouldBe` "_https://simplex.chat"
|
||||
parseMarkdown "this is _https://simplex.chat" `shouldBe` "this is _https://simplex.chat"
|
||||
it "SimpleX links" do
|
||||
let inv = "/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%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 ("simplex:" <> inv) `shouldBe` simplexLink XLInvitation ("simplex:" <> inv) ["smp.simplex.im"] ("simplex:" <> inv)
|
||||
parseMarkdown ("https://example.com" <> inv) `shouldBe` simplexLink XLInvitation ("simplex:" <> inv) ["smp.simplex.im"] ("https://example.com" <> inv)
|
||||
let ct = "/contact#/?v=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)
|
||||
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)
|
||||
|
||||
email :: Text -> Markdown
|
||||
|
@ -15,6 +15,7 @@ import Simplex.Messaging.Agent.Protocol
|
||||
import qualified Simplex.Messaging.Crypto as C
|
||||
import Simplex.Messaging.Crypto.Ratchet
|
||||
import Simplex.Messaging.Protocol (supportedSMPClientVRange)
|
||||
import Simplex.Messaging.ServiceScheme
|
||||
import Simplex.Messaging.Version
|
||||
import Test.Hspec
|
||||
|
||||
@ -37,7 +38,7 @@ queue =
|
||||
connReqData :: ConnReqUriData
|
||||
connReqData =
|
||||
ConnReqUriData
|
||||
{ crScheme = CRSSimplex,
|
||||
{ crScheme = SSSimplex,
|
||||
crAgentVRange = mkVersionRange 1 1,
|
||||
crSmpQueues = [queue],
|
||||
crClientData = Nothing
|
||||
@ -191,7 +192,7 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do
|
||||
"{\"v\":\"1\",\"event\":\"x.msg.deleted\",\"params\":{}}"
|
||||
#==# XMsgDeleted
|
||||
it "x.file" $
|
||||
"{\"v\":\"1\",\"event\":\"x.file\",\"params\":{\"file\":{\"fileConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%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}
|
||||
it "x.file without file invitation" $
|
||||
"{\"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\"}}"
|
||||
#==# XFileAcpt "photo.jpg"
|
||||
it "x.file.acpt.inv" $
|
||||
"{\"v\":\"1\",\"event\":\"x.file.acpt.inv\",\"params\":{\"msgId\":\"AQIDBA==\",\"fileName\":\"photo.jpg\",\"fileConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%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"
|
||||
it "x.file.acpt.inv" $
|
||||
"{\"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\"}}}}}"
|
||||
==# XContact testProfile Nothing
|
||||
it "x.grp.inv" $
|
||||
"{\"v\":\"1\",\"event\":\"x.grp.inv\",\"params\":{\"groupInvitation\":{\"connRequest\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%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}
|
||||
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"}
|
||||
it "x.grp.acpt without incognito profile" $
|
||||
"{\"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\"}}}}}}"
|
||||
#==# XGrpMemIntro MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Nothing, profile = testProfile} (Just MemberRestrictions {restriction = MRSBlocked})
|
||||
it "x.grp.mem.inv" $
|
||||
"{\"v\":\"1\",\"event\":\"x.grp.mem.inv\",\"params\":{\"memberId\":\"AQIDBA==\",\"memberIntro\":{\"directConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%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}
|
||||
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}
|
||||
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}
|
||||
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}
|
||||
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\"}}}}}"
|
||||
@ -281,10 +282,10 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do
|
||||
"{\"v\":\"1\",\"event\":\"x.grp.del\",\"params\":{}}"
|
||||
==# XGrpDel
|
||||
it "x.grp.direct.inv" $
|
||||
"{\"v\":\"1\",\"event\":\"x.grp.direct.inv\",\"params\":{\"connReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%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")
|
||||
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
|
||||
-- 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\"}}}"
|
||||
|
Loading…
Reference in New Issue
Block a user