Compare commits

...

48 Commits

Author SHA1 Message Date
IC Rainbow
ba80a6478c chat: add /zstd 2024-02-19 18:12:31 +02: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
6d523d5b4b 5.5.4: ios 199, android 183, desktop 30 2024-02-17 22:50:13 +00:00
Evgeny Poberezkin
2a321b3ff8 ios: fix showing notification on sent messages 2024-02-17 21:09:19 +00:00
Stanislav Dmitrenko
865a32c608 android, desktop: refactor alerts for slow calls (#3811)
* android, desktop: refactor alerts for slow calls

* sharing text in alerts

* more time to send message

* removed suspend modifier from processing messages

* change

* Revert "removed suspend modifier from processing messages"

This reverts commit 895e804c1b.

* Revert "change"

This reverts commit 013abf49e6.
2024-02-17 18:01:04 +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
91f10c056f docs: change download links to the latest release 2024-02-11 16:26:10 +00:00
Evgeny Poberezkin
3d8d84f978 Merge branch 'stable' 2024-02-11 11:41:13 +00:00
Evgeny Poberezkin
fec34ca875 5.5.3: ios 198, android 181, desktop 29 2024-02-11 11:14:07 +00:00
Evgeny Poberezkin
3a0920e950 core: 5.5.3.0 (simplexmq 5.5.2.0) 2024-02-10 23:24:00 +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
Evgeny Poberezkin
7e9e71ffbd core: update simplexmq (extensible smp handshake) 2024-02-06 07:39:32 +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
Evgeny Poberezkin
09bbaa1c94 5.5.2: ios 196, android 179, desktop 28 2024-02-02 15:32:25 +00:00
Evgeny Poberezkin
3a879b755b core: 5.5.2.0 (simplexmq 5.5.1.2) 2024-02-02 08:30:26 +00:00
Evgeny Poberezkin
c6f4d62d6c core: update simplexmq (fix socket/memory leak on resubscriptions) 2024-02-01 16:36:32 +00:00
Stanislav Dmitrenko
5ad356172f scripts: check that all symbols were exported (#3779)
* scripts: check that all symbols were exported

* changes

* windows

---------

Co-authored-by: Avently <avently@local>
2024-01-31 14:47:10 +00:00
Stanislav Dmitrenko
0a6c464a8d desktop (windows): added a missing symbol to lib build (#3778) 2024-01-31 13:58:30 +00:00
Evgeny Poberezkin
1aa464bdb2 core: unify dependencies for GHC 8.10.7 and 9.6.3 (#3774) 2024-01-30 23:16:39 +00:00
Evgeny Poberezkin
bce829ef58 v5.5.1: ios 195, android 177, desktop 27 2024-01-27 23:23:29 +00:00
Evgeny Poberezkin
7df300cf36 core: 5.5.1.0 2024-01-27 19:18:41 +00:00
Stanislav Dmitrenko
9b35ff6f11 android, desktop: possible fix of chat items race (#3520)
* android, desktop: possible fix of chat items race

* fix compose threads desynchronization

* optimization and preventing race

* removed unused logs

---------

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
2024-01-27 18:46:27 +00:00
M. Sarmad Qadeer
c4cbb49f57 website: update contact page layout if JS is disabled (#3331)
* website: update page layout for the case if javascript is disabled in browser.

* website: add noscript tag & update heading

* website: do some changes in layout of contact page without JS

---------

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
2024-01-27 16:51:21 +00:00
Stanislav Dmitrenko
2b3eebb7a2 android, desktop: show different text when database migrates (#3762)
* android, desktop: show different text when database migrates

* one more check

* refactor

---------

Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
2024-01-26 23:08:48 +00:00
Evgeny Poberezkin
a1328c287c core: remove unused events from api (#3764)
* core: remove unused events from api

* fix test
2024-01-26 18:54:08 +00:00
Stanislav Dmitrenko
f2d498dd79 android, desktop: removed timer for some long running jobs (#3761) 2024-01-26 18:15:20 +00:00
Stanislav Dmitrenko
6b8fc6fdcf android, desktop: lower limit of terminal items for non-developers (#3763) 2024-01-26 18:14:53 +00:00
spaced4ndy
0e585d5e5b core: fix group invitation marked deleted 2024-01-26 20:30:21 +04:00
127 changed files with 2634 additions and 913 deletions

View File

@@ -1,32 +1,41 @@
FROM ubuntu:focal AS build ARG TAG=22.04
# Install curl and simplex-chat-related dependencies FROM ubuntu:${TAG} AS build
RUN apt-get update && apt-get install -y curl git build-essential libgmp3-dev zlib1g-dev libssl-dev
### Build stage
# Install curl and git and simplex-chat dependencies
RUN apt-get update && apt-get install -y curl git build-essential libgmp3-dev zlib1g-dev llvm-12 llvm-12-dev libnuma-dev libssl-dev
# Specify bootstrap Haskell versions
ENV BOOTSTRAP_HASKELL_GHC_VERSION=9.6.3
ENV BOOTSTRAP_HASKELL_CABAL_VERSION=3.10.1.0
# Install ghcup # Install ghcup
RUN a=$(arch); curl https://downloads.haskell.org/~ghcup/$a-linux-ghcup -o /usr/bin/ghcup && \ RUN curl --proto '=https' --tlsv1.2 -sSf https://get-ghcup.haskell.org | BOOTSTRAP_HASKELL_NONINTERACTIVE=1 sh
chmod +x /usr/bin/ghcup
# Install ghc
RUN ghcup install ghc 9.6.3
# Install cabal
RUN ghcup install cabal 3.10.1.0
# Set both as default
RUN ghcup set ghc 9.6.3 && \
ghcup set cabal 3.10.1.0
COPY . /project
WORKDIR /project
# Adjust PATH # Adjust PATH
ENV PATH="/root/.cabal/bin:/root/.ghcup/bin:$PATH" ENV PATH="/root/.cabal/bin:/root/.ghcup/bin:$PATH"
# Set both as default
RUN ghcup set ghc "${BOOTSTRAP_HASKELL_GHC_VERSION}" && \
ghcup set cabal "${BOOTSTRAP_HASKELL_CABAL_VERSION}"
COPY . /project
WORKDIR /project
# Adjust build # Adjust build
RUN cp ./scripts/cabal.project.local.linux ./cabal.project.local RUN cp ./scripts/cabal.project.local.linux ./cabal.project.local
# Compile simplex-chat # Compile simplex-chat
RUN cabal update RUN cabal update
RUN cabal install RUN cabal build exe:simplex-chat
# Strip the binary from debug symbols to reduce size
RUN bin=$(find /project/dist-newstyle -name "simplex-chat" -type f -executable) && \
mv "$bin" ./ && \
strip ./simplex-chat
# Copy compiled app from build stage
FROM scratch AS export-stage FROM scratch AS export-stage
COPY --from=build /root/.cabal/bin/simplex-chat / COPY --from=build /project/simplex-chat /

View File

@@ -34,6 +34,8 @@ struct ContentView: View {
@State private var waitingForOrPassedAuth = true @State private var waitingForOrPassedAuth = true
@State private var chatListActionSheet: ChatListActionSheet? = nil @State private var chatListActionSheet: ChatListActionSheet? = nil
private let callTopPadding: CGFloat = 50
private enum ChatListActionSheet: Identifiable { private enum ChatListActionSheet: Identifiable {
case planAndConnectSheet(sheet: PlanAndConnectActionSheet) case planAndConnectSheet(sheet: PlanAndConnectActionSheet)
@@ -50,16 +52,28 @@ struct ContentView: View {
var body: some View { var body: some View {
ZStack { ZStack {
let showCallArea = chatModel.activeCall != nil && chatModel.activeCall?.callState != .waitCapabilities && chatModel.activeCall?.callState != .invitationAccepted
// contentView() has to be in a single branch, so that enabling authentication doesn't trigger re-rendering and close settings. // contentView() has to be in a single branch, so that enabling authentication doesn't trigger re-rendering and close settings.
// i.e. with separate branches like this settings are closed: `if prefPerformLA { ... contentView() ... } else { contentView() } // i.e. with separate branches like this settings are closed: `if prefPerformLA { ... contentView() ... } else { contentView() }
if !prefPerformLA || accessAuthenticated { if !prefPerformLA || accessAuthenticated {
contentView() contentView()
.padding(.top, showCallArea ? callTopPadding : 0)
} else { } else {
lockButton() lockButton()
.padding(.top, showCallArea ? callTopPadding : 0)
} }
if showCallArea, let call = chatModel.activeCall {
VStack {
activeCallInteractiveArea(call)
Spacer()
}
}
if chatModel.showCallView, let call = chatModel.activeCall { if chatModel.showCallView, let call = chatModel.activeCall {
callView(call) callView(call)
} }
if !showSettings, let la = chatModel.laRequest { if !showSettings, let la = chatModel.laRequest {
LocalAuthView(authRequest: la) LocalAuthView(authRequest: la)
.onDisappear { .onDisappear {
@@ -135,11 +149,11 @@ struct ContentView: View {
if case .onboardingComplete = step, if case .onboardingComplete = step,
chatModel.currentUser != nil { chatModel.currentUser != nil {
mainView() mainView()
.actionSheet(item: $chatListActionSheet) { sheet in .actionSheet(item: $chatListActionSheet) { sheet in
switch sheet { switch sheet {
case let .planAndConnectSheet(sheet): return planAndConnectActionSheet(sheet, dismiss: false) case let .planAndConnectSheet(sheet): return planAndConnectActionSheet(sheet, dismiss: false)
}
} }
}
} else { } else {
OnboardingView(onboarding: step) OnboardingView(onboarding: step)
} }
@@ -163,6 +177,40 @@ struct ContentView: View {
} }
} }
@ViewBuilder private func activeCallInteractiveArea(_ call: Call) -> some View {
HStack {
Text(call.contact.displayName).font(.body).foregroundColor(.white)
Spacer()
CallDuration(call: call)
}
.padding(.horizontal)
.frame(height: callTopPadding - 10)
.background(Color(uiColor: UIColor(red: 47/255, green: 208/255, blue: 88/255, alpha: 1)))
.onTapGesture {
chatModel.activeCallViewIsCollapsed = false
}
}
struct CallDuration: View {
let call: Call
@State var text: String = ""
@State var timer: Timer? = nil
var body: some View {
Text(text).frame(minWidth: text.count <= 5 ? 52 : 77, alignment: .leading).offset(x: 4).font(.body).foregroundColor(.white)
.onAppear {
timer = Timer.scheduledTimer(withTimeInterval: 0.3, repeats: true) { timer in
if let connectedAt = call.connectedAt {
text = durationText(Int(Date.now.timeIntervalSince1970 - connectedAt.timeIntervalSince1970))
}
}
}
.onDisappear {
_ = timer?.invalidate()
}
}
}
private func lockButton() -> some View { private func lockButton() -> some View {
Button(action: authenticateContentViewAccess) { Label("Unlock", systemImage: "lock") } Button(action: authenticateContentViewAccess) { Label("Unlock", systemImage: "lock") }
} }

View File

@@ -80,6 +80,7 @@ final class ChatModel: ObservableObject {
@Published var tokenRegistered = false @Published var tokenRegistered = false
@Published var tokenStatus: NtfTknStatus? @Published var tokenStatus: NtfTknStatus?
@Published var notificationMode = NotificationsMode.off @Published var notificationMode = NotificationsMode.off
@Published var notificationServer: String?
@Published var notificationPreview: NotificationPreviewMode = ntfPreviewModeGroupDefault.get() @Published var notificationPreview: NotificationPreviewMode = ntfPreviewModeGroupDefault.get()
// pending notification actions // pending notification actions
@Published var ntfContactRequest: NTFContactRequest? @Published var ntfContactRequest: NTFContactRequest?
@@ -89,6 +90,7 @@ final class ChatModel: ObservableObject {
@Published var activeCall: Call? @Published var activeCall: Call?
let callCommand: WebRTCCommandProcessor = WebRTCCommandProcessor() let callCommand: WebRTCCommandProcessor = WebRTCCommandProcessor()
@Published var showCallView = false @Published var showCallView = false
@Published var activeCallViewIsCollapsed = false
// remote desktop // remote desktop
@Published var remoteCtrlSession: RemoteCtrlSession? @Published var remoteCtrlSession: RemoteCtrlSession?
// currently showing invitation // currently showing invitation

View File

@@ -412,14 +412,14 @@ func apiDeleteMemberChatItem(groupId: Int64, groupMemberId: Int64, itemId: Int64
throw r throw r
} }
func apiGetNtfToken() -> (DeviceToken?, NtfTknStatus?, NotificationsMode) { func apiGetNtfToken() -> (DeviceToken?, NtfTknStatus?, NotificationsMode, String?) {
let r = chatSendCmdSync(.apiGetNtfToken) let r = chatSendCmdSync(.apiGetNtfToken)
switch r { switch r {
case let .ntfToken(token, status, ntfMode): return (token, status, ntfMode) case let .ntfToken(token, status, ntfMode, ntfServer): return (token, status, ntfMode, ntfServer)
case .chatCmdError(_, .errorAgent(.CMD(.PROHIBITED))): return (nil, nil, .off) case .chatCmdError(_, .errorAgent(.CMD(.PROHIBITED))): return (nil, nil, .off, nil)
default: default:
logger.debug("apiGetNtfToken response: \(String(describing: r))") logger.debug("apiGetNtfToken response: \(String(describing: r))")
return (nil, nil, .off) return (nil, nil, .off, nil)
} }
} }
@@ -1309,7 +1309,7 @@ func startChat(refreshInvitations: Bool = true) throws {
if (refreshInvitations) { if (refreshInvitations) {
try refreshCallInvitations() try refreshCallInvitations()
} }
(m.savedToken, m.tokenStatus, m.notificationMode) = apiGetNtfToken() (m.savedToken, m.tokenStatus, m.notificationMode, m.notificationServer) = apiGetNtfToken()
// deviceToken is set when AppDelegate.application(didRegisterForRemoteNotificationsWithDeviceToken:) is called, // deviceToken is set when AppDelegate.application(didRegisterForRemoteNotificationsWithDeviceToken:) is called,
// when it is called before startChat // when it is called before startChat
if let token = m.deviceToken { if let token = m.deviceToken {
@@ -1861,7 +1861,9 @@ func chatItemSimpleUpdate(_ user: any UserLike, _ aChatItem: AChatItem) async {
let cItem = aChatItem.chatItem let cItem = aChatItem.chatItem
if active(user) { if active(user) {
if await MainActor.run(body: { m.upsertChatItem(cInfo, cItem) }) { if await MainActor.run(body: { m.upsertChatItem(cInfo, cItem) }) {
NtfManager.shared.notifyMessageReceived(user, cInfo, cItem) if cItem.showNotification {
NtfManager.shared.notifyMessageReceived(user, cInfo, cItem)
}
} }
} }
} }

View File

@@ -12,49 +12,67 @@ import SimpleXChat
struct ActiveCallView: View { struct ActiveCallView: View {
@EnvironmentObject var m: ChatModel @EnvironmentObject var m: ChatModel
@Environment(\.colorScheme) var colorScheme
@ObservedObject var call: Call @ObservedObject var call: Call
@Environment(\.scenePhase) var scenePhase @Environment(\.scenePhase) var scenePhase
@State private var client: WebRTCClient? = nil @State private var client: WebRTCClient? = nil
@State private var activeCall: WebRTCClient.Call? = nil @State private var activeCall: WebRTCClient.Call? = nil
@State private var localRendererAspectRatio: CGFloat? = nil @State private var localRendererAspectRatio: CGFloat? = nil
@Binding var canConnectCall: Bool @Binding var canConnectCall: Bool
@State var prevColorScheme: ColorScheme = .dark
@State var pipShown = false
var body: some View { var body: some View {
ZStack(alignment: .bottom) { ZStack(alignment: .topLeading) {
if let client = client, [call.peerMedia, call.localMedia].contains(.video), activeCall != nil { ZStack(alignment: .bottom) {
GeometryReader { g in if let client = client, [call.peerMedia, call.localMedia].contains(.video), activeCall != nil {
let width = g.size.width * 0.3 GeometryReader { g in
ZStack(alignment: .topTrailing) { let width = g.size.width * 0.3
CallViewRemote(client: client, activeCall: $activeCall) ZStack(alignment: .topTrailing) {
CallViewLocal(client: client, activeCall: $activeCall, localRendererAspectRatio: $localRendererAspectRatio) CallViewRemote(client: client, activeCall: $activeCall, activeCallViewIsCollapsed: $m.activeCallViewIsCollapsed, pipShown: $pipShown)
.cornerRadius(10) CallViewLocal(client: client, activeCall: $activeCall, localRendererAspectRatio: $localRendererAspectRatio, pipShown: $pipShown)
.frame(width: width, height: width / (localRendererAspectRatio ?? 1)) .cornerRadius(10)
.padding([.top, .trailing], 17) .frame(width: width, height: width / (localRendererAspectRatio ?? 1))
.padding([.top, .trailing], 17)
ZStack(alignment: .center) {
// For some reason, when the view in GeometryReader and ZStack is visible, it steals clicks on a back button, so showing something on top like this with background color helps (.clear color doesn't work)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.primary.opacity(0.000001))
}
} }
} }
} if let call = m.activeCall, let client = client, (!pipShown || !call.supportsVideo) {
if let call = m.activeCall, let client = client { ActiveCallOverlay(call: call, client: client)
ActiveCallOverlay(call: call, client: client) }
} }
} }
.allowsHitTesting(!m.activeCallViewIsCollapsed)
.opacity(m.activeCallViewIsCollapsed ? 0 : 1)
.onAppear { .onAppear {
logger.debug("ActiveCallView: appear client is nil \(client == nil), scenePhase \(String(describing: scenePhase)), canConnectCall \(canConnectCall)") logger.debug("ActiveCallView: appear client is nil \(client == nil), scenePhase \(String(describing: scenePhase)), canConnectCall \(canConnectCall)")
AppDelegate.keepScreenOn(true) AppDelegate.keepScreenOn(true)
createWebRTCClient() createWebRTCClient()
dismissAllSheets() dismissAllSheets()
hideKeyboard()
prevColorScheme = colorScheme
} }
.onChange(of: canConnectCall) { _ in .onChange(of: canConnectCall) { _ in
logger.debug("ActiveCallView: canConnectCall changed to \(canConnectCall)") logger.debug("ActiveCallView: canConnectCall changed to \(canConnectCall)")
createWebRTCClient() createWebRTCClient()
} }
.onChange(of: m.activeCallViewIsCollapsed) { _ in
hideKeyboard()
}
.onDisappear { .onDisappear {
logger.debug("ActiveCallView: disappear") logger.debug("ActiveCallView: disappear")
Task { await m.callCommand.setClient(nil) } Task { await m.callCommand.setClient(nil) }
AppDelegate.keepScreenOn(false) AppDelegate.keepScreenOn(false)
client?.endCall() client?.endCall()
} }
.background(.black) .background(m.activeCallViewIsCollapsed ? .clear : .black)
.preferredColorScheme(.dark) // Quite a big delay when opening/closing the view when a scheme changes (globally) this way. It's not needed when CallKit is used since status bar is green with white text on it
.preferredColorScheme(m.activeCallViewIsCollapsed || CallController.useCallKit() ? prevColorScheme : .dark)
} }
private func createWebRTCClient() { private func createWebRTCClient() {
@@ -69,8 +87,8 @@ struct ActiveCallView: View {
@MainActor @MainActor
private func processRtcMessage(msg: WVAPIMessage) { private func processRtcMessage(msg: WVAPIMessage) {
if call == m.activeCall, if call == m.activeCall,
let call = m.activeCall, let call = m.activeCall,
let client = client { let client = client {
logger.debug("ActiveCallView: response \(msg.resp.respType)") logger.debug("ActiveCallView: response \(msg.resp.respType)")
switch msg.resp { switch msg.resp {
case let .capabilities(capabilities): case let .capabilities(capabilities):
@@ -90,7 +108,7 @@ struct ActiveCallView: View {
Task { Task {
do { do {
try await apiSendCallOffer(call.contact, offer, iceCandidates, try await apiSendCallOffer(call.contact, offer, iceCandidates,
media: call.localMedia, capabilities: capabilities) media: call.localMedia, capabilities: capabilities)
} catch { } catch {
logger.error("apiSendCallOffer \(responseError(error))") logger.error("apiSendCallOffer \(responseError(error))")
} }
@@ -122,13 +140,15 @@ struct ActiveCallView: View {
if let callStatus = WebRTCCallStatus.init(rawValue: state.connectionState), if let callStatus = WebRTCCallStatus.init(rawValue: state.connectionState),
case .connected = callStatus { case .connected = callStatus {
call.direction == .outgoing call.direction == .outgoing
? CallController.shared.reportOutgoingCall(call: call, connectedAt: nil) ? CallController.shared.reportOutgoingCall(call: call, connectedAt: nil)
: CallController.shared.reportIncomingCall(call: call, connectedAt: nil) : CallController.shared.reportIncomingCall(call: call, connectedAt: nil)
call.callState = .connected call.callState = .connected
call.connectedAt = .now
} }
if state.connectionState == "closed" { if state.connectionState == "closed" {
closeCallView(client) closeCallView(client)
m.activeCall = nil m.activeCall = nil
m.activeCallViewIsCollapsed = false
} }
Task { Task {
do { do {
@@ -140,6 +160,7 @@ struct ActiveCallView: View {
case let .connected(connectionInfo): case let .connected(connectionInfo):
call.callState = .connected call.callState = .connected
call.connectionInfo = connectionInfo call.connectionInfo = connectionInfo
call.connectedAt = .now
case .ended: case .ended:
closeCallView(client) closeCallView(client)
call.callState = .ended call.callState = .ended
@@ -153,6 +174,7 @@ struct ActiveCallView: View {
case .end: case .end:
closeCallView(client) closeCallView(client)
m.activeCall = nil m.activeCall = nil
m.activeCallViewIsCollapsed = false
default: () default: ()
} }
case let .error(message): case let .error(message):
@@ -181,7 +203,7 @@ struct ActiveCallOverlay: View {
VStack { VStack {
switch call.localMedia { switch call.localMedia {
case .video: case .video:
callInfoView(call, .leading) videoCallInfoView(call)
.foregroundColor(.white) .foregroundColor(.white)
.opacity(0.8) .opacity(0.8)
.padding() .padding()
@@ -208,16 +230,25 @@ struct ActiveCallOverlay: View {
.frame(maxWidth: .infinity, alignment: .center) .frame(maxWidth: .infinity, alignment: .center)
case .audio: case .audio:
VStack { ZStack(alignment: .topLeading) {
ProfileImage(imageStr: call.contact.profile.image) Button {
.scaledToFit() chatModel.activeCallViewIsCollapsed = true
.frame(width: 192, height: 192) } label: {
callInfoView(call, .center) Label("Back", systemImage: "chevron.left")
.padding()
.foregroundColor(.white.opacity(0.8))
}
VStack {
ProfileImage(imageStr: call.contact.profile.image)
.scaledToFit()
.frame(width: 192, height: 192)
audioCallInfoView(call)
}
.foregroundColor(.white)
.opacity(0.8)
.padding()
.frame(maxHeight: .infinity)
} }
.foregroundColor(.white)
.opacity(0.8)
.padding()
.frame(maxHeight: .infinity)
Spacer() Spacer()
@@ -235,12 +266,12 @@ struct ActiveCallOverlay: View {
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
} }
private func callInfoView(_ call: Call, _ alignment: Alignment) -> some View { private func audioCallInfoView(_ call: Call) -> some View {
VStack { VStack {
Text(call.contact.chatViewName) Text(call.contact.chatViewName)
.lineLimit(1) .lineLimit(1)
.font(.title) .font(.title)
.frame(maxWidth: .infinity, alignment: alignment) .frame(maxWidth: .infinity, alignment: .center)
Group { Group {
Text(call.callState.text) Text(call.callState.text)
HStack { HStack {
@@ -251,7 +282,36 @@ struct ActiveCallOverlay: View {
} }
} }
.font(.subheadline) .font(.subheadline)
.frame(maxWidth: .infinity, alignment: alignment) .frame(maxWidth: .infinity, alignment: .center)
}
}
private func videoCallInfoView(_ call: Call) -> some View {
VStack {
Button {
chatModel.activeCallViewIsCollapsed = true
} label: {
HStack(alignment: .center, spacing: 16) {
Image(systemName: "chevron.left")
.resizable()
.frame(width: 10, height: 18)
Text(call.contact.chatViewName)
.lineLimit(1)
.font(.title)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
Group {
Text(call.callState.text)
HStack {
Text(call.encryptionStatus)
if let connInfo = call.connectionInfo {
Text("(") + Text(connInfo.text) + Text(")")
}
}
}
.font(.subheadline)
.frame(maxWidth: .infinity, alignment: .leading)
} }
} }

View File

@@ -92,6 +92,7 @@ class CallManager {
if case .ended = call.callState { if case .ended = call.callState {
logger.debug("CallManager.endCall: call ended") logger.debug("CallManager.endCall: call ended")
m.activeCall = nil m.activeCall = nil
m.activeCallViewIsCollapsed = false
m.showCallView = false m.showCallView = false
completed() completed()
} else { } else {
@@ -100,6 +101,7 @@ class CallManager {
await m.callCommand.processCommand(.end) await m.callCommand.processCommand(.end)
await MainActor.run { await MainActor.run {
m.activeCall = nil m.activeCall = nil
m.activeCallViewIsCollapsed = false
m.showCallView = false m.showCallView = false
completed() completed()
} }

View File

@@ -6,14 +6,20 @@
import SwiftUI import SwiftUI
import WebRTC import WebRTC
import SimpleXChat import SimpleXChat
import AVKit
struct CallViewRemote: UIViewRepresentable { struct CallViewRemote: UIViewRepresentable {
var client: WebRTCClient var client: WebRTCClient
var activeCall: Binding<WebRTCClient.Call?> var activeCall: Binding<WebRTCClient.Call?>
@State var enablePip: (Bool) -> Void = {_ in }
@Binding var activeCallViewIsCollapsed: Bool
@Binding var pipShown: Bool
init(client: WebRTCClient, activeCall: Binding<WebRTCClient.Call?>) { init(client: WebRTCClient, activeCall: Binding<WebRTCClient.Call?>, activeCallViewIsCollapsed: Binding<Bool>, pipShown: Binding<Bool>) {
self.client = client self.client = client
self.activeCall = activeCall self.activeCall = activeCall
self._activeCallViewIsCollapsed = activeCallViewIsCollapsed
self._pipShown = pipShown
} }
func makeUIView(context: Context) -> UIView { func makeUIView(context: Context) -> UIView {
@@ -23,12 +29,120 @@ struct CallViewRemote: UIViewRepresentable {
remoteRenderer.videoContentMode = .scaleAspectFill remoteRenderer.videoContentMode = .scaleAspectFill
client.addRemoteRenderer(call, remoteRenderer) client.addRemoteRenderer(call, remoteRenderer)
addSubviewAndResize(remoteRenderer, into: view) addSubviewAndResize(remoteRenderer, into: view)
if AVPictureInPictureController.isPictureInPictureSupported() {
makeViewWithRTCRenderer(call, remoteRenderer, view, context)
}
} }
return view return view
} }
func makeViewWithRTCRenderer(_ call: WebRTCClient.Call, _ remoteRenderer: RTCMTLVideoView, _ view: UIView, _ context: Context) {
let pipRemoteRenderer = RTCMTLVideoView(frame: view.frame)
pipRemoteRenderer.videoContentMode = .scaleAspectFill
let pipVideoCallViewController = AVPictureInPictureVideoCallViewController()
pipVideoCallViewController.preferredContentSize = CGSize(width: 1080, height: 1920)
addSubviewAndResize(pipRemoteRenderer, into: pipVideoCallViewController.view)
let pipContentSource = AVPictureInPictureController.ContentSource(
activeVideoCallSourceView: view,
contentViewController: pipVideoCallViewController
)
let pipController = AVPictureInPictureController(contentSource: pipContentSource)
pipController.canStartPictureInPictureAutomaticallyFromInline = true
pipController.delegate = context.coordinator
context.coordinator.pipController = pipController
context.coordinator.willShowHide = { show in
if show {
client.addRemoteRenderer(call, pipRemoteRenderer)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
activeCallViewIsCollapsed = true
}
} else {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
activeCallViewIsCollapsed = false
}
}
}
context.coordinator.didShowHide = { show in
if show {
remoteRenderer.isHidden = true
} else {
client.removeRemoteRenderer(call, pipRemoteRenderer)
remoteRenderer.isHidden = false
}
pipShown = show
}
DispatchQueue.main.async {
enablePip = { enable in
if enable != pipShown /* pipController.isPictureInPictureActive */ {
if enable {
pipController.startPictureInPicture()
} else {
pipController.stopPictureInPicture()
}
}
}
}
}
func makeCoordinator() -> Coordinator {
Coordinator()
}
func updateUIView(_ view: UIView, context: Context) { func updateUIView(_ view: UIView, context: Context) {
logger.debug("CallView.updateUIView remote") logger.debug("CallView.updateUIView remote")
DispatchQueue.main.async {
if activeCallViewIsCollapsed != pipShown {
enablePip(activeCallViewIsCollapsed)
}
}
}
// MARK: - Coordinator
class Coordinator: NSObject, AVPictureInPictureControllerDelegate {
var pipController: AVPictureInPictureController? = nil
var willShowHide: (Bool) -> Void = { _ in }
var didShowHide: (Bool) -> Void = { _ in }
func pictureInPictureControllerWillStartPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) {
willShowHide(true)
}
func pictureInPictureControllerDidStartPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) {
didShowHide(true)
}
func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, failedToStartPictureInPictureWithError error: Error) {
logger.error("PiP failed to start: \(error.localizedDescription)")
}
func pictureInPictureControllerWillStopPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) {
willShowHide(false)
}
func pictureInPictureControllerDidStopPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) {
didShowHide(false)
}
deinit {
pipController?.stopPictureInPicture()
pipController?.canStartPictureInPictureAutomaticallyFromInline = false
pipController?.contentSource = nil
pipController?.delegate = nil
pipController = nil
}
}
class SampleBufferVideoCallView: UIView {
override class var layerClass: AnyClass {
get { return AVSampleBufferDisplayLayer.self }
}
var sampleBufferDisplayLayer: AVSampleBufferDisplayLayer {
return layer as! AVSampleBufferDisplayLayer
}
} }
} }
@@ -36,11 +150,14 @@ struct CallViewLocal: UIViewRepresentable {
var client: WebRTCClient var client: WebRTCClient
var activeCall: Binding<WebRTCClient.Call?> var activeCall: Binding<WebRTCClient.Call?>
var localRendererAspectRatio: Binding<CGFloat?> var localRendererAspectRatio: Binding<CGFloat?>
@State var pipStateChanged: (Bool) -> Void = {_ in }
@Binding var pipShown: Bool
init(client: WebRTCClient, activeCall: Binding<WebRTCClient.Call?>, localRendererAspectRatio: Binding<CGFloat?>) { init(client: WebRTCClient, activeCall: Binding<WebRTCClient.Call?>, localRendererAspectRatio: Binding<CGFloat?>, pipShown: Binding<Bool>) {
self.client = client self.client = client
self.activeCall = activeCall self.activeCall = activeCall
self.localRendererAspectRatio = localRendererAspectRatio self.localRendererAspectRatio = localRendererAspectRatio
self._pipShown = pipShown
} }
func makeUIView(context: Context) -> UIView { func makeUIView(context: Context) -> UIView {
@@ -50,12 +167,18 @@ struct CallViewLocal: UIViewRepresentable {
client.addLocalRenderer(call, localRenderer) client.addLocalRenderer(call, localRenderer)
client.startCaptureLocalVideo(call) client.startCaptureLocalVideo(call)
addSubviewAndResize(localRenderer, into: view) addSubviewAndResize(localRenderer, into: view)
DispatchQueue.main.async {
pipStateChanged = { shown in
localRenderer.isHidden = shown
}
}
} }
return view return view
} }
func updateUIView(_ view: UIView, context: Context) { func updateUIView(_ view: UIView, context: Context) {
logger.debug("CallView.updateUIView local") logger.debug("CallView.updateUIView local")
pipStateChanged(pipShown)
} }
} }

View File

@@ -28,6 +28,7 @@ class Call: ObservableObject, Equatable {
@Published var speakerEnabled = false @Published var speakerEnabled = false
@Published var videoEnabled: Bool @Published var videoEnabled: Bool
@Published var connectionInfo: ConnectionInfo? @Published var connectionInfo: ConnectionInfo?
@Published var connectedAt: Date? = nil
init( init(
direction: CallDirection, direction: CallDirection,
@@ -59,6 +60,7 @@ class Call: ObservableObject, Equatable {
} }
} }
var hasMedia: Bool { get { callState == .offerSent || callState == .negotiated || callState == .connected } } var hasMedia: Bool { get { callState == .offerSent || callState == .negotiated || callState == .connected } }
var supportsVideo: Bool { get { peerMedia == .video || localMedia == .video } }
} }
enum CallDirection { enum CallDirection {

View File

@@ -331,6 +331,10 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
activeCall.remoteStream?.add(renderer) activeCall.remoteStream?.add(renderer)
} }
func removeRemoteRenderer(_ activeCall: Call, _ renderer: RTCVideoRenderer) {
activeCall.remoteStream?.remove(renderer)
}
func startCaptureLocalVideo(_ activeCall: Call) { func startCaptureLocalVideo(_ activeCall: Call) {
#if targetEnvironment(simulator) #if targetEnvironment(simulator)
guard guard
@@ -410,6 +414,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
guard let call = activeCall.wrappedValue else { return } guard let call = activeCall.wrappedValue else { return }
logger.debug("WebRTCClient: ending the call") logger.debug("WebRTCClient: ending the call")
activeCall.wrappedValue = nil activeCall.wrappedValue = nil
(call.localCamera as? RTCCameraVideoCapturer)?.stopCapture()
call.connection.close() call.connection.close()
call.connection.delegate = nil call.connection.delegate = nil
call.frameEncryptor?.delegate = nil call.frameEncryptor?.delegate = nil

View File

@@ -29,6 +29,9 @@ struct CIImageView: View {
FullScreenMediaView(chatItem: chatItem, image: uiImage, showView: $showFullScreenImage, scrollProxy: scrollProxy) FullScreenMediaView(chatItem: chatItem, image: uiImage, showView: $showFullScreenImage, scrollProxy: scrollProxy)
} }
.onTapGesture { showFullScreenImage = true } .onTapGesture { showFullScreenImage = true }
.onChange(of: m.activeCallViewIsCollapsed) { _ in
showFullScreenImage = false
}
} else if let data = Data(base64Encoded: dropImagePrefix(image)), } else if let data = Data(base64Encoded: dropImagePrefix(image)),
let uiImage = UIImage(data: data) { let uiImage = UIImage(data: data) {
imageView(uiImage) imageView(uiImage)

View File

@@ -120,6 +120,9 @@ struct CIVideoView: View {
showFullScreenPlayer = urlDecrypted != nil showFullScreenPlayer = urlDecrypted != nil
} }
} }
.onChange(of: m.activeCallViewIsCollapsed) { _ in
showFullScreenPlayer = false
}
if !decryptionInProgress { if !decryptionInProgress {
Button { Button {
decrypt(file: file) { decrypt(file: file) {
@@ -168,6 +171,9 @@ struct CIVideoView: View {
default: () default: ()
} }
} }
.onChange(of: m.activeCallViewIsCollapsed) { _ in
showFullScreenPlayer = false
}
if !videoPlaying { if !videoPlaying {
Button { Button {
m.stopPreviousRecPlay = url m.stopPreviousRecPlay = url

View File

@@ -161,11 +161,15 @@ struct ChatView: View {
HStack { HStack {
let callsPrefEnabled = contact.mergedPreferences.calls.enabled.forUser let callsPrefEnabled = contact.mergedPreferences.calls.enabled.forUser
if callsPrefEnabled { if callsPrefEnabled {
callButton(contact, .audio, imageName: "phone") if chatModel.activeCall == nil {
.disabled(!contact.ready || !contact.active) callButton(contact, .audio, imageName: "phone")
.disabled(!contact.ready || !contact.active)
} else if let call = chatModel.activeCall, call.contact.id == cInfo.id {
endCallButton(call)
}
} }
Menu { Menu {
if callsPrefEnabled { if callsPrefEnabled && chatModel.activeCall == nil {
Button { Button {
CallController.shared.startCall(contact, .video) CallController.shared.startCall(contact, .video)
} label: { } label: {
@@ -422,7 +426,19 @@ struct ChatView: View {
Image(systemName: imageName) Image(systemName: imageName)
} }
} }
private func endCallButton(_ call: Call) -> some View {
Button {
if let uuid = call.callkitUUID {
CallController.shared.endCall(callUUID: uuid)
} else {
CallController.shared.endCall(call: call) {}
}
} label: {
Image(systemName: "phone.down.fill").tint(.red)
}
}
private func searchButton() -> some View { private func searchButton() -> some View {
Button { Button {
searchMode = true searchMode = true

View File

@@ -234,39 +234,29 @@ struct GroupChatInfoView: View {
Spacer() Spacer()
memberInfo(member) memberInfo(member)
} }
// revert from this:
if user { if user {
v v
} else if member.canBeRemoved(groupInfo: groupInfo) { } else if groupInfo.membership.memberRole >= .admin {
removeSwipe(member, blockSwipe(member, v)) // TODO if there are more actions, refactor with lists of swipeActions
let canBlockForAll = member.canBlockForAll(groupInfo: groupInfo)
let canRemove = member.canBeRemoved(groupInfo: groupInfo)
if canBlockForAll && canRemove {
removeSwipe(member, blockForAllSwipe(member, v))
} else if canBlockForAll {
blockForAllSwipe(member, v)
} else if canRemove {
removeSwipe(member, v)
} else {
v
}
} else { } else {
blockSwipe(member, v) if !member.blockedByAdmin {
blockSwipe(member, v)
} else {
v
}
} }
// revert to this: vvv
// if user {
// v
// } else if groupInfo.membership.memberRole >= .admin {
// // TODO if there are more actions, refactor with lists of swipeActions
// let canBlockForAll = member.canBlockForAll(groupInfo: groupInfo)
// let canRemove = member.canBeRemoved(groupInfo: groupInfo)
// if canBlockForAll && canRemove {
// removeSwipe(member, blockForAllSwipe(member, v))
// } else if canBlockForAll {
// blockForAllSwipe(member, v)
// } else if canRemove {
// removeSwipe(member, v)
// } else {
// v
// }
// } else {
// if !member.blockedByAdmin {
// blockSwipe(member, v)
// } else {
// v
// }
// }
// ^^^
} }
@ViewBuilder private func memberInfo(_ member: GroupMember) -> some View { @ViewBuilder private func memberInfo(_ member: GroupMember) -> some View {

View File

@@ -168,24 +168,11 @@ struct GroupMemberInfoView: View {
} }
} }
// revert from this: if groupInfo.membership.memberRole >= .admin {
Section { adminDestructiveSection(member)
if member.memberSettings.showMessages { } else {
blockMemberButton(member) nonAdminBlockSection(member)
} else {
unblockMemberButton(member)
}
if member.canBeRemoved(groupInfo: groupInfo) {
removeMemberButton(member)
}
} }
// revert to this: vvv
// if groupInfo.membership.memberRole >= .admin {
// adminDestructiveSection(member)
// } else {
// nonAdminBlockSection(member)
// }
// ^^^
if developerTools { if developerTools {
Section("For console") { Section("For console") {

View File

@@ -76,6 +76,10 @@ struct NotificationsView: View {
Text(m.notificationPreview.label) Text(m.notificationPreview.label)
} }
} }
if let server = m.notificationServer {
smpServers("Push server", [server])
}
} header: { } header: {
Text("Push notifications") Text("Push notifications")
} footer: { } footer: {
@@ -87,6 +91,9 @@ struct NotificationsView: View {
} }
} }
.disabled(legacyDatabase) .disabled(legacyDatabase)
.onAppear {
(m.savedToken, m.tokenStatus, m.notificationMode, m.notificationServer) = apiGetNtfToken()
}
} }
private func notificationAlert(_ alert: NotificationAlert, _ token: DeviceToken) -> Alert { private func notificationAlert(_ alert: NotificationAlert, _ token: DeviceToken) -> Alert {
@@ -125,6 +132,7 @@ struct NotificationsView: View {
m.tokenStatus = .new m.tokenStatus = .new
notificationMode = .off notificationMode = .off
m.notificationMode = .off m.notificationMode = .off
m.notificationServer = nil
} }
} catch let error { } catch let error {
await MainActor.run { await MainActor.run {
@@ -135,11 +143,13 @@ struct NotificationsView: View {
} }
default: default:
do { do {
let status = try await apiRegisterToken(token: token, notificationMode: mode) let _ = try await apiRegisterToken(token: token, notificationMode: mode)
let (_, tknStatus, ntfMode, ntfServer) = apiGetNtfToken()
await MainActor.run { await MainActor.run {
m.tokenStatus = status m.tokenStatus = tknStatus
notificationMode = mode notificationMode = ntfMode
m.notificationMode = mode m.notificationMode = ntfMode
m.notificationServer = ntfServer
} }
} catch let error { } catch let error {
await MainActor.run { await MainActor.run {

View File

@@ -61,11 +61,6 @@
5C7505A527B679EE00BE3227 /* NavLinkPlain.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C7505A427B679EE00BE3227 /* NavLinkPlain.swift */; }; 5C7505A527B679EE00BE3227 /* NavLinkPlain.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C7505A427B679EE00BE3227 /* NavLinkPlain.swift */; };
5C7505A827B6D34800BE3227 /* ChatInfoToolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C7505A727B6D34800BE3227 /* ChatInfoToolbar.swift */; }; 5C7505A827B6D34800BE3227 /* ChatInfoToolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C7505A727B6D34800BE3227 /* ChatInfoToolbar.swift */; };
5C764E89279CBCB3000C6508 /* ChatModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C764E88279CBCB3000C6508 /* ChatModel.swift */; }; 5C764E89279CBCB3000C6508 /* ChatModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C764E88279CBCB3000C6508 /* ChatModel.swift */; };
5C83A1AD2B5EF67D00AE0A4A /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C83A1A82B5EF67D00AE0A4A /* libgmp.a */; };
5C83A1AE2B5EF67D00AE0A4A /* libHSsimplex-chat-5.5.0.4-HTW6wkBBAjO2GDtnvnI9O3-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C83A1A92B5EF67D00AE0A4A /* libHSsimplex-chat-5.5.0.4-HTW6wkBBAjO2GDtnvnI9O3-ghc9.6.3.a */; };
5C83A1AF2B5EF67D00AE0A4A /* libHSsimplex-chat-5.5.0.4-HTW6wkBBAjO2GDtnvnI9O3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C83A1AA2B5EF67D00AE0A4A /* libHSsimplex-chat-5.5.0.4-HTW6wkBBAjO2GDtnvnI9O3.a */; };
5C83A1B02B5EF67D00AE0A4A /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C83A1AB2B5EF67D00AE0A4A /* libffi.a */; };
5C83A1B12B5EF67D00AE0A4A /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C83A1AC2B5EF67D00AE0A4A /* libgmpxx.a */; };
5C8F01CD27A6F0D8007D2C8D /* CodeScanner in Frameworks */ = {isa = PBXBuildFile; productRef = 5C8F01CC27A6F0D8007D2C8D /* CodeScanner */; }; 5C8F01CD27A6F0D8007D2C8D /* CodeScanner in Frameworks */ = {isa = PBXBuildFile; productRef = 5C8F01CC27A6F0D8007D2C8D /* CodeScanner */; };
5C93292F29239A170090FFF9 /* ProtocolServersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C93292E29239A170090FFF9 /* ProtocolServersView.swift */; }; 5C93292F29239A170090FFF9 /* ProtocolServersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C93292E29239A170090FFF9 /* ProtocolServersView.swift */; };
5C93293129239BED0090FFF9 /* ProtocolServerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C93293029239BED0090FFF9 /* ProtocolServerView.swift */; }; 5C93293129239BED0090FFF9 /* ProtocolServerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C93293029239BED0090FFF9 /* ProtocolServerView.swift */; };
@@ -95,6 +90,11 @@
5CB0BA90282713D900B3292C /* SimpleXInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB0BA8F282713D900B3292C /* SimpleXInfo.swift */; }; 5CB0BA90282713D900B3292C /* SimpleXInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB0BA8F282713D900B3292C /* SimpleXInfo.swift */; };
5CB0BA92282713FD00B3292C /* CreateProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB0BA91282713FD00B3292C /* CreateProfile.swift */; }; 5CB0BA92282713FD00B3292C /* CreateProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB0BA91282713FD00B3292C /* CreateProfile.swift */; };
5CB0BA9A2827FD8800B3292C /* HowItWorks.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB0BA992827FD8800B3292C /* HowItWorks.swift */; }; 5CB0BA9A2827FD8800B3292C /* HowItWorks.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB0BA992827FD8800B3292C /* HowItWorks.swift */; };
5CB1CE882B8259EB00963938 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CB1CE832B8259EB00963938 /* libgmpxx.a */; };
5CB1CE892B8259EB00963938 /* libHSsimplex-chat-5.5.3.0-1R6yZC1upSP6aXGrPWvhZE.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CB1CE842B8259EB00963938 /* libHSsimplex-chat-5.5.3.0-1R6yZC1upSP6aXGrPWvhZE.a */; };
5CB1CE8A2B8259EB00963938 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CB1CE852B8259EB00963938 /* libffi.a */; };
5CB1CE8B2B8259EB00963938 /* libHSsimplex-chat-5.5.3.0-1R6yZC1upSP6aXGrPWvhZE-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CB1CE862B8259EB00963938 /* libHSsimplex-chat-5.5.3.0-1R6yZC1upSP6aXGrPWvhZE-ghc9.6.3.a */; };
5CB1CE8C2B8259EB00963938 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CB1CE872B8259EB00963938 /* libgmp.a */; };
5CB2084F28DA4B4800D024EC /* RTCServers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB2084E28DA4B4800D024EC /* RTCServers.swift */; }; 5CB2084F28DA4B4800D024EC /* RTCServers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB2084E28DA4B4800D024EC /* RTCServers.swift */; };
5CB346E52868AA7F001FD2EF /* SuspendChat.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB346E42868AA7F001FD2EF /* SuspendChat.swift */; }; 5CB346E52868AA7F001FD2EF /* SuspendChat.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB346E42868AA7F001FD2EF /* SuspendChat.swift */; };
5CB346E72868D76D001FD2EF /* NotificationsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB346E62868D76D001FD2EF /* NotificationsView.swift */; }; 5CB346E72868D76D001FD2EF /* NotificationsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB346E62868D76D001FD2EF /* NotificationsView.swift */; };
@@ -325,11 +325,6 @@
5C7505A427B679EE00BE3227 /* NavLinkPlain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavLinkPlain.swift; sourceTree = "<group>"; }; 5C7505A427B679EE00BE3227 /* NavLinkPlain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavLinkPlain.swift; sourceTree = "<group>"; };
5C7505A727B6D34800BE3227 /* ChatInfoToolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatInfoToolbar.swift; sourceTree = "<group>"; }; 5C7505A727B6D34800BE3227 /* ChatInfoToolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatInfoToolbar.swift; sourceTree = "<group>"; };
5C764E88279CBCB3000C6508 /* ChatModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatModel.swift; sourceTree = "<group>"; }; 5C764E88279CBCB3000C6508 /* ChatModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatModel.swift; sourceTree = "<group>"; };
5C83A1A82B5EF67D00AE0A4A /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
5C83A1A92B5EF67D00AE0A4A /* libHSsimplex-chat-5.5.0.4-HTW6wkBBAjO2GDtnvnI9O3-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.5.0.4-HTW6wkBBAjO2GDtnvnI9O3-ghc9.6.3.a"; sourceTree = "<group>"; };
5C83A1AA2B5EF67D00AE0A4A /* libHSsimplex-chat-5.5.0.4-HTW6wkBBAjO2GDtnvnI9O3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.5.0.4-HTW6wkBBAjO2GDtnvnI9O3.a"; sourceTree = "<group>"; };
5C83A1AB2B5EF67D00AE0A4A /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; };
5C83A1AC2B5EF67D00AE0A4A /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; };
5C84FE9129A216C800D95B1A /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/Localizable.strings; sourceTree = "<group>"; }; 5C84FE9129A216C800D95B1A /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/Localizable.strings; sourceTree = "<group>"; };
5C84FE9329A2179C00D95B1A /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = "nl.lproj/SimpleX--iOS--InfoPlist.strings"; sourceTree = "<group>"; }; 5C84FE9329A2179C00D95B1A /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = "nl.lproj/SimpleX--iOS--InfoPlist.strings"; sourceTree = "<group>"; };
5C84FE9429A2179C00D95B1A /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/InfoPlist.strings; sourceTree = "<group>"; }; 5C84FE9429A2179C00D95B1A /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/InfoPlist.strings; sourceTree = "<group>"; };
@@ -377,6 +372,11 @@
5CB0BA8F282713D900B3292C /* SimpleXInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpleXInfo.swift; sourceTree = "<group>"; }; 5CB0BA8F282713D900B3292C /* SimpleXInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpleXInfo.swift; sourceTree = "<group>"; };
5CB0BA91282713FD00B3292C /* CreateProfile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateProfile.swift; sourceTree = "<group>"; }; 5CB0BA91282713FD00B3292C /* CreateProfile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateProfile.swift; sourceTree = "<group>"; };
5CB0BA992827FD8800B3292C /* HowItWorks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HowItWorks.swift; sourceTree = "<group>"; }; 5CB0BA992827FD8800B3292C /* HowItWorks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HowItWorks.swift; sourceTree = "<group>"; };
5CB1CE832B8259EB00963938 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; };
5CB1CE842B8259EB00963938 /* libHSsimplex-chat-5.5.3.0-1R6yZC1upSP6aXGrPWvhZE.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.5.3.0-1R6yZC1upSP6aXGrPWvhZE.a"; sourceTree = "<group>"; };
5CB1CE852B8259EB00963938 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; };
5CB1CE862B8259EB00963938 /* libHSsimplex-chat-5.5.3.0-1R6yZC1upSP6aXGrPWvhZE-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.5.3.0-1R6yZC1upSP6aXGrPWvhZE-ghc9.6.3.a"; sourceTree = "<group>"; };
5CB1CE872B8259EB00963938 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
5CB2084E28DA4B4800D024EC /* RTCServers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RTCServers.swift; sourceTree = "<group>"; }; 5CB2084E28DA4B4800D024EC /* RTCServers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RTCServers.swift; sourceTree = "<group>"; };
5CB2085428DE647400D024EC /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Localizable.strings; sourceTree = "<group>"; }; 5CB2085428DE647400D024EC /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Localizable.strings; sourceTree = "<group>"; };
5CB346E42868AA7F001FD2EF /* SuspendChat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuspendChat.swift; sourceTree = "<group>"; }; 5CB346E42868AA7F001FD2EF /* SuspendChat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuspendChat.swift; sourceTree = "<group>"; };
@@ -514,13 +514,13 @@
isa = PBXFrameworksBuildPhase; isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
5CB1CE882B8259EB00963938 /* libgmpxx.a in Frameworks */,
5CB1CE8C2B8259EB00963938 /* libgmp.a in Frameworks */,
5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */, 5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */,
5C83A1B02B5EF67D00AE0A4A /* libffi.a in Frameworks */,
5C83A1AD2B5EF67D00AE0A4A /* libgmp.a in Frameworks */,
5C83A1AE2B5EF67D00AE0A4A /* libHSsimplex-chat-5.5.0.4-HTW6wkBBAjO2GDtnvnI9O3-ghc9.6.3.a in Frameworks */,
5C83A1AF2B5EF67D00AE0A4A /* libHSsimplex-chat-5.5.0.4-HTW6wkBBAjO2GDtnvnI9O3.a in Frameworks */,
5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */, 5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */,
5C83A1B12B5EF67D00AE0A4A /* libgmpxx.a in Frameworks */, 5CB1CE8A2B8259EB00963938 /* libffi.a in Frameworks */,
5CB1CE892B8259EB00963938 /* libHSsimplex-chat-5.5.3.0-1R6yZC1upSP6aXGrPWvhZE.a in Frameworks */,
5CB1CE8B2B8259EB00963938 /* libHSsimplex-chat-5.5.3.0-1R6yZC1upSP6aXGrPWvhZE-ghc9.6.3.a in Frameworks */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@@ -582,11 +582,11 @@
5C764E5C279C70B7000C6508 /* Libraries */ = { 5C764E5C279C70B7000C6508 /* Libraries */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
5C83A1AB2B5EF67D00AE0A4A /* libffi.a */, 5CB1CE852B8259EB00963938 /* libffi.a */,
5C83A1A82B5EF67D00AE0A4A /* libgmp.a */, 5CB1CE872B8259EB00963938 /* libgmp.a */,
5C83A1AC2B5EF67D00AE0A4A /* libgmpxx.a */, 5CB1CE832B8259EB00963938 /* libgmpxx.a */,
5C83A1A92B5EF67D00AE0A4A /* libHSsimplex-chat-5.5.0.4-HTW6wkBBAjO2GDtnvnI9O3-ghc9.6.3.a */, 5CB1CE862B8259EB00963938 /* libHSsimplex-chat-5.5.3.0-1R6yZC1upSP6aXGrPWvhZE-ghc9.6.3.a */,
5C83A1AA2B5EF67D00AE0A4A /* libHSsimplex-chat-5.5.0.4-HTW6wkBBAjO2GDtnvnI9O3.a */, 5CB1CE842B8259EB00963938 /* libHSsimplex-chat-5.5.3.0-1R6yZC1upSP6aXGrPWvhZE.a */,
); );
path = Libraries; path = Libraries;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -1509,7 +1509,7 @@
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 194; CURRENT_PROJECT_VERSION = 199;
DEVELOPMENT_TEAM = 5NN7GUYB6T; DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
@@ -1531,7 +1531,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 5.5; MARKETING_VERSION = 5.5.4;
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app;
PRODUCT_NAME = SimpleX; PRODUCT_NAME = SimpleX;
SDKROOT = iphoneos; SDKROOT = iphoneos;
@@ -1552,7 +1552,7 @@
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 194; CURRENT_PROJECT_VERSION = 199;
DEVELOPMENT_TEAM = 5NN7GUYB6T; DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
@@ -1574,7 +1574,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 5.5; MARKETING_VERSION = 5.5.4;
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app;
PRODUCT_NAME = SimpleX; PRODUCT_NAME = SimpleX;
SDKROOT = iphoneos; SDKROOT = iphoneos;
@@ -1633,7 +1633,7 @@
CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements";
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 194; CURRENT_PROJECT_VERSION = 199;
DEVELOPMENT_TEAM = 5NN7GUYB6T; DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
@@ -1646,7 +1646,7 @@
"@executable_path/Frameworks", "@executable_path/Frameworks",
"@executable_path/../../Frameworks", "@executable_path/../../Frameworks",
); );
MARKETING_VERSION = 5.5; MARKETING_VERSION = 5.5.4;
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE"; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE";
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
@@ -1665,7 +1665,7 @@
CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements";
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 194; CURRENT_PROJECT_VERSION = 199;
DEVELOPMENT_TEAM = 5NN7GUYB6T; DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
@@ -1678,7 +1678,7 @@
"@executable_path/Frameworks", "@executable_path/Frameworks",
"@executable_path/../../Frameworks", "@executable_path/../../Frameworks",
); );
MARKETING_VERSION = 5.5; MARKETING_VERSION = 5.5.4;
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE"; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE";
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
@@ -1697,7 +1697,7 @@
APPLICATION_EXTENSION_API_ONLY = YES; APPLICATION_EXTENSION_API_ONLY = YES;
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 194; CURRENT_PROJECT_VERSION = 199;
DEFINES_MODULE = YES; DEFINES_MODULE = YES;
DEVELOPMENT_TEAM = 5NN7GUYB6T; DEVELOPMENT_TEAM = 5NN7GUYB6T;
DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_COMPATIBILITY_VERSION = 1;
@@ -1721,7 +1721,7 @@
"$(inherited)", "$(inherited)",
"$(PROJECT_DIR)/Libraries/sim", "$(PROJECT_DIR)/Libraries/sim",
); );
MARKETING_VERSION = 5.5; MARKETING_VERSION = 5.5.4;
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat;
PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)";
SDKROOT = iphoneos; SDKROOT = iphoneos;
@@ -1743,7 +1743,7 @@
APPLICATION_EXTENSION_API_ONLY = YES; APPLICATION_EXTENSION_API_ONLY = YES;
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 194; CURRENT_PROJECT_VERSION = 199;
DEFINES_MODULE = YES; DEFINES_MODULE = YES;
DEVELOPMENT_TEAM = 5NN7GUYB6T; DEVELOPMENT_TEAM = 5NN7GUYB6T;
DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_COMPATIBILITY_VERSION = 1;
@@ -1767,7 +1767,7 @@
"$(inherited)", "$(inherited)",
"$(PROJECT_DIR)/Libraries/sim", "$(PROJECT_DIR)/Libraries/sim",
); );
MARKETING_VERSION = 5.5; MARKETING_VERSION = 5.5.4;
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat;
PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)";
SDKROOT = iphoneos; SDKROOT = iphoneos;

View File

@@ -613,7 +613,7 @@ public enum ChatResponse: Decodable, Error {
case callEnded(user: UserRef, contact: Contact) case callEnded(user: UserRef, contact: Contact)
case callInvitations(callInvitations: [RcvCallInvitation]) case callInvitations(callInvitations: [RcvCallInvitation])
case ntfTokenStatus(status: NtfTknStatus) case ntfTokenStatus(status: NtfTknStatus)
case ntfToken(token: DeviceToken, status: NtfTknStatus, ntfMode: NotificationsMode) case ntfToken(token: DeviceToken, status: NtfTknStatus, ntfMode: NotificationsMode, ntfServer: String)
case ntfMessages(user_: User?, connEntity_: ConnectionEntity?, msgTs: Date?, ntfMessages: [NtfMsgInfo]) case ntfMessages(user_: User?, connEntity_: ConnectionEntity?, msgTs: Date?, ntfMessages: [NtfMsgInfo])
case ntfMessage(user: UserRef, connEntity: ConnectionEntity, ntfMessage: NtfMsgInfo) case ntfMessage(user: UserRef, connEntity: ConnectionEntity, ntfMessage: NtfMsgInfo)
case contactConnectionDeleted(user: UserRef, connection: PendingContactConnection) case contactConnectionDeleted(user: UserRef, connection: PendingContactConnection)
@@ -912,7 +912,7 @@ public enum ChatResponse: Decodable, Error {
case let .callEnded(u, contact): return withUser(u, "contact: \(contact.id)") case let .callEnded(u, contact): return withUser(u, "contact: \(contact.id)")
case let .callInvitations(invs): return String(describing: invs) case let .callInvitations(invs): return String(describing: invs)
case let .ntfTokenStatus(status): return String(describing: status) case let .ntfTokenStatus(status): return String(describing: status)
case let .ntfToken(token, status, ntfMode): return "token: \(token)\nstatus: \(status.rawValue)\nntfMode: \(ntfMode.rawValue)" case let .ntfToken(token, status, ntfMode, ntfServer): return "token: \(token)\nstatus: \(status.rawValue)\nntfMode: \(ntfMode.rawValue)\nntfServer: \(ntfServer)"
case let .ntfMessages(u, connEntity, msgTs, ntfMessages): return withUser(u, "connEntity: \(String(describing: connEntity))\nmsgTs: \(String(describing: msgTs))\nntfMessages: \(String(describing: ntfMessages))") case let .ntfMessages(u, connEntity, msgTs, ntfMessages): return withUser(u, "connEntity: \(String(describing: connEntity))\nmsgTs: \(String(describing: msgTs))\nntfMessages: \(String(describing: ntfMessages))")
case let .ntfMessage(u, connEntity, ntfMessage): return withUser(u, "connEntity: \(String(describing: connEntity))\nntfMessage: \(String(describing: ntfMessage))") case let .ntfMessage(u, connEntity, ntfMessage): return withUser(u, "connEntity: \(String(describing: connEntity))\nntfMessage: \(String(describing: ntfMessage))")
case let .contactConnectionDeleted(u, connection): return withUser(u, String(describing: connection)) case let .contactConnectionDeleted(u, connection): return withUser(u, String(describing: connection))

View File

@@ -103,11 +103,14 @@
</intent-filter> </intent-filter>
</activity-alias> </activity-alias>
<activity android:name=".views.call.CallActivity"
<activity android:name=".views.call.IncomingCallActivity"
android:showOnLockScreen="true" android:showOnLockScreen="true"
android:exported="false" android:exported="false"
android:launchMode="singleTask"/> android:launchMode="singleInstance"
android:supportsPictureInPicture="true"
android:autoRemoveFromRecents="true"
android:screenOrientation="portrait"
android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation"/>
<provider <provider
android:name="androidx.core.content.FileProvider" android:name="androidx.core.content.FileProvider"
@@ -133,6 +136,18 @@
android:stopWithTask="false"></service> android:stopWithTask="false"></service>
<!-- SimplexService restart on reboot --> <!-- SimplexService restart on reboot -->
<service
android:name=".CallService"
android:enabled="true"
android:exported="false"
android:stopWithTask="false"/>
<receiver
android:name=".CallService$CallActionReceiver"
android:enabled="true"
android:exported="false" />
<receiver <receiver
android:name=".SimplexService$StartReceiver" android:name=".SimplexService$StartReceiver"
android:enabled="true" android:enabled="true"

View File

@@ -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))
}
}
}

View File

@@ -1,14 +1,15 @@
package chat.simplex.app package chat.simplex.app
import android.app.Application import android.app.*
import android.content.Context import android.content.Context
import androidx.compose.ui.platform.ClipboardManager
import chat.simplex.common.platform.Log import chat.simplex.common.platform.Log
import android.app.UiModeManager import android.content.Intent
import android.os.* import android.os.*
import androidx.lifecycle.* import androidx.lifecycle.*
import androidx.work.* import androidx.work.*
import chat.simplex.app.model.NtfManager import chat.simplex.app.model.NtfManager
import chat.simplex.app.model.NtfManager.AcceptCallAction
import chat.simplex.app.views.call.CallActivity
import chat.simplex.common.helpers.APPLICATION_ID import chat.simplex.common.helpers.APPLICATION_ID
import chat.simplex.common.helpers.requiresIgnoringBattery import chat.simplex.common.helpers.requiresIgnoringBattery
import chat.simplex.common.model.* import chat.simplex.common.model.*
@@ -18,6 +19,7 @@ import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.CurrentColors import chat.simplex.common.ui.theme.CurrentColors
import chat.simplex.common.ui.theme.DefaultTheme import chat.simplex.common.ui.theme.DefaultTheme
import chat.simplex.common.views.call.RcvCallInvitation import chat.simplex.common.views.call.RcvCallInvitation
import chat.simplex.common.views.call.activeCallDestroyWebView
import chat.simplex.common.views.helpers.* import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.onboarding.OnboardingStage import chat.simplex.common.views.onboarding.OnboardingStage
import com.jakewharton.processphoenix.ProcessPhoenix import com.jakewharton.processphoenix.ProcessPhoenix
@@ -71,7 +73,7 @@ class SimplexApp: Application(), LifecycleEventObserver {
override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) { override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
Log.d(TAG, "onStateChanged: $event") Log.d(TAG, "onStateChanged: $event")
withBGApi { withLongRunningApi {
when (event) { when (event) {
Lifecycle.Event.ON_START -> { Lifecycle.Event.ON_START -> {
isAppOnForeground = true isAppOnForeground = true
@@ -184,6 +186,10 @@ class SimplexApp: Application(), LifecycleEventObserver {
SimplexService.safeStopService() SimplexService.safeStopService()
} }
override fun androidCallServiceSafeStop() {
CallService.stopService()
}
override fun androidNotificationsModeChanged(mode: NotificationsMode) { override fun androidNotificationsModeChanged(mode: NotificationsMode) {
if (mode.requiresIgnoringBattery && !SimplexService.isBackgroundAllowed()) { if (mode.requiresIgnoringBattery && !SimplexService.isBackgroundAllowed()) {
appPrefs.backgroundServiceNoticeShown.set(false) appPrefs.backgroundServiceNoticeShown.set(false)
@@ -254,6 +260,28 @@ class SimplexApp: Application(), LifecycleEventObserver {
uiModeManager.setApplicationNightMode(mode) uiModeManager.setApplicationNightMode(mode)
} }
override fun androidStartCallActivity(acceptCall: Boolean, remoteHostId: Long?, chatId: ChatId?) {
val context = mainActivity.get() ?: return
val intent = Intent(context, CallActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION)
if (acceptCall) {
intent.setAction(AcceptCallAction)
.putExtra("remoteHostId", remoteHostId)
.putExtra("chatId", chatId)
}
intent.flags += Intent.FLAG_ACTIVITY_BROUGHT_TO_FRONT
context.startActivity(intent)
}
override fun androidPictureInPictureAllowed(): Boolean {
val appOps = androidAppContext.getSystemService(Context.APP_OPS_SERVICE) as AppOpsManager
return appOps.checkOpNoThrow(AppOpsManager.OPSTR_PICTURE_IN_PICTURE, Process.myUid(), packageName) == AppOpsManager.MODE_ALLOWED
}
override fun androidCallEnded() {
activeCallDestroyWebView()
}
override suspend fun androidAskToAllowBackgroundCalls(): Boolean { override suspend fun androidAskToAllowBackgroundCalls(): Boolean {
if (SimplexService.isBackgroundRestricted()) { if (SimplexService.isBackgroundRestricted()) {
val userChoice: CompletableDeferred<Boolean> = CompletableDeferred() val userChoice: CompletableDeferred<Boolean> = CompletableDeferred()

View File

@@ -34,12 +34,13 @@ import kotlin.system.exitProcess
class SimplexService: Service() { class SimplexService: Service() {
private var wakeLock: PowerManager.WakeLock? = null private var wakeLock: PowerManager.WakeLock? = null
private var isStartingService = false private var isCheckingNewMessages = false
private var notificationManager: NotificationManager? = null private var notificationManager: NotificationManager? = null
private var serviceNotification: Notification? = null private var serviceNotification: Notification? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
Log.d(TAG, "onStartCommand startId: $startId") Log.d(TAG, "onStartCommand startId: $startId")
isServiceStarting = false
if (intent != null) { if (intent != null) {
val action = intent.action val action = intent.action
Log.d(TAG, "intent action $action") Log.d(TAG, "intent action $action")
@@ -71,6 +72,7 @@ class SimplexService: Service() {
stopForeground(true) stopForeground(true)
stopSelf() stopSelf()
} else { } else {
isServiceStarting = false
isServiceStarted = true isServiceStarted = true
// In case of self-destruct is enabled the initialization process will not start in SimplexApp, Let's start it here // In case of self-destruct is enabled the initialization process will not start in SimplexApp, Let's start it here
if (DatabaseUtils.ksSelfDestructPassword.get() != null && chatModel.chatDbStatus.value == null) { if (DatabaseUtils.ksSelfDestructPassword.get() != null && chatModel.chatDbStatus.value == null) {
@@ -89,6 +91,7 @@ class SimplexService: Service() {
} catch (e: Exception) { } catch (e: Exception) {
Log.d(TAG, "Exception while releasing wakelock: ${e.message}") Log.d(TAG, "Exception while releasing wakelock: ${e.message}")
} }
isServiceStarting = false
isServiceStarted = false isServiceStarted = false
stopAfterStart = false stopAfterStart = false
saveServiceState(this, ServiceState.STOPPED) saveServiceState(this, ServiceState.STOPPED)
@@ -101,10 +104,10 @@ class SimplexService: Service() {
private fun startService() { private fun startService() {
Log.d(TAG, "SimplexService startService") Log.d(TAG, "SimplexService startService")
if (wakeLock != null || isStartingService) return if (wakeLock != null || isCheckingNewMessages) return
val self = this val self = this
isStartingService = true isCheckingNewMessages = true
withLongRunningApi(slow = 30_000, deadlock = 60_000) { withLongRunningApi {
val chatController = ChatController val chatController = ChatController
waitDbMigrationEnds(chatController) waitDbMigrationEnds(chatController)
try { try {
@@ -123,7 +126,7 @@ class SimplexService: Service() {
} }
} }
} finally { } finally {
isStartingService = false isCheckingNewMessages = false
} }
} }
} }
@@ -262,6 +265,7 @@ class SimplexService: Service() {
private const val SHARED_PREFS_SERVICE_STATE = "SIMPLEX_SERVICE_STATE" private const val SHARED_PREFS_SERVICE_STATE = "SIMPLEX_SERVICE_STATE"
private const val WORK_NAME_ONCE = "ServiceStartWorkerOnce" private const val WORK_NAME_ONCE = "ServiceStartWorkerOnce"
var isServiceStarting = false
var isServiceStarted = false var isServiceStarted = false
private var stopAfterStart = false private var stopAfterStart = false
@@ -281,7 +285,7 @@ class SimplexService: Service() {
fun safeStopService() { fun safeStopService() {
if (isServiceStarted) { if (isServiceStarted) {
androidAppContext.stopService(Intent(androidAppContext, SimplexService::class.java)) androidAppContext.stopService(Intent(androidAppContext, SimplexService::class.java))
} else { } else if (isServiceStarting) {
stopAfterStart = true stopAfterStart = true
} }
} }
@@ -291,6 +295,7 @@ class SimplexService: Service() {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
Intent(androidAppContext, SimplexService::class.java).also { Intent(androidAppContext, SimplexService::class.java).also {
it.action = action.name it.action = action.name
isServiceStarting = true
ContextCompat.startForegroundService(androidAppContext, it) ContextCompat.startForegroundService(androidAppContext, it)
} }
} }

View File

@@ -13,7 +13,7 @@ import androidx.compose.ui.graphics.asAndroidBitmap
import androidx.core.app.* import androidx.core.app.*
import chat.simplex.app.* import chat.simplex.app.*
import chat.simplex.app.TAG import chat.simplex.app.TAG
import chat.simplex.app.views.call.IncomingCallActivity import chat.simplex.app.views.call.CallActivity
import chat.simplex.app.views.call.getKeyguardManager import chat.simplex.app.views.call.getKeyguardManager
import chat.simplex.common.views.helpers.* import chat.simplex.common.views.helpers.*
import chat.simplex.common.model.* import chat.simplex.common.model.*
@@ -33,6 +33,7 @@ object NtfManager {
const val CallChannel: String = "chat.simplex.app.CALL_NOTIFICATION_2" const val CallChannel: String = "chat.simplex.app.CALL_NOTIFICATION_2"
const val AcceptCallAction: String = "chat.simplex.app.ACCEPT_CALL" const val AcceptCallAction: String = "chat.simplex.app.ACCEPT_CALL"
const val RejectCallAction: String = "chat.simplex.app.REJECT_CALL" const val RejectCallAction: String = "chat.simplex.app.REJECT_CALL"
const val EndCallAction: String = "chat.simplex.app.END_CALL"
const val CallNotificationId: Int = -1 const val CallNotificationId: Int = -1
private const val UserIdKey: String = "userId" private const val UserIdKey: String = "userId"
private const val ChatIdKey: String = "chatId" private const val ChatIdKey: String = "chatId"
@@ -157,7 +158,7 @@ object NtfManager {
val screenOff = displayManager.displays.all { it.state != Display.STATE_ON } val screenOff = displayManager.displays.all { it.state != Display.STATE_ON }
var ntfBuilder = var ntfBuilder =
if ((keyguardManager.isKeyguardLocked || screenOff) && appPreferences.callOnLockScreen.get() != CallOnLockScreen.DISABLE) { if ((keyguardManager.isKeyguardLocked || screenOff) && appPreferences.callOnLockScreen.get() != CallOnLockScreen.DISABLE) {
val fullScreenIntent = Intent(context, IncomingCallActivity::class.java) val fullScreenIntent = Intent(context, CallActivity::class.java)
val fullScreenPendingIntent = PendingIntent.getActivity(context, 0, fullScreenIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) val fullScreenPendingIntent = PendingIntent.getActivity(context, 0, fullScreenIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
NotificationCompat.Builder(context, CallChannel) NotificationCompat.Builder(context, CallChannel)
.setFullScreenIntent(fullScreenPendingIntent, true) .setFullScreenIntent(fullScreenPendingIntent, true)

View File

@@ -1,17 +1,18 @@
package chat.simplex.app.views.call package chat.simplex.app.views.call
import android.app.Activity import android.app.*
import android.app.KeyguardManager import android.content.*
import android.content.Context import android.content.res.Configuration
import android.content.Intent import android.graphics.Rect
import android.os.Build import android.os.*
import android.os.Bundle import android.util.Rational
import chat.simplex.common.platform.Log import android.view.*
import android.view.WindowManager
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.trackPipAnimationHintView
import androidx.compose.desktop.ui.tooling.preview.Preview import androidx.compose.desktop.ui.tooling.preview.Preview
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.* import androidx.compose.material.*
@@ -22,33 +23,115 @@ import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.lifecycle.Lifecycle
import chat.simplex.app.* import chat.simplex.app.*
import chat.simplex.app.R import chat.simplex.app.R
import chat.simplex.app.TAG
import chat.simplex.app.model.NtfManager
import chat.simplex.app.model.NtfManager.AcceptCallAction
import chat.simplex.common.model.* import chat.simplex.common.model.*
import chat.simplex.app.model.NtfManager.OpenChatAction import chat.simplex.common.platform.*
import chat.simplex.common.platform.ntfManager
import chat.simplex.common.ui.theme.* import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.call.* import chat.simplex.common.views.call.*
import chat.simplex.common.views.helpers.* import chat.simplex.common.views.helpers.*
import chat.simplex.res.MR import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.stringResource import dev.icerock.moko.resources.compose.stringResource
import kotlinx.coroutines.launch
import kotlinx.datetime.Clock import kotlinx.datetime.Clock
import java.lang.ref.WeakReference
import chat.simplex.common.platform.chatModel as m
class IncomingCallActivity: ComponentActivity() { class CallActivity: ComponentActivity(), ServiceConnection {
var boundService: CallService? = null
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContent { IncomingCallActivityView(ChatModel) } callActivity = WeakReference(this)
unlockForIncomingCall() when (intent?.action) {
AcceptCallAction -> {
val remoteHostId = intent.getLongExtra("remoteHostId", -1).takeIf { it != -1L }
val chatId = intent.getStringExtra("chatId")
val invitation = (m.callInvitations.values + m.activeCallInvitation.value).lastOrNull {
it?.remoteHostId == remoteHostId && it?.contact?.id == chatId
}
if (invitation != null) {
m.callManager.acceptIncomingCall(invitation = invitation)
}
}
}
setContent { CallActivityView() }
if (isOnLockScreenNow()) {
unlockForIncomingCall()
}
} }
override fun onDestroy() { override fun onDestroy() {
super.onDestroy() super.onDestroy()
lockAfterIncomingCall() if (isOnLockScreenNow()) {
lockAfterIncomingCall()
}
try {
unbindService(this)
} catch (e: Exception) {
Log.i(TAG, "Unable to unbind service: " + e.stackTraceToString())
}
}
private fun isOnLockScreenNow() = getKeyguardManager(this).isKeyguardLocked
fun setPipParams(video: Boolean, sourceRectHint: Rect? = null, viewRatio: Rational? = null) {
// By manually specifying source rect we exclude empty background while toggling PiP
val builder = PictureInPictureParams.Builder()
.setAspectRatio(viewRatio)
.setSourceRectHint(sourceRectHint)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
builder.setAutoEnterEnabled(video)
}
setPictureInPictureParams(builder.build())
}
override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean, newConfig: Configuration) {
super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig)
m.activeCallViewIsCollapsed.value = isInPictureInPictureMode
val layoutType = if (!isInPictureInPictureMode) {
LayoutType.Default
} else {
LayoutType.RemoteVideo
}
m.callCommand.add(WCallCommand.Layout(layoutType))
}
override fun onBackPressed() {
if (isOnLockScreenNow()) {
super.onBackPressed()
} else {
m.activeCallViewIsCollapsed.value = true
}
}
override fun onPictureInPictureRequested(): Boolean {
Log.d(TAG, "Requested picture-in-picture from the system")
return super.onPictureInPictureRequested()
}
override fun onUserLeaveHint() {
// On Android 12+ PiP is enabled automatically when a user hides the app
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.R && callSupportsVideo() && platform.androidPictureInPictureAllowed()) {
enterPictureInPictureMode()
}
}
override fun onResume() {
super.onResume()
m.activeCallViewIsCollapsed.value = false
} }
private fun unlockForIncomingCall() { private fun unlockForIncomingCall() {
@@ -72,6 +155,23 @@ class IncomingCallActivity: ComponentActivity() {
} }
} }
fun startServiceAndBind() {
/**
* On Android 12 there is a bug that prevents starting activity after pressing back button
* (the error says that it denies to start activity in background).
* Workaround is to bind to a service
* */
bindService(CallService.startService(), this, 0)
}
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
boundService = (service as CallService.CallServiceBinder).getService()
}
override fun onServiceDisconnected(name: ComponentName?) {
boundService = null
}
companion object { companion object {
const val activityFlags = WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON or WindowManager.LayoutParams.FLAG_ALLOW_LOCK_WHILE_SCREEN_ON const val activityFlags = WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON or WindowManager.LayoutParams.FLAG_ALLOW_LOCK_WHILE_SCREEN_ON
} }
@@ -80,38 +180,96 @@ class IncomingCallActivity: ComponentActivity() {
fun getKeyguardManager(context: Context): KeyguardManager = fun getKeyguardManager(context: Context): KeyguardManager =
context.getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager context.getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager
private fun callSupportsVideo() = m.activeCall.value?.supportsVideo() == true || m.activeCallInvitation.value?.callType?.media == CallMediaType.Video
@Composable @Composable
fun IncomingCallActivityView(m: ChatModel) { fun CallActivityView() {
val switchingCall = m.switchingCall.value val switchingCall = m.switchingCall.value
val invitation = m.activeCallInvitation.value val invitation = m.activeCallInvitation.value
val call = m.activeCall.value val call = remember { m.activeCall }.value
val showCallView = m.showCallView.value val showCallView = m.showCallView.value
val activity = LocalContext.current as Activity val activity = LocalContext.current as CallActivity
LaunchedEffect(invitation, call, switchingCall, showCallView) { LaunchedEffect(Unit) {
if (!switchingCall && invitation == null && (!showCallView || call == null)) { snapshotFlow { m.activeCallViewIsCollapsed.value }
Log.d(TAG, "IncomingCallActivityView: finishing activity") .collect { collapsed ->
activity.finish() when {
} collapsed -> {
if (!platform.androidPictureInPictureAllowed() || !callSupportsVideo()) {
activity.moveTaskToBack(true)
activity.startActivity(Intent(activity, MainActivity::class.java))
} else if (!activity.isInPictureInPictureMode && activity.lifecycle.currentState == Lifecycle.State.RESUMED) {
// User pressed back button, show MainActivity
activity.startActivity(Intent(activity, MainActivity::class.java))
activity.enterPictureInPictureMode()
}
}
callSupportsVideo() && !platform.androidPictureInPictureAllowed() -> {
// PiP disabled by user
platform.androidStartCallActivity(false)
}
activity.isInPictureInPictureMode -> {
platform.androidStartCallActivity(false)
}
}
}
} }
SimpleXTheme { SimpleXTheme {
Surface( var prevCall by remember { mutableStateOf(call) }
Modifier KeyChangeEffect(m.activeCall.value) {
.fillMaxSize(), if (m.activeCall.value != null) {
color = MaterialTheme.colors.background, prevCall = m.activeCall.value
contentColor = LocalContentColor.current activity.boundService?.updateNotification()
) { }
if (showCallView) { }
Box { Box(Modifier.background(Color.Black)) {
ActiveCallView() if (call != null) {
if (invitation != null) IncomingCallAlertView(invitation, m) val view = LocalView.current
ActiveCallView()
if (callSupportsVideo()) {
val scope = rememberCoroutineScope()
LaunchedEffect(Unit) {
scope.launch {
activity.setPipParams(callSupportsVideo(), viewRatio = Rational(view.width, view.height))
activity.trackPipAnimationHintView(view)
}
}
}
} else if (prevCall != null) {
prevCall?.let { ActiveCallOverlayDisabled(it) }
}
if (invitation != null) {
if (call == null) {
Surface(
Modifier
.fillMaxSize(),
color = MaterialTheme.colors.background,
contentColor = LocalContentColor.current
) {
IncomingCallLockScreenAlert(invitation, m)
}
} else {
IncomingCallAlertView(invitation, m)
} }
} else if (invitation != null) {
IncomingCallLockScreenAlert(invitation, m)
} }
} }
} }
LaunchedEffect(call == null) {
if (call != null) {
activity.startServiceAndBind()
}
}
LaunchedEffect(invitation, call, switchingCall, showCallView) {
if (!switchingCall && invitation == null && (!showCallView || call == null)) {
Log.d(TAG, "CallActivityView: finishing activity")
activity.finish()
}
}
} }
/**
* Related to lockscreen
* */
@Composable @Composable
fun IncomingCallLockScreenAlert(invitation: RcvCallInvitation, chatModel: ChatModel) { fun IncomingCallLockScreenAlert(invitation: RcvCallInvitation, chatModel: ChatModel) {
val cm = chatModel.callManager val cm = chatModel.callManager
@@ -135,7 +293,7 @@ fun IncomingCallLockScreenAlert(invitation: RcvCallInvitation, chatModel: ChatMo
acceptCall = { cm.acceptIncomingCall(invitation = invitation) }, acceptCall = { cm.acceptIncomingCall(invitation = invitation) },
openApp = { openApp = {
val intent = Intent(context, MainActivity::class.java) val intent = Intent(context, MainActivity::class.java)
.setAction(OpenChatAction) .setAction(NtfManager.OpenChatAction)
.putExtra("userId", invitation.user.userId) .putExtra("userId", invitation.user.userId)
.putExtra("chatId", invitation.contact.id) .putExtra("chatId", invitation.contact.id)
context.startActivity(intent) context.startActivity(intent)

View File

@@ -4,6 +4,7 @@ import android.annotation.SuppressLint
import android.content.Context import android.content.Context
import android.net.LocalServerSocket import android.net.LocalServerSocket
import android.util.Log import android.util.Log
import androidx.activity.ComponentActivity
import androidx.fragment.app.FragmentActivity import androidx.fragment.app.FragmentActivity
import chat.simplex.common.* import chat.simplex.common.*
import chat.simplex.common.platform.* import chat.simplex.common.platform.*
@@ -25,7 +26,8 @@ val defaultLocale: Locale = Locale.getDefault()
@SuppressLint("StaticFieldLeak") @SuppressLint("StaticFieldLeak")
lateinit var androidAppContext: Context lateinit var androidAppContext: Context
lateinit var mainActivity: WeakReference<FragmentActivity> var mainActivity: WeakReference<FragmentActivity> = WeakReference(null)
var callActivity: WeakReference<ComponentActivity> = WeakReference(null)
fun initHaskell() { fun initHaskell() {
val socketName = "chat.simplex.app.local.socket.address.listen.native.cmd2" + Random.nextLong(100000) val socketName = "chat.simplex.app.local.socket.address.listen.native.cmd2" + Random.nextLong(100000)

View File

@@ -61,6 +61,16 @@ actual fun cropToSquare(image: ImageBitmap): ImageBitmap {
return Bitmap.createBitmap(image.asAndroidBitmap(), xOffset, yOffset, side, side).asImageBitmap() return Bitmap.createBitmap(image.asAndroidBitmap(), xOffset, yOffset, side, side).asImageBitmap()
} }
fun Bitmap.clipToCircle(): Bitmap {
val circle = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
val path = android.graphics.Path()
path.addCircle(width / 2f, height / 2f, min(width, height) / 2f, android.graphics.Path.Direction.CCW)
val canvas = android.graphics.Canvas(circle)
canvas.clipPath(path)
canvas.drawBitmap(this, 0f, 0f, null)
return circle
}
actual fun compressImageStr(bitmap: ImageBitmap): String { actual fun compressImageStr(bitmap: ImageBitmap): String {
val usePng = bitmap.hasAlpha() val usePng = bitmap.hasAlpha()
val ext = if (usePng) "png" else "jpg" val ext = if (usePng) "png" else "jpg"

View File

@@ -14,17 +14,27 @@ import chat.simplex.common.views.helpers.*
import java.io.BufferedOutputStream import java.io.BufferedOutputStream
import java.io.File import java.io.File
import chat.simplex.res.MR import chat.simplex.res.MR
import kotlin.math.min
actual fun ClipboardManager.shareText(text: String) { actual fun ClipboardManager.shareText(text: String) {
val sendIntent: Intent = Intent().apply { var text = text
action = Intent.ACTION_SEND for (i in 10 downTo 1) {
putExtra(Intent.EXTRA_TEXT, text) try {
type = "text/plain" val sendIntent: Intent = Intent().apply {
flags = FLAG_ACTIVITY_NEW_TASK action = Intent.ACTION_SEND
putExtra(Intent.EXTRA_TEXT, text)
type = "text/plain"
flags = FLAG_ACTIVITY_NEW_TASK
}
val shareIntent = Intent.createChooser(sendIntent, null)
shareIntent.addFlags(FLAG_ACTIVITY_NEW_TASK)
androidAppContext.startActivity(shareIntent)
break
} catch (e: Exception) {
Log.e(TAG, "Failed to share text: ${e.stackTraceToString()}")
text = text.substring(0, min(i * 1000, text.length))
}
} }
val shareIntent = Intent.createChooser(sendIntent, null)
shareIntent.addFlags(FLAG_ACTIVITY_NEW_TASK)
androidAppContext.startActivity(shareIntent)
} }
actual fun shareFile(text: String, fileSource: CryptoFile) { actual fun shareFile(text: String, fileSource: CryptoFile) {

View File

@@ -12,6 +12,8 @@ import androidx.activity.compose.setContent
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.LocalView
import chat.simplex.common.AppScreen import chat.simplex.common.AppScreen
import chat.simplex.common.model.clear
import chat.simplex.common.ui.theme.SimpleXTheme
import chat.simplex.common.views.helpers.* import chat.simplex.common.views.helpers.*
import androidx.compose.ui.platform.LocalContext as LocalContext1 import androidx.compose.ui.platform.LocalContext as LocalContext1
import chat.simplex.res.MR import chat.simplex.res.MR
@@ -112,7 +114,8 @@ actual class GlobalExceptionsHandler: Thread.UncaughtExceptionHandler {
Handler(Looper.getMainLooper()).post { Handler(Looper.getMainLooper()).post {
AlertManager.shared.showAlertMsg( AlertManager.shared.showAlertMsg(
title = generalGetString(MR.strings.app_was_crashed), title = generalGetString(MR.strings.app_was_crashed),
text = e.stackTraceToString() text = e.stackTraceToString(),
shareText = true
) )
} }
} }

View File

@@ -28,6 +28,7 @@ import androidx.compose.ui.platform.LocalLifecycleOwner
import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
@@ -50,20 +51,30 @@ import kotlinx.datetime.Clock
import kotlinx.serialization.decodeFromString import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
// Should be destroy()'ed and set as null when call is ended. Otherwise, it will be a leak
@SuppressLint("StaticFieldLeak")
private var staticWebView: WebView? = null
// WebView methods must be called on Main thread
fun activeCallDestroyWebView() = withApi {
// Stop it when call ended
platform.androidCallServiceSafeStop()
staticWebView?.destroy()
staticWebView = null
Log.d(TAG, "CallView: webview was destroyed")
}
@SuppressLint("SourceLockedOrientationActivity") @SuppressLint("SourceLockedOrientationActivity")
@Composable @Composable
actual fun ActiveCallView() { actual fun ActiveCallView() {
val chatModel = ChatModel
BackHandler(onBack = {
val call = chatModel.activeCall.value
if (call != null) withBGApi { chatModel.callManager.endCall(call) }
})
val audioViaBluetooth = rememberSaveable { mutableStateOf(false) } val audioViaBluetooth = rememberSaveable { mutableStateOf(false) }
val ntfModeService = remember { chatModel.controller.appPrefs.notificationsMode.get() == NotificationsMode.SERVICE } val proximityLock = remember {
LaunchedEffect(Unit) { val pm = (androidAppContext.getSystemService(Context.POWER_SERVICE) as PowerManager)
// Start service when call happening since it's not already started. if (pm.isWakeLockLevelSupported(PROXIMITY_SCREEN_OFF_WAKE_LOCK)) {
// It's needed to prevent Android from shutting down a microphone after a minute or so when screen is off pm.newWakeLock(PROXIMITY_SCREEN_OFF_WAKE_LOCK, androidAppContext.packageName + ":proximityLock")
if (!ntfModeService) platform.androidServiceStart() } else {
null
}
} }
DisposableEffect(Unit) { DisposableEffect(Unit) {
val am = androidAppContext.getSystemService(Context.AUDIO_SERVICE) as AudioManager val am = androidAppContext.getSystemService(Context.AUDIO_SERVICE) as AudioManager
@@ -93,22 +104,24 @@ actual fun ActiveCallView() {
} }
} }
am.registerAudioDeviceCallback(audioCallback, null) am.registerAudioDeviceCallback(audioCallback, null)
val pm = (androidAppContext.getSystemService(Context.POWER_SERVICE) as PowerManager)
val proximityLock = if (pm.isWakeLockLevelSupported(PROXIMITY_SCREEN_OFF_WAKE_LOCK)) {
pm.newWakeLock(PROXIMITY_SCREEN_OFF_WAKE_LOCK, androidAppContext.packageName + ":proximityLock")
} else {
null
}
proximityLock?.acquire()
onDispose { onDispose {
// Stop it when call ended
if (!ntfModeService) platform.androidServiceSafeStop()
dropAudioManagerOverrides() dropAudioManagerOverrides()
am.unregisterAudioDeviceCallback(audioCallback) am.unregisterAudioDeviceCallback(audioCallback)
proximityLock?.release() if (proximityLock?.isHeld == true) {
proximityLock.release()
}
}
}
LaunchedEffect(chatModel.activeCallViewIsCollapsed.value) {
if (chatModel.activeCallViewIsCollapsed.value) {
if (proximityLock?.isHeld == true) proximityLock.release()
} else {
delay(1000)
if (proximityLock?.isHeld == false) proximityLock.acquire()
} }
} }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val call = chatModel.activeCall.value
Box(Modifier.fillMaxSize()) { Box(Modifier.fillMaxSize()) {
WebRTCView(chatModel.callCommand) { apiMsg -> WebRTCView(chatModel.callCommand) { apiMsg ->
Log.d(TAG, "received from WebRTCView: $apiMsg") Log.d(TAG, "received from WebRTCView: $apiMsg")
@@ -120,15 +133,15 @@ actual fun ActiveCallView() {
is WCallResponse.Capabilities -> withBGApi { is WCallResponse.Capabilities -> withBGApi {
val callType = CallType(call.localMedia, r.capabilities) val callType = CallType(call.localMedia, r.capabilities)
chatModel.controller.apiSendCallInvitation(callRh, call.contact, callType) chatModel.controller.apiSendCallInvitation(callRh, call.contact, callType)
chatModel.activeCall.value = call.copy(callState = CallState.InvitationSent, localCapabilities = r.capabilities) updateActiveCall(call) { it.copy(callState = CallState.InvitationSent, localCapabilities = r.capabilities) }
} }
is WCallResponse.Offer -> withBGApi { is WCallResponse.Offer -> withBGApi {
chatModel.controller.apiSendCallOffer(callRh, call.contact, r.offer, r.iceCandidates, call.localMedia, r.capabilities) chatModel.controller.apiSendCallOffer(callRh, call.contact, r.offer, r.iceCandidates, call.localMedia, r.capabilities)
chatModel.activeCall.value = call.copy(callState = CallState.OfferSent, localCapabilities = r.capabilities) updateActiveCall(call) { it.copy(callState = CallState.OfferSent, localCapabilities = r.capabilities) }
} }
is WCallResponse.Answer -> withBGApi { is WCallResponse.Answer -> withBGApi {
chatModel.controller.apiSendCallAnswer(callRh, call.contact, r.answer, r.iceCandidates) chatModel.controller.apiSendCallAnswer(callRh, call.contact, r.answer, r.iceCandidates)
chatModel.activeCall.value = call.copy(callState = CallState.Negotiated) updateActiveCall(call) { it.copy(callState = CallState.Negotiated) }
} }
is WCallResponse.Ice -> withBGApi { is WCallResponse.Ice -> withBGApi {
chatModel.controller.apiSendCallExtraInfo(callRh, call.contact, r.iceCandidates) chatModel.controller.apiSendCallExtraInfo(callRh, call.contact, r.iceCandidates)
@@ -137,7 +150,7 @@ actual fun ActiveCallView() {
try { try {
val callStatus = json.decodeFromString<WebRTCCallStatus>("\"${r.state.connectionState}\"") val callStatus = json.decodeFromString<WebRTCCallStatus>("\"${r.state.connectionState}\"")
if (callStatus == WebRTCCallStatus.Connected) { if (callStatus == WebRTCCallStatus.Connected) {
chatModel.activeCall.value = call.copy(callState = CallState.Connected, connectedAt = Clock.System.now()) updateActiveCall(call) { it.copy(callState = CallState.Connected, connectedAt = Clock.System.now()) }
setCallSound(call.soundSpeaker, audioViaBluetooth) setCallSound(call.soundSpeaker, audioViaBluetooth)
} }
withBGApi { chatModel.controller.apiCallStatus(callRh, call.contact, callStatus) } withBGApi { chatModel.controller.apiCallStatus(callRh, call.contact, callStatus) }
@@ -145,7 +158,7 @@ actual fun ActiveCallView() {
Log.d(TAG,"call status ${r.state.connectionState} not used") Log.d(TAG,"call status ${r.state.connectionState} not used")
} }
is WCallResponse.Connected -> { is WCallResponse.Connected -> {
chatModel.activeCall.value = call.copy(callState = CallState.Connected, connectionInfo = r.connectionInfo) updateActiveCall(call) { it.copy(callState = CallState.Connected, connectionInfo = r.connectionInfo) }
scope.launch { scope.launch {
setCallSound(call.soundSpeaker, audioViaBluetooth) setCallSound(call.soundSpeaker, audioViaBluetooth)
} }
@@ -154,27 +167,29 @@ actual fun ActiveCallView() {
withBGApi { chatModel.callManager.endCall(call) } withBGApi { chatModel.callManager.endCall(call) }
} }
is WCallResponse.Ended -> { is WCallResponse.Ended -> {
chatModel.activeCall.value = call.copy(callState = CallState.Ended) updateActiveCall(call) { it.copy(callState = CallState.Ended) }
withBGApi { chatModel.callManager.endCall(call) } withBGApi { chatModel.callManager.endCall(call) }
chatModel.showCallView.value = false
} }
is WCallResponse.Ok -> when (val cmd = apiMsg.command) { is WCallResponse.Ok -> when (val cmd = apiMsg.command) {
is WCallCommand.Answer -> is WCallCommand.Answer ->
chatModel.activeCall.value = call.copy(callState = CallState.Negotiated) updateActiveCall(call) { it.copy(callState = CallState.Negotiated) }
is WCallCommand.Media -> { is WCallCommand.Media -> {
when (cmd.media) { updateActiveCall(call) {
CallMediaType.Video -> chatModel.activeCall.value = call.copy(videoEnabled = cmd.enable) when (cmd.media) {
CallMediaType.Audio -> chatModel.activeCall.value = call.copy(audioEnabled = cmd.enable) CallMediaType.Video -> it.copy(videoEnabled = cmd.enable)
CallMediaType.Audio -> it.copy(audioEnabled = cmd.enable)
}
} }
} }
is WCallCommand.Camera -> { is WCallCommand.Camera -> {
chatModel.activeCall.value = call.copy(localCamera = cmd.camera) updateActiveCall(call) { it.copy(localCamera = cmd.camera) }
if (!call.audioEnabled) { if (!call.audioEnabled) {
chatModel.callCommand.add(WCallCommand.Media(CallMediaType.Audio, enable = false)) chatModel.callCommand.add(WCallCommand.Media(CallMediaType.Audio, enable = false))
} }
} }
is WCallCommand.End -> is WCallCommand.End -> {
chatModel.showCallView.value = false withBGApi { chatModel.callManager.endCall(call) }
}
else -> {} else -> {}
} }
is WCallResponse.Error -> { is WCallResponse.Error -> {
@@ -183,8 +198,16 @@ actual fun ActiveCallView() {
} }
} }
} }
val call = chatModel.activeCall.value val showOverlay = when {
if (call != null) ActiveCallOverlay(call, chatModel, audioViaBluetooth) call == null -> false
!platform.androidPictureInPictureAllowed() -> true
!call.supportsVideo() -> true
!chatModel.activeCallViewIsCollapsed.value -> true
else -> false
}
if (call != null && showOverlay) {
ActiveCallOverlay(call, chatModel, audioViaBluetooth)
}
} }
val context = LocalContext.current val context = LocalContext.current
@@ -229,6 +252,20 @@ private fun ActiveCallOverlay(call: Call, chatModel: ChatModel, audioViaBluetoot
) )
} }
@Composable
fun ActiveCallOverlayDisabled(call: Call) {
ActiveCallOverlayLayout(
call = call,
speakerCanBeEnabled = false,
enabled = false,
dismiss = {},
toggleAudio = {},
toggleVideo = {},
toggleSound = {},
flipCamera = {}
)
}
private fun setCallSound(speaker: Boolean, audioViaBluetooth: MutableState<Boolean>) { private fun setCallSound(speaker: Boolean, audioViaBluetooth: MutableState<Boolean>) {
val am = androidAppContext.getSystemService(Context.AUDIO_SERVICE) as AudioManager val am = androidAppContext.getSystemService(Context.AUDIO_SERVICE) as AudioManager
Log.d(TAG, "setCallSound: set audio mode, speaker enabled: $speaker") Log.d(TAG, "setCallSound: set audio mode, speaker enabled: $speaker")
@@ -271,59 +308,69 @@ private fun dropAudioManagerOverrides() {
private fun ActiveCallOverlayLayout( private fun ActiveCallOverlayLayout(
call: Call, call: Call,
speakerCanBeEnabled: Boolean, speakerCanBeEnabled: Boolean,
enabled: Boolean = true,
dismiss: () -> Unit, dismiss: () -> Unit,
toggleAudio: () -> Unit, toggleAudio: () -> Unit,
toggleVideo: () -> Unit, toggleVideo: () -> Unit,
toggleSound: () -> Unit, toggleSound: () -> Unit,
flipCamera: () -> Unit flipCamera: () -> Unit
) { ) {
Column(Modifier.padding(DEFAULT_PADDING)) { Column {
when (call.peerMedia ?: call.localMedia) { val media = call.peerMedia ?: call.localMedia
CallMediaType.Video -> { CloseSheetBar({ chatModel.activeCallViewIsCollapsed.value = true }, true, tintColor = Color(0xFFFFFFD8)) {
CallInfoView(call, alignment = Alignment.Start) if (media == CallMediaType.Video) {
Box(Modifier.fillMaxWidth().fillMaxHeight().weight(1f), contentAlignment = Alignment.BottomCenter) { Text(call.contact.chatViewName, Modifier.fillMaxWidth().padding(end = DEFAULT_PADDING), color = Color(0xFFFFFFD8), style = MaterialTheme.typography.h2, overflow = TextOverflow.Ellipsis, maxLines = 1)
DisabledBackgroundCallsButton()
}
Row(Modifier.fillMaxWidth().padding(horizontal = 6.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
ToggleAudioButton(call, toggleAudio)
Spacer(Modifier.size(40.dp))
IconButton(onClick = dismiss) {
Icon(painterResource(MR.images.ic_call_end_filled), stringResource(MR.strings.icon_descr_hang_up), tint = Color.Red, modifier = Modifier.size(64.dp))
}
if (call.videoEnabled) {
ControlButton(call, painterResource(MR.images.ic_flip_camera_android_filled), MR.strings.icon_descr_flip_camera, flipCamera)
ControlButton(call, painterResource(MR.images.ic_videocam_filled), MR.strings.icon_descr_video_off, toggleVideo)
} else {
Spacer(Modifier.size(48.dp))
ControlButton(call, painterResource(MR.images.ic_videocam_off), MR.strings.icon_descr_video_on, toggleVideo)
}
}
} }
CallMediaType.Audio -> { }
Spacer(Modifier.fillMaxHeight().weight(1f)) Column(Modifier.padding(horizontal = DEFAULT_PADDING)) {
Column( when (media) {
Modifier.fillMaxWidth(), CallMediaType.Video -> {
horizontalAlignment = Alignment.CenterHorizontally, VideoCallInfoView(call)
verticalArrangement = Arrangement.Center Box(Modifier.fillMaxWidth().fillMaxHeight().weight(1f), contentAlignment = Alignment.BottomCenter) {
) { DisabledBackgroundCallsButton()
ProfileImage(size = 192.dp, image = call.contact.profile.image) }
CallInfoView(call, alignment = Alignment.CenterHorizontally) Row(Modifier.fillMaxWidth().padding(horizontal = 6.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
} ToggleAudioButton(call, enabled, toggleAudio)
Box(Modifier.fillMaxWidth().fillMaxHeight().weight(1f), contentAlignment = Alignment.BottomCenter) { Spacer(Modifier.size(40.dp))
DisabledBackgroundCallsButton() IconButton(onClick = dismiss, enabled = enabled) {
} Icon(painterResource(MR.images.ic_call_end_filled), stringResource(MR.strings.icon_descr_hang_up), tint = if (enabled) Color.Red else MaterialTheme.colors.secondary, modifier = Modifier.size(64.dp))
Box(Modifier.fillMaxWidth().padding(bottom = DEFAULT_BOTTOM_PADDING), contentAlignment = Alignment.CenterStart) { }
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { if (call.videoEnabled) {
IconButton(onClick = dismiss) { ControlButton(call, painterResource(MR.images.ic_flip_camera_android_filled), MR.strings.icon_descr_flip_camera, enabled, flipCamera)
Icon(painterResource(MR.images.ic_call_end_filled), stringResource(MR.strings.icon_descr_hang_up), tint = Color.Red, modifier = Modifier.size(64.dp)) ControlButton(call, painterResource(MR.images.ic_videocam_filled), MR.strings.icon_descr_video_off, enabled, toggleVideo)
} else {
Spacer(Modifier.size(48.dp))
ControlButton(call, painterResource(MR.images.ic_videocam_off), MR.strings.icon_descr_video_on, enabled, toggleVideo)
} }
} }
Box(Modifier.padding(start = 32.dp)) { }
ToggleAudioButton(call, toggleAudio)
CallMediaType.Audio -> {
Spacer(Modifier.fillMaxHeight().weight(1f))
Column(
Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
ProfileImage(size = 192.dp, image = call.contact.profile.image)
AudioCallInfoView(call)
} }
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.CenterEnd) { Box(Modifier.fillMaxWidth().fillMaxHeight().weight(1f), contentAlignment = Alignment.BottomCenter) {
Box(Modifier.padding(end = 32.dp)) { DisabledBackgroundCallsButton()
ToggleSoundButton(call, speakerCanBeEnabled, toggleSound) }
Box(Modifier.fillMaxWidth().padding(bottom = DEFAULT_BOTTOM_PADDING), contentAlignment = Alignment.CenterStart) {
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
IconButton(onClick = dismiss, enabled = enabled) {
Icon(painterResource(MR.images.ic_call_end_filled), stringResource(MR.strings.icon_descr_hang_up), tint = if (enabled) Color.Red else MaterialTheme.colors.secondary, modifier = Modifier.size(64.dp))
}
}
Box(Modifier.padding(start = 32.dp)) {
ToggleAudioButton(call, enabled, toggleAudio)
}
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.CenterEnd) {
Box(Modifier.padding(end = 32.dp)) {
ToggleSoundButton(call, speakerCanBeEnabled && enabled, toggleSound)
}
} }
} }
} }
@@ -333,7 +380,7 @@ private fun ActiveCallOverlayLayout(
} }
@Composable @Composable
private fun ControlButton(call: Call, icon: Painter, iconText: StringResource, action: () -> Unit, enabled: Boolean = true) { private fun ControlButton(call: Call, icon: Painter, iconText: StringResource, enabled: Boolean = true, action: () -> Unit) {
if (call.hasMedia) { if (call.hasMedia) {
IconButton(onClick = action, enabled = enabled) { IconButton(onClick = action, enabled = enabled) {
Icon(icon, stringResource(iconText), tint = if (enabled) Color(0xFFFFFFD8) else MaterialTheme.colors.secondary, modifier = Modifier.size(40.dp)) Icon(icon, stringResource(iconText), tint = if (enabled) Color(0xFFFFFFD8) else MaterialTheme.colors.secondary, modifier = Modifier.size(40.dp))
@@ -344,28 +391,26 @@ private fun ControlButton(call: Call, icon: Painter, iconText: StringResource, a
} }
@Composable @Composable
private fun ToggleAudioButton(call: Call, toggleAudio: () -> Unit) { private fun ToggleAudioButton(call: Call, enabled: Boolean = true, toggleAudio: () -> Unit) {
if (call.audioEnabled) { if (call.audioEnabled) {
ControlButton(call, painterResource(MR.images.ic_mic), MR.strings.icon_descr_audio_off, toggleAudio) ControlButton(call, painterResource(MR.images.ic_mic), MR.strings.icon_descr_audio_off, enabled, toggleAudio)
} else { } else {
ControlButton(call, painterResource(MR.images.ic_mic_off), MR.strings.icon_descr_audio_on, toggleAudio) ControlButton(call, painterResource(MR.images.ic_mic_off), MR.strings.icon_descr_audio_on, enabled, toggleAudio)
} }
} }
@Composable @Composable
private fun ToggleSoundButton(call: Call, enabled: Boolean, toggleSound: () -> Unit) { private fun ToggleSoundButton(call: Call, enabled: Boolean, toggleSound: () -> Unit) {
if (call.soundSpeaker) { if (call.soundSpeaker) {
ControlButton(call, painterResource(MR.images.ic_volume_up), MR.strings.icon_descr_speaker_off, toggleSound, enabled) ControlButton(call, painterResource(MR.images.ic_volume_up), MR.strings.icon_descr_speaker_off, enabled, toggleSound)
} else { } else {
ControlButton(call, painterResource(MR.images.ic_volume_down), MR.strings.icon_descr_speaker_on, toggleSound, enabled) ControlButton(call, painterResource(MR.images.ic_volume_down), MR.strings.icon_descr_speaker_on, enabled, toggleSound)
} }
} }
@Composable @Composable
fun CallInfoView(call: Call, alignment: Alignment.Horizontal) { fun AudioCallInfoView(call: Call) {
@Composable fun InfoText(text: String, style: TextStyle = MaterialTheme.typography.body2) = Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(text, color = Color(0xFFFFFFD8), style = style)
Column(horizontalAlignment = alignment) {
InfoText(call.contact.chatViewName, style = MaterialTheme.typography.h2) InfoText(call.contact.chatViewName, style = MaterialTheme.typography.h2)
InfoText(call.callState.text) InfoText(call.callState.text)
@@ -375,6 +420,21 @@ fun CallInfoView(call: Call, alignment: Alignment.Horizontal) {
} }
} }
@Composable
fun VideoCallInfoView(call: Call) {
Column(horizontalAlignment = Alignment.Start) {
InfoText(call.callState.text)
val connInfo = call.connectionInfo
val connInfoText = if (connInfo == null) "" else " (${connInfo.text})"
InfoText(call.encryptionStatus + connInfoText)
}
}
@Composable
fun InfoText(text: String, modifier: Modifier = Modifier, style: TextStyle = MaterialTheme.typography.body2) =
Text(text, modifier, color = Color(0xFFFFFFD8), style = style)
@Composable @Composable
private fun DisabledBackgroundCallsButton() { private fun DisabledBackgroundCallsButton() {
var show by remember { mutableStateOf(!platform.androidIsBackgroundCallAllowed()) } var show by remember { mutableStateOf(!platform.androidIsBackgroundCallAllowed()) }
@@ -452,7 +512,6 @@ private fun DisabledBackgroundCallsButton() {
@Composable @Composable
fun WebRTCView(callCommand: SnapshotStateList<WCallCommand>, onResponse: (WVAPIMessage) -> Unit) { fun WebRTCView(callCommand: SnapshotStateList<WCallCommand>, onResponse: (WVAPIMessage) -> Unit) {
val scope = rememberCoroutineScope()
val webView = remember { mutableStateOf<WebView?>(null) } val webView = remember { mutableStateOf<WebView?>(null) }
val permissionsState = rememberMultiplePermissionsState( val permissionsState = rememberMultiplePermissionsState(
permissions = listOf( permissions = listOf(
@@ -475,10 +534,10 @@ fun WebRTCView(callCommand: SnapshotStateList<WCallCommand>, onResponse: (WVAPIM
} }
lifecycleOwner.lifecycle.addObserver(observer) lifecycleOwner.lifecycle.addObserver(observer)
onDispose { onDispose {
val wv = webView.value
if (wv != null) processCommand(wv, WCallCommand.End)
lifecycleOwner.lifecycle.removeObserver(observer) lifecycleOwner.lifecycle.removeObserver(observer)
webView.value?.destroy() // val wv = webView.value
// if (wv != null) processCommand(wv, WCallCommand.End)
// webView.value?.destroy()
webView.value = null webView.value = null
} }
} }
@@ -505,7 +564,7 @@ fun WebRTCView(callCommand: SnapshotStateList<WCallCommand>, onResponse: (WVAPIM
Box(Modifier.fillMaxSize()) { Box(Modifier.fillMaxSize()) {
AndroidView( AndroidView(
factory = { AndroidViewContext -> factory = { AndroidViewContext ->
WebView(AndroidViewContext).apply { (staticWebView ?: WebView(androidAppContext)).apply {
layoutParams = ViewGroup.LayoutParams( layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT,
@@ -530,7 +589,11 @@ fun WebRTCView(callCommand: SnapshotStateList<WCallCommand>, onResponse: (WVAPIM
webViewSettings.javaScriptEnabled = true webViewSettings.javaScriptEnabled = true
webViewSettings.mediaPlaybackRequiresUserGesture = false webViewSettings.mediaPlaybackRequiresUserGesture = false
webViewSettings.cacheMode = WebSettings.LOAD_NO_CACHE webViewSettings.cacheMode = WebSettings.LOAD_NO_CACHE
this.loadUrl("file:android_asset/www/android/call.html") if (staticWebView == null) {
this.loadUrl("file:android_asset/www/android/call.html")
} else {
webView.value = this
}
} }
} }
) { /* WebView */ } ) { /* WebView */ }
@@ -554,6 +617,15 @@ class WebRTCInterface(private val onResponse: (WVAPIMessage) -> Unit) {
} }
} }
private fun updateActiveCall(initial: Call, transform: (Call) -> Call) {
val activeCall = chatModel.activeCall.value
if (activeCall != null && activeCall.contact.apiId == initial.contact.apiId) {
chatModel.activeCall.value = transform(activeCall)
} else {
Log.d(TAG, "withActiveCall: ignoring, not in call with the contact ${activeCall?.contact?.id}")
}
}
private class LocalContentWebViewClient(val webView: MutableState<WebView?>, private val assetLoader: WebViewAssetLoader) : WebViewClientCompat() { private class LocalContentWebViewClient(val webView: MutableState<WebView?>, private val assetLoader: WebViewAssetLoader) : WebViewClientCompat() {
override fun shouldInterceptRequest( override fun shouldInterceptRequest(
view: WebView, view: WebView,
@@ -566,6 +638,7 @@ private class LocalContentWebViewClient(val webView: MutableState<WebView?>, pri
super.onPageFinished(view, url) super.onPageFinished(view, url)
view.evaluateJavascript("sendMessageToNative = (msg) => WebRTCInterface.postMessage(JSON.stringify(msg))", null) view.evaluateJavascript("sendMessageToNative = (msg) => WebRTCInterface.postMessage(JSON.stringify(msg))", null)
webView.value = view webView.value = view
staticWebView = view
Log.d(TAG, "WebRTCView: webview ready") Log.d(TAG, "WebRTCView: webview ready")
// for debugging // for debugging
// view.evaluateJavascript("sendMessageToNative = ({resp}) => WebRTCInterface.postMessage(JSON.stringify({command: resp}))", null) // view.evaluateJavascript("sendMessageToNative = ({resp}) => WebRTCInterface.postMessage(JSON.stringify({command: resp}))", null)
@@ -579,6 +652,7 @@ fun PreviewActiveCallOverlayVideo() {
ActiveCallOverlayLayout( ActiveCallOverlayLayout(
call = Call( call = Call(
remoteHostId = null, remoteHostId = null,
userProfile = Profile.sampleData,
contact = Contact.sampleData, contact = Contact.sampleData,
callState = CallState.Negotiated, callState = CallState.Negotiated,
localMedia = CallMediaType.Video, localMedia = CallMediaType.Video,
@@ -605,6 +679,7 @@ fun PreviewActiveCallOverlayAudio() {
ActiveCallOverlayLayout( ActiveCallOverlayLayout(
call = Call( call = Call(
remoteHostId = null, remoteHostId = null,
userProfile = Profile.sampleData,
contact = Contact.sampleData, contact = Contact.sampleData,
callState = CallState.Negotiated, callState = CallState.Negotiated,
localMedia = CallMediaType.Audio, localMedia = CallMediaType.Audio,

View File

@@ -1,8 +1,112 @@
package chat.simplex.common.views.chatlist package chat.simplex.common.views.chatlist
import android.app.Activity
import androidx.compose.foundation.*
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.*
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.*
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.common.ANDROID_CALL_TOP_PADDING
import chat.simplex.common.model.durationText
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.call.*
import chat.simplex.common.views.helpers.* import chat.simplex.common.views.helpers.*
import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.painterResource
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.datetime.Clock
private val CALL_INTERACTIVE_AREA_HEIGHT = 74.dp
private val CALL_TOP_OFFSET = (-10).dp
private val CALL_TOP_GREEN_LINE_HEIGHT = ANDROID_CALL_TOP_PADDING - CALL_TOP_OFFSET
private val CALL_BOTTOM_ICON_OFFSET = (-15).dp
private val CALL_BOTTOM_ICON_HEIGHT = CALL_INTERACTIVE_AREA_HEIGHT + CALL_BOTTOM_ICON_OFFSET
@Composable @Composable
actual fun DesktopActiveCallOverlayLayout(newChatSheetState: MutableStateFlow<AnimatedViewState>) {} actual fun ActiveCallInteractiveArea(call: Call, newChatSheetState: MutableStateFlow<AnimatedViewState>) {
val onClick = { platform.androidStartCallActivity(false) }
Box(Modifier.offset(y = CALL_TOP_OFFSET).height(CALL_INTERACTIVE_AREA_HEIGHT)) {
val source = remember { MutableInteractionSource() }
val indication = rememberRipple(bounded = true, 3000.dp)
Box(Modifier.height(CALL_TOP_GREEN_LINE_HEIGHT).clickable(onClick = onClick, indication = indication, interactionSource = source)) {
GreenLine(call)
}
Box(
Modifier
.offset(y = CALL_BOTTOM_ICON_OFFSET)
.size(CALL_BOTTOM_ICON_HEIGHT)
.background(SimplexGreen, CircleShape)
.clip(CircleShape)
.clickable(onClick = onClick, indication = indication, interactionSource = source)
.align(Alignment.BottomCenter),
contentAlignment = Alignment.Center
) {
val media = call.peerMedia ?: call.localMedia
if (media == CallMediaType.Video) {
Icon(painterResource(MR.images.ic_videocam_filled), null, Modifier.size(27.dp).offset(x = 2.5.dp, y = 2.dp), tint = Color.White)
} else {
Icon(painterResource(MR.images.ic_call_filled), null, Modifier.size(27.dp).offset(x = -0.5.dp, y = 2.dp), tint = Color.White)
}
}
}
}
@Composable
private fun GreenLine(call: Call) {
Row(
Modifier
.fillMaxSize()
.background(SimplexGreen)
.padding(top = -CALL_TOP_OFFSET)
.padding(horizontal = DEFAULT_PADDING),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
ContactName(call.contact.displayName)
Spacer(Modifier.weight(1f))
CallDuration(call)
}
val window = (LocalContext.current as Activity).window
DisposableEffect(Unit) {
window.statusBarColor = SimplexGreen.toArgb()
onDispose {
window.statusBarColor = Color.Black.toArgb()
}
}
}
@Composable
private fun ContactName(name: String) {
Text(name, Modifier.width(windowWidth() * 0.35f), color = Color.White, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
@Composable
private fun CallDuration(call: Call) {
val connectedAt = call.connectedAt
if (connectedAt != null) {
val time = remember { mutableStateOf(durationText(0)) }
LaunchedEffect(Unit) {
while (true) {
time.value = durationText((Clock.System.now() - connectedAt).inWholeSeconds.toInt())
delay(250)
}
}
val text = time.value
val sp40Or50 = with(LocalDensity.current) { if (text.length >= 6) 60.sp.toDp() else 42.sp.toDp() }
val offset = with(LocalDensity.current) { 7.sp.toDp() }
Text(text, Modifier.offset(x = offset).widthIn(min = sp40Or50), color = Color.White)
}
}

View File

@@ -1,16 +1,19 @@
package chat.simplex.common package chat.simplex.common
import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.Animatable
import androidx.compose.foundation.background import androidx.compose.foundation.*
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.* import androidx.compose.material.*
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import chat.simplex.common.views.usersettings.SetDeliveryReceiptsView import chat.simplex.common.views.usersettings.SetDeliveryReceiptsView
@@ -20,8 +23,7 @@ import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.CreateFirstProfile import chat.simplex.common.views.CreateFirstProfile
import chat.simplex.common.views.helpers.SimpleButton import chat.simplex.common.views.helpers.SimpleButton
import chat.simplex.common.views.SplashView import chat.simplex.common.views.SplashView
import chat.simplex.common.views.call.ActiveCallView import chat.simplex.common.views.call.*
import chat.simplex.common.views.call.IncomingCallAlertView
import chat.simplex.common.views.chat.ChatView import chat.simplex.common.views.chat.ChatView
import chat.simplex.common.views.chatlist.* import chat.simplex.common.views.chatlist.*
import chat.simplex.common.views.database.DatabaseErrorView import chat.simplex.common.views.database.DatabaseErrorView
@@ -108,6 +110,7 @@ fun MainScreen() {
val localUserCreated = chatModel.localUserCreated.value val localUserCreated = chatModel.localUserCreated.value
var showInitializationView by remember { mutableStateOf(false) } var showInitializationView by remember { mutableStateOf(false) }
when { when {
chatModel.dbMigrationInProgress.value -> DefaultProgressView(stringResource(MR.strings.database_migration_in_progress))
chatModel.chatDbStatus.value == null && showInitializationView -> DefaultProgressView(stringResource(MR.strings.opening_database)) chatModel.chatDbStatus.value == null && showInitializationView -> DefaultProgressView(stringResource(MR.strings.opening_database))
showChatDatabaseError -> { showChatDatabaseError -> {
// Prevent showing keyboard on Android when: passcode enabled and database password not saved // Prevent showing keyboard on Android when: passcode enabled and database password not saved
@@ -168,7 +171,17 @@ fun MainScreen() {
} }
} else { } else {
if (chatModel.showCallView.value) { if (chatModel.showCallView.value) {
ActiveCallView() if (appPlatform.isAndroid) {
LaunchedEffect(Unit) {
// This if prevents running the activity in the following condition:
// - the activity already started before and was destroyed by collapsing active call (start audio call, press back button, go to a launcher)
if (!chatModel.activeCallViewIsCollapsed.value) {
platform.androidStartCallActivity(false)
}
}
} else {
ActiveCallView()
}
} else { } else {
// It's needed for privacy settings toggle, so it can be shown even if the app is passcode unlocked // It's needed for privacy settings toggle, so it can be shown even if the app is passcode unlocked
ModalManager.fullscreen.showPasscodeInView() ModalManager.fullscreen.showPasscodeInView()
@@ -205,9 +218,13 @@ fun MainScreen() {
} }
} }
val ANDROID_CALL_TOP_PADDING = 40.dp
@Composable @Composable
fun AndroidScreen(settingsState: SettingsViewState) { fun AndroidScreen(settingsState: SettingsViewState) {
BoxWithConstraints { BoxWithConstraints {
val call = remember { chatModel.activeCall} .value
val showCallArea = call != null && call.callState != CallState.WaitCapabilities && call.callState != CallState.InvitationAccepted
var currentChatId by rememberSaveable { mutableStateOf(chatModel.chatId.value) } var currentChatId by rememberSaveable { mutableStateOf(chatModel.chatId.value) }
val offset = remember { Animatable(if (chatModel.chatId.value == null) 0f else maxWidth.value) } val offset = remember { Animatable(if (chatModel.chatId.value == null) 0f else maxWidth.value) }
Box( Box(
@@ -215,6 +232,7 @@ fun AndroidScreen(settingsState: SettingsViewState) {
.graphicsLayer { .graphicsLayer {
translationX = -offset.value.dp.toPx() translationX = -offset.value.dp.toPx()
} }
.padding(top = if (showCallArea) ANDROID_CALL_TOP_PADDING else 0.dp)
) { ) {
StartPartOfScreen(settingsState) StartPartOfScreen(settingsState)
} }
@@ -241,11 +259,17 @@ fun AndroidScreen(settingsState: SettingsViewState) {
} }
} }
} }
Box(Modifier.graphicsLayer { translationX = maxWidth.toPx() - offset.value.dp.toPx() }) Box2@{ Box(Modifier
.graphicsLayer { translationX = maxWidth.toPx() - offset.value.dp.toPx() }
.padding(top = if (showCallArea) ANDROID_CALL_TOP_PADDING else 0.dp)
) Box2@{
currentChatId?.let { currentChatId?.let {
ChatView(it, chatModel, onComposed) ChatView(it, chatModel, onComposed)
} }
} }
if (call != null && showCallArea) {
ActiveCallInteractiveArea(call, remember { MutableStateFlow(AnimatedViewState.GONE) })
}
} }
} }

View File

@@ -2,6 +2,7 @@ package chat.simplex.common.model
import androidx.compose.material.* import androidx.compose.material.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.runtime.snapshots.SnapshotStateMap import androidx.compose.runtime.snapshots.SnapshotStateMap
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.SpanStyle
@@ -48,6 +49,7 @@ object ChatModel {
val chatDbEncrypted = mutableStateOf<Boolean?>(false) val chatDbEncrypted = mutableStateOf<Boolean?>(false)
val chatDbStatus = mutableStateOf<DBMigrationResult?>(null) val chatDbStatus = mutableStateOf<DBMigrationResult?>(null)
val ctrlInitInProgress = mutableStateOf(false) val ctrlInitInProgress = mutableStateOf(false)
val dbMigrationInProgress = mutableStateOf(false)
val chats = mutableStateListOf<Chat>() val chats = mutableStateListOf<Chat>()
// map of connections network statuses, key is agent connection id // map of connections network statuses, key is agent connection id
val networkStatuses = mutableStateMapOf<String, NetworkStatus>() val networkStatuses = mutableStateMapOf<String, NetworkStatus>()
@@ -55,7 +57,7 @@ object ChatModel {
// current chat // current chat
val chatId = mutableStateOf<String?>(null) val chatId = mutableStateOf<String?>(null)
val chatItems = mutableStateListOf<ChatItem>() val chatItems = mutableStateOf(SnapshotStateList<ChatItem>())
// rhId, chatId // rhId, chatId
val deletedChats = mutableStateOf<List<Pair<Long?, String>>>(emptyList()) val deletedChats = mutableStateOf<List<Pair<Long?, String>>>(emptyList())
val chatItemStatuses = mutableMapOf<Long, CIStatus>() val chatItemStatuses = mutableMapOf<Long, CIStatus>()
@@ -94,6 +96,7 @@ object ChatModel {
val activeCallInvitation = mutableStateOf<RcvCallInvitation?>(null) val activeCallInvitation = mutableStateOf<RcvCallInvitation?>(null)
val activeCall = mutableStateOf<Call?>(null) val activeCall = mutableStateOf<Call?>(null)
val activeCallViewIsVisible = mutableStateOf<Boolean>(false) val activeCallViewIsVisible = mutableStateOf<Boolean>(false)
val activeCallViewIsCollapsed = mutableStateOf<Boolean>(false)
val callCommand = mutableStateListOf<WCallCommand>() val callCommand = mutableStateListOf<WCallCommand>()
val showCallView = mutableStateOf(false) val showCallView = mutableStateOf(false)
val switchingCall = mutableStateOf(false) val switchingCall = mutableStateOf(false)
@@ -267,18 +270,15 @@ object ChatModel {
} else { } else {
addChat(Chat(remoteHostId = rhId, chatInfo = cInfo, chatItems = arrayListOf(cItem))) addChat(Chat(remoteHostId = rhId, chatInfo = cInfo, chatItems = arrayListOf(cItem)))
} }
Log.d(TAG, "TODOCHAT: addChatItem: adding to chat ${chatId.value} from ${cInfo.id} ${cItem.id}, size ${chatItems.size}")
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
// add to current chat // add to current chat
if (chatId.value == cInfo.id) { if (chatId.value == cInfo.id) {
Log.d(TAG, "TODOCHAT: addChatItem: chatIds are equal, size ${chatItems.size}")
// Prevent situation when chat item already in the list received from backend // Prevent situation when chat item already in the list received from backend
if (chatItems.none { it.id == cItem.id }) { if (chatItems.value.none { it.id == cItem.id }) {
if (chatItems.lastOrNull()?.id == ChatItem.TEMP_LIVE_CHAT_ITEM_ID) { if (chatItems.value.lastOrNull()?.id == ChatItem.TEMP_LIVE_CHAT_ITEM_ID) {
chatItems.add(kotlin.math.max(0, chatItems.lastIndex), cItem) chatItems.add(kotlin.math.max(0, chatItems.value.lastIndex), cItem)
} else { } else {
chatItems.add(cItem) chatItems.add(cItem)
Log.d(TAG, "TODOCHAT: addChatItem: added to chat ${chatId.value} from ${cInfo.id} ${cItem.id}, size ${chatItems.size}")
} }
} }
} }
@@ -305,14 +305,13 @@ object ChatModel {
addChat(Chat(remoteHostId = rhId, chatInfo = cInfo, chatItems = arrayListOf(cItem))) addChat(Chat(remoteHostId = rhId, chatInfo = cInfo, chatItems = arrayListOf(cItem)))
res = true res = true
} }
Log.d(TAG, "TODOCHAT: upsertChatItem: upserting to chat ${chatId.value} from ${cInfo.id} ${cItem.id}, size ${chatItems.size}")
return withContext(Dispatchers.Main) { return withContext(Dispatchers.Main) {
// update current chat // update current chat
if (chatId.value == cInfo.id) { if (chatId.value == cInfo.id) {
val itemIndex = chatItems.indexOfFirst { it.id == cItem.id } val items = chatItems.value
val itemIndex = items.indexOfFirst { it.id == cItem.id }
if (itemIndex >= 0) { if (itemIndex >= 0) {
chatItems[itemIndex] = cItem items[itemIndex] = cItem
Log.d(TAG, "TODOCHAT: upsertChatItem: updated in chat $chatId from ${cInfo.id} ${cItem.id}, size ${chatItems.size}")
false false
} else { } else {
val status = chatItemStatuses.remove(cItem.id) val status = chatItemStatuses.remove(cItem.id)
@@ -322,7 +321,6 @@ object ChatModel {
cItem cItem
} }
chatItems.add(ci) chatItems.add(ci)
Log.d(TAG, "TODOCHAT: upsertChatItem: added to chat $chatId from ${cInfo.id} ${cItem.id}, size ${chatItems.size}")
true true
} }
} else { } else {
@@ -334,9 +332,10 @@ object ChatModel {
suspend fun updateChatItem(cInfo: ChatInfo, cItem: ChatItem, status: CIStatus? = null) { suspend fun updateChatItem(cInfo: ChatInfo, cItem: ChatItem, status: CIStatus? = null) {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
if (chatId.value == cInfo.id) { if (chatId.value == cInfo.id) {
val itemIndex = chatItems.indexOfFirst { it.id == cItem.id } val items = chatItems.value
val itemIndex = items.indexOfFirst { it.id == cItem.id }
if (itemIndex >= 0) { if (itemIndex >= 0) {
chatItems[itemIndex] = cItem items[itemIndex] = cItem
} }
} else if (status != null) { } else if (status != null) {
chatItemStatuses[cItem.id] = status chatItemStatuses[cItem.id] = status
@@ -360,10 +359,10 @@ object ChatModel {
} }
// remove from current chat // remove from current chat
if (chatId.value == cInfo.id) { if (chatId.value == cInfo.id) {
val itemIndex = chatItems.indexOfFirst { it.id == cItem.id } chatItems.removeAll {
if (itemIndex >= 0) { val remove = it.id == cItem.id
AudioPlayer.stop(chatItems[itemIndex]) if (remove) { AudioPlayer.stop(it) }
chatItems.removeAt(itemIndex) remove
} }
} }
} }
@@ -404,7 +403,7 @@ object ChatModel {
} }
fun removeLiveDummy() { fun removeLiveDummy() {
if (chatItems.lastOrNull()?.id == ChatItem.TEMP_LIVE_CHAT_ITEM_ID) { if (chatItems.value.lastOrNull()?.id == ChatItem.TEMP_LIVE_CHAT_ITEM_ID) {
chatItems.removeLast() chatItems.removeLast()
} }
} }
@@ -436,14 +435,14 @@ object ChatModel {
var markedRead = 0 var markedRead = 0
if (chatId.value == cInfo.id) { if (chatId.value == cInfo.id) {
var i = 0 var i = 0
Log.d(TAG, "TODOCHAT: markItemsReadInCurrentChat: marking read ${cInfo.id}, current chatId ${chatId.value}, size was ${chatItems.size}") val items = chatItems.value
while (i < chatItems.count()) { while (i < items.size) {
val item = chatItems[i] val item = items[i]
if (item.meta.itemStatus is CIStatus.RcvNew && (range == null || (range.from <= item.id && item.id <= range.to))) { if (item.meta.itemStatus is CIStatus.RcvNew && (range == null || (range.from <= item.id && item.id <= range.to))) {
val newItem = item.withStatus(CIStatus.RcvRead()) val newItem = item.withStatus(CIStatus.RcvRead())
chatItems[i] = newItem items[i] = newItem
if (newItem.meta.itemLive != true && newItem.meta.itemTimed?.ttl != null) { if (newItem.meta.itemLive != true && newItem.meta.itemTimed?.ttl != null) {
chatItems[i] = newItem.copy(meta = newItem.meta.copy(itemTimed = newItem.meta.itemTimed.copy( items[i] = newItem.copy(meta = newItem.meta.copy(itemTimed = newItem.meta.itemTimed.copy(
deleteAt = Clock.System.now() + newItem.meta.itemTimed.ttl.toDuration(DurationUnit.SECONDS))) deleteAt = Clock.System.now() + newItem.meta.itemTimed.ttl.toDuration(DurationUnit.SECONDS)))
) )
} }
@@ -451,7 +450,6 @@ object ChatModel {
} }
i += 1 i += 1
} }
Log.d(TAG, "TODOCHAT: markItemsReadInCurrentChat: marked read ${cInfo.id}, current chatId ${chatId.value}, size now ${chatItems.size}")
} }
return markedRead return markedRead
} }
@@ -642,7 +640,8 @@ object ChatModel {
} }
fun addTerminalItem(item: TerminalItem) { fun addTerminalItem(item: TerminalItem) {
if (terminalItems.value.size >= 500) { val maxItems = if (appPreferences.developerTools.get()) 500 else 200
if (terminalItems.value.size >= maxItems) {
terminalItems.value = terminalItems.value.subList(1, terminalItems.value.size) terminalItems.value = terminalItems.value.subList(1, terminalItems.value.size)
} }
terminalItems.value += item terminalItems.value += item
@@ -2004,6 +2003,46 @@ data class ChatItem (
} }
} }
fun MutableState<SnapshotStateList<ChatItem>>.add(index: Int, chatItem: ChatItem) {
value = SnapshotStateList<ChatItem>().apply { addAll(value); add(index, chatItem) }
}
fun MutableState<SnapshotStateList<ChatItem>>.add(chatItem: ChatItem) {
value = SnapshotStateList<ChatItem>().apply { addAll(value); add(chatItem) }
}
fun MutableState<SnapshotStateList<ChatItem>>.addAll(index: Int, chatItems: List<ChatItem>) {
value = SnapshotStateList<ChatItem>().apply { addAll(value); addAll(index, chatItems) }
}
fun MutableState<SnapshotStateList<ChatItem>>.addAll(chatItems: List<ChatItem>) {
value = SnapshotStateList<ChatItem>().apply { addAll(value); addAll(chatItems) }
}
fun MutableState<SnapshotStateList<ChatItem>>.removeAll(block: (ChatItem) -> Boolean) {
value = SnapshotStateList<ChatItem>().apply { addAll(value); removeAll(block) }
}
fun MutableState<SnapshotStateList<ChatItem>>.removeAt(index: Int) {
value = SnapshotStateList<ChatItem>().apply { addAll(value); removeAt(index) }
}
fun MutableState<SnapshotStateList<ChatItem>>.removeLast() {
value = SnapshotStateList<ChatItem>().apply { addAll(value); removeLast() }
}
fun MutableState<SnapshotStateList<ChatItem>>.replaceAll(chatItems: List<ChatItem>) {
value = SnapshotStateList<ChatItem>().apply { addAll(chatItems) }
}
fun MutableState<SnapshotStateList<ChatItem>>.clear() {
value = SnapshotStateList<ChatItem>()
}
fun State<SnapshotStateList<ChatItem>>.asReversed(): MutableList<ChatItem> = value.asReversed()
val State<List<ChatItem>>.size: Int get() = value.size
enum class CIMergeCategory { enum class CIMergeCategory {
MemberConnected, MemberConnected,
RcvGroupEvent, RcvGroupEvent,

View File

@@ -451,7 +451,21 @@ object ChatController {
} }
try { try {
val msg = recvMsg(ctrl) val msg = recvMsg(ctrl)
if (msg != null) processReceivedMsg(msg) if (msg != null) {
val finishedWithoutTimeout = withTimeoutOrNull(60_000L) {
processReceivedMsg(msg)
}
if (finishedWithoutTimeout == null) {
Log.e(TAG, "Timeout reached while processing received message: " + msg.resp.responseType)
if (appPreferences.developerTools.get() && appPreferences.showSlowApiCalls.get()) {
AlertManager.shared.showAlertMsg(
title = generalGetString(MR.strings.possible_slow_function_title),
text = generalGetString(MR.strings.possible_slow_function_desc).format(60, msg.resp.responseType + "\n" + Exception().stackTraceToString()),
shareText = true
)
}
}
}
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "ChatController recvMsg/processReceivedMsg exception: " + e.stackTraceToString()); Log.e(TAG, "ChatController recvMsg/processReceivedMsg exception: " + e.stackTraceToString());
} catch (e: Throwable) { } catch (e: Throwable) {
@@ -1685,7 +1699,7 @@ object ChatController {
chatModel.networkStatuses[s.agentConnId] = s.networkStatus chatModel.networkStatuses[s.agentConnId] = s.networkStatus
} }
} }
is CR.NewChatItem -> { is CR.NewChatItem -> withBGApi {
val cInfo = r.chatItem.chatInfo val cInfo = r.chatItem.chatInfo
val cItem = r.chatItem.chatItem val cItem = r.chatItem.chatItem
if (active(r.user)) { if (active(r.user)) {
@@ -1700,7 +1714,7 @@ object ChatController {
((mc is MsgContent.MCImage && file.fileSize <= MAX_IMAGE_SIZE_AUTO_RCV) ((mc is MsgContent.MCImage && file.fileSize <= MAX_IMAGE_SIZE_AUTO_RCV)
|| (mc is MsgContent.MCVideo && file.fileSize <= MAX_VIDEO_SIZE_AUTO_RCV) || (mc is MsgContent.MCVideo && file.fileSize <= MAX_VIDEO_SIZE_AUTO_RCV)
|| (mc is MsgContent.MCVoice && file.fileSize <= MAX_VOICE_SIZE_AUTO_RCV && file.fileStatus !is CIFileStatus.RcvAccepted))) { || (mc is MsgContent.MCVoice && file.fileSize <= MAX_VOICE_SIZE_AUTO_RCV && file.fileStatus !is CIFileStatus.RcvAccepted))) {
withBGApi { receiveFile(rhId, r.user, file.fileId, auto = true) } receiveFile(rhId, r.user, file.fileId, auto = true)
} }
if (cItem.showNotification && (allowedToShowNotification() || chatModel.chatId.value != cInfo.id || chatModel.remoteHostId() != rhId)) { if (cItem.showNotification && (allowedToShowNotification() || chatModel.chatId.value != cInfo.id || chatModel.remoteHostId() != rhId)) {
ntfManager.notifyMessageReceived(r.user, cInfo, cItem) ntfManager.notifyMessageReceived(r.user, cInfo, cItem)
@@ -1900,10 +1914,8 @@ object ChatController {
if (invitation != null) { if (invitation != null) {
chatModel.callManager.reportCallRemoteEnded(invitation = invitation) chatModel.callManager.reportCallRemoteEnded(invitation = invitation)
} }
withCall(r, r.contact) { _ -> withCall(r, r.contact) { call ->
chatModel.callCommand.add(WCallCommand.End) withBGApi { chatModel.callManager.endCall(call) }
chatModel.activeCall.value = null
chatModel.showCallView.value = false
} }
} }
is CR.ContactSwitch -> is CR.ContactSwitch ->

View File

@@ -43,7 +43,7 @@ val appPreferences: AppPreferences
val chatController: ChatController = ChatController val chatController: ChatController = ChatController
fun initChatControllerAndRunMigrations() { fun initChatControllerAndRunMigrations() {
withLongRunningApi(slow = 30_000, deadlock = 60_000) { withLongRunningApi {
if (appPreferences.chatStopped.get() && appPreferences.storeDBPassphrase.get() && ksDatabasePassword.get() != null) { if (appPreferences.chatStopped.get() && appPreferences.storeDBPassphrase.get() && ksDatabasePassword.get() != null) {
initChatController(startChat = ::showStartChatAfterRestartAlert) initChatController(startChat = ::showStartChatAfterRestartAlert)
} else { } else {
@@ -59,10 +59,23 @@ suspend fun initChatController(useKey: String? = null, confirmMigrations: Migrat
chatModel.ctrlInitInProgress.value = true chatModel.ctrlInitInProgress.value = true
val dbKey = useKey ?: DatabaseUtils.useDatabaseKey() val dbKey = useKey ?: DatabaseUtils.useDatabaseKey()
val confirm = confirmMigrations ?: if (appPreferences.developerTools.get() && appPreferences.confirmDBUpgrades.get()) MigrationConfirmation.Error else MigrationConfirmation.YesUp val confirm = confirmMigrations ?: if (appPreferences.developerTools.get() && appPreferences.confirmDBUpgrades.get()) MigrationConfirmation.Error else MigrationConfirmation.YesUp
val migrated: Array<Any> = chatMigrateInit(dbAbsolutePrefixPath, dbKey, confirm.value) var migrated: Array<Any> = chatMigrateInit(dbAbsolutePrefixPath, dbKey, MigrationConfirmation.Error.value)
val res: DBMigrationResult = kotlin.runCatching { var res: DBMigrationResult = runCatching {
json.decodeFromString<DBMigrationResult>(migrated[0] as String) json.decodeFromString<DBMigrationResult>(migrated[0] as String)
}.getOrElse { DBMigrationResult.Unknown(migrated[0] as String) } }.getOrElse { DBMigrationResult.Unknown(migrated[0] as String) }
val rerunMigration = res is DBMigrationResult.ErrorMigration && when (res.migrationError) {
// we don't allow to run down migrations without confirmation in UI, so currently it won't be YesUpDown
is MigrationError.Upgrade -> confirm == MigrationConfirmation.YesUp || confirm == MigrationConfirmation.YesUpDown
is MigrationError.Downgrade -> confirm == MigrationConfirmation.YesUpDown
is MigrationError.Error -> false
}
if (rerunMigration) {
chatModel.dbMigrationInProgress.value = true
migrated = chatMigrateInit(dbAbsolutePrefixPath, dbKey, confirm.value)
res = runCatching {
json.decodeFromString<DBMigrationResult>(migrated[0] as String)
}.getOrElse { DBMigrationResult.Unknown(migrated[0] as String) }
}
val ctrl = if (res is DBMigrationResult.OK) { val ctrl = if (res is DBMigrationResult.OK) {
migrated[1] as Long migrated[1] as Long
} else null } else null
@@ -120,6 +133,7 @@ suspend fun initChatController(useKey: String? = null, confirmMigrations: Migrat
} }
} finally { } finally {
chatModel.ctrlInitInProgress.value = false chatModel.ctrlInitInProgress.value = false
chatModel.dbMigrationInProgress.value = false
} }
} }

View File

@@ -55,7 +55,7 @@ abstract class NtfManager {
} }
fun openChatAction(userId: Long?, chatId: ChatId) { fun openChatAction(userId: Long?, chatId: ChatId) {
withLongRunningApi(slow = 30_000, deadlock = 60_000) { withLongRunningApi {
awaitChatStartedIfNeeded(chatModel) awaitChatStartedIfNeeded(chatModel)
if (userId != null && userId != chatModel.currentUser.value?.userId && chatModel.currentUser.value != null) { if (userId != null && userId != chatModel.currentUser.value?.userId && chatModel.currentUser.value != null) {
// TODO include remote host ID in desktop notifications? // TODO include remote host ID in desktop notifications?
@@ -70,7 +70,7 @@ abstract class NtfManager {
} }
fun showChatsAction(userId: Long?) { fun showChatsAction(userId: Long?) {
withLongRunningApi(slow = 30_000, deadlock = 60_000) { withLongRunningApi {
awaitChatStartedIfNeeded(chatModel) awaitChatStartedIfNeeded(chatModel)
if (userId != null && userId != chatModel.currentUser.value?.userId && chatModel.currentUser.value != null) { if (userId != null && userId != chatModel.currentUser.value?.userId && chatModel.currentUser.value != null) {
// TODO include remote host ID in desktop notifications? // TODO include remote host ID in desktop notifications?

View File

@@ -1,16 +1,21 @@
package chat.simplex.common.platform package chat.simplex.common.platform
import chat.simplex.common.model.ChatId
import chat.simplex.common.model.NotificationsMode import chat.simplex.common.model.NotificationsMode
interface PlatformInterface { interface PlatformInterface {
suspend fun androidServiceStart() {} suspend fun androidServiceStart() {}
fun androidServiceSafeStop() {} fun androidServiceSafeStop() {}
fun androidCallServiceSafeStop() {}
fun androidNotificationsModeChanged(mode: NotificationsMode) {} fun androidNotificationsModeChanged(mode: NotificationsMode) {}
fun androidChatStartedAfterBeingOff() {} fun androidChatStartedAfterBeingOff() {}
fun androidChatStopped() {} fun androidChatStopped() {}
fun androidChatInitializedAndStarted() {} fun androidChatInitializedAndStarted() {}
fun androidIsBackgroundCallAllowed(): Boolean = true fun androidIsBackgroundCallAllowed(): Boolean = true
fun androidSetNightModeIfSupported() {} fun androidSetNightModeIfSupported() {}
fun androidStartCallActivity(acceptCall: Boolean, remoteHostId: Long? = null, chatId: ChatId? = null) {}
fun androidPictureInPictureAllowed(): Boolean = true
fun androidCallEnded() {}
suspend fun androidAskToAllowBackgroundCalls(): Boolean = true suspend fun androidAskToAllowBackgroundCalls(): Boolean = true
} }
/** /**

View File

@@ -1,6 +1,6 @@
package chat.simplex.common.views.call package chat.simplex.common.views.call
import chat.simplex.common.model.ChatModel import chat.simplex.common.model.*
import chat.simplex.common.platform.* import chat.simplex.common.platform.*
import chat.simplex.common.views.helpers.withBGApi import chat.simplex.common.views.helpers.withBGApi
import kotlinx.datetime.Clock import kotlinx.datetime.Clock
@@ -23,27 +23,29 @@ class CallManager(val chatModel: ChatModel) {
} }
} }
fun acceptIncomingCall(invitation: RcvCallInvitation) { fun acceptIncomingCall(invitation: RcvCallInvitation) = withBGApi {
val call = chatModel.activeCall.value val call = chatModel.activeCall.value
if (call == null) { val contactInfo = chatModel.controller.apiContactInfo(invitation.remoteHostId, invitation.contact.contactId)
justAcceptIncomingCall(invitation = invitation) val profile = contactInfo?.second ?: invitation.user.profile.toProfile()
// In case the same contact calling while previous call didn't end yet (abnormal ending of call from the other side)
if (call == null || (call.remoteHostId == invitation.remoteHostId && call.contact.id == invitation.contact.id)) {
justAcceptIncomingCall(invitation = invitation, profile)
} else { } else {
withBGApi { chatModel.switchingCall.value = true
chatModel.switchingCall.value = true try {
try { endCall(call = call)
endCall(call = call) justAcceptIncomingCall(invitation = invitation, profile)
justAcceptIncomingCall(invitation = invitation) } finally {
} finally { chatModel.switchingCall.value = false
chatModel.switchingCall.value = false
}
} }
} }
} }
private fun justAcceptIncomingCall(invitation: RcvCallInvitation) { private fun justAcceptIncomingCall(invitation: RcvCallInvitation, userProfile: Profile) {
with (chatModel) { with (chatModel) {
activeCall.value = Call( activeCall.value = Call(
remoteHostId = invitation.remoteHostId, remoteHostId = invitation.remoteHostId,
userProfile = userProfile,
contact = invitation.contact, contact = invitation.contact,
callState = CallState.InvitationAccepted, callState = CallState.InvitationAccepted,
localMedia = invitation.callType.media, localMedia = invitation.callType.media,
@@ -68,17 +70,23 @@ class CallManager(val chatModel: ChatModel) {
} }
suspend fun endCall(call: Call) { suspend fun endCall(call: Call) {
with (chatModel) { with(chatModel) {
// If there is active call currently and it's with other contact, don't interrupt it
if (activeCall.value != null && !(activeCall.value?.remoteHostId == call.remoteHostId && activeCall.value?.contact?.id == call.contact.id)) return
// Don't destroy WebView if you plan to accept next call right after this one
if (!switchingCall.value) {
showCallView.value = false
activeCall.value = null
activeCallViewIsCollapsed.value = false
platform.androidCallEnded()
}
if (call.callState == CallState.Ended) { if (call.callState == CallState.Ended) {
Log.d(TAG, "CallManager.endCall: call ended") Log.d(TAG, "CallManager.endCall: call ended")
activeCall.value = null
showCallView.value = false
} else { } else {
Log.d(TAG, "CallManager.endCall: ending call...") Log.d(TAG, "CallManager.endCall: ending call...")
callCommand.add(WCallCommand.End) //callCommand.add(WCallCommand.End)
showCallView.value = false
controller.apiEndCall(call.remoteHostId, call.contact) controller.apiEndCall(call.remoteHostId, call.contact)
activeCall.value = null
} }
} }
} }

View File

@@ -7,11 +7,11 @@ import kotlinx.datetime.Instant
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import java.net.URI import java.net.URI
import java.util.*
import kotlin.collections.ArrayList import kotlin.collections.ArrayList
data class Call( data class Call(
val remoteHostId: Long?, val remoteHostId: Long?,
val userProfile: Profile,
val contact: Contact, val contact: Contact,
val callState: CallState, val callState: CallState,
val localMedia: CallMediaType, val localMedia: CallMediaType,
@@ -23,7 +23,7 @@ data class Call(
val soundSpeaker: Boolean = localMedia == CallMediaType.Video, val soundSpeaker: Boolean = localMedia == CallMediaType.Video,
var localCamera: VideoCamera = VideoCamera.User, var localCamera: VideoCamera = VideoCamera.User,
val connectionInfo: ConnectionInfo? = null, val connectionInfo: ConnectionInfo? = null,
var connectedAt: Instant? = null var connectedAt: Instant? = null,
) { ) {
val encrypted: Boolean get() = localEncrypted && sharedKey != null val encrypted: Boolean get() = localEncrypted && sharedKey != null
val localEncrypted: Boolean get() = localCapabilities?.encryption ?: false val localEncrypted: Boolean get() = localCapabilities?.encryption ?: false
@@ -36,6 +36,9 @@ data class Call(
} }
val hasMedia: Boolean get() = callState == CallState.OfferSent || callState == CallState.Negotiated || callState == CallState.Connected val hasMedia: Boolean get() = callState == CallState.OfferSent || callState == CallState.Negotiated || callState == CallState.Connected
fun supportsVideo(): Boolean = peerMedia == CallMediaType.Video || localMedia == CallMediaType.Video
} }
enum class CallState { enum class CallState {
@@ -75,6 +78,7 @@ sealed class WCallCommand {
@Serializable @SerialName("media") data class Media(val media: CallMediaType, val enable: Boolean): WCallCommand() @Serializable @SerialName("media") data class Media(val media: CallMediaType, val enable: Boolean): WCallCommand()
@Serializable @SerialName("camera") data class Camera(val camera: VideoCamera): WCallCommand() @Serializable @SerialName("camera") data class Camera(val camera: VideoCamera): WCallCommand()
@Serializable @SerialName("description") data class Description(val state: String, val description: String): WCallCommand() @Serializable @SerialName("description") data class Description(val state: String, val description: String): WCallCommand()
@Serializable @SerialName("layout") data class Layout(val layout: LayoutType): WCallCommand()
@Serializable @SerialName("end") object End: WCallCommand() @Serializable @SerialName("end") object End: WCallCommand()
} }
@@ -167,6 +171,13 @@ enum class VideoCamera {
val flipped: VideoCamera get() = if (this == User) Environment else User val flipped: VideoCamera get() = if (this == User) Environment else User
} }
@Serializable
enum class LayoutType {
@SerialName("default") Default,
@SerialName("localVideo") LocalVideo,
@SerialName("remoteVideo") RemoteVideo
}
@Serializable @Serializable
data class ConnectionState( data class ConnectionState(
val connectionState: String, val connectionState: String,

View File

@@ -67,14 +67,12 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId:
launch { launch {
snapshotFlow { chatModel.chatId.value } snapshotFlow { chatModel.chatId.value }
.distinctUntilChanged() .distinctUntilChanged()
.onEach { Log.d(TAG, "TODOCHAT: chatId: activeChatId ${activeChat.value?.id} == new chatId $it ${activeChat.value?.id == it} ") }
.filterNotNull() .filterNotNull()
.collect { chatId -> .collect { chatId ->
if (activeChat.value?.id != chatId) { if (activeChat.value?.id != chatId) {
// Redisplay the whole hierarchy if the chat is different to make going from groups to direct chat working correctly // Redisplay the whole hierarchy if the chat is different to make going from groups to direct chat working correctly
// Also for situation when chatId changes after clicking in notification, etc // Also for situation when chatId changes after clicking in notification, etc
activeChat.value = chatModel.getChat(chatId) activeChat.value = chatModel.getChat(chatId)
Log.d(TAG, "TODOCHAT: chatId: activeChatId became ${activeChat.value?.id}")
} }
markUnreadChatAsRead(activeChat, chatModel) markUnreadChatAsRead(activeChat, chatModel)
} }
@@ -94,12 +92,10 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId:
} }
} }
.distinctUntilChanged() .distinctUntilChanged()
.onEach { Log.d(TAG, "TODOCHAT: chats: activeChatId ${activeChat.value?.id} == new chatId ${it?.id} ${activeChat.value?.id == it?.id} ") }
// Only changed chatInfo is important thing. Other properties can be skipped for reducing recompositions // Only changed chatInfo is important thing. Other properties can be skipped for reducing recompositions
.filter { it != null && it?.chatInfo != activeChat.value?.chatInfo } .filter { it != null && it.chatInfo != activeChat.value?.chatInfo }
.collect { .collect {
activeChat.value = it activeChat.value = it
Log.d(TAG, "TODOCHAT: chats: activeChatId became ${activeChat.value?.id}")
} }
} }
} }
@@ -150,7 +146,6 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId:
}, },
attachmentOption, attachmentOption,
attachmentBottomSheetState, attachmentBottomSheetState,
chatModel.chatItems,
searchText, searchText,
useLinkPreviews = useLinkPreviews, useLinkPreviews = useLinkPreviews,
linkMode = chatModel.simplexLinkMode.value, linkMode = chatModel.simplexLinkMode.value,
@@ -228,19 +223,17 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId:
loadPrevMessages = { loadPrevMessages = {
if (chatModel.chatId.value != activeChat.value?.id) return@ChatLayout if (chatModel.chatId.value != activeChat.value?.id) return@ChatLayout
val c = chatModel.getChat(chatModel.chatId.value ?: return@ChatLayout) val c = chatModel.getChat(chatModel.chatId.value ?: return@ChatLayout)
val firstId = chatModel.chatItems.firstOrNull()?.id val firstId = chatModel.chatItems.value.firstOrNull()?.id
if (c != null && firstId != null) { if (c != null && firstId != null) {
withBGApi { withBGApi {
Log.d(TAG, "TODOCHAT: loadPrevMessages: loading for ${c.id}, current chatId ${ChatModel.chatId.value}, size was ${ChatModel.chatItems.size}")
apiLoadPrevMessages(c, chatModel, firstId, searchText.value) apiLoadPrevMessages(c, chatModel, firstId, searchText.value)
Log.d(TAG, "TODOCHAT: loadPrevMessages: loaded for ${c.id}, current chatId ${ChatModel.chatId.value}, size now ${ChatModel.chatItems.size}")
} }
} }
}, },
deleteMessage = { itemId, mode -> deleteMessage = { itemId, mode ->
withBGApi { withBGApi {
val cInfo = chat.chatInfo val cInfo = chat.chatInfo
val toDeleteItem = chatModel.chatItems.firstOrNull { it.id == itemId } val toDeleteItem = chatModel.chatItems.value.firstOrNull { it.id == itemId }
val toModerate = toDeleteItem?.memberToModerate(chat.chatInfo) val toModerate = toDeleteItem?.memberToModerate(chat.chatInfo)
val groupInfo = toModerate?.first val groupInfo = toModerate?.first
val groupMember = toModerate?.second val groupMember = toModerate?.second
@@ -308,7 +301,9 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId:
withBGApi { withBGApi {
val cInfo = chat.chatInfo val cInfo = chat.chatInfo
if (cInfo is ChatInfo.Direct) { if (cInfo is ChatInfo.Direct) {
chatModel.activeCall.value = Call(remoteHostId = chatRh, contact = cInfo.contact, callState = CallState.WaitCapabilities, localMedia = media) val contactInfo = chatModel.controller.apiContactInfo(chat.remoteHostId, cInfo.contact.contactId)
val profile = contactInfo?.second ?: chatModel.currentUser.value?.profile?.toProfile() ?: return@withBGApi
chatModel.activeCall.value = Call(remoteHostId = chatRh, contact = cInfo.contact, callState = CallState.WaitCapabilities, localMedia = media, userProfile = profile)
chatModel.showCallView.value = true chatModel.showCallView.value = true
chatModel.callCommand.add(WCallCommand.Capabilities(media)) chatModel.callCommand.add(WCallCommand.Capabilities(media))
} }
@@ -500,7 +495,6 @@ fun ChatLayout(
composeView: (@Composable () -> Unit), composeView: (@Composable () -> Unit),
attachmentOption: MutableState<AttachmentOption?>, attachmentOption: MutableState<AttachmentOption?>,
attachmentBottomSheetState: ModalBottomSheetState, attachmentBottomSheetState: ModalBottomSheetState,
chatItems: List<ChatItem>,
searchValue: State<String>, searchValue: State<String>,
useLinkPreviews: Boolean, useLinkPreviews: Boolean,
linkMode: SimplexLinkMode, linkMode: SimplexLinkMode,
@@ -587,7 +581,7 @@ fun ChatLayout(
.padding(contentPadding) .padding(contentPadding)
) { ) {
ChatItemsList( ChatItemsList(
chat, unreadCount, composeState, chatItems, searchValue, chat, unreadCount, composeState, searchValue,
useLinkPreviews, linkMode, showMemberInfo, loadPrevMessages, deleteMessage, deleteMessages, useLinkPreviews, linkMode, showMemberInfo, loadPrevMessages, deleteMessage, deleteMessages,
receiveFile, cancelFile, joinGroup, acceptCall, acceptFeature, openDirectChat, receiveFile, cancelFile, joinGroup, acceptCall, acceptFeature, openDirectChat,
updateContactStats, updateMemberStats, syncContactConnection, syncMemberConnection, findModelChat, findModelMember, updateContactStats, updateMemberStats, syncContactConnection, syncMemberConnection, findModelChat, findModelMember,
@@ -681,7 +675,7 @@ fun ChatInfoToolbar(
} }
} }
} }
} else if (activeCall?.contact?.id == chat.id) { } else if (activeCall?.contact?.id == chat.id && appPlatform.isDesktop) {
barButtons.add { barButtons.add {
val call = remember { chatModel.activeCall }.value val call = remember { chatModel.activeCall }.value
val connectedAt = call?.connectedAt val connectedAt = call?.connectedAt
@@ -845,7 +839,6 @@ fun BoxWithConstraintsScope.ChatItemsList(
chat: Chat, chat: Chat,
unreadCount: State<Int>, unreadCount: State<Int>,
composeState: MutableState<ComposeState>, composeState: MutableState<ComposeState>,
chatItems: List<ChatItem>,
searchValue: State<String>, searchValue: State<String>,
useLinkPreviews: Boolean, useLinkPreviews: Boolean,
linkMode: SimplexLinkMode, linkMode: SimplexLinkMode,
@@ -874,7 +867,7 @@ fun BoxWithConstraintsScope.ChatItemsList(
) { ) {
val listState = rememberLazyListState() val listState = rememberLazyListState()
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
ScrollToBottom(chat.id, listState, chatItems) ScrollToBottom(chat.id, listState, chatModel.chatItems)
var prevSearchEmptiness by rememberSaveable { mutableStateOf(searchValue.value.isEmpty()) } var prevSearchEmptiness by rememberSaveable { mutableStateOf(searchValue.value.isEmpty()) }
// Scroll to bottom when search value changes from something to nothing and back // Scroll to bottom when search value changes from something to nothing and back
LaunchedEffect(searchValue.value.isEmpty()) { LaunchedEffect(searchValue.value.isEmpty()) {
@@ -891,7 +884,7 @@ fun BoxWithConstraintsScope.ChatItemsList(
PreloadItems(listState, ChatPagination.UNTIL_PRELOAD_COUNT, loadPrevMessages) PreloadItems(listState, ChatPagination.UNTIL_PRELOAD_COUNT, loadPrevMessages)
Spacer(Modifier.size(8.dp)) Spacer(Modifier.size(8.dp))
val reversedChatItems by remember { derivedStateOf { chatItems.reversed().toList() } } val reversedChatItems by remember { derivedStateOf { chatModel.chatItems.asReversed() } }
val maxHeightRounded = with(LocalDensity.current) { maxHeight.roundToPx() } val maxHeightRounded = with(LocalDensity.current) { maxHeight.roundToPx() }
val scrollToItem: (Long) -> Unit = { itemId: Long -> val scrollToItem: (Long) -> Unit = { itemId: Long ->
val index = reversedChatItems.indexOfFirst { it.id == itemId } val index = reversedChatItems.indexOfFirst { it.id == itemId }
@@ -944,7 +937,7 @@ fun BoxWithConstraintsScope.ChatItemsList(
} }
} }
val provider = { val provider = {
providerForGallery(i, chatItems, cItem.id) { indexInReversed -> providerForGallery(i, chatModel.chatItems.value, cItem.id) { indexInReversed ->
scope.launch { scope.launch {
listState.scrollToItem( listState.scrollToItem(
kotlin.math.min(reversedChatItems.lastIndex, indexInReversed + 1), kotlin.math.min(reversedChatItems.lastIndex, indexInReversed + 1),
@@ -1067,11 +1060,11 @@ fun BoxWithConstraintsScope.ChatItemsList(
} }
} }
} }
FloatingButtons(chatItems, unreadCount, chat.chatStats.minUnreadItemId, searchValue, markRead, setFloatingButton, listState) FloatingButtons(chatModel.chatItems, unreadCount, chat.chatStats.minUnreadItemId, searchValue, markRead, setFloatingButton, listState)
} }
@Composable @Composable
private fun ScrollToBottom(chatId: ChatId, listState: LazyListState, chatItems: List<ChatItem>) { private fun ScrollToBottom(chatId: ChatId, listState: LazyListState, chatItems: State<List<ChatItem>>) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
// Helps to scroll to bottom after moving from Group to Direct chat // Helps to scroll to bottom after moving from Group to Direct chat
// and prevents scrolling to bottom on orientation change // and prevents scrolling to bottom on orientation change
@@ -1089,7 +1082,7 @@ private fun ScrollToBottom(chatId: ChatId, listState: LazyListState, chatItems:
* When the first visible item (from bottom) is visible (even partially) we can autoscroll to 0 item. Or just scrollBy small distance otherwise * When the first visible item (from bottom) is visible (even partially) we can autoscroll to 0 item. Or just scrollBy small distance otherwise
* */ * */
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
snapshotFlow { chatItems.lastOrNull()?.id } snapshotFlow { chatItems.value.lastOrNull()?.id }
.distinctUntilChanged() .distinctUntilChanged()
.filter { listState.layoutInfo.visibleItemsInfo.firstOrNull()?.key != it } .filter { listState.layoutInfo.visibleItemsInfo.firstOrNull()?.key != it }
.collect { .collect {
@@ -1112,7 +1105,7 @@ private fun ScrollToBottom(chatId: ChatId, listState: LazyListState, chatItems:
@Composable @Composable
fun BoxWithConstraintsScope.FloatingButtons( fun BoxWithConstraintsScope.FloatingButtons(
chatItems: List<ChatItem>, chatItems: State<List<ChatItem>>,
unreadCount: State<Int>, unreadCount: State<Int>,
minUnreadItemId: Long, minUnreadItemId: Long,
searchValue: State<String>, searchValue: State<String>,
@@ -1146,10 +1139,11 @@ fun BoxWithConstraintsScope.FloatingButtons(
val bottomUnreadCount by remember { val bottomUnreadCount by remember {
derivedStateOf { derivedStateOf {
if (unreadCount.value == 0) return@derivedStateOf 0 if (unreadCount.value == 0) return@derivedStateOf 0
val from = chatItems.lastIndex - firstVisibleIndex - lastIndexOfVisibleItems val items = chatItems.value
if (chatItems.size <= from || from < 0) return@derivedStateOf 0 val from = items.lastIndex - firstVisibleIndex - lastIndexOfVisibleItems
if (items.size <= from || from < 0) return@derivedStateOf 0
chatItems.subList(from, chatItems.size).count { it.isRcvNew } items.subList(from, items.size).count { it.isRcvNew }
} }
} }
val firstVisibleOffset = (-with(LocalDensity.current) { maxHeight.roundToPx() } * 0.8).toInt() val firstVisibleOffset = (-with(LocalDensity.current) { maxHeight.roundToPx() } * 0.8).toInt()
@@ -1195,7 +1189,7 @@ fun BoxWithConstraintsScope.FloatingButtons(
painterResource(MR.images.ic_check), painterResource(MR.images.ic_check),
onClick = { onClick = {
markRead( markRead(
CC.ItemRange(minUnreadItemId, chatItems[chatItems.size - listState.layoutInfo.visibleItemsInfo.lastIndex - 1].id - 1), CC.ItemRange(minUnreadItemId, chatItems.value[chatItems.size - listState.layoutInfo.visibleItemsInfo.lastIndex - 1].id - 1),
bottomUnreadCount bottomUnreadCount
) )
showDropDown.value = false showDropDown.value = false
@@ -1500,7 +1494,6 @@ fun PreviewChatLayout() {
composeView = {}, composeView = {},
attachmentOption = remember { mutableStateOf<AttachmentOption?>(null) }, attachmentOption = remember { mutableStateOf<AttachmentOption?>(null) },
attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden), attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden),
chatItems = chatItems,
searchValue, searchValue,
useLinkPreviews = true, useLinkPreviews = true,
linkMode = SimplexLinkMode.DESCRIPTION, linkMode = SimplexLinkMode.DESCRIPTION,
@@ -1573,7 +1566,6 @@ fun PreviewGroupChatLayout() {
composeView = {}, composeView = {},
attachmentOption = remember { mutableStateOf<AttachmentOption?>(null) }, attachmentOption = remember { mutableStateOf<AttachmentOption?>(null) },
attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden), attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden),
chatItems = chatItems,
searchValue, searchValue,
useLinkPreviews = true, useLinkPreviews = true,
linkMode = SimplexLinkMode.DESCRIPTION, linkMode = SimplexLinkMode.DESCRIPTION,

View File

@@ -267,7 +267,7 @@ fun ComposeView(
fun loadLinkPreview(url: String, wait: Long? = null) { fun loadLinkPreview(url: String, wait: Long? = null) {
if (pendingLinkUrl.value == url) { if (pendingLinkUrl.value == url) {
composeState.value = composeState.value.copy(preview = ComposePreview.CLinkPreview(null)) composeState.value = composeState.value.copy(preview = ComposePreview.CLinkPreview(null))
withLongRunningApi(slow = 30_000, deadlock = 60_000) { withLongRunningApi(slow = 60_000) {
if (wait != null) delay(wait) if (wait != null) delay(wait)
val lp = getLinkPreview(url) val lp = getLinkPreview(url)
if (lp != null && pendingLinkUrl.value == url) { if (lp != null && pendingLinkUrl.value == url) {
@@ -551,7 +551,7 @@ fun ComposeView(
} }
fun sendMessage(ttl: Int?) { fun sendMessage(ttl: Int?) {
withLongRunningApi(slow = 30_000, deadlock = 60_000) { withLongRunningApi(slow = 120_000) {
sendMessageAsync(null, false, ttl) sendMessageAsync(null, false, ttl)
} }
} }
@@ -665,7 +665,7 @@ fun ComposeView(
fun editPrevMessage() { fun editPrevMessage() {
if (composeState.value.contextItem != ComposeContextItem.NoContextItem || composeState.value.preview != ComposePreview.NoPreview) return if (composeState.value.contextItem != ComposeContextItem.NoContextItem || composeState.value.preview != ComposePreview.NoPreview) return
val lastEditable = chatModel.chatItems.findLast { it.meta.editable } val lastEditable = chatModel.chatItems.value.findLast { it.meta.editable }
if (lastEditable != null) { if (lastEditable != null) {
composeState.value = ComposeState(editingItem = lastEditable, useLinkPreviews = useLinkPreviews) composeState.value = ComposeState(editingItem = lastEditable, useLinkPreviews = useLinkPreviews)
} }

View File

@@ -54,7 +54,7 @@ fun AddGroupMembersView(rhId: Long?, groupInfo: GroupInfo, creatingGroup: Boolea
}, },
inviteMembers = { inviteMembers = {
allowModifyMembers = false allowModifyMembers = false
withLongRunningApi(slow = 30_000, deadlock = 60_000) { withLongRunningApi(slow = 120_000) {
for (contactId in selectedContacts) { for (contactId in selectedContacts) {
val member = chatModel.controller.apiAddMember(rhId, groupInfo.groupId, contactId, selectedRole.value) val member = chatModel.controller.apiAddMember(rhId, groupInfo.groupId, contactId, selectedRole.value)
if (member != null) { if (member != null) {

View File

@@ -152,7 +152,7 @@ fun leaveGroupDialog(rhId: Long?, groupInfo: GroupInfo, chatModel: ChatModel, cl
text = generalGetString(MR.strings.you_will_stop_receiving_messages_from_this_group_chat_history_will_be_preserved), text = generalGetString(MR.strings.you_will_stop_receiving_messages_from_this_group_chat_history_will_be_preserved),
confirmText = generalGetString(MR.strings.leave_group_button), confirmText = generalGetString(MR.strings.leave_group_button),
onConfirm = { onConfirm = {
withBGApi { withLongRunningApi(60_000) {
chatModel.controller.leaveGroup(rhId, groupInfo.groupId) chatModel.controller.leaveGroup(rhId, groupInfo.groupId)
close?.invoke() close?.invoke()
} }
@@ -424,69 +424,47 @@ private fun MemberVerifiedShield() {
@Composable @Composable
private fun DropDownMenuForMember(rhId: Long?, member: GroupMember, groupInfo: GroupInfo, showMenu: MutableState<Boolean>) { private fun DropDownMenuForMember(rhId: Long?, member: GroupMember, groupInfo: GroupInfo, showMenu: MutableState<Boolean>) {
// revert from this: if (groupInfo.membership.memberRole >= GroupMemberRole.Admin) {
DefaultDropdownMenu(showMenu) { val canBlockForAll = member.canBlockForAll(groupInfo)
if (member.canBeRemoved(groupInfo)) { val canRemove = member.canBeRemoved(groupInfo)
ItemAction(stringResource(MR.strings.remove_member_button), painterResource(MR.images.ic_delete), color = MaterialTheme.colors.error, onClick = { if (canBlockForAll || canRemove) {
removeMemberAlert(rhId, groupInfo, member) DefaultDropdownMenu(showMenu) {
showMenu.value = false if (canBlockForAll) {
}) if (member.blockedByAdmin) {
ItemAction(stringResource(MR.strings.unblock_for_all), painterResource(MR.images.ic_do_not_touch), onClick = {
unblockForAllAlert(rhId, groupInfo, member)
showMenu.value = false
})
} else {
ItemAction(stringResource(MR.strings.block_for_all), painterResource(MR.images.ic_back_hand), color = MaterialTheme.colors.error, onClick = {
blockForAllAlert(rhId, groupInfo, member)
showMenu.value = false
})
}
}
if (canRemove) {
ItemAction(stringResource(MR.strings.remove_member_button), painterResource(MR.images.ic_delete), color = MaterialTheme.colors.error, onClick = {
removeMemberAlert(rhId, groupInfo, member)
showMenu.value = false
})
}
}
} }
if (member.memberSettings.showMessages) { } else if (!member.blockedByAdmin) {
ItemAction(stringResource(MR.strings.block_member_button), painterResource(MR.images.ic_back_hand), color = MaterialTheme.colors.error, onClick = { DefaultDropdownMenu(showMenu) {
blockMemberAlert(rhId, groupInfo, member) if (member.memberSettings.showMessages) {
showMenu.value = false ItemAction(stringResource(MR.strings.block_member_button), painterResource(MR.images.ic_back_hand), color = MaterialTheme.colors.error, onClick = {
}) blockMemberAlert(rhId, groupInfo, member)
} else { showMenu.value = false
ItemAction(stringResource(MR.strings.unblock_member_button), painterResource(MR.images.ic_do_not_touch), onClick = { })
unblockMemberAlert(rhId, groupInfo, member) } else {
showMenu.value = false ItemAction(stringResource(MR.strings.unblock_member_button), painterResource(MR.images.ic_do_not_touch), onClick = {
}) unblockMemberAlert(rhId, groupInfo, member)
showMenu.value = false
})
}
} }
} }
// revert to this: vvv
// if (groupInfo.membership.memberRole >= GroupMemberRole.Admin) {
// val canBlockForAll = member.canBlockForAll(groupInfo)
// val canRemove = member.canBeRemoved(groupInfo)
// if (canBlockForAll || canRemove) {
// DefaultDropdownMenu(showMenu) {
// if (canBlockForAll) {
// if (member.blockedByAdmin) {
// ItemAction(stringResource(MR.strings.unblock_for_all), painterResource(MR.images.ic_do_not_touch), onClick = {
// unblockForAllAlert(rhId, groupInfo, member)
// showMenu.value = false
// })
// } else {
// ItemAction(stringResource(MR.strings.block_for_all), painterResource(MR.images.ic_back_hand), color = MaterialTheme.colors.error, onClick = {
// blockForAllAlert(rhId, groupInfo, member)
// showMenu.value = false
// })
// }
// }
// if (canRemove) {
// ItemAction(stringResource(MR.strings.remove_member_button), painterResource(MR.images.ic_delete), color = MaterialTheme.colors.error, onClick = {
// removeMemberAlert(rhId, groupInfo, member)
// showMenu.value = false
// })
// }
// }
// }
// } else if (!member.blockedByAdmin) {
// DefaultDropdownMenu(showMenu) {
// if (member.memberSettings.showMessages) {
// ItemAction(stringResource(MR.strings.block_member_button), painterResource(MR.images.ic_back_hand), color = MaterialTheme.colors.error, onClick = {
// blockMemberAlert(rhId, groupInfo, member)
// showMenu.value = false
// })
// } else {
// ItemAction(stringResource(MR.strings.unblock_member_button), painterResource(MR.images.ic_do_not_touch), onClick = {
// unblockMemberAlert(rhId, groupInfo, member)
// showMenu.value = false
// })
// }
// }
// }
// ^^^
} }
@Composable @Composable

View File

@@ -3,11 +3,9 @@ package chat.simplex.common.views.chat.group
import InfoRow import InfoRow
import SectionBottomSpacer import SectionBottomSpacer
import SectionDividerSpaced import SectionDividerSpaced
import SectionItemView
import SectionSpacer import SectionSpacer
import SectionTextFooter import SectionTextFooter
import SectionView import SectionView
import TextIconSpaced
import androidx.compose.desktop.ui.tooling.preview.Preview import androidx.compose.desktop.ui.tooling.preview.Preview
import java.net.URI import java.net.URI
import androidx.compose.foundation.* import androidx.compose.foundation.*
@@ -74,9 +72,8 @@ fun GroupMemberInfoView(
if (chatModel.getContactChat(it) == null) { if (chatModel.getContactChat(it) == null) {
chatModel.addChat(c) chatModel.addChat(c)
} }
chatModel.chatItems.clear()
chatModel.chatItemStatuses.clear() chatModel.chatItemStatuses.clear()
chatModel.chatItems.addAll(c.chatItems) chatModel.chatItems.replaceAll(c.chatItems)
chatModel.chatId.value = c.id chatModel.chatId.value = c.id
closeAll() closeAll()
} }
@@ -390,25 +387,11 @@ fun GroupMemberInfoLayout(
} }
} }
// revert from this: if (groupInfo.membership.memberRole >= GroupMemberRole.Admin) {
SectionDividerSpaced(maxBottomPadding = false) AdminDestructiveSection()
SectionView { } else {
if (member.memberSettings.showMessages) { NonAdminBlockSection()
BlockMemberButton(blockMember)
} else {
UnblockMemberButton(unblockMember)
}
if (member.canBeRemoved(groupInfo)) {
RemoveMemberButton(removeMember)
}
} }
// revert to this: vvv
// if (groupInfo.membership.memberRole >= GroupMemberRole.Admin) {
// AdminDestructiveSection()
// } else {
// NonAdminBlockSection()
// }
// ^^^
if (developerTools) { if (developerTools) {
SectionDividerSpaced() SectionDividerSpaced()

View File

@@ -94,7 +94,7 @@ fun CIFileView(
FileProtocol.LOCAL -> {} FileProtocol.LOCAL -> {}
} }
file.fileStatus is CIFileStatus.RcvComplete || (file.fileStatus is CIFileStatus.SndStored && file.fileProtocol == FileProtocol.LOCAL) -> { file.fileStatus is CIFileStatus.RcvComplete || (file.fileStatus is CIFileStatus.SndStored && file.fileProtocol == FileProtocol.LOCAL) -> {
withLongRunningApi(slow = 60_000, deadlock = 600_000) { withLongRunningApi(slow = 600_000) {
var filePath = getLoadedFilePath(file) var filePath = getLoadedFilePath(file)
if (chatModel.connectedToRemote() && filePath == null) { if (chatModel.connectedToRemote() && filePath == null) {
file.loadRemoteFile(true) file.loadRemoteFile(true)

View File

@@ -41,7 +41,7 @@ fun CIVideoView(
val filePath = remember(file, CIFile.cachedRemoteFileRequests.toList()) { mutableStateOf(getLoadedFilePath(file)) } val filePath = remember(file, CIFile.cachedRemoteFileRequests.toList()) { mutableStateOf(getLoadedFilePath(file)) }
if (chatModel.connectedToRemote()) { if (chatModel.connectedToRemote()) {
LaunchedEffect(file) { LaunchedEffect(file) {
withLongRunningApi(slow = 60_000, deadlock = 600_000) { withLongRunningApi(slow = 600_000) {
if (file != null && file.loaded && getLoadedFilePath(file) == null) { if (file != null && file.loaded && getLoadedFilePath(file) == null) {
file.loadRemoteFile(false) file.loadRemoteFile(false)
filePath.value = getLoadedFilePath(file) filePath.value = getLoadedFilePath(file)

View File

@@ -213,7 +213,7 @@ fun ChatItemView(
showMenu.value = false showMenu.value = false
} }
if (chatModel.connectedToRemote() && fileSource == null) { if (chatModel.connectedToRemote() && fileSource == null) {
withLongRunningApi(slow = 60_000, deadlock = 600_000) { withLongRunningApi(slow = 600_000) {
cItem.file?.loadRemoteFile(true) cItem.file?.loadRemoteFile(true)
fileSource = getLoadedFileSource(cItem.file) fileSource = getLoadedFileSource(cItem.file)
shareIfExists() shareIfExists()
@@ -527,8 +527,9 @@ fun DeleteItemAction(
val range = chatViewItemsRange(currIndex, prevHidden) val range = chatViewItemsRange(currIndex, prevHidden)
if (range != null) { if (range != null) {
val itemIds: ArrayList<Long> = arrayListOf() val itemIds: ArrayList<Long> = arrayListOf()
val reversedChatItems = chatModel.chatItems.asReversed()
for (i in range) { for (i in range) {
itemIds.add(chatModel.chatItems.asReversed()[i].id) itemIds.add(reversedChatItems[i].id)
} }
deleteMessagesAlertDialog(itemIds, generalGetString(MR.strings.delete_message_mark_deleted_warning), deleteMessages = deleteMessages) deleteMessagesAlertDialog(itemIds, generalGetString(MR.strings.delete_message_mark_deleted_warning), deleteMessages = deleteMessages)
} else { } else {

View File

@@ -212,18 +212,15 @@ suspend fun openGroupChat(rhId: Long?, groupId: Long, chatModel: ChatModel) {
} }
suspend fun openChat(rhId: Long?, chatInfo: ChatInfo, chatModel: ChatModel) { suspend fun openChat(rhId: Long?, chatInfo: ChatInfo, chatModel: ChatModel) {
Log.d(TAG, "TODOCHAT: openChat: opening ${chatInfo.id}, current chatId ${ChatModel.chatId.value}, size ${ChatModel.chatItems.size}")
val chat = chatModel.controller.apiGetChat(rhId, chatInfo.chatType, chatInfo.apiId) val chat = chatModel.controller.apiGetChat(rhId, chatInfo.chatType, chatInfo.apiId)
if (chat != null) { if (chat != null) {
openLoadedChat(chat, chatModel) openLoadedChat(chat, chatModel)
Log.d(TAG, "TODOCHAT: openChat: opened ${chatInfo.id}, current chatId ${ChatModel.chatId.value}, size ${ChatModel.chatItems.size}")
} }
} }
fun openLoadedChat(chat: Chat, chatModel: ChatModel) { fun openLoadedChat(chat: Chat, chatModel: ChatModel) {
chatModel.chatItems.clear()
chatModel.chatItemStatuses.clear() chatModel.chatItemStatuses.clear()
chatModel.chatItems.addAll(chat.chatItems) chatModel.chatItems.replaceAll(chat.chatItems)
chatModel.chatId.value = chat.chatInfo.id chatModel.chatId.value = chat.chatInfo.id
} }
@@ -239,8 +236,7 @@ suspend fun apiFindMessages(ch: Chat, chatModel: ChatModel, search: String) {
val chatInfo = ch.chatInfo val chatInfo = ch.chatInfo
val chat = chatModel.controller.apiGetChat(ch.remoteHostId, chatInfo.chatType, chatInfo.apiId, search = search) ?: return val chat = chatModel.controller.apiGetChat(ch.remoteHostId, chatInfo.chatType, chatInfo.apiId, search = search) ?: return
if (chatModel.chatId.value != chat.id) return if (chatModel.chatId.value != chat.id) return
chatModel.chatItems.clear() chatModel.chatItems.replaceAll(chat.chatItems)
chatModel.chatItems.addAll(0, chat.chatItems)
} }
suspend fun setGroupMembers(rhId: Long?, groupInfo: GroupInfo, chatModel: ChatModel) { suspend fun setGroupMembers(rhId: Long?, groupInfo: GroupInfo, chatModel: ChatModel) {

View File

@@ -29,6 +29,7 @@ import chat.simplex.common.views.onboarding.WhatsNewView
import chat.simplex.common.views.onboarding.shouldShowWhatsNew import chat.simplex.common.views.onboarding.shouldShowWhatsNew
import chat.simplex.common.views.usersettings.SettingsView import chat.simplex.common.views.usersettings.SettingsView
import chat.simplex.common.platform.* import chat.simplex.common.platform.*
import chat.simplex.common.views.call.Call
import chat.simplex.common.views.newchat.* import chat.simplex.common.views.newchat.*
import chat.simplex.res.MR import chat.simplex.res.MR
import kotlinx.coroutines.* import kotlinx.coroutines.*
@@ -121,7 +122,12 @@ fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerf
} }
} }
if (searchText.value.text.isEmpty()) { if (searchText.value.text.isEmpty()) {
DesktopActiveCallOverlayLayout(newChatSheetState) if (appPlatform.isDesktop) {
val call = remember { chatModel.activeCall }.value
if (call != null) {
ActiveCallInteractiveArea(call, newChatSheetState)
}
}
// TODO disable this button and sheet for the duration of the switch // TODO disable this button and sheet for the duration of the switch
tryOrShowError("NewChatSheet", error = {}) { tryOrShowError("NewChatSheet", error = {}) {
NewChatSheet(chatModel, newChatSheetState, stopped, hideNewChatSheet) NewChatSheet(chatModel, newChatSheetState, stopped, hideNewChatSheet)
@@ -314,7 +320,7 @@ private fun ToggleFilterDisabledButton() {
} }
@Composable @Composable
expect fun DesktopActiveCallOverlayLayout(newChatSheetState: MutableStateFlow<AnimatedViewState>) expect fun ActiveCallInteractiveArea(call: Call, newChatSheetState: MutableStateFlow<AnimatedViewState>)
fun connectIfOpenedViaUri(rhId: Long?, uri: URI, chatModel: ChatModel) { fun connectIfOpenedViaUri(rhId: Long?, uri: URI, chatModel: ChatModel) {
Log.d(TAG, "connectIfOpenedViaUri: opened via link") Log.d(TAG, "connectIfOpenedViaUri: opened via link")

View File

@@ -85,7 +85,7 @@ private fun ShareListToolbar(chatModel: ChatModel, userPickerState: MutableState
userPickerState.value = AnimatedViewState.VISIBLE userPickerState.value = AnimatedViewState.VISIBLE
} }
} }
else -> NavigationButtonBack { chatModel.sharedContent.value = null } else -> NavigationButtonBack(onButtonClicked = { chatModel.sharedContent.value = null })
} }
} }
if (chatModel.chats.size >= 8) { if (chatModel.chats.size >= 8) {
@@ -143,7 +143,7 @@ private fun ShareList(chatModel: ChatModel, search: String) {
} }
val chats by remember(search) { val chats by remember(search) {
derivedStateOf { derivedStateOf {
if (search.isEmpty()) chatModel.chats.filter { it.chatInfo.ready } else chatModel.chats.filter { it.chatInfo.ready }.filter(filter) if (search.isEmpty()) chatModel.chats.toList().filter { it.chatInfo.ready } else chatModel.chats.toList().filter { it.chatInfo.ready }.filter(filter)
} }
} }
LazyColumn( LazyColumn(

View File

@@ -62,7 +62,7 @@ fun DatabaseEncryptionView(m: ChatModel) {
initialRandomDBPassphrase, initialRandomDBPassphrase,
progressIndicator, progressIndicator,
onConfirmEncrypt = { onConfirmEncrypt = {
withLongRunningApi(slow = 30_000, deadlock = 60_000) { withLongRunningApi {
encryptDatabase(currentKey, newKey, confirmNewKey, initialRandomDBPassphrase, useKeychain, storedKey, progressIndicator) encryptDatabase(currentKey, newKey, confirmNewKey, initialRandomDBPassphrase, useKeychain, storedKey, progressIndicator)
} }
} }

View File

@@ -368,7 +368,7 @@ fun chatArchiveTitle(chatArchiveTime: Instant, chatLastStart: Instant): String {
} }
fun startChat(m: ChatModel, chatLastStart: MutableState<Instant?>, chatDbChanged: MutableState<Boolean>, progressIndicator: MutableState<Boolean>? = null) { fun startChat(m: ChatModel, chatLastStart: MutableState<Instant?>, chatDbChanged: MutableState<Boolean>, progressIndicator: MutableState<Boolean>? = null) {
withLongRunningApi(slow = 30_000, deadlock = 60_000) { withLongRunningApi {
try { try {
progressIndicator?.value = true progressIndicator?.value = true
if (chatDbChanged.value) { if (chatDbChanged.value) {
@@ -581,7 +581,7 @@ private fun importArchive(
progressIndicator.value = true progressIndicator.value = true
val archivePath = saveArchiveFromURI(importedArchiveURI) val archivePath = saveArchiveFromURI(importedArchiveURI)
if (archivePath != null) { if (archivePath != null) {
withLongRunningApi(slow = 60_000, deadlock = 180_000) { withLongRunningApi {
try { try {
m.controller.apiDeleteStorage() m.controller.apiDeleteStorage()
try { try {

View File

@@ -12,6 +12,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.* import androidx.compose.ui.focus.*
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
@@ -22,6 +23,7 @@ import chat.simplex.common.ui.theme.*
import chat.simplex.res.MR import chat.simplex.res.MR
import dev.icerock.moko.resources.StringResource import dev.icerock.moko.resources.StringResource
import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -189,6 +191,7 @@ class AlertManager {
title: String, text: String? = null, title: String, text: String? = null,
confirmText: String = generalGetString(MR.strings.ok), confirmText: String = generalGetString(MR.strings.ok),
hostDevice: Pair<Long?, String>? = null, hostDevice: Pair<Long?, String>? = null,
shareText: Boolean? = null
) { ) {
showAlert { showAlert {
AlertDialog( AlertDialog(
@@ -202,10 +205,19 @@ class AlertManager {
delay(200) delay(200)
focusRequester.requestFocus() focusRequester.requestFocus()
} }
// Can pass shareText = false to prevent showing Share button if it's needed in a specific case
val showShareButton = text != null && (shareText == true || (shareText == null && text.length > 500))
Row( Row(
Modifier.fillMaxWidth().padding(horizontal = DEFAULT_PADDING), Modifier.fillMaxWidth().padding(horizontal = DEFAULT_PADDING),
horizontalArrangement = Arrangement.Center horizontalArrangement = if (showShareButton) Arrangement.SpaceBetween else Arrangement.Center
) { ) {
val clipboard = LocalClipboardManager.current
if (showShareButton && text != null) {
TextButton(onClick = {
clipboard.shareText(text)
hideAlert()
}) { Text(stringResource(MR.strings.share_verb)) }
}
TextButton( TextButton(
onClick = { onClick = {
hideAlert() hideAlert()

View File

@@ -18,7 +18,7 @@ import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.painterResource
@Composable @Composable
fun CloseSheetBar(close: (() -> Unit)?, showClose: Boolean = true, endButtons: @Composable RowScope.() -> Unit = {}) { fun CloseSheetBar(close: (() -> Unit)?, showClose: Boolean = true, tintColor: Color = if (close != null) MaterialTheme.colors.primary else MaterialTheme.colors.secondary, endButtons: @Composable RowScope.() -> Unit = {}) {
Column( Column(
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -35,7 +35,7 @@ fun CloseSheetBar(close: (() -> Unit)?, showClose: Boolean = true, endButtons: @
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
if (showClose) { if (showClose) {
NavigationButtonBack(onButtonClicked = close) NavigationButtonBack(tintColor = tintColor, onButtonClicked = close)
} else { } else {
Spacer(Modifier) Spacer(Modifier)
} }

View File

@@ -5,6 +5,7 @@ import androidx.compose.material.*
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import chat.simplex.common.ui.theme.DEFAULT_PADDING import chat.simplex.common.ui.theme.DEFAULT_PADDING
@@ -20,7 +21,7 @@ fun DefaultProgressView(description: String?) {
strokeWidth = 2.5.dp strokeWidth = 2.5.dp
) )
if (description != null) { if (description != null) {
Text(description) Text(description, textAlign = TextAlign.Center)
} }
} }
} }

View File

@@ -44,10 +44,10 @@ fun DefaultTopAppBar(
} }
@Composable @Composable
fun NavigationButtonBack(onButtonClicked: (() -> Unit)?) { fun NavigationButtonBack(onButtonClicked: (() -> Unit)?, tintColor: Color = if (onButtonClicked != null) MaterialTheme.colors.primary else MaterialTheme.colors.secondary) {
IconButton(onButtonClicked ?: {}, enabled = onButtonClicked != null) { IconButton(onButtonClicked ?: {}, enabled = onButtonClicked != null) {
Icon( Icon(
painterResource(MR.images.ic_arrow_back_ios_new), stringResource(MR.strings.back), tint = if (onButtonClicked != null) MaterialTheme.colors.primary else MaterialTheme.colors.secondary painterResource(MR.images.ic_arrow_back_ios_new), stringResource(MR.strings.back), tint = tintColor
) )
} }
} }

View File

@@ -29,7 +29,7 @@ fun ModalView(
} }
Surface(Modifier.fillMaxSize(), contentColor = LocalContentColor.current) { Surface(Modifier.fillMaxSize(), contentColor = LocalContentColor.current) {
Column(if (background != MaterialTheme.colors.background) Modifier.background(background) else Modifier.themedBackground()) { Column(if (background != MaterialTheme.colors.background) Modifier.background(background) else Modifier.themedBackground()) {
CloseSheetBar(close, showClose, endButtons) CloseSheetBar(close, showClose, endButtons = endButtons)
Box(modifier) { content() } Box(modifier) { content() }
} }
} }

View File

@@ -16,7 +16,7 @@ class ProcessedErrors <T: AgentErrorType>(val interval: Long) {
fun newError(error: T, offerRestart: Boolean) { fun newError(error: T, offerRestart: Boolean) {
timer.cancel() timer.cancel()
timer = withLongRunningApi(slow = 70_000, deadlock = 130_000) { timer = withLongRunningApi(slow = 130_000) {
val delayBeforeNext = (lastShownTimestamp + interval) - System.currentTimeMillis() val delayBeforeNext = (lastShownTimestamp + interval) - System.currentTimeMillis()
if ((lastShownOfferRestart || !offerRestart) && delayBeforeNext >= 0) { if ((lastShownOfferRestart || !offerRestart) && delayBeforeNext >= 0) {
delay(delayBeforeNext) delay(delayBeforeNext)

View File

@@ -37,30 +37,22 @@ fun withBGApi(action: suspend CoroutineScope.() -> Unit): Job =
CoroutineScope(singleThreadDispatcher).launch(block = { wrapWithLogging(action, it) }) CoroutineScope(singleThreadDispatcher).launch(block = { wrapWithLogging(action, it) })
} }
fun withLongRunningApi(slow: Long = Long.MAX_VALUE, deadlock: Long = Long.MAX_VALUE, action: suspend CoroutineScope.() -> Unit): Job = fun withLongRunningApi(slow: Long = Long.MAX_VALUE, action: suspend CoroutineScope.() -> Unit): Job =
Exception().let { Exception().let {
CoroutineScope(Dispatchers.Default).launch(block = { wrapWithLogging(action, it, slow = slow, deadlock = deadlock) }) CoroutineScope(Dispatchers.Default).launch(block = { wrapWithLogging(action, it, slow = slow) })
} }
private suspend fun wrapWithLogging(action: suspend CoroutineScope.() -> Unit, exception: java.lang.Exception, slow: Long = 10_000, deadlock: Long = 60_000) = coroutineScope { private suspend fun wrapWithLogging(action: suspend CoroutineScope.() -> Unit, exception: java.lang.Exception, slow: Long = 20_000) = coroutineScope {
val start = System.currentTimeMillis() val start = System.currentTimeMillis()
val job = launch {
delay(deadlock)
Log.e(TAG, "Possible deadlock of the thread, not finished after ${deadlock / 1000}s:\n${exception.stackTraceToString()}")
AlertManager.shared.showAlertMsg(
title = generalGetString(MR.strings.possible_deadlock_title),
text = generalGetString(MR.strings.possible_deadlock_desc).format(deadlock / 1000, exception.stackTraceToString()),
)
}
action() action()
job.cancel() val end = System.currentTimeMillis()
if (appPreferences.developerTools.get() && appPreferences.showSlowApiCalls.get()) { if (end - start > slow) {
val end = System.currentTimeMillis() Log.e(TAG, "Possible problem with execution of the thread, took ${(end - start) / 1000}s:\n${exception.stackTraceToString()}")
if (end - start > slow) { if (appPreferences.developerTools.get() && appPreferences.showSlowApiCalls.get()) {
Log.e(TAG, "Possible problem with execution of the thread, took ${(end - start) / 1000}s:\n${exception.stackTraceToString()}")
AlertManager.shared.showAlertMsg( AlertManager.shared.showAlertMsg(
title = generalGetString(MR.strings.possible_slow_function_title), title = generalGetString(MR.strings.possible_slow_function_title),
text = generalGetString(MR.strings.possible_slow_function_desc).format((end - start) / 1000, exception.stackTraceToString()), text = generalGetString(MR.strings.possible_slow_function_desc).format((end - start) / 1000, exception.stackTraceToString()),
shareText = true
) )
} }
} }
@@ -419,7 +411,7 @@ expect fun ByteArray.toBase64StringForPassphrase(): String
// Android's default implementation that was used before multiplatform, adds non-needed characters at the end of string // Android's default implementation that was used before multiplatform, adds non-needed characters at the end of string
// which can be bypassed by: // which can be bypassed by:
// fun String.toByteArrayFromBase64(): ByteArray = Base64.getDecoder().decode(this.trimEnd { it == '\n' || it == ' ' }) // fun String.toByteArrayFromBase64(): ByteArray = Base64.getMimeDecoder().decode(this.trimEnd { it == '\n' || it == ' ' })
expect fun String.toByteArrayFromBase64ForPassphrase(): ByteArray expect fun String.toByteArrayFromBase64ForPassphrase(): ByteArray
val LongRange.Companion.saver val LongRange.Companion.saver

View File

@@ -49,7 +49,7 @@ fun LocalAuthView(m: ChatModel, authRequest: LocalAuthRequest) {
} }
private fun deleteStorageAndRestart(m: ChatModel, password: String, completed: (LAResult) -> Unit) { private fun deleteStorageAndRestart(m: ChatModel, password: String, completed: (LAResult) -> Unit) {
withLongRunningApi(slow = 30_000, deadlock = 60_000) { withLongRunningApi {
try { try {
/** Waiting until [initChatController] finishes */ /** Waiting until [initChatController] finishes */
while (m.ctrlInitInProgress.value) { while (m.ctrlInitInProgress.value) {

View File

@@ -50,7 +50,7 @@ fun SetupDatabasePassphrase(m: ChatModel) {
confirmNewKey, confirmNewKey,
progressIndicator, progressIndicator,
onConfirmEncrypt = { onConfirmEncrypt = {
withLongRunningApi(slow = 30_000, deadlock = 60_000) { withLongRunningApi {
if (m.chatRunning.value == true) { if (m.chatRunning.value == true) {
// Stop chat if it's started before doing anything // Stop chat if it's started before doing anything
stopChatAsync(m) stopChatAsync(m)

View File

@@ -96,7 +96,7 @@ fun PrivacySettingsView(
val currentUser = chatModel.currentUser.value val currentUser = chatModel.currentUser.value
if (currentUser != null) { if (currentUser != null) {
fun setSendReceiptsContacts(enable: Boolean, clearOverrides: Boolean) { fun setSendReceiptsContacts(enable: Boolean, clearOverrides: Boolean) {
withLongRunningApi(slow = 30_000, deadlock = 60_000) { withLongRunningApi(slow = 60_000) {
val mrs = UserMsgReceiptSettings(enable, clearOverrides) val mrs = UserMsgReceiptSettings(enable, clearOverrides)
chatModel.controller.apiSetUserContactReceipts(currentUser, mrs) chatModel.controller.apiSetUserContactReceipts(currentUser, mrs)
chatModel.controller.appPrefs.privacyDeliveryReceiptsSet.set(true) chatModel.controller.appPrefs.privacyDeliveryReceiptsSet.set(true)
@@ -119,7 +119,7 @@ fun PrivacySettingsView(
} }
fun setSendReceiptsGroups(enable: Boolean, clearOverrides: Boolean) { fun setSendReceiptsGroups(enable: Boolean, clearOverrides: Boolean) {
withLongRunningApi(slow = 30_000, deadlock = 60_000) { withLongRunningApi(slow = 60_000) {
val mrs = UserMsgReceiptSettings(enable, clearOverrides) val mrs = UserMsgReceiptSettings(enable, clearOverrides)
chatModel.controller.apiSetUserGroupReceipts(currentUser, mrs) chatModel.controller.apiSetUserGroupReceipts(currentUser, mrs)
chatModel.controller.appPrefs.privacyDeliveryReceiptsSet.set(true) chatModel.controller.appPrefs.privacyDeliveryReceiptsSet.set(true)

View File

@@ -1588,8 +1588,6 @@
<string name="remote_ctrl_error_busy">سطح المكتب مشغول</string> <string name="remote_ctrl_error_busy">سطح المكتب مشغول</string>
<string name="remote_ctrl_error_bad_version">يحتوي سطح المكتب على إصدار غير مدعوم. يُرجى التأكد من استخدام نفس الإصدار على كلا الجهازين</string> <string name="remote_ctrl_error_bad_version">يحتوي سطح المكتب على إصدار غير مدعوم. يُرجى التأكد من استخدام نفس الإصدار على كلا الجهازين</string>
<string name="past_member_vName">العضو السابق %1$s</string> <string name="past_member_vName">العضو السابق %1$s</string>
<string name="possible_deadlock_title">مأزق</string>
<string name="possible_deadlock_desc">يستغرق تنفيذ التعليمات البرمجية وقتًا طويلاً جدًا: %1$d ثانية. من المحتمل أن التطبيق مجمّد: %2$s</string>
<string name="possible_slow_function_title">وظيفة بطيئة</string> <string name="possible_slow_function_title">وظيفة بطيئة</string>
<string name="developer_options_section">خيارات المطور</string> <string name="developer_options_section">خيارات المطور</string>
<string name="profile_update_event_member_name_changed">تغيّر العضو %1$s إلى %2$s</string> <string name="profile_update_event_member_name_changed">تغيّر العضو %1$s إلى %2$s</string>

View File

@@ -16,6 +16,7 @@
<!-- MainActivity.kt --> <!-- MainActivity.kt -->
<string name="opening_database">Opening database…</string> <string name="opening_database">Opening database…</string>
<string name="database_migration_in_progress">Database migration is in progress.\nIt may take a few minutes.</string>
<string name="non_content_uri_alert_title">Invalid file path</string> <string name="non_content_uri_alert_title">Invalid file path</string>
<string name="non_content_uri_alert_text">You shared an invalid file path. Report the issue to the app developers.</string> <string name="non_content_uri_alert_text">You shared an invalid file path. Report the issue to the app developers.</string>
<string name="app_was_crashed">View crashed</string> <string name="app_was_crashed">View crashed</string>
@@ -146,8 +147,6 @@
<string name="smp_server_test_delete_file">Delete file</string> <string name="smp_server_test_delete_file">Delete file</string>
<string name="error_deleting_user">Error deleting user profile</string> <string name="error_deleting_user">Error deleting user profile</string>
<string name="error_updating_user_privacy">Error updating user privacy</string> <string name="error_updating_user_privacy">Error updating user privacy</string>
<string name="possible_deadlock_title">Deadlock</string>
<string name="possible_deadlock_desc">Execution of code takes too long time: %1$d seconds. Probably, the app is frozen: %2$s</string>
<string name="possible_slow_function_title">Slow function</string> <string name="possible_slow_function_title">Slow function</string>
<string name="possible_slow_function_desc">Execution of function takes too long time: %1$d seconds: %2$s</string> <string name="possible_slow_function_desc">Execution of function takes too long time: %1$d seconds: %2$s</string>
@@ -178,6 +177,9 @@
<!-- SimpleX Chat foreground Service --> <!-- SimpleX Chat foreground Service -->
<string name="simplex_service_notification_title">SimpleX Chat service</string> <string name="simplex_service_notification_title">SimpleX Chat service</string>
<string name="simplex_service_notification_text">Receiving messages…</string> <string name="simplex_service_notification_text">Receiving messages…</string>
<string name="call_service_notification_audio_call">Audio call</string>
<string name="call_service_notification_video_call">Video call</string>
<string name="call_service_notification_end_call">End call</string>
<string name="hide_notification">Hide</string> <string name="hide_notification">Hide</string>
<!-- Notification channels --> <!-- Notification channels -->
@@ -802,6 +804,10 @@
<string name="callstate_connected">connected</string> <string name="callstate_connected">connected</string>
<string name="callstate_ended">ended</string> <string name="callstate_ended">ended</string>
<!-- CallView -->
<string name="unable_to_open_browser_title">Error opening browser</string>
<string name="unable_to_open_browser_desc">The default web browser is required for calls. Please configure the default browser in the system, and share more information with the developers.</string>
<!-- SimpleXInfo --> <!-- SimpleXInfo -->
<string name="next_generation_of_private_messaging">The next generation of private messaging</string> <string name="next_generation_of_private_messaging">The next generation of private messaging</string>
<string name="privacy_redefined">Privacy redefined</string> <string name="privacy_redefined">Privacy redefined</string>

View File

@@ -1555,7 +1555,6 @@
<string name="chat_is_stopped_you_should_transfer_database">Чатът е спрян. Ако вече сте използвали тази база данни на друго устройство, трябва да я прехвърлите обратно, преди да стартирате чата отново.</string> <string name="chat_is_stopped_you_should_transfer_database">Чатът е спрян. Ако вече сте използвали тази база данни на друго устройство, трябва да я прехвърлите обратно, преди да стартирате чата отново.</string>
<string name="remote_ctrl_error_bad_invitation">Настолното устройство има грешен код за връзка</string> <string name="remote_ctrl_error_bad_invitation">Настолното устройство има грешен код за връзка</string>
<string name="remote_ctrl_error_bad_version">Настолното устройство е с неподдържана версия. Моля, уверете се, че използвате една и съща версия и на двете устройства</string> <string name="remote_ctrl_error_bad_version">Настолното устройство е с неподдържана версия. Моля, уверете се, че използвате една и съща версия и на двете устройства</string>
<string name="possible_deadlock_desc">Изпълнението на кода отнема твърде много време: %1$d секунди. Вероятно приложението е замразено: %2$s</string>
<string name="possible_slow_function_title">Бавна функция</string> <string name="possible_slow_function_title">Бавна функция</string>
<string name="possible_slow_function_desc">Изпълнението на функцията отнема твърде много време: %1$d секунди: %2$s</string> <string name="possible_slow_function_desc">Изпълнението на функцията отнема твърде много време: %1$d секунди: %2$s</string>
<string name="show_internal_errors">Покажи вътрешните грешки</string> <string name="show_internal_errors">Покажи вътрешните грешки</string>
@@ -1591,5 +1590,4 @@
\nПрепоръчително е да рестартирате приложението.</string> \nПрепоръчително е да рестартирате приложението.</string>
<string name="developer_options_section">Опции за разработчици</string> <string name="developer_options_section">Опции за разработчици</string>
<string name="show_slow_api_calls">Показване на бавни API заявки</string> <string name="show_slow_api_calls">Показване на бавни API заявки</string>
<string name="possible_deadlock_title">Грешка в заключено положение</string>
</resources> </resources>

View File

@@ -1672,9 +1672,7 @@
<string name="possible_slow_function_title">Langsame Funktion</string> <string name="possible_slow_function_title">Langsame Funktion</string>
<string name="show_slow_api_calls">Zeige langsame API-Aufrufe an</string> <string name="show_slow_api_calls">Zeige langsame API-Aufrufe an</string>
<string name="group_member_status_unknown_short">unbekannt</string> <string name="group_member_status_unknown_short">unbekannt</string>
<string name="possible_deadlock_title">Blockade</string>
<string name="developer_options_section">Optionen für Entwickler</string> <string name="developer_options_section">Optionen für Entwickler</string>
<string name="possible_deadlock_desc">Die Code-Ausführung dauert zu lange: %1$d Sekunden. Wahrscheinlich ist die App eingefroren: %2$s</string>
<string name="group_member_status_unknown">unbekannter Gruppenmitglieds-Status</string> <string name="group_member_status_unknown">unbekannter Gruppenmitglieds-Status</string>
<string name="v5_5_private_notes_descr">Mit verschlüsselten Dateien und Medien.</string> <string name="v5_5_private_notes_descr">Mit verschlüsselten Dateien und Medien.</string>
<string name="v5_5_private_notes">Private Notizen</string> <string name="v5_5_private_notes">Private Notizen</string>

View File

@@ -1559,11 +1559,9 @@
<string name="remote_host_error_bad_state"><![CDATA[État médiocre de la connexion au mobile <b>%s</b>.]]></string> <string name="remote_host_error_bad_state"><![CDATA[État médiocre de la connexion au mobile <b>%s</b>.]]></string>
<string name="remote_ctrl_was_disconnected_title">Connexion interrompue</string> <string name="remote_ctrl_was_disconnected_title">Connexion interrompue</string>
<string name="remote_ctrl_error_bad_state">État médiocre de la connexion avec le bureau</string> <string name="remote_ctrl_error_bad_state">État médiocre de la connexion avec le bureau</string>
<string name="possible_deadlock_title">Impasse</string>
<string name="remote_ctrl_error_bad_version">La version de l\'ordinateur de bureau n\'est pas prise en charge. Veillez à utiliser la même version sur les deux appareils.</string> <string name="remote_ctrl_error_bad_version">La version de l\'ordinateur de bureau n\'est pas prise en charge. Veillez à utiliser la même version sur les deux appareils.</string>
<string name="remote_ctrl_error_disconnected">Le bureau a été déconnecté</string> <string name="remote_ctrl_error_disconnected">Le bureau a été déconnecté</string>
<string name="developer_options_section">Options pour les développeurs</string> <string name="developer_options_section">Options pour les développeurs</string>
<string name="possible_deadlock_desc">Le code prend trop de temps à s\'exécuter: %1$d secondes. Il est probable que l\'application soit figée: %2$s</string>
<string name="agent_internal_error_title">Erreur interne</string> <string name="agent_internal_error_title">Erreur interne</string>
<string name="remote_host_error_bad_version"><![CDATA[La version du mobile <b>%s</b> n\'est pas prise en charge. Veillez à utiliser la même version sur les deux appareils.]]></string> <string name="remote_host_error_bad_version"><![CDATA[La version du mobile <b>%s</b> n\'est pas prise en charge. Veillez à utiliser la même version sur les deux appareils.]]></string>
<string name="show_internal_errors">Afficher les erreurs internes</string> <string name="show_internal_errors">Afficher les erreurs internes</string>

View File

@@ -1583,9 +1583,7 @@
<string name="possible_slow_function_title">Lassú funkció</string> <string name="possible_slow_function_title">Lassú funkció</string>
<string name="show_slow_api_calls">Lassú API-hívások megjelenítése</string> <string name="show_slow_api_calls">Lassú API-hívások megjelenítése</string>
<string name="remote_host_error_inactive"><![CDATA[A(z) <b>%s</b> mobil eszköz inaktív]]></string> <string name="remote_host_error_inactive"><![CDATA[A(z) <b>%s</b> mobil eszköz inaktív]]></string>
<string name="possible_deadlock_title">Elakadt</string>
<string name="developer_options_section">Fejlesztői beállítások</string> <string name="developer_options_section">Fejlesztői beállítások</string>
<string name="possible_deadlock_desc">A kód végrehajtása túl sokáig tart: %1$d másodperc. Valószínűleg az alkalmazás lefagyott: %2$s</string>
<string name="possible_slow_function_desc">A funkció végrehajtása túl sokáig tart: %1$d másodperc: %2$s</string> <string name="possible_slow_function_desc">A funkció végrehajtása túl sokáig tart: %1$d másodperc: %2$s</string>
<string name="remote_host_error_busy"><![CDATA[A(z) <b>%s</b> mobil eszköz elfoglalt]]></string> <string name="remote_host_error_busy"><![CDATA[A(z) <b>%s</b> mobil eszköz elfoglalt]]></string>
<string name="past_member_vName">Legutóbbi tag %1$s</string> <string name="past_member_vName">Legutóbbi tag %1$s</string>

View File

@@ -1591,9 +1591,7 @@
<string name="possible_slow_function_title">Funzione lenta</string> <string name="possible_slow_function_title">Funzione lenta</string>
<string name="show_slow_api_calls">Mostra chiamate API lente</string> <string name="show_slow_api_calls">Mostra chiamate API lente</string>
<string name="group_member_status_unknown_short">sconosciuto</string> <string name="group_member_status_unknown_short">sconosciuto</string>
<string name="possible_deadlock_desc">L\'esecuzione del codice impiega troppo tempo: %1$d secondi. Probabilmente l\'app è congelata: %2$s</string>
<string name="group_member_status_unknown">stato sconosciuto</string> <string name="group_member_status_unknown">stato sconosciuto</string>
<string name="possible_deadlock_title">Stallo</string>
<string name="developer_options_section">Opzioni sviluppatore</string> <string name="developer_options_section">Opzioni sviluppatore</string>
<string name="v5_5_private_notes">Note private</string> <string name="v5_5_private_notes">Note private</string>
<string name="v5_5_new_interface_languages">Interfaccia in ungherese e turco</string> <string name="v5_5_new_interface_languages">Interfaccia in ungherese e turco</string>

View File

@@ -1571,9 +1571,7 @@
<string name="remote_ctrl_error_busy">PC版が処理中</string> <string name="remote_ctrl_error_busy">PC版が処理中</string>
<string name="remote_ctrl_error_disconnected">PC版が切断されました</string> <string name="remote_ctrl_error_disconnected">PC版が切断されました</string>
<string name="remote_ctrl_error_bad_version">ご利用のPC版のバージョンがサポートされてません。両端末が同じバージョンかどうか、ご確認ください。</string> <string name="remote_ctrl_error_bad_version">ご利用のPC版のバージョンがサポートされてません。両端末が同じバージョンかどうか、ご確認ください。</string>
<string name="possible_deadlock_title">デッドロック状態</string>
<string name="developer_options_section">開発者向けの設定</string> <string name="developer_options_section">開発者向けの設定</string>
<string name="possible_deadlock_desc">処理時間が異常にかかるようです: %1$d 秒。アプリが固まった恐れがあります: %2$s</string>
<string name="remote_host_error_busy"><![CDATA[携帯版 <b>%s</b> がただいま処理中]]></string> <string name="remote_host_error_busy"><![CDATA[携帯版 <b>%s</b> がただいま処理中]]></string>
<string name="possible_slow_function_desc">機能の処理時間が以上にかかってます: %1$d 秒: %2$s</string> <string name="possible_slow_function_desc">機能の処理時間が以上にかかってます: %1$d 秒: %2$s</string>
<string name="show_internal_errors">内部エラーを表示</string> <string name="show_internal_errors">内部エラーを表示</string>

View File

@@ -1574,7 +1574,6 @@
<string name="remote_host_error_missing"><![CDATA[Mobiel <b>%s</b> ontbreekt]]></string> <string name="remote_host_error_missing"><![CDATA[Mobiel <b>%s</b> ontbreekt]]></string>
<string name="remote_host_error_bad_state"><![CDATA[De verbinding met de mobiel <b>%s</b> is in slechte staat]]></string> <string name="remote_host_error_bad_state"><![CDATA[De verbinding met de mobiel <b>%s</b> is in slechte staat]]></string>
<string name="remote_ctrl_error_disconnected">De verbinding met desktop is verbroken</string> <string name="remote_ctrl_error_disconnected">De verbinding met desktop is verbroken</string>
<string name="possible_deadlock_title">Impasse</string>
<string name="possible_slow_function_desc">Uitvoering van functie duurt te lang: %1$d seconden: %2$s</string> <string name="possible_slow_function_desc">Uitvoering van functie duurt te lang: %1$d seconden: %2$s</string>
<string name="possible_slow_function_title">Langzame functie</string> <string name="possible_slow_function_title">Langzame functie</string>
<string name="developer_options_section">Ontwikkelaars opties</string> <string name="developer_options_section">Ontwikkelaars opties</string>
@@ -1588,7 +1587,6 @@
<string name="restart_chat_button">Chat opnieuw starten</string> <string name="restart_chat_button">Chat opnieuw starten</string>
<string name="remote_host_error_timeout"><![CDATA[Time-out bereikt tijdens het verbinden met de mobiel <b>%s</b>]]></string> <string name="remote_host_error_timeout"><![CDATA[Time-out bereikt tijdens het verbinden met de mobiel <b>%s</b>]]></string>
<string name="remote_ctrl_error_bad_state">De verbinding met de desktop is in slechte staat</string> <string name="remote_ctrl_error_bad_state">De verbinding met de desktop is in slechte staat</string>
<string name="possible_deadlock_desc">Het uitvoeren van de code duurt te lang: %1$d seconden. Waarschijnlijk is de app vastgelopen: %2$s</string>
<string name="remote_ctrl_error_bad_invitation">Desktop heeft verkeerde uitnodigingscode</string> <string name="remote_ctrl_error_bad_invitation">Desktop heeft verkeerde uitnodigingscode</string>
<string name="remote_host_error_bad_version"><![CDATA[Mobiel <b>%s</b> heeft een niet-ondersteunde versie. Zorg ervoor dat u op beide apparaten dezelfde versie gebruikt]]></string> <string name="remote_host_error_bad_version"><![CDATA[Mobiel <b>%s</b> heeft een niet-ondersteunde versie. Zorg ervoor dat u op beide apparaten dezelfde versie gebruikt]]></string>
<string name="remote_ctrl_error_timeout">Time-out bereikt tijdens het verbinden met de desktop</string> <string name="remote_ctrl_error_timeout">Time-out bereikt tijdens het verbinden met de desktop</string>

View File

@@ -1606,7 +1606,6 @@
<string name="remote_ctrl_error_bad_version">Komputer ma niewspieraną wersję. Proszę upewnić się, że używasz tych samych wersji na obu urządzeniach</string> <string name="remote_ctrl_error_bad_version">Komputer ma niewspieraną wersję. Proszę upewnić się, że używasz tych samych wersji na obu urządzeniach</string>
<string name="blocked_by_admin_items_description">%d wiadomości zablokowanych przez admina</string> <string name="blocked_by_admin_items_description">%d wiadomości zablokowanych przez admina</string>
<string name="error_creating_message">Błąd tworzenia wiadomości</string> <string name="error_creating_message">Błąd tworzenia wiadomości</string>
<string name="possible_deadlock_desc">Wykonanie kodu zajmuje za dużo czasu: %1$d sekund. Prawdopodobnie aplikacja jest zamrożona: %2$s</string>
<string name="possible_slow_function_desc">Wykonanie kodu zajmuje za dużo czasu: %1$d sekund: %2$s</string> <string name="possible_slow_function_desc">Wykonanie kodu zajmuje za dużo czasu: %1$d sekund: %2$s</string>
<string name="note_folder_local_display_name">Prywatne notatki</string> <string name="note_folder_local_display_name">Prywatne notatki</string>
<string name="group_member_status_unknown">nieznany status</string> <string name="group_member_status_unknown">nieznany status</string>
@@ -1621,7 +1620,6 @@
<string name="remote_host_error_inactive"><![CDATA[Telefon <b>%s</b> jest nieaktywny]]></string> <string name="remote_host_error_inactive"><![CDATA[Telefon <b>%s</b> jest nieaktywny]]></string>
<string name="remote_host_error_bad_version"><![CDATA[Telefon <b>%s</b> ma niewspieraną wersję. Proszę, upewnij się, że używasz tej samej wersji na obydwu urządzeniach]]></string> <string name="remote_host_error_bad_version"><![CDATA[Telefon <b>%s</b> ma niewspieraną wersję. Proszę, upewnij się, że używasz tej samej wersji na obydwu urządzeniach]]></string>
<string name="group_member_status_unknown_short">nieznany</string> <string name="group_member_status_unknown_short">nieznany</string>
<string name="possible_deadlock_title">Blokada</string>
<string name="profile_update_event_contact_name_changed">kontakt %1$s zmieniony na %2$s</string> <string name="profile_update_event_contact_name_changed">kontakt %1$s zmieniony na %2$s</string>
<string name="profile_update_event_removed_address">usunięto adres kontaktu</string> <string name="profile_update_event_removed_address">usunięto adres kontaktu</string>
<string name="profile_update_event_removed_picture">usunięto zdjęcie profilu</string> <string name="profile_update_event_removed_picture">usunięto zdjęcie profilu</string>

View File

@@ -1680,8 +1680,6 @@
<string name="error_showing_message">ошибка отображения сообщения</string> <string name="error_showing_message">ошибка отображения сообщения</string>
<string name="error_showing_content">ошибка отображения содержания</string> <string name="error_showing_content">ошибка отображения содержания</string>
<string name="remote_ctrl_disconnected_with_reason">Отсоединён по причине: %s</string> <string name="remote_ctrl_disconnected_with_reason">Отсоединён по причине: %s</string>
<string name="possible_deadlock_title">Взаимная блокировка</string>
<string name="possible_deadlock_desc">Выполнение задачи занимает долгое время: %1$d секунд. Возможно, приложение заблокировано: %2$s</string>
<string name="possible_slow_function_desc">Выполнение задачи занимает долгое время: %1$d секунд: %2$s</string> <string name="possible_slow_function_desc">Выполнение задачи занимает долгое время: %1$d секунд: %2$s</string>
<string name="possible_slow_function_title">Медленный вызов</string> <string name="possible_slow_function_title">Медленный вызов</string>
<string name="profile_update_event_contact_name_changed">контакт %1$s изменён на %2$s</string> <string name="profile_update_event_contact_name_changed">контакт %1$s изменён на %2$s</string>

View File

@@ -1586,8 +1586,6 @@
<string name="remote_host_error_bad_state"><![CDATA[到移动主机 <b>%s</b>的连接状态不佳]]></string> <string name="remote_host_error_bad_state"><![CDATA[到移动主机 <b>%s</b>的连接状态不佳]]></string>
<string name="remote_host_error_timeout"><![CDATA[连接到移动主机<b>%s</b>时超时]]></string> <string name="remote_host_error_timeout"><![CDATA[连接到移动主机<b>%s</b>时超时]]></string>
<string name="failed_to_create_user_invalid_desc">显示名无效。请另选一个名称。</string> <string name="failed_to_create_user_invalid_desc">显示名无效。请另选一个名称。</string>
<string name="possible_deadlock_title">死锁</string>
<string name="possible_deadlock_desc">代码执行花费的时间过久:%1$d秒。应用可能卡住了%2$s</string>
<string name="possible_slow_function_title">慢函数</string> <string name="possible_slow_function_title">慢函数</string>
<string name="show_slow_api_calls">显示缓慢的 API 调用</string> <string name="show_slow_api_calls">显示缓慢的 API 调用</string>
<string name="past_member_vName">过往成员 %1$s</string> <string name="past_member_vName">过往成员 %1$s</string>

View File

@@ -8,6 +8,7 @@
<body> <body>
<video <video
id="remote-video-stream" id="remote-video-stream"
class="inline"
autoplay autoplay
playsinline playsinline
poster="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAQAAAAnOwc2AAAAEUlEQVR42mNk+M+AARiHsiAAcCIKAYwFoQ8AAAAASUVORK5CYII=" poster="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAQAAAAnOwc2AAAAEUlEQVR42mNk+M+AARiHsiAAcCIKAYwFoQ8AAAAASUVORK5CYII="
@@ -15,6 +16,7 @@
></video> ></video>
<video <video
id="local-video-stream" id="local-video-stream"
class="inline"
muted muted
autoplay autoplay
playsinline playsinline

View File

@@ -5,14 +5,14 @@ body {
background-color: black; background-color: black;
} }
#remote-video-stream { #remote-video-stream.inline {
position: absolute; position: absolute;
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: cover; object-fit: cover;
} }
#local-video-stream { #local-video-stream.inline {
position: absolute; position: absolute;
width: 30%; width: 30%;
max-width: 30%; max-width: 30%;
@@ -23,6 +23,20 @@ body {
right: 0; right: 0;
} }
#remote-video-stream.fullscreen {
position: absolute;
height: 100%;
width: 100%;
object-fit: cover;
}
#local-video-stream.fullscreen {
position: absolute;
height: 100%;
width: 100%;
object-fit: cover;
}
*::-webkit-media-controls { *::-webkit-media-controls {
display: none !important; display: none !important;
-webkit-appearance: none !important; -webkit-appearance: none !important;

View File

@@ -11,6 +11,12 @@ var VideoCamera;
VideoCamera["User"] = "user"; VideoCamera["User"] = "user";
VideoCamera["Environment"] = "environment"; VideoCamera["Environment"] = "environment";
})(VideoCamera || (VideoCamera = {})); })(VideoCamera || (VideoCamera = {}));
var LayoutType;
(function (LayoutType) {
LayoutType["Default"] = "default";
LayoutType["LocalVideo"] = "localVideo";
LayoutType["RemoteVideo"] = "remoteVideo";
})(LayoutType || (LayoutType = {}));
// for debugging // for debugging
// var sendMessageToNative = ({resp}: WVApiMessage) => console.log(JSON.stringify({command: resp})) // var sendMessageToNative = ({resp}: WVApiMessage) => console.log(JSON.stringify({command: resp}))
var sendMessageToNative = (msg) => console.log(JSON.stringify(msg)); var sendMessageToNative = (msg) => console.log(JSON.stringify(msg));
@@ -319,6 +325,10 @@ const processCommand = (function () {
localizedDescription = command.description; localizedDescription = command.description;
resp = { type: "ok" }; resp = { type: "ok" };
break; break;
case "layout":
changeLayout(command.layout);
resp = { type: "ok" };
break;
case "end": case "end":
endCall(); endCall();
resp = { type: "ok" }; resp = { type: "ok" };
@@ -607,6 +617,28 @@ function toggleMedia(s, media) {
} }
return res; return res;
} }
function changeLayout(layout) {
const local = document.getElementById("local-video-stream");
const remote = document.getElementById("remote-video-stream");
switch (layout) {
case LayoutType.Default:
local.className = "inline";
remote.className = "inline";
local.style.visibility = "visible";
remote.style.visibility = "visible";
break;
case LayoutType.LocalVideo:
local.className = "fullscreen";
local.style.visibility = "visible";
remote.style.visibility = "hidden";
break;
case LayoutType.RemoteVideo:
remote.className = "fullscreen";
local.style.visibility = "hidden";
remote.style.visibility = "visible";
break;
}
}
// Cryptography function - it is loaded both in the main window and in worker context (if the worker is used) // Cryptography function - it is loaded both in the main window and in worker context (if the worker is used)
function callCryptoFunction() { function callCryptoFunction() {
const initialPlainTextRequired = { const initialPlainTextRequired = {

View File

@@ -9,6 +9,7 @@
<body> <body>
<video <video
id="remote-video-stream" id="remote-video-stream"
class="inline"
autoplay autoplay
playsinline playsinline
poster="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAQAAAAnOwc2AAAAEUlEQVR42mNk+M+AARiHsiAAcCIKAYwFoQ8AAAAASUVORK5CYII=" poster="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAQAAAAnOwc2AAAAEUlEQVR42mNk+M+AARiHsiAAcCIKAYwFoQ8AAAAASUVORK5CYII="
@@ -16,6 +17,7 @@
></video> ></video>
<video <video
id="local-video-stream" id="local-video-stream"
class="inline"
muted muted
autoplay autoplay
playsinline playsinline

View File

@@ -5,14 +5,14 @@ body {
background-color: black; background-color: black;
} }
#remote-video-stream { #remote-video-stream.inline {
position: absolute; position: absolute;
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: cover; object-fit: cover;
} }
#local-video-stream { #local-video-stream.inline {
position: absolute; position: absolute;
width: 20%; width: 20%;
max-width: 20%; max-width: 20%;
@@ -23,6 +23,20 @@ body {
right: 0; right: 0;
} }
#remote-video-stream.fullscreen {
position: absolute;
height: 100%;
width: 100%;
object-fit: cover;
}
#local-video-stream.fullscreen {
position: absolute;
height: 100%;
width: 100%;
object-fit: cover;
}
*::-webkit-media-controls { *::-webkit-media-controls {
display: none !important; display: none !important;
-webkit-appearance: none !important; -webkit-appearance: none !important;

View File

@@ -14,8 +14,7 @@ import androidx.compose.ui.input.key.*
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.* import androidx.compose.ui.window.*
import chat.simplex.common.model.ChatController import chat.simplex.common.model.*
import chat.simplex.common.model.ChatModel
import chat.simplex.common.platform.* import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.DEFAULT_START_MODAL_WIDTH import chat.simplex.common.ui.theme.DEFAULT_START_MODAL_WIDTH
import chat.simplex.common.ui.theme.SimpleXTheme import chat.simplex.common.ui.theme.SimpleXTheme
@@ -40,7 +39,8 @@ fun showApp() {
WindowExceptionHandler { e -> WindowExceptionHandler { e ->
AlertManager.shared.showAlertMsg( AlertManager.shared.showAlertMsg(
title = generalGetString(MR.strings.app_was_crashed), title = generalGetString(MR.strings.app_was_crashed),
text = e.stackTraceToString() text = e.stackTraceToString(),
shareText = true
) )
Log.e(TAG, "App crashed, thread name: " + Thread.currentThread().name + ", exception: " + e.stackTraceToString()) Log.e(TAG, "App crashed, thread name: " + Thread.currentThread().name + ", exception: " + e.stackTraceToString())
window.dispatchEvent(WindowEvent(window, WindowEvent.WINDOW_CLOSING)) window.dispatchEvent(WindowEvent(window, WindowEvent.WINDOW_CLOSING))

View File

@@ -17,14 +17,14 @@ import javax.imageio.stream.MemoryCacheImageOutputStream
import kotlin.math.sqrt import kotlin.math.sqrt
private fun errorBitmap(): ImageBitmap = private fun errorBitmap(): ImageBitmap =
ImageIO.read(ByteArrayInputStream(Base64.getDecoder().decode("iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAAXNSR0IArs4c6QAAAKVJREFUeF7t1kENACEUQ0FQhnVQ9lfGO+xggITQdvbMzArPey+8fa3tAfwAEdABZQspQStgBssEcgAIkSAJkiAJljtEgiRIgmUCSZAESZAESZAEyx0iQRIkwTKBJEiCv5fgvTd1wDmn7QAP4AeIgA4oW0gJWgEzWCZwbQ7gAA7ggLKFOIADOKBMIAeAEAmSIAmSYLlDJEiCJFgmkARJkARJ8N8S/ADTZUewBvnTOQAAAABJRU5ErkJggg=="))).toComposeImageBitmap() ImageIO.read(ByteArrayInputStream(Base64.getMimeDecoder().decode("iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAAXNSR0IArs4c6QAAAKVJREFUeF7t1kENACEUQ0FQhnVQ9lfGO+xggITQdvbMzArPey+8fa3tAfwAEdABZQspQStgBssEcgAIkSAJkiAJljtEgiRIgmUCSZAESZAESZAEyx0iQRIkwTKBJEiCv5fgvTd1wDmn7QAP4AeIgA4oW0gJWgEzWCZwbQ7gAA7ggLKFOIADOKBMIAeAEAmSIAmSYLlDJEiCJFgmkARJkARJ8N8S/ADTZUewBvnTOQAAAABJRU5ErkJggg=="))).toComposeImageBitmap()
actual fun base64ToBitmap(base64ImageString: String): ImageBitmap { actual fun base64ToBitmap(base64ImageString: String): ImageBitmap {
val imageString = base64ImageString val imageString = base64ImageString
.removePrefix("data:image/png;base64,") .removePrefix("data:image/png;base64,")
.removePrefix("data:image/jpg;base64,") .removePrefix("data:image/jpg;base64,")
return try { return try {
ImageIO.read(ByteArrayInputStream(Base64.getDecoder().decode(imageString))).toComposeImageBitmap() ImageIO.read(ByteArrayInputStream(Base64.getMimeDecoder().decode(imageString))).toComposeImageBitmap()
} catch (e: IOException) { } catch (e: IOException) {
Log.e(TAG, "base64ToBitmap error: $e") Log.e(TAG, "base64ToBitmap error: $e")
errorBitmap() errorBitmap()
@@ -77,7 +77,7 @@ actual fun compressImageStr(bitmap: ImageBitmap): String {
return try { return try {
val encoded = Base64.getEncoder().encodeToString(compressImageData(bitmap, usePng).toByteArray()) val encoded = Base64.getEncoder().encodeToString(compressImageData(bitmap, usePng).toByteArray())
"data:image/$ext;base64,$encoded" "data:image/$ext;base64,$encoded"
} catch (e: IOException) { } catch (e: Exception) {
Log.e(TAG, "resizeImageToStrSize error: $e") Log.e(TAG, "resizeImageToStrSize error: $e")
throw e throw e
} }

View File

@@ -146,8 +146,21 @@ private fun SendStateUpdates() {
@Composable @Composable
fun WebRTCController(callCommand: SnapshotStateList<WCallCommand>, onResponse: (WVAPIMessage) -> Unit) { fun WebRTCController(callCommand: SnapshotStateList<WCallCommand>, onResponse: (WVAPIMessage) -> Unit) {
val uriHandler = LocalUriHandler.current val uriHandler = LocalUriHandler.current
val endCall = {
val call = chatModel.activeCall.value
if (call != null) withBGApi { chatModel.callManager.endCall(call) }
}
val server = remember { val server = remember {
uriHandler.openUri("http://${SERVER_HOST}:$SERVER_PORT/simplex/call/") try {
uriHandler.openUri("http://${SERVER_HOST}:$SERVER_PORT/simplex/call/")
} catch (e: Exception) {
Log.e(TAG, "Unable to open browser: ${e.stackTraceToString()}")
AlertManager.shared.showAlertMsg(
title = generalGetString(MR.strings.unable_to_open_browser_title),
text = generalGetString(MR.strings.unable_to_open_browser_desc)
)
endCall()
}
startServer(onResponse) startServer(onResponse)
} }
fun processCommand(cmd: WCallCommand) { fun processCommand(cmd: WCallCommand) {

View File

@@ -42,7 +42,7 @@ actual fun SaveContentItemAction(cItem: ChatItem, saveFileLauncher: FileChooserL
} }
var fileSource = getLoadedFileSource(cItem.file) var fileSource = getLoadedFileSource(cItem.file)
if (chatModel.connectedToRemote() && fileSource == null) { if (chatModel.connectedToRemote() && fileSource == null) {
withLongRunningApi(slow = 60_000, deadlock = 600_000) { withLongRunningApi(slow = 600_000) {
cItem.file?.loadRemoteFile(true) cItem.file?.loadRemoteFile(true)
fileSource = getLoadedFileSource(cItem.file) fileSource = getLoadedFileSource(cItem.file)
saveIfExists() saveIfExists()
@@ -51,7 +51,7 @@ actual fun SaveContentItemAction(cItem: ChatItem, saveFileLauncher: FileChooserL
}) })
} }
actual fun copyItemToClipboard(cItem: ChatItem, clipboard: ClipboardManager) = withLongRunningApi(slow = 60_000, deadlock = 600_000) { actual fun copyItemToClipboard(cItem: ChatItem, clipboard: ClipboardManager) = withLongRunningApi(slow = 600_000) {
var fileSource = getLoadedFileSource(cItem.file) var fileSource = getLoadedFileSource(cItem.file)
if (chatModel.connectedToRemote() && fileSource == null) { if (chatModel.connectedToRemote() && fileSource == null) {
cItem.file?.loadRemoteFile(true) cItem.file?.loadRemoteFile(true)

View File

@@ -3,7 +3,6 @@ package chat.simplex.common.views.chatlist
import androidx.compose.foundation.* import androidx.compose.foundation.*
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Icon import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.* import androidx.compose.runtime.*
@@ -13,6 +12,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import chat.simplex.common.platform.* import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.* import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.call.Call
import chat.simplex.common.views.call.CallMediaType import chat.simplex.common.views.call.CallMediaType
import chat.simplex.common.views.chat.item.ItemAction import chat.simplex.common.views.chat.item.ItemAction
import chat.simplex.common.views.helpers.* import chat.simplex.common.views.helpers.*
@@ -22,10 +22,9 @@ import dev.icerock.moko.resources.compose.stringResource
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@Composable @Composable
actual fun DesktopActiveCallOverlayLayout(newChatSheetState: MutableStateFlow<AnimatedViewState>) { actual fun ActiveCallInteractiveArea(call: Call, newChatSheetState: MutableStateFlow<AnimatedViewState>) {
val call = remember { chatModel.activeCall}.value // if (call.callState == CallState.Connected && !newChatSheetState.collectAsState().value.isVisible()) {
// if (call?.callState == CallState.Connected && !newChatSheetState.collectAsState().value.isVisible()) { if (!newChatSheetState.collectAsState().value.isVisible()) {
if (call != null && !newChatSheetState.collectAsState().value.isVisible()) {
val showMenu = remember { mutableStateOf(false) } val showMenu = remember { mutableStateOf(false) }
val media = call.peerMedia ?: call.localMedia val media = call.peerMedia ?: call.localMedia
CompositionLocalProvider( CompositionLocalProvider(

View File

@@ -25,11 +25,11 @@ android.nonTransitiveRClass=true
android.enableJetifier=true android.enableJetifier=true
kotlin.mpp.androidSourceSetLayoutVersion=2 kotlin.mpp.androidSourceSetLayoutVersion=2
android.version_name=5.5 android.version_name=5.5.4
android.version_code=175 android.version_code=183
desktop.version_name=5.5 desktop.version_name=5.5.4
desktop.version_code=26 desktop.version_code=30
kotlin.version=1.8.20 kotlin.version=1.8.20
gradle.plugin.version=7.4.2 gradle.plugin.version=7.4.2

View File

@@ -12,7 +12,7 @@ constraints: zip +disable-bzip2 +disable-zstd
source-repository-package source-repository-package
type: git type: git
location: https://github.com/simplex-chat/simplexmq.git location: https://github.com/simplex-chat/simplexmq.git
tag: 7a0cd8041bbb7d7ab2f089395a244dc4af0f9e3b tag: caeeb2df9ccca29a6bb504886736502d081fba0e
source-repository-package source-repository-package
type: git type: git

View File

@@ -1,13 +1,13 @@
--- ---
title: Download SimpleX apps title: Download SimpleX apps
permalink: /downloads/index.html permalink: /downloads/index.html
revision: 25.11.2023 revision: 11.02.2024
--- ---
| Updated 25.11.2023 | Languages: EN | | Updated 11.02.2024 | Languages: EN |
# Download SimpleX apps # Download SimpleX apps
The latest stable version is v5.5. The latest stable version is v5.5.3.
You can get the latest beta releases from [GitHub](https://github.com/simplex-chat/simplex-chat/releases). You can get the latest beta releases from [GitHub](https://github.com/simplex-chat/simplex-chat/releases).
@@ -21,24 +21,24 @@ You can get the latest beta releases from [GitHub](https://github.com/simplex-ch
Using the same profile as on mobile device is not yet supported you need to create a separate profile to use desktop apps. Using the same profile as on mobile device is not yet supported you need to create a separate profile to use desktop apps.
**Linux**: [AppImage](https://github.com/simplex-chat/simplex-chat/releases/download/v5.5.0/simplex-desktop-x86_64.AppImage) (most Linux distros), [Ubuntu 20.04](https://github.com/simplex-chat/simplex-chat/releases/download/v5.5.0/simplex-desktop-ubuntu-20_04-x86_64.deb) (and Debian-based distros), [Ubuntu 22.04](https://github.com/simplex-chat/simplex-chat/releases/download/v5.5.0/simplex-desktop-ubuntu-22_04-x86_64.deb). **Linux**: [AppImage](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex-desktop-x86_64.AppImage) (most Linux distros), [Ubuntu 20.04](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex-desktop-ubuntu-20_04-x86_64.deb) (and Debian-based distros), [Ubuntu 22.04](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex-desktop-ubuntu-22_04-x86_64.deb).
**Mac**: [x86_64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.5.0/simplex-desktop-macos-x86_64.dmg) (Intel), [aarch64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.5.0/simplex-desktop-macos-aarch64.dmg) (Apple Silicon). **Mac**: [aarch64](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex-desktop-macos-aarch64.dmg) (Apple Silicon), [x86_64](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex-desktop-macos-x86_64.dmg) (Intel).
**Windows**: [x86_64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.5.0/simplex-desktop-windows-x86_64.msi). **Windows**: [x86_64](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex-desktop-windows-x86_64.msi).
## Mobile apps ## Mobile apps
**iOS**: [App store](https://apps.apple.com/us/app/simplex-chat/id1605771084), [TestFlight](https://testflight.apple.com/join/DWuT2LQu). **iOS**: [App store](https://apps.apple.com/us/app/simplex-chat/id1605771084), [TestFlight](https://testflight.apple.com/join/DWuT2LQu).
**Android**: [Play store](https://play.google.com/store/apps/details?id=chat.simplex.app), [F-Droid](https://simplex.chat/fdroid/), [APK aarch64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.5.0/simplex.apk), [APK armv7](https://github.com/simplex-chat/simplex-chat/releases/download/v5.5.0/simplex-armv7a.apk). **Android**: [Play store](https://play.google.com/store/apps/details?id=chat.simplex.app), [F-Droid](https://simplex.chat/fdroid/), [APK aarch64](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex.apk), [APK armv7](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex-armv7a.apk).
## Terminal (console) app ## Terminal (console) app
See [Using terminal app](/docs/CLI.md). See [Using terminal app](/docs/CLI.md).
**Linux**: [Ubuntu 20.04](https://github.com/simplex-chat/simplex-chat/releases/download/v5.5.0/simplex-chat-ubuntu-20_04-x86-64), [Ubuntu 22.04](https://github.com/simplex-chat/simplex-chat/releases/download/v5.5.0/simplex-chat-ubuntu-22_04-x86-64). **Linux**: [Ubuntu 20.04](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex-chat-ubuntu-20_04-x86-64), [Ubuntu 22.04](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex-chat-ubuntu-22_04-x86-64).
**Mac** [x86_64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.5.0/simplex-chat-macos-x86-64), aarch64 - [compile from source](/docs/CLI.md#). **Mac** [x86_64](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex-chat-macos-x86-64), aarch64 - [compile from source](/docs/CLI.md#).
**Windows**: [x86_64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.5.0/simplex-chat-windows-x86-64). **Windows**: [x86_64](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex-chat-windows-x86-64).

View File

@@ -108,3 +108,33 @@ Sending member builds messages history starting starting from requested/remember
\*** \***
Same XGrpMsgHistory protocol event could be sent by host to new members, after sending introductions. Same XGrpMsgHistory protocol event could be sent by host to new members, after sending introductions.
---
Update 2024-02-12:
### Group "pings"
Alternatively to tracking unanswered messages counts per member, which is complex and in some cases as discussed above ineffective, group members could periodically send group wide pings indicating their active presence.
```haskell
XGrpPing :: ChatMsgEvent 'Json
```
Members track:
- inactive flag (as above - set on QUOTA errors as well)
- last_snd_ts on group
- last_rcv_ts on group member
Clients run a worker process for checking last_snd_ts in each of their groups, and send pings to groups on a periodic basis.
- part of cleanup manager or separate process?
- on each worker step, for each group matching criteria to send ping, send ping with a random delay to reduce correlation between groups (spawn a separate thread with a random delay for each group)
- criteria for sending ping: last_snd_ts earlier than group_ping_interval ago
- configure group_ping_interval to, for example, 23 hours (so that if user opens app each day at same time client will match criteria to send pings daily)
Clients receiving pings:
- update last_rcv_ts
- when sending a message to group, check only for timestamp difference (no unanswered snd msg count logic as above)

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.

View File

@@ -385,6 +385,7 @@
"chat_send_cmd" "chat_send_cmd"
"chat_send_remote_cmd" "chat_send_remote_cmd"
"chat_valid_name" "chat_valid_name"
"chat_json_length"
"chat_write_file" "chat_write_file"
]; ];
postInstall = '' postInstall = ''
@@ -487,6 +488,7 @@
"chat_send_cmd" "chat_send_cmd"
"chat_send_remote_cmd" "chat_send_remote_cmd"
"chat_valid_name" "chat_valid_name"
"chat_json_length"
"chat_write_file" "chat_write_file"
]; ];
postInstall = '' postInstall = ''

View File

@@ -12,6 +12,7 @@ EXPORTS
chat_parse_server chat_parse_server
chat_password_hash chat_password_hash
chat_valid_name chat_valid_name
chat_json_length
chat_encrypt_media chat_encrypt_media
chat_decrypt_media chat_decrypt_media
chat_write_file chat_write_file

View File

@@ -1,5 +1,5 @@
name: simplex-chat name: simplex-chat
version: 5.5.0.4 version: 5.5.3.0
#synopsis: #synopsis:
#description: #description:
homepage: https://github.com/simplex-chat/simplex-chat#readme homepage: https://github.com/simplex-chat/simplex-chat#readme
@@ -36,7 +36,6 @@ dependencies:
- network >= 3.1.2.7 && < 3.2 - network >= 3.1.2.7 && < 3.2
- network-transport == 0.5.6 - network-transport == 0.5.6
- optparse-applicative >= 0.15 && < 0.17 - optparse-applicative >= 0.15 && < 0.17
- process == 1.6.*
- random >= 1.1 && < 1.3 - random >= 1.1 && < 1.3
- record-hasfield == 1.0.* - record-hasfield == 1.0.*
- simple-logger == 0.1.* - simple-logger == 0.1.*
@@ -50,6 +49,7 @@ dependencies:
- unliftio == 0.2.* - unliftio == 0.2.*
- unliftio-core == 0.2.* - unliftio-core == 0.2.*
- zip == 2.0.* - zip == 2.0.*
- zstd
flags: flags:
swift: swift:
@@ -64,11 +64,13 @@ when:
- condition: impl(ghc >= 9.6.2) - condition: impl(ghc >= 9.6.2)
dependencies: dependencies:
- bytestring == 0.11.* - bytestring == 0.11.*
- process == 1.6.*
- template-haskell == 2.20.* - template-haskell == 2.20.*
- text >= 2.0.1 && < 2.2 - text >= 2.0.1 && < 2.2
- condition: impl(ghc < 9.6.2) - condition: impl(ghc < 9.6.2)
dependencies: dependencies:
- bytestring == 0.10.* - bytestring == 0.10.*
- process >= 1.6 && < 1.6.18
- template-haskell == 2.16.* - template-haskell == 2.16.*
- text >= 1.2.3.0 && < 1.3 - text >= 1.2.3.0 && < 1.3
@@ -125,13 +127,19 @@ tests:
- apps/simplex-broadcast-bot/src - apps/simplex-broadcast-bot/src
- apps/simplex-directory-service/src - apps/simplex-directory-service/src
main: Test.hs main: Test.hs
when:
- condition: impl(ghc >= 9.6.2)
dependencies:
- hspec == 2.11.*
- condition: impl(ghc < 9.6.2)
dependencies:
- hspec == 2.7.*
dependencies: dependencies:
- QuickCheck == 2.14.* - QuickCheck == 2.14.*
- simplex-chat - simplex-chat
- async == 2.2.* - async == 2.2.*
- deepseq == 1.4.* - deepseq == 1.4.*
- generic-random == 1.5.* - generic-random == 1.5.*
- hspec == 2.11.*
- network == 3.1.* - network == 3.1.*
- silently == 1.2.* - silently == 1.2.*
- stm == 2.5.* - stm == 2.5.*

View File

@@ -8,6 +8,7 @@
<body> <body>
<video <video
id="remote-video-stream" id="remote-video-stream"
class="inline"
autoplay autoplay
playsinline playsinline
poster="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAQAAAAnOwc2AAAAEUlEQVR42mNk+M+AARiHsiAAcCIKAYwFoQ8AAAAASUVORK5CYII=" poster="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAQAAAAnOwc2AAAAEUlEQVR42mNk+M+AARiHsiAAcCIKAYwFoQ8AAAAASUVORK5CYII="
@@ -15,6 +16,7 @@
></video> ></video>
<video <video
id="local-video-stream" id="local-video-stream"
class="inline"
muted muted
autoplay autoplay
playsinline playsinline

View File

@@ -5,14 +5,14 @@ body {
background-color: black; background-color: black;
} }
#remote-video-stream { #remote-video-stream.inline {
position: absolute; position: absolute;
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: cover; object-fit: cover;
} }
#local-video-stream { #local-video-stream.inline {
position: absolute; position: absolute;
width: 30%; width: 30%;
max-width: 30%; max-width: 30%;
@@ -23,6 +23,20 @@ body {
right: 0; right: 0;
} }
#remote-video-stream.fullscreen {
position: absolute;
height: 100%;
width: 100%;
object-fit: cover;
}
#local-video-stream.fullscreen {
position: absolute;
height: 100%;
width: 100%;
object-fit: cover;
}
*::-webkit-media-controls { *::-webkit-media-controls {
display: none !important; display: none !important;
-webkit-appearance: none !important; -webkit-appearance: none !important;

View File

@@ -16,6 +16,7 @@ type WCallCommand =
| WCEnableMedia | WCEnableMedia
| WCToggleCamera | WCToggleCamera
| WCDescription | WCDescription
| WCLayout
| WCEndCall | WCEndCall
type WCallResponse = type WCallResponse =
@@ -31,7 +32,7 @@ type WCallResponse =
| WRError | WRError
| WCAcceptOffer | WCAcceptOffer
type WCallCommandTag = "capabilities" | "start" | "offer" | "answer" | "ice" | "media" | "camera" | "description" | "end" type WCallCommandTag = "capabilities" | "start" | "offer" | "answer" | "ice" | "media" | "camera" | "description" | "layout" | "end"
type WCallResponseTag = "capabilities" | "offer" | "answer" | "ice" | "connection" | "connected" | "end" | "ended" | "ok" | "error" type WCallResponseTag = "capabilities" | "offer" | "answer" | "ice" | "connection" | "connected" | "end" | "ended" | "ok" | "error"
@@ -45,6 +46,12 @@ enum VideoCamera {
Environment = "environment", Environment = "environment",
} }
enum LayoutType {
Default = "default",
LocalVideo = "localVideo",
RemoteVideo = "remoteVideo",
}
interface IWCallCommand { interface IWCallCommand {
type: WCallCommandTag type: WCallCommandTag
} }
@@ -115,6 +122,11 @@ interface WCDescription extends IWCallCommand {
description: string description: string
} }
interface WCLayout extends IWCallCommand {
type: "layout"
layout: LayoutType
}
interface WRCapabilities extends IWCallResponse { interface WRCapabilities extends IWCallResponse {
type: "capabilities" type: "capabilities"
capabilities: CallCapabilities capabilities: CallCapabilities
@@ -515,6 +527,10 @@ const processCommand = (function () {
localizedDescription = command.description localizedDescription = command.description
resp = {type: "ok"} resp = {type: "ok"}
break break
case "layout":
changeLayout(command.layout)
resp = {type: "ok"}
break
case "end": case "end":
endCall() endCall()
resp = {type: "ok"} resp = {type: "ok"}
@@ -824,6 +840,29 @@ function toggleMedia(s: MediaStream, media: CallMediaType): boolean {
return res return res
} }
function changeLayout(layout: LayoutType) {
const local = document.getElementById("local-video-stream")!
const remote = document.getElementById("remote-video-stream")!
switch (layout) {
case LayoutType.Default:
local.className = "inline"
remote.className = "inline"
local.style.visibility = "visible"
remote.style.visibility = "visible"
break
case LayoutType.LocalVideo:
local.className = "fullscreen"
local.style.visibility = "visible"
remote.style.visibility = "hidden"
break
case LayoutType.RemoteVideo:
remote.className = "fullscreen"
local.style.visibility = "hidden"
remote.style.visibility = "visible"
break
}
}
type TransformFrameFunc = (key: CryptoKey) => (frame: RTCEncodedVideoFrame, controller: TransformStreamDefaultController) => Promise<void> type TransformFrameFunc = (key: CryptoKey) => (frame: RTCEncodedVideoFrame, controller: TransformStreamDefaultController) => Promise<void>
interface CallCrypto { interface CallCrypto {

View File

@@ -9,6 +9,7 @@
<body> <body>
<video <video
id="remote-video-stream" id="remote-video-stream"
class="inline"
autoplay autoplay
playsinline playsinline
poster="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAQAAAAnOwc2AAAAEUlEQVR42mNk+M+AARiHsiAAcCIKAYwFoQ8AAAAASUVORK5CYII=" poster="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAQAAAAnOwc2AAAAEUlEQVR42mNk+M+AARiHsiAAcCIKAYwFoQ8AAAAASUVORK5CYII="
@@ -16,6 +17,7 @@
></video> ></video>
<video <video
id="local-video-stream" id="local-video-stream"
class="inline"
muted muted
autoplay autoplay
playsinline playsinline

View File

@@ -5,14 +5,14 @@ body {
background-color: black; background-color: black;
} }
#remote-video-stream { #remote-video-stream.inline {
position: absolute; position: absolute;
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: cover; object-fit: cover;
} }
#local-video-stream { #local-video-stream.inline {
position: absolute; position: absolute;
width: 20%; width: 20%;
max-width: 20%; max-width: 20%;
@@ -23,6 +23,20 @@ body {
right: 0; right: 0;
} }
#remote-video-stream.fullscreen {
position: absolute;
height: 100%;
width: 100%;
object-fit: cover;
}
#local-video-stream.fullscreen {
position: absolute;
height: 100%;
width: 100%;
object-fit: cover;
}
*::-webkit-media-controls { *::-webkit-media-controls {
display: none !important; display: none !important;
-webkit-appearance: none !important; -webkit-appearance: none !important;

View File

@@ -103,7 +103,7 @@ build() {
for arch in $arches; do for arch in $arches; do
tag_full="$(git tag --points-at HEAD)" tag_full="$(git tag --points-at HEAD | head -n1)"
tag_version="${tag_full%%-*}" tag_version="${tag_full%%-*}"
if [ "$arch" = "armv7a" ] && [ -n "$tag_full" ] ; then if [ "$arch" = "armv7a" ] && [ -n "$tag_full" ] ; then

View File

@@ -20,6 +20,10 @@ root_dir="$(dirname "$(dirname "$(readlink "$0")")")"
cd $root_dir cd $root_dir
BUILD_DIR=dist-newstyle/build/$ARCH-$OS/ghc-${GHC_VERSION}/simplex-chat-* BUILD_DIR=dist-newstyle/build/$ARCH-$OS/ghc-${GHC_VERSION}/simplex-chat-*
exports=( $(sed 's/foreign export ccall "chat_migrate_init_key"//' src/Simplex/Chat/Mobile.hs | sed 's/foreign export ccall "chat_reopen_store"//' |grep "foreign export ccall" | cut -d '"' -f2) )
for elem in "${exports[@]}"; do count=$(grep -R "$elem$" libsimplex.dll.def | wc -l); if [ $count -ne 1 ]; then echo Wrong exports in libsimplex.dll.def. Add \"$elem\" to that file; exit 1; fi ; done
for elem in "${exports[@]}"; do count=$(grep -R "\"$elem\"" flake.nix | wc -l); if [ $count -ne 2 ]; then echo Wrong exports in flake.nix. Add \"$elem\" in two places of the file; exit 1; fi ; done
rm -rf $BUILD_DIR rm -rf $BUILD_DIR
cabal build lib:simplex-chat --ghc-options='-optl-Wl,-rpath,$ORIGIN -flink-rts -threaded' cabal build lib:simplex-chat --ghc-options='-optl-Wl,-rpath,$ORIGIN -flink-rts -threaded'
cd $BUILD_DIR/build cd $BUILD_DIR/build

View File

@@ -19,6 +19,10 @@ GHC_LIBS_DIR=$(ghc --print-libdir)
BUILD_DIR=dist-newstyle/build/$ARCH-*/ghc-*/simplex-chat-* BUILD_DIR=dist-newstyle/build/$ARCH-*/ghc-*/simplex-chat-*
exports=( $(sed 's/foreign export ccall "chat_migrate_init_key"//' src/Simplex/Chat/Mobile.hs | sed 's/foreign export ccall "chat_reopen_store"//' |grep "foreign export ccall" | cut -d '"' -f2) )
for elem in "${exports[@]}"; do count=$(grep -R "$elem$" libsimplex.dll.def | wc -l); if [ $count -ne 1 ]; then echo Wrong exports in libsimplex.dll.def. Add \"$elem\" to that file; exit 1; fi ; done
for elem in "${exports[@]}"; do count=$(grep -R "\"$elem\"" flake.nix | wc -l); if [ $count -ne 2 ]; then echo Wrong exports in flake.nix. Add \"$elem\" in two places of the file; exit 1; fi ; done
rm -rf $BUILD_DIR rm -rf $BUILD_DIR
cabal build lib:simplex-chat lib:simplex-chat --ghc-options="-optl-Wl,-rpath,@loader_path -optl-Wl,-L$GHC_LIBS_DIR/$ARCH-osx-ghc-$GHC_VERSION -optl-lHSrts_thr-ghc$GHC_VERSION -optl-lffi" cabal build lib:simplex-chat lib:simplex-chat --ghc-options="-optl-Wl,-rpath,@loader_path -optl-Wl,-L$GHC_LIBS_DIR/$ARCH-osx-ghc-$GHC_VERSION -optl-lHSrts_thr-ghc$GHC_VERSION -optl-lffi"

View File

@@ -17,6 +17,10 @@ fi
BUILD_DIR=dist-newstyle/build/$ARCH-$OS/ghc-*/simplex-chat-* BUILD_DIR=dist-newstyle/build/$ARCH-$OS/ghc-*/simplex-chat-*
exports=( $(sed 's/foreign export ccall "chat_migrate_init_key"//' src/Simplex/Chat/Mobile.hs | sed 's/foreign export ccall "chat_reopen_store"//' |grep "foreign export ccall" | cut -d '"' -f2) )
for elem in "${exports[@]}"; do count=$(grep -R "$elem$" libsimplex.dll.def | wc -l); if [ $count -ne 1 ]; then echo Wrong exports in libsimplex.dll.def. Add \"$elem\" to that file; exit 1; fi ; done
for elem in "${exports[@]}"; do count=$(grep -R "\"$elem\"" flake.nix | wc -l); if [ $count -ne 2 ]; then echo Wrong exports in flake.nix. Add \"$elem\" in two places of the file; exit 1; fi ; done
# IMPORTANT: in order to get a working build you should use x86_64 MinGW with make, cmake, gcc. # IMPORTANT: in order to get a working build you should use x86_64 MinGW with make, cmake, gcc.
# 100% working MinGW is https://github.com/brechtsanders/winlibs_mingw/releases/download/13.1.0-16.0.5-11.0.0-ucrt-r5/winlibs-x86_64-posix-seh-gcc-13.1.0-mingw-w64ucrt-11.0.0-r5.zip # 100% working MinGW is https://github.com/brechtsanders/winlibs_mingw/releases/download/13.1.0-16.0.5-11.0.0-ucrt-r5/winlibs-x86_64-posix-seh-gcc-13.1.0-mingw-w64ucrt-11.0.0-r5.zip
# Many other distributions I tested don't work in some cases or don't have required tools. # Many other distributions I tested don't work in some cases or don't have required tools.

View File

@@ -1,5 +1,5 @@
{ {
"https://github.com/simplex-chat/simplexmq.git"."7a0cd8041bbb7d7ab2f089395a244dc4af0f9e3b" = "0jxf9dnsg14ffd1y3i7md2ninrds4daq1fmpnd6j5z99im07ns52"; "https://github.com/simplex-chat/simplexmq.git"."caeeb2df9ccca29a6bb504886736502d081fba0e" = "187avx8h014fhik76qv1l0nifv6db6nrg9kjk2azqia21n4s2m38";
"https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38";
"https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d";
"https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl"; "https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl";

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