Compare commits

...

46 Commits

Author SHA1 Message Date
IC Rainbow 960010c243 add thread stats 2024-03-08 18:51:22 +02:00
Alexander Bondarenko 25214076d8 fix eventlog name 2024-03-05 16:54:59 +02:00
Alexander Bondarenko 3c8e277e2e ffi: make RTS options configurable by app 2024-03-05 16:41:24 +02:00
Alexander Bondarenko db2ccaa450 controller: add standalone upload limit (#3853)
* controller: add standalone upload limit

* use hard limit from simplexmq
2024-03-02 19:31:11 +00:00
spaced4ndy 4b7458b58f docs: PQ integration rfc (#3847) 2024-03-01 11:27:13 +04:00
spaced4ndy 05383477d9 core: wait for delivery to avoid race between connection deletion and sending service messages about entity deletion (#3849) 2024-03-01 11:26:54 +04:00
spaced4ndy c27973d202 core: restrict to delete user contact and display name (#3822) 2024-02-26 17:10:21 +04:00
spaced4ndy 51a2e09714 core: batch db operations for group leave and delete (#3807)
* core: batch db operations for group leave and delete

* remove comment

* batch delete files

* cleanup

* rename

* use new agent api

* refactor

* refactor, catch error

* refactor

* update simplexmq

---------

Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
2024-02-26 15:36:42 +04:00
spaced4ndy ec8ae9febe docs: inactive group members rfc (simplified) (#3803) 2024-02-26 15:05:25 +04:00
Evgeny Poberezkin e37654772f core: api to save/get app settings to migrate them as part of the database (#3824)
* rfc: migrate app settings as part of export/import/migration

* export/import app settings

* test, fix

* chat: store app settings in db (#3834)

* chat: store app settings in db

* add combining with app-defaults

* commit schema

* test with tweaked settings

* remove unused error

---------

Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>

* remove app settings from export/import

* test, more settings

---------

Co-authored-by: Alexander Bondarenko <486682+dpwiz@users.noreply.github.com>
2024-02-24 15:00:16 +00:00
sh b7709c59d3 docs: include update instructions (#3825) 2024-02-24 13:42:11 +00:00
Evgeny Poberezkin 395654098c core: do not mark store as changed after passphrase test (#3833)
* core: do not mark store as changed after passphrase test

* fix
2024-02-24 13:37:09 +00:00
Evgeny Poberezkin 2d643e8d29 rfc: amend PQ double ratchet RFC 2024-02-24 09:27:55 +00:00
Evgeny Poberezkin 4e9703f0ff ios: update library 2024-02-22 12:19:42 +00:00
spaced4ndy b0b249a56a Merge branch 'stable' 2024-02-22 12:11:23 +04:00
spaced4ndy 92c89632d4 core, ui: don't mark profile updated chat item as unread (#3830)
* core, ui: don't mark profile updated chat item as unread

* android
2024-02-21 21:54:52 +00:00
Alexander Bondarenko d54b453b49 controller: fix standalone using relative paths (#3831) 2024-02-21 21:54:03 +00:00
Evgeny Poberezkin e6e27db243 5.5.5: ios 200, android 185, desktop 31 2024-02-21 18:32:53 +00:00
Evgeny Poberezkin b629c22ee0 5.5.5.0, update simplexmq to 5.5.2.1 (fix performance degradation) 2024-02-21 14:26:46 +00:00
spaced4ndy f7d7f5461f core: check user record when deleting contact and display name (#3826)
* filter out on merge

* checl contact, ldn

* fix

* corrections

* fix

* refactor

* diff

* refactor2

* remove contact id from error

* Revert "remove contact id from error"

This reverts commit f58af3dcac.

* remove Maybe from error

---------

Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
2024-02-21 14:24:24 +00:00
spaced4ndy c544a636f6 core, ui: remove usage of inline files (send only xftp files) (#3823) 2024-02-20 13:56:31 +04:00
Evgeny Poberezkin 73de74d7e9 rfc: UX for database migration and other actions (#3810)
* rfc: UX for database migration

* update

* update
2024-02-19 12:20:12 +00:00
spaced4ndy 654a7885c3 core: read chat items with logical database errors as invalid (don't fail) (#3736) 2024-02-19 15:17:14 +04:00
Alexander Bondarenko daf67c0456 core: add direct xftp upload/download commands (#3781)
* chat: add direct xftp upload/download commands

* adapt to FileDescriptionURI record

* bump simplexmq

* add description uploading

* filter URIs by size

* cleanup

* add file meta to events

* remove focus

* auto-redirect when no URI fits

* send "upload complete" event with the original file id

* remove description upload command

* add index

* refactor

* update simplexmq

* Apply suggestions from code review

Co-authored-by: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com>

* fix /fc command for non-chat uploads

* fix

* rename (tests fail)

* num recipients

* update messages

* split "file complete" events for chats and standalone

* restore xftpSndFileRedirect

* remove unused store error

* add send/cancel test

* untangle standalone views

* fix confused id

* fix /fc and /fs

* resolve comments

* misc fixes

* bump simplexmq

* fix build

* handle redirect errors independently

* fix missing file status in tests

---------

Co-authored-by: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com>
Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
2024-02-19 10:21:32 +00:00
Evgeny Poberezkin e361bcf140 ios: update core library 2024-02-18 17:52:11 +00:00
sh 5de9087207 build-android.sh: fix tag detection (#3817) 2024-02-18 15:28:12 +00:00
Alexander Bondarenko 364b62320b controller: add db passphrase test command (#3788)
* controller: add passphrase test

* refactor

---------

Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
2024-02-18 13:28:24 +00:00
Evgeny Poberezkin d83a6b7133 core: ntf server test (#3819) 2024-02-18 12:12:38 +00:00
Evgeny Poberezkin cd21a74b83 Merge branch 'stable' 2024-02-18 00:05:49 +00:00
Evgeny Poberezkin e3df7945d5 core: update simplexmq (updated protocol, discontinue old versions) (#3818)
* core: update simplexmq (updated protocol, discontinue old versions)

* update nix
2024-02-17 16:29:45 +00:00
sh bb1620d7d2 docker: update and fix build (#3805) 2024-02-14 20:33:48 +00:00
Stanislav Dmitrenko edc5a4c31b ios: Picture-in-picture while in calls (#3792)
* ios: Picture-in-picture while in calls

* simplify

* improvements

* back button and lots of small issues

* layout

* padding

* back button

* animation, padding, fullscreen

* end active call button

* removed unused code

* unused line

* transition

* better

* better

* deinit PiP controller

* stop camera after call end

* formatting

* stop capture if active

---------

Co-authored-by: Avently <avently@local>
Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
2024-02-13 15:04:42 +00:00
spaced4ndy 4260c20012 ios: show ntf server (#3800) 2024-02-13 17:58:54 +04:00
spaced4ndy 1a7efbc333 core: update default ntf servers (#3804) 2024-02-13 15:10:40 +04:00
spaced4ndy e4984cb38d core: update sha256map.nix 2024-02-13 13:56:14 +04:00
spaced4ndy dfa9775d7e docs: add to inactive group members rfc (#3798) 2024-02-13 12:10:58 +04:00
spaced4ndy e39544dd24 core: return ntf server in APIGetNtfToken (#3797) 2024-02-12 21:21:20 +04:00
spaced4ndy 71bcfc2848 ui: uncomment block for all functionality (#3799) 2024-02-12 17:33:53 +04:00
Evgeny Poberezkin 3d8d84f978 Merge branch 'stable' 2024-02-11 11:41:13 +00:00
Evgeny Poberezkin f4ae60756c Merge branch 'stable' 2024-02-08 13:33:06 +00:00
Stanislav Dmitrenko eedc1b2860 android: circular icon in notification while in call (#3790)
Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
2024-02-08 13:32:35 +00:00
Stanislav Dmitrenko 24a35698dc android: fix applying updated instance of ended call (#3789) 2024-02-08 09:51:15 +00:00
Stanislav Dmitrenko 7e37155938 desktop: catching exception while opening non-existing web browser (#3787)
* desktop: catching exception while opening non-existing web browser

* update strings

---------

Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
2024-02-08 09:50:24 +00:00
Stanislav Dmitrenko c8b38183c9 desktop: better decoding Android's base64 encoded image (#3786) 2024-02-08 09:46:30 +00:00
Stanislav Dmitrenko 90a866ca56 android: fix ConcurrentModification in sharing screen (#3785) 2024-02-06 17:21:47 +00:00
Stanislav Dmitrenko 5da8aef794 android: ability to hide active call (#3770)
* android: ability to hide active call

* enhancements

* fixed some problems and adapted to lock screen usage

* change

* reduce diff

* dealing with disable PiP by user

* fix back action

* fix hidden information on view rotation while info collapsed

* better info showing

* status bar color and user icon

* reorder

* experiment

* icon placement

* enhancements

* back button

* invitation accepted state handling

* awesome background work

* better service interaction and UI

* disabled call overlay when call ends and ability to accept a new call from the same contact while previous call is not ended

* incomming call alert

* enhancements

* text

* text2

* top area

* faster ending call

* a lot of enhancements

* paddings

* icon position

* move icon

---------

Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
2024-02-05 21:44:02 +00:00
108 changed files with 3483 additions and 2061 deletions
+27 -18
View File
@@ -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 /
+52 -4
View File
@@ -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") }
}
+2
View File
@@ -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
+5 -12
View File
@@ -252,12 +252,6 @@ func apiSetFilesFolder(filesFolder: String) throws {
throw r
}
func setXFTPConfig(_ cfg: XFTPFileConfig?) throws {
let r = chatSendCmdSync(.apiSetXFTPConfig(config: cfg))
if case .cmdOk = r { return }
throw r
}
func apiSetEncryptLocalFiles(_ enable: Bool) throws {
let r = chatSendCmdSync(.apiSetEncryptLocalFiles(enable: enable))
if case .cmdOk = r { return }
@@ -412,14 +406,14 @@ func apiDeleteMemberChatItem(groupId: Int64, groupMemberId: Int64, itemId: Int64
throw r
}
func apiGetNtfToken() -> (DeviceToken?, NtfTknStatus?, NotificationsMode) {
func apiGetNtfToken() -> (DeviceToken?, NtfTknStatus?, NotificationsMode, String?) {
let r = chatSendCmdSync(.apiGetNtfToken)
switch r {
case let .ntfToken(token, status, ntfMode): return (token, status, ntfMode)
case .chatCmdError(_, .errorAgent(.CMD(.PROHIBITED))): return (nil, nil, .off)
case let .ntfToken(token, status, ntfMode, ntfServer): return (token, status, ntfMode, ntfServer)
case .chatCmdError(_, .errorAgent(.CMD(.PROHIBITED))): return (nil, nil, .off, nil)
default:
logger.debug("apiGetNtfToken response: \(String(describing: r))")
return (nil, nil, .off)
return (nil, nil, .off, nil)
}
}
@@ -1249,7 +1243,6 @@ func initializeChat(start: Bool, confirmStart: Bool = false, dbKey: String? = ni
}
try apiSetTempFolder(tempFolder: getTempFilesDirectory().path)
try apiSetFilesFolder(filesFolder: getAppFilesDirectory().path)
try setXFTPConfig(getXFTPCfg())
try apiSetEncryptLocalFiles(privacyEncryptLocalFilesGroupDefault.get())
m.chatInitialized = true
m.currentUser = try apiGetActiveUser()
@@ -1309,7 +1302,7 @@ func startChat(refreshInvitations: Bool = true) throws {
if (refreshInvitations) {
try refreshCallInvitations()
}
(m.savedToken, m.tokenStatus, m.notificationMode) = apiGetNtfToken()
(m.savedToken, m.tokenStatus, m.notificationMode, m.notificationServer) = apiGetNtfToken()
// deviceToken is set when AppDelegate.application(didRegisterForRemoteNotificationsWithDeviceToken:) is called,
// when it is called before startChat
if let token = m.deviceToken {
+93 -33
View File
@@ -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)
}
}
+2
View File
@@ -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
+20 -4
View File
@@ -161,11 +161,15 @@ struct ChatView: View {
HStack {
let callsPrefEnabled = contact.mergedPreferences.calls.enabled.forUser
if callsPrefEnabled {
callButton(contact, .audio, imageName: "phone")
.disabled(!contact.ready || !contact.active)
if chatModel.activeCall == nil {
callButton(contact, .audio, imageName: "phone")
.disabled(!contact.ready || !contact.active)
} else if let call = chatModel.activeCall, call.contact.id == cInfo.id {
endCallButton(call)
}
}
Menu {
if callsPrefEnabled {
if callsPrefEnabled && chatModel.activeCall == nil {
Button {
CallController.shared.startCall(contact, .video)
} label: {
@@ -422,7 +426,19 @@ struct ChatView: View {
Image(systemName: imageName)
}
}
private func endCallButton(_ call: Call) -> some View {
Button {
if let uuid = call.callkitUUID {
CallController.shared.endCall(callUUID: uuid)
} else {
CallController.shared.endCall(call: call) {}
}
} label: {
Image(systemName: "phone.down.fill").tint(.red)
}
}
private func searchButton() -> some View {
Button {
searchMode = true
@@ -234,39 +234,29 @@ struct GroupChatInfoView: View {
Spacer()
memberInfo(member)
}
// revert from this:
if user {
v
} else if member.canBeRemoved(groupInfo: groupInfo) {
removeSwipe(member, blockSwipe(member, v))
} else if groupInfo.membership.memberRole >= .admin {
// TODO if there are more actions, refactor with lists of swipeActions
let canBlockForAll = member.canBlockForAll(groupInfo: groupInfo)
let canRemove = member.canBeRemoved(groupInfo: groupInfo)
if canBlockForAll && canRemove {
removeSwipe(member, blockForAllSwipe(member, v))
} else if canBlockForAll {
blockForAllSwipe(member, v)
} else if canRemove {
removeSwipe(member, v)
} else {
v
}
} else {
blockSwipe(member, v)
if !member.blockedByAdmin {
blockSwipe(member, v)
} else {
v
}
}
// revert to this: vvv
// if user {
// v
// } else if groupInfo.membership.memberRole >= .admin {
// // TODO if there are more actions, refactor with lists of swipeActions
// let canBlockForAll = member.canBlockForAll(groupInfo: groupInfo)
// let canRemove = member.canBeRemoved(groupInfo: groupInfo)
// if canBlockForAll && canRemove {
// removeSwipe(member, blockForAllSwipe(member, v))
// } else if canBlockForAll {
// blockForAllSwipe(member, v)
// } else if canRemove {
// removeSwipe(member, v)
// } else {
// v
// }
// } else {
// if !member.blockedByAdmin {
// blockSwipe(member, v)
// } else {
// v
// }
// }
// ^^^
}
@ViewBuilder private func memberInfo(_ member: GroupMember) -> some View {
@@ -168,24 +168,11 @@ struct GroupMemberInfoView: View {
}
}
// revert from this:
Section {
if member.memberSettings.showMessages {
blockMemberButton(member)
} else {
unblockMemberButton(member)
}
if member.canBeRemoved(groupInfo: groupInfo) {
removeMemberButton(member)
}
if groupInfo.membership.memberRole >= .admin {
adminDestructiveSection(member)
} else {
nonAdminBlockSection(member)
}
// revert to this: vvv
// if groupInfo.membership.memberRole >= .admin {
// adminDestructiveSection(member)
// } else {
// nonAdminBlockSection(member)
// }
// ^^^
if developerTools {
Section("For console") {
@@ -42,25 +42,6 @@ struct DeveloperView: View {
} footer: {
(developerTools ? Text("Show:") : Text("Hide:")) + Text(" ") + Text("Database IDs and Transport isolation option.")
}
// Section {
// settingsRow("arrow.up.doc") {
// Toggle("Send videos and files via XFTP", isOn: $xftpSendEnabled)
// .onChange(of: xftpSendEnabled) { _ in
// do {
// try setXFTPConfig(getXFTPCfg())
// } catch {
// logger.error("setXFTPConfig: cannot set XFTP config \(responseError(error))")
// }
// }
// }
// } header: {
// Text("Experimental")
// } footer: {
// if xftpSendEnabled {
// Text("v4.6.1+ is required to receive via XFTP.")
// }
// }
}
}
}
@@ -76,6 +76,10 @@ struct NotificationsView: View {
Text(m.notificationPreview.label)
}
}
if let server = m.notificationServer {
smpServers("Push server", [server])
}
} header: {
Text("Push notifications")
} footer: {
@@ -87,6 +91,9 @@ struct NotificationsView: View {
}
}
.disabled(legacyDatabase)
.onAppear {
(m.savedToken, m.tokenStatus, m.notificationMode, m.notificationServer) = apiGetNtfToken()
}
}
private func notificationAlert(_ alert: NotificationAlert, _ token: DeviceToken) -> Alert {
@@ -125,6 +132,7 @@ struct NotificationsView: View {
m.tokenStatus = .new
notificationMode = .off
m.notificationMode = .off
m.notificationServer = nil
}
} catch let error {
await MainActor.run {
@@ -135,11 +143,13 @@ struct NotificationsView: View {
}
default:
do {
let status = try await apiRegisterToken(token: token, notificationMode: mode)
let _ = try await apiRegisterToken(token: token, notificationMode: mode)
let (_, tknStatus, ntfMode, ntfServer) = apiGetNtfToken()
await MainActor.run {
m.tokenStatus = status
notificationMode = mode
m.notificationMode = mode
m.tokenStatus = tknStatus
notificationMode = ntfMode
m.notificationMode = ntfMode
m.notificationServer = ntfServer
}
} catch let error {
await MainActor.run {
@@ -453,7 +453,6 @@ var receiverStarted = false
let startLock = DispatchSemaphore(value: 1)
let suspendLock = DispatchSemaphore(value: 1)
var networkConfig: NetCfg = getNetCfg()
let xftpConfig: XFTPFileConfig? = getXFTPCfg()
// startChat uses semaphore startLock to ensure that only one didReceive thread can start chat controller
// Subsequent calls to didReceive will be waiting on semaphore and won't start chat again, as it will be .active
@@ -499,7 +498,6 @@ func doStartChat() -> DBMigrationResult? {
try setNetworkConfig(networkConfig)
try apiSetTempFolder(tempFolder: getTempFilesDirectory().path)
try apiSetFilesFolder(filesFolder: getAppFilesDirectory().path)
try setXFTPConfig(xftpConfig)
try apiSetEncryptLocalFiles(privacyEncryptLocalFilesGroupDefault.get())
// prevent suspension while starting chat
suspendLock.wait()
@@ -733,12 +731,6 @@ func apiSetFilesFolder(filesFolder: String) throws {
throw r
}
func setXFTPConfig(_ cfg: XFTPFileConfig?) throws {
let r = sendSimpleXCmd(.apiSetXFTPConfig(config: cfg))
if case .cmdOk = r { return }
throw r
}
func apiSetEncryptLocalFiles(_ enable: Bool) throws {
let r = sendSimpleXCmd(.apiSetEncryptLocalFiles(enable: enable))
if case .cmdOk = r { return }
+20 -20
View File
@@ -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 */; };
5CB1CE9C2B8771DB00963938 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CB1CE972B8771DB00963938 /* libffi.a */; };
5CB1CE9D2B8771DB00963938 /* libHSsimplex-chat-5.5.5.0-4Xh8aGOq2uNGBj7gMJTaKI.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CB1CE982B8771DB00963938 /* libHSsimplex-chat-5.5.5.0-4Xh8aGOq2uNGBj7gMJTaKI.a */; };
5CB1CE9E2B8771DB00963938 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CB1CE992B8771DB00963938 /* libgmpxx.a */; };
5CB1CE9F2B8771DB00963938 /* libHSsimplex-chat-5.5.5.0-4Xh8aGOq2uNGBj7gMJTaKI-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CB1CE9A2B8771DB00963938 /* libHSsimplex-chat-5.5.5.0-4Xh8aGOq2uNGBj7gMJTaKI-ghc9.6.3.a */; };
5CB1CEA02B8771DB00963938 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CB1CE9B2B8771DB00963938 /* libgmp.a */; };
5CB2084F28DA4B4800D024EC /* RTCServers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB2084E28DA4B4800D024EC /* RTCServers.swift */; };
5CB346E52868AA7F001FD2EF /* SuspendChat.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB346E42868AA7F001FD2EF /* SuspendChat.swift */; };
5CB346E72868D76D001FD2EF /* NotificationsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB346E62868D76D001FD2EF /* NotificationsView.swift */; };
@@ -278,11 +278,6 @@
5C245F3C2B501E98001CC39F /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/Localizable.strings; sourceTree = "<group>"; };
5C245F3D2B501F13001CC39F /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = "tr.lproj/SimpleX--iOS--InfoPlist.strings"; sourceTree = "<group>"; };
5C245F3E2B501F13001CC39F /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/InfoPlist.strings; sourceTree = "<group>"; };
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>"; };
5CB1CE972B8771DB00963938 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; };
5CB1CE982B8771DB00963938 /* libHSsimplex-chat-5.5.5.0-4Xh8aGOq2uNGBj7gMJTaKI.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.5.5.0-4Xh8aGOq2uNGBj7gMJTaKI.a"; sourceTree = "<group>"; };
5CB1CE992B8771DB00963938 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; };
5CB1CE9A2B8771DB00963938 /* libHSsimplex-chat-5.5.5.0-4Xh8aGOq2uNGBj7gMJTaKI-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.5.5.0-4Xh8aGOq2uNGBj7gMJTaKI-ghc9.6.3.a"; sourceTree = "<group>"; };
5CB1CE9B2B8771DB00963938 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
5CB2084E28DA4B4800D024EC /* RTCServers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RTCServers.swift; sourceTree = "<group>"; };
5CB2085428DE647400D024EC /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Localizable.strings; sourceTree = "<group>"; };
5CB346E42868AA7F001FD2EF /* SuspendChat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuspendChat.swift; sourceTree = "<group>"; };
@@ -514,13 +514,13 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
5C29C3B02B783F82003DF84C /* libgmpxx.a in Frameworks */,
5C29C3B32B783F82003DF84C /* libHSsimplex-chat-5.5.3.0-AUrnxTuqxo1yzY63w39Bk.a in Frameworks */,
5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */,
5CB1CEA02B8771DB00963938 /* libgmp.a in Frameworks */,
5CB1CE9E2B8771DB00963938 /* libgmpxx.a in Frameworks */,
5CB1CE9C2B8771DB00963938 /* libffi.a in Frameworks */,
5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */,
5C29C3B12B783F82003DF84C /* libffi.a in Frameworks */,
5C29C3B22B783F82003DF84C /* libgmp.a in Frameworks */,
5C29C3AF2B783F82003DF84C /* libHSsimplex-chat-5.5.3.0-AUrnxTuqxo1yzY63w39Bk-ghc9.6.3.a in Frameworks */,
5CB1CE9D2B8771DB00963938 /* libHSsimplex-chat-5.5.5.0-4Xh8aGOq2uNGBj7gMJTaKI.a in Frameworks */,
5CB1CE9F2B8771DB00963938 /* libHSsimplex-chat-5.5.5.0-4Xh8aGOq2uNGBj7gMJTaKI-ghc9.6.3.a in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -582,11 +582,11 @@
5C764E5C279C70B7000C6508 /* Libraries */ = {
isa = PBXGroup;
children = (
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 */,
5CB1CE972B8771DB00963938 /* libffi.a */,
5CB1CE9B2B8771DB00963938 /* libgmp.a */,
5CB1CE992B8771DB00963938 /* libgmpxx.a */,
5CB1CE9A2B8771DB00963938 /* libHSsimplex-chat-5.5.5.0-4Xh8aGOq2uNGBj7gMJTaKI-ghc9.6.3.a */,
5CB1CE982B8771DB00963938 /* libHSsimplex-chat-5.5.5.0-4Xh8aGOq2uNGBj7gMJTaKI.a */,
);
path = Libraries;
sourceTree = "<group>";
+2 -13
View File
@@ -31,7 +31,6 @@ public enum ChatCommand {
case apiSuspendChat(timeoutMicroseconds: Int)
case setTempFolder(tempFolder: String)
case setFilesFolder(filesFolder: String)
case apiSetXFTPConfig(config: XFTPFileConfig?)
case apiSetEncryptLocalFiles(enable: Bool)
case apiExportArchive(config: ArchiveConfig)
case apiImportArchive(config: ArchiveConfig)
@@ -162,11 +161,6 @@ public enum ChatCommand {
case let .apiSuspendChat(timeoutMicroseconds): return "/_app suspend \(timeoutMicroseconds)"
case let .setTempFolder(tempFolder): return "/_temp_folder \(tempFolder)"
case let .setFilesFolder(filesFolder): return "/_files_folder \(filesFolder)"
case let .apiSetXFTPConfig(cfg): if let cfg = cfg {
return "/_xftp on \(encodeJSON(cfg))"
} else {
return "/_xftp off"
}
case let .apiSetEncryptLocalFiles(enable): return "/_files_encrypt \(onOff(enable))"
case let .apiExportArchive(cfg): return "/_db export \(encodeJSON(cfg))"
case let .apiImportArchive(cfg): return "/_db import \(encodeJSON(cfg))"
@@ -311,7 +305,6 @@ public enum ChatCommand {
case .apiSuspendChat: return "apiSuspendChat"
case .setTempFolder: return "setTempFolder"
case .setFilesFolder: return "setFilesFolder"
case .apiSetXFTPConfig: return "apiSetXFTPConfig"
case .apiSetEncryptLocalFiles: return "apiSetEncryptLocalFiles"
case .apiExportArchive: return "apiExportArchive"
case .apiImportArchive: return "apiImportArchive"
@@ -613,7 +606,7 @@ public enum ChatResponse: Decodable, Error {
case callEnded(user: UserRef, contact: Contact)
case callInvitations(callInvitations: [RcvCallInvitation])
case ntfTokenStatus(status: NtfTknStatus)
case ntfToken(token: DeviceToken, status: NtfTknStatus, ntfMode: NotificationsMode)
case ntfToken(token: DeviceToken, status: NtfTknStatus, ntfMode: NotificationsMode, ntfServer: String)
case ntfMessages(user_: User?, connEntity_: ConnectionEntity?, msgTs: Date?, ntfMessages: [NtfMsgInfo])
case ntfMessage(user: UserRef, connEntity: ConnectionEntity, ntfMessage: NtfMsgInfo)
case contactConnectionDeleted(user: UserRef, connection: PendingContactConnection)
@@ -912,7 +905,7 @@ public enum ChatResponse: Decodable, Error {
case let .callEnded(u, contact): return withUser(u, "contact: \(contact.id)")
case let .callInvitations(invs): return String(describing: invs)
case let .ntfTokenStatus(status): return String(describing: status)
case let .ntfToken(token, status, ntfMode): return "token: \(token)\nstatus: \(status.rawValue)\nntfMode: \(ntfMode.rawValue)"
case let .ntfToken(token, status, ntfMode, ntfServer): return "token: \(token)\nstatus: \(status.rawValue)\nntfMode: \(ntfMode.rawValue)\nntfServer: \(ntfServer)"
case let .ntfMessages(u, connEntity, msgTs, ntfMessages): return withUser(u, "connEntity: \(String(describing: connEntity))\nmsgTs: \(String(describing: msgTs))\nntfMessages: \(String(describing: ntfMessages))")
case let .ntfMessage(u, connEntity, ntfMessage): return withUser(u, "connEntity: \(String(describing: connEntity))\nntfMessage: \(String(describing: ntfMessage))")
case let .contactConnectionDeleted(u, connection): return withUser(u, String(describing: connection))
@@ -1005,10 +998,6 @@ struct ComposedMessage: Encodable {
var msgContent: MsgContent
}
public struct XFTPFileConfig: Encodable {
var minFileSize: Int64
}
public struct ArchiveConfig: Encodable {
var archivePath: String
var disableCompression: Bool?
-4
View File
@@ -265,10 +265,6 @@ public class Default<T> {
}
}
public func getXFTPCfg() -> XFTPFileConfig {
return XFTPFileConfig(minFileSize: 0)
}
public func getNetCfg() -> NetCfg {
let onionHosts = networkUseOnionHostsGroupDefault.get()
let (hostMode, requiredHostMode) = onionHosts.hostMode
+1 -1
View File
@@ -2268,7 +2268,7 @@ public struct ChatItem: Identifiable, Decodable {
case .rcvDirectEvent(rcvDirectEvent: let rcvDirectEvent):
switch rcvDirectEvent {
case .contactDeleted: return false
case .profileUpdated: return true
case .profileUpdated: return false
}
case .rcvGroupEvent(rcvGroupEvent: let rcvGroupEvent):
switch rcvGroupEvent {
@@ -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)
}
}
@@ -8,13 +8,39 @@ void hs_init_with_rtsopts(int * argc, char **argv[]);
JNIEXPORT void JNICALL
Java_chat_simplex_common_platform_CoreKt_initHS(JNIEnv *env, jclass clazz) {
// setup static arena for bump allocation and passing to RTS
char *argv[32] = {NULL,};
int argc = 0; // number of arguments used so far, always stands at the first NULL in argv
// common args
argv[argc++] = "simplex"; // fake program name
argv[argc++] = "+RTS"; // start adding RTS options
argv[argc++] = "-T"; // make GC counters available from inside the program
argv[argc++] = "-A16m"; // chunk size for new allocations (less frequent GC)
argv[argc++] = "-H64m"; // larger heap size on start (faster boot)
argv[argc++] = "-M8G"; // keep memory usage under 8G, collecting more aggressively when approaching it (and crashing sooner rather than taking down the whole system)
int eventlog = 1; // TODO: geto option flag from app (pointer for -ol?)
int eventlog_threads = 2; // XXX: can be a bit flag on eventlog
if (eventlog) {
argv[argc++] = "-olsimplex.eventlog"; // produce simplex.eventlog in "current" directory
if (eventlog_threads) {
argv[argc++] = "-l-asgu"; // collect scheduler, GC, and user events
} else {
argv[argc++] = "-l-agu"; // collect GC and user events
}
}
int profiling = 1; // TODO: get option flag from app (pointer for -po?)
if (profiling) {
argv[argc++] = "-posimplex"; // produce simplex.hp in "current" directory
argv[argc++] = "-hT"; // emit heap profile by closure type
}
int non_moving_gc = !profiling;
#ifdef _WIN32
int argc = 4;
char *argv[] = {"simplex", "+RTS", "-A16m", "-H64m", NULL}; // non-moving GC is broken on windows with GHC 9.4-9.6.3
#else
int argc = 5;
char *argv[] = {"simplex", "+RTS", "-A16m", "-H64m", "-xn", NULL}; // see android/simplex-api.c for details
// non-moving GC is broken on windows with GHC 9.4-9.6.3
non_moving_gc = 0;
#endif
if (non_moving_gc)
argv[argc++] = "-xn";
// wrap args as expected by RTS
char **pargv = argv;
hs_init_with_rtsopts(&argc, &pargv);
}
@@ -1,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)
@@ -1821,7 +1822,7 @@ data class ChatItem (
is CIContent.SndGroupInvitation -> false
is CIContent.RcvDirectEventContent -> when (content.rcvDirectEvent) {
is RcvDirectEvent.ContactDeleted -> false
is RcvDirectEvent.ProfileUpdated -> true
is RcvDirectEvent.ProfileUpdated -> false
}
is CIContent.RcvGroupEventContent -> when (content.rcvGroupEvent) {
is RcvGroupEvent.MemberAdded -> false
@@ -631,12 +631,6 @@ object ChatController {
throw Error("failed to set remote hosts folder: ${r.responseType} ${r.details}")
}
suspend fun apiSetXFTPConfig(cfg: XFTPFileConfig?) {
val r = sendCmd(null, CC.ApiSetXFTPConfig(cfg))
if (r is CR.CmdOk) return
throw Error("apiSetXFTPConfig bad response: ${r.responseType} ${r.details}")
}
suspend fun apiSetEncryptLocalFiles(enable: Boolean) = sendCommandOkResp(null, CC.ApiSetEncryptLocalFiles(enable))
suspend fun apiExportArchive(config: ArchiveConfig) {
@@ -1914,10 +1908,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 ->
@@ -2173,10 +2165,6 @@ object ChatController {
}
}
fun getXFTPCfg(): XFTPFileConfig {
return XFTPFileConfig(minFileSize = 0)
}
fun getNetCfg(): NetCfg {
val useSocksProxy = appPrefs.networkUseSocksProxy.get()
val proxyHostPort = appPrefs.networkProxyHostPort.get()
@@ -2285,7 +2273,6 @@ sealed class CC {
class SetTempFolder(val tempFolder: String): CC()
class SetFilesFolder(val filesFolder: String): CC()
class SetRemoteHostsFolder(val remoteHostsFolder: String): CC()
class ApiSetXFTPConfig(val config: XFTPFileConfig?): CC()
class ApiSetEncryptLocalFiles(val enable: Boolean): CC()
class ApiExportArchive(val config: ArchiveConfig): CC()
class ApiImportArchive(val config: ArchiveConfig): CC()
@@ -2415,7 +2402,6 @@ sealed class CC {
is SetTempFolder -> "/_temp_folder $tempFolder"
is SetFilesFolder -> "/_files_folder $filesFolder"
is SetRemoteHostsFolder -> "/remote_hosts_folder $remoteHostsFolder"
is ApiSetXFTPConfig -> if (config != null) "/_xftp on ${json.encodeToString(config)}" else "/_xftp off"
is ApiSetEncryptLocalFiles -> "/_files_encrypt ${onOff(enable)}"
is ApiExportArchive -> "/_db export ${json.encodeToString(config)}"
is ApiImportArchive -> "/_db import ${json.encodeToString(config)}"
@@ -2550,7 +2536,6 @@ sealed class CC {
is SetTempFolder -> "setTempFolder"
is SetFilesFolder -> "setFilesFolder"
is SetRemoteHostsFolder -> "setRemoteHostsFolder"
is ApiSetXFTPConfig -> "apiSetXFTPConfig"
is ApiSetEncryptLocalFiles -> "apiSetEncryptLocalFiles"
is ApiExportArchive -> "apiExportArchive"
is ApiImportArchive -> "apiImportArchive"
@@ -2716,9 +2701,6 @@ sealed class ChatPagination {
@Serializable
class ComposedMessage(val fileSource: CryptoFile?, val quotedItemId: Long?, val msgContent: MsgContent)
@Serializable
class XFTPFileConfig(val minFileSize: Long)
@Serializable
class ArchiveConfig(val archivePath: String, val disableCompression: Boolean? = null, val parentTempDirectory: String? = null)
@@ -91,7 +91,6 @@ suspend fun initChatController(useKey: String? = null, confirmMigrations: Migrat
if (appPlatform.isDesktop) {
controller.apiSetRemoteHostsFolder(remoteHostsDir.absolutePath)
}
controller.apiSetXFTPConfig(controller.getXFTPCfg())
controller.apiSetEncryptLocalFiles(controller.appPrefs.privacyEncryptLocalFiles.get())
// If we migrated successfully means previous re-encryption process on database level finished successfully too
if (appPreferences.encryptionStartedAt.get() != null) appPreferences.encryptionStartedAt.set(null)
@@ -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(
+4 -4
View File
@@ -25,11 +25,11 @@ android.nonTransitiveRClass=true
android.enableJetifier=true
kotlin.mpp.androidSourceSetLayoutVersion=2
android.version_name=5.5.4
android.version_code=183
android.version_name=5.5.5
android.version_code=185
desktop.version_name=5.5.4
desktop.version_code=30
desktop.version_name=5.5.5
desktop.version_code=31
kotlin.version=1.8.20
gradle.plugin.version=7.4.2
+1 -1
View File
@@ -12,7 +12,7 @@ constraints: zip +disable-bzip2 +disable-zstd
source-repository-package
type: git
location: https://github.com/simplex-chat/simplexmq.git
tag: e64b6cba4b7e4107f78ae596ab2a6a28ef24ff78
tag: 246a0d10c22ebe02af2eb34773b77cce10247459
source-repository-package
type: git
+59 -2
View File
@@ -24,7 +24,7 @@ _Please note_: when you change the servers in the app configuration, it only aff
- Semi-automatic deployment:
- [Offical installation script](https://github.com/simplex-chat/simplexmq#using-installation-script)
- [Docker container](https://github.com/simplex-chat/simplexmq#using-docker)
- [Linode StackScript](https://github.com/simplex-chat/simplexmq#deploy-smp-server-on-linode)
- [Linode Marketplace](https://www.linode.com/marketplace/apps/simplex-chat/simplex-chat/)
Manual installation requires some preliminary actions:
@@ -33,7 +33,7 @@ Manual installation requires some preliminary actions:
- Using offical binaries:
```sh
curl -L https://github.com/simplex-chat/simplexmq/releases/latest/download/smp-server-ubuntu-20_04-x86-64 -o /usr/local/bin/smp-server
curl -L https://github.com/simplex-chat/simplexmq/releases/latest/download/smp-server-ubuntu-20_04-x86-64 -o /usr/local/bin/smp-server && chmod +x /usr/local/bin/smp-server
```
- Compiling from source:
@@ -417,6 +417,63 @@ To import `csv` to `Grafana` one should:
For further documentation, see: [CSV Data Source for Grafana - Documentation](https://grafana.github.io/grafana-csv-datasource/)
# Updating your SMP server
To update your smp-server to latest version, choose your installation method and follow the steps:
- Manual deployment
1. Stop the server:
```sh
sudo systemctl stop smp-server
```
2. Update the binary:
```sh
curl -L https://github.com/simplex-chat/simplexmq/releases/latest/download/smp-server-ubuntu-20_04-x86-64 -o /usr/local/bin/smp-server && chmod +x /usr/local/bin/smp-server
```
3. Start the server:
```sh
sudo systemctl start smp-server
```
- [Offical installation script](https://github.com/simplex-chat/simplexmq#using-installation-script)
1. Execute the followin command:
```sh
sudo simplex-servers-update
```
2. Done!
- [Docker container](https://github.com/simplex-chat/simplexmq#using-docker)
1. Stop and remove the container:
```sh
docker rm $(docker stop $(docker ps -a -q --filter ancestor=simplexchat/smp-server --format="{{.ID}}"))
```
2. Pull latest image:
```sh
docker pull simplexchat/smp-server:latest
```
3. Start new container:
```sh
docker run -d \
-p 5223:5223 \
-v $HOME/simplex/smp/config:/etc/opt/simplex:z \
-v $HOME/simplex/smp/logs:/var/opt/simplex:z \
simplexchat/smp-server:latest
```
- [Linode Marketplace](https://www.linode.com/marketplace/apps/simplex-chat/simplex-chat/)
1. Pull latest images:
```sh
docker-compose --project-directory /etc/docker/compose/simplex pull
```
2. Restart the containers:
```sh
docker-compose --project-directory /etc/docker/compose/simplex up -d --remove-orphans
```
3. Remove obsolete images:
```sh
docker image prune
```
### Configuring the app to use the server
To configure the app to use your messaging server copy it's full address, including password, and add it to the app. You have an option to use your server together with preset servers or without them - you can remove or disable them.
+61 -1
View File
@@ -24,6 +24,7 @@ XFTP is a new file transfer protocol focussed on meta-data protection - it is ba
- Semi-automatic deployment:
- [Offical installation script](https://github.com/simplex-chat/simplexmq#using-installation-script)
- [Docker container](https://github.com/simplex-chat/simplexmq#using-docker)
- [Linode Marketplace](https://www.linode.com/marketplace/apps/simplex-chat/simplex-chat/)
Manual installation requires some preliminary actions:
@@ -32,7 +33,7 @@ Manual installation requires some preliminary actions:
- Using offical binaries:
```sh
curl -L https://github.com/simplex-chat/simplexmq/releases/latest/download/xftp-server-ubuntu-20_04-x86-64 -o /usr/local/bin/xftp-server
curl -L https://github.com/simplex-chat/simplexmq/releases/latest/download/xftp-server-ubuntu-20_04-x86-64 -o /usr/local/bin/xftp-server && chmod +x /usr/local/bin/xftp-server
```
- Compiling from source:
@@ -418,6 +419,65 @@ To import `csv` to `Grafana` one should:
For further documentation, see: [CSV Data Source for Grafana - Documentation](https://grafana.github.io/grafana-csv-datasource/)
# Updating your XFTP server
To update your XFTP server to latest version, choose your installation method and follow the steps:
- Manual deployment
1. Stop the server:
```sh
sudo systemctl stop xftp-server
```
2. Update the binary:
```sh
curl -L https://github.com/simplex-chat/simplexmq/releases/latest/download/xftp-server-ubuntu-20_04-x86-64 -o /usr/local/bin/xftp-server && chmod +x /usr/local/bin/xftp-server
```
3. Start the server:
```sh
sudo systemctl start xftp-server
```
- [Offical installation script](https://github.com/simplex-chat/simplexmq#using-installation-script)
1. Execute the followin command:
```sh
sudo simplex-servers-update
```
2. Done!
- [Docker container](https://github.com/simplex-chat/simplexmq#using-docker)
1. Stop and remove the container:
```sh
docker rm $(docker stop $(docker ps -a -q --filter ancestor=simplexchat/xftp-server --format="{{.ID}}"))
```
2. Pull latest image:
```sh
docker pull simplexchat/xftp-server:latest
```
3. Start new container:
```sh
docker run -d \
-p 443:443 \
-v $HOME/simplex/xftp/config:/etc/opt/simplex-xftp:z \
-v $HOME/simplex/xftp/logs:/var/opt/simplex-xftp:z \
-v $HOME/simplex/xftp/files:/srv/xftp:z \
simplexchat/xftp-server:latest
```
- [Linode Marketplace](https://www.linode.com/marketplace/apps/simplex-chat/simplex-chat/)
1. Pull latest images:
```sh
docker-compose --project-directory /etc/docker/compose/simplex pull
```
2. Restart the containers:
```sh
docker-compose --project-directory /etc/docker/compose/simplex up -d --remove-orphans
```
3. Remove obsolete images:
```sh
docker image prune
```
### Configuring the app to use the server
Please see: [SMP Server: Configuring the app to use the server](./SERVER.md#configuring-the-app-to-use-the-server).
+20 -8
View File
@@ -82,7 +82,7 @@ def RatchetInitAlicePQ2HE(state, SK, bob_dh_public_key, shared_hka, shared_nhkb,
state.PQRs = GENERATE_PQKEM()
state.PQRr = bob_pq_kem_encapsulation_key
state.PQRss = random // shared secret for KEM
state.PQRenc_ss = PQKEM-ENC(state.PQRr.encaps, state.PQRss) // encapsulated additional shared secret
state.PQRct = PQKEM-ENC(state.PQRr, state.PQRss) // encapsulated additional shared secret
// above added for KEM
// below augments DH key agreement with PQ shared secret
state.RK, state.CKs, state.NHKs = KDF_RK_HE(SK, DH(state.DHRs, state.DHRr) || state.PQRss)
@@ -103,7 +103,7 @@ def RatchetInitBobPQ2HE(state, SK, bob_dh_key_pair, shared_hka, shared_nhkb, bob
state.PQRs = bob_pq_kem_key_pair
state.PQRr = None
state.PQRss = None
state.PQRenc_ss = None
state.PQRct = None
// above added for KEM
state.RK = SK
state.CKs = None
@@ -132,10 +132,10 @@ def RatchetEncryptPQ2HE(state, plaintext, AD):
// encapsulation key from PQRs and encapsulated shared secret is added to header
header = HEADER_PQ2(
dh = state.DHRs.public,
kem = state.PQRs.public, // added for KEM #2
ct = state.PQRct // added for KEM #1
pn = state.PN,
n = state.Ns,
encaps = state.PQRs.encaps, // added for KEM #1
enc_ss = state.PQRenc_ss // added for KEM #2
)
enc_header = HENCRYPT(state.HKs, header)
state.Ns += 1
@@ -162,6 +162,16 @@ def RatchetDecryptPQ2HE(state, enc_header, ciphertext, AD):
state.Nr += 1
return DECRYPT(mk, ciphertext, CONCAT(AD, enc_header))
// DecryptHeader is the same as in double ratchet specification
def DecryptHeader(state, enc_header):
header = HDECRYPT(state.HKr, enc_header)
if header != None:
return header, False
header = HDECRYPT(state.NHKr, enc_header)
if header != None:
return header, True
raise Error()
def DHRatchetPQ2HE(state, header):
state.PN = state.Ns
state.Ns = 0
@@ -170,16 +180,16 @@ def DHRatchetPQ2HE(state, header):
state.HKr = state.NHKr
state.DHRr = header.dh
// save new encapsulation key from header
state.PQRr = header.encaps
state.PQRr = header.kem
// decapsulate shared secret from header - KEM #2
ss = PQKEM-DEC(state.PQRs.decaps, header.enc_ss)
ss = PQKEM-DEC(state.PQRs.private, header.ct)
// use decapsulated shared secret with receiving ratchet
state.RK, state.CKr, state.NHKr = KDF_RK_HE(state.RK, DH(state.DHRs, state.DHRr) || ss)
state.DHRs = GENERATE_DH()
// below is added for KEM
state.PQRs = GENERATE_PQKEM() // generate new PQ key pair
state.PQRss = random // shared secret for KEM
state.PQRenc_ss = PQKEM-ENC(state.PQRr.encaps, state.PQRss) // encapsulated additional shared secret KEM #1
state.PQRct = PQKEM-ENC(state.PQRr, state.PQRss) // encapsulated additional shared secret KEM #1
// above is added for KEM
// use new shared secret with sending ratchet
state.RK, state.CKs, state.NHKs = KDF_RK_HE(state.RK, DH(state.DHRs, state.DHRr) || state.PQRss)
@@ -201,7 +211,7 @@ The main downside is the absense of performance-efficient implementation for aar
## Implementation considerations for SimpleX Chat
As SimpleX Chat pads messages to a fixed size, using 16kb transport blocks, the size increase introduced by this scheme will not cause additional traffic in most cases. For large texts it may require additional messages to be sent. Similarly, for media previews it may require either reducing the preview size (and quality) or sending additional messages.
As SimpleX Chat pads messages to a fixed size, using 16kb transport blocks, the size increase introduced by this scheme will not cause additional traffic in most cases. For large texts it may require additional messages to be sent. Similarly, for media previews it may require either reducing the preview size (and quality), or sending additional messages, or compressing the current JSON encoding, e.g. with zstd algorithm.
That might be the primary reason why this scheme was not adopted by Signal, as it would have resulted in substantial traffic growth to the best of our knowledge, Signal messages are not padded to a fixed size.
@@ -209,6 +219,8 @@ Sharing the initial keys in case of SimpleX Chat it is equivalent to sharing the
It is possible to postpone sharing the encapsulation key until the first message from Alice (confirmation message in SMP protocol), the party sending connection request. The upside here is that the invitation link size would not increase. The downside is that the user profile shared in this confirmation will not be encrypted with PQ-resistant algorithm. To mitigate it, the hadnshake protocol can be modified to postpone sending the user profile until the second message from Alice (HELLO message in SMP protocol).
Another consideration is pairwise ratchets in groups. Key generation in sntrup761 is quite slow - on slow devices it can probably be as slow as 10 keys per second, so using this primitive in groups larger than 10 members would result in slow performance. An option could be not to use ratchets in groups at all, but that would result in the lack of protection in small groups that simply combine multiple devices of 1-3 people. So a better option would be to support dynamically adding and removing sntrup761 keys for pairwise ratchets in groups, which means that when sending each message a boolean flag needs to be passed whether to use PQ KEM or not.
## Summary
If chosen PQ KEM proves secure against quantum computer attacks, then the proposed augmented double ratchet will also be secure against quantum computer attack, including break-in recovery property, while keeping deniability and forward secrecy, because the [same proof](https://eprint.iacr.org/2016/1013.pdf) as for double ratchet algorithm would hold here, provided KEM is secure.
@@ -108,3 +108,33 @@ Sending member builds messages history starting starting from requested/remember
\***
Same XGrpMsgHistory protocol event could be sent by host to new members, after sending introductions.
---
Update 2024-02-12:
### Group "pings"
Alternatively to tracking unanswered messages counts per member, which is complex and in some cases as discussed above ineffective, group members could periodically send group wide pings indicating their active presence.
```haskell
XGrpPing :: ChatMsgEvent 'Json
```
Members track:
- inactive flag (as above - set on QUOTA errors as well)
- last_snd_ts on group
- last_rcv_ts on group member
Clients run a worker process for checking last_snd_ts in each of their groups, and send pings to groups on a periodic basis.
- part of cleanup manager or separate process?
- on each worker step, for each group matching criteria to send ping, send ping with a random delay to reduce correlation between groups (spawn a separate thread with a random delay for each group)
- criteria for sending ping: last_snd_ts earlier than group_ping_interval ago
- configure group_ping_interval to, for example, 23 hours (so that if user opens app each day at same time client will match criteria to send pings daily)
Clients receiving pings:
- update last_rcv_ts
- when sending a message to group, check only for timestamp difference (no unanswered snd msg count logic as above)
+130
View File
@@ -0,0 +1,130 @@
# Database migration and other operations
## Problem
Migrating database to another device is very complex for most people - it is multi-step and error-prone.
In addition to that, any database operation is confusing as it requires stopping chat.
## Solution
Let users migrate database to another device by scanning QR code.
Simplify other database operations by removing the need to compose multiple actions, stop chat, etc.
To support it, we already added the way to represent the file as link/QR code (by uploading file description to XFTP, and supporting "recursive" descriptions).
There will be these actions in the Database settings (no stop/start chat toggle):
- Export database.
- Import database.
- Migrate from another device.
- Set passphrase (or Change passphrase if it was set).
- Remove passphrase from device / Store passphrase on the device.
Stop chat toggle will be moved to dev tools.
Migrate to another device will be available in the top part of the settings,
### Database export
Currently, it requires these steps:
1. Open Database settings.
2. Stop chat (many users don't understand it).
3. Tap "Export database" in settings.
4. Look at the alert that says "set passphrase".
5. Tap Ok.
6. Tap Set passphrase.
7. Enter passphrase and confirm.
8. Exit back to Database settings.
9. Tap "Export database" again.
10. Choose file location and save.
11. Tap "New archive".
12. Remove exported archive.
These steps are all very confusing, and if they were to stay as composable steps, they belong to dev tools.
Instead we can offer these simple steps:
1. Open Database settings.
2. Tap "Export database".
3. Alert will appear saying: "The chat will stop, and you will need to set (or verify) database passphrase. Continue?".
4. Tap "Ok".
5. Enter passphrase and confirm in the window that appears (or verify if it was already set, possibly allowing to skip this step).
7. Choose whether to save file or upload to XFTP and generate link.
8. File: choose file location and save.
Link: show upload progress and then show link to copy.
9. Alert will appear saying: "Database exported!", exported archive will be automatically removed.
So instead of asking users to understand the required sequence of steps, we will guide them through the required process.
### Database import
1. Open Database settings.
2. Tap "Import database".
3. Alert will appear saying: "The chat will stop, you will import?".
4. File: choose file location and tap "Import".
Link: paste link (or scan QR code) and tap "Import".
5. Confirm to replace database.
6. Start chat automatically once imported.
### Set or change passphrase
1. Open Database settings.
2. Tap "Set passphrase" or "Change passphrase" (if it was set).
3. Choose - store passphrase on the device or enter it every time the app starts.
### Remove / store passphrase from the device
To remove:
1. Open Database settings.
2. Tap "Remove passphrase".
3. Confirm to remove passphrase in alert.
4. Button is replaced with Store.
To store:
1. Open Database settings.
2. Tap "Store passphrase".
3. Enter current passphrase - it is verified.
4. Button is replaced with Remove.
### Migrate database to / from another device
#### User experience
This function is the most important, and it should be available from the main section in settings, under "Use from desktop" (or under "Link from mobile" on desktop).
On the receiving device it will be available via Database settings and also on the Onboarding screen, so users don't need to create a profile.
The steps are:
On the source device:
1. Tap "Migrate to another device".
2. The chat will stop showing "Stopping chat" to the user.
3. If passphrase was:
- not set: make user set it in a separate screen.
- set: make user verify it.
5. Show the screen to confirm the upload.
6. Upload progress (full screen circular progress showing the share, with the %s and total/uploaded size) will be shown.
7. Once upload is completed, show QR code (with option to copy link), instruct to tap "Migrate from another device" on the receiving device.
On the receiving device:
2. Tap "Migrate from another device".
2. The chat will stop (if not from Onboarding) showing "Stopping chat" to the user.
4. Scan QR code (with option to paste link on desktop only).
5. Show similar download progress, but probably in reversed direction - design TBC.
6. Once download is completed, show "Replace the current database" (if not from Onboarding).
7. Once imported, start chat automatically, and once chat started show "Tap remove database on source device".
On the source device:
1. Tap "Remove database" on the showing screen (this should also remove uploaded file).
#### Implementation considerations
The latest updates allow uploading and downloading XFTP files without messages.
So to perform the above, the second instance of the chat controller will be required, that probably requires supporting additional/optional chat controller parameter in the APIs that are required for that process.
@@ -0,0 +1,38 @@
# Inactive group members (simplified)
[Original doc](./2023-11-21-inactive-group-members.md)
## Problem
Groups traffic is higher than necessary due to sending messages to inactive group members.
## Solution
### Improve connection deletion
- When leaving or deleting group, batch db operations to optimize performance.
- In agent - fix race where connection can be deleted while it has remaining pending messages.
- Current agent logic is to immediately delete connection if it has no rcv queues left.
- Simplest should be to make a smart version of `deleteConn` for this improvement, checking `snd_messages` table for remaining messages, and keep connection around in case there are.
- While this may improve delivery of group leave and delete messages, it may as well have undesirable side effects for other use cases, as any pending messages will be sent prior to deleting connection. For example, user sends several messages on bad network, decides to delete contact, messages are still delivered when user is on good network before deletion, even though this contradicts user's intent and messages hadn't left user's device at the time of deletion. Considering this race when it happens is identical to simply leaving groups by deleting app, or deleting user profile only locally, it may be a bad idea to affect regular contact deletion for this use case.
### Track member inactivity
- Mark members as inactive on QUOTA errors, reset as active on QCONT
- track `group_members.inactive` flag per group member
- on SMP.QUOTA error agent to notify client with ERR CONN QUOTA (new ConnectionErrorType QUOTA)
- on receiving QCONT agent to notify client (new event)
- apart from QCONT, reset on any message or receipt
- Don't send to member if inactive
- don't send only content messages (x.msg.new, etc.) and always send messages altering group state?
- or don't send any messages?
- Track number of skipped messages per member and first skipped message
- count `group_members.skipped_msg_cnt`
- only count messages of same types/criteria that are included into history
- track `group_members.skipped_first_shared_msg_id` (only content or including service messages?)
- Send XGrpMsgSkipped before next message
- check `skipped_msg_cnt` > 0 and `skipped_first_shared_msg_id` is not null to only send once, reset after sending
```haskell
XGrpMsgSkipped :: SharedMsgId -> Int64 -> ChatMsgEvent 'Json -- from, count
```
+60
View File
@@ -0,0 +1,60 @@
# Migrating app settings to another device
## Problem
This is related to simplified database migration UX in the [previous RFC](./2024-02-12-database-migration.md).
Currently, when database is imported after the onboarding is complete, users can configure the app prior to the import.
Some of the settings are particularly important for privacy and security:
- SOCKS proxy settings
- Automatic image etc. downloads
- Link previews
With the new UX, the chat will start automatically, without giving users a chance to configure the app. That means that we have to migrate settings to a new device as well, as part of the archive.
## Solution
There are several possible approaches:
- put settings to the database via the API
- save settings as some file with cross-platform format (e.g. JSON or YAML or properties used on desktop).
The second approach seems much simpler than maintaining the settings in the database.
If we save a file, then there are two options:
- native apps maintain cross-platform schemas for this file, support any JSON and parse it in a safe way (so that even invalid or incorrect JSON - e.g., array instead of object - or invalid types in some properties do not cause the failure of properties that are correct).
- this schema and type will be maintained in the core library, that will be responsible for storing and reading the settings and passing to native UI as correct record of a given type.
The downside of the second approach is that addition of any property that needs to be migrated will have to be done on any change in either of the platforms. The downside of the first approach is that neither app platform will be self-sufficient any more, and not only iOS/Android would have to take into account code, but also each other code.
If we go with the second approach, there will be these types:
```haskell
data AppSettings = AppSettings
{ networkConfig :: NetworkConfig, -- existing type in Haskell and all UIs
privacyConfig :: PrivacyConfig -- new type, etc.
-- ... additional properties after the initial release should be added as Maybe, as all extensions
}
data ArchiveConfig = ArchiveConfig
{ -- existing properties
archivePath :: FilePath,
disableCompression :: Maybe Bool,
parentTempDirectory :: Maybe FilePath,
-- new property
appSettings :: AppSettings
-- for export, these settings will contain the settings passed from the UI and will be saved to JSON file as simplex_v1_settings.json in the archive
-- for import, these settings will contain the defaults that will be used if some property or subproperty is missing in JSON
}
-- importArchive :: ChatMonad m => ArchiveConfig -> m [ArchiveError] -- current type
importArchive :: ChatMonad m => ArchiveConfig -> m ArchiveImportResult -- new type
-- | CRArchiveImported {archiveErrors :: [ArchiveError]} -- current type
| CRArchiveImported {importResult :: ArchiveImportResult} -- new type
data ArchiveImportResult = ArchiveImportResult
{ archiveErrors :: [ArchiveError],
appSettings :: Maybe AppSettings
}
```
+98
View File
@@ -0,0 +1,98 @@
# PQ integration in chat
## Problem
- Group size not known when joining
- Communicate intent and current state of each conversation
## Solution
### Group size not known when joining
- Add to XGrpInv GroupInvitation
- pros: easy
- cons: size can change before joining, but can ignore as it's still a good estimate
or
- Send before introductions
- new protocol message
- XGrpIntro :: GrpIntro -> ChatMsgEvent 'Json -- (GrpIntro is a box type with Int, for possible extension)
- or put into XGrpInfo
- XGrpInfo :: GroupProfile -> GroupStats -> ChatMsgEvent 'Json -- GroupData?
- can update profile between invitation if it happened before joining
- can later add logic to "verify" stats?
- may be over-complicated until since there "supposed" use cases are out-of-scope / not planned / not known
- What should be default if it's not known? (e.g. admin has older version)
- On -> then off when member count reaches 20?
### Communicate intent and current state of each conversation
- Current state items
- RCEPQEnabled (see #3845) both for direct conversation and per member (regular event items, merged in UI)
- created when PQ changes for contact/member (e.g. received from agent on MsgMeta / SENT)
- experimental toggle is planned: it doesn't affect contacts/members with already enabled PQ
- contact enabled PQ always overrides toggle (can't downgrade)
- member enabled PQ also overrides, but can downgrade if group size increases past 20
- New items communicating state of e2e encryption in conversation
- should be well pronounced in UI, not merged
- should always say that conversation is e2e encrypted
- in direct chats:
- reflect actual state of PQ at the time of creation
- created during connection handshake when receiving first info about PQ in MsgMeta / some other event (TBC agent api)
- will not update if state changes (e.g. upgrades), as toggle is planned to be removed, PQ can't be downgraded, all will support soon
- flag in contacts table "e2e_info_created" to only create it once?
- should create for legacy contacts or not?
- in groups:
- reflect intent (should say "PQ will be used for members who support") based on number of members (see above) + toggle
- created at the same time as feature items? race with history may be possible, but we don't observe it? need to double check or ignore
- if based on XGrpInv GroupInvitation (first option above), can create item even before joining
- also will not update (as conversation progresses and it will scroll far up anyway) even if group size changes and it's disabled
- flag in groups table "e2e_info_created" to only create it once? and state is only reflected by RCEPQEnabled items?
- or create new such item if group size increases and PQ is off / decreases and PQ is on?
- "large group" thresholds have to different for group size increasing (e.g. 20) and decreases (e.g. 15), to avoid constant switching on the border.
- Example texts for "e2e encryption info" chat items:
- for direct conversations:
- with PQ (and also forward a couple releases when more clients have upgraded):
```
Messages in this conversation are end-to-end encrypted.
Post-quantum encryption is enabled.
```
- no PQ (experimental toggle disabled):
```
-//- (e2ee)
Post-quantum encryption is not enabled. [Also possibly:] Enabling post-quantum encryption in experimental settings will enable it in this conversation if your contact supports it.
```
- no PQ (experimental toggle enabled):
```
-//-
Post-quantum encryption will be enabled when your contact upgrades.
```
"upgrades" / "supports it" / "starts to support it"
- can be of different color, but seems unnecessary
- created once at the start of conversation
- created once for old contacts when PQ is enabled?
- for groups:
- with PQ (small group; toggle enabled or later, as above):
```
-//-
Post-quantum encryption will be enabled for members who support it.
```
can remove qualification later when most clients have upgraded
- no PQ (large group):
```
-//-
Post-quantum encryption is not enabled (group is too large).
```
- created each time group changes between small/large, or once?
- created for old groups when experimental toggle is first turned on, and first message is received?
- Save PQ encryption on chat items (messages)?
- in meta for direct + group rcv
- in group_snd_item_statuses for group snd?
- display in chat item details (info)
- may be overkill if aggressive upgrade strategy is planned
+1 -1
View File
@@ -1,5 +1,5 @@
name: simplex-chat
version: 5.5.3.0
version: 5.5.5.0
#synopsis:
#description:
homepage: https://github.com/simplex-chat/simplex-chat#readme
@@ -12,7 +12,6 @@ export type ChatCommand =
| APIStopChat
| SetTempFolder
| SetFilesFolder
| APISetXFTPConfig
| SetIncognito
| APIExportArchive
| APIImportArchive
@@ -112,7 +111,6 @@ type ChatCommandTag =
| "apiStopChat"
| "setTempFolder"
| "setFilesFolder"
| "apiSetXFTPConfig"
| "setIncognito"
| "apiExportArchive"
| "apiImportArchive"
@@ -242,15 +240,6 @@ export interface SetFilesFolder extends IChatCommand {
filePath: string
}
export interface APISetXFTPConfig extends IChatCommand {
type: "apiSetXFTPConfig"
config?: XFTPFileConfig
}
export interface XFTPFileConfig {
minFileSize: number
}
export interface SetIncognito extends IChatCommand {
type: "setIncognito"
incognito: boolean
@@ -707,8 +696,6 @@ export function cmdString(cmd: ChatCommand): string {
return `/_temp_folder ${cmd.tempFolder}`
case "setFilesFolder":
return `/_files_folder ${cmd.filePath}`
case "apiSetXFTPConfig":
return `/_xftp ${onOff(cmd.config)}${maybeJSON(cmd.config)}`
case "setIncognito":
return `/incognito ${onOff(cmd.incognito)}`
case "apiExportArchive":
@@ -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;
+40 -1
View File
@@ -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;
+1 -1
View File
@@ -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 -1
View File
@@ -1,5 +1,5 @@
{
"https://github.com/simplex-chat/simplexmq.git"."e64b6cba4b7e4107f78ae596ab2a6a28ef24ff78" = "0fxgklq65bh2f4kx36vjicdxqmi88m91xs601hm81v5pn6kk0ppd";
"https://github.com/simplex-chat/simplexmq.git"."246a0d10c22ebe02af2eb34773b77cce10247459" = "0kx5swx1g9jimg7ks008nqzvkyx5x9irjkjwvgwrd3km5g0wnzf4";
"https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38";
"https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d";
"https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl";
+6 -1
View File
@@ -5,7 +5,7 @@ cabal-version: 1.12
-- see: https://github.com/sol/hpack
name: simplex-chat
version: 5.5.3.0
version: 5.5.5.0
category: Web, System, Services, Cryptography
homepage: https://github.com/simplex-chat/simplex-chat#readme
author: simplex.chat
@@ -26,6 +26,7 @@ flag swift
library
exposed-modules:
Simplex.Chat
Simplex.Chat.AppSettings
Simplex.Chat.Archive
Simplex.Chat.Bot
Simplex.Chat.Bot.KnownContacts
@@ -133,6 +134,9 @@ library
Simplex.Chat.Migrations.M20240104_members_profile_update
Simplex.Chat.Migrations.M20240115_block_member_for_all
Simplex.Chat.Migrations.M20240122_indexes
Simplex.Chat.Migrations.M20240214_redirect_file_id
Simplex.Chat.Migrations.M20240222_app_settings
Simplex.Chat.Migrations.M20240226_users_restrict
Simplex.Chat.Mobile
Simplex.Chat.Mobile.File
Simplex.Chat.Mobile.Shared
@@ -148,6 +152,7 @@ library
Simplex.Chat.Remote.Transport
Simplex.Chat.Remote.Types
Simplex.Chat.Store
Simplex.Chat.Store.AppSettings
Simplex.Chat.Store.Connections
Simplex.Chat.Store.Direct
Simplex.Chat.Store.Files
+355 -297
View File
File diff suppressed because it is too large Load Diff
+190
View File
@@ -0,0 +1,190 @@
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE StrictData #-}
{-# LANGUAGE TemplateHaskell #-}
module Simplex.Chat.AppSettings where
import Control.Applicative ((<|>))
import Data.Aeson (FromJSON (..), (.:?))
import qualified Data.Aeson as J
import qualified Data.Aeson.TH as JQ
import Data.Maybe (fromMaybe)
import Data.Text (Text)
import Simplex.Messaging.Client (NetworkConfig, defaultNetworkConfig)
import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON)
import Simplex.Messaging.Util (catchAll_)
data AppPlatform = APIOS | APAndroid | APDesktop deriving (Show)
data NotificationMode = NMOff | NMPeriodic | NMInstant deriving (Show)
data NotificationPreviewMode = NPMHidden | NPMContact | NPMMessage deriving (Show)
data LockScreenCalls = LSCDisable | LSCShow | LSCAccept deriving (Show)
data AppSettings = AppSettings
{ appPlatform :: Maybe AppPlatform,
networkConfig :: Maybe NetworkConfig,
privacyEncryptLocalFiles :: Maybe Bool,
privacyAcceptImages :: Maybe Bool,
privacyLinkPreviews :: Maybe Bool,
privacyShowChatPreviews :: Maybe Bool,
privacySaveLastDraft :: Maybe Bool,
privacyProtectScreen :: Maybe Bool,
notificationMode :: Maybe NotificationMode,
notificationPreviewMode :: Maybe NotificationPreviewMode,
webrtcPolicyRelay :: Maybe Bool,
webrtcICEServers :: Maybe [Text],
confirmRemoteSessions :: Maybe Bool,
connectRemoteViaMulticast :: Maybe Bool,
connectRemoteViaMulticastAuto :: Maybe Bool,
developerTools :: Maybe Bool,
confirmDBUpgrades :: Maybe Bool,
androidCallOnLockScreen :: Maybe LockScreenCalls,
iosCallKitEnabled :: Maybe Bool,
iosCallKitCallsInRecents :: Maybe Bool
}
deriving (Show)
defaultAppSettings :: AppSettings
defaultAppSettings =
AppSettings
{ appPlatform = Nothing,
networkConfig = Just defaultNetworkConfig,
privacyEncryptLocalFiles = Just True,
privacyAcceptImages = Just True,
privacyLinkPreviews = Just True,
privacyShowChatPreviews = Just True,
privacySaveLastDraft = Just True,
privacyProtectScreen = Just False,
notificationMode = Just NMInstant,
notificationPreviewMode = Just NPMMessage,
webrtcPolicyRelay = Just True,
webrtcICEServers = Just [],
confirmRemoteSessions = Just False,
connectRemoteViaMulticast = Just True,
connectRemoteViaMulticastAuto = Just True,
developerTools = Just False,
confirmDBUpgrades = Just False,
androidCallOnLockScreen = Just LSCShow,
iosCallKitEnabled = Just True,
iosCallKitCallsInRecents = Just False
}
defaultParseAppSettings :: AppSettings
defaultParseAppSettings =
AppSettings
{ appPlatform = Nothing,
networkConfig = Nothing,
privacyEncryptLocalFiles = Nothing,
privacyAcceptImages = Nothing,
privacyLinkPreviews = Nothing,
privacyShowChatPreviews = Nothing,
privacySaveLastDraft = Nothing,
privacyProtectScreen = Nothing,
notificationMode = Nothing,
notificationPreviewMode = Nothing,
webrtcPolicyRelay = Nothing,
webrtcICEServers = Nothing,
confirmRemoteSessions = Nothing,
connectRemoteViaMulticast = Nothing,
connectRemoteViaMulticastAuto = Nothing,
developerTools = Nothing,
confirmDBUpgrades = Nothing,
androidCallOnLockScreen = Nothing,
iosCallKitEnabled = Nothing,
iosCallKitCallsInRecents = Nothing
}
combineAppSettings :: AppSettings -> AppSettings -> AppSettings
combineAppSettings platformDefaults storedSettings =
AppSettings
{ appPlatform = p appPlatform,
networkConfig = p networkConfig,
privacyEncryptLocalFiles = p privacyEncryptLocalFiles,
privacyAcceptImages = p privacyAcceptImages,
privacyLinkPreviews = p privacyLinkPreviews,
privacyShowChatPreviews = p privacyShowChatPreviews,
privacySaveLastDraft = p privacySaveLastDraft,
privacyProtectScreen = p privacyProtectScreen,
notificationMode = p notificationMode,
notificationPreviewMode = p notificationPreviewMode,
webrtcPolicyRelay = p webrtcPolicyRelay,
webrtcICEServers = p webrtcICEServers,
confirmRemoteSessions = p confirmRemoteSessions,
connectRemoteViaMulticast = p connectRemoteViaMulticast,
connectRemoteViaMulticastAuto = p connectRemoteViaMulticastAuto,
developerTools = p developerTools,
confirmDBUpgrades = p confirmDBUpgrades,
iosCallKitEnabled = p iosCallKitEnabled,
iosCallKitCallsInRecents = p iosCallKitCallsInRecents,
androidCallOnLockScreen = p androidCallOnLockScreen
}
where
p :: (AppSettings -> Maybe a) -> Maybe a
p sel = sel storedSettings <|> sel platformDefaults <|> sel defaultAppSettings
$(JQ.deriveJSON (enumJSON $ dropPrefix "AP") ''AppPlatform)
$(JQ.deriveJSON (enumJSON $ dropPrefix "NM") ''NotificationMode)
$(JQ.deriveJSON (enumJSON $ dropPrefix "NPM") ''NotificationPreviewMode)
$(JQ.deriveJSON (enumJSON $ dropPrefix "LSC") ''LockScreenCalls)
$(JQ.deriveToJSON defaultJSON ''AppSettings)
instance FromJSON AppSettings where
parseJSON (J.Object v) = do
appPlatform <- p "appPlatform"
networkConfig <- p "networkConfig"
privacyEncryptLocalFiles <- p "privacyEncryptLocalFiles"
privacyAcceptImages <- p "privacyAcceptImages"
privacyLinkPreviews <- p "privacyLinkPreviews"
privacyShowChatPreviews <- p "privacyShowChatPreviews"
privacySaveLastDraft <- p "privacySaveLastDraft"
privacyProtectScreen <- p "privacyProtectScreen"
notificationMode <- p "notificationMode"
notificationPreviewMode <- p "notificationPreviewMode"
webrtcPolicyRelay <- p "webrtcPolicyRelay"
webrtcICEServers <- p "webrtcICEServers"
confirmRemoteSessions <- p "confirmRemoteSessions"
connectRemoteViaMulticast <- p "connectRemoteViaMulticast"
connectRemoteViaMulticastAuto <- p "connectRemoteViaMulticastAuto"
developerTools <- p "developerTools"
confirmDBUpgrades <- p "confirmDBUpgrades"
iosCallKitEnabled <- p "iosCallKitEnabled"
iosCallKitCallsInRecents <- p "iosCallKitCallsInRecents"
androidCallOnLockScreen <- p "androidCallOnLockScreen"
pure
AppSettings
{ appPlatform,
networkConfig,
privacyEncryptLocalFiles,
privacyAcceptImages,
privacyLinkPreviews,
privacyShowChatPreviews,
privacySaveLastDraft,
privacyProtectScreen,
notificationMode,
notificationPreviewMode,
webrtcPolicyRelay,
webrtcICEServers,
confirmRemoteSessions,
connectRemoteViaMulticast,
connectRemoteViaMulticastAuto,
developerTools,
confirmDBUpgrades,
iosCallKitEnabled,
iosCallKitCallsInRecents,
androidCallOnLockScreen
}
where
p key = v .:? key <|> pure Nothing
parseJSON _ = pure defaultParseAppSettings
readAppSettings :: FilePath -> Maybe AppSettings -> IO AppSettings
readAppSettings f platformDefaults =
combineAppSettings (fromMaybe defaultAppSettings platformDefaults) . fromMaybe defaultParseAppSettings
<$> (J.decodeFileStrict f `catchAll_` pure Nothing)
+35 -20
View File
@@ -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)
+34 -33
View File
@@ -49,6 +49,7 @@ import Data.Word (Word16)
import Language.Haskell.TH (Exp, Q, runIO)
import Numeric.Natural
import qualified Paths_simplex_chat as SC
import Simplex.Chat.AppSettings
import Simplex.Chat.Call
import Simplex.Chat.Markdown (MarkdownList)
import Simplex.Chat.Messages
@@ -59,6 +60,7 @@ import Simplex.Chat.Remote.Types
import Simplex.Chat.Store (AutoAccept, StoreError (..), UserContactLink, UserMsgReceiptSettings)
import Simplex.Chat.Types
import Simplex.Chat.Types.Preferences
import Simplex.FileTransfer.Description (FileDescriptionURI)
import Simplex.Messaging.Agent (AgentClient, SubscriptionsInfo)
import Simplex.Messaging.Agent.Client (AgentLocks, AgentWorkersDetails (..), AgentWorkersSummary (..), ProtocolTestFailure)
import Simplex.Messaging.Agent.Env.SQLite (AgentConfig, NetworkConfig)
@@ -128,8 +130,6 @@ data ChatConfig = ChatConfig
xftpDescrPartSize :: Int,
inlineFiles :: InlineFilesConfig,
autoAcceptFileSize :: Integer,
xftpFileConfig :: Maybe XFTPFileConfig, -- Nothing - XFTP is disabled
tempDir :: Maybe FilePath,
showReactions :: Bool,
showReceipts :: Bool,
subscriptionEvents :: Bool,
@@ -204,7 +204,6 @@ data ChatController = ChatController
timedItemThreads :: TMap (ChatRef, ChatItemId) (TVar (Maybe (Weak ThreadId))),
showLiveItems :: TVar Bool,
encryptLocalFiles :: TVar Bool,
userXFTPFileConfig :: TVar (Maybe XFTPFileConfig),
tempDirectory :: TVar (Maybe FilePath),
logFilePath :: Maybe FilePath,
contactMergeEnabled :: TVar Bool
@@ -242,14 +241,16 @@ data ChatCommand
| SetTempFolder FilePath
| SetFilesFolder FilePath
| SetRemoteHostsFolder FilePath
| APISetXFTPConfig (Maybe XFTPFileConfig)
| APISetEncryptLocalFiles Bool
| SetContactMergeEnabled Bool
| APIExportArchive ArchiveConfig
| ExportArchive
| APIImportArchive ArchiveConfig
| APISaveAppSettings AppSettings
| APIGetAppSettings (Maybe AppSettings)
| APIDeleteStorage
| APIStorageEncryption DBEncryptionConfig
| TestStorageEncryption DBEncryptionKey
| ExecChatStoreSQL Text
| ExecAgentStoreSQL Text
| SlowSQLQueries
@@ -452,6 +453,8 @@ data ChatCommand
| ListRemoteCtrls
| StopRemoteCtrl -- Stop listening for announcements or terminate an active session
| DeleteRemoteCtrl RemoteCtrlId -- Remove all local data associated with a remote controller session
| APIUploadStandaloneFile UserId CryptoFile
| APIDownloadStandaloneFile UserId FileDescriptionURI CryptoFile
| QuitChat
| ShowVersion
| DebugLocks
@@ -473,7 +476,6 @@ allowRemoteCommand = \case
SetTempFolder _ -> False
SetFilesFolder _ -> False
SetRemoteHostsFolder _ -> False
APISetXFTPConfig _ -> False
APISetEncryptLocalFiles _ -> False
APIExportArchive _ -> False
APIImportArchive _ -> False
@@ -592,21 +594,26 @@ data ChatResponse
| CRRcvFileAccepted {user :: User, chatItem :: AChatItem}
| CRRcvFileAcceptedSndCancelled {user :: User, rcvFileTransfer :: RcvFileTransfer}
| CRRcvFileDescrNotReady {user :: User, chatItem :: AChatItem}
| CRRcvFileStart {user :: User, chatItem :: AChatItem}
| CRRcvFileProgressXFTP {user :: User, chatItem :: AChatItem, receivedSize :: Int64, totalSize :: Int64}
| CRRcvStandaloneFileCreated {user :: User, rcvFileTransfer :: RcvFileTransfer} -- returned by _download
| CRRcvFileStart {user :: User, chatItem :: AChatItem} -- sent by chats
| CRRcvFileProgressXFTP {user :: User, chatItem_ :: Maybe AChatItem, receivedSize :: Int64, totalSize :: Int64, rcvFileTransfer :: RcvFileTransfer}
| CRRcvFileComplete {user :: User, chatItem :: AChatItem}
| CRRcvFileCancelled {user :: User, chatItem :: AChatItem, rcvFileTransfer :: RcvFileTransfer}
| CRRcvStandaloneFileComplete {user :: User, targetPath :: FilePath, rcvFileTransfer :: RcvFileTransfer}
| CRRcvFileCancelled {user :: User, chatItem_ :: Maybe AChatItem, rcvFileTransfer :: RcvFileTransfer}
| CRRcvFileSndCancelled {user :: User, chatItem :: AChatItem, rcvFileTransfer :: RcvFileTransfer}
| CRRcvFileError {user :: User, chatItem :: AChatItem, agentError :: AgentErrorType}
| CRRcvFileError {user :: User, chatItem_ :: Maybe AChatItem, agentError :: AgentErrorType, rcvFileTransfer :: RcvFileTransfer}
| CRSndFileStart {user :: User, chatItem :: AChatItem, sndFileTransfer :: SndFileTransfer}
| CRSndFileComplete {user :: User, chatItem :: AChatItem, sndFileTransfer :: SndFileTransfer}
| CRSndFileRcvCancelled {user :: User, chatItem :: AChatItem, sndFileTransfer :: SndFileTransfer}
| CRSndFileCancelled {user :: User, chatItem :: AChatItem, fileTransferMeta :: FileTransferMeta, sndFileTransfers :: [SndFileTransfer]}
| CRSndFileStartXFTP {user :: User, chatItem :: AChatItem, fileTransferMeta :: FileTransferMeta}
| CRSndFileProgressXFTP {user :: User, chatItem :: AChatItem, fileTransferMeta :: FileTransferMeta, sentSize :: Int64, totalSize :: Int64}
| CRSndFileRcvCancelled {user :: User, chatItem_ :: Maybe AChatItem, sndFileTransfer :: SndFileTransfer}
| CRSndFileCancelled {user :: User, chatItem_ :: Maybe AChatItem, fileTransferMeta :: FileTransferMeta, sndFileTransfers :: [SndFileTransfer]}
| CRSndStandaloneFileCreated {user :: User, fileTransferMeta :: FileTransferMeta} -- returned by _upload
| CRSndFileStartXFTP {user :: User, chatItem :: AChatItem, fileTransferMeta :: FileTransferMeta} -- not used
| CRSndFileProgressXFTP {user :: User, chatItem_ :: Maybe AChatItem, fileTransferMeta :: FileTransferMeta, sentSize :: Int64, totalSize :: Int64}
| CRSndFileRedirectStartXFTP {user :: User, fileTransferMeta :: FileTransferMeta, redirectMeta :: FileTransferMeta}
| CRSndFileCompleteXFTP {user :: User, chatItem :: AChatItem, fileTransferMeta :: FileTransferMeta}
| CRSndFileCancelledXFTP {user :: User, chatItem :: AChatItem, fileTransferMeta :: FileTransferMeta}
| CRSndFileError {user :: User, chatItem :: AChatItem}
| CRSndStandaloneFileComplete {user :: User, fileTransferMeta :: FileTransferMeta, rcvURIs :: [Text]}
| CRSndFileCancelledXFTP {user :: User, chatItem_ :: Maybe AChatItem, fileTransferMeta :: FileTransferMeta}
| CRSndFileError {user :: User, chatItem_ :: Maybe AChatItem, fileTransferMeta :: FileTransferMeta}
| CRUserProfileUpdated {user :: User, fromProfile :: Profile, toProfile :: Profile, updateSummary :: UserProfileUpdateSummary}
| CRUserProfileImage {user :: User, profile :: Profile}
| CRContactAliasUpdated {user :: User, toContact :: Contact}
@@ -672,7 +679,7 @@ data ChatResponse
| CRUserContactLinkSubscribed -- TODO delete
| CRUserContactLinkSubError {chatError :: ChatError} -- TODO delete
| CRNtfTokenStatus {status :: NtfTknStatus}
| CRNtfToken {token :: DeviceToken, status :: NtfTknStatus, ntfMode :: NotificationsMode}
| CRNtfToken {token :: DeviceToken, status :: NtfTknStatus, ntfMode :: NotificationsMode, ntfServer :: NtfServer}
| CRNtfMessages {user_ :: Maybe User, connEntity_ :: Maybe ConnectionEntity, msgTs :: Maybe UTCTime, ntfMessages :: [NtfMsgInfo]}
| CRNtfMessage {user :: User, connEntity :: ConnectionEntity, ntfMessage :: NtfMsgInfo}
| CRContactConnectionDeleted {user :: User, connection :: PendingContactConnection}
@@ -707,6 +714,7 @@ data ChatResponse
| CRChatError {user_ :: Maybe User, chatError :: ChatError}
| CRChatErrors {user_ :: Maybe User, chatErrors :: [ChatError]}
| CRArchiveImported {archiveErrors :: [ArchiveError]}
| CRAppSettings {appSettings :: AppSettings}
| CRTimedAction {action :: String, durationMilliseconds :: Int64}
deriving (Show)
@@ -934,22 +942,14 @@ instance FromJSON ComposedMessage where
parseJSON invalid =
JT.prependFailure "bad ComposedMessage, " (JT.typeMismatch "Object" invalid)
data XFTPFileConfig = XFTPFileConfig
{ minFileSize :: Integer
}
deriving (Show)
defaultXFTPFileConfig :: XFTPFileConfig
defaultXFTPFileConfig = XFTPFileConfig {minFileSize = 0}
data NtfMsgInfo = NtfMsgInfo {msgId :: Text, msgTs :: UTCTime}
deriving (Show)
ntfMsgInfo :: SMPMsgMeta -> NtfMsgInfo
ntfMsgInfo SMPMsgMeta {msgId, msgTs} = NtfMsgInfo {msgId = decodeLatin1 $ strEncode msgId, msgTs = systemToUTCTime msgTs}
crNtfToken :: (DeviceToken, NtfTknStatus, NotificationsMode) -> ChatResponse
crNtfToken (token, status, ntfMode) = CRNtfToken {token, status, ntfMode}
crNtfToken :: (DeviceToken, NtfTknStatus, NotificationsMode, NtfServer) -> ChatResponse
crNtfToken (token, status, ntfMode, ntfServer) = CRNtfToken {token, status, ntfMode, ntfServer}
data SwitchProgress = SwitchProgress
{ queueDirection :: QueueDirection,
@@ -1001,11 +1001,6 @@ data CoreVersionInfo = CoreVersionInfo
}
deriving (Show)
data SendFileMode
= SendFileSMP (Maybe InlineFileMode)
| SendFileXFTP
deriving (Show)
data SlowSQLQuery = SlowSQLQuery
{ query :: Text,
queryStats :: SlowQueryStats
@@ -1257,6 +1252,14 @@ mkChatError :: SomeException -> ChatError
mkChatError = ChatError . CEException . show
{-# INLINE mkChatError #-}
catchStoreError :: ExceptT StoreError IO a -> (StoreError -> ExceptT StoreError IO a) -> ExceptT StoreError IO a
catchStoreError = catchAllErrors mkStoreError
{-# INLINE catchStoreError #-}
mkStoreError :: SomeException -> StoreError
mkStoreError = SEInternalError . show
{-# INLINE mkStoreError #-}
chatCmdError :: Maybe User -> String -> ChatResponse
chatCmdError user = CRChatCmdError user . ChatError . CECommandError
@@ -1409,6 +1412,4 @@ $(JQ.deriveFromJSON defaultJSON ''ArchiveConfig)
$(JQ.deriveFromJSON defaultJSON ''DBEncryptionConfig)
$(JQ.deriveJSON defaultJSON ''XFTPFileConfig)
$(JQ.deriveToJSON defaultJSON ''ComposedMessage)
+4 -3
View File
@@ -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
+18
View File
@@ -360,6 +360,24 @@ mkCIMeta itemId itemContent itemText itemStatus itemSharedMsgId itemDeleted item
_ -> False
in CIMeta {itemId, itemTs, itemText, itemStatus, itemSharedMsgId, itemDeleted, itemEdited, itemTimed, itemLive, editable, forwardedByMember, createdAt, updatedAt}
dummyMeta :: ChatItemId -> UTCTime -> Text -> CIMeta c 'MDSnd
dummyMeta itemId ts itemText =
CIMeta
{ itemId,
itemTs = ts,
itemText,
itemStatus = CISSndNew,
itemSharedMsgId = Nothing,
itemDeleted = Nothing,
itemEdited = False,
itemTimed = Nothing,
itemLive = Nothing,
editable = False,
forwardedByMember = Nothing,
createdAt = ts,
updatedAt = ts
}
data CITimed = CITimed
{ ttl :: Int, -- seconds
deleteAt :: Maybe UTCTime -- this is initially Nothing for received items, the timer starts when they are read
+2 -2
View File
@@ -139,7 +139,7 @@ data CIContent (d :: MsgDirection) where
CISndModerated :: CIContent 'MDSnd
CIRcvModerated :: CIContent 'MDRcv
CIRcvBlocked :: CIContent 'MDRcv
CIInvalidJSON :: Text -> CIContent d
CIInvalidJSON :: Text -> CIContent d -- this is also used for logical database errors, e.g. SEBadChatItem
-- ^ This type is used both in API and in DB, so we use different JSON encodings for the database and for the API
-- ! ^ Nested sum types also have to use different encodings for database and API
-- ! ^ to avoid breaking cross-platform compatibility, see RcvGroupEvent and SndGroupEvent
@@ -172,7 +172,7 @@ ciRequiresAttention content = case msgDirection @d of
CIRcvGroupInvitation {} -> True
CIRcvDirectEvent rde -> case rde of
RDEContactDeleted -> False
RDEProfileUpdated {} -> True
RDEProfileUpdated {} -> False
CIRcvGroupEvent rge -> case rge of
RGEMemberAdded {} -> False
RGEMemberConnected -> False
@@ -0,0 +1,22 @@
{-# LANGUAGE QuasiQuotes #-}
module Simplex.Chat.Migrations.M20240214_redirect_file_id where
import Database.SQLite.Simple (Query)
import Database.SQLite.Simple.QQ (sql)
m20240214_redirect_file_id :: Query
m20240214_redirect_file_id =
[sql|
ALTER TABLE files ADD COLUMN redirect_file_id INTEGER REFERENCES files ON DELETE CASCADE;
CREATE INDEX idx_files_redirect_file_id on files(redirect_file_id);
|]
down_m20240214_redirect_file_id :: Query
down_m20240214_redirect_file_id =
[sql|
DROP INDEX idx_files_redirect_file_id;
ALTER TABLE files DROP COLUMN redirect_file_id;
|]
@@ -0,0 +1,20 @@
{-# LANGUAGE QuasiQuotes #-}
module Simplex.Chat.Migrations.M20240222_app_settings where
import Database.SQLite.Simple (Query)
import Database.SQLite.Simple.QQ (sql)
m20240222_app_settings :: Query
m20240222_app_settings =
[sql|
CREATE TABLE app_settings (
app_settings TEXT NOT NULL
);
|]
down_m20240222_app_settings :: Query
down_m20240222_app_settings =
[sql|
DROP TABLE app_settings;
|]
@@ -0,0 +1,30 @@
{-# LANGUAGE QuasiQuotes #-}
module Simplex.Chat.Migrations.M20240226_users_restrict where
import Database.SQLite.Simple (Query)
import Database.SQLite.Simple.QQ (sql)
m20240226_users_restrict :: Query
m20240226_users_restrict =
[sql|
PRAGMA writable_schema=1;
UPDATE sqlite_master
SET sql = replace(sql, 'ON DELETE CASCADE', 'ON DELETE RESTRICT')
WHERE name = 'users' AND type = 'table';
PRAGMA writable_schema=0;
|]
down_m20240226_users_restrict :: Query
down_m20240226_users_restrict =
[sql|
PRAGMA writable_schema=1;
UPDATE sqlite_master
SET sql = replace(sql, 'ON DELETE RESTRICT', 'ON DELETE CASCADE')
WHERE name = 'users' AND type = 'table';
PRAGMA writable_schema=0;
|]
+6 -3
View File
@@ -22,7 +22,7 @@ CREATE TABLE contact_profiles(
);
CREATE TABLE users(
user_id INTEGER PRIMARY KEY,
contact_id INTEGER NOT NULL UNIQUE REFERENCES contacts ON DELETE CASCADE
contact_id INTEGER NOT NULL UNIQUE REFERENCES contacts ON DELETE RESTRICT
DEFERRABLE INITIALLY DEFERRED,
local_display_name TEXT NOT NULL UNIQUE,
active_user INTEGER NOT NULL DEFAULT 0,
@@ -37,7 +37,7 @@ CREATE TABLE users(
user_member_profile_updated_at TEXT, -- 1 for active user
FOREIGN KEY(user_id, local_display_name)
REFERENCES display_names(user_id, local_display_name)
ON DELETE CASCADE
ON DELETE RESTRICT
ON UPDATE CASCADE
DEFERRABLE INITIALLY DEFERRED
);
@@ -193,7 +193,8 @@ CREATE TABLE files(
protocol TEXT NOT NULL DEFAULT 'smp',
file_crypto_key BLOB,
file_crypto_nonce BLOB,
note_folder_id INTEGER DEFAULT NULL REFERENCES note_folders ON DELETE CASCADE
note_folder_id INTEGER DEFAULT NULL REFERENCES note_folders ON DELETE CASCADE,
redirect_file_id INTEGER REFERENCES files ON DELETE CASCADE
);
CREATE TABLE snd_files(
file_id INTEGER NOT NULL REFERENCES files ON DELETE CASCADE,
@@ -561,6 +562,7 @@ CREATE TABLE note_folders(
favorite INTEGER NOT NULL DEFAULT 0,
unread_chat INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE app_settings(app_settings TEXT NOT NULL);
CREATE INDEX contact_profiles_index ON contact_profiles(
display_name,
full_name
@@ -854,3 +856,4 @@ CREATE INDEX idx_chat_items_notes_item_status on chat_items(
note_folder_id,
item_status
);
CREATE INDEX idx_files_redirect_file_id on files(redirect_file_id);
+22
View File
@@ -0,0 +1,22 @@
{-# LANGUAGE OverloadedStrings #-}
module Simplex.Chat.Store.AppSettings where
import Control.Monad (join)
import Control.Monad.IO.Class (liftIO)
import qualified Data.Aeson as J
import Data.Maybe (fromMaybe)
import Database.SQLite.Simple (Only (..))
import Simplex.Chat.AppSettings (AppSettings (..), combineAppSettings, defaultAppSettings, defaultParseAppSettings)
import Simplex.Messaging.Agent.Store.SQLite (maybeFirstRow)
import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB
saveAppSettings :: DB.Connection -> AppSettings -> IO ()
saveAppSettings db appSettings = do
DB.execute_ db "DELETE FROM app_settings"
DB.execute db "INSERT INTO app_settings (app_settings) VALUES (?)" (Only $ J.encode appSettings)
getAppSettings :: DB.Connection -> Maybe AppSettings -> IO AppSettings
getAppSettings db platformDefaults = do
stored_ <- join <$> liftIO (maybeFirstRow (J.decodeStrict . fromOnly) $ DB.query_ db "SELECT app_settings FROM app_settings")
pure $ combineAppSettings (fromMaybe defaultAppSettings platformDefaults) (fromMaybe defaultParseAppSettings stored_)
+43 -34
View File
@@ -229,37 +229,45 @@ deleteContactConnectionsAndFiles db userId Contact {contactId} = do
(userId, contactId)
DB.execute db "DELETE FROM files WHERE user_id = ? AND contact_id = ?" (userId, contactId)
deleteContact :: DB.Connection -> User -> Contact -> IO ()
deleteContact db user@User {userId} Contact {contactId, localDisplayName, activeConn} = do
DB.execute db "DELETE FROM chat_items WHERE user_id = ? AND contact_id = ?" (userId, contactId)
ctMember :: (Maybe ContactId) <- maybeFirstRow fromOnly $ DB.query db "SELECT contact_id FROM group_members WHERE user_id = ? AND contact_id = ? LIMIT 1" (userId, contactId)
if isNothing ctMember
then do
deleteContactProfile_ db userId contactId
DB.execute db "DELETE FROM display_names WHERE user_id = ? AND local_display_name = ?" (userId, localDisplayName)
else do
currentTs <- getCurrentTime
DB.execute db "UPDATE group_members SET contact_id = NULL, updated_at = ? WHERE user_id = ? AND contact_id = ?" (currentTs, userId, contactId)
DB.execute db "DELETE FROM contacts WHERE user_id = ? AND contact_id = ?" (userId, contactId)
forM_ activeConn $ \Connection {customUserProfileId} ->
forM_ customUserProfileId $ \profileId ->
deleteUnusedIncognitoProfileById_ db user profileId
deleteContact :: DB.Connection -> User -> Contact -> ExceptT StoreError IO ()
deleteContact db user@User {userId} ct@Contact {contactId, localDisplayName, activeConn} = do
assertNotUser db user ct
liftIO $ do
DB.execute db "DELETE FROM chat_items WHERE user_id = ? AND contact_id = ?" (userId, contactId)
ctMember :: (Maybe ContactId) <- maybeFirstRow fromOnly $ DB.query db "SELECT contact_id FROM group_members WHERE user_id = ? AND contact_id = ? LIMIT 1" (userId, contactId)
if isNothing ctMember
then do
deleteContactProfile_ db userId contactId
-- user's local display name already checked in assertNotUser
DB.execute db "DELETE FROM display_names WHERE user_id = ? AND local_display_name = ?" (userId, localDisplayName)
else do
currentTs <- getCurrentTime
DB.execute db "UPDATE group_members SET contact_id = NULL, updated_at = ? WHERE user_id = ? AND contact_id = ?" (currentTs, userId, contactId)
DB.execute db "DELETE FROM contacts WHERE user_id = ? AND contact_id = ?" (userId, contactId)
forM_ activeConn $ \Connection {customUserProfileId} ->
forM_ customUserProfileId $ \profileId ->
deleteUnusedIncognitoProfileById_ db user profileId
-- should only be used if contact is not member of any groups
deleteContactWithoutGroups :: DB.Connection -> User -> Contact -> IO ()
deleteContactWithoutGroups db user@User {userId} Contact {contactId, localDisplayName, activeConn} = do
DB.execute db "DELETE FROM chat_items WHERE user_id = ? AND contact_id = ?" (userId, contactId)
deleteContactProfile_ db userId contactId
DB.execute db "DELETE FROM display_names WHERE user_id = ? AND local_display_name = ?" (userId, localDisplayName)
DB.execute db "DELETE FROM contacts WHERE user_id = ? AND contact_id = ?" (userId, contactId)
forM_ activeConn $ \Connection {customUserProfileId} ->
forM_ customUserProfileId $ \profileId ->
deleteUnusedIncognitoProfileById_ db user profileId
deleteContactWithoutGroups :: DB.Connection -> User -> Contact -> ExceptT StoreError IO ()
deleteContactWithoutGroups db user@User {userId} ct@Contact {contactId, localDisplayName, activeConn} = do
assertNotUser db user ct
liftIO $ do
DB.execute db "DELETE FROM chat_items WHERE user_id = ? AND contact_id = ?" (userId, contactId)
deleteContactProfile_ db userId contactId
-- user's local display name already checked in assertNotUser
DB.execute db "DELETE FROM display_names WHERE user_id = ? AND local_display_name = ?" (userId, localDisplayName)
DB.execute db "DELETE FROM contacts WHERE user_id = ? AND contact_id = ?" (userId, contactId)
forM_ activeConn $ \Connection {customUserProfileId} ->
forM_ customUserProfileId $ \profileId ->
deleteUnusedIncognitoProfileById_ db user profileId
setContactDeleted :: DB.Connection -> User -> Contact -> IO ()
setContactDeleted db User {userId} Contact {contactId} = do
currentTs <- getCurrentTime
DB.execute db "UPDATE contacts SET deleted = 1, updated_at = ? WHERE user_id = ? AND contact_id = ?" (currentTs, userId, contactId)
setContactDeleted :: DB.Connection -> User -> Contact -> ExceptT StoreError IO ()
setContactDeleted db user@User {userId} ct@Contact {contactId} = do
assertNotUser db user ct
liftIO $ do
currentTs <- getCurrentTime
DB.execute db "UPDATE contacts SET deleted = 1, updated_at = ? WHERE user_id = ? AND contact_id = ?" (currentTs, userId, contactId)
getDeletedContacts :: DB.Connection -> User -> IO [Contact]
getDeletedContacts db user@User {userId} = do
@@ -320,7 +328,7 @@ updateContactProfile db user@User {userId} c p'
ExceptT . withLocalDisplayName db userId newName $ \ldn -> do
currentTs <- getCurrentTime
updateContactProfile_' db userId profileId p' currentTs
updateContactLDN_ db userId contactId localDisplayName ldn currentTs
updateContactLDN_ db user contactId localDisplayName ldn currentTs
pure $ Right c {localDisplayName = ldn, profile, mergedPreferences}
where
Contact {contactId, localDisplayName, profile = LocalProfile {profileId, displayName, localAlias}, userPreferences} = c
@@ -491,8 +499,8 @@ updateMemberContactProfile_' db userId profileId Profile {displayName, fullName,
|]
(displayName, fullName, image, updatedAt, userId, profileId)
updateContactLDN_ :: DB.Connection -> UserId -> Int64 -> ContactName -> ContactName -> UTCTime -> IO ()
updateContactLDN_ db userId contactId displayName newName updatedAt = do
updateContactLDN_ :: DB.Connection -> User -> Int64 -> ContactName -> ContactName -> UTCTime -> IO ()
updateContactLDN_ db user@User {userId} contactId displayName newName updatedAt = do
DB.execute
db
"UPDATE contacts SET local_display_name = ?, updated_at = ? WHERE user_id = ? AND contact_id = ?"
@@ -501,7 +509,7 @@ updateContactLDN_ db userId contactId displayName newName updatedAt = do
db
"UPDATE group_members SET local_display_name = ?, updated_at = ? WHERE user_id = ? AND contact_id = ?"
(newName, updatedAt, userId, contactId)
DB.execute db "DELETE FROM display_names WHERE local_display_name = ? AND user_id = ?" (displayName, userId)
safeDeleteLDN db user displayName
getContactByName :: DB.Connection -> User -> ContactName -> ExceptT StoreError IO Contact
getContactByName db user localDisplayName = do
@@ -614,7 +622,7 @@ createOrUpdateContactRequest db user@User {userId} userContactLinkId invId (Vers
WHERE user_id = ? AND contact_request_id = ?
|]
(invId, minV, maxV, ldn, currentTs, userId, cReqId)
DB.execute db "DELETE FROM display_names WHERE local_display_name = ? AND user_id = ?" (oldLdn, userId)
safeDeleteLDN db user oldLdn
where
updateProfile currentTs =
DB.execute
@@ -684,8 +692,9 @@ deleteContactRequest db User {userId} contactRequestId = do
SELECT local_display_name FROM contact_requests
WHERE user_id = ? AND contact_request_id = ?
)
AND local_display_name NOT IN (SELECT local_display_name FROM users WHERE user_id = ?)
|]
(userId, userId, contactRequestId)
(userId, userId, contactRequestId, userId)
DB.execute db "DELETE FROM contact_requests WHERE user_id = ? AND contact_request_id = ?" (userId, contactRequestId)
createAcceptedContact :: DB.Connection -> User -> ConnId -> VersionRange -> ContactName -> ProfileId -> Profile -> Int64 -> Maybe XContactId -> Maybe IncognitoProfile -> SubscriptionMode -> Bool -> IO Contact
+50 -35
View File
@@ -14,7 +14,6 @@ module Simplex.Chat.Store.Files
( getLiveSndFileTransfers,
getLiveRcvFileTransfers,
getPendingSndChunks,
createSndDirectFileTransfer,
createSndDirectFTConnection,
createSndGroupFileTransfer,
createSndGroupFileTransferConnection,
@@ -39,6 +38,7 @@ module Simplex.Chat.Store.Files
getGroupFileIdBySharedMsgId,
getDirectFileIdBySharedMsgId,
getChatRefByFileId,
lookupChatRefByFileId,
updateSndFileStatus,
createSndFileChunk,
updateSndFileChunkMsg,
@@ -46,6 +46,7 @@ module Simplex.Chat.Store.Files
deleteSndFileChunks,
createRcvFileTransfer,
createRcvGroupFileTransfer,
createRcvStandaloneFileTransfer,
appendRcvFD,
getRcvFileDescrByRcvFileId,
getRcvFileDescrBySndFileId,
@@ -70,6 +71,7 @@ module Simplex.Chat.Store.Files
getFileTransfer,
getFileTransferProgress,
getFileTransferMeta,
lookupFileTransferRedirectMeta,
getSndFileTransfer,
getSndFileTransfers,
getContactFileInfo,
@@ -86,12 +88,14 @@ import Control.Monad
import Control.Monad.Except
import Control.Monad.IO.Class
import Data.Either (rights)
import Data.Functor ((<&>))
import Data.Int (Int64)
import Data.Maybe (fromMaybe, isJust, listToMaybe)
import Data.Text (Text)
import Data.Time (addUTCTime)
import Data.Time.Clock (UTCTime (..), getCurrentTime, nominalDay)
import Data.Type.Equality
import Data.Word (Word32)
import Database.SQLite.Simple (Only (..), (:.) (..))
import Database.SQLite.Simple.QQ (sql)
import Database.SQLite.Simple.ToField (ToField)
@@ -169,23 +173,6 @@ getPendingSndChunks db fileId connId =
|]
(fileId, connId)
createSndDirectFileTransfer :: DB.Connection -> UserId -> Contact -> FilePath -> FileInvitation -> Maybe ConnId -> Integer -> SubscriptionMode -> IO FileTransferMeta
createSndDirectFileTransfer db userId Contact {contactId} filePath FileInvitation {fileName, fileSize, fileInline} acId_ chunkSize subMode = do
currentTs <- getCurrentTime
DB.execute
db
"INSERT INTO files (user_id, contact_id, file_name, file_path, file_size, chunk_size, file_inline, ci_file_status, protocol, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?)"
((userId, contactId, fileName, filePath, fileSize, chunkSize) :. (fileInline, CIFSSndStored, FPSMP, currentTs, currentTs))
fileId <- insertedRowId db
forM_ acId_ $ \acId -> do
Connection {connId} <- createSndFileConnection_ db userId fileId acId subMode
let fileStatus = FSNew
DB.execute
db
"INSERT INTO snd_files (file_id, file_status, file_inline, connection_id, created_at, updated_at) VALUES (?,?,?,?,?,?)"
(fileId, fileStatus, fileInline, connId, currentTs, currentTs)
pure FileTransferMeta {fileId, xftpSndFile = Nothing, fileName, filePath, fileSize, fileInline, chunkSize, cancelled = False}
createSndDirectFTConnection :: DB.Connection -> User -> Int64 -> (CommandId, ConnId) -> SubscriptionMode -> IO ()
createSndDirectFTConnection db user@User {userId} fileId (cmdId, acId) subMode = do
currentTs <- getCurrentTime
@@ -204,7 +191,7 @@ createSndGroupFileTransfer db userId GroupInfo {groupId} filePath FileInvitation
"INSERT INTO files (user_id, group_id, file_name, file_path, file_size, chunk_size, file_inline, ci_file_status, protocol, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?)"
((userId, groupId, fileName, filePath, fileSize, chunkSize) :. (fileInline, CIFSSndStored, FPSMP, currentTs, currentTs))
fileId <- insertedRowId db
pure FileTransferMeta {fileId, xftpSndFile = Nothing, fileName, filePath, fileSize, fileInline, chunkSize, cancelled = False}
pure FileTransferMeta {fileId, xftpSndFile = Nothing, xftpRedirectFor = Nothing, fileName, filePath, fileSize, fileInline, chunkSize, cancelled = False}
createSndGroupFileTransferConnection :: DB.Connection -> User -> Int64 -> (CommandId, ConnId) -> GroupMember -> SubscriptionMode -> IO ()
createSndGroupFileTransferConnection db user@User {userId} fileId (cmdId, acId) GroupMember {groupMemberId} subMode = do
@@ -277,16 +264,16 @@ getSndFTViaMsgDelivery db User {userId} Connection {connId, agentConnId} agentMs
(\n -> SndFileTransfer {fileId, fileStatus, fileName, fileSize, chunkSize, filePath, fileDescrId, fileInline, groupMemberId, recipientDisplayName = n, connId, agentConnId})
<$> (contactName_ <|> memberName_)
createSndFileTransferXFTP :: DB.Connection -> User -> ContactOrGroup -> CryptoFile -> FileInvitation -> AgentSndFileId -> Integer -> IO FileTransferMeta
createSndFileTransferXFTP db User {userId} contactOrGroup (CryptoFile filePath cryptoArgs) FileInvitation {fileName, fileSize} agentSndFileId chunkSize = do
createSndFileTransferXFTP :: DB.Connection -> User -> Maybe ContactOrGroup -> CryptoFile -> FileInvitation -> AgentSndFileId -> Maybe FileTransferId -> Integer -> IO FileTransferMeta
createSndFileTransferXFTP db User {userId} contactOrGroup_ (CryptoFile filePath cryptoArgs) FileInvitation {fileName, fileSize} agentSndFileId xftpRedirectFor chunkSize = do
currentTs <- getCurrentTime
let xftpSndFile = Just XFTPSndFile {agentSndFileId, privateSndFileDescr = Nothing, agentSndFileDeleted = False, cryptoArgs}
DB.execute
db
"INSERT INTO files (contact_id, group_id, user_id, file_name, file_path, file_crypto_key, file_crypto_nonce, file_size, chunk_size, agent_snd_file_id, ci_file_status, protocol, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)"
(contactAndGroupIds contactOrGroup :. (userId, fileName, filePath, CF.fileKey <$> cryptoArgs, CF.fileNonce <$> cryptoArgs, fileSize, chunkSize, agentSndFileId, CIFSSndStored, FPXFTP, currentTs, currentTs))
"INSERT INTO files (contact_id, group_id, user_id, file_name, file_path, file_crypto_key, file_crypto_nonce, file_size, chunk_size, redirect_file_id, agent_snd_file_id, ci_file_status, protocol, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)"
(maybe (Nothing, Nothing) contactAndGroupIds contactOrGroup_ :. (userId, fileName, filePath, CF.fileKey <$> cryptoArgs, CF.fileNonce <$> cryptoArgs, fileSize, chunkSize) :. (xftpRedirectFor, agentSndFileId, CIFSSndStored, FPXFTP, currentTs, currentTs))
fileId <- insertedRowId db
pure FileTransferMeta {fileId, xftpSndFile, fileName, filePath, fileSize, fileInline = Nothing, chunkSize, cancelled = False}
pure FileTransferMeta {fileId, xftpSndFile, xftpRedirectFor, fileName, filePath, fileSize, fileInline = Nothing, chunkSize, cancelled = False}
createSndFTDescrXFTP :: DB.Connection -> User -> Maybe GroupMember -> Connection -> FileTransferMeta -> FileDescr -> IO ()
createSndFTDescrXFTP db User {userId} m Connection {connId} FileTransferMeta {fileId} FileDescr {fileDescrText, fileDescrPartNo, fileDescrComplete} = do
@@ -421,11 +408,14 @@ getDirectFileIdBySharedMsgId db User {userId} Contact {contactId} sharedMsgId =
(userId, contactId, sharedMsgId)
getChatRefByFileId :: DB.Connection -> User -> Int64 -> ExceptT StoreError IO ChatRef
getChatRefByFileId db User {userId} fileId =
liftIO getChatRef >>= \case
[(Just contactId, Nothing)] -> pure $ ChatRef CTDirect contactId
[(Nothing, Just groupId)] -> pure $ ChatRef CTGroup groupId
_ -> throwError $ SEInternalError "could not retrieve chat ref by file id"
getChatRefByFileId db user fileId = liftIO (lookupChatRefByFileId db user fileId) >>= maybe (throwError $ SEInternalError "could not retrieve chat ref by file id") pure
lookupChatRefByFileId :: DB.Connection -> User -> Int64 -> IO (Maybe ChatRef)
lookupChatRefByFileId db User {userId} fileId =
getChatRef <&> \case
[(Just contactId, Nothing)] -> Just $ ChatRef CTDirect contactId
[(Nothing, Just groupId)] -> Just $ ChatRef CTGroup groupId
_ -> Nothing
where
getChatRef =
DB.query
@@ -536,6 +526,23 @@ createRcvGroupFileTransfer db userId GroupMember {groupId, groupMemberId, localD
(fileId, FSNew, fileConnReq, fileInline, rcvFileInline, groupMemberId, rfdId, currentTs, currentTs)
pure RcvFileTransfer {fileId, xftpRcvFile, fileInvitation = f, fileStatus = RFSNew, rcvFileInline, senderDisplayName = c, chunkSize, cancelled = False, grpMemberId = Just groupMemberId, cryptoArgs = Nothing}
createRcvStandaloneFileTransfer :: DB.Connection -> UserId -> CryptoFile -> Int64 -> Word32 -> ExceptT StoreError IO Int64
createRcvStandaloneFileTransfer db userId (CryptoFile filePath cfArgs_) fileSize chunkSize = do
currentTs <- liftIO getCurrentTime
fileId <- liftIO $ do
DB.execute
db
"INSERT INTO files (user_id, file_name, file_path, file_size, chunk_size, ci_file_status, protocol, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?)"
(userId, takeFileName filePath, filePath, fileSize, chunkSize, CIFSRcvInvitation, FPXFTP, currentTs, currentTs)
insertedRowId db
liftIO . forM_ cfArgs_ $ \cfArgs -> setFileCryptoArgs_ db fileId cfArgs currentTs
liftIO $
DB.execute
db
"INSERT INTO rcv_files (file_id, file_status, created_at, updated_at) VALUES (?,?,?,?)"
(fileId, FSNew, currentTs, currentTs)
pure fileId
createRcvFD_ :: DB.Connection -> UserId -> UTCTime -> FileDescr -> ExceptT StoreError IO RcvFileDescr
createRcvFD_ db userId currentTs FileDescr {fileDescrText, fileDescrPartNo, fileDescrComplete} = do
when (fileDescrPartNo /= 0) $ throwError SERcvFileInvalidDescrPart
@@ -662,9 +669,9 @@ getRcvFileTransfer_ db userId fileId = do
(FileStatus, Maybe ConnReqInvitation, Maybe Int64, String, Integer, Integer, Maybe Bool) :. (Maybe ContactName, Maybe ContactName, Maybe FilePath, Maybe C.SbKey, Maybe C.CbNonce, Maybe InlineFileMode, Maybe InlineFileMode, Maybe AgentRcvFileId, Bool) :. (Maybe Int64, Maybe AgentConnId) ->
ExceptT StoreError IO RcvFileTransfer
rcvFileTransfer rfd_ ((fileStatus', fileConnReq, grpMemberId, fileName, fileSize, chunkSize, cancelled_) :. (contactName_, memberName_, filePath_, fileKey, fileNonce, fileInline, rcvFileInline, agentRcvFileId, agentRcvFileDeleted) :. (connId_, agentConnId_)) =
case contactName_ <|> memberName_ of
case contactName_ <|> memberName_ <|> standaloneName_ of
Nothing -> throwError $ SERcvFileInvalid fileId
Just name -> do
Just name ->
case fileStatus' of
FSNew -> pure $ ft name RFSNew
FSAccepted -> ft name . RFSAccepted <$> rfi
@@ -672,6 +679,9 @@ getRcvFileTransfer_ db userId fileId = do
FSComplete -> ft name . RFSComplete <$> rfi
FSCancelled -> ft name . RFSCancelled <$> rfi_
where
standaloneName_ = case (connId_, agentRcvFileId, filePath_) of
(Nothing, Just _, Just _) -> Just "" -- filePath marks files that are accepted from contact or, in this case, set by createRcvDirectFileTransfer
_ -> Nothing
ft senderDisplayName fileStatus =
let fileInvitation = FileInvitation {fileName, fileSize, fileDigest = Nothing, fileConnReq, fileInline, fileDescr = Nothing}
cryptoArgs = CFArgs <$> fileKey <*> fileNonce
@@ -906,17 +916,22 @@ getFileTransferMeta_ db userId fileId =
DB.query
db
[sql|
SELECT file_name, file_size, chunk_size, file_path, file_crypto_key, file_crypto_nonce, file_inline, agent_snd_file_id, agent_snd_file_deleted, private_snd_file_descr, cancelled
SELECT file_name, file_size, chunk_size, file_path, file_crypto_key, file_crypto_nonce, file_inline, agent_snd_file_id, agent_snd_file_deleted, private_snd_file_descr, cancelled, redirect_file_id
FROM files
WHERE user_id = ? AND file_id = ?
|]
(userId, fileId)
where
fileTransferMeta :: (String, Integer, Integer, FilePath, Maybe C.SbKey, Maybe C.CbNonce, Maybe InlineFileMode, Maybe AgentSndFileId, Bool, Maybe Text, Maybe Bool) -> FileTransferMeta
fileTransferMeta (fileName, fileSize, chunkSize, filePath, fileKey, fileNonce, fileInline, aSndFileId_, agentSndFileDeleted, privateSndFileDescr, cancelled_) =
fileTransferMeta :: (String, Integer, Integer, FilePath, Maybe C.SbKey, Maybe C.CbNonce, Maybe InlineFileMode, Maybe AgentSndFileId, Bool, Maybe Text, Maybe Bool, Maybe FileTransferId) -> FileTransferMeta
fileTransferMeta (fileName, fileSize, chunkSize, filePath, fileKey, fileNonce, fileInline, aSndFileId_, agentSndFileDeleted, privateSndFileDescr, cancelled_, xftpRedirectFor) =
let cryptoArgs = CFArgs <$> fileKey <*> fileNonce
xftpSndFile = (\fId -> XFTPSndFile {agentSndFileId = fId, privateSndFileDescr, agentSndFileDeleted, cryptoArgs}) <$> aSndFileId_
in FileTransferMeta {fileId, xftpSndFile, fileName, fileSize, chunkSize, filePath, fileInline, cancelled = fromMaybe False cancelled_}
in FileTransferMeta {fileId, xftpSndFile, xftpRedirectFor, fileName, fileSize, chunkSize, filePath, fileInline, cancelled = fromMaybe False cancelled_}
lookupFileTransferRedirectMeta :: DB.Connection -> User -> Int64 -> IO [FileTransferMeta]
lookupFileTransferRedirectMeta db User {userId} fileId = do
redirects <- DB.query db "SELECT file_id FROM files WHERE user_id = ? AND redirect_file_id = ?" (userId, fileId)
rights <$> mapM (runExceptT . getFileTransferMeta_ db userId . fromOnly) redirects
createLocalFile :: ToField (CIFileStatus d) => CIFileStatus d -> DB.Connection -> User -> NoteFolder -> ChatItemId -> UTCTime -> CryptoFile -> Integer -> Integer -> IO Int64
createLocalFile fileStatus db User {userId} NoteFolder {noteFolderId} chatItemId itemTs CryptoFile {filePath, cryptoArgs} fileSize fileChunkSize = do
+15 -12
View File
@@ -225,8 +225,9 @@ deleteGroupLink db User {userId} GroupInfo {groupId} = do
JOIN user_contact_links uc USING (user_contact_link_id)
WHERE uc.user_id = ? AND uc.group_id = ?
)
AND local_display_name NOT IN (SELECT local_display_name FROM users WHERE user_id = ?)
|]
(userId, userId, groupId)
(userId, userId, groupId, userId)
DB.execute
db
[sql|
@@ -586,7 +587,7 @@ deleteGroup :: DB.Connection -> User -> GroupInfo -> IO ()
deleteGroup db user@User {userId} g@GroupInfo {groupId, localDisplayName} = do
deleteGroupProfile_ db userId groupId
DB.execute db "DELETE FROM groups WHERE user_id = ? AND group_id = ?" (userId, groupId)
DB.execute db "DELETE FROM display_names WHERE user_id = ? AND local_display_name = ?" (userId, localDisplayName)
safeDeleteLDN db user localDisplayName
forM_ (incognitoMembershipProfile g) $ deleteUnusedIncognitoProfileById_ db user . localProfileId
deleteGroupProfile_ :: DB.Connection -> UserId -> GroupId -> IO ()
@@ -1044,14 +1045,14 @@ deleteGroupMember db user@User {userId} m@GroupMember {groupMemberId, groupId, m
when (memberIncognito m) $ deleteUnusedIncognitoProfileById_ db user $ localProfileId memberProfile
cleanupMemberProfileAndName_ :: DB.Connection -> User -> GroupMember -> IO ()
cleanupMemberProfileAndName_ db User {userId} GroupMember {groupMemberId, memberContactId, memberContactProfileId, localDisplayName} =
cleanupMemberProfileAndName_ db user@User {userId} GroupMember {groupMemberId, memberContactId, memberContactProfileId, localDisplayName} =
-- check record has no memberContactId (contact_id) - it means contact has been deleted and doesn't use profile & ldn
when (isNothing memberContactId) $ do
-- check other group member records don't use profile & ldn
sameProfileMember :: (Maybe GroupMemberId) <- maybeFirstRow fromOnly $ DB.query db "SELECT group_member_id FROM group_members WHERE user_id = ? AND contact_profile_id = ? AND group_member_id != ? LIMIT 1" (userId, memberContactProfileId, groupMemberId)
when (isNothing sameProfileMember) $ do
DB.execute db "DELETE FROM contact_profiles WHERE user_id = ? AND contact_profile_id = ?" (userId, memberContactProfileId)
DB.execute db "DELETE FROM display_names WHERE user_id = ? AND local_display_name = ?" (userId, localDisplayName)
safeDeleteLDN db user localDisplayName
deleteGroupMemberConnection :: DB.Connection -> User -> GroupMember -> IO ()
deleteGroupMemberConnection db User {userId} GroupMember {groupMemberId} =
@@ -1330,7 +1331,7 @@ getViaGroupContact db user@User {userId} GroupMember {groupMemberId} = do
maybe (pure Nothing) (fmap eitherToMaybe . runExceptT . getContact db user) contactId_
updateGroupProfile :: DB.Connection -> User -> GroupInfo -> GroupProfile -> ExceptT StoreError IO GroupInfo
updateGroupProfile db User {userId} g@GroupInfo {groupId, localDisplayName, groupProfile = GroupProfile {displayName}} p'@GroupProfile {displayName = newName, fullName, description, image, groupPreferences}
updateGroupProfile db user@User {userId} g@GroupInfo {groupId, localDisplayName, groupProfile = GroupProfile {displayName}} p'@GroupProfile {displayName = newName, fullName, description, image, groupPreferences}
| displayName == newName = liftIO $ do
currentTs <- getCurrentTime
updateGroupProfile_ currentTs
@@ -1361,7 +1362,7 @@ updateGroupProfile db User {userId} g@GroupInfo {groupId, localDisplayName, grou
db
"UPDATE groups SET local_display_name = ?, updated_at = ? WHERE user_id = ? AND group_id = ?"
(ldn, currentTs, userId, groupId)
DB.execute db "DELETE FROM display_names WHERE local_display_name = ? AND user_id = ?" (localDisplayName, userId)
safeDeleteLDN db user localDisplayName
getGroupInfo :: DB.Connection -> VersionRange -> User -> Int64 -> ExceptT StoreError IO GroupInfo
getGroupInfo db vr User {userId, userContactId} groupId =
@@ -1464,7 +1465,7 @@ getMatchingContacts db user@User {userId} Contact {contactId, profile = LocalPro
FROM contacts ct
JOIN contact_profiles p ON ct.contact_profile_id = p.contact_profile_id
WHERE ct.user_id = ? AND ct.contact_id != ?
AND ct.contact_status = ? AND ct.deleted = 0
AND ct.contact_status = ? AND ct.deleted = 0 AND ct.is_user = 0
AND p.display_name = ? AND p.full_name = ?
|]
@@ -1502,7 +1503,7 @@ getMatchingMemberContacts db user@User {userId} GroupMember {memberProfile = Loc
FROM contacts ct
JOIN contact_profiles p ON ct.contact_profile_id = p.contact_profile_id
WHERE ct.user_id = ?
AND ct.contact_status = ? AND ct.deleted = 0
AND ct.contact_status = ? AND ct.deleted = 0 AND ct.is_user = 0
AND p.display_name = ? AND p.full_name = ?
|]
@@ -1615,6 +1616,8 @@ mergeContactRecords db user@User {userId} to@Contact {localDisplayName = keepLDN
let (toCt, fromCt) = toFromContacts to from
Contact {contactId = toContactId, localDisplayName = toLDN} = toCt
Contact {contactId = fromContactId, localDisplayName = fromLDN} = fromCt
assertNotUser db user toCt
assertNotUser db user fromCt
liftIO $ do
currentTs <- getCurrentTime
-- next query fixes incorrect unused contacts deletion
@@ -2018,7 +2021,7 @@ createMemberContactConn_
pure Connection {connId, agentConnId = AgentConnId acId, peerChatVRange, connType = ConnContact, contactConnInitiated = False, entityId = Just contactId, viaContact = Nothing, viaUserContactLink = Nothing, viaGroupLink = False, groupLinkId = Nothing, customUserProfileId, connLevel, connStatus = ConnJoined, localAlias = "", createdAt = currentTs, connectionCode = Nothing, authErrCounter = 0}
updateMemberProfile :: DB.Connection -> User -> GroupMember -> Profile -> ExceptT StoreError IO GroupMember
updateMemberProfile db User {userId} m p'
updateMemberProfile db user@User {userId} m p'
| displayName == newName = do
liftIO $ updateMemberContactProfileReset_ db userId profileId p'
pure m {memberProfile = profile}
@@ -2030,7 +2033,7 @@ updateMemberProfile db User {userId} m p'
db
"UPDATE group_members SET local_display_name = ?, updated_at = ? WHERE user_id = ? AND group_member_id = ?"
(ldn, currentTs, userId, groupMemberId)
DB.execute db "DELETE FROM display_names WHERE local_display_name = ? AND user_id = ?" (localDisplayName, userId)
safeDeleteLDN db user localDisplayName
pure $ Right m {localDisplayName = ldn, memberProfile = profile}
where
GroupMember {groupMemberId, localDisplayName, memberProfile = LocalProfile {profileId, displayName, localAlias}} = m
@@ -2038,7 +2041,7 @@ updateMemberProfile db User {userId} m p'
profile = toLocalProfile profileId p' localAlias
updateContactMemberProfile :: DB.Connection -> User -> GroupMember -> Contact -> Profile -> ExceptT StoreError IO (GroupMember, Contact)
updateContactMemberProfile db User {userId} m ct@Contact {contactId} p'
updateContactMemberProfile db user@User {userId} m ct@Contact {contactId} p'
| displayName == newName = do
liftIO $ updateMemberContactProfile_ db userId profileId p'
pure (m {memberProfile = profile}, ct {profile} :: Contact)
@@ -2046,7 +2049,7 @@ updateContactMemberProfile db User {userId} m ct@Contact {contactId} p'
ExceptT . withLocalDisplayName db userId newName $ \ldn -> do
currentTs <- getCurrentTime
updateMemberContactProfile_' db userId profileId p' currentTs
updateContactLDN_ db userId contactId localDisplayName ldn currentTs
updateContactLDN_ db user contactId localDisplayName ldn currentTs
pure $ Right (m {localDisplayName = ldn, memberProfile = profile}, ct {localDisplayName = ldn, profile} :: Contact)
where
GroupMember {localDisplayName, memberProfile = LocalProfile {profileId, displayName, localAlias}} = m
+180 -98
View File
@@ -39,7 +39,7 @@ module Simplex.Chat.Store.Messages
getDirectChat,
getGroupChat,
getLocalChat,
getDirectChatItemsLast,
getDirectChatItemLast,
getAllChatItems,
getAChatItem,
updateDirectChatItem,
@@ -92,6 +92,7 @@ module Simplex.Chat.Store.Messages
getLocalChatItemIdByText,
getLocalChatItemIdByText',
getChatItemByFileId,
lookupChatItemByFileId,
getChatItemByGroupId,
updateDirectChatItemStatus,
getTimedItems,
@@ -125,6 +126,7 @@ import Data.List (sortBy)
import Data.Maybe (fromMaybe, isJust, mapMaybe)
import Data.Ord (Down (..), comparing)
import Data.Text (Text)
import qualified Data.Text as T
import Data.Time (addUTCTime)
import Data.Time.Clock (UTCTime (..), getCurrentTime)
import Database.SQLite.Simple (NamedParam (..), Only (..), Query, (:.) (..))
@@ -828,7 +830,7 @@ toLocalChatItem currentTs ((itemId, itemTs, AMsgDirection msgDir, itemContentTex
cItem :: MsgDirectionI d => SMsgDirection d -> CIDirection 'CTLocal d -> CIStatus d -> CIContent d -> Maybe (CIFile d) -> CChatItem 'CTLocal
cItem d chatDir ciStatus content file =
CChatItem d ChatItem {chatDir, meta = ciMeta content ciStatus, content, formattedText = parseMaybeMarkdownList itemText, quotedItem = Nothing, reactions = [], file}
badItem = Left $ SEBadChatItem itemId
badItem = Left $ SEBadChatItem itemId (Just itemTs)
ciMeta :: CIContent d -> CIStatus d -> CIMeta 'CTLocal d
ciMeta content status =
let itemDeleted' = case itemDeleted of
@@ -922,97 +924,118 @@ getDirectChat :: DB.Connection -> User -> Int64 -> ChatPagination -> Maybe Strin
getDirectChat db user contactId pagination search_ = do
let search = fromMaybe "" search_
ct <- getContact db user contactId
liftIO . getDirectChatReactions_ db ct =<< case pagination of
liftIO $ case pagination of
CPLast count -> getDirectChatLast_ db user ct count search
CPAfter afterId count -> getDirectChatAfter_ db user ct afterId count search
CPBefore beforeId count -> getDirectChatBefore_ db user ct beforeId count search
getDirectChatLast_ :: DB.Connection -> User -> Contact -> Int -> String -> ExceptT StoreError IO (Chat 'CTDirect)
getDirectChatLast_ db user ct@Contact {contactId} count search = do
let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False}
chatItems <- getDirectChatItemsLast db user contactId count search
pure $ Chat (DirectChat ct) (reverse chatItems) stats
-- the last items in reverse order (the last item in the conversation is the first in the returned list)
getDirectChatItemsLast :: DB.Connection -> User -> ContactId -> Int -> String -> ExceptT StoreError IO [CChatItem 'CTDirect]
getDirectChatItemsLast db User {userId} contactId count search = ExceptT $ do
currentTs <- getCurrentTime
mapM (toDirectChatItem currentTs)
<$> DB.query
db
[sql|
SELECT
-- ChatItem
i.chat_item_id, i.item_ts, i.item_sent, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_deleted_ts, i.item_edited, i.created_at, i.updated_at, i.timed_ttl, i.timed_delete_at, i.item_live,
-- CIFile
f.file_id, f.file_name, f.file_size, f.file_path, f.file_crypto_key, f.file_crypto_nonce, f.ci_file_status, f.protocol,
-- DirectQuote
ri.chat_item_id, i.quoted_shared_msg_id, i.quoted_sent_at, i.quoted_content, i.quoted_sent
FROM chat_items i
LEFT JOIN files f ON f.chat_item_id = i.chat_item_id
LEFT JOIN chat_items ri ON ri.user_id = i.user_id AND ri.contact_id = i.contact_id AND ri.shared_msg_id = i.quoted_shared_msg_id
WHERE i.user_id = ? AND i.contact_id = ? AND i.item_text LIKE '%' || ? || '%'
ORDER BY i.created_at DESC, i.chat_item_id DESC
LIMIT ?
|]
(userId, contactId, search, count)
getDirectChatAfter_ :: DB.Connection -> User -> Contact -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTDirect)
getDirectChatAfter_ db User {userId} ct@Contact {contactId} afterChatItemId count search = do
getDirectChatLast_ :: DB.Connection -> User -> Contact -> Int -> String -> IO (Chat 'CTDirect)
getDirectChatLast_ db user@User {userId} ct@Contact {contactId} count search = do
let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False}
chatItems <- ExceptT getDirectChatItemsAfter_
pure $ Chat (DirectChat ct) chatItems stats
chatItemIds <- getDirectChatItemIdsLast_
currentTs <- getCurrentTime
chatItems <- mapM (safeGetDirectItem db user ct currentTs) chatItemIds
pure $ Chat (DirectChat ct) (reverse chatItems) stats
where
getDirectChatItemsAfter_ :: IO (Either StoreError [CChatItem 'CTDirect])
getDirectChatItemsAfter_ = do
currentTs <- getCurrentTime
mapM (toDirectChatItem currentTs)
getDirectChatItemIdsLast_ :: IO [ChatItemId]
getDirectChatItemIdsLast_ =
map fromOnly
<$> DB.query
db
[sql|
SELECT
-- ChatItem
i.chat_item_id, i.item_ts, i.item_sent, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_deleted_ts, i.item_edited, i.created_at, i.updated_at, i.timed_ttl, i.timed_delete_at, i.item_live,
-- CIFile
f.file_id, f.file_name, f.file_size, f.file_path, f.file_crypto_key, f.file_crypto_nonce, f.ci_file_status, f.protocol,
-- DirectQuote
ri.chat_item_id, i.quoted_shared_msg_id, i.quoted_sent_at, i.quoted_content, i.quoted_sent
FROM chat_items i
LEFT JOIN files f ON f.chat_item_id = i.chat_item_id
LEFT JOIN chat_items ri ON ri.user_id = i.user_id AND ri.contact_id = i.contact_id AND ri.shared_msg_id = i.quoted_shared_msg_id
WHERE i.user_id = ? AND i.contact_id = ? AND i.item_text LIKE '%' || ? || '%'
AND i.chat_item_id > ?
ORDER BY i.created_at ASC, i.chat_item_id ASC
SELECT chat_item_id
FROM chat_items
WHERE user_id = ? AND contact_id = ? AND item_text LIKE '%' || ? || '%'
ORDER BY created_at DESC, chat_item_id DESC
LIMIT ?
|]
(userId, contactId, search, count)
safeGetDirectItem :: DB.Connection -> User -> Contact -> UTCTime -> ChatItemId -> IO (CChatItem 'CTDirect)
safeGetDirectItem db user ct currentTs itemId =
runExceptT (getDirectCIWithReactions db user ct itemId)
>>= pure <$> safeToDirectItem currentTs itemId
safeToDirectItem :: UTCTime -> ChatItemId -> Either StoreError (CChatItem 'CTDirect) -> CChatItem 'CTDirect
safeToDirectItem currentTs itemId = \case
Right ci -> ci
Left e@(SEBadChatItem _ (Just itemTs)) -> badDirectItem itemTs e
Left e -> badDirectItem currentTs e
where
badDirectItem :: UTCTime -> StoreError -> CChatItem 'CTDirect
badDirectItem ts e =
let errorText = T.pack $ show e
in CChatItem
SMDSnd
ChatItem
{ chatDir = CIDirectSnd,
meta = dummyMeta itemId ts errorText,
content = CIInvalidJSON errorText,
formattedText = Nothing,
quotedItem = Nothing,
reactions = [],
file = Nothing
}
getDirectChatItemLast :: DB.Connection -> User -> ContactId -> ExceptT StoreError IO (CChatItem 'CTDirect)
getDirectChatItemLast db user@User {userId} contactId = do
chatItemId <-
ExceptT . firstRow fromOnly (SEChatItemNotFoundByContactId contactId) $
DB.query
db
[sql|
SELECT chat_item_id
FROM chat_items
WHERE user_id = ? AND contact_id = ?
ORDER BY created_at DESC, chat_item_id DESC
LIMIT 1
|]
(userId, contactId)
getDirectChatItem db user contactId chatItemId
getDirectChatAfter_ :: DB.Connection -> User -> Contact -> ChatItemId -> Int -> String -> IO (Chat 'CTDirect)
getDirectChatAfter_ db user@User {userId} ct@Contact {contactId} afterChatItemId count search = do
let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False}
chatItemIds <- getDirectChatItemIdsAfter_
currentTs <- getCurrentTime
chatItems <- mapM (safeGetDirectItem db user ct currentTs) chatItemIds
pure $ Chat (DirectChat ct) chatItems stats
where
getDirectChatItemIdsAfter_ :: IO [ChatItemId]
getDirectChatItemIdsAfter_ =
map fromOnly
<$> DB.query
db
[sql|
SELECT chat_item_id
FROM chat_items
WHERE user_id = ? AND contact_id = ? AND item_text LIKE '%' || ? || '%'
AND chat_item_id > ?
ORDER BY created_at ASC, chat_item_id ASC
LIMIT ?
|]
(userId, contactId, search, afterChatItemId, count)
getDirectChatBefore_ :: DB.Connection -> User -> Contact -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTDirect)
getDirectChatBefore_ db User {userId} ct@Contact {contactId} beforeChatItemId count search = do
getDirectChatBefore_ :: DB.Connection -> User -> Contact -> ChatItemId -> Int -> String -> IO (Chat 'CTDirect)
getDirectChatBefore_ db user@User {userId} ct@Contact {contactId} beforeChatItemId count search = do
let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False}
chatItems <- ExceptT getDirectChatItemsBefore_
chatItemIds <- getDirectChatItemsIdsBefore_
currentTs <- getCurrentTime
chatItems <- mapM (safeGetDirectItem db user ct currentTs) chatItemIds
pure $ Chat (DirectChat ct) (reverse chatItems) stats
where
getDirectChatItemsBefore_ :: IO (Either StoreError [CChatItem 'CTDirect])
getDirectChatItemsBefore_ = do
currentTs <- getCurrentTime
mapM (toDirectChatItem currentTs)
getDirectChatItemsIdsBefore_ :: IO [ChatItemId]
getDirectChatItemsIdsBefore_ =
map fromOnly
<$> DB.query
db
[sql|
SELECT
-- ChatItem
i.chat_item_id, i.item_ts, i.item_sent, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_deleted_ts, i.item_edited, i.created_at, i.updated_at, i.timed_ttl, i.timed_delete_at, i.item_live,
-- CIFile
f.file_id, f.file_name, f.file_size, f.file_path, f.file_crypto_key, f.file_crypto_nonce, f.ci_file_status, f.protocol,
-- DirectQuote
ri.chat_item_id, i.quoted_shared_msg_id, i.quoted_sent_at, i.quoted_content, i.quoted_sent
FROM chat_items i
LEFT JOIN files f ON f.chat_item_id = i.chat_item_id
LEFT JOIN chat_items ri ON ri.user_id = i.user_id AND ri.contact_id = i.contact_id AND ri.shared_msg_id = i.quoted_shared_msg_id
WHERE i.user_id = ? AND i.contact_id = ? AND i.item_text LIKE '%' || ? || '%'
AND i.chat_item_id < ?
ORDER BY i.created_at DESC, i.chat_item_id DESC
SELECT chat_item_id
FROM chat_items
WHERE user_id = ? AND contact_id = ? AND item_text LIKE '%' || ? || '%'
AND chat_item_id < ?
ORDER BY created_at DESC, chat_item_id DESC
LIMIT ?
|]
(userId, contactId, search, beforeChatItemId, count)
@@ -1022,15 +1045,16 @@ getGroupChat db vr user groupId pagination search_ = do
let search = fromMaybe "" search_
g <- getGroupInfo db vr user groupId
case pagination of
CPLast count -> getGroupChatLast_ db user g count search
CPLast count -> liftIO $ getGroupChatLast_ db user g count search
CPAfter afterId count -> getGroupChatAfter_ db user g afterId count search
CPBefore beforeId count -> getGroupChatBefore_ db user g beforeId count search
getGroupChatLast_ :: DB.Connection -> User -> GroupInfo -> Int -> String -> ExceptT StoreError IO (Chat 'CTGroup)
getGroupChatLast_ :: DB.Connection -> User -> GroupInfo -> Int -> String -> IO (Chat 'CTGroup)
getGroupChatLast_ db user@User {userId} g@GroupInfo {groupId} count search = do
let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False}
chatItemIds <- liftIO getGroupChatItemIdsLast_
chatItems <- mapM (getGroupCIWithReactions db user g) chatItemIds
chatItemIds <- getGroupChatItemIdsLast_
currentTs <- getCurrentTime
chatItems <- mapM (safeGetGroupItem db user g currentTs) chatItemIds
pure $ Chat (GroupChat g) (reverse chatItems) stats
where
getGroupChatItemIdsLast_ :: IO [ChatItemId]
@@ -1047,6 +1071,32 @@ getGroupChatLast_ db user@User {userId} g@GroupInfo {groupId} count search = do
|]
(userId, groupId, search, count)
safeGetGroupItem :: DB.Connection -> User -> GroupInfo -> UTCTime -> ChatItemId -> IO (CChatItem 'CTGroup)
safeGetGroupItem db user g currentTs itemId =
runExceptT (getGroupCIWithReactions db user g itemId)
>>= pure <$> safeToGroupItem currentTs itemId
safeToGroupItem :: UTCTime -> ChatItemId -> Either StoreError (CChatItem 'CTGroup) -> CChatItem 'CTGroup
safeToGroupItem currentTs itemId = \case
Right ci -> ci
Left e@(SEBadChatItem _ (Just itemTs)) -> badGroupItem itemTs e
Left e -> badGroupItem currentTs e
where
badGroupItem :: UTCTime -> StoreError -> CChatItem 'CTGroup
badGroupItem ts e =
let errorText = T.pack $ show e
in CChatItem
SMDSnd
ChatItem
{ chatDir = CIGroupSnd,
meta = dummyMeta itemId ts errorText,
content = CIInvalidJSON errorText,
formattedText = Nothing,
quotedItem = Nothing,
reactions = [],
file = Nothing
}
getGroupMemberChatItemLast :: DB.Connection -> User -> GroupId -> GroupMemberId -> ExceptT StoreError IO (CChatItem 'CTGroup)
getGroupMemberChatItemLast db user@User {userId} groupId groupMemberId = do
chatItemId <-
@@ -1068,7 +1118,8 @@ getGroupChatAfter_ db user@User {userId} g@GroupInfo {groupId} afterChatItemId c
let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False}
afterChatItem <- getGroupChatItem db user groupId afterChatItemId
chatItemIds <- liftIO $ getGroupChatItemIdsAfter_ (chatItemTs afterChatItem)
chatItems <- mapM (getGroupCIWithReactions db user g) chatItemIds
currentTs <- liftIO getCurrentTime
chatItems <- liftIO $ mapM (safeGetGroupItem db user g currentTs) chatItemIds
pure $ Chat (GroupChat g) chatItems stats
where
getGroupChatItemIdsAfter_ :: UTCTime -> IO [ChatItemId]
@@ -1091,7 +1142,8 @@ getGroupChatBefore_ db user@User {userId} g@GroupInfo {groupId} beforeChatItemId
let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False}
beforeChatItem <- getGroupChatItem db user groupId beforeChatItemId
chatItemIds <- liftIO $ getGroupChatItemIdsBefore_ (chatItemTs beforeChatItem)
chatItems <- mapM (getGroupCIWithReactions db user g) chatItemIds
currentTs <- liftIO getCurrentTime
chatItems <- liftIO $ mapM (safeGetGroupItem db user g currentTs) chatItemIds
pure $ Chat (GroupChat g) (reverse chatItems) stats
where
getGroupChatItemIdsBefore_ :: UTCTime -> IO [ChatItemId]
@@ -1113,16 +1165,17 @@ getLocalChat :: DB.Connection -> User -> Int64 -> ChatPagination -> Maybe String
getLocalChat db user folderId pagination search_ = do
let search = fromMaybe "" search_
nf <- getNoteFolder db user folderId
case pagination of
liftIO $ case pagination of
CPLast count -> getLocalChatLast_ db user nf count search
CPAfter afterId count -> getLocalChatAfter_ db user nf afterId count search
CPBefore beforeId count -> getLocalChatBefore_ db user nf beforeId count search
getLocalChatLast_ :: DB.Connection -> User -> NoteFolder -> Int -> String -> ExceptT StoreError IO (Chat 'CTLocal)
getLocalChatLast_ :: DB.Connection -> User -> NoteFolder -> Int -> String -> IO (Chat 'CTLocal)
getLocalChatLast_ db user@User {userId} nf@NoteFolder {noteFolderId} count search = do
let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False}
chatItemIds <- liftIO getLocalChatItemIdsLast_
chatItems <- mapM (getLocalChatItem db user noteFolderId) chatItemIds
chatItemIds <- getLocalChatItemIdsLast_
currentTs <- getCurrentTime
chatItems <- mapM (safeGetLocalItem db user nf currentTs) chatItemIds
pure $ Chat (LocalChat nf) (reverse chatItems) stats
where
getLocalChatItemIdsLast_ :: IO [ChatItemId]
@@ -1139,11 +1192,38 @@ getLocalChatLast_ db user@User {userId} nf@NoteFolder {noteFolderId} count searc
|]
(userId, noteFolderId, search, count)
getLocalChatAfter_ :: DB.Connection -> User -> NoteFolder -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTLocal)
safeGetLocalItem :: DB.Connection -> User -> NoteFolder -> UTCTime -> ChatItemId -> IO (CChatItem 'CTLocal)
safeGetLocalItem db user NoteFolder {noteFolderId} currentTs itemId =
runExceptT (getLocalChatItem db user noteFolderId itemId)
>>= pure <$> safeToLocalItem currentTs itemId
safeToLocalItem :: UTCTime -> ChatItemId -> Either StoreError (CChatItem 'CTLocal) -> CChatItem 'CTLocal
safeToLocalItem currentTs itemId = \case
Right ci -> ci
Left e@(SEBadChatItem _ (Just itemTs)) -> badLocalItem itemTs e
Left e -> badLocalItem currentTs e
where
badLocalItem :: UTCTime -> StoreError -> CChatItem 'CTLocal
badLocalItem ts e =
let errorText = T.pack $ show e
in CChatItem
SMDSnd
ChatItem
{ chatDir = CILocalSnd,
meta = dummyMeta itemId ts errorText,
content = CIInvalidJSON errorText,
formattedText = Nothing,
quotedItem = Nothing,
reactions = [],
file = Nothing
}
getLocalChatAfter_ :: DB.Connection -> User -> NoteFolder -> ChatItemId -> Int -> String -> IO (Chat 'CTLocal)
getLocalChatAfter_ db user@User {userId} nf@NoteFolder {noteFolderId} afterChatItemId count search = do
let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False}
chatItemIds <- liftIO getLocalChatItemIdsAfter_
chatItems <- mapM (getLocalChatItem db user noteFolderId) chatItemIds
chatItemIds <- getLocalChatItemIdsAfter_
currentTs <- getCurrentTime
chatItems <- mapM (safeGetLocalItem db user nf currentTs) chatItemIds
pure $ Chat (LocalChat nf) chatItems stats
where
getLocalChatItemIdsAfter_ :: IO [ChatItemId]
@@ -1161,11 +1241,12 @@ getLocalChatAfter_ db user@User {userId} nf@NoteFolder {noteFolderId} afterChatI
|]
(userId, noteFolderId, search, afterChatItemId, count)
getLocalChatBefore_ :: DB.Connection -> User -> NoteFolder -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTLocal)
getLocalChatBefore_ :: DB.Connection -> User -> NoteFolder -> ChatItemId -> Int -> String -> IO (Chat 'CTLocal)
getLocalChatBefore_ db user@User {userId} nf@NoteFolder {noteFolderId} beforeChatItemId count search = do
let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False}
chatItemIds <- liftIO getLocalChatItemIdsBefore_
chatItems <- mapM (getLocalChatItem db user noteFolderId) chatItemIds
chatItemIds <- getLocalChatItemIdsBefore_
currentTs <- getCurrentTime
chatItems <- mapM (safeGetLocalItem db user nf currentTs) chatItemIds
pure $ Chat (LocalChat nf) (reverse chatItems) stats
where
getLocalChatItemIdsBefore_ :: IO [ChatItemId]
@@ -1188,7 +1269,7 @@ toChatItemRef = \case
(itemId, Just contactId, Nothing, Nothing) -> Right (ChatRef CTDirect contactId, itemId)
(itemId, Nothing, Just groupId, Nothing) -> Right (ChatRef CTGroup groupId, itemId)
(itemId, Nothing, Nothing, Just folderId) -> Right (ChatRef CTLocal folderId, itemId)
(itemId, _, _, _) -> Left $ SEBadChatItem itemId
(itemId, _, _, _) -> Left $ SEBadChatItem itemId Nothing
updateDirectChatItemsRead :: DB.Connection -> User -> ContactId -> Maybe (ChatItemId, ChatItemId) -> IO ()
updateDirectChatItemsRead db User {userId} contactId itemsRange_ = do
@@ -1361,7 +1442,7 @@ toDirectChatItem currentTs (((itemId, itemTs, AMsgDirection msgDir, itemContentT
cItem :: MsgDirectionI d => SMsgDirection d -> CIDirection 'CTDirect d -> CIStatus d -> CIContent d -> Maybe (CIFile d) -> CChatItem 'CTDirect
cItem d chatDir ciStatus content file =
CChatItem d ChatItem {chatDir, meta = ciMeta content ciStatus, content, formattedText = parseMaybeMarkdownList itemText, quotedItem = toDirectQuote quoteRow, reactions = [], file}
badItem = Left $ SEBadChatItem itemId
badItem = Left $ SEBadChatItem itemId (Just itemTs)
ciMeta :: CIContent d -> CIStatus d -> CIMeta 'CTDirect d
ciMeta content status =
let itemDeleted' = case itemDeleted of
@@ -1412,7 +1493,7 @@ toGroupChatItem currentTs userContactId (((itemId, itemTs, AMsgDirection msgDir,
cItem :: MsgDirectionI d => SMsgDirection d -> CIDirection 'CTGroup d -> CIStatus d -> CIContent d -> Maybe (CIFile d) -> CChatItem 'CTGroup
cItem d chatDir ciStatus content file =
CChatItem d ChatItem {chatDir, meta = ciMeta content ciStatus, content, formattedText = parseMaybeMarkdownList itemText, quotedItem = toGroupQuote quoteRow quotedMember_, reactions = [], file}
badItem = Left $ SEBadChatItem itemId
badItem = Left $ SEBadChatItem itemId (Just itemTs)
ciMeta :: CIContent d -> CIStatus d -> CIMeta 'CTGroup d
ciMeta content status =
let itemDeleted' = case itemDeleted of
@@ -2085,6 +2166,12 @@ getChatItemByFileId db vr user@User {userId} fileId = do
(userId, fileId)
getAChatItem db vr user chatRef itemId
lookupChatItemByFileId :: DB.Connection -> VersionRange -> User -> Int64 -> ExceptT StoreError IO (Maybe AChatItem)
lookupChatItemByFileId db vr user fileId = do
fmap Just (getChatItemByFileId db vr user fileId) `catchError` \case
SEChatItemNotFoundByFileId {} -> pure Nothing
e -> throwError e
getChatItemByGroupId :: DB.Connection -> VersionRange -> User -> GroupId -> ExceptT StoreError IO AChatItem
getChatItemByGroupId db vr user@User {userId} groupId = do
(chatRef, itemId) <-
@@ -2109,7 +2196,7 @@ getChatRefViaItemId db User {userId} itemId = do
toChatRef = \case
(Just contactId, Nothing) -> Right $ ChatRef CTDirect contactId
(Nothing, Just groupId) -> Right $ ChatRef CTGroup groupId
(_, _) -> Left $ SEBadChatItem itemId
(_, _) -> Left $ SEBadChatItem itemId Nothing
getAChatItem :: DB.Connection -> VersionRange -> User -> ChatRef -> ChatItemId -> ExceptT StoreError IO AChatItem
getAChatItem db vr user chatRef itemId = case chatRef of
@@ -2145,11 +2232,6 @@ getChatItemVersions db itemId = do
let formattedText = parseMaybeMarkdownList $ msgContentText msgContent
in ChatItemVersion {chatItemVersionId, msgContent, formattedText, itemVersionTs, createdAt}
getDirectChatReactions_ :: DB.Connection -> Contact -> Chat 'CTDirect -> IO (Chat 'CTDirect)
getDirectChatReactions_ db ct c@Chat {chatItems} = do
chatItems' <- mapM (directCIWithReactions db ct) chatItems
pure c {chatItems = chatItems'}
directCIWithReactions :: DB.Connection -> Contact -> CChatItem 'CTDirect -> IO (CChatItem 'CTDirect)
directCIWithReactions db ct cci@(CChatItem md ci@ChatItem {meta = CIMeta {itemSharedMsgId}}) = case itemSharedMsgId of
Just sharedMsgId -> do
+7 -1
View File
@@ -98,6 +98,9 @@ import Simplex.Chat.Migrations.M20240102_note_folders
import Simplex.Chat.Migrations.M20240104_members_profile_update
import Simplex.Chat.Migrations.M20240115_block_member_for_all
import Simplex.Chat.Migrations.M20240122_indexes
import Simplex.Chat.Migrations.M20240214_redirect_file_id
import Simplex.Chat.Migrations.M20240222_app_settings
import Simplex.Chat.Migrations.M20240226_users_restrict
import Simplex.Messaging.Agent.Store.SQLite.Migrations (Migration (..))
schemaMigrations :: [(String, Query, Maybe Query)]
@@ -195,7 +198,10 @@ schemaMigrations =
("20240102_note_folders", m20240102_note_folders, Just down_m20240102_note_folders),
("20240104_members_profile_update", m20240104_members_profile_update, Just down_m20240104_members_profile_update),
("20240115_block_member_for_all", m20240115_block_member_for_all, Just down_m20240115_block_member_for_all),
("20240122_indexes", m20240122_indexes, Just down_m20240122_indexes)
("20240122_indexes", m20240122_indexes, Just down_m20240122_indexes),
("20240214_redirect_file_id", m20240214_redirect_file_id, Just down_m20240214_redirect_file_id),
("20240222_app_settings", m20240222_app_settings, Just down_m20240222_app_settings),
("20240226_users_restrict", m20240226_users_restrict, Just down_m20240226_users_restrict)
]
-- | The list of migrations in ascending order by date
+2 -1
View File
@@ -267,7 +267,7 @@ updateUserProfile db user p'
"INSERT INTO display_names (local_display_name, ldn_base, user_id, created_at, updated_at) VALUES (?,?,?,?,?)"
(newName, newName, userId, currentTs, currentTs)
updateContactProfile_' db userId profileId p' currentTs
updateContactLDN_ db userId userContactId localDisplayName newName currentTs
updateContactLDN_ db user userContactId localDisplayName newName currentTs
pure user {localDisplayName = newName, profile, fullPreferences, userMemberProfileUpdatedAt = userMemberProfileUpdatedAt'}
where
updateUserMemberProfileUpdatedAt_ currentTs
@@ -388,6 +388,7 @@ deleteUserAddress db user@User {userId} = do
JOIN user_contact_links uc USING (user_contact_link_id)
WHERE uc.user_id = :user_id AND uc.local_display_name = '' AND uc.group_id IS NULL
)
AND local_display_name NOT IN (SELECT local_display_name FROM users WHERE user_id = :user_id)
|]
[":user_id" := userId]
DB.executeNamed
+33 -1
View File
@@ -92,11 +92,12 @@ data StoreError
| SEUniqueID
| SELargeMsg
| SEInternalError {message :: String}
| SEBadChatItem {itemId :: ChatItemId}
| SEBadChatItem {itemId :: ChatItemId, itemTs :: Maybe ChatItemTs}
| SEChatItemNotFound {itemId :: ChatItemId}
| SEChatItemNotFoundByText {text :: Text}
| SEChatItemSharedMsgIdNotFound {sharedMsgId :: SharedMsgId}
| SEChatItemNotFoundByFileId {fileId :: FileTransferId}
| SEChatItemNotFoundByContactId {contactId :: ContactId}
| SEChatItemNotFoundByGroupId {groupId :: GroupId}
| SEProfileNotFound {profileId :: Int64}
| SEDuplicateGroupLink {groupInfo :: GroupInfo}
@@ -110,6 +111,7 @@ data StoreError
| SERemoteHostDuplicateCA
| SERemoteCtrlNotFound {remoteCtrlId :: RemoteCtrlId}
| SERemoteCtrlDuplicateCA
| SEProhibitedDeleteUser {userId :: UserId, contactId :: ContactId}
deriving (Show, Exception)
$(J.deriveJSON (sumTypeJSON $ dropPrefix "SE") ''StoreError)
@@ -401,3 +403,33 @@ createWithRandomBytes' size gVar create = tryCreate 3
encodedRandomBytes :: TVar ChaChaDRG -> Int -> IO ByteString
encodedRandomBytes gVar n = atomically $ B64.encode <$> C.randomBytes n gVar
assertNotUser :: DB.Connection -> User -> Contact -> ExceptT StoreError IO ()
assertNotUser db User {userId} Contact {contactId, localDisplayName} = do
r :: (Maybe Int64) <-
-- This query checks that the foreign keys in the users table
-- are not referencing the contact about to be deleted.
-- With the current schema it would cause cascade delete of user,
-- with mofified schema (in v5.6.0-beta.0) it would cause foreign key violation error.
liftIO . maybeFirstRow fromOnly $
DB.query
db
[sql|
SELECT 1 FROM users
WHERE (user_id = ? AND local_display_name = ?)
OR contact_id = ?
LIMIT 1
|]
(userId, localDisplayName, contactId)
when (isJust r) $ throwError $ SEProhibitedDeleteUser userId contactId
safeDeleteLDN :: DB.Connection -> User -> ContactName -> IO ()
safeDeleteLDN db User {userId} localDisplayName = do
DB.execute
db
[sql|
DELETE FROM display_names
WHERE user_id = ? AND local_display_name = ?
AND local_display_name NOT IN (SELECT local_display_name FROM users WHERE user_id = ?)
|]
(userId, localDisplayName, userId)
+4 -3
View File
@@ -46,7 +46,7 @@ import Database.SQLite.Simple.ToField (ToField (..))
import Simplex.Chat.Types.Preferences
import Simplex.Chat.Types.Util
import Simplex.FileTransfer.Description (FileDigest)
import Simplex.Messaging.Agent.Protocol (ACommandTag (..), ACorrId, AParty (..), APartyCmdTag (..), ConnId, ConnectionMode (..), ConnectionRequestUri, InvitationId, SAEntity (..), UserId)
import Simplex.Messaging.Agent.Protocol (ACommandTag (..), ACorrId, AParty (..), APartyCmdTag (..), ConnId, ConnectionMode (..), ConnectionRequestUri, InvitationId, RcvFileId, SAEntity (..), SndFileId, UserId)
import Simplex.Messaging.Crypto.File (CryptoFileArgs (..))
import Simplex.Messaging.Encoding.String
import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, fromTextField_, sumTypeJSON, taggedObjectJSON)
@@ -1142,7 +1142,7 @@ instance FromField AgentConnId where fromField f = AgentConnId <$> fromField f
instance ToField AgentConnId where toField (AgentConnId m) = toField m
newtype AgentSndFileId = AgentSndFileId ConnId
newtype AgentSndFileId = AgentSndFileId SndFileId
deriving (Eq, Show)
instance StrEncoding AgentSndFileId where
@@ -1161,7 +1161,7 @@ instance FromField AgentSndFileId where fromField f = AgentSndFileId <$> fromFie
instance ToField AgentSndFileId where toField (AgentSndFileId m) = toField m
newtype AgentRcvFileId = AgentRcvFileId ConnId
newtype AgentRcvFileId = AgentRcvFileId RcvFileId
deriving (Eq, Show)
instance StrEncoding AgentRcvFileId where
@@ -1210,6 +1210,7 @@ data FileTransfer
data FileTransferMeta = FileTransferMeta
{ fileId :: FileTransferId,
xftpSndFile :: Maybe XFTPSndFile,
xftpRedirectFor :: Maybe FileTransferId,
fileName :: String,
filePath :: String,
fileSize :: Integer,
+37 -10
View File
@@ -198,17 +198,24 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe
CRGroupMemberUpdated {} -> []
CRContactsMerged u intoCt mergedCt ct' -> ttyUser u $ viewContactsMerged intoCt mergedCt ct'
CRReceivedContactRequest u UserContactRequest {localDisplayName = c, profile} -> ttyUser u $ viewReceivedContactRequest c profile
CRRcvStandaloneFileCreated u ft -> ttyUser u $ receivingFileStandalone "started" ft
CRRcvFileStart u ci -> ttyUser u $ receivingFile_' hu testView "started" ci
CRRcvFileComplete u ci -> ttyUser u $ receivingFile_' hu testView "completed" ci
CRRcvStandaloneFileComplete u _ ft -> ttyUser u $ receivingFileStandalone "completed" ft
CRRcvFileSndCancelled u _ ft -> ttyUser u $ viewRcvFileSndCancelled ft
CRRcvFileError u ci e -> ttyUser u $ receivingFile_' hu testView "error" ci <> [sShow e]
CRRcvFileError u (Just ci) e _ -> ttyUser u $ receivingFile_' hu testView "error" ci <> [sShow e]
CRRcvFileError u Nothing e ft -> ttyUser u $ receivingFileStandalone "error" ft <> [sShow e]
CRSndFileStart u _ ft -> ttyUser u $ sendingFile_ "started" ft
CRSndFileComplete u _ ft -> ttyUser u $ sendingFile_ "completed" ft
CRSndStandaloneFileCreated u ft -> ttyUser u $ uploadingFileStandalone "started" ft
CRSndFileStartXFTP {} -> []
CRSndFileProgressXFTP {} -> []
CRSndFileRedirectStartXFTP u ft ftRedirect -> ttyUser u $ standaloneUploadRedirect ft ftRedirect
CRSndStandaloneFileComplete u ft uris -> ttyUser u $ standaloneUploadComplete ft uris
CRSndFileCompleteXFTP u ci _ -> ttyUser u $ uploadingFile "completed" ci
CRSndFileCancelledXFTP {} -> []
CRSndFileError u ci -> ttyUser u $ uploadingFile "error" ci
CRSndFileError u Nothing ft -> ttyUser u $ uploadingFileStandalone "error" ft
CRSndFileError u (Just ci) _ -> ttyUser u $ uploadingFile "error" ci
CRSndFileRcvCancelled u _ ft@SndFileTransfer {recipientDisplayName = c} ->
ttyUser u [ttyContact c <> " cancelled receiving " <> sndFile ft]
CRContactConnecting u _ -> ttyUser u []
@@ -283,7 +290,7 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe
CRUserContactLinkSubError e -> ["user address error: " <> sShow e, "to delete your address: " <> highlight' "/da"]
CRContactConnectionDeleted u PendingContactConnection {pccConnId} -> ttyUser u ["connection :" <> sShow pccConnId <> " deleted"]
CRNtfTokenStatus status -> ["device token status: " <> plain (smpEncode status)]
CRNtfToken _ status mode -> ["device token status: " <> plain (smpEncode status) <> ", notifications mode: " <> plain (strEncode mode)]
CRNtfToken _ status mode srv -> ["device token status: " <> plain (smpEncode status) <> ", notifications mode: " <> plain (strEncode mode) <> ", server: " <> sShow srv]
CRNtfMessages {} -> []
CRNtfMessage {} -> []
CRCurrentRemoteHost rhi_ ->
@@ -378,6 +385,7 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe
CRChatError u e -> ttyUser' u $ viewChatError logLevel testView e
CRChatErrors u errs -> ttyUser' u $ concatMap (viewChatError logLevel testView) errs
CRArchiveImported archiveErrs -> if null archiveErrs then ["ok"] else ["archive import errors: " <> plain (show archiveErrs)]
CRAppSettings as -> ["app settings: " <> plain (LB.unpack $ J.encode as)]
CRTimedAction _ _ -> []
where
ttyUser :: User -> [StyledString] -> [StyledString]
@@ -1558,11 +1566,26 @@ sendingFile_ status ft@SndFileTransfer {recipientDisplayName = c} =
[status <> " sending " <> sndFile ft <> " to " <> ttyContact c]
uploadingFile :: StyledString -> AChatItem -> [StyledString]
uploadingFile status (AChatItem _ _ (DirectChat Contact {localDisplayName = c}) ChatItem {file = Just CIFile {fileId, fileName}, chatDir = CIDirectSnd}) =
[status <> " uploading " <> fileTransferStr fileId fileName <> " for " <> ttyContact c]
uploadingFile status (AChatItem _ _ (GroupChat g) ChatItem {file = Just CIFile {fileId, fileName}, chatDir = CIGroupSnd}) =
[status <> " uploading " <> fileTransferStr fileId fileName <> " for " <> ttyGroup' g]
uploadingFile status _ = [status <> " uploading file"] -- shouldn't happen
uploadingFile status = \case
AChatItem _ _ (DirectChat Contact {localDisplayName = c}) ChatItem {file = Just CIFile {fileId, fileName}, chatDir = CIDirectSnd} ->
[status <> " uploading " <> fileTransferStr fileId fileName <> " for " <> ttyContact c]
AChatItem _ _ (GroupChat g) ChatItem {file = Just CIFile {fileId, fileName}, chatDir = CIGroupSnd} ->
[status <> " uploading " <> fileTransferStr fileId fileName <> " for " <> ttyGroup' g]
_ -> [status <> " uploading file"]
uploadingFileStandalone :: StyledString -> FileTransferMeta -> [StyledString]
uploadingFileStandalone status FileTransferMeta {fileId, fileName} = [status <> " standalone uploading " <> fileTransferStr fileId fileName]
standaloneUploadRedirect :: FileTransferMeta -> FileTransferMeta -> [StyledString]
standaloneUploadRedirect FileTransferMeta {fileId, fileName} FileTransferMeta {fileId = redirectId} =
[fileTransferStr fileId fileName <> " uploaded, preparing redirect file " <> sShow redirectId]
standaloneUploadComplete :: FileTransferMeta -> [Text] -> [StyledString]
standaloneUploadComplete FileTransferMeta {fileId, fileName} = \case
[] -> [fileTransferStr fileId fileName <> " upload complete."]
uris ->
fileTransferStr fileId fileName <> " upload complete. download with:"
: map plain uris
sndFile :: SndFileTransfer -> StyledString
sndFile SndFileTransfer {fileId, fileName} = fileTransferStr fileId fileName
@@ -1608,7 +1631,11 @@ receivingFile_' hu testView status (AChatItem _ _ chat ChatItem {file = Just CIF
highlight ("/get remote file " <> show rhId <> " " <> LB.unpack (J.encode RemoteFile {userId, fileId, sent = False, fileSource = f}))
]
_ -> []
receivingFile_' _ _ status _ = [plain status <> " receiving file"] -- shouldn't happen
receivingFile_' _ _ status _ = [plain status <> " receiving file"]
receivingFileStandalone :: String -> RcvFileTransfer -> [StyledString]
receivingFileStandalone status RcvFileTransfer {fileId, fileInvitation = FileInvitation {fileName}} =
[plain status <> " standalone receiving " <> fileTransferStr fileId fileName]
viewLocalFile :: StyledString -> CIFile d -> CurrentTime -> TimeZone -> CIMeta c d -> [StyledString]
viewLocalFile to CIFile {fileId, fileSource} ts tz = case fileSource of
@@ -1627,7 +1654,7 @@ fileFrom _ _ = ""
receivingFile_ :: StyledString -> RcvFileTransfer -> [StyledString]
receivingFile_ status ft@RcvFileTransfer {senderDisplayName = c} =
[status <> " receiving " <> rcvFile ft <> " from " <> ttyContact c]
[status <> " receiving " <> rcvFile ft <> if c == "" then "" else " from " <> ttyContact c]
rcvFile :: RcvFileTransfer -> StyledString
rcvFile RcvFileTransfer {fileId, fileInvitation = FileInvitation {fileName}} = fileTransferStr fileId fileName
+10 -9
View File
@@ -15,6 +15,7 @@ import Control.Concurrent.STM
import Control.Exception (bracket, bracket_)
import Control.Monad
import Control.Monad.Except
import Control.Monad.Reader
import Data.ByteArray (ScrubbedBytes)
import Data.Functor (($>))
import Data.List (dropWhileEnd, find)
@@ -22,7 +23,7 @@ import Data.Maybe (isNothing)
import qualified Data.Text as T
import Network.Socket
import Simplex.Chat
import Simplex.Chat.Controller (ChatConfig (..), ChatController (..), ChatDatabase (..), ChatLogLevel (..))
import Simplex.Chat.Controller (ChatCommand (..), ChatConfig (..), ChatController (..), ChatDatabase (..), ChatLogLevel (..))
import Simplex.Chat.Core
import Simplex.Chat.Options
import Simplex.Chat.Store
@@ -129,8 +130,7 @@ testCfg =
{ agentConfig = testAgentCfg,
showReceipts = False,
testView = True,
tbqSize = 16,
xftpFileConfig = Nothing
tbqSize = 16
}
testAgentCfgVPrev :: AgentConfig
@@ -146,9 +146,9 @@ testAgentCfgV1 :: AgentConfig
testAgentCfgV1 =
testAgentCfg
{ smpClientVRange = v1Range,
smpAgentVRange = v1Range,
e2eEncryptVRange = v1Range,
smpCfg = (smpCfg testAgentCfg) {serverVRange = v1Range}
smpAgentVRange = versionToRange 2, -- duplexHandshakeSMPAgentVersion,
e2eEncryptVRange = versionToRange 2, -- kdfX3DHE2EEncryptVersion,
smpCfg = (smpCfg testAgentCfg) {serverVRange = versionToRange 4} -- batchCmdsSMPVersion
}
testCfgVPrev :: ChatConfig
@@ -166,7 +166,7 @@ testCfgV1 =
}
prevRange :: VersionRange -> VersionRange
prevRange vr = vr {maxVersion = maxVersion vr - 1}
prevRange vr = vr {maxVersion = max (minVersion vr) (maxVersion vr - 1)}
v1Range :: VersionRange
v1Range = mkVersionRange 1 1
@@ -209,6 +209,7 @@ startTestChat_ db cfg opts user = do
t <- withVirtualTerminal termSettings pure
ct <- newChatTerminal t opts
cc <- newChatController db (Just user) cfg opts False
void $ execChatCommand' (SetTempFolder "tests/tmp/tmp") `runReaderT` cc
chatAsync <- async . runSimplexChat opts user cc $ \_u cc' -> runChatTerminal ct cc' opts
atomically . unless (maintenance opts) $ readTVar (agentAsync cc) >>= \a -> when (isNothing a) retry
termQ <- newTQueueIO
@@ -384,7 +385,7 @@ serverCfg =
logStatsStartTime = 0,
serverStatsLogFile = "tests/smp-server-stats.daily.log",
serverStatsBackupFile = Nothing,
smpServerVRange = supportedSMPServerVRange,
smpServerVRange = supportedServerSMPRelayVRange,
transportConfig = defaultTransportServerConfig,
smpHandshakeTimeout = 1000000,
controlPort = Nothing
@@ -407,7 +408,7 @@ xftpServerConfig =
storeLogFile = Just "tests/tmp/xftp-server-store.log",
filesPath = xftpServerFiles,
fileSizeQuota = Nothing,
allowedChunkSizes = [kb 128, kb 256, mb 1, mb 4],
allowedChunkSizes = [kb 64, kb 128, kb 256, mb 1, mb 4],
allowNewFiles = True,
newFileBasicAuth = Nothing,
fileExpiration = Just defaultFileExpiration,
+47 -6
View File
@@ -14,6 +14,9 @@ import Data.Aeson (ToJSON)
import qualified Data.Aeson as J
import qualified Data.ByteString.Char8 as B
import qualified Data.ByteString.Lazy.Char8 as LB
import qualified Data.Text as T
import Simplex.Chat.AppSettings (defaultAppSettings)
import qualified Simplex.Chat.AppSettings as AS
import Simplex.Chat.Call
import Simplex.Chat.Controller (ChatConfig (..))
import Simplex.Chat.Options (ChatOpts (..))
@@ -21,6 +24,7 @@ import Simplex.Chat.Protocol (supportedChatVRange)
import Simplex.Chat.Store (agentStoreFile, chatStoreFile)
import Simplex.Chat.Types (authErrDisableCount, sameVerificationCode, verificationCode)
import qualified Simplex.Messaging.Crypto as C
import Simplex.Messaging.Util (safeDecodeUtf8)
import Simplex.Messaging.Version
import System.Directory (copyFile, doesDirectoryExist, doesFileExist)
import System.FilePath ((</>))
@@ -84,8 +88,9 @@ chatDirectTests = do
it "disabling chat item expiration doesn't disable it for other users" testDisableCIExpirationOnlyForOneUser
it "both users have configured timed messages with contacts, messages expire, restart" testUsersTimedMessages
it "user profile privacy: hide profiles and notificaitons" testUserPrivacy
describe "chat item expiration" $ do
it "set chat item TTL" testSetChatItemTTL
describe "settings" $ do
it "set chat item expiration TTL" testSetChatItemTTL
it "save/get app settings" testAppSettings
describe "connection switch" $ do
it "switch contact to a different queue" testSwitchContact
it "stop switching contact to a different queue" testAbortSwitchContact
@@ -1067,7 +1072,7 @@ testChatWorking alice bob = do
alice <# "bob> hello too"
testMaintenanceModeWithFiles :: HasCallStack => FilePath -> IO ()
testMaintenanceModeWithFiles tmp = do
testMaintenanceModeWithFiles tmp = withXFTPServer $ do
withNewTestChat tmp "bob" bobProfile $ \bob -> do
withNewTestChatOpts tmp testOpts {maintenance = True} "alice" aliceProfile $ \alice -> do
alice ##> "/_start"
@@ -1075,12 +1080,26 @@ testMaintenanceModeWithFiles tmp = do
alice ##> "/_files_folder ./tests/tmp/alice_files"
alice <## "ok"
connectUsers alice bob
startFileTransferWithDest' bob alice "test.jpg" "136.5 KiB / 139737 bytes" Nothing
bob <## "completed sending file 1 (test.jpg) to alice"
bob #> "/f @alice ./tests/fixtures/test.jpg"
bob <## "use /fc 1 to cancel sending"
alice <# "bob> sends file test.jpg (136.5 KiB / 139737 bytes)"
alice <## "use /fr 1 [<dir>/ | <path>] to receive it"
bob <## "completed uploading file 1 (test.jpg) for alice"
alice ##> "/fr 1"
alice
<### [ "saving file 1 from bob to test.jpg",
"started receiving file 1 (test.jpg) from bob"
]
alice <## "completed receiving file 1 (test.jpg) from bob"
src <- B.readFile "./tests/fixtures/test.jpg"
B.readFile "./tests/tmp/alice_files/test.jpg" `shouldReturn` src
dest <- B.readFile "./tests/tmp/alice_files/test.jpg"
dest `shouldBe` src
threadDelay 500000
alice ##> "/_stop"
alice <## "chat stopped"
alice ##> "/_db export {\"archivePath\": \"./tests/tmp/alice-chat.zip\"}"
@@ -1124,6 +1143,10 @@ testDatabaseEncryption tmp = do
testChatWorking alice bob
alice ##> "/_stop"
alice <## "chat stopped"
alice ##> "/db test key wrongkey"
alice <## "error opening database after encryption: wrong passphrase or invalid database file"
alice ##> "/db test key mykey"
alice <## "ok"
alice ##> "/db key wrongkey nextkey"
alice <## "error encrypting database: wrong passphrase or invalid database file"
alice ##> "/db key mykey nextkey"
@@ -2177,6 +2200,24 @@ testSetChatItemTTL =
alice #$> ("/ttl none", id, "ok")
alice #$> ("/ttl", id, "old messages are not being deleted")
testAppSettings :: HasCallStack => FilePath -> IO ()
testAppSettings tmp =
withNewTestChat tmp "alice" aliceProfile $ \alice -> do
let settings = T.unpack . safeDecodeUtf8 . LB.toStrict $ J.encode defaultAppSettings
settingsApp = T.unpack . safeDecodeUtf8 . LB.toStrict $ J.encode defaultAppSettings {AS.webrtcICEServers = Just ["non-default.value.com"]}
-- app-provided defaults
alice ##> ("/_get app settings " <> settingsApp)
alice <## ("app settings: " <> settingsApp)
-- parser defaults fallback
alice ##> "/_get app settings"
alice <## ("app settings: " <> settings)
-- store
alice ##> ("/_save app settings " <> settingsApp)
alice <## "ok"
-- read back
alice ##> "/_get app settings"
alice <## ("app settings: " <> settingsApp)
testSwitchContact :: HasCallStack => FilePath -> IO ()
testSwitchContact =
testChat2 aliceProfile bobProfile $

Some files were not shown because too many files have changed in this diff Show More