diff --git a/.gitignore b/.gitignore
index 521f6b4..8e01f3d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,123 +1,74 @@
-docs
-Frameworks
-#########################
-# **.gitignore** file for Xcode4 / OS X Source projects
-#
-# NB: if you are storing "built" products, this WILL NOT WORK,
-# and you should use a different **.gitignore** (or none at all)
-# This file is for SOURCE projects, where there are many extra
-# files that we want to exclude
-#
-# For updates, see: http://stackoverflow.com/questions/49478/git-ignore-file-for-xcode-projects
-#########################
+# rust bridge
+rust/monal-rust-swift-bridge/generated
+rust/LibMonalRustSwiftBridge
-#####
-# OS X temporary files that should never be committed
+# Created by https://www.toptal.com/developers/gitignore/api/rust
+# Edit at https://www.toptal.com/developers/gitignore?templates=rust
+### Rust ###
+# Generated by Cargo
+# will have compiled files and executables
+debug/
+target/
+
+# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries
+# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html
+Cargo.lock
+!rust/Cargo.lock
+
+# These are backup files generated by rustfmt
+**/*.rs.bk
+
+# MSVC Windows builds of rustc generate these, which store debugging information
+*.pdb
+
+# End of https://www.toptal.com/developers/gitignore/api/rust
+
+# macOS
.DS_Store
-*.swp
-profile
+# Xcode
+Monal/Monal.xcodeproj/xcuserdata/*
+Monal/Monal.xcodeproj/project.xcworkspace/xcshareddata/*
+Monal/Monal.xcodeproj/project.xcworkspace/xcuserdata/*
+contents.xcworkspacedata
+._*
+*.xcscmblueprint
+*.xccheckout
+Monal/Monal.xcworkspace/xcshareddata/*
+Monal/Monal.xcworkspace/xcuserdata/*
-####
-# Xcode temporary files that should never be committed
-#
-# NB: NIB/XIB files still exist even on Storyboard projects, so we want this...
-
-*~.nib
-
-
-####
-# Xcode build files -
-#
-# NB: slash on the end, so we only remove the FOLDER, not any files that were badly named "DerivedData"
-
-DerivedData/
-
-# NB: slash on the end, so we only remove the FOLDER, not any files that were badly named "build"
-
-build/
-
-
-#####
-# Xcode private settings (window sizes, bookmarks, breakpoints, custom executables, smart groups)
-#
-# This is complicated:
-#
-# SOMETIMES you need to put this file in version control.
-# Apple designed it poorly - if you use "custom executables", they are
-# saved in this file.
-# 99% of projects do NOT use those, so they do NOT want to version control this file.
-# ..but if you're in the 1%, comment out the line "*.pbxuser"
-
+Monal/build/*
*.pbxuser
-*.mode1v3
-*.mode2v3
-*.perspectivev3
-# NB: also, whitelist the default ones, some projects need to use these
!default.pbxuser
+*.mode1v3
!default.mode1v3
+*.mode2v3
!default.mode2v3
+*.perspectivev3
!default.perspectivev3
-
-
-####
-# Xcode 4 - semi-personal settings, often included in workspaces
-#
-# You can safely ignore the xcuserdata files - but do NOT ignore the files next to them
-#
-
-xcuserdata
-
-####
-# XCode 4 workspaces - more detailed
-#
-# Workspaces are important! They are a core feature of Xcode - don't exclude them :)
-#
-# Workspace layout is quite spammy. For reference:
-#
-# (root)/
-# (project-name).xcodeproj/
-# project.pbxproj
-# project.xcworkspace/
-# contents.xcworkspacedata
-# xcuserdata/
-# (your name)/xcuserdatad/
-# xcuserdata/
-# (your name)/xcuserdatad/
-#
-#
-#
-# Xcode 4 workspaces - SHARED
-#
-# This is UNDOCUMENTED (google: "developer.apple.com xcshareddata" - 0 results
-# But if you're going to kill personal workspaces, at least keep the shared ones...
-#
-#
-!xcshareddata
-
-####
-# XCode 4 build-schemes
-#
-# PRIVATE ones are stored inside xcuserdata
-!xcschemes
-
-####
-# Xcode 4 - Deprecated classes
-#
-# Allegedly, if you manually "deprecate" your classes, they get moved here.
-#
-# We're using source-control, so this is a "feature" that we do not want!
-
+profile
*.moved-aside
-/.idea
-/ConversationsClassic/.idea
-/ConversationsClassic.xcodeproj
-/Info.plist
-/ConversationsClassic/ConversationsClassic.entitlements
-/XMPPSwift/Client/VoIP/rickroll.mp4
-/.nvim
-/buildServer.json
-TODO.txt
-PASSWD.txt
+DerivedData
+Monal/.nvim
+Monal/buildServer.json
+
+# Pods
+Monal/Pods
+
+#Don't accidentally commit localization state
+Monal/localization/external
+Monal/localization/external/*
+Monal/shareSheet-iOS/localization/external
+Monal/shareSheet-iOS/localization/external/*
+
+# certs and other encrypted stuff
+*.pem
+*.key
+*.csr
+*.cer
+*.mobileprovision
+*.provisionprofile
+*.p12
+Monal/Classes/secrets.h
diff --git a/Art/alpha_logo.png b/Art/alpha_logo.png
new file mode 100644
index 0000000..b320494
Binary files /dev/null and b/Art/alpha_logo.png differ
diff --git a/Art/callkit_logo.png b/Art/callkit_logo.png
new file mode 100644
index 0000000..bb4f00d
Binary files /dev/null and b/Art/callkit_logo.png differ
diff --git a/Art/chat.png b/Art/chat.png
new file mode 100644
index 0000000..b539dfd
Binary files /dev/null and b/Art/chat.png differ
diff --git a/Art/chat2.png b/Art/chat2.png
new file mode 100644
index 0000000..c1046cc
Binary files /dev/null and b/Art/chat2.png differ
diff --git a/Art/chat_dark.png b/Art/chat_dark.png
new file mode 100644
index 0000000..2ad8a9e
Binary files /dev/null and b/Art/chat_dark.png differ
diff --git a/Art/friends.png b/Art/friends.png
new file mode 100644
index 0000000..923aff7
Binary files /dev/null and b/Art/friends.png differ
diff --git a/Art/friends2.png b/Art/friends2.png
new file mode 100644
index 0000000..7a7c1e7
Binary files /dev/null and b/Art/friends2.png differ
diff --git a/Art/friends_dark.png b/Art/friends_dark.png
new file mode 100644
index 0000000..969511e
Binary files /dev/null and b/Art/friends_dark.png differ
diff --git a/Art/logo.png b/Art/logo.png
new file mode 100644
index 0000000..09ab909
Binary files /dev/null and b/Art/logo.png differ
diff --git a/Art/monal.svg b/Art/monal.svg
new file mode 100644
index 0000000..e8c7d1c
--- /dev/null
+++ b/Art/monal.svg
@@ -0,0 +1,149 @@
+
+
+
+
diff --git a/Art/park_black_white.png b/Art/park_black_white.png
new file mode 100644
index 0000000..8ca4132
Binary files /dev/null and b/Art/park_black_white.png differ
diff --git a/Art/park_colors.png b/Art/park_colors.png
new file mode 100644
index 0000000..977e5f8
Binary files /dev/null and b/Art/park_colors.png differ
diff --git a/Art/park_white_black.png b/Art/park_white_black.png
new file mode 100644
index 0000000..4d0e88e
Binary files /dev/null and b/Art/park_white_black.png differ
diff --git a/Art/screenshots/01_groupchats.png b/Art/screenshots/01_groupchats.png
new file mode 100644
index 0000000..b861a42
Binary files /dev/null and b/Art/screenshots/01_groupchats.png differ
diff --git a/Art/screenshots/02_chats.png b/Art/screenshots/02_chats.png
new file mode 100644
index 0000000..c0be7d9
Binary files /dev/null and b/Art/screenshots/02_chats.png differ
diff --git a/Art/screenshots/04_contacts.png b/Art/screenshots/04_contacts.png
new file mode 100644
index 0000000..3d33b63
Binary files /dev/null and b/Art/screenshots/04_contacts.png differ
diff --git a/Art/screenshots/ipad_01_groupchats.png b/Art/screenshots/ipad_01_groupchats.png
new file mode 100644
index 0000000..dbc1991
Binary files /dev/null and b/Art/screenshots/ipad_01_groupchats.png differ
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..5dd9fac
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,29 @@
+BSD 2-Clause License
+
+Copyright (c) 2024, Thilo Molitor
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+1. Redistributions of source code must retain the above copyright notice, this
+ list of conditions and the following disclaimer.
+
+2. Redistributions in binary form must reproduce the above copyright notice,
+ this list of conditions and the following disclaimer in the documentation
+ and/or other materials provided with the distribution.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+The views and conclusions contained in the software and documentation are those
+of the authors and should not be interpreted as representing official policies,
+either expressed or implied, of Anurodh Pokharel
diff --git a/Monal/.bartycrouch.toml b/Monal/.bartycrouch.toml
new file mode 100644
index 0000000..a18fe87
--- /dev/null
+++ b/Monal/.bartycrouch.toml
@@ -0,0 +1,27 @@
+[update]
+tasks = ["interfaces", "code", "normalize"]
+
+[update.interfaces]
+paths = ["."]
+defaultToBase = true
+ignoreEmptyStrings = true
+unstripped = false
+
+[update.code]
+codePaths = ["Classes", "shareSheet-iOS", "NotificationService"]
+localizablePaths = ["localization", "shareSheet-iOS/localization"]
+defaultToKeys = true
+additive = true
+unstripped = false
+plistArguments = true
+
+[update.normalize]
+paths = ["."]
+sourceLocale = "base"
+harmonizeWithSource = true
+sortByKeys = true
+
+[lint]
+paths = ["."]
+duplicateKeys = true
+emptyValues = true
diff --git a/Monal/AlertSounds/alert1.aif b/Monal/AlertSounds/alert1.aif
new file mode 100644
index 0000000..36da355
Binary files /dev/null and b/Monal/AlertSounds/alert1.aif differ
diff --git a/Monal/AlertSounds/alert10.aif b/Monal/AlertSounds/alert10.aif
new file mode 100644
index 0000000..3955c5f
Binary files /dev/null and b/Monal/AlertSounds/alert10.aif differ
diff --git a/Monal/AlertSounds/alert11.aif b/Monal/AlertSounds/alert11.aif
new file mode 100644
index 0000000..32b7a23
Binary files /dev/null and b/Monal/AlertSounds/alert11.aif differ
diff --git a/Monal/AlertSounds/alert12.aif b/Monal/AlertSounds/alert12.aif
new file mode 100644
index 0000000..6800631
Binary files /dev/null and b/Monal/AlertSounds/alert12.aif differ
diff --git a/Monal/AlertSounds/alert2.aif b/Monal/AlertSounds/alert2.aif
new file mode 100644
index 0000000..3528ee6
Binary files /dev/null and b/Monal/AlertSounds/alert2.aif differ
diff --git a/Monal/AlertSounds/alert3.aif b/Monal/AlertSounds/alert3.aif
new file mode 100644
index 0000000..a605602
Binary files /dev/null and b/Monal/AlertSounds/alert3.aif differ
diff --git a/Monal/AlertSounds/alert4.aif b/Monal/AlertSounds/alert4.aif
new file mode 100644
index 0000000..6fed8e4
Binary files /dev/null and b/Monal/AlertSounds/alert4.aif differ
diff --git a/Monal/AlertSounds/alert5.aif b/Monal/AlertSounds/alert5.aif
new file mode 100644
index 0000000..37f510e
Binary files /dev/null and b/Monal/AlertSounds/alert5.aif differ
diff --git a/Monal/AlertSounds/alert6.aif b/Monal/AlertSounds/alert6.aif
new file mode 100644
index 0000000..4b59bb6
Binary files /dev/null and b/Monal/AlertSounds/alert6.aif differ
diff --git a/Monal/AlertSounds/alert7.aif b/Monal/AlertSounds/alert7.aif
new file mode 100644
index 0000000..d6e057b
Binary files /dev/null and b/Monal/AlertSounds/alert7.aif differ
diff --git a/Monal/AlertSounds/alert8.aif b/Monal/AlertSounds/alert8.aif
new file mode 100644
index 0000000..469b8f2
Binary files /dev/null and b/Monal/AlertSounds/alert8.aif differ
diff --git a/Monal/AlertSounds/alert9.aif b/Monal/AlertSounds/alert9.aif
new file mode 100644
index 0000000..f160e1f
Binary files /dev/null and b/Monal/AlertSounds/alert9.aif differ
diff --git a/Monal/Alpha.shareSheet.entitlements b/Monal/Alpha.shareSheet.entitlements
new file mode 100644
index 0000000..20fe102
--- /dev/null
+++ b/Monal/Alpha.shareSheet.entitlements
@@ -0,0 +1,14 @@
+
+
+
+
+ com.apple.security.app-sandbox
+
+ com.apple.security.application-groups
+
+ group.monalalpha
+
+ com.apple.security.network.client
+
+
+
diff --git a/Monal/CallSounds/busy.wav b/Monal/CallSounds/busy.wav
new file mode 100644
index 0000000..24e3bb2
Binary files /dev/null and b/Monal/CallSounds/busy.wav differ
diff --git a/Monal/CallSounds/error.wav b/Monal/CallSounds/error.wav
new file mode 100644
index 0000000..f685f84
Binary files /dev/null and b/Monal/CallSounds/error.wav differ
diff --git a/Monal/CallSounds/ringing.wav b/Monal/CallSounds/ringing.wav
new file mode 100644
index 0000000..f639089
Binary files /dev/null and b/Monal/CallSounds/ringing.wav differ
diff --git a/Monal/Classes/AESGcm.h b/Monal/Classes/AESGcm.h
new file mode 100644
index 0000000..a0db6a5
--- /dev/null
+++ b/Monal/Classes/AESGcm.h
@@ -0,0 +1,26 @@
+//
+// AESGcm.h
+// Monal
+//
+// Created by Anurodh Pokharel on 4/19/19.
+// Copyright © 2019 Monal.im. All rights reserved.
+//
+
+#import
+#import "MLEncryptedPayload.h"
+
+NS_ASSUME_NONNULL_BEGIN
+
+@interface AESGcm : NSObject
+/**
+ key size should be 16 or 32
+ */
++(MLEncryptedPayload* _Nullable) encrypt:(NSData*) body keySize:(int) keySize;
++(MLEncryptedPayload* _Nullable) encrypt:(NSData*) body withKey:(NSData*) gcmKey;
++(NSData* _Nullable) decrypt:(NSData *)body withKey:(NSData *) key andIv:(NSData *)iv withAuth:(NSData * _Nullable) auth;
++(NSData* _Nullable) genIV;
++(NSData* _Nullable) genKey:(int) keySize;
+
+@end
+
+NS_ASSUME_NONNULL_END
diff --git a/Monal/Classes/AESGcm.m b/Monal/Classes/AESGcm.m
new file mode 100644
index 0000000..af44527
--- /dev/null
+++ b/Monal/Classes/AESGcm.m
@@ -0,0 +1,71 @@
+//
+// AESGcm.m
+// Monal
+//
+// Created by Anurodh Pokharel on 4/19/19.
+// Copyright © 2019 Monal.im. All rights reserved.
+//
+
+#import "MLConstants.h"
+#import "AESGcm.h"
+#import
+
+@class MLCrypto;
+
+@implementation AESGcm
+
++(MLEncryptedPayload*) encrypt:(NSData*) body keySize:(int) keySize
+{
+ NSData* gcmKey = [self genKey:keySize];
+ if(!gcmKey)
+ {
+ return nil;
+ }
+ return [self encrypt:body withKey:gcmKey];
+}
+
++(MLEncryptedPayload*) encrypt:(NSData*) body withKey:(NSData*) gcmKey
+{
+ MLCrypto* crypto = [MLCrypto new];
+ EncryptedPayload* payload = [crypto encryptGCMWithKey:gcmKey decryptedContent:body];
+ if(payload == nil)
+ {
+ return nil;
+ }
+ NSMutableData* combinedKey = [NSMutableData dataWithData:gcmKey];
+ [combinedKey appendData:payload.tag];
+ if(combinedKey == nil)
+ {
+ return nil;
+ }
+ return [[MLEncryptedPayload alloc] initWithBody:payload.body key:combinedKey iv:payload.iv authTag:payload.tag];
+}
+
++(NSData*) genIV
+{
+ MLCrypto* crypto = [MLCrypto new];
+ return [crypto genIV];
+}
+
++(NSData*) genKey:(int) keySize
+{
+ uint8_t randomBytes[keySize];
+ if(SecRandomCopyBytes(kSecRandomDefault, keySize, randomBytes) != 0)
+ return nil;
+ return [[NSData alloc] initWithBytes:randomBytes length:keySize];
+}
+
++(NSData*) decrypt:(NSData*) body withKey:(NSData*) key andIv:(NSData*) iv withAuth:(NSData* _Nullable) auth
+{
+ MLCrypto* crypto = [MLCrypto new];
+
+ NSMutableData* combined = [NSMutableData new];
+ [combined appendData:iv];
+ [combined appendData:body];
+ [combined appendData:auth]; //if auth is nil assume it already was apended to body
+
+ NSData* toReturn = [crypto decryptGCMWithKey:key encryptedContent:combined];
+ return toReturn;
+}
+
+@end
diff --git a/Monal/Classes/AVCallUI.swift b/Monal/Classes/AVCallUI.swift
new file mode 100644
index 0000000..d99671c
--- /dev/null
+++ b/Monal/Classes/AVCallUI.swift
@@ -0,0 +1,594 @@
+//
+// AVCallUI.swift
+// Monal
+//
+// Created by Thilo Molitor on 20.12.22.
+// Copyright © 2021 Monal.im. All rights reserved.
+//
+import WebRTC
+import AVFoundation
+import CallKit
+import AVKit
+
+struct VideoView: UIViewRepresentable {
+ var renderer: RTCMTLVideoView
+
+ init(renderer: RTCMTLVideoView) {
+ self.renderer = renderer
+ }
+
+ func makeUIView(context: Context) -> RTCMTLVideoView {
+ return self.renderer
+ }
+
+ func updateUIView(_ renderer: RTCMTLVideoView, context: Context) {
+ DDLogDebug("updateUIView called...")
+ //do nothing
+ }
+}
+
+struct AVCallUI: View {
+ @StateObject private var appDelegate: ObservableKVOWrapper
+ @StateObject private var call: ObservableKVOWrapper
+ @StateObject private var contact: ObservableKVOWrapper
+ @State private var showMicAlert = false
+ @State private var showSecurityHelpAlert: MLCallEncryptionState? = nil
+ @State private var controlsVisible = true
+ @State private var localRendererLocation: CGPoint = CGPoint(
+ x: UIScreen.main.bounds.size.width - (UIScreen.main.bounds.size.width/5.0/2.0 + 24.0),
+ y: UIScreen.main.bounds.size.height/5.0/2.0 + 16.0
+ )
+ @State private var cameraPosition: AVCaptureDevice.Position = .front
+ @State private var sendingVideo = true
+ private var ringingPlayer: AVAudioPlayer!
+ private var busyPlayer: AVAudioPlayer!
+ private var errorPlayer: AVAudioPlayer!
+ private var delegate: SheetDismisserProtocol
+ private var formatter: DateComponentsFormatter
+ private var localRenderer: RTCMTLVideoView
+ private var remoteRenderer: RTCMTLVideoView
+
+ init(delegate: SheetDismisserProtocol, call: MLCall) {
+ _call = StateObject(wrappedValue: ObservableKVOWrapper(call))
+ _contact = StateObject(wrappedValue: ObservableKVOWrapper(call.contact))
+ _appDelegate = StateObject(wrappedValue: ObservableKVOWrapper(UIApplication.shared.delegate as! MonalAppDelegate))
+ self.delegate = delegate
+ self.formatter = DateComponentsFormatter()
+ self.formatter.allowedUnits = [.hour, .minute, .second]
+ self.formatter.unitsStyle = .positional
+ self.formatter.zeroFormattingBehavior = .pad
+
+ //use the complete screen for remote video
+ self.remoteRenderer = RTCMTLVideoView(frame: UIScreen.main.bounds)
+ self.remoteRenderer.videoContentMode = .scaleAspectFill
+
+ self.localRenderer = RTCMTLVideoView(frame: UIScreen.main.bounds)
+ self.localRenderer.videoContentMode = .scaleAspectFill
+ self.localRenderer.transform = CGAffineTransformMakeScale(-1.0, 1.0) //local video should be displayed as "mirrored"
+
+ self.ringingPlayer = try! AVAudioPlayer(contentsOf:Bundle.main.url(forResource:"ringing", withExtension:"wav", subdirectory:"CallSounds")!)
+ self.busyPlayer = try! AVAudioPlayer(contentsOf:Bundle.main.url(forResource:"busy", withExtension:"wav", subdirectory:"CallSounds")!)
+ self.errorPlayer = try! AVAudioPlayer(contentsOf:Bundle.main.url(forResource:"error", withExtension:"wav", subdirectory:"CallSounds")!)
+ }
+
+ func maybeStartRenderer() {
+ if MLCallType(rawValue:call.callType) == .video && MLCallState(rawValue:call.state) == .connected {
+ DDLogInfo("Starting local and remote video renderers...")
+ call.obj.startCaptureLocalVideo(withRenderer: self.localRenderer, andCameraPosition:cameraPosition)
+ call.obj.renderRemoteVideo(withRenderer: self.remoteRenderer)
+ }
+ }
+
+ func handleStateChange(_ state:MLCallState, _ audioState:MLAudioState) {
+ switch state {
+ case .unknown:
+ DDLogDebug("state: unknown")
+ ringingPlayer.stop()
+ busyPlayer.stop()
+ errorPlayer.play()
+ case .discovering:
+ DDLogDebug("state: discovering")
+ ringingPlayer.stop()
+ busyPlayer.stop()
+ errorPlayer.stop()
+ case .ringing:
+ DDLogDebug("state: ringing")
+ busyPlayer.stop()
+ errorPlayer.stop()
+ ringingPlayer.play()
+ case .connecting:
+ DDLogDebug("state: connecting")
+ ringingPlayer.stop()
+ busyPlayer.stop()
+ errorPlayer.stop()
+ case .reconnecting:
+ DDLogDebug("state: reconnecting")
+ ringingPlayer.stop()
+ busyPlayer.stop()
+ errorPlayer.stop()
+ case .connected:
+ DDLogDebug("state: connected")
+ maybeStartRenderer()
+ case .finished:
+ DDLogDebug("state: finished: \(String(describing:call.finishReason as NSNumber))")
+ //check audio state before trying to play anything (if we are still in state .call,
+ //callkit will deactivate this audio session shortly, stopping our players)
+ if audioState == .normal {
+ switch MLCallFinishReason(rawValue:call.finishReason) {
+ case .unknown:
+ DDLogDebug("state: finished: unknown")
+ ringingPlayer.stop()
+ busyPlayer.stop()
+ errorPlayer.stop()
+ case .connectivityError:
+ DDLogDebug("state: finished: connectivityError")
+ ringingPlayer.stop()
+ busyPlayer.stop()
+ errorPlayer.play()
+ case .securityError:
+ DDLogDebug("state: finished: securityError")
+ ringingPlayer.stop()
+ busyPlayer.stop()
+ errorPlayer.play()
+ case .unanswered:
+ DDLogDebug("state: finished: unanswered")
+ ringingPlayer.stop()
+ errorPlayer.stop()
+ busyPlayer.play()
+ case .retracted:
+ DDLogDebug("state: finished: retracted")
+ ringingPlayer.stop()
+ errorPlayer.stop()
+ busyPlayer.play()
+ case .rejected:
+ DDLogDebug("state: finished: rejected")
+ ringingPlayer.stop()
+ errorPlayer.stop()
+ busyPlayer.play()
+ case .declined:
+ DDLogDebug("state: finished: declined")
+ ringingPlayer.stop()
+ errorPlayer.stop()
+ busyPlayer.play()
+ case .error:
+ DDLogDebug("state: finished: error")
+ ringingPlayer.stop()
+ busyPlayer.stop()
+ errorPlayer.play()
+// case .normal:
+// case .answeredElsewhere:
+ default:
+ DDLogDebug("state: finished: default")
+ ringingPlayer.stop()
+ busyPlayer.stop()
+ errorPlayer.stop()
+ }
+ }
+ default:
+ DDLogDebug("state: default")
+ }
+ }
+
+ var body: some View {
+ ZStack {
+ Color.background
+ .ignoresSafeArea()
+
+ if MLCallType(rawValue:call.callType) == .video && MLCallState(rawValue:call.state) == .connected {
+ VideoView(renderer:self.remoteRenderer)
+
+ ZStack {
+ VideoView(renderer:self.localRenderer)
+ //this will sometimes only honor the width and ignore the height
+ .frame(width: UIScreen.main.bounds.size.width/5.0, height: UIScreen.main.bounds.size.height/5.0)
+
+ if controlsVisible {
+ Button(action: {
+ if cameraPosition == .front {
+ cameraPosition = .back
+ } else {
+ cameraPosition = .front
+ }
+ call.obj.stopCaptureLocalVideo()
+ maybeStartRenderer()
+ }, label: {
+ Image(systemName: "arrow.triangle.2.circlepath.camera.fill")
+ .resizable()
+ .frame(width: 32.0, height: 32.0)
+ .foregroundColor(.primary)
+ })
+ }
+ }
+ .position(localRendererLocation)
+ .gesture(DragGesture().onChanged { value in
+ self.localRendererLocation = value.location
+ })
+ .onTapGesture(count: 2) {
+ if sendingVideo {
+ call.obj.hideVideo()
+ } else {
+ call.obj.showVideo()
+ }
+ sendingVideo = !sendingVideo
+ }
+ }
+
+ if MLCallType(rawValue:call.callType) == .audio ||
+ (MLCallType(rawValue:call.callType) == .video && (MLCallState(rawValue:call.state) != .connected || controlsVisible)) {
+ VStack {
+ Group {
+ Spacer().frame(height: 24)
+
+ HStack(alignment: .top) {
+ Spacer().frame(width:20)
+
+ VStack {
+ Spacer().frame(height: 8)
+ switch MLCallDirection(rawValue:call.direction) {
+ case .incoming:
+ Image(systemName: "phone.arrow.down.left")
+ .resizable()
+ .frame(width: 20.0, height: 20.0)
+ .foregroundColor(.primary)
+ case .outgoing:
+ Image(systemName: "phone.arrow.up.right")
+ .resizable()
+ .frame(width: 20.0, height: 20.0)
+ .foregroundColor(.primary)
+ default: //should never be reached
+ Text("")
+ }
+ }
+
+ VStack {
+ Spacer().frame(height: 8)
+ Button(action: {
+ //show dialog explaining different encryption states
+ self.showSecurityHelpAlert = MLCallEncryptionState(rawValue:call.encryptionState)
+ }, label: {
+ switch MLCallEncryptionState(rawValue:call.encryptionState) {
+ case .unknown:
+ Text("")
+ case .clear:
+ Spacer().frame(width: 10)
+ Image(systemName: "xmark.shield.fill")
+ .resizable()
+ .frame(width: 20.0, height: 20.0)
+ .foregroundColor(.red)
+ case .toFU:
+ Spacer().frame(width: 10)
+ Image(systemName: "checkmark.shield.fill")
+ .resizable()
+ .frame(width: 20.0, height: 20.0)
+ .foregroundColor(.yellow)
+ case .trusted:
+ Spacer().frame(width: 10)
+ Image(systemName: "checkmark.shield.fill")
+ .resizable()
+ .frame(width: 20.0, height: 20.0)
+ .foregroundColor(.green)
+ default: //should never be reached
+ Text("")
+ }
+ })
+ }
+
+ Spacer()
+
+ Text(contact.contactDisplayName as String)
+ .font(.largeTitle)
+ .foregroundColor(.primary)
+
+ Spacer()
+
+ VStack {
+ Spacer().frame(height: 8)
+ Button(action: {
+ if let activeChats = self.appDelegate.obj.activeChats {
+ //make sure we don't animate anything
+ activeChats.dismissCompleteViewChain(withAnimation: false) {
+ activeChats.presentChat(with:self.contact.obj)
+ }
+ } else {
+ //self.delegate.dismissWithoutAnimation()
+ unreachable("active chats should always be accessible from AVCallUI!")
+ }
+ }, label: {
+ Image(systemName: "text.bubble")
+ .resizable()
+ .frame(width: 28.0, height: 28.0)
+ .foregroundColor(.primary)
+ })
+ }
+
+ Spacer().frame(width:20)
+ }
+
+ Spacer().frame(height: 16)
+
+ //this is needed because ObservableKVOWrapper somehow extracts an NSNumber? from it's wrapped object
+ //which results in a runtime error when trying to cast NSNumber? to MLCallState
+ switch MLCallState(rawValue:call.state) {
+ case .discovering:
+ Text("Discovering devices...")
+ .bold()
+ .foregroundColor(.primary)
+ case .ringing:
+ Text("Ringing...")
+ .bold()
+ .foregroundColor(.primary)
+ case .connecting:
+ Text("Connecting...")
+ .bold()
+ .foregroundColor(.primary)
+ case .reconnecting:
+ Text("Reconnecting...")
+ .bold()
+ .foregroundColor(.primary)
+ case .connected:
+ Text("Connected: \(formatter.string(from: TimeInterval(call.durationTime as UInt))!)")
+ .bold()
+ .foregroundColor(.primary)
+ case .finished:
+ switch MLCallFinishReason(rawValue:call.finishReason) {
+ case .unknown:
+ Text("Call ended for an unknown reason")
+ .bold()
+ .foregroundColor(.primary)
+ case .normal:
+ if call.wasConnectedOnce {
+ Text("Call ended, duration: \(formatter.string(from: TimeInterval(call.durationTime as UInt))!)")
+ .bold()
+ .foregroundColor(.primary)
+ } else {
+ Text("Call ended")
+ .bold()
+ .foregroundColor(.primary)
+ }
+ case .connectivityError:
+ if call.wasConnectedOnce {
+ Text("Call ended: connection failed\nDuration: \(formatter.string(from: TimeInterval(call.durationTime as UInt))!)")
+ .bold()
+ .foregroundColor(.primary)
+ } else {
+ Text("Call ended: connection failed")
+ .bold()
+ .foregroundColor(.primary)
+ }
+ case .securityError:
+ Text("Call ended: couldn't establish encryption")
+ .bold()
+ .foregroundColor(.primary)
+ case .unanswered:
+ Text("Call was not answered")
+ .bold()
+ .foregroundColor(.primary)
+ case .answeredElsewhere:
+ Text("Call ended: answered with other device")
+ .bold()
+ .foregroundColor(.primary)
+ case .retracted:
+ //this will only be displayed for timer-induced retractions,
+ //reflect that in our text instead of using some generic "hung up"
+ //Text("Call ended: hung up")
+ Text("Call ended: remote busy")
+ .bold()
+ .foregroundColor(.primary)
+ case .rejected:
+ Text("Call ended: remote busy")
+ .bold()
+ .foregroundColor(.primary)
+ case .declined:
+ Text("Call ended: declined")
+ .bold()
+ .foregroundColor(.primary)
+ case .error:
+ Text("Call ended: application error")
+ .bold()
+ .foregroundColor(.primary)
+ default: //should never be reached
+ Text("")
+ }
+ default: //should never be reached
+ Text("")
+ }
+
+ Spacer().frame(height: 48)
+
+ if MLCallType(rawValue:call.callType) == .audio || MLCallState(rawValue:call.state) != .connected {
+ Image(uiImage: contact.avatar)
+ .resizable()
+ .frame(minWidth: 100, idealWidth: 150, maxWidth: 200, minHeight: 100, idealHeight: 150, maxHeight: 200, alignment: .center)
+ .scaledToFit()
+ .shadow(radius: 7)
+ }
+
+ Spacer()
+ }
+
+ if MLCallState(rawValue:call.state) == .finished {
+ HStack() {
+ Spacer()
+
+ Button(action: {
+ self.delegate.dismissWithoutAnimation()
+ if let activeChats = self.appDelegate.obj.activeChats {
+ activeChats.call(contact.obj, with:MLCallType(rawValue:call.callType)!)
+ }
+ }) {
+ Image(systemName: "arrow.clockwise.circle.fill")
+ .resizable()
+ .frame(width: 64.0, height: 64.0)
+ .symbolRenderingMode(.palette)
+ .foregroundStyle(.white, .green)
+ .shadow(radius: 7)
+ }
+ .buttonStyle(BorderlessButtonStyle())
+
+ Spacer().frame(width: 64)
+
+ Button(action: {
+ delegate.dismissWithoutAnimation()
+ }) {
+ Image(systemName: "x.circle.fill")
+ .resizable()
+ .frame(width: 64.0, height: 64.0)
+ .symbolRenderingMode(.palette)
+ .foregroundStyle(.white, .red)
+ .shadow(radius: 7)
+ }
+ .buttonStyle(BorderlessButtonStyle())
+
+ Spacer()
+ }
+ } else {
+ HStack() {
+ Spacer()
+
+ if MLCallState(rawValue:call.state) == .connected || MLCallState(rawValue:call.state) == .reconnecting {
+ Button(action: {
+ call.muted = !call.muted
+ }) {
+ Image(systemName: "mic.slash.circle.fill")
+ .resizable()
+ .frame(width: 64.0, height: 64.0)
+ .symbolRenderingMode(.palette)
+ .foregroundStyle(call.muted ? .black : .white, call.muted ? .white : .black)
+ .shadow(radius: 7)
+ }
+ .buttonStyle(BorderlessButtonStyle())
+
+ Spacer().frame(width: 32)
+ }
+
+ Button(action: {
+ call.obj.end()
+ self.delegate.dismissWithoutAnimation()
+ }) {
+ Image(systemName: "phone.down.circle.fill")
+ .resizable()
+ .frame(width: 64.0, height: 64.0)
+ .symbolRenderingMode(.palette)
+ .foregroundStyle(.white, .red)
+ .shadow(radius: 7)
+ }
+ .buttonStyle(BorderlessButtonStyle())
+
+ if MLCallState(rawValue:call.state) == .connected || MLCallState(rawValue:call.state) == .reconnecting {
+ Spacer().frame(width: 32)
+ Button(action: {
+ call.speaker = !call.speaker
+ }) {
+ Image(systemName: "speaker.wave.2.circle.fill")
+ .resizable()
+ .frame(width: 64.0, height: 64.0)
+ .symbolRenderingMode(.palette)
+ .foregroundStyle(call.speaker ? .black : .white, call.speaker ? .white : .black)
+ .shadow(radius: 7)
+ }
+ .buttonStyle(BorderlessButtonStyle())
+ }
+
+ Spacer()
+ }
+ }
+
+ Spacer().frame(height: 32)
+ }
+ }
+ }
+ .onTapGesture(count: 1) {
+ controlsVisible = !controlsVisible
+ }
+ .alert(isPresented: $showMicAlert) {
+ Alert(
+ title: Text("Missing permission"),
+ message: Text("You need to grant microphone access in iOS Settings-> Privacy-> Microphone, if you want that others can hear you."),
+ dismissButton: .default(Text("OK"))
+ )
+ }
+ .richAlert(isPresented:$showSecurityHelpAlert, title:Text("Call security help")) {
+ VStack(alignment: .leading) {
+ HStack {
+ Image(systemName: "xmark.shield.fill")
+ .resizable()
+ .frame(width: 20.0, height: 20.0)
+ .foregroundColor(.red)
+ Spacer().frame(width: 10)
+ Text("Red x-mark shield:")
+ }.font(Font.body.weight(showSecurityHelpAlert == .clear ? .heavy : .medium))
+ Text("This means your call is encrypted, but the remote party could not be verified using OMEMO encryption.\nYour or the callee's XMPP server could possibly Man-In-The-Middle you.")
+ Spacer().frame(height: 20)
+
+ HStack {
+ Image(systemName: "checkmark.shield.fill")
+ .resizable()
+ .frame(width: 20.0, height: 20.0)
+ .foregroundColor(.yellow)
+ Spacer().frame(width: 10)
+ Text("Yellow checkmark shield:")
+ }.font(Font.body.weight(showSecurityHelpAlert == .toFU ? .heavy : .medium))
+ Text("This means your call is encrypted and the remote party was verified using OMEMO encryption.\nBut since you did not manually verify the callee's OMEMO fingerprints, your or the callee's XMPP server could possibly have inserted their own OMEMO keys to Man-In-The-Middle you.")
+ Spacer().frame(height: 20)
+
+ HStack {
+ Image(systemName: "checkmark.shield.fill")
+ .resizable()
+ .frame(width: 20.0, height: 20.0)
+ .foregroundColor(.green)
+ Spacer().frame(width: 10)
+ Text("Green checkmark shield:")
+ }.font(Font.body.weight(showSecurityHelpAlert == .trusted ? .heavy : .medium))
+ Text("This means your call is encrypted and the remote party was verified using OMEMO encryption.\nYou manually verified the used OMEMO keys and no Man-In-The-Middle can take place.")
+ Spacer().frame(height: 20)
+ }
+ }
+ .onAppear {
+ //force portrait mode and lock ui there
+ UIDevice.current.setValue(UIInterfaceOrientation.portrait.rawValue, forKey: "orientation")
+ self.appDelegate.obj.orientationLock = .portrait
+ UIApplication.shared.isIdleTimerDisabled = true
+
+ self.ringingPlayer.numberOfLoops = -1
+ self.busyPlayer.numberOfLoops = -1
+ self.errorPlayer.numberOfLoops = -1
+
+ //ask for mic permissions
+ AVAudioSession.sharedInstance().requestRecordPermission { granted in
+ if !granted {
+ showMicAlert = true
+ }
+ }
+
+ maybeStartRenderer()
+ }
+ .onDisappear {
+ //allow all orientations again
+ self.appDelegate.obj.orientationLock = .all
+ UIApplication.shared.isIdleTimerDisabled = false
+
+ ringingPlayer.stop()
+ busyPlayer.stop()
+ errorPlayer.stop()
+
+ if MLCallType(rawValue:call.callType) == .video {
+ call.obj.stopCaptureLocalVideo()
+ }
+ }
+ .onChange(of: MLCallState(rawValue:call.state)) { state in
+ DDLogVerbose("call state changed: \(String(describing:call.state as NSNumber))")
+ handleStateChange(call.obj.state, appDelegate.obj.audioState)
+ }
+ .onChange(of: MLAudioState(rawValue:appDelegate.audioState)) { audioState in
+ DDLogVerbose("audioState changed: \(String(describing:appDelegate.audioState as NSNumber))")
+ handleStateChange(call.obj.state, appDelegate.obj.audioState)
+ }
+ }
+}
+
+struct AVCallUI_Previews: PreviewProvider {
+ static var delegate = SheetDismisserProtocol()
+ static var previews: some View {
+ AVCallUI(delegate:delegate, call:MLCall.makeDummyCall(0))
+ }
+}
diff --git a/Monal/Classes/AccountListController.h b/Monal/Classes/AccountListController.h
new file mode 100644
index 0000000..7d5f246
--- /dev/null
+++ b/Monal/Classes/AccountListController.h
@@ -0,0 +1,20 @@
+//
+// AccountListController.h
+// Monal
+//
+// Created by Anurodh Pokharel on 6/14/13.
+//
+//
+
+#import
+#import
+#import "MLSwitchCell.h"
+
+@interface AccountListController : UITableViewController
+
+-(NSUInteger) getAccountNum;
+-(NSNumber*) getAccountIDByIndex:(NSUInteger) index;
+-(void) setupAccountsView;
+-(void) refreshAccountList;
+-(void) initContactCell:(MLSwitchCell*) cell forAccNo:(NSUInteger) accNo;
+@end
diff --git a/Monal/Classes/AccountListController.m b/Monal/Classes/AccountListController.m
new file mode 100644
index 0000000..49fb41d
--- /dev/null
+++ b/Monal/Classes/AccountListController.m
@@ -0,0 +1,114 @@
+//
+// AccountListController.m
+// Monal
+//
+// Created by Anurodh Pokharel on 6/14/13.
+//
+//
+
+#import "AccountListController.h"
+#import "DataLayer.h"
+#import "MLXMPPManager.h"
+#import "HelperTools.h"
+
+@interface AccountListController ()
+@property (nonatomic, strong) NSDateFormatter* uptimeFormatter;
+
+@property (nonatomic, strong) NSIndexPath* selected; // User-selected account - needed for segue
+@property (nonatomic, strong) UITableView* accountsTable;
+@property (nonatomic, strong) NSArray* accountList;
+
+@end
+
+@implementation AccountListController
+
+
+#pragma mark View life cycle
+- (void) setupAccountsView
+{
+ // Do any additional setup after loading the view.
+ self.accountsTable.backgroundView = nil;
+ self.accountsTable = self.tableView;
+ self.accountsTable.delegate = self;
+ self.accountsTable.dataSource = self;
+
+ dispatch_async(dispatch_get_main_queue(), ^{
+ [self.accountsTable reloadData];
+ });
+
+ self.uptimeFormatter = [NSDateFormatter new];
+ self.uptimeFormatter.dateStyle = NSDateFormatterShortStyle;
+ self.uptimeFormatter.timeStyle = NSDateFormatterShortStyle;
+ self.uptimeFormatter.doesRelativeDateFormatting = YES;
+ self.uptimeFormatter.locale = [NSLocale currentLocale];
+ self.uptimeFormatter.timeZone = [NSTimeZone systemTimeZone];
+
+ NSNotificationCenter* nc = [NSNotificationCenter defaultCenter];
+ [nc addObserver:self selector:@selector(refreshAccountList) name:kMonalAccountStatusChanged object:nil];
+}
+
+-(void) dealloc
+{
+ [[NSNotificationCenter defaultCenter] removeObserver:self];
+}
+
+-(NSUInteger) getAccountNum
+{
+ return self.accountList.count;
+}
+
+-(NSNumber*) getAccountIDByIndex:(NSUInteger) index
+{
+ NSNumber* result = [[self.accountList objectAtIndex: index] objectForKey:@"account_id"];
+ MLAssert(result != nil, @"getAccountIDByIndex, result should not be nil");
+ return result;
+}
+
+-(void) refreshAccountList
+{
+ NSArray* accountList = [[DataLayer sharedInstance] accountList];
+ dispatch_async(dispatch_get_main_queue(), ^{
+ self.accountList = accountList;
+ [self.accountsTable reloadData];
+ });
+}
+
+-(void) initContactCell:(MLSwitchCell*) cell forAccNo:(NSUInteger) accNo
+{
+ [cell initTapCell:@"\n\n"];
+ NSDictionary* account = [self.accountList objectAtIndex:accNo];
+ MLAssert(account != nil, ([NSString stringWithFormat:@"Expected non nil account in row %lu", (unsigned long)accNo]));
+ if([(NSString*)[account objectForKey:@"domain"] length] > 0) {
+ cell.textLabel.text = [NSString stringWithFormat:@"%@@%@", [[self.accountList objectAtIndex:accNo] objectForKey:@"username"],
+ [[self.accountList objectAtIndex:accNo] objectForKey:@"domain"]];
+ }
+ else
+ {
+ cell.textLabel.text = [[self.accountList objectAtIndex:accNo] objectForKey:@"username"];
+ }
+
+ UIImageView* accessory = [[UIImageView alloc] initWithFrame:CGRectMake(0, 0, 30, 30)];
+
+ if([[account objectForKey:@"enabled"] boolValue] == YES)
+ {
+ cell.imageView.image = [UIImage systemImageNamed:@"checkmark.circle"];
+ if([[MLXMPPManager sharedInstance] isAccountForIdConnected:[[self.accountList objectAtIndex:accNo] objectForKey:@"account_id"]])
+ {
+ accessory.image = [UIImage imageNamed:@"Connected"];
+ cell.accessoryView = accessory;
+ }
+ else
+ {
+ accessory.image = [UIImage imageNamed:@"Disconnected"];
+ cell.accessoryView = accessory;
+ }
+ }
+ else
+ {
+ cell.imageView.image = [UIImage systemImageNamed:@"circle"];
+ accessory.image = nil;
+ cell.accessoryView = accessory;
+ }
+}
+
+@end
diff --git a/Monal/Classes/AccountPicker.swift b/Monal/Classes/AccountPicker.swift
new file mode 100644
index 0000000..3704fe5
--- /dev/null
+++ b/Monal/Classes/AccountPicker.swift
@@ -0,0 +1,66 @@
+//
+// AccountPicker.swift
+// Monal
+//
+// Created by Thilo Molitor on 20.01.23.
+// Copyright © 2023 monal-im.org. All rights reserved.
+//
+
+struct AccountPicker: View {
+ let contacts: [MLContact]
+ let callType: MLCallType
+#if IS_ALPHA
+ let appLogoId = "AlphaAppLogo"
+#elseif IS_QUICKSY
+ let appLogoId = "QuicksyAppLogo"
+#else
+ let appLogoId = "AppLogo"
+#endif
+
+ init(contacts:[MLContact], callType: MLCallType) {
+ self.contacts = contacts
+ self.callType = callType
+ }
+
+ var body: some View {
+ //ScrollView {
+ VStack {
+ HStack () {
+ Image(decorative: appLogoId)
+ .resizable()
+ .frame(width: CGFloat(120), height: CGFloat(120), alignment: .center)
+ .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
+ .padding()
+ Text("You are trying to call '\(contacts.first!.contactDisplayName)' (\(contacts.first!.contactJid)), but this contact can be reached using different accounts. Please select the account you want to place the outgoing call with.")
+ .padding()
+ .padding(.leading, -16.0)
+ }
+ .frame(maxWidth: .infinity)
+ .background(Color(UIColor.systemBackground))
+
+ List {
+ ForEach(contacts) { contact in
+ if let accountEntry = DataLayer.sharedInstance().details(forAccount:contact.accountID) {
+ let accountJid = "\(accountEntry["username"] ?? "" as NSString)@\(accountEntry["domain"] ?? "" as NSString)"
+ let accountContact = MLContact.createContact(fromJid:accountJid, andAccountID:accountEntry["account_id"] as! NSNumber)
+ Button {
+ (UIApplication.shared.delegate as! MonalAppDelegate).activeChats!.call(contact, with:callType)
+ } label: {
+ ContactEntry(contact:ObservableKVOWrapper(accountContact), selfnotesPrefix:false)
+ }
+ }
+ }
+ }
+ .listStyle(.insetGrouped)
+ }
+ //}
+ .textFieldStyle(.roundedBorder)
+ .navigationBarTitle(Text("Account Picker"))
+ }
+}
+
+struct AccountPicker_Previews: PreviewProvider {
+ static var previews: some View {
+ AccountPicker(contacts:[MLContact.makeDummyContact(0)], callType:.audio)
+ }
+}
diff --git a/Monal/Classes/ActiveChatsViewController.h b/Monal/Classes/ActiveChatsViewController.h
new file mode 100644
index 0000000..05790e1
--- /dev/null
+++ b/Monal/Classes/ActiveChatsViewController.h
@@ -0,0 +1,64 @@
+//
+// ActiveChatsViewController.h
+// Monal
+//
+// Created by Anurodh Pokharel on 6/14/13.
+//
+//
+
+#import
+#import "MLConstants.h"
+#import "MLContact.h"
+#import "MLCall.h"
+#import
+
+NS_ASSUME_NONNULL_BEGIN
+
+@class UIHostingControllerWorkaround;
+@class chatViewController;
+@class MLCall;
+
+@interface SizeClassWrapper: NSObject
+@property (atomic) UIUserInterfaceSizeClass horizontal;
+@end
+
+@interface ActiveChatsViewController : UITableViewController
+
+@property (nonatomic, strong) UITableView* chatListTable;
+@property (nonatomic, weak) IBOutlet UIBarButtonItem* settingsButton;
+@property (weak, nonatomic) IBOutlet UIBarButtonItem* spinnerButton;
+@property (nonatomic, weak) IBOutlet UIBarButtonItem* composeButton;
+@property (nonatomic, strong) UIActivityIndicatorView* spinner;
+@property (atomic, strong) SizeClassWrapper* sizeClass;
+@property (atomic, readonly) chatViewController* _Nullable currentChatView;
+
+-(void) showCallContactNotFoundAlert:(NSString*) jid;
+-(void) callContact:(MLContact*) contact withUIKitSender:(_Nullable id) sender;
+-(void) callContact:(MLContact*) contact withCallType:(MLCallType) callType;
+-(void) presentAccountPickerForContacts:(NSArray*) contacts andCallType:(MLCallType) callType;
+-(void) presentCall:(MLCall*) call;
+-(void) presentChatWithContact:(MLContact* _Nullable) contact;
+-(void) presentChatWithContact:(MLContact* _Nullable) contact andCompletion:(monal_id_block_t _Nullable) completion;
+-(void) presentSplitPlaceholder;
+-(void) refreshDisplay;
+
+-(void) showContacts;
+-(void) deleteConversation;
+-(void) showSettings;
+-(void) showGeneralSettings;
+-(void) prependGeneralSettings;
+-(void) showNotificationSettings;
+-(void) showDetails;
+-(void) showRegisterWithUsername:(NSString*) username onHost:(NSString*) host withToken:(NSString* _Nullable) token usingCompletion:(monal_id_block_t _Nullable) callback;
+-(void) showAddContactWithJid:(NSString*) jid preauthToken:(NSString* _Nullable) preauthToken prefillAccount:(xmpp* _Nullable) account andOmemoFingerprints:(NSDictionary* _Nullable) fingerprints;
+-(void) showAddContact;
+-(void) sheetDismissed;
+
+-(void) segueToIntroScreensIfNeeded;
+-(void) resetViewQueue;
+-(void) dismissCompleteViewChainWithAnimation:(BOOL) animation andCompletion:(monal_void_block_t _Nullable) completion;
+-(void) updateSizeClass;
+
+@end
+
+NS_ASSUME_NONNULL_END
diff --git a/Monal/Classes/ActiveChatsViewController.m b/Monal/Classes/ActiveChatsViewController.m
new file mode 100755
index 0000000..7442ac9
--- /dev/null
+++ b/Monal/Classes/ActiveChatsViewController.m
@@ -0,0 +1,1558 @@
+//
+// ActiveChatsViewController.m
+// Monal
+//
+// Created by Anurodh Pokharel on 6/14/13.
+//
+//
+
+#include "metamacros.h"
+
+#import
+#import "ActiveChatsViewController.h"
+#import "DataLayer.h"
+#import "xmpp.h"
+#import "MLContactCell.h"
+#import "chatViewController.h"
+#import "MonalAppDelegate.h"
+#import "MLImageManager.h"
+#import "MLXEPSlashMeHandler.h"
+#import "MLNotificationQueue.h"
+#import "MLSettingsAboutViewController.h"
+#import "MLVoIPProcessor.h"
+#import "MLCall.h" //for MLCallType
+#import "XMPPIQ.h"
+#import "MLIQProcessor.h"
+#import "Quicksy_Country.h"
+#import
+
+#define prependToViewQueue(firstArg, ...) metamacro_if_eq(0, metamacro_argcount(__VA_ARGS__))([self prependToViewQueue:firstArg withId:MLViewIDUnspecified andFile:(char*)__FILE__ andLine:__LINE__ andFunc:(char*)__func__])(_prependToViewQueue(firstArg, __VA_ARGS__))
+#define _prependToViewQueue(ownId, block) [self prependToViewQueue:block withId:ownId andFile:(char*)__FILE__ andLine:__LINE__ andFunc:(char*)__func__]
+#define appendToViewQueue(firstArg, ...) metamacro_if_eq(0, metamacro_argcount(__VA_ARGS__))([self appendToViewQueue:firstArg withId:MLViewIDUnspecified andFile:(char*)__FILE__ andLine:__LINE__ andFunc:(char*)__func__])(_appendToViewQueue(firstArg, __VA_ARGS__))
+#define _appendToViewQueue(ownId, block) [self prependToViewQueue:block withId:ownId andFile:(char*)__FILE__ andLine:__LINE__ andFunc:(char*)__func__]
+#define appendingReplaceOnViewQueue(firstArg, secondArg, ...) metamacro_if_eq(0, metamacro_argcount(__VA_ARGS__))([self replaceIdOnViewQueue:firstArg withBlock:secondArg havingId:MLViewIDUnspecified andAppendOnUnknown:YES withFile:(char*)__FILE__ andLine:__LINE__ andFunc:(char*)__func__])(_appendingReplaceOnViewQueue(firstArg, secondArg, __VA_ARGS__))
+#define prependingReplaceOnViewQueue(firstArg, secondArg, ...) metamacro_if_eq(0, metamacro_argcount(__VA_ARGS__))([self replaceIdOnViewQueue:firstArg withBlock:secondArg havingId:MLViewIDUnspecified andAppendOnUnknown:NO withFile:(char*)__FILE__ andLine:__LINE__ andFunc:(char*)__func__])(_prependingReplaceOnViewQueue(firstArg, secondArg, __VA_ARGS__))
+#define _appendingReplaceOnViewQueue(replaceId, ownId, block) [self replaceIdOnViewQueue:replaceId withBlock:block havingId:ownId andAppendOnUnknown:YES withFile:(char*)__FILE__ andLine:__LINE__ andFunc:(char*)__func__]
+#define _prependingReplaceOnViewQueue(replaceId, ownId, block) [self replaceIdOnViewQueue:replaceId withBlock:block havingId:ownId andAppendOnUnknown:NO withFile:(char*)__FILE__ andLine:__LINE__ andFunc:(char*)__func__]
+typedef void (^view_queue_block_t)(PMKResolver _Nonnull);
+
+@import QuartzCore.CATransaction;
+
+@interface DZNEmptyDataSetView
+@property (atomic, strong) UIView* contentView;
+@property (atomic, strong) UIImageView* imageView;
+@property (atomic, strong) UILabel* titleLabel;
+@property (atomic, strong) UILabel* detailLabel;
+@end
+
+@interface UIScrollView ()
+@property (nonatomic, readonly) DZNEmptyDataSetView* emptyDataSetView;
+@end
+
+@interface ActiveChatsViewController() {
+ int _startedOrientation;
+ double _portraitTop;
+ double _landscapeTop;
+ BOOL _loginAlreadyAutodisplayed;
+ NSMutableArray* _blockQueue;
+ dispatch_semaphore_t _blockQueueSemaphore;
+}
+@property (atomic, strong) NSMutableArray* unpinnedContacts;
+@property (atomic, strong) NSMutableArray* pinnedContacts;
+@end
+
+@implementation SizeClassWrapper
+@end
+
+@implementation ActiveChatsViewController
+
+enum activeChatsControllerSections {
+ pinnedChats,
+ unpinnedChats,
+ activeChatsViewControllerSectionCnt
+};
+
+typedef NS_ENUM(NSUInteger, MLViewID) {
+ MLViewIDUnspecified,
+ MLViewIDRegisterView,
+ MLViewIDWelcomeLoginView,
+};
+
+static NSMutableSet* _mamWarningDisplayed;
+static NSMutableSet* _smacksWarningDisplayed;
+static NSMutableSet* _pushWarningDisplayed;
+
++(void) initialize
+{
+ DDLogDebug(@"initializing active chats class");
+ _mamWarningDisplayed = [NSMutableSet new];
+ _smacksWarningDisplayed = [NSMutableSet new];
+ _pushWarningDisplayed = [NSMutableSet new];
+}
+
+-(instancetype)initWithNibName:(NSString*) nibNameOrNil bundle:(NSBundle*) nibBundleOrNil
+{
+ self = [super initWithNibName:nibNameOrNil bundle:nibBundleOrNil];
+ [self commonInit];
+ return self;
+}
+
+-(instancetype) initWithStyle:(UITableViewStyle) style
+{
+ self = [super initWithStyle:style];
+ [self commonInit];
+ return self;
+}
+
+-(instancetype) initWithCoder:(NSCoder*) coder
+{
+ self = [super initWithCoder:coder];
+ [self commonInit];
+ return self;
+}
+
+-(void) commonInit
+{
+ _blockQueue = [NSMutableArray new];
+ _blockQueueSemaphore = dispatch_semaphore_create(1);
+}
+
+-(void) resetViewQueue
+{
+ [_blockQueue removeAllObjects];
+}
+
+-(void) prependToViewQueue:(view_queue_block_t) block withId:(MLViewID) viewId andFile:(char*) file andLine:(int) line andFunc:(char*) func
+{
+ @synchronized(_blockQueue) {
+ DDLogDebug(@"Prepending block with id %lu defined in %s at %@:%d to queue...", (unsigned long)viewId, func, [HelperTools sanitizeFilePath:file], line);
+ [_blockQueue insertObject:@{@"id":@(viewId), @"block":^(PMKResolver resolve) {
+ DDLogDebug(@"Calling block with id %lu defined in %s at %@:%d...", (unsigned long)viewId, func, [HelperTools sanitizeFilePath:file], line);
+ block(resolve);
+ DDLogDebug(@"Block with id %lu defined in %s at %@:%d finished...", (unsigned long)viewId, func, [HelperTools sanitizeFilePath:file], line);
+ }} atIndex:0];
+ }
+ [self processViewQueue];
+}
+
+-(void) appendToViewQueue:(view_queue_block_t) block withId:(MLViewID) viewId andFile:(char*) file andLine:(int) line andFunc:(char*) func
+{
+ @synchronized(_blockQueue) {
+ DDLogDebug(@"Appending block with id %lu defined in %s at %@:%d to queue...", (unsigned long)viewId, func, [HelperTools sanitizeFilePath:file], line);
+ [_blockQueue addObject:@{@"id":@(viewId), @"block":^(PMKResolver resolve) {
+ DDLogDebug(@"Calling block with id %lu defined in %s at %@:%d...", (unsigned long)viewId, func, [HelperTools sanitizeFilePath:file], line);
+ block(resolve);
+ DDLogDebug(@"Block with id %lu defined in %s at %@:%d finished...", (unsigned long)viewId, func, [HelperTools sanitizeFilePath:file], line);
+ }}];
+ }
+ [self processViewQueue];
+}
+
+-(void) replaceIdOnViewQueue:(MLViewID) previousId withBlock:(view_queue_block_t) block havingId:(MLViewID) viewId andAppendOnUnknown:(BOOL) appendOnUnknown withFile:(char*) file andLine:(int) line andFunc:(char*) func
+{
+ @synchronized(_blockQueue) {
+ DDLogDebug(@"Replacing block with id %lu with new block having id %lu defined in %s at %@:%d to queue...", (unsigned long)previousId, (unsigned long)viewId, func, [HelperTools sanitizeFilePath:file], line);
+
+ //search for old block to replace and remove it
+ NSInteger index = -1;
+ for(NSDictionary* blockInfo in _blockQueue)
+ {
+ index++;
+ if(((NSNumber*)blockInfo[@"id"]).unsignedIntegerValue == previousId)
+ {
+ DDLogDebug(@"Found blockInfo at index %d: %@", (int)index, blockInfo);
+ [self->_blockQueue removeObjectAtIndex:index];
+ break;
+ }
+ }
+ if(index == -1)
+ {
+ if(appendOnUnknown)
+ {
+ DDLogDebug(@"Did not find block with id %lu on queue, appending block instead...", (unsigned long)previousId);
+ [self appendToViewQueue:block withId:viewId andFile:file andLine:line andFunc:func];
+ }
+ else
+ {
+ DDLogDebug(@"Did not find block with id %lu on queue, prepending block instead...", (unsigned long)previousId);
+ [self prependToViewQueue:block withId:viewId andFile:file andLine:line andFunc:func];
+ }
+ return;
+ }
+
+ //add replaement block at right position
+ [_blockQueue insertObject:@{@"id":@(viewId), @"block":^(PMKResolver resolve) {
+ DDLogDebug(@"Calling block with id %lu defined in %s at %@:%d...", (unsigned long)viewId, func, [HelperTools sanitizeFilePath:file], line);
+ block(resolve);
+ DDLogDebug(@"Block with id %lu defined in %s at %@:%d finished...", (unsigned long)viewId, func, [HelperTools sanitizeFilePath:file], line);
+ }} atIndex:index];
+ }
+ [self processViewQueue];
+}
+
+-(void) processViewQueue
+{
+ //we are using uikit api all over the place: make sure we always run in the main queue
+ [HelperTools dispatchAsync:YES reentrantOnQueue:dispatch_get_main_queue() withBlock:^{
+ NSMutableArray* viewControllerHierarchy = [self getCurrentViewControllerHierarchy];
+
+ //don't show the next entry if there is still the previous one
+ //if(self.splitViewController.collapsed)
+ if([viewControllerHierarchy count] > 0)
+ {
+ DDLogDebug(@"Ignoring call to processViewQueue, already showing: %@", viewControllerHierarchy);
+ return;
+ }
+
+ //don't run the next block if the previous one did not yet complete
+ if(dispatch_semaphore_wait(self->_blockQueueSemaphore, DISPATCH_TIME_NOW) != 0)
+ {
+ DDLogDebug(@"Ignoring call to processViewQueue, block still running, showing: %@", viewControllerHierarchy);
+ return;
+ }
+
+ NSDictionary* blockInfo = nil;
+ @synchronized(self->_blockQueue) {
+ if(self->_blockQueue.count > 0)
+ {
+ blockInfo = [self->_blockQueue objectAtIndex:0];
+ [self->_blockQueue removeObjectAtIndex:0];
+ }
+ else
+ DDLogDebug(@"Queue is empty...");
+ }
+ if(blockInfo)
+ {
+ //DDLogDebug(@"Calling next block, stacktrace: %@", [NSThread callStackSymbols]);
+ monal_void_block_t looper = ^{
+ dispatch_semaphore_signal(self->_blockQueueSemaphore);
+ DDLogDebug(@"Looping to next block...");
+ [self processViewQueue];
+ };
+ [AnyPromise promiseWithResolverBlock:^(PMKResolver resolve) {
+ ((view_queue_block_t)blockInfo[@"block"])(resolve);
+ }].ensure(^{
+ looper();
+ });
+ }
+ else
+ {
+ DDLogDebug(@"Not calling next block: there is none...");
+ dispatch_semaphore_signal(self->_blockQueueSemaphore);
+ }
+ }];
+}
+
+#pragma mark view lifecycle
+
+-(void) configureComposeButton
+{
+ UIImage* image = [[UIImage systemImageNamed:@"person.2.fill"] imageWithTintColor:UIColor.tintColor];
+ UITapGestureRecognizer* tapRecognizer = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(showContacts:)];
+ self.composeButton.customView = [HelperTools
+ buttonWithNotificationBadgeForImage:image
+ hasNotification:[[DataLayer sharedInstance] allContactRequests].count > 0
+ withTapHandler:tapRecognizer];
+ [self.composeButton setIsAccessibilityElement:YES];
+ if([[DataLayer sharedInstance] allContactRequests].count > 0)
+ [self.composeButton setAccessibilityLabel:NSLocalizedString(@"Open contact list (contact requests pending)", @"")];
+ else
+ [self.composeButton setAccessibilityLabel:NSLocalizedString(@"Open contact list", @"")];
+ [self.composeButton setAccessibilityTraits:UIAccessibilityTraitButton];
+}
+
+-(void) viewDidLoad
+{
+ DDLogDebug(@"active chats view did load");
+ [super viewDidLoad];
+
+ _loginAlreadyAutodisplayed = NO;
+ _startedOrientation = 0;
+
+ self.spinner = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:UIActivityIndicatorViewStyleMedium];
+ self.spinner.hidesWhenStopped = YES;
+
+ self.view.backgroundColor = [UIColor lightGrayColor];
+ self.view.autoresizingMask = UIViewAutoresizingFlexibleHeight|UIViewAutoresizingFlexibleWidth;
+
+ MonalAppDelegate* appDelegate = (MonalAppDelegate*)[[UIApplication sharedApplication] delegate];
+ appDelegate.activeChats = self;
+
+ self.chatListTable = [UITableView new];
+ self.chatListTable.delegate = self;
+ self.chatListTable.dataSource = self;
+
+ self.view = self.chatListTable;
+
+ self.sizeClass = [SizeClassWrapper new];
+ [self updateSizeClass];
+
+ NSNotificationCenter* nc = [NSNotificationCenter defaultCenter];
+ [nc addObserver:self selector:@selector(handleRefreshDisplayNotification:) name:kMonalRefresh object:nil];
+ [nc addObserver:self selector:@selector(handleContactRemoved:) name:kMonalContactRemoved object:nil];
+ [nc addObserver:self selector:@selector(handleRefreshDisplayNotification:) name:kMonalMessageFiletransferUpdateNotice object:nil];
+ [nc addObserver:self selector:@selector(refreshContact:) name:kMonalContactRefresh object:nil];
+ [nc addObserver:self selector:@selector(handleNewMessage:) name:kMonalNewMessageNotice object:nil];
+ [nc addObserver:self selector:@selector(handleNewMessage:) name:kMonalDeletedMessageNotice object:nil];
+ [nc addObserver:self selector:@selector(messageSent:) name:kMLMessageSentToContact object:nil];
+ [nc addObserver:self selector:@selector(handleDeviceRotation) name:UIDeviceOrientationDidChangeNotification object:nil];
+ [nc addObserver:self selector:@selector(showWarningsIfNeeded) name:kMonalFinishedCatchup object:nil];
+
+ [_chatListTable registerNib:[UINib nibWithNibName:@"MLContactCell" bundle:[NSBundle mainBundle]] forCellReuseIdentifier:@"ContactCell"];
+
+ self.splitViewController.preferredDisplayMode = UISplitViewControllerDisplayModeOneBesideSecondary;
+#if !TARGET_OS_MACCATALYST
+ self.splitViewController.primaryBackgroundStyle = UISplitViewControllerBackgroundStyleSidebar;
+#endif
+ self.settingsButton.image = [UIImage systemImageNamed:@"gearshape.fill"];
+ [self configureComposeButton];
+
+ self.spinnerButton.customView = self.spinner;
+
+ self.chatListTable.emptyDataSetSource = self;
+ self.chatListTable.emptyDataSetDelegate = self;
+
+ //has to be done here to not always prepend intro screens onto our view queue
+ //once a fullscreen view is dismissed (or the app is switched to foreground)
+ [self segueToIntroScreensIfNeeded];
+}
+
+-(void) dealloc
+{
+ DDLogDebug(@"active chats dealloc");
+ [[NSNotificationCenter defaultCenter] removeObserver:self];
+}
+
+-(void) handleDeviceRotation
+{
+ dispatch_async(dispatch_get_main_queue(), ^{
+ [self imageForEmptyDataSet:nil];
+ [self.chatListTable setNeedsDisplay];
+ });
+}
+
+-(void) refreshDisplay
+{
+ size_t unpinnedConCntBefore = self.unpinnedContacts.count;
+ size_t pinnedConCntBefore = self.pinnedContacts.count;
+ NSMutableArray* newUnpinnedContacts = [[DataLayer sharedInstance] activeContactsWithPinned:NO];
+ NSMutableArray* newPinnedContacts = [[DataLayer sharedInstance] activeContactsWithPinned:YES];
+ if(!newUnpinnedContacts || ! newPinnedContacts)
+ return;
+
+ int unpinnedCntDiff = (int)unpinnedConCntBefore - (int)newUnpinnedContacts.count;
+ int pinnedCntDiff = (int)pinnedConCntBefore - (int)newPinnedContacts.count;
+
+ void (^resizeSections)(UITableView*, size_t, int) = ^void(UITableView* table, size_t section, int diff){
+ if(diff > 0)
+ {
+ // remove rows
+ for(int i = 0; i < diff; i++)
+ {
+ NSIndexPath* posInSection = [NSIndexPath indexPathForRow:i inSection:section];
+ [table deleteRowsAtIndexPaths:@[posInSection] withRowAnimation:UITableViewRowAnimationNone];
+ }
+ }
+ else if(diff < 0)
+ {
+ // add rows
+ for(size_t i = (-1) * diff; i > 0; i--)
+ {
+ NSIndexPath* posInSectin = [NSIndexPath indexPathForRow:(i - 1) inSection:section];
+ [table insertRowsAtIndexPaths:@[posInSectin] withRowAnimation:UITableViewRowAnimationNone];
+ }
+ }
+ };
+
+ dispatch_async(dispatch_get_main_queue(), ^{
+ //make sure we don't display a chat view for a disabled account
+ if([MLNotificationManager sharedInstance].currentContact != nil)
+ {
+ BOOL found = NO;
+ for(NSDictionary* accountDict in [[DataLayer sharedInstance] enabledAccountList])
+ {
+ NSNumber* accountID = accountDict[kAccountID];
+ if([MLNotificationManager sharedInstance].currentContact.accountID.intValue == accountID.intValue)
+ found = YES;
+ }
+ if(!found)
+ [self presentChatWithContact:nil];
+ }
+
+ if(self.chatListTable.hasUncommittedUpdates)
+ return;
+ [CATransaction begin];
+ [UIView performWithoutAnimation:^{
+ [self.chatListTable beginUpdates];
+ resizeSections(self.chatListTable, unpinnedChats, unpinnedCntDiff);
+ resizeSections(self.chatListTable, pinnedChats, pinnedCntDiff);
+ self.unpinnedContacts = newUnpinnedContacts;
+ self.pinnedContacts = newPinnedContacts;
+ [self.chatListTable reloadSections:[NSIndexSet indexSetWithIndex:pinnedChats] withRowAnimation:UITableViewRowAnimationNone];
+ [self.chatListTable reloadSections:[NSIndexSet indexSetWithIndex:unpinnedChats] withRowAnimation:UITableViewRowAnimationNone];
+ [self.chatListTable endUpdates];
+ }];
+ [CATransaction commit];
+ [self.chatListTable reloadEmptyDataSet];
+
+ MonalAppDelegate* appDelegate = (MonalAppDelegate*)[UIApplication sharedApplication].delegate;
+ [appDelegate updateUnread];
+ });
+}
+
+-(void) refreshContact:(NSNotification*) notification
+{
+ MLContact* contact = [notification.userInfo objectForKey:@"contact"];
+ DDLogInfo(@"Refreshing contact %@ at %@: unread=%lu", contact.contactJid, contact.accountID, (unsigned long)contact.unreadCount);
+
+ //update red dot
+ dispatch_async(dispatch_get_main_queue(), ^{
+ [self configureComposeButton];
+ });
+
+ // if pinning changed we have to move the user to a other section
+ if([notification.userInfo objectForKey:@"pinningChanged"])
+ [self insertOrMoveContact:contact completion:nil];
+ else
+ {
+ dispatch_async(dispatch_get_main_queue(), ^{
+ NSIndexPath* indexPath = nil;
+ for(size_t section = pinnedChats; section < activeChatsViewControllerSectionCnt && !indexPath; section++)
+ {
+ NSMutableArray* curContactArray = [self getChatArrayForSection:section];
+ // check if contact is already displayed -> get coresponding indexPath
+ NSUInteger rowIdx = 0;
+ for(MLContact* rowContact in curContactArray)
+ {
+ if([rowContact isEqualToContact:contact])
+ {
+ //this MLContact instance is used in various ui parts, not just this file --> update all properties but keep the instance intact
+ [rowContact updateWithContact:contact];
+ indexPath = [NSIndexPath indexPathForRow:rowIdx inSection:section];
+ break;
+ }
+ rowIdx++;
+ }
+ }
+ // reload contact entry if we found it
+ if(indexPath)
+ {
+ DDLogDebug(@"Reloading row at %@", indexPath);
+ [self.chatListTable reloadRowsAtIndexPaths:@[indexPath] withRowAnimation:UITableViewRowAnimationNone];
+ }
+ [self.chatListTable reloadEmptyDataSet];
+ });
+ }
+}
+
+-(void) handleRefreshDisplayNotification:(NSNotification*) notification
+{
+ // filter notifcations from within this class
+ if([notification.object isKindOfClass:[ActiveChatsViewController class]])
+ return;
+ [self refresh];
+}
+
+-(void) handleContactRemoved:(NSNotification*) notification
+{
+ MLContact* removedContact = [notification.userInfo objectForKey:@"contact"];
+ if(removedContact == nil)
+ unreachable();
+
+ dispatch_async(dispatch_get_main_queue(), ^{
+ DDLogInfo(@"Contact removed, refreshing active chats...");
+
+ //update red dot
+ [self configureComposeButton];
+
+ // remove contact from activechats table
+ [self refreshDisplay];
+
+ // open placeholder if the removed contact was "in foreground"
+ if([removedContact isEqualToContact:[MLNotificationManager sharedInstance].currentContact])
+ {
+ DDLogInfo(@"Contact removed, closing chat view...");
+ [self presentChatWithContact:nil];
+ }
+ });
+}
+
+
+-(void) messageSent:(NSNotification*) notification
+{
+ MLContact* contact = [notification.userInfo objectForKey:@"contact"];
+ if(!contact)
+ unreachable();
+ [self insertOrMoveContact:contact completion:nil];
+}
+
+-(void) handleNewMessage:(NSNotification*) notification
+{
+ MLMessage* newMessage = notification.userInfo[@"message"];
+ MLContact* contact = notification.userInfo[@"contact"];
+ xmpp* msgAccount = (xmpp*)notification.object;
+ if(!newMessage || !contact || !msgAccount)
+ {
+ unreachable();
+ return;
+ }
+ if([newMessage.messageType isEqualToString:kMessageTypeStatus])
+ return;
+
+ // contact.statusMessage = newMessage;
+ [self insertOrMoveContact:contact completion:nil];
+}
+
+-(void) insertOrMoveContact:(MLContact*) contact completion:(void (^ _Nullable)(BOOL finished)) completion
+{
+ dispatch_async(dispatch_get_main_queue(), ^{
+ [self.chatListTable performBatchUpdates:^{
+ __block NSIndexPath* indexPath = nil;
+ for(size_t section = pinnedChats; section < activeChatsViewControllerSectionCnt && !indexPath; section++) {
+ NSMutableArray* curContactArray = [self getChatArrayForSection:section];
+
+ // check if contact is already displayed -> get coresponding indexPath
+ [curContactArray enumerateObjectsUsingBlock:^(id _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
+ MLContact* rowContact = (MLContact *) obj;
+ if([rowContact isEqualToContact:contact])
+ {
+ indexPath = [NSIndexPath indexPathForRow:idx inSection:section];
+ *stop = YES;
+ }
+ }];
+ }
+
+ size_t insertInSection = unpinnedChats;
+ if(contact.isPinned) {
+ insertInSection = pinnedChats;
+ }
+ NSMutableArray* insertContactToArray = [self getChatArrayForSection:insertInSection];
+ NSIndexPath* insertAtPath = [NSIndexPath indexPathForRow:0 inSection:insertInSection];
+
+ if(indexPath && insertAtPath.section == indexPath.section && insertAtPath.row == indexPath.row)
+ {
+ [insertContactToArray replaceObjectAtIndex:insertAtPath.row withObject:contact];
+ [self.chatListTable reloadRowsAtIndexPaths:@[insertAtPath] withRowAnimation:UITableViewRowAnimationNone];
+ return;
+ }
+ else if(indexPath)
+ {
+ // Contact is already in our active chats list
+ NSMutableArray* removeContactFromArray = [self getChatArrayForSection:indexPath.section];
+ [self.chatListTable deleteRowsAtIndexPaths:@[indexPath] withRowAnimation:UITableViewRowAnimationNone];
+ [removeContactFromArray removeObjectAtIndex:indexPath.row];
+ [insertContactToArray insertObject:contact atIndex:0];
+ [self.chatListTable insertRowsAtIndexPaths:@[insertAtPath] withRowAnimation:UITableViewRowAnimationNone];
+ }
+ else
+ {
+ // Chats does not exists in active Chats yet
+ NSUInteger oldCount = [insertContactToArray count];
+ [insertContactToArray insertObject:contact atIndex:0];
+ [self.chatListTable insertRowsAtIndexPaths:@[insertAtPath] withRowAnimation:UITableViewRowAnimationRight];
+ //make sure to fully refresh to remove the empty dataset (yes this will trigger on first chat pinning, too, but that does no harm)
+ if(oldCount == 0)
+ [self refreshDisplay];
+ }
+ } completion:^(BOOL finished) {
+ if(completion) completion(finished);
+ }];
+ });
+}
+
+-(void) viewWillAppear:(BOOL) animated
+{
+ DDLogDebug(@"active chats view will appear");
+ [super viewWillAppear:animated];
+
+ [self presentSplitPlaceholder];
+}
+
+-(void) viewWillDisappear:(BOOL) animated
+{
+ DDLogDebug(@"active chats view will disappear");
+ [super viewWillDisappear:animated];
+}
+
+-(void) viewDidAppear:(BOOL) animated
+{
+ DDLogDebug(@"active chats view did appear");
+ [super viewDidAppear:animated];
+
+ [self refresh];
+}
+
+-(void) sheetDismissed
+{
+ [self refresh];
+}
+
+-(void) refresh
+{
+ dispatch_async(dispatch_get_main_queue(), ^{
+ [self refreshDisplay]; // load contacts
+ [self processViewQueue];
+ });
+}
+
+-(void) didReceiveMemoryWarning
+{
+ [super didReceiveMemoryWarning];
+}
+
+-(void) showAddContactWithJid:(NSString*) jid preauthToken:(NSString* _Nullable) preauthToken prefillAccount:(xmpp* _Nullable) account andOmemoFingerprints:(NSDictionary* _Nullable) fingerprints
+{
+ //check if contact is already known in any of our accounts and open a chat with the first contact we can find
+ for(xmpp* checkAccount in [MLXMPPManager sharedInstance].connectedXMPP)
+ {
+ MLContact* checkContact = [MLContact createContactFromJid:jid andAccountID:checkAccount.accountID];
+ if(checkContact.isInRoster)
+ {
+ [self presentChatWithContact:checkContact];
+ return;
+ }
+ }
+
+ appendToViewQueue((^(PMKResolver resolve) {
+ UIViewController* addContactMenuView = [[SwiftuiInterface new] makeAddContactViewForJid:jid preauthToken:preauthToken prefillAccount:account andOmemoFingerprints:fingerprints withDismisser:^(MLContact* _Nonnull newContact) {
+ [self presentChatWithContact:newContact];
+ }];
+ addContactMenuView.ml_disposeCallback = ^{
+ [self sheetDismissed];
+ };
+ [self dismissCompleteViewChainWithAnimation:NO andCompletion:^{
+ [self presentViewController:addContactMenuView animated:NO completion:^{resolve(nil);}];
+ }];
+ }));
+}
+
+-(void) showAddContact
+{
+ appendToViewQueue((^(PMKResolver resolve) {
+ UIViewController* addContactMenuView = [[SwiftuiInterface new] makeAddContactViewWithDismisser:^(MLContact* _Nonnull newContact) {
+ [self presentChatWithContact:newContact];
+ }];
+ addContactMenuView.ml_disposeCallback = ^{
+ [self sheetDismissed];
+ };
+ [self dismissCompleteViewChainWithAnimation:NO andCompletion:^{
+ [self presentViewController:addContactMenuView animated:NO completion:^{resolve(nil);}];
+ }];
+ }));
+}
+
+-(void) segueToIntroScreensIfNeeded
+{
+ DDLogDebug(@"segueToIntroScreensIfNeeded got called...");
+ //prepend in a prepend block to make sure we have prepended everything in order before showing the first view
+ //(if we would not do this, the first view prepended would be shown regardless of other views prepended after it)
+ //every entry in here is flipped, because we want to prepend all intro screens to our queue
+ prependToViewQueue((^(PMKResolver resolve) {
+#ifdef IS_QUICKSY
+ prependToViewQueue((^(PMKResolver resolve) {
+ [self syncContacts];
+ resolve(nil);
+ }));
+#else
+ [self showWarningsIfNeeded];
+#endif
+
+ prependToViewQueue(MLViewIDWelcomeLoginView, (^(PMKResolver resolve) {
+#ifdef IS_QUICKSY
+ if([[[DataLayer sharedInstance] accountList] count] == 0)
+ {
+ DDLogDebug(@"Showing account registration view...");
+ UIViewController* view = [[SwiftuiInterface new] makeAccountRegistration:@{}];
+ if(UIDevice.currentDevice.userInterfaceIdiom != UIUserInterfaceIdiomPad)
+ view.modalPresentationStyle = UIModalPresentationFullScreen;
+ else
+ view.ml_disposeCallback = ^{
+ [self sheetDismissed];
+ };
+ [self dismissCompleteViewChainWithAnimation:NO andCompletion:^{
+ [self presentViewController:view animated:NO completion:^{resolve(nil);}];
+ }];
+ }
+ else
+ resolve(nil);
+#else
+ // display quick start if the user never seen it or if there are 0 enabled accounts
+ if([[DataLayer sharedInstance] enabledAccountCnts].intValue == 0 && !self->_loginAlreadyAutodisplayed)
+ {
+ DDLogDebug(@"Showing WelcomeLogIn view...");
+ UIViewController* loginViewController = [[SwiftuiInterface new] makeViewWithName:@"WelcomeLogIn"];
+ loginViewController.ml_disposeCallback = ^{
+ self->_loginAlreadyAutodisplayed = YES;
+ [self sheetDismissed];
+ };
+ [self dismissCompleteViewChainWithAnimation:NO andCompletion:^{
+ [self presentViewController:loginViewController animated:YES completion:^{resolve(nil);}];
+ }];
+ }
+ else
+ resolve(nil);
+#endif
+ }));
+
+ prependToViewQueue((^(PMKResolver resolve) {
+ if(![[HelperTools defaultsDB] boolForKey:@"hasCompletedOnboarding"])
+ {
+ DDLogDebug(@"Showing onboarding view...");
+ UIViewController* view = [[SwiftuiInterface new] makeViewWithName:@"OnboardingView"];
+ if(UIDevice.currentDevice.userInterfaceIdiom != UIUserInterfaceIdiomPad)
+ view.modalPresentationStyle = UIModalPresentationFullScreen;
+ else
+ view.ml_disposeCallback = ^{
+ [self sheetDismissed];
+ };
+ [self dismissCompleteViewChainWithAnimation:NO andCompletion:^{
+ [self presentViewController:view animated:NO completion:^{resolve(nil);}];
+ }];
+ }
+ else
+ resolve(nil);
+ }));
+
+ prependToViewQueue((^(PMKResolver resolve) {
+ //open password migration if needed
+ NSArray* needingMigration = [[DataLayer sharedInstance] accountListNeedingPasswordMigration];
+ if(needingMigration.count > 0)
+ {
+#ifdef IS_QUICKSY
+ DDLogDebug(@"Showing account registration view to do password migration...");
+ UIViewController* view = [[SwiftuiInterface new] makeAccountRegistration:@{}];
+ if(UIDevice.currentDevice.userInterfaceIdiom != UIUserInterfaceIdiomPad)
+ view.modalPresentationStyle = UIModalPresentationFullScreen;
+ else
+ view.ml_disposeCallback = ^{
+ [self sheetDismissed];
+ };
+ [self dismissCompleteViewChainWithAnimation:NO andCompletion:^{
+ [self presentViewController:view animated:NO completion:^{resolve(nil);}];
+ }];
+#else
+ DDLogDebug(@"Showing password migration view...");
+ UIViewController* passwordMigration = [[SwiftuiInterface new] makePasswordMigration:needingMigration];
+ passwordMigration.ml_disposeCallback = ^{
+ [self sheetDismissed];
+ };
+ [self dismissCompleteViewChainWithAnimation:NO andCompletion:^{
+ [self presentViewController:passwordMigration animated:YES completion:^{resolve(nil);}];
+ }];
+#endif
+ }
+ else
+ resolve(nil);
+ }));
+
+ resolve(nil);
+ }));
+}
+
+#ifdef IS_QUICKSY
+-(void) syncContacts
+{
+ CNContactStore* store = [[CNContactStore alloc] init];
+ [store requestAccessForEntityType:CNEntityTypeContacts completionHandler:^(BOOL granted, NSError* _Nullable error) {
+ if(granted)
+ {
+ Quicksy_Country* country = [[HelperTools defaultsDB] objectForKey:@"Quicksy_country"];
+ NSString* countryCode = country.code;
+ NSCharacterSet* allowedCharacters = [[NSCharacterSet characterSetWithCharactersInString:@"+0123456789"] invertedSet];
+ NSMutableDictionary* numbers = [NSMutableDictionary new];
+
+ CNContactFetchRequest* request = [[CNContactFetchRequest alloc] initWithKeysToFetch:@[CNContactPhoneNumbersKey, CNContactNicknameKey, CNContactGivenNameKey, CNContactFamilyNameKey]];
+ NSError* error;
+ [store enumerateContactsWithFetchRequest:request error:&error usingBlock:^(CNContact* _Nonnull contact, BOOL* _Nonnull stop) {
+ if(!error)
+ {
+ NSString* name = [[NSString stringWithFormat:@"%@ %@", contact.givenName, contact.familyName] stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceCharacterSet]];
+ for(CNLabeledValue* phone in contact.phoneNumbers)
+ {
+ //add country code if missing
+ NSString* number = [[phone.value.stringValue componentsSeparatedByCharactersInSet:allowedCharacters] componentsJoinedByString:@""];
+ if(countryCode != nil && ![number hasPrefix:@"+"] && ![number hasPrefix:@"00"])
+ {
+ DDLogVerbose(@"Adding country code '%@' to number: %@", countryCode, number);
+ number = [NSString stringWithFormat:@"%@%@", countryCode, [number hasPrefix:@"0"] ? [number substringFromIndex:1] : number];
+ }
+ numbers[number] = name;
+ }
+ }
+ else
+ DDLogWarn(@"Error fetching contacts: %@", error);
+ }];
+
+ DDLogDebug(@"Got list of contact phone numbers: %@", numbers);
+
+ NSArray* enabledAccounts = [MLXMPPManager sharedInstance].connectedXMPP;
+ if(enabledAccounts.count == 0)
+ {
+ DDLogError(@"No connected account while trying to send quicksy phonebook!");
+ return;
+ }
+ else if(enabledAccounts.count > 1)
+ DDLogWarn(@"More than 1 connected account while trying to send quicksy phonebook, using first one!");
+
+ XMPPIQ* iqNode = [[XMPPIQ alloc] initWithType:kiqGetType to:@"api.quicksy.im"];
+ [iqNode setQuicksyPhoneBook:numbers.allKeys];
+ [enabledAccounts[0] sendIq:iqNode withHandler:$newHandler(MLIQProcessor, handleQuicksyPhoneBook, $ID(numbers))];
+ }
+ else
+ DDLogError(@"Access to contacts not granted!");
+ }];
+}
+#endif
+
+-(void) showWarningsIfNeeded
+{
+ for(NSDictionary* accountDict in [[DataLayer sharedInstance] enabledAccountList])
+ {
+ NSNumber* accountID = accountDict[kAccountID];
+ xmpp* account = [[MLXMPPManager sharedInstance] getEnabledAccountForID:accountID];
+ if(!account)
+ @throw [NSException exceptionWithName:@"RuntimeException" reason:@"Connected xmpp* object for accountID is nil!" userInfo:accountDict];
+
+ prependToViewQueue((^(PMKResolver resolve) {
+ if(![_mamWarningDisplayed containsObject:accountID] && account.accountState >= kStateBound && account.connectionProperties.accountDiscoDone)
+ {
+ if(![account.connectionProperties.accountDiscoFeatures containsObject:@"urn:xmpp:mam:2"])
+ {
+ DDLogDebug(@"Showing MAM not supported warning...");
+ UIAlertController* messageAlert = [UIAlertController alertControllerWithTitle:[NSString stringWithFormat:NSLocalizedString(@"Account %@", @""), account.connectionProperties.identity.jid] message:NSLocalizedString(@"Your server does not support MAM (XEP-0313). That means you could frequently miss incoming messages!! You should switch your server or talk to the server admin to enable this!", @"") preferredStyle:UIAlertControllerStyleAlert];
+ [messageAlert addAction:[UIAlertAction actionWithTitle:NSLocalizedString(@"Close", @"") style:UIAlertActionStyleCancel handler:^(UIAlertAction* action __unused) {
+ [_mamWarningDisplayed addObject:accountID];
+ resolve(nil);
+ }]];
+ [self dismissCompleteViewChainWithAnimation:NO andCompletion:^{
+ [self presentViewController:messageAlert animated:YES completion:nil];
+ }];
+ }
+ else
+ {
+ [_mamWarningDisplayed addObject:accountID];
+ resolve(nil);
+ }
+ }
+ else
+ resolve(nil);
+ }));
+
+ prependToViewQueue((^(PMKResolver resolve) {
+ if(![_smacksWarningDisplayed containsObject:accountID] && account.accountState >= kStateBound)
+ {
+ if(!account.connectionProperties.supportsSM3)
+ {
+ DDLogDebug(@"Showing smacks not supported warning...");
+ UIAlertController* messageAlert = [UIAlertController alertControllerWithTitle:[NSString stringWithFormat:NSLocalizedString(@"Account %@", @""), account.connectionProperties.identity.jid] message:NSLocalizedString(@"Your server does not support Stream Management (XEP-0198). That means your outgoing messages can get lost frequently!! You should switch your server or talk to the server admin to enable this!", @"") preferredStyle:UIAlertControllerStyleAlert];
+ [messageAlert addAction:[UIAlertAction actionWithTitle:NSLocalizedString(@"Close", @"") style:UIAlertActionStyleCancel handler:^(UIAlertAction* action __unused) {
+ [_smacksWarningDisplayed addObject:accountID];
+ resolve(nil);
+ }]];
+ [self dismissCompleteViewChainWithAnimation:NO andCompletion:^{
+ [self presentViewController:messageAlert animated:YES completion:nil];
+ }];
+ }
+ else
+ {
+ [_smacksWarningDisplayed addObject:accountID];
+ resolve(nil);
+ }
+ }
+ else
+ resolve(nil);
+ }));
+
+ prependToViewQueue((^(PMKResolver resolve) {
+ if(![_pushWarningDisplayed containsObject:accountID] && account.accountState >= kStateBound && account.connectionProperties.accountDiscoDone)
+ {
+ if(![account.connectionProperties.accountDiscoFeatures containsObject:@"urn:xmpp:push:0"])
+ {
+ DDLogDebug(@"Showing push not supported warning...");
+ UIAlertController* messageAlert = [UIAlertController alertControllerWithTitle:[NSString stringWithFormat:NSLocalizedString(@"Account %@", @""), account.connectionProperties.identity.jid] message:NSLocalizedString(@"Your server does not support PUSH (XEP-0357). That means you have to manually open the app to retrieve new incoming messages!! You should switch your server or talk to the server admin to enable this!", @"") preferredStyle:UIAlertControllerStyleAlert];
+ [messageAlert addAction:[UIAlertAction actionWithTitle:NSLocalizedString(@"Close", @"") style:UIAlertActionStyleCancel handler:^(UIAlertAction* action __unused) {
+ [_pushWarningDisplayed addObject:accountID];
+ resolve(nil);
+ }]];
+ [self dismissCompleteViewChainWithAnimation:NO andCompletion:^{
+ [self presentViewController:messageAlert animated:YES completion:nil];
+ }];
+ }
+ else
+ {
+ [_pushWarningDisplayed addObject:accountID];
+ resolve(nil);
+ }
+ }
+ else
+ resolve(nil);
+ }));
+ }
+}
+
+-(void) presentSplitPlaceholder
+{
+ // only show placeholder if we use a split view
+ if(!self.splitViewController.collapsed)
+ {
+ DDLogVerbose(@"Presenting Chat Placeholder...");
+ UIViewController* detailsViewController = [[SwiftuiInterface new] makeViewWithName:@"ChatPlaceholder"];
+ [self showDetailViewController:detailsViewController sender:self];
+ }
+ [MLNotificationManager sharedInstance].currentContact = nil;
+}
+
+-(void) showNotificationSettings
+{
+ [self dismissCompleteViewChainWithAnimation:NO andCompletion:^{
+ UIViewController* view = [[SwiftuiInterface new] makeViewWithName:@"ActiveChatsNotificationSettings"];
+ view.ml_disposeCallback = ^{
+ [self sheetDismissed];
+ };
+ [self presentViewController:view animated:YES completion:nil];
+ }];
+}
+
+-(void) prependGeneralSettings
+{
+ prependToViewQueue((^(PMKResolver resolve) {
+ UIViewController* view = [[SwiftuiInterface new] makeViewWithName:@"ActiveChatsGeneralSettings"];
+ view.ml_disposeCallback = ^{
+ [self sheetDismissed];
+ };
+ [self dismissCompleteViewChainWithAnimation:NO andCompletion:^{
+ [self presentViewController:view animated:YES completion:^{resolve(nil);}];
+ }];
+ }));
+}
+
+-(void) showGeneralSettings
+{
+ appendToViewQueue((^(PMKResolver resolve) {
+ UIViewController* view = [[SwiftuiInterface new] makeViewWithName:@"ActiveChatsGeneralSettings"];
+ view.ml_disposeCallback = ^{
+ [self sheetDismissed];
+ };
+ [self dismissCompleteViewChainWithAnimation:NO andCompletion:^{
+ [self presentViewController:view animated:YES completion:^{resolve(nil);}];
+ }];
+ }));
+}
+
+-(void) showSettings
+{
+ appendToViewQueue((^(PMKResolver resolve) {
+ [self performSegueWithIdentifier:@"showSettings" sender:self];
+ resolve(nil);
+ }));
+}
+
+-(void) showCallContactNotFoundAlert:(NSString*) jid
+{
+ UIAlertController* messageAlert = [UIAlertController alertControllerWithTitle:NSLocalizedString(@"Contact not found", @"") message:[NSString stringWithFormat:NSLocalizedString(@"You tried to call contact '%@' but this contact could not be found in your contact list.", @""), jid] preferredStyle:UIAlertControllerStyleAlert];
+ [messageAlert addAction:[UIAlertAction actionWithTitle:NSLocalizedString(@"Close", @"") style:UIAlertActionStyleCancel handler:^(UIAlertAction* action __unused) {}]];
+ [self dismissCompleteViewChainWithAnimation:NO andCompletion:^{
+ [self presentViewController:messageAlert animated:NO completion:nil];
+ }];
+}
+
+-(void) callContact:(MLContact*) contact withCallType:(MLCallType) callType
+{
+ MonalAppDelegate* appDelegate = (MonalAppDelegate *)[[UIApplication sharedApplication] delegate];
+ MLCall* activeCall = [appDelegate.voipProcessor getActiveCallWithContact:contact];
+ if(activeCall != nil)
+ [self presentCall:activeCall];
+ else
+ [self presentCall:[appDelegate.voipProcessor initiateCallWithType:callType toContact:contact]];
+}
+
+-(void) callContact:(MLContact*) contact withUIKitSender:(_Nullable id) sender
+{
+ MonalAppDelegate* appDelegate = (MonalAppDelegate *)[[UIApplication sharedApplication] delegate];
+ MLCall* activeCall = [appDelegate.voipProcessor getActiveCallWithContact:contact];
+ if(activeCall != nil)
+ [self presentCall:activeCall];
+ else
+ {
+ UIAlertController* alert = [UIAlertController alertControllerWithTitle:NSLocalizedString(@"Call Type", @"") message:NSLocalizedString(@"What call do you want to place?", @"") preferredStyle:UIAlertControllerStyleActionSheet];
+ [alert addAction:[UIAlertAction actionWithTitle:NSLocalizedString(@"🎵 Audio", @"") style:UIAlertActionStyleDefault handler:^(UIAlertAction *action) {
+ [self dismissViewControllerAnimated:YES completion:nil];
+ [self presentCall:[appDelegate.voipProcessor initiateCallWithType:MLCallTypeAudio toContact:contact]];
+ }]];
+ [alert addAction:[UIAlertAction actionWithTitle:NSLocalizedString(@"🎥 Video", @"") style:UIAlertActionStyleDefault handler:^(UIAlertAction *action) {
+ [self dismissViewControllerAnimated:YES completion:nil];
+ [self presentCall:[appDelegate.voipProcessor initiateCallWithType:MLCallTypeVideo toContact:contact]];
+ }]];
+ [alert addAction:[UIAlertAction actionWithTitle:NSLocalizedString(@"Cancel", @"") style:UIAlertActionStyleCancel handler:^(UIAlertAction *action) {
+ [self dismissViewControllerAnimated:YES completion:nil];
+ }]];
+ UIPopoverPresentationController* popPresenter = [alert popoverPresentationController];
+ if(sender != nil)
+ popPresenter.sourceItem = sender;
+ else
+ popPresenter.sourceView = self.view;
+ [self presentViewController:alert animated:YES completion:nil];
+ }
+}
+
+-(void) presentAccountPickerForContacts:(NSArray*) contacts andCallType:(MLCallType) callType
+{
+ [self dismissCompleteViewChainWithAnimation:NO andCompletion:^{
+ UIViewController* accountPickerController = [[SwiftuiInterface new] makeAccountPickerForContacts:contacts andCallType:callType];
+ accountPickerController.ml_disposeCallback = ^{
+ [self sheetDismissed];
+ };
+ [self presentViewController:accountPickerController animated:YES completion:^{}];
+ }];
+}
+
+-(void) presentCall:(MLCall*) call
+{
+ [self dismissCompleteViewChainWithAnimation:NO andCompletion:^{
+ UIViewController* callViewController = [[SwiftuiInterface new] makeCallScreenForCall:call];
+ callViewController.modalPresentationStyle = UIModalPresentationFullScreen;
+ [self presentViewController:callViewController animated:NO completion:^{}];
+ }];
+}
+
+-(void) presentChatWithContact:(MLContact*) contact
+{
+ return [self presentChatWithContact:contact andCompletion:nil];
+}
+
+-(void) presentChatWithContact:(MLContact*) contact andCompletion:(monal_id_block_t _Nullable) completion
+{
+ DDLogVerbose(@"presenting chat with contact: %@, stacktrace: %@", contact, [NSThread callStackSymbols]);
+ [HelperTools dispatchAsync:YES reentrantOnQueue:dispatch_get_main_queue() withBlock:^{
+ [self dismissCompleteViewChainWithAnimation:YES andCompletion:^{
+ // only open contact chat when it is not opened yet (needed for opening via notifications and for macOS)
+ if([contact isEqualToContact:[MLNotificationManager sharedInstance].currentContact])
+ {
+ // make sure the already open chat is reloaded and return
+ [[MLNotificationQueue currentQueue] postNotificationName:kMonalRefresh object:nil userInfo:nil];
+ if(completion != nil)
+ completion(@YES);
+ return;
+ }
+
+ // clear old chat before opening a new one (but not for splitView == YES)
+ if(self.splitViewController.collapsed)
+ [self.navigationController popViewControllerAnimated:NO];
+
+ // show placeholder if contact is nil, open chat otherwise
+ if(contact == nil)
+ {
+ [self presentSplitPlaceholder];
+ if(completion != nil)
+ completion(@NO);
+ return;
+ }
+
+ //open chat (make sure we have an active buddy for it and add it to our ui, if needed)
+ //but don't animate this if the contact is already present in our list
+ [[DataLayer sharedInstance] addActiveBuddies:contact.contactJid forAccount:contact.accountID];
+ if([[self getChatArrayForSection:pinnedChats] containsObject:contact] || [[self getChatArrayForSection:unpinnedChats] containsObject:contact])
+ {
+ [self scrollToContact:contact];
+ [self performSegueWithIdentifier:@"showConversation" sender:contact];
+ if(completion != nil)
+ completion(@YES);
+ }
+ else
+ {
+ [self insertOrMoveContact:contact completion:^(BOOL finished __unused) {
+ [self scrollToContact:contact];
+ [self performSegueWithIdentifier:@"showConversation" sender:contact];
+ if(completion != nil)
+ completion(@YES);
+ }];
+ }
+ }];
+ }];
+}
+
+/*
+ * return YES if no enabled account was found && a alert will open
+ */
+-(BOOL) showAccountNumberWarningIfNeeded
+{
+ // Only open contacts list / roster if at least one account is enabled
+ if([[DataLayer sharedInstance] enabledAccountCnts].intValue == 0) {
+ // Show warning
+ UIAlertController* alert = [UIAlertController alertControllerWithTitle:NSLocalizedString(@"No enabled account found", @"") message:NSLocalizedString(@"Please add a new account under settings first. If you already added your account you may need to enable it under settings", @"") preferredStyle:UIAlertControllerStyleAlert];
+ [alert addAction:[UIAlertAction actionWithTitle:NSLocalizedString(@"Close", @"") style:UIAlertActionStyleDefault handler:^(UIAlertAction* action __unused) {
+ [alert dismissViewControllerAnimated:YES completion:nil];
+ }]];
+ [self presentViewController:alert animated:YES completion:nil];
+ return YES;
+ }
+ return NO;
+}
+
+-(BOOL) shouldPerformSegueWithIdentifier:(NSString*) identifier sender:(id) sender
+{
+ return YES;
+}
+
+//this is needed to prevent segues invoked programmatically
+-(void) performSegueWithIdentifier:(NSString*) identifier sender:(id) sender
+{
+ [super performSegueWithIdentifier:identifier sender:sender];
+}
+
+-(void) prepareForSegue:(UIStoryboardSegue*) segue sender:(id) sender
+{
+ DDLogInfo(@"Got segue identifier '%@'", segue.identifier);
+ if([segue.identifier isEqualToString:@"showConversation"])
+ {
+ UINavigationController* nav = segue.destinationViewController;
+ chatViewController* chatVC = (chatViewController*)nav.topViewController;
+ UIBarButtonItem* barButtonItem = [[UIBarButtonItem alloc] initWithTitle:@"" style:UIBarButtonItemStylePlain target:nil action:nil];
+ self.navigationItem.backBarButtonItem = barButtonItem;
+ [chatVC setupWithContact:sender];
+ }
+ else if([segue.identifier isEqualToString:@"showDetails"])
+ {
+ UIViewController* detailsViewController = [[SwiftuiInterface new] makeContactDetails:sender];
+ detailsViewController.ml_disposeCallback = ^{
+ [self sheetDismissed];
+ };
+ [self presentViewController:detailsViewController animated:YES completion:^{}];
+ }
+}
+
+-(void) updateSizeClass {
+ self.sizeClass.horizontal = self.view.traitCollection.horizontalSizeClass;
+}
+
+-(NSMutableArray*) getChatArrayForSection:(size_t) section
+{
+ NSMutableArray* chatArray = nil;
+ if(section == pinnedChats) {
+ chatArray = self.pinnedContacts;
+ } else if(section == unpinnedChats) {
+ chatArray = self.unpinnedContacts;
+ }
+ return chatArray;
+}
+#pragma mark - tableview datasource
+
+-(NSInteger) numberOfSectionsInTableView:(UITableView *)tableView
+{
+ return activeChatsViewControllerSectionCnt;
+}
+
+- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section
+{
+ if(section == pinnedChats) {
+ return [self.pinnedContacts count];
+ } else if(section == unpinnedChats) {
+ return [self.unpinnedContacts count];
+ } else {
+ return 0;
+ }
+}
+
+- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath
+{
+ MLContactCell* cell = (MLContactCell*)[tableView dequeueReusableCellWithIdentifier:@"ContactCell" forIndexPath:indexPath];
+
+ MLContact* chatContact = nil;
+ // Select correct contact array
+ if(indexPath.section == pinnedChats)
+ chatContact = [self.pinnedContacts objectAtIndex:indexPath.row];
+ else
+ chatContact = [self.unpinnedContacts objectAtIndex:indexPath.row];
+
+ // Display msg draft or last msg
+ MLMessage* messageRow = [[DataLayer sharedInstance] lastMessageForContact:chatContact.contactJid forAccount:chatContact.accountID];
+
+ [cell initCell:chatContact withLastMessage:messageRow];
+
+ cell.selectionStyle = UITableViewCellSelectionStyleNone;
+
+ // Highlight the selected chat
+ if([MLNotificationManager sharedInstance].currentContact != nil && [chatContact isEqual:[MLNotificationManager sharedInstance].currentContact])
+ {
+ cell.backgroundColor = [UIColor lightGrayColor];
+ cell.statusText.textColor = [UIColor whiteColor];
+ }
+ else
+ {
+ cell.backgroundColor = [UIColor clearColor];
+ cell.statusText.textColor = [UIColor lightGrayColor];
+ }
+
+ return cell;
+}
+
+
+#pragma mark - tableview delegate
+
+-(CGFloat) tableView:(UITableView*) tableView heightForRowAtIndexPath:(NSIndexPath*) indexPath
+{
+ return 60.0f;
+}
+
+-(NSString*) tableView:(UITableView*) tableView titleForDeleteConfirmationButtonForRowAtIndexPath:(NSIndexPath*) indexPath
+{
+ return NSLocalizedString(@"Archive chat", @"");
+}
+
+-(BOOL) tableView:(UITableView*) tableView canEditRowAtIndexPath:(NSIndexPath*) indexPath
+{
+ return YES;
+}
+
+-(BOOL)tableView:(UITableView*) tableView shouldIndentWhileEditingRowAtIndexPath:(NSIndexPath*) indexPath
+{
+ return YES;
+}
+
+-(void)tableView:(UITableView*) tableView commitEditingStyle:(UITableViewCellEditingStyle) editingStyle forRowAtIndexPath:(NSIndexPath*) indexPath
+{
+ if (editingStyle == UITableViewCellEditingStyleDelete) {
+ MLContact* contact = nil;
+ // Delete contact from view
+ if(indexPath.section == pinnedChats) {
+ contact = [self.pinnedContacts objectAtIndex:indexPath.row];
+ [self.pinnedContacts removeObjectAtIndex:indexPath.row];
+ } else {
+ contact = [self.unpinnedContacts objectAtIndex:indexPath.row];
+ [self.unpinnedContacts removeObjectAtIndex:indexPath.row];
+ }
+ [self.chatListTable deleteRowsAtIndexPaths:@[indexPath] withRowAnimation:UITableViewRowAnimationAutomatic];
+ // removeActiveBuddy in db
+ [[DataLayer sharedInstance] removeActiveBuddy:contact.contactJid forAccount:contact.accountID];
+ // remove contact from activechats table
+ [self refreshDisplay];
+ // open placeholder
+ [self presentChatWithContact:nil];
+ }
+}
+
+-(void) tableView:(UITableView*) tableView didSelectRowAtIndexPath:(NSIndexPath*) indexPath
+{
+ MLContact* selected = nil;
+ if(indexPath.section == pinnedChats) {
+ selected = self.pinnedContacts[indexPath.row];
+ } else {
+ selected = self.unpinnedContacts[indexPath.row];
+ }
+ [self presentChatWithContact:selected];
+}
+
+-(void) tableView:(UITableView*) tableView accessoryButtonTappedForRowWithIndexPath:(NSIndexPath*) indexPath
+{
+ MLContact* selected = nil;
+ if(indexPath.section == pinnedChats) {
+ selected = self.pinnedContacts[indexPath.row];
+ } else {
+ selected = self.unpinnedContacts[indexPath.row];
+ }
+ UIViewController* detailsViewController = [[SwiftuiInterface new] makeContactDetails:selected];
+ detailsViewController.ml_disposeCallback = ^{
+ [self sheetDismissed];
+ };
+ [self presentViewController:detailsViewController animated:YES completion:^{}];
+}
+
+
+#pragma mark - empty data set
+
+-(void) viewWillTransitionToSize:(CGSize) size withTransitionCoordinator:(id) coordinator
+{
+ //DDLogError(@"Transitioning to size: %@", NSStringFromCGSize(size));
+ [super viewWillTransitionToSize:size withTransitionCoordinator:coordinator];
+}
+
+
+-(UIImage*) imageForEmptyDataSet:(UIScrollView*) scrollView
+{
+ int orientation;
+ if(self.tableView.frame.size.height > self.tableView.frame.size.width)
+ {
+ orientation = 1; //portrait
+ _portraitTop = self.navigationController.navigationBar.frame.size.height;
+ }
+ else
+ {
+ orientation = 2; //landscape
+ _landscapeTop = self.navigationController.navigationBar.frame.size.height;
+ }
+ if(_startedOrientation == 0)
+ _startedOrientation = orientation;
+
+ //DDLogError(@"started orientation: %@", _startedOrientation == 1 ? @"portrait" : @"landscape");
+ //DDLogError(@"current orientation: %@", orientation == 1 ? @"portrait" : @"landscape");
+
+ DZNEmptyDataSetView* emptyDataSetView = self.tableView.emptyDataSetView;
+ CGRect headerFrame = self.navigationController.navigationBar.frame;
+ CGRect tableFrame = self.tableView.frame;
+ //CGRect contentFrame = emptyDataSetView.contentView.frame;
+ //DDLogError(@"headerFrame: %@", NSStringFromCGRect(headerFrame));
+ //DDLogError(@"tableFrame: %@", NSStringFromCGRect(tableFrame));
+ //DDLogError(@"contentFrame: %@", NSStringFromCGRect(contentFrame));
+ tableFrame.size.height *= 0.5;
+
+ //started in landscape, moved to portrait
+ if(_startedOrientation == 2 && orientation == 1)
+ {
+ tableFrame.origin.y += headerFrame.size.height - _landscapeTop - _portraitTop;
+ }
+ //started in portrait, moved to landscape
+ else if(_startedOrientation == 1 && orientation == 2)
+ {
+ tableFrame.origin.y += (_portraitTop + _landscapeTop * 2);
+ tableFrame.size.height -= _portraitTop;
+ }
+ //started in any orientation, moved to same orientation (or just started)
+ else
+ {
+ tableFrame.origin.y += headerFrame.size.height;
+ }
+
+ emptyDataSetView.contentView.frame = tableFrame;
+ emptyDataSetView.imageView.frame = tableFrame;
+ [emptyDataSetView.contentView addConstraints:[NSLayoutConstraint constraintsWithVisualFormat:@"V:|[imageView]-(32@750)-[titleLabel]-(16@750)-[detailLabel]|" options:0 metrics:nil views:@{
+ @"imageView": emptyDataSetView.imageView,
+ @"titleLabel": emptyDataSetView.titleLabel,
+ @"detailLabel": emptyDataSetView.detailLabel,
+ }]];
+ emptyDataSetView.imageView.translatesAutoresizingMaskIntoConstraints = YES;
+ if(self.traitCollection.userInterfaceStyle == UIUserInterfaceStyleDark)
+ return [UIImage imageNamed:@"chat_dark"];
+ return [UIImage imageNamed:@"chat"];
+
+ /*
+ DZNEmptyDataSetView* emptyDataSetView = self.chatListTable.emptyDataSetView;
+ CGRect headerFrame = self.navigationController.navigationBar.frame;
+ CGRect tableFrame = self.chatListTable.frame;
+ CGRect contentFrame = emptyDataSetView.contentView.frame;
+ DDLogError(@"headerFrame: %@", NSStringFromCGRect(headerFrame));
+ DDLogError(@"tableFrame: %@", NSStringFromCGRect(tableFrame));
+ DDLogError(@"contentFrame: %@", NSStringFromCGRect(contentFrame));
+ if(tableFrame.size.height > tableFrame.size.width)
+ {
+ DDLogError(@"height is bigger");
+ tableFrame.size.height *= 0.5;
+ tableFrame.origin.y += headerFrame.size.height;
+ }
+ else
+ {
+ DDLogError(@"width is bigger");
+ tableFrame.size.height *= 2.0;
+ }
+ //tableFrame.size.height *= (tableFrame.size.width / tableFrame.size.height);
+ emptyDataSetView.imageView.frame = tableFrame;
+ emptyDataSetView.contentView.frame = tableFrame;
+ [emptyDataSetView.contentView addConstraints:[NSLayoutConstraint constraintsWithVisualFormat:@"V:|[imageView]-(48@750)-[titleLabel]-(16@750)-[detailLabel]|" options:0 metrics:nil views:@{
+ @"imageView": emptyDataSetView.imageView,
+ @"titleLabel": emptyDataSetView.titleLabel,
+ @"detailLabel": emptyDataSetView.detailLabel,
+ }]];
+ emptyDataSetView.imageView.translatesAutoresizingMaskIntoConstraints = YES;
+ return [UIImage imageNamed:@"chat"];
+ */
+}
+
+-(CGFloat) spaceHeightForEmptyDataSet:(UIScrollView*) scrollView
+{
+ return 480.0f;
+}
+
+-(NSAttributedString*) titleForEmptyDataSet:(UIScrollView*) scrollView
+{
+ NSString* text = NSLocalizedString(@"No active conversations", @"");
+
+ NSDictionary* attributes = @{NSFontAttributeName: [UIFont boldSystemFontOfSize:18.0f],
+ NSForegroundColorAttributeName: (self.traitCollection.userInterfaceStyle == UIUserInterfaceStyleDark ? [UIColor whiteColor] : [UIColor blackColor])};
+
+ return [[NSAttributedString alloc] initWithString:text attributes:attributes];
+}
+
+- (NSAttributedString*)descriptionForEmptyDataSet:(UIScrollView*) scrollView
+{
+ NSString* text = NSLocalizedString(@"When you start a conversation\nwith someone, they will\nshow up here.", @"");
+
+ NSMutableParagraphStyle* paragraph = [NSMutableParagraphStyle new];
+ paragraph.lineBreakMode = NSLineBreakByWordWrapping;
+ paragraph.alignment = NSTextAlignmentCenter;
+
+ NSDictionary* attributes = @{NSFontAttributeName: [UIFont systemFontOfSize:14.0f],
+ NSForegroundColorAttributeName: (self.traitCollection.userInterfaceStyle == UIUserInterfaceStyleDark ? [UIColor whiteColor] : [UIColor blackColor]),
+ NSParagraphStyleAttributeName: paragraph};
+
+ return [[NSAttributedString alloc] initWithString:text attributes:attributes];
+}
+
+-(UIColor*) backgroundColorForEmptyDataSet:(UIScrollView*) scrollView
+{
+ return [UIColor colorNamed:@"chats"];
+}
+
+-(BOOL) emptyDataSetShouldDisplay:(UIScrollView*) scrollView
+{
+ BOOL toreturn = (self.unpinnedContacts.count == 0 && self.pinnedContacts.count == 0) ? YES : NO;
+ if(toreturn)
+ {
+ // A little trick for removing the cell separators
+ self.tableView.tableFooterView = [UIView new];
+ }
+ return toreturn;
+}
+
+#pragma mark - mac menu
+
+-(void) showContacts:(id) sender { // function definition for @selector
+ [self showContacts];
+}
+
+-(void) showContacts
+{
+ if([self showAccountNumberWarningIfNeeded]) {
+ return;
+ }
+
+ appendToViewQueue((^(PMKResolver resolve) {
+ contactCompletion callback = ^(MLContact* selectedContact) {
+ DDLogVerbose(@"Got selected contact from contactlist ui: %@", selectedContact);
+ [self presentChatWithContact:selectedContact];
+ };
+
+ UIViewController* contactsView = [[SwiftuiInterface new] makeContactsViewWithDismisser: callback onButton: self.composeButton];
+ [self presentViewController:contactsView animated:YES completion:^{resolve(nil);}];
+ }));
+}
+
+//we can not call this var "completion" because then some dumb comiler check kicks in and tells us "completion handler is never called"
+//which is plainly wrong. "callback" on the other hand doesn't seem to be a word in the objc compiler's "bad words" dictionary,
+//so this makes it compile again
+-(void) showRegisterWithUsername:(NSString*) username onHost:(NSString*) host withToken:(NSString*) token usingCompletion:(monal_id_block_t) callback
+{
+ prependingReplaceOnViewQueue(MLViewIDWelcomeLoginView, MLViewIDRegisterView, (^(PMKResolver resolve) {
+ UIViewController* registerViewController = [[SwiftuiInterface new] makeAccountRegistration:@{
+ @"host": nilWrapper(host),
+ @"username": nilWrapper(username),
+ @"token": nilWrapper(token),
+ @"completion": nilDefault(callback, (^(id accountID) {
+ DDLogWarn(@"Dummy reg completion called for accountID: %@", accountID);
+ })),
+ }];
+ registerViewController.ml_disposeCallback = ^{
+ [self sheetDismissed];
+ };
+ [self dismissCompleteViewChainWithAnimation:NO andCompletion:^{
+ [self presentViewController:registerViewController animated:YES completion:^{resolve(nil);}];
+ }];
+ }));
+}
+
+-(void) showDetails
+{
+ appendToViewQueue((^(PMKResolver resolve) {
+ if([MLNotificationManager sharedInstance].currentContact != nil)
+ {
+ UIViewController* detailsViewController = [[SwiftuiInterface new] makeContactDetails:[MLNotificationManager sharedInstance].currentContact];
+ detailsViewController.ml_disposeCallback = ^{
+ [self sheetDismissed];
+ };
+ [self dismissCompleteViewChainWithAnimation:NO andCompletion:^{
+ [self presentViewController:detailsViewController animated:YES completion:^{resolve(nil);}];
+ }];
+ }
+ else
+ resolve(nil);
+ }));
+}
+
+-(void) deleteConversation
+{
+ for(size_t section = pinnedChats; section < activeChatsViewControllerSectionCnt; section++)
+ {
+ NSMutableArray* curContactArray = [self getChatArrayForSection:section];
+ // check if contact is already displayed -> get coresponding indexPath
+ [curContactArray enumerateObjectsUsingBlock:^(id _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop __unused) {
+ MLContact* rowContact = (MLContact*)obj;
+ if([rowContact isEqualToContact:[MLNotificationManager sharedInstance].currentContact])
+ {
+ [self tableView:self.chatListTable commitEditingStyle:UITableViewCellEditingStyleDelete forRowAtIndexPath:[NSIndexPath indexPathForRow:idx inSection:section]];
+ // remove contact from activechats table
+ [self refreshDisplay];
+ // open placeholder
+ [self presentChatWithContact:nil];
+ return;
+ }
+ }];
+ }
+}
+
+-(NSMutableArray*) getCurrentViewControllerHierarchy
+{
+ MonalAppDelegate* appDelegate = (MonalAppDelegate *)[[UIApplication sharedApplication] delegate];
+ UIViewController* rootViewController = appDelegate.window.rootViewController;
+ NSMutableArray* viewControllers = [NSMutableArray new];
+ while(rootViewController.presentedViewController)
+ {
+ [viewControllers addObject:rootViewController.presentedViewController];
+ rootViewController = rootViewController.presentedViewController;
+ }
+ return [[[viewControllers reverseObjectEnumerator] allObjects] mutableCopy];
+}
+
+-(void) dismissCompleteViewChainWithAnimation:(BOOL) animation andCompletion:(monal_void_block_t _Nullable) completion
+{
+ NSMutableArray* viewControllers = [self getCurrentViewControllerHierarchy];
+ DDLogVerbose(@"Dismissing view controller hierarchy: %@", viewControllers);
+ [self dismissRecursorWithViewControllers:viewControllers animation:animation andCompletion:completion];
+}
+
+-(void) dismissRecursorWithViewControllers:(NSMutableArray*) viewControllers animation:(BOOL) animation andCompletion:(monal_void_block_t _Nullable) completion
+{
+ if([viewControllers count] > 0)
+ {
+ UIViewController* viewController = viewControllers[0];
+ [viewControllers removeObjectAtIndex:0];
+ DDLogVerbose(@"Dismissing: %@", viewController);
+ [viewController dismissViewControllerAnimated:animation completion:^{
+ [self dismissRecursorWithViewControllers:viewControllers animation:animation andCompletion:completion];
+ }];
+ }
+ else
+ {
+ DDLogVerbose(@"View chain completely dismissed...");
+ completion();
+ }
+}
+
+-(chatViewController* _Nullable) currentChatView
+{
+ NSArray* controllers = ((UINavigationController*)self.splitViewController.viewControllers[0]).viewControllers;
+ chatViewController* chatView = nil;
+ if(controllers.count > 1)
+ chatView = [((UINavigationController*)controllers[1]).viewControllers firstObject];
+ if(![chatView isKindOfClass:NSClassFromString(@"chatViewController")])
+ chatView = nil;
+ return chatView;
+}
+
+-(void) scrollToContact:(MLContact*) contact
+{
+ __block NSIndexPath* indexPath = nil;
+ for(size_t section = pinnedChats; section < activeChatsViewControllerSectionCnt && !indexPath; section++) {
+ NSMutableArray* curContactArray = [self getChatArrayForSection:section];
+
+ // get indexPath
+ [curContactArray enumerateObjectsUsingBlock:^(id _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
+ MLContact* rowContact = (MLContact*)obj;
+ if([rowContact isEqualToContact:contact])
+ {
+ indexPath = [NSIndexPath indexPathForRow:idx inSection:section];
+ *stop = YES;
+ }
+ }];
+ }
+ [self.chatListTable selectRowAtIndexPath:indexPath animated:NO scrollPosition:UITableViewScrollPositionNone];
+}
+
+@end
diff --git a/Monal/Classes/AddContactMenu.swift b/Monal/Classes/AddContactMenu.swift
new file mode 100644
index 0000000..c573add
--- /dev/null
+++ b/Monal/Classes/AddContactMenu.swift
@@ -0,0 +1,347 @@
+//
+// AddContactMenu.swift
+// Monal
+//
+// Created by Jan on 27.10.22.
+// Copyright © 2022 monal-im.org. All rights reserved.
+//
+
+import MobileCoreServices
+import UniformTypeIdentifiers
+
+struct AddContactMenu: View {
+ var delegate: SheetDismisserProtocol
+ static private let jidFaultyPattern = "^([^@]+@)?.+(\\..{2,})?$"
+
+ @State private var enabledAccounts: [xmpp]
+ @State private var selectedAccount: Int
+ @State private var scannedFingerprints: [NSNumber:Data]? = nil
+ @State private var importScannedFingerprints: Bool = false
+ @State private var toAdd: String = ""
+
+ @State private var showInvitationError = false
+ @State private var showAlert = false
+ // note: dismissLabel is not accessed but defined at the .alert() section
+ @State private var alertPrompt = AlertPrompt(dismissLabel: Text("Close"))
+ @State private var invitationResult: [String:AnyObject]? = nil
+
+ @StateObject private var overlay = LoadingOverlayState()
+
+ @State private var showQRCodeScanner = false
+ @State private var success = false
+ @State private var newContact : MLContact?
+
+ @State private var isEditingJid = false
+
+ private let dismissWithNewContact: (MLContact) -> ()
+ private let preauthToken: String?
+
+ init(delegate: SheetDismisserProtocol, dismissWithNewContact: @escaping (MLContact) -> (), prefillJid: String = "", preauthToken:String? = nil, prefillAccount:xmpp? = nil, omemoFingerprints: [NSNumber:Data]? = nil) {
+ self.delegate = delegate
+ self.dismissWithNewContact = dismissWithNewContact
+ //self.toAdd = State(wrappedValue: prefillJid)
+ self.toAdd = prefillJid
+ self.preauthToken = preauthToken
+ //only display omemo ui part if there are any fingerprints (the checks below test for nil, not for 0)
+ if omemoFingerprints?.count ?? 0 > 0 {
+ self.scannedFingerprints = omemoFingerprints
+ }
+
+ let enabledAccounts = MLXMPPManager.sharedInstance().connectedXMPP as! [xmpp]
+ self.enabledAccounts = enabledAccounts
+ self.selectedAccount = enabledAccounts.first != nil ? 0 : -1;
+ if let prefillAccount = prefillAccount {
+ for index in enabledAccounts.indices {
+ if enabledAccounts[index].accountID.isEqual(to:prefillAccount.accountID) {
+ self.selectedAccount = index
+ }
+ }
+ }
+ }
+
+ // FIXME duplicate code from WelcomeLogIn.swift, maybe move to SwiftuiHelpers
+ private var toAddEmptyAlert: Bool {
+ alertPrompt.title = Text("No Empty Values!")
+ alertPrompt.message = Text("Please make sure you have entered a valid jid.")
+ return toAddEmpty
+ }
+
+ private var toAddInvalidAlert: Bool {
+ alertPrompt.title = Text("Invalid Credentials!")
+ alertPrompt.message = Text("The jid you want to add should be in in the format user@domain.tld.")
+ return toAddInvalid
+ }
+
+ private func errorAlert(title: Text, message: Text = Text("")) {
+ alertPrompt.title = title
+ alertPrompt.message = message
+ showAlert = true
+ }
+
+ private func successAlert(title: Text, message: Text) {
+ alertPrompt.title = title
+ alertPrompt.message = message
+ self.success = true // < dismiss entire view on close
+ showAlert = true
+ }
+
+ private var toAddEmpty: Bool {
+ return toAdd.isEmpty
+ }
+
+ private var toAddInvalid: Bool {
+ return toAdd.range(of: AddContactMenu.jidFaultyPattern, options:.regularExpression) == nil
+ }
+
+ func trustFingerprints(_ fingerprints:[NSNumber:Data]?, for jid:String, on account:xmpp) {
+ //we don't untrust other devices not included in here, because conversations only exports its own fingerprint
+ if let fingerprints = fingerprints {
+ for (deviceId, fingerprint) in fingerprints {
+ let address = SignalAddress.init(name:jid, deviceId:deviceId.int32Value)
+ let knownDevices = Array(account.omemo.knownDevices(forAddressName:jid))
+ if !knownDevices.contains(deviceId) {
+ account.omemo.addIdentityManually(address, identityKey:fingerprint)
+ assert(account.omemo.getIdentityFor(address) == fingerprint, "The stored and created fingerprint should match")
+ }
+ //trust device/fingerprint if fingerprints match
+ let knownFingerprintHex = HelperTools.signalHexKey(with:account.omemo.getIdentityFor(address))
+ let addedFingerprintHex = HelperTools.signalHexKey(with:fingerprint)
+ if knownFingerprintHex.uppercased() == addedFingerprintHex.uppercased() {
+ account.omemo.updateTrust(true, for:address)
+ }
+ }
+ }
+ }
+
+ func addJid(jid: String) {
+ let account = self.enabledAccounts[selectedAccount]
+ let contact = MLContact.createContact(fromJid: jid, andAccountID: account.accountID)
+ if contact.isInRoster {
+ self.newContact = contact
+ //import omemo fingerprints as manually trusted, if requested
+ trustFingerprints(self.importScannedFingerprints ? self.scannedFingerprints : [:], for:jid, on:account)
+ //only alert of already known contact if we did not import the omemo fingerprints
+ if !self.importScannedFingerprints || self.scannedFingerprints?.count ?? 0 == 0 {
+ if self.enabledAccounts.count > 1 {
+ self.success = true
+ successAlert(title: Text("Already present"), message: Text("This contact is already in the contact list of the selected account"))
+ } else {
+ self.success = true
+ successAlert(title: Text("Already present"), message: Text("This contact is already in your contact list"))
+ }
+ }
+ return
+ }
+ showPromisingLoadingOverlay(overlay, headline:NSLocalizedString("Adding...", comment: ""), description:"") {
+ account.checkJidType(jid)
+ }.done { type in
+ let type = type as! String
+ if type == "account" {
+ let contact = MLContact.createContact(fromJid: jid, andAccountID: account.accountID)
+ self.newContact = contact
+ MLXMPPManager.sharedInstance().add(contact, withPreauthToken:preauthToken)
+ //import omemo fingerprints as manually trusted, if requested
+ trustFingerprints(self.importScannedFingerprints ? self.scannedFingerprints : [:], for:jid, on:account)
+ successAlert(title: Text("Permission Requested"), message: Text("The new contact will be added to your contacts list when the person you've added has approved your request."))
+ } else if type == "muc" {
+ showPromisingLoadingOverlay(overlay, headlineView:Text("Adding Group/Channel..."), descriptionView:Text("")) {
+ promisifyMucAction(account:account, mucJid:jid) {
+ account.joinMuc(jid)
+ }
+ }.done { _ in
+ self.newContact = MLContact.createContact(fromJid: jid, andAccountID: account.accountID)
+ successAlert(title: Text("Success!"), message: Text("Successfully joined group/channel \(jid)!"))
+ }.catch { error in
+ errorAlert(title: Text("Error entering group/channel!"), message: Text("\(String(describing:error))"))
+ }
+ }
+ }.catch { error in
+ errorAlert(title: Text("Error"), message: Text(error.localizedDescription))
+ }
+ }
+
+ var body: some View {
+ let account = self.enabledAccounts[selectedAccount]
+ let splitJid = HelperTools.splitJid(account.connectionProperties.identity.jid)
+ Form {
+ if enabledAccounts.isEmpty {
+ Text("Please make sure at least one account has connected before trying to add a contact or channel.")
+ .foregroundColor(.secondary)
+ }
+ else
+ {
+ if DataLayer.sharedInstance().allContactRequests().count > 0 {
+ ContactRequestsMenu()
+ }
+
+ Section(header:Text("Contact and Group/Channel Jids are usually in the format: name@domain.tld")) {
+ if enabledAccounts.count > 1 {
+ Picker("Use account", selection: $selectedAccount) {
+ ForEach(Array(self.enabledAccounts.enumerated()), id: \.element) { idx, account in
+ Text(account.connectionProperties.identity.jid).tag(idx)
+ }
+ }
+ .pickerStyle(.menu)
+ }
+
+ TextField(NSLocalizedString("Contact-, Group- or Channel-Jid", comment: "placeholder when adding jid"), text: $toAdd, onEditingChanged: { isEditingJid = $0 })
+ .textInputAutocapitalization(.never)
+ .autocapitalization(.none)
+ .autocorrectionDisabled()
+ .keyboardType(.emailAddress)
+ .addClearButton(isEditing: isEditingJid, text:$toAdd)
+ .disabled(scannedFingerprints != nil)
+ .foregroundColor(scannedFingerprints != nil ? .secondary : .primary)
+ .onChange(of: toAdd) { _ in toAdd = toAdd.replacingOccurrences(of: " ", with: "") }
+
+ if scannedFingerprints != nil && scannedFingerprints!.count > 0 {
+ Section(header: Text("A contact was scanned through the QR code scanner")) {
+ Toggle(isOn: $importScannedFingerprints) {
+ Text("Import and trust OMEMO fingerprints from QR code")
+ }
+ }
+ }
+
+ if scannedFingerprints != nil {
+ Button(action: {
+ toAdd = ""
+ importScannedFingerprints = true
+ scannedFingerprints = nil
+ }, label: {
+ Text("Clear scanned contact")
+ .foregroundColor(.red)
+ })
+ }
+
+ HStack {
+ Spacer()
+
+ Button(action: {
+ showAlert = toAddEmptyAlert || toAddInvalidAlert
+
+ if !showAlert {
+ let jidComponents = HelperTools.splitJid(toAdd)
+ if jidComponents["host"] == nil || jidComponents["host"]!.isEmpty {
+ errorAlert(title: Text("Error"), message: Text("Something went wrong while parsing your input..."))
+ showAlert = true
+ return
+ }
+ // use the canonized jid from now on (lowercased, resource removed etc.)
+ addJid(jid: jidComponents["user"]!)
+ }
+ }) {
+ scannedFingerprints == nil ? Text("Add") : Text("Add scanned contact")
+ }
+ .disabled(toAddEmpty || toAddInvalid)
+ .buttonStyle(MonalProminentButtonStyle())
+ }
+ }
+
+ if DataLayer.sharedInstance().allContactRequests().count == 0 {
+ Section {
+ ContactRequestsMenu()
+ }
+ }
+ }
+ }
+ .padding()
+ .alert(isPresented: $showAlert) {
+ Alert(title: alertPrompt.title, message: alertPrompt.message, dismissButton:.default(Text("Close"), action: {
+ showAlert = false
+ if self.success == true {
+ if self.newContact != nil {
+ self.dismissWithNewContact(newContact!)
+ } else {
+ self.delegate.dismiss()
+ }
+ }
+ }))
+ }
+ .richAlert(isPresented: $invitationResult, title:Text("Invitation for \(splitJid["host"]!) created")) { data in
+ VStack {
+ Image(uiImage: createQrCode(value: data["landing"] as! String))
+ .interpolation(.none)
+ .resizable()
+ .scaledToFit()
+ .aspectRatio(1, contentMode: .fit)
+
+ if let expires = data["expires"] as? Date {
+ Text("This invitation will expire on \(expires.formatted(date:.numeric, time:.shortened))")
+ .font(.footnote)
+ .multilineTextAlignment(.leading)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+ }
+ } buttons: { data in
+ Button(action: {
+ UIPasteboard.general.setValue(data["landing"] as! String, forPasteboardType:UTType.utf8PlainText.identifier as String)
+ invitationResult = nil
+ }) {
+ ShareLink("Share invitation link", item: URL(string: data["landing"] as! String)!)
+ }
+ Button(action: {
+ invitationResult = nil
+ }) {
+ Text("Close")
+ .frame(maxWidth: .infinity)
+ }
+ }
+ .sheet(isPresented: $showQRCodeScanner) {
+ NavigationStack {
+ MLQRCodeScanner(handleClose: {
+ self.showQRCodeScanner = false
+ })
+ .navigationTitle("QR-Code Scanner")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar(content: {
+ ToolbarItem(placement: .navigationBarLeading, content: {
+ Button(action: {
+ self.showQRCodeScanner = false
+ }, label: {
+ Text("Close")
+ })
+ })
+ })
+ }
+ }
+ .navigationBarTitle(Text("Add Contact or Channel"), displayMode: .inline)
+ .toolbar(content: {
+ ToolbarItemGroup(placement: .navigationBarTrailing) {
+ if account.connectionProperties.discoveredAdhocCommands["urn:xmpp:invite#invite"] != nil {
+ Button(action: {
+ DDLogVerbose("Trying to create invitation for: \(String(describing:splitJid["host"]!))")
+ showLoadingOverlay(overlay, headline: NSLocalizedString("Creating invitation...", comment: ""))
+ account.createInvitation(completion: {
+ let result = $0 as! [String:AnyObject]
+ DispatchQueue.main.async {
+ hideLoadingOverlay(overlay)
+ DDLogVerbose("Got invitation result: \(String(describing:result))")
+ if result["success"] as! Bool == true {
+ invitationResult = result
+ } else {
+ errorAlert(title:Text("Failed to create invitation for \(splitJid["host"]!)"), message:Text(result["error"] as! String))
+ }
+ }
+ })
+ }, label: {
+ Image(systemName: "square.and.arrow.up")
+ })
+ }
+ Button(action: {
+ self.showQRCodeScanner = true
+ }, label: {
+ Image(systemName: "camera.fill")
+ })
+ }
+ })
+ .addLoadingOverlay(overlay)
+ }
+}
+
+struct AddContactMenu_Previews: PreviewProvider {
+ static var delegate = SheetDismisserProtocol()
+ static var previews: some View {
+ AddContactMenu(delegate: delegate, dismissWithNewContact: { c in
+ })
+ }
+}
diff --git a/Monal/Classes/BackgroundSettings.swift b/Monal/Classes/BackgroundSettings.swift
new file mode 100644
index 0000000..e1ddb90
--- /dev/null
+++ b/Monal/Classes/BackgroundSettings.swift
@@ -0,0 +1,100 @@
+//
+// BackgroundSettings.swift
+// Monal
+//
+// Created by Thilo Molitor on 14.11.22.
+// Copyright © 2022 monal-im.org. All rights reserved.
+//
+
+//swiftui is somehow needed to let the PhotosUI import succeed, even if it's already imported by SwiftuiHelpers.swift using @_exported
+import SwiftUI
+import PhotosUI
+
+@ViewBuilder
+func title(contact: ObservableKVOWrapper?) -> some View {
+ if let contact = contact {
+ Text("Select a background to display behind conversations with \(contact.contactDisplayName as String)")
+ } else {
+ Text("Select a default background to display behind conversations.")
+ }
+}
+
+struct BackgroundSettings: View {
+ @State private var selectedItem: PhotosPickerItem? = nil
+ @State private var showingImagePicker = false
+ @State private var inputImage: UIImage?
+ let contact: ObservableKVOWrapper?
+
+ init(contact: ObservableKVOWrapper?) {
+ self.contact = contact
+ _inputImage = State(initialValue:MLImageManager.sharedInstance().getBackgroundFor(self.contact?.obj))
+
+ }
+
+ var body: some View {
+ VStack {
+ Form {
+ Section(header:title(contact:contact)) {
+ VStack(spacing: 20) {
+ Spacer().frame(height: 0)
+
+ PhotosPicker(selection:$selectedItem, matching:.images, photoLibrary:.shared()) {
+ if let inputImage = inputImage {
+ HStack(alignment: .center) {
+ Image(uiImage:inputImage)
+ .resizable()
+ .aspectRatio(contentMode: .fit)
+ .frame(maxWidth: .infinity, alignment: .center)
+ }
+ .addTopRight {
+ Button(action: {
+ self.inputImage = nil
+ }, label: {
+ Image(systemName: "xmark.circle.fill")
+ .resizable()
+ .frame(width: 32.0, height: 32.0)
+ .accessibilityLabel(Text("Remove Background Image"))
+ .symbolRenderingMode(.palette)
+ .foregroundStyle(.white, .red)
+ })
+ .buttonStyle(.borderless)
+ .offset(x: 12, y: -12)
+ }
+ } else {
+ Text("Select background image")
+ .frame(maxWidth: .infinity, alignment: .center)
+ }
+ }
+ .accessibilityLabel(Text("Change Background Image"))
+ .onChange(of:selectedItem) { newItem in
+ // Retrive selected asset in the form of Data
+ newItem?.loadTransferable(type:Data.self) { result in
+ guard let data = try? result.get() else {
+ self.inputImage = nil
+ return
+ }
+ guard let loadedImage = UIImage(data: data) else {
+ self.inputImage = nil
+ return
+ }
+ self.inputImage = loadedImage
+ }
+ }
+
+ Spacer().frame(height: 0)
+ }
+ }
+ }
+ }
+ .navigationBarTitle(contact != nil ? Text("Chat Background") : Text("Default Background"))
+ .onChange(of:inputImage) { _ in
+ MLImageManager.sharedInstance().saveBackgroundImageData(inputImage?.pngData(), for:self.contact?.obj)
+ }
+ }
+}
+
+struct BackgroundSettings_Previews: PreviewProvider {
+ static var previews: some View {
+ BackgroundSettings(contact:nil)
+ }
+}
diff --git a/Monal/Classes/BlockedUsers.swift b/Monal/Classes/BlockedUsers.swift
new file mode 100644
index 0000000..fcff23e
--- /dev/null
+++ b/Monal/Classes/BlockedUsers.swift
@@ -0,0 +1,119 @@
+//
+// BlockedUsers.swift
+// Monal
+//
+// Created by lissine on 10/9/2024.
+// Copyright © 2024 monal-im.org. All rights reserved.
+//
+
+struct BlockedUsers: View {
+ let xmppAccount: xmpp
+ static private let jidPattern = "^([^@]+@)?[^/\\n]+(\\..{2,})?(/.+)?$"
+
+ @State private var blockedJids: [String] = []
+ @State private var jidToBlock = ""
+ @State private var showAddingToBlocklistForm = false
+ @State private var showBlockingUnsupportedPlaceholder = false
+ @State private var showInvalidJidAlert = false
+ @StateObject private var overlay = LoadingOverlayState()
+
+ private var blockingUnsupported: Bool {
+ return !xmppAccount.connectionProperties.serverDiscoFeatures.contains("urn:xmpp:blocking")
+ }
+
+ private func reloadBlocksFromDB() {
+ self.blockedJids = DataLayer.sharedInstance().blockedJids(forAccount: xmppAccount.accountID)
+ }
+
+ var body: some View {
+ if showBlockingUnsupportedPlaceholder {
+ ContentUnavailableShimView("Blocking unsupported", systemImage: "iphone.homebutton.slash", description: Text("Your server does not support blocking (XEP-0191)."))
+ } else {
+ List {
+ ForEach(blockedJids, id: \.self) { blockedJid in
+ Text(blockedJid)
+ }
+ .onDelete { indexSet in
+ for row in indexSet {
+ showLoadingOverlay(overlay, headlineView: Text("Saving changes to server"), descriptionView: Text(""))
+ // unblock the jid
+ MLXMPPManager.sharedInstance().block(false, fullJid: self.blockedJids[row], onAccount: self.xmppAccount.accountID)
+ }
+ }
+ }
+ .listStyle(.plain)
+ .navigationTitle("Blocked Users")
+ .animation(.default, value: blockedJids)
+ .onAppear {
+ if !(xmppAccount.accountState.rawValue >= xmppState.stateBound.rawValue && xmppAccount.connectionProperties.accountDiscoDone) {
+ showLoadingOverlay(overlay, headlineView: Text("Account is connecting..."), descriptionView: Text(""))
+ }
+ showBlockingUnsupportedPlaceholder = blockingUnsupported
+ reloadBlocksFromDB()
+ }
+ .onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("kMonalAccountDiscoDone")).receive(on: RunLoop.main)) { notification in
+ guard let notificationAccountID = notification.userInfo?["accountID"] as? NSNumber,
+ notificationAccountID.intValue == xmppAccount.accountID.intValue else {
+ return
+ }
+
+ // recompute this state variable, so the view is re-rendered if it changed.
+ showBlockingUnsupportedPlaceholder = blockingUnsupported
+ reloadBlocksFromDB()
+ hideLoadingOverlay(overlay)
+ }
+ .onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("kMonalBlockListRefresh")).receive(on: RunLoop.main)) { notification in
+ guard let notificationAccountID = notification.userInfo?["accountID"] as? NSNumber,
+ notificationAccountID.intValue == xmppAccount.accountID.intValue else {
+ return
+ }
+
+ DispatchQueue.main.async {
+ reloadBlocksFromDB()
+ DDLogVerbose("Got block list update from account \(xmppAccount)...")
+ hideLoadingOverlay(overlay)
+ }
+
+ }
+ .toolbar {
+ ToolbarItem(placement: .navigationBarTrailing) {
+ Button(action: {
+ showAddingToBlocklistForm = true
+ }, label: {
+ Image(systemName: "plus")
+ })
+ }
+ }
+ .alert("Enter the jid that you want to block", isPresented: $showAddingToBlocklistForm, actions: {
+ TextField("user@example.org/resource", text: $jidToBlock)
+ .textInputAutocapitalization(.never)
+ .keyboardType(.emailAddress)
+ .autocorrectionDisabled()
+
+ Button("Block", role: .destructive) {
+ guard (jidToBlock.range(of: BlockedUsers.jidPattern, options: .regularExpression) != nil) else {
+ showInvalidJidAlert = true
+ return
+ }
+
+ showLoadingOverlay(overlay, headlineView: Text("Saving changes to server"), descriptionView: Text(""))
+ // block the jid
+ MLXMPPManager.sharedInstance().block(true, fullJid: jidToBlock, onAccount: self.xmppAccount.accountID)
+ }
+
+ Button("Cancel", role: .cancel, action: {})
+ }
+ )
+ // If .onDisappear is applied to the alert or any of its subviews, its perform action won't
+ // get executed until the whole Blocked Users view is dismissed. Therefore .onChange is used instead
+ .onChange(of: showAddingToBlocklistForm) { _ in
+ if !showAddingToBlocklistForm {
+ // The alert has been dismissed
+ jidToBlock = ""
+ }
+ }
+ .alert("Input is not a valid jid", isPresented: $showInvalidJidAlert, actions: {})
+ .addLoadingOverlay(overlay)
+ }
+ }
+}
diff --git a/Monal/Classes/BoardingCards.swift b/Monal/Classes/BoardingCards.swift
new file mode 100644
index 0000000..177cd90
--- /dev/null
+++ b/Monal/Classes/BoardingCards.swift
@@ -0,0 +1,254 @@
+//
+// BoardingCards.swift
+// Monal
+//
+// Created by Vaidik Dubey on 05/06/24.
+// Copyright © 2024 monal-im.org. All rights reserved.
+//
+
+import FrameUp
+
+class OnboardingState: ObservableObject {
+ @defaultsDB("hasCompletedOnboarding")
+ var hasCompletedOnboarding: Bool
+}
+
+struct OnboardingCard: Identifiable {
+ let id = UUID()
+ let title: Text?
+ let description: Text?
+ let imageName: String?
+ let articleText: Text?
+ let customView: AnyView?
+ let nextText: String?
+}
+
+struct OnboardingView: View {
+ var delegate: SheetDismisserProtocol
+ let cards: [OnboardingCard]
+ @ObservedObject var onboardingState = OnboardingState()
+ @State private var currentIndex = 0
+
+ var body: some View {
+ ZStack {
+ /// Ensure the ZStack takes the entire area
+ Color.clear
+
+ ForEach(Array(zip(cards, cards.indices)), id: \.1) { card, index in
+ /// Only show card that's visible
+ if index == currentIndex {
+ GeometryReader { proxy in
+ SmartScrollView(.vertical, showsIndicators: true, optionalScrolling: true, shrinkToFit: false) {
+ VStack(alignment: .leading, spacing: 16) {
+
+ if currentIndex > 0 {
+ Button {
+ currentIndex -= 1
+ } label: {
+ Label("Back", systemImage: "chevron.left")
+ .labelStyle(.iconOnly)
+ .padding(10)
+ }
+ } else {
+ //make sure the space the "back" label will take, is already reserved to not have "jumps" when pressing next
+ Text("").padding(10)
+ }
+
+ HStack {
+ if let imageName = card.imageName {
+ Image(systemName: imageName)
+ .font(.custom("MarkerFelt-Wide", size: 80))
+ .foregroundColor(.accentColor)
+ .accessibilityHidden(true)
+
+ }
+
+ card.title?
+ .font(.title)
+ .fontWeight(.bold)
+ .foregroundColor(.primary)
+ .padding(.bottom, 4)
+ /// This ensures text doesn't get truncated which sometimes happens in ScrollView
+ .fixedSize(horizontal: false, vertical: true)
+ }
+ .accessibilityElement(children: .combine)
+ .accessibilityAddTraits(.isHeader)
+
+ if let description = card.description {
+ description
+ .font(.custom("HelveticaNeue-Medium", size: 20))
+ .foregroundColor(.primary)
+ .multilineTextAlignment(.leading)
+ /// This ensures text doesn't get truncated which sometimes happens in ScrollView
+ .fixedSize(horizontal: false, vertical: true)
+ }
+
+ if card.imageName != nil || card.description != nil || card.imageName != nil {
+ Spacer().frame(height: 1)
+ Divider()
+ Spacer().frame(height: 1)
+ }
+
+ card.articleText?
+ .font(.custom("HelveticaNeue-Medium", size: 20))
+ .foregroundColor(.primary)
+ .multilineTextAlignment(.leading)
+
+ card.customView
+
+ Spacer()
+
+ Group {
+ if index < cards.count - 1 {
+ Button {
+ currentIndex += 1
+ } label: {
+ HStack {
+ Text(card.nextText ?? NSLocalizedString("Next", comment:"onboarding"))
+ .fontWeight(.bold)
+ Image(systemName: "chevron.right")
+ }
+ }
+ } else {
+ Button {
+ onboardingState.hasCompletedOnboarding = true
+ delegate.dismissWithoutAnimation()
+ } label: {
+ Text(card.nextText ?? NSLocalizedString("Close", comment:"onboarding"))
+ }
+ .buttonStyle(MonalProminentButtonStyle())
+ }
+ }
+ .frame(maxWidth: .infinity, alignment: .trailing)
+ }
+ .padding(.bottom, 16)
+ .padding()
+ /// Sets the minimum frame height to the available height of the scrollview and the maxHeight to infinity
+ .frame(minHeight: proxy.size.height, maxHeight: .infinity)
+ }
+ }
+ .accessibilityAddTraits(.isModal)
+ }
+ }
+ }
+ .onAppear {
+ if UIDevice.current.userInterfaceIdiom != .pad {
+ //force portrait mode and lock ui there
+ UIDevice.current.setValue(UIInterfaceOrientation.portrait.rawValue, forKey: "orientation")
+ (UIApplication.shared.delegate as! MonalAppDelegate).orientationLock = .portrait
+ }
+ }
+ }
+}
+
+@ViewBuilder
+func createOnboardingView(delegate: SheetDismisserProtocol) -> some View {
+#if IS_QUICKSY
+ let cards = [
+ OnboardingCard(
+ title: Text("Welcome to Quicksy !"),
+ description: nil,
+ imageName: "hand.wave",
+ articleText: Text("""
+ Quicksy syncs your contact list in regular intervals to make suggestions about possible contacts who are already on Quicksy.
+
+ Quicksy shares and stores images, audio recordings, videos and other media to deliver them to the intended recipients. Files will be stored for up to 30 days.
+
+ Find more Information in our [Privacy Policy](https://quicksy.im/privacy.htm).
+ """),
+ customView: nil,
+ nextText: "Accept and continue"
+ ),
+ ]
+#else
+ let cards = [
+ OnboardingCard(
+ title: Text("Welcome to Monal !"),
+ description: Text("Become part of a worldwide decentralized chat network!"),
+ imageName: "hand.wave",
+ articleText: Text("""
+ Modern iOS and macOS XMPP chat client.\n\nXMPP is a federated network: Just like email, you can register your account on many servers and still talk to anyone, even if they signed up on a different server.\n\nUsing Monal instead of a centralized chat app therefore increases your digital sovereignty.
+ """),
+ customView: nil,
+ nextText: nil
+ ),
+ OnboardingCard(
+ title: Text("Features"),
+ description: nil,
+ imageName: "sparkles",
+ articleText: Text("""
+ 🛜 Decentralized Network :
+ Leverages the decentralized nature of XMPP, avoiding central servers and increasing your digital sovereignty.
+
+ 🌐 Data privacy :
+ We do not sell or track information for external parties (nor for anyone else).
+
+ 🔐 End-to-end encryption :
+ Secure multi-end messaging using the OMEMO protocol.
+
+ 👨💻 Open Source :
+ The app's source code is publicly available for audit and contribution.
+ """),
+ customView: nil,
+ nextText: nil
+ ),
+ OnboardingCard(
+ title: Text("Settings"),
+ description: Text("These are important privacy settings you may want to review!"),
+ imageName: "gear",
+ articleText: nil,
+ customView: AnyView(PrivacySettingsSubview(onboardingPart:0)),
+ nextText: nil
+ ),
+ OnboardingCard(
+ title: Text("Settings"),
+ description: Text("These are important privacy settings you may want to review!"),
+ imageName: "gear",
+ articleText: nil,
+ customView: AnyView(PrivacySettingsSubview(onboardingPart:1)),
+ nextText: nil
+ ),
+ OnboardingCard(
+ title: Text("Even more to customize!"),
+ description: Text("You can customize even more, just use the button below to open the settings."),
+ imageName: "hand.wave",
+ articleText: nil,
+ customView: AnyView(TakeMeToSettingsView(delegate:delegate)),
+ nextText: nil
+ ),
+ ]
+#endif
+ OnboardingView(delegate: delegate, cards: cards)
+}
+
+struct TakeMeToSettingsView: View {
+ @ObservedObject var onboardingState = OnboardingState()
+ var delegate: SheetDismisserProtocol
+
+ var body: some View {
+ HStack {
+ Spacer()
+ Button(action: {
+ let appDelegate = UIApplication.shared.delegate as! MonalAppDelegate
+ if let activeChats = appDelegate.activeChats {
+ activeChats.prependGeneralSettings()
+ }
+ onboardingState.hasCompletedOnboarding = true
+ delegate.dismissWithoutAnimation()
+ }) {
+ Text("Take me to settings")
+ }
+ .buttonStyle(MonalProminentButtonStyle())
+
+ Spacer()
+ }
+ }
+}
+
+struct OnboardingView_Previews: PreviewProvider {
+ static var delegate = SheetDismisserProtocol()
+ static var previews: some View {
+ createOnboardingView(delegate: delegate)
+ .environmentObject(OnboardingState())
+ }
+}
diff --git a/Monal/Classes/ChannelMemberList.swift b/Monal/Classes/ChannelMemberList.swift
new file mode 100644
index 0000000..3229edb
--- /dev/null
+++ b/Monal/Classes/ChannelMemberList.swift
@@ -0,0 +1,77 @@
+//
+// ChannelMemberList.swift
+// Monal
+//
+// Created by Friedrich Altheide on 17.02.24.
+// Copyright © 2024 monal-im.org. All rights reserved.
+//
+
+import OrderedCollections
+
+struct ChannelMemberList: View {
+ private let account: xmpp
+ @State private var ownAffiliation: String;
+ @StateObject var channel: ObservableKVOWrapper
+ @State private var participants: OrderedDictionary
+
+ init(mucContact: ObservableKVOWrapper) {
+ account = mucContact.obj.account! as xmpp
+ _channel = StateObject(wrappedValue:mucContact)
+ _ownAffiliation = State(wrappedValue:kMucAffiliationNone)
+ _participants = State(wrappedValue:OrderedDictionary())
+ }
+
+ func updateParticipantList() {
+ ownAffiliation = DataLayer.sharedInstance().getOwnAffiliation(inGroupOrChannel:channel.obj) ?? kMucAffiliationNone
+ participants.removeAll(keepingCapacity:true)
+ for memberInfo in Array(DataLayer.sharedInstance().getMembersAndParticipants(ofMuc:channel.contactJid, forAccountID:account.accountID)) {
+ //ignore ourselves
+ if let jid = memberInfo["participant_jid"] as? String ?? memberInfo["member_jid"] as? String {
+ if jid == account.connectionProperties.identity.jid {
+ continue
+ }
+ }
+ if let nick = memberInfo["room_nick"] as? String {
+ participants[nick] = memberInfo["affiliation"] as? String ?? kMucAffiliationNone
+ }
+ }
+ participants.sort {
+ (mucAffiliationToInt($0.value), $0.key.lowercased()) < (mucAffiliationToInt($1.value), $1.key.lowercased())
+ }
+ }
+
+
+ var body: some View {
+ List {
+ Section(header: Text("\(self.channel.contactDisplayName as String) (affiliation: \(mucAffiliationToString(ownAffiliation)))")) {
+ ForEach(participants.keys, id: \.self) { participant_key in
+ ZStack(alignment: .topLeading) {
+ HStack(alignment: .center) {
+ Text(participant_key)
+ Spacer()
+ Text(mucAffiliationToString(participants[participant_key]))
+ }
+ }
+ }
+ }
+ }
+ .navigationBarTitle(Text("Channel Participants"), displayMode: .inline)
+ .onAppear {
+ updateParticipantList()
+ }
+ .onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("kMonalMucParticipantsAndMembersUpdated")).receive(on: RunLoop.main)) { notification in
+ if let xmppAccount = notification.object as? xmpp, let contact = notification.userInfo?["contact"] as? MLContact {
+ DDLogVerbose("Got muc participants/members update from account \(xmppAccount)...")
+ if contact == channel {
+ updateParticipantList()
+ }
+ }
+ }
+ }
+}
+
+struct ChannelMemberList_Previews: PreviewProvider {
+ static var previews: some View {
+ ChannelMemberList(mucContact:ObservableKVOWrapper(MLContact.makeDummyContact(3)));
+ }
+}
diff --git a/Monal/Classes/ChatPlaceholder.swift b/Monal/Classes/ChatPlaceholder.swift
new file mode 100644
index 0000000..cd6a407
--- /dev/null
+++ b/Monal/Classes/ChatPlaceholder.swift
@@ -0,0 +1,29 @@
+//
+// ChatPlaceholder.swift
+// Monal
+//
+// Created by Thilo Molitor on 30.11.22.
+// Copyright © 2022 monal-im.org. All rights reserved.
+//
+
+struct ChatPlaceholder: View {
+ @Environment(\.colorScheme) var colorScheme
+ var body: some View {
+ ZStack {
+ if colorScheme == .dark {
+ Color.black
+ } else {
+ Color.white
+ }
+ Image(colorScheme == .dark ? "park_white_black" : "park_colors")
+ .resizable()
+ .scaledToFill()
+ }
+ }
+}
+
+struct ChatPlaceholder_Previews: PreviewProvider {
+ static var previews: some View {
+ ChatPlaceholder()
+ }
+}
diff --git a/Monal/Classes/ContactDetails.swift b/Monal/Classes/ContactDetails.swift
new file mode 100644
index 0000000..067b7da
--- /dev/null
+++ b/Monal/Classes/ContactDetails.swift
@@ -0,0 +1,701 @@
+//
+// ContactDetails.swift
+// Monal
+//
+// Created by Jan on 22.10.21.
+// Copyright © 2021 Monal.im. All rights reserved.
+//
+
+struct ContactDetails: View {
+ @Environment(\.presentationMode) private var presentationMode
+ @State private var ownRole = kMucRoleParticipant
+ @State private var ownAffiliation = kMucAffiliationNone
+ @StateObject var contact: ObservableKVOWrapper
+ @State private var showingRemoveAvatarConfirmation = false
+ @State private var showingBlockContactConfirmation = false
+ @State private var showingCannotBlockAlert = false
+ @State private var showingRemoveContactConfirmation = false
+ @State private var showingAddContactConfirmation = false
+ @State private var showingClearHistoryConfirmation = false
+ @State private var showingResetOmemoSessionConfirmation = false
+ @State private var showingCannotEncryptAlert = false
+ @State private var showingShouldDisableEncryptionAlert = false
+ @State private var isEditingNickname = false
+ @State private var inputImage: UIImage?
+ @State private var showingImagePicker = false
+ @State private var showingSheetEditSubject = false
+ @State private var showingDestroyConfirmation = false
+ @State private var alertPrompt = AlertPrompt(dismissLabel: Text("Close"))
+ @State private var showAlert = false
+ @State private var success = false
+ @State private var successCallback: monal_void_block_t?
+ @StateObject private var overlay = LoadingOverlayState()
+ var delegate: SheetDismisserProtocol?
+ private var account: xmpp
+
+ init(delegate: SheetDismisserProtocol?, contact: ObservableKVOWrapper) {
+ self.delegate = delegate
+ _contact = StateObject(wrappedValue: contact)
+ self.account = contact.obj.account!
+ }
+
+ private func updateRoleAndAffiliation() {
+ if contact.isMuc {
+ self.ownRole = DataLayer.sharedInstance().getOwnRole(inGroupOrChannel: contact.obj) ?? kMucRoleNone
+ self.ownAffiliation = DataLayer.sharedInstance().getOwnAffiliation(inGroupOrChannel:contact.obj) ?? kMucAffiliationNone
+ } else {
+ self.ownRole = kMucRoleParticipant
+ self.ownAffiliation = kMucAffiliationNone
+ }
+ }
+
+ private func errorAlert(title: Text, message: Text = Text("")) {
+ alertPrompt.title = title
+ alertPrompt.message = message
+ showAlert = true
+ }
+
+ private func successAlert(title: Text, message: Text = Text("")) {
+ alertPrompt.title = title
+ alertPrompt.message = message
+ showAlert = true
+ success = true // < dismiss entire view on close
+ }
+
+ private func showImagePicker() {
+#if targetEnvironment(macCatalyst)
+ let picker = DocumentPickerViewController(
+ supportedTypes: [UTType.image],
+ onPick: { url in
+ if let imageData = try? Data(contentsOf: url) {
+ if let loadedImage = UIImage(data: imageData) {
+ self.inputImage = loadedImage
+ }
+ }
+ },
+ onDismiss: {
+ //do nothing on dismiss
+ }
+ )
+ UIApplication.shared.windows.first?.rootViewController?.present(picker, animated: true)
+#else
+ showingImagePicker = true
+#endif
+ }
+
+ var body: some View {
+ Form {
+ Section {
+ VStack(spacing: 20) {
+ if !contact.isSelfChat {
+ Image(uiImage: contact.avatar)
+ .resizable()
+ .scaledToFit()
+ .applyClosure {view in
+ if contact.isMuc {
+ if ownAffiliation == kMucAffiliationOwner {
+ view.accessibilityLabel((contact.mucType == kMucTypeGroup) ? Text("Change Group Avatar") : Text("Change Channel Avatar"))
+ .onTapGesture {
+ showImagePicker()
+ }
+ .addTopRight {
+ if contact.hasAvatar {
+ Button(action: {
+ showingRemoveAvatarConfirmation = true
+ }, label: {
+ Image(systemName: "xmark.circle.fill")
+ .resizable()
+ .frame(width: 24.0, height: 24.0)
+ .accessibilityLabel((contact.mucType == kMucTypeGroup) ? Text("Remove Group Avatar") : Text("Remove Channel Avatar"))
+ .symbolRenderingMode(.palette)
+ .foregroundStyle(.white, .red)
+ })
+ .buttonStyle(.borderless)
+ .offset(x: 8, y: -8)
+ } else {
+ Button(action: {
+ showImagePicker()
+ }, label: {
+ Image(systemName: "pencil.circle.fill")
+ .resizable()
+ .frame(width: 24.0, height: 24.0)
+ .accessibilityLabel((contact.mucType == kMucTypeGroup) ? Text("Change Group Avatar") : Text("Change Channel Avatar"))
+ })
+ .buttonStyle(.borderless)
+ .offset(x: 8, y: -8)
+ }
+ }
+ } else {
+ view.accessibilityLabel((contact.mucType == kMucTypeGroup) ? Text("Group Avatar") : Text("Channel Avatar"))
+ }
+ } else {
+ view.accessibilityLabel(Text("Avatar"))
+ }
+ }
+ .frame(width: 150, height: 150, alignment: .center)
+ .shadow(radius: 7)
+ .actionSheet(isPresented: $showingRemoveAvatarConfirmation) {
+ ActionSheet(
+ title: Text("Really remove avatar?"),
+ message: Text("This will remove the current avatar image and revert this group/channel to the default one."),
+ buttons: [
+ .cancel(),
+ .destructive(
+ Text("Yes"),
+ action: {
+ showPromisingLoadingOverlay(overlay, headlineView:Text("Removing avatar..."), descriptionView:Text("")) {
+ promisifyMucAction(account:account, mucJid:contact.contactJid) {
+ self.account.mucProcessor.publishAvatar(nil, forMuc: contact.contactJid)
+ }
+ }.catch { error in
+ errorAlert(title: Text("Error removing avatar!"), message: Text("\(String(describing:error))"))
+ hideLoadingOverlay(overlay)
+ }
+ }
+ )
+ ]
+ )
+ }
+ }
+
+ Button {
+ UIPasteboard.general.setValue(contact.contactJid as String, forPasteboardType:UTType.utf8PlainText.identifier as String)
+ UIAccessibility.post(notification: .announcement, argument: "JID Copied")
+ } label: {
+ HStack {
+ Text(contact.contactJid as String)
+
+ Image(systemName: "doc.on.doc")
+ .foregroundColor(.primary)
+ .accessibilityHidden(true)
+ }
+ .accessibilityHint("Copies JID")
+ }
+ .buttonStyle(.borderless)
+
+// //TODO: wait for account edit to become swiftui
+// if contact.isSelfChat {
+// Button {
+// //TODO: open account edit
+// } label: {
+// Text("Open account settings")
+// .accessibilityHint("Open account settings")
+// }
+// .buttonStyle(.borderless)
+// }
+
+
+ //only show account jid if more than one is configured
+ if MLXMPPManager.sharedInstance().connectedXMPP.count > 1 && !contact.isSelfChat {
+ Text("Account: \(account.connectionProperties.identity.jid)")
+ }
+
+ if !contact.isSelfChat && !contact.isMuc {
+ if let lastInteractionTime = contact.lastInteractionTime as Date? {
+ if lastInteractionTime.timeIntervalSince1970 > 0 {
+ Text(String(format: NSLocalizedString("Last seen: %@", comment: ""),
+ DateFormatter.localizedString(from: lastInteractionTime, dateStyle: DateFormatter.Style.short, timeStyle: DateFormatter.Style.short)))
+ } else {
+ Text(String(format: NSLocalizedString("Last seen: %@", comment: ""), NSLocalizedString("now", comment: "")))
+ }
+ } else {
+ Text(String(format: NSLocalizedString("Last seen: %@", comment: ""), NSLocalizedString("unknown", comment: "")))
+ }
+ }
+
+ if !contact.isMuc, let statusMessage = contact.statusMessage as String?, statusMessage.count > 0 {
+ VStack {
+ Text("Status message:")
+ Text(contact.statusMessage as String)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+ }
+
+ if contact.isMuc && ((contact.groupSubject as String).count > 0 || ownRole == kMucRoleModerator) {
+ VStack {
+ if ownRole == kMucRoleModerator {
+ Button {
+ showingSheetEditSubject.toggle()
+ } label: {
+ if contact.obj.mucType == kMucTypeGroup {
+ HStack {
+ Text("Group subject:")
+ Spacer().frame(width:8)
+ Image(systemName: "pencil")
+ .foregroundColor(.primary)
+ .accessibilityHidden(true)
+ }
+ .accessibilityHint("Edit Group Subject")
+ } else {
+ HStack {
+ Text("Channel subject:")
+ Spacer().frame(width:8)
+ Image(systemName: "pencil")
+ .foregroundColor(.primary)
+ .accessibilityHidden(true)
+ }
+ .accessibilityHint("Edit Channel Subject")
+ }
+ }
+ .buttonStyle(.borderless)
+ } else {
+ Text("Group subject:")
+ }
+
+ Text(contact.groupSubject as String)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+ }
+ }
+ .foregroundColor(.primary)
+ .padding([.top, .bottom])
+ .frame(maxWidth: .infinity)
+ }
+
+ // info/nondestructive buttons
+ Section {
+ if !contact.isSelfChat {
+ Button {
+ if contact.isMuc {
+ if !contact.isMuted && !contact.isMentionOnly {
+ contact.obj.toggleMentionOnly(true)
+ } else if !contact.isMuted && contact.isMentionOnly {
+ contact.obj.toggleMentionOnly(false)
+ contact.obj.toggleMute(true)
+ } else {
+ contact.obj.toggleMentionOnly(false)
+ contact.obj.toggleMute(false)
+ }
+ } else {
+ contact.obj.toggleMute(!contact.isMuted)
+ }
+ } label: {
+ if contact.isMuted {
+ Label {
+ contact.isMuc ? Text("Notifications disabled") : Text("Contact is muted")
+ } icon: {
+ Image(systemName: "bell.slash.fill")
+ }
+ .foregroundStyle(Color.red)
+ } else if contact.isMuc && contact.isMentionOnly {
+ Label {
+ Text("Notify only when mentioned")
+ } icon: {
+ Image(systemName: "bell.badge")
+ }
+ .foregroundStyle(Color.primary)
+ } else {
+ Label {
+ contact.isMuc ? Text("Notify on all messages") : Text("Contact is not muted")
+ } icon: {
+ Image(systemName: "bell.fill")
+ }
+ .foregroundStyle(Color.green)
+ }
+ }
+ }
+
+#if !DISABLE_OMEMO
+ if (!contact.isMuc || (contact.isMuc && contact.mucType == kMucTypeGroup)) && !HelperTools.isContactBlacklistedForEncryption(contact.obj) {
+ Button {
+ if contact.isEncrypted {
+ showingShouldDisableEncryptionAlert = true
+ } else {
+ showingCannotEncryptAlert = !contact.obj.toggleEncryption(!contact.isEncrypted)
+ }
+ } label: {
+ if contact.isEncrypted {
+ Label {
+ Text("Messages are encrypted")
+ } icon: {
+ Image(systemName: "lock.fill")
+ }
+ .foregroundStyle(Color.green)
+ } else {
+ Label {
+ Text("Messages are NOT encrypted")
+ } icon: {
+ Image(systemName: "lock.open.fill")
+ }
+ .foregroundStyle(Color.red)
+ }
+ }
+ .alert(isPresented: $showingCannotEncryptAlert) {
+ Alert(title: Text("Encryption Not Supported"), message: Text("This contact does not appear to have any devices that support encryption, please try again later if you think this is wrong."), dismissButton: .default(Text("Close")))
+ }
+ .actionSheet(isPresented: $showingShouldDisableEncryptionAlert) {
+ ActionSheet(
+ title: Text("Disable encryption?"),
+ message: Text("Do you really want to disable encryption for this contact?"),
+ buttons: [
+ .cancel(
+ Text("No, keep encryption activated"),
+ action: { }
+ ),
+ .destructive(
+ Text("Yes, deactivate encryption"),
+ action: {
+ showingCannotEncryptAlert = !contact.obj.toggleEncryption(!contact.isEncrypted)
+ }
+ )
+ ]
+ )
+ }
+ //.buttonStyle(BorderlessButtonStyle())
+ }
+#endif
+
+ if contact.isMuc && ownAffiliation == kMucAffiliationOwner {
+ let label = contact.obj.mucType == kMucTypeGroup ? NSLocalizedString("Rename Group", comment:"") : NSLocalizedString("Rename Channel", comment:"")
+ TextField(label, text: $contact.fullNameView, onEditingChanged: {
+ isEditingNickname = $0
+ })
+ .accessibilityLabel(contact.obj.mucType == kMucTypeGroup ? Text("Group name") : Text("Channel name"))
+ .addClearButton(isEditing: isEditingNickname, text: $contact.fullNameView)
+ } else if !contact.isMuc && !contact.isSelfChat {
+ TextField(NSLocalizedString("Rename Contact", comment: "placeholder text in contact details"), text: $contact.nickNameView, onEditingChanged: {
+ isEditingNickname = $0
+ })
+ .accessibilityLabel(Text("Nickname"))
+ .addClearButton(isEditing: isEditingNickname, text: $contact.nickNameView)
+ }
+
+ Toggle(isOn: Binding(get: {
+ contact.isPinned
+ }, set: {
+ contact.obj.togglePinnedChat($0)
+ })) {
+ Text("Pin Chat")
+ }
+
+#if !DISABLE_OMEMO
+ if !HelperTools.isContactBlacklistedForEncryption(contact.obj) && !contact.isSelfChat {
+ if !contact.isMuc || contact.mucType == kMucTypeGroup {
+ NavigationLink(destination: LazyClosureView(OmemoKeysView(omemoKeys: OmemoKeysForChat(viewContact: contact)))) {
+ Text("Encryption Keys")
+ }
+ }
+ }
+#endif
+
+ if !contact.isMuc && !contact.isSelfChat {
+ NavigationLink(destination: LazyClosureView(ContactResources(contact: contact))) {
+ Text("Resources")
+ }
+ }
+
+ let accountJid = account.connectionProperties.identity.jid
+ let displayName = contact.contactDisplayName as String
+ let sharedUrl = HelperTools.getSharedDocumentsURL(forPathComponents:[accountJid, displayName])
+ if UIApplication.shared.canOpenURL(sharedUrl) && FileManager.default.fileExists(atPath:sharedUrl.path) {
+ NavigationLink(destination: LazyClosureView{MediaGalleryView(contact: contact.contactJid as String, accountID: contact.accountID)}) {
+ Text("Shared Media")
+ }
+
+ Button(action: {
+ UIApplication.shared.open(sharedUrl, options:[:])
+ }) {
+ Text("Shared Files")
+ }
+ }
+
+ NavigationLink(destination: LazyClosureView(BackgroundSettings(contact:contact))) {
+ Text("Change Chat Background")
+ }
+
+ if contact.obj.isMuc && contact.obj.mucType == kMucTypeGroup {
+ NavigationLink(destination: LazyClosureView(MemberList(mucContact:contact))) {
+ Text("Group Members")
+ }
+ } else if contact.obj.isMuc && contact.obj.mucType == kMucTypeChannel {
+ if [kMucAffiliationOwner, kMucAffiliationAdmin].contains(ownAffiliation) {
+ NavigationLink(destination: LazyClosureView(MemberList(mucContact:contact))) {
+ Text("Channel Participants")
+ }
+ } else {
+ NavigationLink(destination: LazyClosureView(ChannelMemberList(mucContact:contact))) {
+ Text("Channel Participants")
+ }
+ }
+ }
+ }
+ .listStyle(.plain)
+
+ Section { // the destructive section...
+ if !contact.isSelfChat {
+ Button(action: {
+ if !contact.isBlocked {
+ showingBlockContactConfirmation = true
+ } else {
+ showingCannotBlockAlert = !contact.obj.toggleBlocked(!contact.isBlocked)
+ }
+ }) {
+ if !contact.isBlocked {
+ Text("Block Contact")
+ .foregroundColor(.red)
+ } else {
+ Text("Unblock Contact")
+ }
+ }
+ .alert(isPresented: $showingCannotBlockAlert) {
+ Alert(title: Text("Blocking/Unblocking Not Supported"), message: Text("The server does not support blocking (XEP-0191)."), dismissButton: .default(Text("Close")))
+ }
+ .actionSheet(isPresented: $showingBlockContactConfirmation) {
+ ActionSheet(
+ title: Text("Block Contact"),
+ message: Text("Do you really want to block this contact? You won't receive any messages from this contact."),
+ buttons: [
+ .cancel(),
+ .destructive(
+ Text("Yes"),
+ action: {
+ showingCannotBlockAlert = !contact.obj.toggleBlocked(!contact.isBlocked)
+ }
+ )
+ ]
+ )
+ }
+
+ Group {
+ if contact.isInRoster {
+ Button(action: {
+ showingRemoveContactConfirmation = true
+ }) {
+ if contact.isMuc {
+ if contact.mucType == kMucTypeGroup {
+ Text("Leave Group")
+ .foregroundColor(.red)
+ } else {
+ Text("Leave Channel")
+ .foregroundColor(.red)
+ }
+ } else {
+ Text("Remove from contacts")
+ .foregroundColor(.red)
+ }
+ }
+ .actionSheet(isPresented: $showingRemoveContactConfirmation) {
+ ActionSheet(
+ title: Text(contact.isMuc ? NSLocalizedString("Leave this conversation", comment: "") : String(format: NSLocalizedString("Remove %@ from contacts?", comment: ""), contact.contactJid)),
+ message: Text(contact.isMuc ? NSLocalizedString("You will no longer receive messages from this conversation", comment: "") : NSLocalizedString("They will no longer see when you are online. They may not be able to send you encrypted messages.", comment: "")),
+ buttons: [
+ .cancel(),
+ .destructive(
+ Text("Yes"),
+ action: {
+ contact.obj.removeFromRoster() //this will dismiss the chatview via kMonalContactRemoved notification
+ //NOTE: since we can get opened from objc through active chats,
+ //NOTE: we still need to support our SheetDismisserProtocol
+ if let delegate = self.delegate {
+ delegate.dismiss()
+ } else {
+ self.presentationMode.wrappedValue.dismiss()
+ }
+ }
+ )
+ ]
+ )
+ }
+ } else {
+ Button(action: {
+ showingAddContactConfirmation = true
+ }) {
+ if contact.isMuc {
+ if contact.mucType == kMucTypeGroup {
+ Text("Join Group")
+ } else {
+ Text("Join Channel")
+ }
+ } else {
+ Text("Add to contacts")
+ }
+ }
+ .actionSheet(isPresented: $showingAddContactConfirmation) {
+ ActionSheet(
+ title: Text(contact.isMuc ? (contact.mucType == kMucTypeGroup ? NSLocalizedString("Join Group", comment: "") : NSLocalizedString("Join Channel", comment: "")) : String(format: NSLocalizedString("Add %@ to your contacts?", comment: ""), contact.contactJid)),
+ message: Text(contact.isMuc ? NSLocalizedString("You will receive subsequent messages from this conversation", comment: "") : NSLocalizedString("They will see when you are online. They will be able to send you encrypted messages.", comment: "")),
+ buttons: [
+ .cancel(),
+ .default(
+ Text("Yes"),
+ action: {
+ contact.obj.addToRoster()
+ }
+ ),
+ ]
+ )
+ }
+ }
+ }
+ }
+
+ if ownAffiliation == kMucAffiliationOwner {
+ Section {
+ Button(action: {
+ showingDestroyConfirmation = true
+ }) {
+ if contact.mucType == kMucTypeGroup {
+ Text("Destroy Group").foregroundColor(.red)
+ } else {
+ Text("Destroy Channel").foregroundColor(.red)
+ }
+ }
+ .actionSheet(isPresented: $showingDestroyConfirmation) {
+ ActionSheet(
+ title: contact.mucType == kMucTypeGroup ? Text("Destroy Group") : Text("Destroy Channel"),
+ message: contact.mucType == kMucTypeGroup ? Text("Do you really want to destroy this group? Every member will be kicked out and it will be destroyed afterwards.") : Text("Do you really want to destroy this channel? Every member will be kicked out and it will be destroyed afterwards."),
+ buttons: [
+ .cancel(),
+ .destructive(
+ Text("Yes"),
+ action: {
+ showPromisingLoadingOverlay(overlay, headlineView:contact.mucType == kMucTypeGroup ? Text("Destroying group...") : Text("Destroying channel..."), descriptionView:Text("")) {
+ promisifyMucAction(account:account, mucJid:contact.contactJid) {
+ self.account.mucProcessor.destroyRoom(contact.contactJid as String)
+ }
+ }.done { callback in
+ if let callback = callback {
+ self.successCallback = callback
+ }
+ successAlert(title: Text("Success"), message: contact.mucType == kMucTypeGroup ? Text("Successfully destroyed group.") : Text("Successfully destroyed channel."))
+ }.catch { error in
+ errorAlert(title: Text("Error destroying group!"), message: Text("\(String(describing:error))"))
+ }
+ }
+ )
+ ]
+ )
+ }
+ }
+ }
+
+ Button(action: {
+ showingClearHistoryConfirmation = true
+ }) {
+ if contact.isMuc {
+ if contact.obj.mucType == kMucTypeGroup {
+ Text("Clear chat history of this group")
+ } else {
+ Text("Clear chat history of this channel")
+ }
+ } else {
+ Text("Clear chat history of this contact")
+ }
+ }
+ .foregroundColor(.red)
+ .actionSheet(isPresented: $showingClearHistoryConfirmation) {
+ ActionSheet(
+ title: Text("Clear History"),
+ message: Text("Do you really want to clear all messages exchanged in this conversation? If using OMEMO you won't even be able to load them from your server again."),
+ buttons: [
+ .cancel(),
+ .destructive(
+ Text("Yes"),
+ action: {
+ contact.obj.clearHistory()
+ }
+ )
+ ]
+ )
+ }
+ }
+
+#if !DISABLE_OMEMO
+ //omemo debug stuff, should be removed in a few months
+ Section {
+ // only display omemo session reset button on 1:1 and private groups
+ if contact.obj.isMuc == false || (contact.isMuc && contact.mucType == kMucTypeGroup) {
+ Button(action: {
+ showingResetOmemoSessionConfirmation = true
+ }) {
+ Text("Reset OMEMO session")
+ .foregroundColor(.red)
+ }
+ .actionSheet(isPresented: $showingResetOmemoSessionConfirmation) {
+ ActionSheet(
+ title: Text("Reset OMEMO session"),
+ message: Text("Do you really want to reset the OMEMO session? You should only reset the connection if you know what you are doing!"),
+ buttons: [
+ .cancel(),
+ .destructive(
+ Text("Yes"),
+ action: {
+ self.account.omemo.clearAllSessions(forJid:contact.contactJid);
+ }
+ )
+ ]
+ )
+ }
+ }
+ }
+#endif
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .tint(Color.primary)
+ .addLoadingOverlay(overlay)
+ .navigationBarTitle(contact.contactDisplayName as String, displayMode:.inline)
+ .alert(isPresented: $showAlert) {
+ Alert(title: alertPrompt.title, message: alertPrompt.message, dismissButton:.default(Text("Close"), action: {
+ showAlert = false
+ if self.success == true {
+ if let callback = self.successCallback {
+ callback()
+ }
+ //close muc ui and leave chat ui of this muc
+ if let activeChats = (UIApplication.shared.delegate as! MonalAppDelegate).activeChats {
+ activeChats.presentChat(with:nil)
+ }
+ }
+ }))
+ }
+ .sheet(isPresented: $showingSheetEditSubject) {
+ LazyClosureView(EditGroupSubject(contact: contact))
+ }
+ .sheet(isPresented:$showingImagePicker) {
+ ImagePicker(image:$inputImage)
+ }
+ .sheet(isPresented: $inputImage.optionalMappedToBool()) {
+ ImageCropView(originalImage: inputImage!, configureBlock: { cropViewController in
+ cropViewController.aspectRatioPreset = .presetSquare
+ cropViewController.aspectRatioLockEnabled = true
+ cropViewController.aspectRatioPickerButtonHidden = true
+ cropViewController.resetAspectRatioEnabled = false
+ }, onCanceled: {
+ inputImage = nil
+ }) { (image, cropRect, angle) in
+ showPromisingLoadingOverlay(overlay, headlineView:Text("Uploading avatar..."), descriptionView:Text("")) {
+ promisifyMucAction(account:account, mucJid:contact.contactJid) {
+ self.account.mucProcessor.publishAvatar(image, forMuc: contact.contactJid)
+ }
+ }.catch { error in
+ errorAlert(title: Text("Error changing avatar!"), message: Text("\(String(describing:error))"))
+ hideLoadingOverlay(overlay)
+ }
+ }
+ }
+ .onChange(of:contact.avatar as UIImage) { _ in
+ hideLoadingOverlay(overlay)
+ }
+ .onAppear {
+ self.updateRoleAndAffiliation()
+ }
+ .onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("kMonalMucParticipantsAndMembersUpdated")).receive(on: RunLoop.main)) { notification in
+ if let xmppAccount = notification.object as? xmpp, let notificationContact = notification.userInfo?["contact"] as? MLContact {
+ DDLogVerbose("Got muc participants/members update from account \(xmppAccount)...")
+ if notificationContact == contact {
+ self.updateRoleAndAffiliation()
+ }
+ }
+ }
+ }
+}
+
+struct ContactDetails_Previews: PreviewProvider {
+ static var previews: some View {
+ ContactDetails(delegate:nil, contact:ObservableKVOWrapper(MLContact.makeDummyContact(0)))
+ ContactDetails(delegate:nil, contact:ObservableKVOWrapper(MLContact.makeDummyContact(1)))
+ ContactDetails(delegate:nil, contact:ObservableKVOWrapper(MLContact.makeDummyContact(2)))
+ ContactDetails(delegate:nil, contact:ObservableKVOWrapper(MLContact.makeDummyContact(3)))
+ ContactDetails(delegate:nil, contact:ObservableKVOWrapper(MLContact.makeDummyContact(4)))
+ }
+}
diff --git a/Monal/Classes/ContactEntry.swift b/Monal/Classes/ContactEntry.swift
new file mode 100644
index 0000000..55069e7
--- /dev/null
+++ b/Monal/Classes/ContactEntry.swift
@@ -0,0 +1,86 @@
+//
+// ContactEntry.swift
+// Monal
+//
+// Created by Friedrich Altheide on 28.11.23.
+// Copyright © 2023 monal-im.org. All rights reserved.
+//
+
+struct ContactEntry: View {
+ let contact: ObservableKVOWrapper
+ let selfnotesPrefix: Bool
+ let fallback: String?
+ @ViewBuilder let additionalContent: () -> AdditionalContent
+
+ init(contact:ObservableKVOWrapper, selfnotesPrefix: Bool = true, fallback: String? = nil) where AdditionalContent == EmptyView {
+ self.init(contact:contact, selfnotesPrefix:selfnotesPrefix, fallback:fallback, additionalContent:{ EmptyView() })
+ }
+
+ init(contact:ObservableKVOWrapper, fallback: String?) where AdditionalContent == EmptyView {
+ self.init(contact:contact, selfnotesPrefix:true, fallback:fallback, additionalContent:{ EmptyView() })
+ }
+
+ init(contact:ObservableKVOWrapper, @ViewBuilder additionalContent: @escaping () -> AdditionalContent) {
+ self.init(contact:contact, selfnotesPrefix:true, additionalContent:additionalContent)
+ }
+
+ init(contact:ObservableKVOWrapper, fallback: String?, @ViewBuilder additionalContent: @escaping () -> AdditionalContent) {
+ self.init(contact:contact, selfnotesPrefix:true, fallback:fallback, additionalContent:additionalContent)
+ }
+
+ init(contact:ObservableKVOWrapper, selfnotesPrefix: Bool, @ViewBuilder additionalContent: @escaping () -> AdditionalContent) {
+ self.init(contact:contact, selfnotesPrefix:selfnotesPrefix, fallback:nil, additionalContent:additionalContent)
+ }
+
+ init(contact:ObservableKVOWrapper, selfnotesPrefix: Bool, fallback: String?, @ViewBuilder additionalContent: @escaping () -> AdditionalContent) {
+ self.contact = contact
+ self.selfnotesPrefix = selfnotesPrefix
+ self.fallback = fallback
+ self.additionalContent = additionalContent
+ }
+
+ var body:some View {
+ ZStack(alignment: .topLeading) {
+ HStack(alignment: .center) {
+ Image(uiImage: contact.avatar)
+ .resizable()
+ .frame(width: 40, height: 40, alignment: .center)
+ VStack(alignment: .leading) {
+ if selfnotesPrefix {
+ // use the if to make sure this view gets updated if the contact display name changes
+ // (the condition is never false, because contactDisplayName can not be nil)
+ if (contact.contactDisplayName as String?) != nil {
+ Text(contact.obj.contactDisplayName(withFallback:fallback))
+ }
+ } else {
+ // use the if to make sure this view gets updated if the contact display name changes
+ // (the condition is never false, because contactDisplayNameWithoutSelfnotesPrefix can not be nil)
+ if (contact.contactDisplayNameWithoutSelfnotesPrefix as String?) != nil {
+ Text(contact.obj.contactDisplayName(withFallback:fallback, andSelfnotesPrefix:false))
+ }
+ }
+ additionalContent()
+ Text(contact.contactJid as String)
+ .foregroundColor(Color(UIColor.secondaryLabel))
+ .font(.footnote)
+ }
+ }
+ }
+ }
+}
+
+#Preview {
+ ContactEntry(contact:ObservableKVOWrapper(MLContact.makeDummyContact(0)))
+}
+
+#Preview {
+ ContactEntry(contact:ObservableKVOWrapper(MLContact.makeDummyContact(1)))
+}
+
+#Preview {
+ ContactEntry(contact:ObservableKVOWrapper(MLContact.makeDummyContact(2)))
+}
+
+#Preview {
+ ContactEntry(contact:ObservableKVOWrapper(MLContact.makeDummyContact(3)))
+}
diff --git a/Monal/Classes/ContactPicker.swift b/Monal/Classes/ContactPicker.swift
new file mode 100644
index 0000000..4489052
--- /dev/null
+++ b/Monal/Classes/ContactPicker.swift
@@ -0,0 +1,135 @@
+//
+// ContactList.swift
+// Monal
+//
+// Created by Jan on 15.12.22.
+// Copyright © 2022 monal-im.org. All rights reserved.
+//
+
+import OrderedCollections
+
+struct ContactPickerEntry: View {
+ let contact : ObservableKVOWrapper
+ let isPicked: Bool
+ let isExistingMember: Bool
+
+ var body:some View {
+ ZStack(alignment: .topLeading) {
+ HStack(alignment: .center) {
+ if(isExistingMember) {
+ Image(systemName: "checkmark.circle")
+ .foregroundColor(.gray)
+ } else if(isPicked) {
+ Image(systemName: "checkmark.circle")
+ .foregroundColor(.accentColor)
+ } else {
+ Image(systemName: "circle")
+ }
+ ContactEntry(contact: contact)
+ }
+ }
+ }
+}
+
+struct ContactPicker: View {
+ typealias completionType = (OrderedSet>)->Void
+ let account: xmpp
+ @Binding var returnedContacts: OrderedSet>
+ @State var selectedContacts: OrderedSet>
+ @State var searchText = ""
+ @State var isEditingSearchInput = false
+ let allowRemoval: Bool
+ let completion: completionType?
+
+ init(_ account: xmpp, initializeFrom contacts: OrderedSet>, allowRemoval: Bool = true, completion:completionType?) {
+ self.account = account
+ self.allowRemoval = allowRemoval
+ self.completion = completion
+ _selectedContacts = State(wrappedValue:OrderedSet())
+ //use a temporary storage because we don't have a binding to the outside world but use the completion handler
+ var storage = contacts
+ _returnedContacts = Binding(
+ get: { storage },
+ set: { storage = $0 }
+ )
+ buildPreselectedContacts(contacts)
+ DDLogError("self.allowRemoval = \(String(describing:self.allowRemoval))")
+ }
+
+ init(_ account: xmpp, binding returnedContacts: Binding>>, allowRemoval: Bool = true) {
+ self.account = account
+ self.allowRemoval = allowRemoval
+ self.completion = nil
+ _selectedContacts = State(wrappedValue:OrderedSet())
+ _returnedContacts = returnedContacts
+ buildPreselectedContacts(returnedContacts.wrappedValue)
+ }
+
+ private mutating func buildPreselectedContacts(_ source: OrderedSet>) {
+ //build currently selected list of contacts
+ var contactsTmp: OrderedSet> = OrderedSet()
+ for contact in source {
+ contactsTmp.append(contact)
+ }
+ _selectedContacts = State(wrappedValue:contactsTmp)
+ }
+
+ private var allContacts: OrderedSet> {
+ //build list of all possible contacts on this account (excluding selfchat and other mucs)
+ var contactsTmp: OrderedSet> = OrderedSet()
+ for contact in DataLayer.sharedInstance().possibleGroupMembers(forAccount: account.accountID) {
+ contactsTmp.append(ObservableKVOWrapper(contact))
+ }
+ return contactsTmp
+ }
+
+ private var searchResults : OrderedSet> {
+ if searchText.isEmpty {
+ return self.allContacts
+ } else {
+ var filteredContacts: OrderedSet> = OrderedSet()
+ for contact in self.allContacts {
+ if (contact.contactDisplayName as String).lowercased().contains(searchText.lowercased()) ||
+ (contact.contactJid as String).contains(searchText.lowercased()) {
+ filteredContacts.append(contact)
+ }
+ }
+ return filteredContacts
+ }
+ }
+
+ var body: some View {
+ if(allContacts.isEmpty) {
+ Text("No contacts to show :(")
+ .navigationTitle("Contact Lists")
+ } else {
+ List(searchResults) { contact in
+ let contactIsSelected = self.selectedContacts.contains(contact);
+ let contactIsAlreadyMember = self.returnedContacts.contains(contact);
+ ContactPickerEntry(contact: contact, isPicked: contactIsSelected, isExistingMember: !(!contactIsAlreadyMember || allowRemoval))
+ .onTapGesture {
+ // only allow changes to members that are not already part of the group
+ if(!contactIsAlreadyMember || allowRemoval) {
+ if(contactIsSelected) {
+ self.selectedContacts.remove(contact)
+ } else {
+ self.selectedContacts.append(contact)
+ }
+ }
+ }
+ }
+ .searchable(text: $searchText, placement: .automatic, prompt: nil)
+ .listStyle(.inset)
+ .navigationBarTitle(Text("Contact Selection"), displayMode: .inline)
+ .onDisappear {
+ returnedContacts.removeAll()
+ for contact in selectedContacts {
+ returnedContacts.append(contact)
+ }
+ if let completion = completion {
+ completion(returnedContacts)
+ }
+ }
+ }
+ }
+}
diff --git a/Monal/Classes/ContactRequestsMenu.swift b/Monal/Classes/ContactRequestsMenu.swift
new file mode 100644
index 0000000..44ce357
--- /dev/null
+++ b/Monal/Classes/ContactRequestsMenu.swift
@@ -0,0 +1,120 @@
+//
+// ContactRequestsMenu.swift
+// Monal
+//
+// Created by Jan on 27.10.22.
+// Copyright © 2022 monal-im.org. All rights reserved.
+//
+
+struct ContactRequestsMenuEntry: View {
+ let contact : MLContact
+ @State private var isDeleted = false
+
+ var body: some View {
+ HStack {
+ Text(contact.contactJid)
+ .foregroundColor(.secondary)
+
+ Spacer()
+
+ Group {
+ Button {
+ let appDelegate = UIApplication.shared.delegate as! MonalAppDelegate
+ appDelegate.openChat(of:contact)
+ } label: {
+ Image(systemName: "text.bubble")
+ .foregroundStyle(Color.primary)
+ }
+ //see https://www.hackingwithswift.com/forums/swiftui/tap-button-in-hstack-activates-all-button-actions-ios-14-swiftui-2/2952
+ .buttonStyle(BorderlessButtonStyle())
+
+ Button {
+ // deny request
+ MLXMPPManager.sharedInstance().remove(contact)
+ } label: {
+ Image(systemName: "trash.circle")
+ .foregroundStyle(Color.red)
+ }
+ //see https://www.hackingwithswift.com/forums/swiftui/tap-button-in-hstack-activates-all-button-actions-ios-14-swiftui-2/2952
+ .buttonStyle(BorderlessButtonStyle())
+
+ Button {
+ // accept request
+ MLXMPPManager.sharedInstance().add(contact)
+ let appDelegate = UIApplication.shared.delegate as! MonalAppDelegate
+ appDelegate.openChat(of:contact)
+ } label: {
+ Image(systemName: "checkmark.circle")
+ .foregroundStyle(Color.green)
+ }
+ //see https://www.hackingwithswift.com/forums/swiftui/tap-button-in-hstack-activates-all-button-actions-ios-14-swiftui-2/2952
+ .buttonStyle(BorderlessButtonStyle())
+ }
+ .font(.largeTitle)
+ }
+ }
+}
+
+struct ContactRequestsMenu: View {
+ @State var pendingRequests: [xmpp:[MLContact]] = [:]
+ @State var enabledAccounts: [Int:xmpp] = [:]
+
+ func updateRequests() {
+ let requests = DataLayer.sharedInstance().allContactRequests() as! [MLContact]
+ enabledAccounts.removeAll()
+ for account in MLXMPPManager.sharedInstance().connectedXMPP as! [xmpp] {
+ enabledAccounts[account.accountID.intValue] = account
+ }
+ pendingRequests.removeAll()
+ for contact in requests {
+ //add only requests having an enabled (dubbed connected) account
+ //(should be a noop because allContactRequests() returns only enabled accounts)
+ if let account = enabledAccounts[contact.accountID.intValue] {
+ if pendingRequests[account] == nil {
+ pendingRequests[account] = []
+ }
+ pendingRequests[account]!.append(contact)
+ }
+ }
+ }
+
+ var body: some View {
+ Section(header: Text("Allowing someone to add you as a contact lets them see your profile picture and when you are online.")) {
+ if(pendingRequests.isEmpty) {
+ Text("No pending contact requests")
+ .foregroundColor(.secondary)
+ } else {
+ List {
+ ForEach(pendingRequests.sorted(by:{ $0.0.connectionProperties.identity.jid < $1.0.connectionProperties.identity.jid }), id: \.key) { account, requests in
+ if enabledAccounts.count == 1 {
+ ForEach(requests.indices, id: \.self) { idx in
+ ContactRequestsMenuEntry(contact: requests[idx])
+ }
+ } else {
+ Section(header: Text("Account: \(account.connectionProperties.identity.jid)")) {
+ ForEach(requests.indices, id: \.self) { idx in
+ ContactRequestsMenuEntry(contact: requests[idx])
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ .onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("kMonalContactRefresh")).receive(on: RunLoop.main)) { notification in
+ updateRequests()
+ }
+ .onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("kMonalContactRemoved")).receive(on: RunLoop.main)) { notification in
+ updateRequests()
+ }
+ .onAppear {
+ updateRequests()
+ }
+ }
+}
+
+struct ContactRequestsMenu_Previews: PreviewProvider {
+ static var previews: some View {
+ ContactRequestsMenu()
+ }
+}
diff --git a/Monal/Classes/ContactResources.swift b/Monal/Classes/ContactResources.swift
new file mode 100644
index 0000000..5b51d44
--- /dev/null
+++ b/Monal/Classes/ContactResources.swift
@@ -0,0 +1,159 @@
+//
+// ContactResources.swift
+// Monal
+//
+// Created by Friedrich Altheide on 24.12.21.
+// Copyright © 2021 Monal.im. All rights reserved.
+//
+import OrderedCollections
+
+@ViewBuilder
+func resourceRowElement(_ k: String, _ v: some View, space: CGFloat = 5) -> some View {
+ HStack {
+ Text(k).font(.headline)
+ Spacer()
+ v.foregroundColor(.secondary)
+ }
+}
+
+struct ContactResources: View {
+ @StateObject var contact: ObservableKVOWrapper
+ @State var contactVersionInfos: [String:ObservableKVOWrapper]
+ @State private var showCaps: String?
+
+ init(contact: ObservableKVOWrapper, previewMock: [String:ObservableKVOWrapper]? = nil) {
+ _contact = StateObject(wrappedValue: contact)
+
+ if previewMock != nil {
+ self.contactVersionInfos = previewMock!
+ } else {
+ var tmpInfos:[String:ObservableKVOWrapper] = [:]
+ for ressourceName in DataLayer.sharedInstance().resources(for: contact.obj) {
+ // load already known software version info from database
+ if let softwareInfo = DataLayer.sharedInstance().getSoftwareVersionInfo(forContact:contact.obj.contactJid, resource:ressourceName, andAccount:contact.obj.accountID) {
+ tmpInfos[ressourceName] = ObservableKVOWrapper(softwareInfo)
+ }
+ }
+ self.contactVersionInfos = tmpInfos
+ }
+ }
+
+ var body: some View {
+ List {
+ ForEach(self.contactVersionInfos.sorted(by:{ $0.0 < $1.0 }), id: \.key) { key, versionInfo in
+ Section {
+ VStack {
+ resourceRowElement("Resource:", Text(versionInfo.resource as String))
+ resourceRowElement("Client Name:", Text(versionInfo.appName as String? ?? ""))
+ resourceRowElement("Client Version:", Text(versionInfo.appVersion as String? ?? ""))
+ resourceRowElement("OS:", Text(versionInfo.platformOs as String? ?? ""))
+ if let lastInteraction = versionInfo.lastInteraction as Date? {
+ if lastInteraction.timeIntervalSince1970 == 0 {
+ resourceRowElement("Last Interaction:", Text("Currently Online"))
+ } else {
+ resourceRowElement("Last Interaction:", Text(lastInteraction.formatted(date:.numeric, time:.standard)))
+ }
+ } else {
+ resourceRowElement("Last Interaction:", Text("unsupported"))
+ }
+ }
+ .onTapGesture(count: 2, perform: {
+ showCaps = versionInfo.resource
+ })
+ }
+ }
+ }
+ .richAlert(isPresented:$showCaps, title:Text("XMPP Capabilities")) { resource in
+ VStack(alignment: .leading) {
+ Text("The resource '\(resource)' has the following capabilities:")
+ .font(Font.body.weight(.semibold))
+ Spacer()
+ .frame(height: 20)
+ Section {
+ let capsVer = DataLayer.sharedInstance().getVerForUser(self.contact.contactJid, andResource:resource, onAccountID:self.contact.accountID)
+ Text("Caps hash: \(String(describing:capsVer))")
+ Divider()
+ if let capsSet = DataLayer.sharedInstance().getCapsforVer(capsVer, onAccountID:contact.obj.accountID) as? Set {
+ let caps = Array(capsSet)
+ VStack(alignment: .leading) {
+ ForEach(caps, id: \.self) { cap in
+ Text(cap)
+ .font(.system(.footnote, design:.monospaced))
+ if cap != caps.last {
+ Divider()
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ .onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("kMonalXmppUserSoftWareVersionRefresh")).receive(on: RunLoop.main)) { notification in
+ if let xmppAccount = notification.object as? xmpp, let softwareInfo = notification.userInfo?["versionInfo"] as? MLContactSoftwareVersionInfo {
+ DDLogVerbose("Got software version info from account \(xmppAccount)...")
+ if softwareInfo.fromJid == contact.obj.contactJid && xmppAccount.accountID == contact.obj.accountID {
+ DispatchQueue.main.async {
+ DDLogVerbose("Successfully matched software version info update to current contact: \(contact.obj)")
+ self.contactVersionInfos[softwareInfo.resource ?? ""] = ObservableKVOWrapper(softwareInfo)
+ }
+ }
+ }
+ }
+ .onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("kMonalNewPresenceNotice")).receive(on: RunLoop.main)) { notification in
+ if let xmppAccount = notification.object as? xmpp, let jid = notification.userInfo?["jid"] as? String, let resource = notification.userInfo?["resource"] as? String, let available = notification.userInfo?["available"] as? NSNumber {
+ DDLogVerbose("Got presence update from account \(xmppAccount)...")
+ if jid == contact.obj.contactJid && xmppAccount.accountID == contact.obj.accountID {
+ DispatchQueue.main.async {
+ DDLogVerbose("Successfully matched presence update to current contact: \(contact.obj)")
+ if available.boolValue {
+ if let softwareInfo = DataLayer.sharedInstance().getSoftwareVersionInfo(forContact:contact.obj.contactJid, resource:resource, andAccount:contact.obj.accountID) {
+ self.contactVersionInfos[resource] = ObservableKVOWrapper(softwareInfo)
+ }
+ // query software version from contact
+ MLXMPPManager.sharedInstance().getEntitySoftWareVersion(for:contact.obj, andResource:resource)
+ } else {
+ self.contactVersionInfos[resource] = nil
+ }
+ }
+ }
+ }
+ }
+ .onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("kMonalLastInteractionUpdatedNotice")).receive(on: RunLoop.main)) { notification in
+ if let xmppAccount = notification.object as? xmpp, let jid = notification.userInfo?["jid"] as? String, let resource = notification.userInfo?["resource"] as? String, notification.userInfo?["lastInteraction"] as? NSDate != nil {
+ DDLogVerbose("Got lastInteraction update from account \(xmppAccount)...")
+ if jid == contact.obj.contactJid && xmppAccount.accountID == contact.obj.accountID {
+ DispatchQueue.main.async {
+ DDLogVerbose("Successfully matched lastInteraction update to current contact: \(contact.obj)")
+ self.contactVersionInfos[resource]?.obj.lastInteraction = DataLayer.sharedInstance().lastInteraction(ofJid:self.contact.obj.contactJid, andResource:resource, forAccountID:contact.obj.accountID)
+ }
+ }
+ }
+ }
+ .onAppear {
+ DDLogVerbose("View will appear...")
+ let newTimeout = DispatchTime.now() + 1.0;
+ DispatchQueue.main.asyncAfter(deadline: newTimeout) {
+ DDLogVerbose("Refreshing software version info...")
+ for ressourceName in DataLayer.sharedInstance().resources(for: contact.obj) {
+ // query software version from contact
+ MLXMPPManager.sharedInstance().getEntitySoftWareVersion(for:contact.obj, andResource:ressourceName)
+ }
+ }
+ }
+ .navigationBarTitle(Text("Devices of \(contact.contactDisplayName as String)"), displayMode: .inline)
+ }
+}
+
+func previewMock() -> [String:ObservableKVOWrapper] {
+ var previewMock:[String:ObservableKVOWrapper] = [:]
+ previewMock["m1"] = ObservableKVOWrapper(MLContactSoftwareVersionInfo.init(jid: "test1@monal.im", andRessource: "m1", andAppName: "Monal", andAppVersion: "1.1.1", andPlatformOS: "ios", andLastInteraction: Date()))
+ previewMock["m2"] = ObservableKVOWrapper(MLContactSoftwareVersionInfo.init(jid: "test1@monal.im", andRessource: "m2", andAppName: "Monal", andAppVersion: "1.1.2", andPlatformOS: "macOS", andLastInteraction: Date()))
+ previewMock["m3"] = ObservableKVOWrapper(MLContactSoftwareVersionInfo.init(jid: "test1@monal.im", andRessource: "m3", andAppName: "Monal", andAppVersion: "1.1.2", andPlatformOS: "macOS", andLastInteraction: Date()))
+ return previewMock
+}
+
+struct ContactResources_Previews: PreviewProvider {
+ static var previews: some View {
+ ContactResources(contact:ObservableKVOWrapper(MLContact.makeDummyContact(0)), previewMock:previewMock())
+ }
+}
diff --git a/Monal/Classes/ContactsView.swift b/Monal/Classes/ContactsView.swift
new file mode 100644
index 0000000..5e2f037
--- /dev/null
+++ b/Monal/Classes/ContactsView.swift
@@ -0,0 +1,194 @@
+//
+// ContactsView.swift
+// Monal
+//
+// Created by Matthew Fennell on 10/08/2024.
+// Copyright © 2024 monal-im.org. All rights reserved.
+//
+
+import SwiftUI
+
+struct ContactViewEntry: View {
+ private let contact: MLContact
+ @Binding private var selectedContactForContactDetails: ObservableKVOWrapper?
+ private let dismissWithContact: (MLContact) -> ()
+
+ @State private var shouldPresentRemoveContactAlert: Bool = false
+
+ private var removeContactButtonText: String {
+ if (!isDeletable) {
+ return "Cannot delete notes to self"
+ }
+ return contact.isMuc ? "Remove Conversation" : "Remove Contact"
+ }
+
+ private var removeContactConfirmationTitle: String {
+ contact.isMuc ? "Leave this converstion?" : "Remove \(contact.contactJid) from contacts?"
+ }
+
+ private var removeContactConfirmationDetail: String {
+ contact.isMuc ? "" : "They will no longer see when you are online. They may not be able to access your encryption keys."
+ }
+
+ private var isDeletable: Bool {
+ !contact.isSelfChat
+ }
+
+ init (contact: MLContact, selectedContactForContactDetails: Binding?>, dismissWithContact: @escaping (MLContact) -> ()) {
+ self.contact = contact
+ self._selectedContactForContactDetails = selectedContactForContactDetails
+ self.dismissWithContact = dismissWithContact
+ }
+
+ var body: some View {
+ // Apple's list dividers only extend as far left as the left-most text in the view.
+ // This means, by default, that the dividers on this screen would not extend all the way to the left of the view.
+ // This combination of HStack with spacing of 0, and empty text at the left of the view, is a workaround to override this behaviour.
+ // See https://stackoverflow.com/a/76698909
+ HStack(spacing: 0) {
+ Text("").frame(maxWidth: 0)
+ Button(action: { dismissWithContact(contact) }) {
+ HStack {
+ ContactEntry(contact: ObservableKVOWrapper(contact))
+ Spacer()
+ Button {
+ selectedContactForContactDetails = ObservableKVOWrapper(contact)
+ } label: {
+ Image(systemName: "info.circle")
+ .imageScale(.large)
+ }
+ .accessibilityLabel("Open contact details")
+ }
+ }
+ }
+ .swipeActions(allowsFullSwipe: false) {
+ // We do not use a Button with destructive role here as we would like to display the confirmation dialog first.
+ // A destructive role would dismiss the row immediately, without waiting for the confirmation.
+ Button(removeContactButtonText) {
+ shouldPresentRemoveContactAlert = true
+ }
+ .tint(isDeletable ? .red : .gray)
+ .disabled(!isDeletable)
+ }
+ .confirmationDialog(removeContactConfirmationTitle, isPresented: $shouldPresentRemoveContactAlert, titleVisibility: .visible) {
+ Button(role: .cancel) {} label: {
+ Text("No")
+ }
+ Button(role: .destructive) {
+ MLXMPPManager.sharedInstance().remove(contact)
+ } label: {
+ Text("Yes")
+ }
+ } message: {
+ Text(removeContactConfirmationDetail)
+ }
+ }
+}
+
+struct ContactsView: View {
+ @ObservedObject private var contacts: Contacts
+ private let delegate: SheetDismisserProtocol
+ private let dismissWithContact: (MLContact) -> ()
+
+ @State private var searchText: String = ""
+ @State private var selectedContactForContactDetails: ObservableKVOWrapper? = nil
+
+ init(contacts: Contacts, delegate: SheetDismisserProtocol, dismissWithContact: @escaping (MLContact) -> ()) {
+ self.contacts = contacts
+ self.delegate = delegate
+ self.dismissWithContact = dismissWithContact
+ }
+
+ private static func shouldDisplayContact(contact: MLContact) -> Bool {
+#if IS_QUICKSY
+ return true
+#endif
+ return contact.isSubscribedTo || contact.hasOutgoingContactRequest || contact.isSubscribedFrom
+ }
+
+ private var contactList: [MLContact] {
+ return contacts.contacts
+ .filter(ContactsView.shouldDisplayContact)
+ .sorted { ContactsView.sortingCriteria($0) < ContactsView.sortingCriteria($1) }
+ }
+
+ private var searchResults: [MLContact] {
+ if searchText.isEmpty { return contactList }
+ return contactList.filter { searchMatchesContact(contact: $0, search: searchText) }
+ }
+
+ private static func sortingCriteria(_ contact: MLContact) -> (String, String) {
+ return (contact.contactDisplayName.lowercased(), contact.contactJid.lowercased())
+ }
+
+ private func searchMatchesContact(contact: MLContact, search: String) -> Bool {
+ let jid = contact.contactJid.lowercased()
+ let name = contact.contactDisplayName.lowercased()
+ let search = search.lowercased()
+
+ return jid.contains(search) || name.contains(search)
+ }
+
+ var body: some View {
+ List {
+ ForEach(searchResults, id: \.self) { contact in
+ ContactViewEntry(contact: contact, selectedContactForContactDetails: $selectedContactForContactDetails, dismissWithContact: dismissWithContact)
+ }
+ }
+ .animation(.default, value: contactList)
+ .navigationTitle("Contacts")
+ .listStyle(.plain)
+ .searchable(text: $searchText, placement: .navigationBarDrawer(displayMode: .always))
+ .autocorrectionDisabled()
+ .textInputAutocapitalization(.never)
+ .keyboardType(.emailAddress)
+ .overlay {
+ if contactList.isEmpty {
+ ContentUnavailableShimView("You need friends for this ride", systemImage: "figure.wave", description: Text("Add new contacts with the + button above. Your friends will pop up here when they can talk"))
+ } else if searchResults.isEmpty {
+ ContentUnavailableShimView.search
+ }
+ }
+ .toolbar {
+ ToolbarItemGroup(placement: .topBarTrailing) {
+ NavigationLink(destination: CreateGroupMenu(delegate: SheetDismisserProtocol())) {
+ Image(systemName: "person.3.fill")
+ }
+ .accessibilityLabel("Create contact group")
+
+ NavigationLink(destination: AddContactMenu(delegate: SheetDismisserProtocol(), dismissWithNewContact: dismissWithContact)) {
+ Image(systemName: "person.fill.badge.plus")
+ .overlay { NumberlessBadge($contacts.requestCount) }
+ }
+ .accessibilityLabel(contacts.requestCount > 0 ? "Add contact (contact requests pending)" : "Add New Contact")
+ }
+ }
+ .sheet(item: $selectedContactForContactDetails) { selectedContact in
+ AnyView(AddTopLevelNavigation(withDelegate: delegate, to: ContactDetails(delegate:delegate, contact:selectedContact)))
+ }
+ }
+}
+
+class Contacts: ObservableObject {
+ @Published var contacts: Set
+ @Published var requestCount: Int
+ private var subscriptions: Set = Set()
+
+ init() {
+ self.contacts = Set(DataLayer.sharedInstance().contactList())
+ self.requestCount = DataLayer.sharedInstance().allContactRequests().count
+ subscriptions = [
+ NotificationCenter.default.publisher(for: NSNotification.Name("kMonalContactRemoved"))
+ .receive(on: DispatchQueue.main)
+ .sink() { _ in self.refreshContacts() },
+ NotificationCenter.default.publisher(for: NSNotification.Name("kMonalContactRefresh"))
+ .receive(on: DispatchQueue.main)
+ .sink() { _ in self.refreshContacts() }
+ ]
+ }
+
+ private func refreshContacts() {
+ self.contacts = Set(DataLayer.sharedInstance().contactList())
+ self.requestCount = DataLayer.sharedInstance().allContactRequests().count
+ }
+}
diff --git a/Monal/Classes/ContentUnavailableShimView.swift b/Monal/Classes/ContentUnavailableShimView.swift
new file mode 100644
index 0000000..da456ec
--- /dev/null
+++ b/Monal/Classes/ContentUnavailableShimView.swift
@@ -0,0 +1,49 @@
+//
+// ContentUnavailableShimView.swift
+// Monal
+//
+// Created by Matthew Fennell on 05/08/2024.
+// Copyright © 2024 monal-im.org. All rights reserved.
+//
+
+import SwiftUI
+
+struct ContentUnavailableShimView: View {
+ private var reason: String
+ private var systemImage: String
+ private var description: Text
+
+ init(_ reason: String, systemImage: String, description: Text) {
+ self.reason = reason
+ self.systemImage = systemImage
+ self.description = description
+ }
+
+ var body: some View {
+ if #available(iOS 17, *) {
+ ContentUnavailableView(reason, systemImage: systemImage, description: description)
+ } else {
+ VStack {
+ Image(systemName: systemImage)
+ .foregroundStyle(.secondary)
+ .font(.largeTitle)
+ .padding(.bottom, 4)
+ Text(reason)
+ .fontWeight(.bold)
+ .font(.title3)
+ description
+ .foregroundStyle(.secondary)
+ .font(.footnote)
+ .multilineTextAlignment(.center)
+ }
+ }
+ }
+}
+
+extension ContentUnavailableShimView {
+ static var search: ContentUnavailableShimView = ContentUnavailableShimView("No Results", systemImage: "magnifyingglass", description: Text("Check the spelling or try a new search."))
+}
+
+#Preview {
+ ContentUnavailableShimView("Cannot Display", systemImage: "iphone.homebutton.slash", description: Text("Cannot display for this reason."))
+}
diff --git a/Monal/Classes/CreateGroupMenu.swift b/Monal/Classes/CreateGroupMenu.swift
new file mode 100644
index 0000000..91235f5
--- /dev/null
+++ b/Monal/Classes/CreateGroupMenu.swift
@@ -0,0 +1,141 @@
+//
+// AddContactMenu.swift
+// Monal
+//
+// Created by Jan on 27.10.22.
+// Copyright © 2022 monal-im.org. All rights reserved.
+//
+
+import OrderedCollections
+
+struct CreateGroupMenu: View {
+ private var appDelegate: MonalAppDelegate
+ private var delegate: SheetDismisserProtocol
+ @State private var enabledAccounts: [xmpp]
+ @State private var selectedAccount: xmpp?
+ @State private var groupName: String = ""
+ @State private var showAlert = false
+ // note: dismissLabel is not accessed but defined at the .alert() section
+ @State private var alertPrompt = AlertPrompt(dismissLabel: Text("Close"))
+ @State private var selectedContacts: OrderedSet> = []
+ @State private var isEditingGroupName = false
+ @StateObject private var overlay = LoadingOverlayState()
+
+ init(delegate: SheetDismisserProtocol) {
+ self.appDelegate = UIApplication.shared.delegate as! MonalAppDelegate
+ self.delegate = delegate
+
+ let enabledAccounts = MLXMPPManager.sharedInstance().connectedXMPP as! [xmpp]
+ self.enabledAccounts = enabledAccounts
+ _selectedAccount = State(wrappedValue: enabledAccounts.first)
+ }
+
+ private func errorAlert(title: Text, message: Text = Text("")) {
+ alertPrompt.title = title
+ alertPrompt.message = message
+ showAlert = true
+ }
+
+ // When a Form is placed inside a Popover, and the horizontal size class is regular, the spacing chosen by SwiftUI is incorrect.
+ // In particular, the spacing between the top of the first element and the navigation bar is too small, meaning the two overlap.
+ // This only happens when the view is inside a popover, and the horizontal size class is regular.
+ // Therefore, it is inconvenient to apply some manual spacing, as this we would have to work out in which situations it should be applied.
+ // Placing a Text view inside the header causes SwiftUI to add consistent spacing in all situations.
+ var popoverFormSpacingWorkaround: some View {
+ Text("")
+ }
+
+ var body: some View {
+ Form {
+ if enabledAccounts.isEmpty {
+ Text("Please make sure at least one account has connected before trying to create new group.")
+ .foregroundColor(.secondary)
+ }
+ else
+ {
+ Section(header: popoverFormSpacingWorkaround) {
+ if enabledAccounts.count > 1 {
+ Picker(selection: $selectedAccount, label: Text("Use account")) {
+ ForEach(Array(self.enabledAccounts.enumerated()), id: \.element) { idx, account in
+ Text(account.connectionProperties.identity.jid).tag(account as xmpp?)
+ }
+ }
+ .pickerStyle(.menu)
+ }
+
+ TextField(NSLocalizedString("Group Name (optional)", comment: "placeholder when creating new group"), text: $groupName, onEditingChanged: { isEditingGroupName = $0 })
+ .autocorrectionDisabled()
+ .autocapitalization(.none)
+ .addClearButton(isEditing: isEditingGroupName, text:$groupName)
+
+ Button(action: {
+ guard let generatedJid = self.selectedAccount!.mucProcessor.generateMucJid() else {
+ errorAlert(title: Text("Error creating group!"), message: Text("Your server does not provide a MUC component."))
+ return
+ }
+ showLoadingOverlay(overlay, headline: NSLocalizedString("Creating Group", comment: ""))
+ guard let roomJid = self.selectedAccount!.mucProcessor.createGroup(generatedJid) else {
+ //room already existing in our local bookmarks --> just open it
+ //this should never happen since we randomly generated a jid above
+ hideLoadingOverlay(overlay)
+ let groupContact = MLContact.createContact(fromJid: generatedJid, andAccountID: self.selectedAccount!.accountID)
+ self.delegate.dismissWithoutAnimation()
+ if let activeChats = self.appDelegate.activeChats {
+ activeChats.presentChat(with:groupContact)
+ }
+ return
+ }
+ self.selectedAccount!.mucProcessor.addUIHandler({_data in let data = _data as! NSDictionary
+ let success : Bool = data["success"] as! Bool;
+ if success {
+ DataLayer.sharedInstance().setFullName(self.groupName, forContact:roomJid, andAccount:self.selectedAccount!.accountID)
+ self.selectedAccount!.mucProcessor.changeName(ofMuc: roomJid, to: self.groupName)
+ for user in self.selectedContacts {
+ self.selectedAccount!.mucProcessor.setAffiliation(kMucAffiliationMember, ofUser: user.contactJid, inMuc: roomJid)
+ self.selectedAccount!.mucProcessor.inviteUser(user.contactJid, inMuc: roomJid)
+ }
+ let groupContact = MLContact.createContact(fromJid: roomJid, andAccountID: self.selectedAccount!.accountID)
+ hideLoadingOverlay(overlay)
+ self.delegate.dismissWithoutAnimation()
+ if let activeChats = self.appDelegate.activeChats {
+ activeChats.presentChat(with:groupContact)
+ }
+ } else {
+ hideLoadingOverlay(overlay)
+ errorAlert(title: Text("Error creating group!"), message: Text(data["errorMessage"] as! String))
+ }
+ }, forMuc: roomJid)
+ }, label: {
+ Text("Create new group")
+ })
+ }
+
+ Section(header: Text("Selected Group Members")) {
+ NavigationLink(destination: LazyClosureView(ContactPicker(self.selectedAccount!, binding: $selectedContacts))) {
+ Text("Change Group Members")
+ }
+ ForEach(self.selectedContacts, id: \.obj.contactJid) { contact in
+ ContactEntry(contact: contact)
+ }
+ .onDelete(perform: { indexSet in
+ self.selectedContacts.remove(at: indexSet.first!)
+ })
+ }
+ }
+ }
+ .alert(isPresented: $showAlert) {
+ Alert(title: alertPrompt.title, message: alertPrompt.message, dismissButton:.default(Text("Close"), action: {
+ showAlert = false
+ }))
+ }
+ .addLoadingOverlay(overlay)
+ .navigationBarTitle(Text("Create new group"), displayMode: .inline)
+ }
+}
+
+struct CreateGroupMenu_Previews: PreviewProvider {
+ static var delegate = SheetDismisserProtocol()
+ static var previews: some View {
+ CreateGroupMenu(delegate: delegate)
+ }
+}
diff --git a/Monal/Classes/DataLayer.h b/Monal/Classes/DataLayer.h
new file mode 100644
index 0000000..c2670ca
--- /dev/null
+++ b/Monal/Classes/DataLayer.h
@@ -0,0 +1,329 @@
+//
+// DataLayer.h
+// SworIM
+//
+// Created by Anurodh Pokharel on 3/28/09.
+// Copyright 2009 __MyCompanyName__. All rights reserved.
+//
+
+#import
+#import "MLConstants.h"
+#import "XMPPPresence.h"
+#import "MLMessage.h"
+#import "MLContact.h"
+#import "MLContactSoftwareVersionInfo.h"
+
+NS_ASSUME_NONNULL_BEGIN
+
+@interface DataLayer : NSObject
+
+extern NSString* const kAccountID;
+extern NSString* const kAccountState;
+extern NSString* const kDomain;
+extern NSString* const kEnabled;
+extern NSString* const kNeedsPasswordMigration;
+extern NSString* const kPlainActivated;
+
+extern NSString* const kServer;
+extern NSString* const kPort;
+extern NSString* const kResource;
+extern NSString* const kDirectTLS;
+extern NSString* const kRosterName;
+
+extern NSString* const kUsername;
+
+extern NSString* const kMessageTypeStatus;
+extern NSString* const kMessageTypeMessageDraft;
+extern NSString* const kMessageTypeText;
+extern NSString* const kMessageTypeGeo;
+extern NSString* const kMessageTypeUrl;
+extern NSString* const kMessageTypeFiletransfer;
+
++(DataLayer*) sharedInstance;
+-(NSString* _Nullable) exportDB;
+-(void) createTransaction:(monal_void_block_t) block;
+-(void) vacuum;
+
+//Roster
+-(NSString *) getRosterVersionForAccount:(NSNumber*) accountID;
+-(void) setRosterVersion:(NSString *) version forAccount: (NSNumber*) accountID;
+
+// Buddy Commands
+-(BOOL) addContact:(NSString*) contact forAccount:(NSNumber*) accountID nickname:(NSString* _Nullable) nickName;
+-(void) removeBuddy:(NSString*) buddy forAccount:(NSNumber*) accountID;
+-(BOOL) clearBuddies:(NSNumber*) accountID;
+-(NSDictionary* _Nullable) contactDictionaryForUsername:(NSString*) username forAccount: (NSNumber*) accountID;
+
+/**
+ should be called when a new session needs to be established
+ */
+-(BOOL) resetContactsForAccount:(NSNumber*) accountID;
+
+-(NSMutableArray*) searchContactsWithString:(NSString*) search;
+
+-(NSArray*) contactList;
+-(NSArray*) contactListWithJid:(NSString*) jid;
+-(NSArray*) possibleGroupMembersForAccount:(NSNumber*) accountID;
+-(NSArray*) resourcesForContact:(MLContact* _Nonnull)contact ;
+-(MLContactSoftwareVersionInfo* _Nullable) getSoftwareVersionInfoForContact:(NSString*)contact resource:(NSString*)resource andAccount:(NSNumber*)account;
+-(void) setSoftwareVersionInfoForContact:(NSString*)contact
+ resource:(NSString*)resource
+ andAccount:(NSNumber*)account
+ withSoftwareInfo:(MLContactSoftwareVersionInfo*) newSoftwareInfo;
+
+#pragma mark Ver string and Capabilities
+
+-(BOOL) checkCap:(NSString*) cap forUser:(NSString*) user onAccountID:(NSNumber*) accountID;
+-(BOOL) checkCap:(NSString*) cap forUser:(NSString*) user andResource:(NSString*) resource onAccountID:(NSNumber*) accountID;
+-(NSString*) getVerForUser:(NSString*) user andResource:(NSString*) resource onAccountID:(NSNumber*) accountID;
+-(void) setVer:(NSString*) ver forUser:(NSString*) user andResource:(NSString*) resource onAccountID:(NSNumber*) accountID;
+-(NSSet* _Nullable) getCapsforVer:(NSString*) ver onAccountID:(NSNumber*) accountID;
+-(void) setCaps:(NSSet*) caps forVer:(NSString*) ver onAccountID:(NSNumber*) accountID;
+
+#pragma mark presence functions
+-(void) setResourceOnline:(XMPPPresence*) presenceObj forAccount:(NSNumber*) accountID;
+-(void) setOnlineBuddy:(XMPPPresence*) presenceObj forAccount:(NSNumber*) accountID;
+-(void) setOfflineBuddy:(XMPPPresence*) presenceObj forAccount:(NSNumber*) accountID;
+
+-(void) setBuddyStatus:(XMPPPresence*) presenceObj forAccount:(NSNumber*) accountID;
+-(NSString*) buddyStatus:(NSString*) buddy forAccount:(NSNumber*) accountID;
+
+-(void) setBuddyState:(XMPPPresence*) presenceObj forAccount:(NSNumber*) accountID;
+-(NSString*) buddyState:(NSString*) buddy forAccount:(NSNumber*) accountID;
+
+-(BOOL) hasContactRequestForContact:(MLContact*) contact;
+-(NSMutableArray*) allContactRequests;
+-(void) addContactRequest:(MLContact *) requestor;
+-(void) deleteContactRequest:(MLContact *) requestor;
+
+#pragma mark Contact info
+
+-(void) setFullName:(NSString*) fullName forContact:(NSString*) contact andAccount:(NSNumber*) accountID;
+
+-(void) setAvatarHash:(NSString*) hash forContact:(NSString*) contact andAccount:(NSNumber*) accountID;
+-(NSString*) getAvatarHashForContact:(NSString*) buddy andAccount:(NSNumber*) accountID;
+
+-(BOOL) saveMessageDraft:(NSString*) buddy forAccount:(NSNumber*) accountID withComment:(NSString*) comment;
+-(NSString*) loadMessageDraft:(NSString*) buddy forAccount:(NSNumber*) accountID;
+
+#pragma mark - MUC
+
+-(BOOL) initMuc:(NSString*) room forAccountID:(NSNumber*) accountID andMucNick:(NSString* _Nullable) mucNick;
+-(void) cleanupParticipantsListFor:(NSString*) room onAccountID:(NSNumber*) accountID;
+-(void) cleanupMembersListFor:(NSString*) room andType:(NSString*) type onAccountID:(NSNumber*) accountID;
+-(void) addMember:(NSDictionary*) member toMuc:(NSString*) room forAccountID:(NSNumber*) accountID;
+-(void) removeMember:(NSDictionary*) member fromMuc:(NSString*) room forAccountID:(NSNumber*) accountID;
+-(void) addParticipant:(NSDictionary*) participant toMuc:(NSString*) room forAccountID:(NSNumber*) accountID;
+-(void) removeParticipant:(NSDictionary*) participant fromMuc:(NSString*) room forAccountID:(NSNumber*) accountID;
+-(NSDictionary* _Nullable) getParticipantForNick:(NSString*) nick inRoom:(NSString*) room forAccountID:(NSNumber*) accountID;
+-(NSDictionary* _Nullable) getParticipantForOccupant:(NSString*) occupant inRoom:(NSString*) room forAccountID:(NSNumber*) accountID;
+-(NSArray*>*) getMembersAndParticipantsOfMuc:(NSString*) room forAccountID:(NSNumber*) accountID;
+-(NSString* _Nullable) getOwnAffiliationInGroupOrChannel:(MLContact*) contact;
+-(NSString* _Nullable) getOwnRoleInGroupOrChannel:(MLContact*) contact;
+-(void) addMucFavorite:(NSString*) room forAccountID:(NSNumber*) accountID andMucNick:(NSString* _Nullable) mucNick;
+-(NSString*) lastStanzaIdForMuc:(NSString* _Nonnull) room andAccount:(NSNumber* _Nonnull) accountID;
+-(void) setLastStanzaId:(NSString*) lastStanzaId forMuc:(NSString* _Nonnull) room andAccount:(NSNumber* _Nonnull) accountID;
+-(BOOL) isBuddyMuc:(NSString*) buddy forAccount:(NSNumber*) accountID;
+
+-(NSString* _Nullable) ownNickNameforMuc:(NSString*) room forAccount:(NSNumber*) accountID;
+-(BOOL) updateOwnNickName:(NSString*) nick forMuc:(NSString*) room forAccount:(NSNumber*) accountID;
+
+-(BOOL) updateOwnOccupantID:(NSString* _Nullable) occupantID forMuc:(NSString*) room onAccountID:(NSNumber*) accountID;
+-(NSString* _Nullable) getOwnOccupantIdForMuc:(NSString*) room onAccountID:(NSNumber*) accountID;
+
+-(BOOL) updateMucSubject:(NSString*) subject forAccount:(NSNumber*) accountID andRoom:(NSString*) room;
+-(NSString*) mucSubjectforAccount:(NSNumber*) accountID andRoom:(NSString*) room;
+
+-(NSSet*) listMucsForAccount:(NSNumber*) accountID;
+-(BOOL) deleteMuc:(NSString*) room forAccountID:(NSNumber*) accountID;
+
+-(void) updateMucTypeTo:(NSString*) type forRoom:(NSString*) room andAccount:(NSNumber*) accountID;
+-(NSString*) getMucTypeOfRoom:(NSString*) room andAccount:(NSNumber*) accountID;
+
+/**
+ Calls with YES if contact has already been added to the database for this account
+ */
+-(BOOL) isContactInList:(NSString*) buddy forAccount:(NSNumber*) accountID;
+
+#pragma mark - account commands
+-(NSArray*) accountList;
+-(NSNumber*) enabledAccountCnts;
+-(NSArray*) enabledAccountList;
+-(BOOL) isAccountEnabled:(NSNumber*) accountID;
+-(BOOL) doesAccountExistUser:(NSString*) user andDomain:(NSString *) domain;
+-(NSNumber* _Nullable) accountIDForUser:(NSString*) user andDomain:(NSString *) domain;
+
+-(NSMutableDictionary* _Nullable) detailsForAccount:(NSNumber*) accountID;
+
+-(BOOL) updateAccounWithDictionary:(NSDictionary *) dictionary;
+-(NSNumber* _Nullable) addAccountWithDictionary:(NSDictionary *) dictionary;
+
+
+-(BOOL) removeAccount:(NSNumber*) accountID;
+
+/**
+ password migration
+ */
+-(BOOL) disableAccountForPasswordMigration:(NSNumber*) accountID;
+-(NSArray*) accountListNeedingPasswordMigration;
+
+-(BOOL) isPlainActivatedForAccount:(NSNumber*) accountID;
+-(BOOL) deactivatePlainForAccount:(NSNumber*) accountID;
+
+-(NSMutableDictionary* _Nullable) readStateForAccount:(NSNumber*) accountID;
+-(void) persistState:(NSDictionary*) state forAccount:(NSNumber*) accountID;
+
+#pragma mark - message Commands
+/**
+ returns messages with the provided local id number
+ */
+-(NSArray*) messagesForHistoryIDs:(NSArray*) historyIDs;
+-(MLMessage* _Nullable) messageForHistoryID:(NSNumber* _Nullable) historyID;
+-(NSNumber*) getSmallestHistoryId;
+-(NSNumber*) getBiggestHistoryId;
+
+-(NSNumber* _Nullable) hasMessageForStanzaId:(NSString*) stanzaId orMessageID:(NSString*) messageId withInboundDir:(BOOL) inbound occupantId:(NSString* _Nullable) occupantId andJid:(NSString*) jid onAccount:(NSNumber*) accountID;
+
+/*
+ adds a specified message to the database
+ */
+-(NSNumber*) addMessageToChatBuddy:(NSString*) buddyName withInboundDir:(BOOL) inbound forAccount:(NSNumber*) accountID withBody:(NSString*) message actuallyfrom:(NSString*) actualfrom occupantId:(NSString* _Nullable) occupantId participantJid:(NSString*_Nullable) participantJid sent:(BOOL) sent unread:(BOOL) unread messageId:(NSString*) messageid serverMessageId:(NSString*) stanzaid messageType:(NSString*) messageType andOverrideDate:(NSDate*) messageDate encrypted:(BOOL) encrypted displayMarkerWanted:(BOOL) displayMarkerWanted usingHistoryId:(NSNumber* _Nullable) historyId checkForDuplicates:(BOOL) checkForDuplicates;
+
+/*
+ Marks a message as sent. When the server acked it
+ */
+-(void) setMessageId:(NSString*_Nonnull) messageid andJid:(NSString*) jid sent:(BOOL) sent;
+
+/**
+ Marked when the client on the other end replies with a recived message
+ */
+-(void) setMessageId:( NSString* _Nonnull ) messageid andJid:(NSString*) jid received:(BOOL) received;
+/**
+ if the server replies with an error for a message, store it
+ */
+-(void) setMessageId:(NSString* _Nonnull) messageid andJid:(NSString*) jid errorType:(NSString *_Nonnull) errorType errorReason:(NSString *_Nonnull)errorReason;
+-(void) clearErrorOfMessageId:(NSString* _Nonnull) messageid;
+
+/**
+ sets a preview info for a specified message
+ */
+-(void) setMessageId:(NSString*_Nonnull) messageid previewText:(NSString *) text andPreviewImage:(NSString *) image;
+
+-(void) setMessageId:(NSString*) messageid stanzaId:(NSString *) stanzaId;
+-(void) setMessageHistoryId:(NSNumber*) historyId filetransferMimeType:(NSString*) mimeType filetransferSize:(NSNumber*) size;
+-(void) setMessageHistoryId:(NSNumber*) historyId messageType:(NSString*) messageType;
+
+-(void) clearMessages:(NSNumber*) accountID;
+-(void) clearMessagesWithBuddy:(NSString*) buddy onAccount:(NSNumber*) accountID;
+-(NSNumber*) autoDeleteMessagesAfterInterval:(NSTimeInterval)interval;
+-(void) retractMessageHistory:(NSNumber *) messageNo;
+-(void) deleteMessageHistoryLocally:(NSNumber*) messageNo;
+-(void) updateMessageHistory:(NSNumber*) messageNo withText:(NSString*) newText;
+-(NSNumber* _Nullable) getLMCHistoryIDForMessageId:(NSString*) messageid from:(NSString*) from occupantId:(NSString* _Nullable) occupantId participantJid:(NSString* _Nullable) participantJid andAccount:(NSNumber*) accountID;
+-(NSNumber* _Nullable) getRetractionHistoryIDForMessageId:(NSString*) messageid from:(NSString*) from participantJid:(NSString* _Nullable) participantJid occupantId:(NSString* _Nullable) occupantId andAccount:(NSNumber*) accountID;
+-(NSNumber* _Nullable) getRetractionHistoryIDForModeratedStanzaId:(NSString*) stanzaId from:(NSString*) from andAccount:(NSNumber*) accountID;
+
+-(NSDate* _Nullable) returnTimestampForQuote:(NSNumber*) historyID;
+-(BOOL) checkLMCEligible:(NSNumber*) historyID encrypted:(BOOL) encrypted historyBaseID:(NSNumber* _Nullable) historyBaseID;
+
+#pragma mark - message history
+
+-(NSNumber*) lastMessageHistoryIdForContact:(NSString*) buddy forAccount:(NSNumber*) accountID;
+-(NSMutableArray*) messagesForContact:(NSString*) buddy forAccount:(NSNumber*) accountID beforeMsgHistoryID:(NSNumber* _Nullable) msgHistoryID;
+-(NSMutableArray*) messagesForContact:(NSString*) buddy forAccount:(NSNumber*) accountID;
+
+
+-(MLMessage*) lastMessageForContact:(NSString*) contact forAccount:(NSNumber*) accountID;
+-(NSString*) lastStanzaIdForAccount:(NSNumber*) accountID;
+-(void) setLastStanzaId:(NSString*) lastStanzaId forAccount:(NSNumber*) accountID;
+
+-(NSArray*) markMessagesAsReadForBuddy:(NSString*) buddy andAccount:(NSNumber*) accountID tillStanzaId:(NSString* _Nullable) stanzaId wasOutgoing:(BOOL) outgoing;
+
+-(NSNumber*) addMessageHistoryTo:(NSString*) to forAccount:(NSNumber*) accountID withMessage:(NSString*) message actuallyFrom:(NSString*) actualfrom withId:(NSString*) messageId encrypted:(BOOL) encrypted messageType:(NSString*) messageType mimeType:(NSString* _Nullable) mimeType size:(NSNumber* _Nullable) size;
+
+#pragma mark active contacts
+-(NSMutableArray*) activeContactsWithPinned:(BOOL) pinned;
+-(NSArray*) activeContactDict;
+-(void) removeActiveBuddy:(NSString*) buddyname forAccount:(NSNumber*) accountID;
+-(void) addActiveBuddies:(NSString*) buddyname forAccount:(NSNumber*) accountID;
+-(BOOL) isActiveBuddy:(NSString*) buddyname forAccount:(NSNumber*) accountID;
+-(BOOL) updateActiveBuddy:(NSString*) buddyname setTime:(NSString*)timestamp forAccount:(NSNumber*) accountID;
+
+
+
+#pragma mark count unread
+-(NSNumber*) countUserUnreadMessages:(NSString* _Nullable) buddy forAccount:(NSNumber* _Nullable) accountID;
+-(NSNumber*) countUnreadMessages;
+
+-(void) muteContact:(MLContact*) contact;
+-(void) unMuteContact:(MLContact*) contact;
+-(BOOL) isMutedJid:(NSString*) jid onAccount:(NSNumber*) accountID;
+
+-(void) setMucAlertOnMentionOnly:(NSString*) jid onAccount:(NSNumber*) accountID;
+-(void) setMucAlertOnAll:(NSString*) jid onAccount:(NSNumber*) accountID;
+-(BOOL) isMucAlertOnMentionOnly:(NSString*) jid onAccount:(NSNumber*) accountID;
+
+-(void) blockJid:(NSString*) jid withAccountID:(NSNumber*) accountID;
+-(void) unBlockJid:(NSString*) jid withAccountID:(NSNumber*) accountID;
+-(BOOL) isBlockedContact:(MLContact*) contact;
+-(void) updateLocalBlocklistCache:(NSSet*) blockedJids forAccountID:(NSNumber*) accountID;
+-(NSArray*) blockedJidsForAccount:(NSNumber*) accountID;
+
+-(BOOL) isPinnedChat:(NSNumber*) accountID andBuddyJid:(NSString*) buddyJid;
+-(void) pinChat:(NSNumber*) accountID andBuddyJid:(NSString*) buddyJid;
+-(void) unPinChat:(NSNumber*) accountID andBuddyJid:(NSString*) buddyJid;
+
+-(BOOL) shouldEncryptForJid:(NSString *) jid andAccountID:(NSNumber*) account;
+-(void) encryptForJid:(NSString*) jid andAccountID:(NSNumber*) accountID;
+-(void) disableEncryptForJid:(NSString*) jid andAccountID:(NSNumber*) accountID;
+
+-(NSMutableArray*) allAttachmentsFromContact:(NSString*) contact forAccount:(NSNumber*) accountID;
+
+-(NSDate* _Nullable) lastInteractionOfJid:(NSString* _Nonnull) jid forAccountID:(NSNumber* _Nonnull) accountID;
+-(NSDate* _Nullable) lastInteractionOfJid:(NSString* _Nonnull) jid andResource:(NSString* _Nonnull) resource forAccountID:(NSNumber* _Nonnull) accountID;
+-(void) setLastInteraction:(NSDate*) lastInteractionTime forJid:(NSString* _Nonnull) jid andResource:(NSString*) resource onAccountID:(NSNumber* _Nonnull) accountID;
+
+-(NSDictionary *) getSubscriptionForContact:(NSString*) contact andAccount:(NSNumber*) accountID;
+-(void) setSubscription:(NSString *)sub andAsk:(NSString*) ask forContact:(NSString*) contact andAccount:(NSNumber*) accountID;
+-(void) setGroups:(NSSet*) groups forContact:(NSString*) contact inAccount:(NSNumber*) accountID;
+
+#pragma mark History Message Search
+/*
+ search message by keyword in message, buddy_name, messageType.
+ */
+-(NSArray* _Nullable) searchResultOfHistoryMessageWithKeyWords:(NSString* _Nonnull) keyword
+ accountID:(NSNumber* _Nonnull) accountID;
+
+/*
+ search message by keyword in message, buddy_name, messageType.
+ */
+-(NSArray*) searchResultOfHistoryMessageWithKeyWords:(NSString*) keyword betweenContact:(MLContact* _Nonnull) contact;
+
+-(NSArray*) getAllCachedImages;
+-(void) removeImageCacheTables;
+-(NSArray*) getAllMessagesForFiletransferUrl:(NSString*) url;
+-(void) upgradeImageMessagesToFiletransferMessages;
+
+-(void) invalidateAllAccountStates;
+
+-(NSString*) lastUsedPushServerForAccount:(NSNumber*) accountID;
+-(void) updateUsedPushServer:(NSString*) pushServer forAccount:(NSNumber*) accountID;
+
+
+
+-(void) deleteDelayedMessageStanzasForAccount:(NSNumber*) accountID;
+-(void) addDelayedMessageStanza:(MLXMLNode*) stanza forArchiveJid:(NSString*) archiveJid andAccountID:(NSNumber*) accountID;
+-(MLXMLNode* _Nullable) getNextDelayedMessageStanzaForArchiveJid:(NSString*) archiveJid andAccountID:(NSNumber*) accountID;
+
+-(void) addShareSheetPayload:(NSDictionary*) payload;
+-(NSArray*) getShareSheetPayload;
+-(void) deleteShareSheetPayloadWithId:(NSNumber*) payloadId;
+
+-(NSNumber*) addIdleTimerWithTimeout:(NSNumber*) timeout andHandler:(MLHandler*) handler onAccountID:(NSNumber*) accountID;
+-(void) delIdleTimerWithId:(NSNumber* _Nullable) timerId;
+-(void) cleanupIdleTimerOnAccountID:(NSNumber*) accountID;
+-(void) decrementIdleTimersForAccount:(xmpp*) account;
+
+@end
+
+NS_ASSUME_NONNULL_END
diff --git a/Monal/Classes/DataLayer.m b/Monal/Classes/DataLayer.m
new file mode 100644
index 0000000..1b6cc92
--- /dev/null
+++ b/Monal/Classes/DataLayer.m
@@ -0,0 +1,2540 @@
+//
+// DataLayer.m
+// SworIM
+//
+// Created by Anurodh Pokharel on 3/28/09.
+// Copyright 2009 __MyCompanyName__. All rights reserved.
+//
+
+#import "DataLayer.h"
+#import "xmpp.h"
+#import "MLSQLite.h"
+#import "HelperTools.h"
+#import "MLXMLNode.h"
+#import "XMPPPresence.h"
+#import "XMPPMessage.h"
+#import "XMPPIQ.h"
+#import "XMPPDataForm.h"
+#import "MLFiletransfer.h"
+#import "DataLayerMigrations.h"
+#import "MLContactSoftwareVersionInfo.h"
+#import "MLXMPPManager.h"
+
+@interface DataLayer()
+@property (readonly, strong) MLSQLite* db;
+@end
+
+@implementation DataLayer
+
+NSString* const kAccountID = @"account_id";
+NSString* const kAccountState = @"account_state";
+
+//used for account rows
+NSString *const kDomain = @"domain";
+NSString *const kEnabled = @"enabled";
+NSString *const kNeedsPasswordMigration = @"needs_password_migration";
+NSString *const kPlainActivated = @"plain_activated";
+
+NSString *const kServer = @"server";
+NSString *const kPort = @"other_port";
+NSString *const kResource = @"resource";
+NSString *const kDirectTLS = @"directTLS";
+NSString *const kRosterName = @"rosterName";
+
+NSString *const kUsername = @"username";
+
+NSString *const kMessageTypeStatus = @"Status";
+NSString *const kMessageTypeMessageDraft = @"MessageDraft";
+NSString *const kMessageTypeText = @"Text";
+NSString *const kMessageTypeGeo = @"Geo";
+NSString *const kMessageTypeUrl = @"Url";
+NSString *const kMessageTypeFiletransfer = @"Filetransfer";
+
+static NSString* dbPath;
+static NSDateFormatter* dbFormatter;
+
++(void) initialize
+{
+ NSFileManager* fileManager = [NSFileManager defaultManager];
+ NSString* writableDBPath = [[HelperTools getContainerURLForPathComponents:@[@"sworim.sqlite"]] path];
+
+ //the file does not exist (e.g. fresh install) --> copy default database to app group path
+ if(![fileManager fileExistsAtPath:writableDBPath])
+ {
+ DDLogInfo(@"initialize: copying default DB to: %@", writableDBPath);
+ NSString* defaultDBPath = [[[NSBundle mainBundle] resourcePath] stringByAppendingPathComponent:@"sworim.sqlite"];
+ NSError* error;
+ [fileManager copyItemAtPath:defaultDBPath toPath:writableDBPath error:&error];
+ if(error)
+ @throw [NSException exceptionWithName:@"NSError" reason:[NSString stringWithFormat:@"%@", error] userInfo:@{@"error": error}];
+ }
+
+ //init global state
+ dbPath = writableDBPath;
+ dbFormatter = [NSDateFormatter new];
+ [dbFormatter setDateFormat:@"yyyy-MM-dd HH:mm:ss"];
+ [dbFormatter setTimeZone:[NSTimeZone timeZoneForSecondsFromGMT:0]];
+}
+
+//we are a singleton (compatible with old code), but conceptually we could also be a static class instead
++(id) sharedInstance
+{
+ static DataLayer* newInstance;
+ static dispatch_once_t once;
+ dispatch_once(&once, ^{
+ newInstance = [self new];
+ });
+ return newInstance;
+}
+
+-(id) init
+{
+ self = [super init];
+
+ //checking db version and upgrading if necessary
+ DDLogInfo(@"Database version check");
+
+ //set wal mode (this setting is permanent): https://www.sqlite.org/pragma.html#pragma_journal_mode
+ //this is a special case because it can not be done while in a transaction!!!
+ [self.db enableWAL];
+ [self.db executeNonQuery:@"PRAGMA secure_delete=on;"];
+
+ //needed for sqlite >= 3.26.0 (see https://sqlite.org/lang_altertable.html point 2)
+ [self.db executeNonQuery:@"PRAGMA legacy_alter_table=on;"];
+ [self.db executeNonQuery:@"PRAGMA foreign_keys=off;"];
+
+ //do db upgrades and vacuum db afterwards
+ if([DataLayerMigrations migrateDB:self.db withDataLayer:self])
+ [self.db vacuum];
+
+ //turn foreign keys on again
+ //needed for sqlite >= 3.26.0 (see https://sqlite.org/lang_altertable.html point 2)
+ [self.db executeNonQuery:@"PRAGMA legacy_alter_table=off;"];
+ [self.db executeNonQuery:@"PRAGMA foreign_keys=on;"];
+
+ DDLogInfo(@"Database version check completed");
+
+ return self;
+}
+
+//this is the getter of our readonly "db" property always returning the thread-local instance of the MLSQLite class
+-(MLSQLite*) db
+{
+ //always return thread-local instance of sqlite class (this is important for performance!)
+ return [MLSQLite sharedInstanceForFile:dbPath];
+}
+
+-(NSString* _Nullable) exportDB
+{
+ NSFileManager* fileManager = [NSFileManager defaultManager];
+ NSString* temporaryFilename = [NSString stringWithFormat:@"sworim_%@.db", [[NSProcessInfo processInfo] globallyUniqueString]];
+ NSString* temporaryFilePath = [NSTemporaryDirectory() stringByAppendingPathComponent:temporaryFilename];
+
+ //checkpoint db before copying db file
+ [self.db checkpointWal];
+
+ //this transaction creates a new wal log and makes sure the file copy is atomic/consistent
+ BOOL success = [self.db boolWriteTransaction:^{
+ //copy db file to temp file
+ NSError* error;
+ [fileManager copyItemAtPath:dbPath toPath:temporaryFilePath error:&error];
+ if(error)
+ {
+ DDLogError(@"Could not copy database to export location!");
+ return NO;
+ }
+ return YES;
+ }];
+
+ if(success)
+ return temporaryFilePath;
+ return nil;
+}
+
+-(void) createTransaction:(monal_void_block_t) block
+{
+ [self.db voidWriteTransaction:block];
+}
+
+-(void) vacuum
+{
+ return [self.db vacuum];
+}
+
+#pragma mark account commands
+
+-(NSArray*) accountList
+{
+ return [self.db idReadTransaction:^{
+ return [self.db executeReader:@"SELECT * FROM account ORDER BY account_id ASC;"];
+ }];
+}
+
+-(NSNumber*) enabledAccountCnts
+{
+ return [self.db idReadTransaction:^{
+ return (NSNumber*)[self.db executeScalar:@"SELECT COUNT(*) FROM account WHERE enabled=1;"];
+ }];
+}
+
+-(NSArray*) enabledAccountList
+{
+ return [self.db idReadTransaction:^{
+ return [self.db executeReader:@"SELECT * FROM account WHERE enabled=1 ORDER BY account_id ASC;"];
+ }];
+}
+
+-(BOOL) isAccountEnabled:(NSNumber*) accountID
+{
+ return [self.db boolReadTransaction:^{
+ return [[self.db executeScalar:@"SELECT enabled FROM account WHERE account_id=?;" andArguments:@[accountID]] boolValue];
+ }];
+}
+
+-(NSNumber*) accountIDForUser:(NSString*) user andDomain:(NSString*) domain
+{
+ if(!user && !domain)
+ return nil;
+
+ NSString* cleanUser = user;
+ NSString* cleanDomain = domain;
+
+ if(!cleanDomain)
+ cleanDomain= @"";
+ if(!cleanUser)
+ cleanUser= @"";
+
+ return [self.db idReadTransaction:^{
+ NSString* query = @"SELECT account_id FROM account WHERE domain=? and username=?;";
+ NSArray* result = [self.db executeReader:query andArguments:@[cleanDomain, cleanUser]];
+ if(result.count > 0) {
+ return (NSNumber*)[result[0] objectForKey:@"account_id"];
+ }
+ return (NSNumber*)nil;
+ }];
+}
+
+-(BOOL) doesAccountExistUser:(NSString*) user andDomain:(NSString *) domain
+{
+ return [self.db boolReadTransaction:^{
+ NSString* query = @"SELECT * FROM account WHERE domain=? AND username=?;";
+ NSArray* result = [self.db executeReader:query andArguments:@[domain, user]];
+ return (BOOL)(result.count > 0);
+ }];
+}
+
+-(NSMutableDictionary*) detailsForAccount:(NSNumber*) accountID
+{
+ if(accountID == nil)
+ return nil;
+ return [self.db idReadTransaction:^{
+ NSArray* result = [self.db executeReader:@"SELECT * FROM account WHERE account_id=?;" andArguments:@[accountID]];
+ if(result != nil && [result count])
+ {
+ DDLogVerbose(@"count: %lu", (unsigned long)[result count]);
+ return (NSMutableDictionary*)result[0];
+ }
+ else
+ DDLogError(@"account list is empty or failed to read");
+ return (NSMutableDictionary*)nil;
+ }];
+}
+
+-(BOOL) updateAccounWithDictionary:(NSDictionary*) dictionary
+{
+ return [self.db boolWriteTransaction:^{
+ DDLogVerbose(@"Updating account with: %@", dictionary);
+ NSString* query = @"UPDATE account SET server=?, other_port=?, username=?, resource=?, domain=?, enabled=?, directTLS=?, rosterName=?, statusMessage=?, needs_password_migration=? WHERE account_id=?;";
+ NSString* server = (NSString*)[dictionary objectForKey:kServer];
+ NSString* port = (NSString*)[dictionary objectForKey:kPort];
+ if ([port isEqual:@""])
+ port = nil;
+ NSArray* params = @[
+ nilDefault(server, @""),
+ nilDefault(port, @"5222"),
+ ((NSString*)[dictionary objectForKey:kUsername]),
+ ((NSString*)[dictionary objectForKey:kResource]),
+ ((NSString*)[dictionary objectForKey:kDomain]),
+ [dictionary objectForKey:kEnabled],
+ [dictionary objectForKey:kDirectTLS],
+ [dictionary objectForKey:kRosterName] ? ((NSString*)[dictionary objectForKey:kRosterName]) : @"",
+ [dictionary objectForKey:@"statusMessage"] ? ((NSString*)[dictionary objectForKey:@"statusMessage"]) : @"",
+ [dictionary objectForKey:kNeedsPasswordMigration],
+ [dictionary objectForKey:kAccountID],
+ ];
+ BOOL retval = [self.db executeNonQuery:query andArguments:params];
+ [self addSelfChatForAccount:dictionary[kAccountID]];
+ return retval;
+ }];
+}
+
+-(NSNumber*) addAccountWithDictionary:(NSDictionary*) dictionary
+{
+ return [self.db idWriteTransaction:^{
+ NSString* query = @"INSERT INTO account (server, other_port, resource, domain, enabled, directTLS, username, rosterName, statusMessage, plain_activated) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?);";
+ NSString* server = (NSString*) [dictionary objectForKey:kServer];
+ NSString* port = (NSString*)[dictionary objectForKey:kPort];
+ if ([port isEqual:@""])
+ port = nil;
+ NSArray* params = @[
+ nilDefault(server, @""),
+ nilDefault(port, @"5222"),
+ ((NSString *)[dictionary objectForKey:kResource]),
+ ((NSString *)[dictionary objectForKey:kDomain]),
+ [dictionary objectForKey:kEnabled] ,
+ [dictionary objectForKey:kDirectTLS],
+ ((NSString *)[dictionary objectForKey:kUsername]),
+ [dictionary objectForKey:kRosterName] ? ((NSString*)[dictionary objectForKey:kRosterName]) : @"",
+ [dictionary objectForKey:@"statusMessage"] ? ((NSString*)[dictionary objectForKey:@"statusMessage"]) : @"",
+ [dictionary objectForKey:kPlainActivated] != nil ? [dictionary objectForKey:kPlainActivated] : [NSNumber numberWithBool:NO],
+ ];
+ BOOL result = [self.db executeNonQuery:query andArguments:params];
+ // return the accountID
+ if(result == YES) {
+ NSNumber* accountID = [self.db lastInsertId];
+ DDLogInfo(@"Added account %@ to account table with accountID %@", [dictionary objectForKey:kUsername], accountID);
+ [self addSelfChatForAccount:accountID];
+ return accountID;
+ } else {
+ return (NSNumber*)nil;
+ }
+ }];
+}
+
+-(BOOL) removeAccount:(NSNumber*) accountID
+{
+ // remove all other traces of the account_id in one transaction
+ return [self.db boolWriteTransaction:^{
+ // enable secure delete
+ [self.db executeNonQuery:@"PRAGMA secure_delete=on;"];
+
+ // delete transfered files from local device
+ NSArray* messageHistoryIDs = [self.db executeScalarReader:@"SELECT message_history_id FROM message_history WHERE messageType=? AND account_id=?;" andArguments:@[kMessageTypeFiletransfer, accountID]];
+ for(NSNumber* historyId in messageHistoryIDs)
+ [MLFiletransfer deleteFileForMessage:[self messageForHistoryID:historyId]];
+
+ // delete account and all entries with the same account_id (CASCADE DELETE)
+ BOOL accountDeleted = [self.db executeNonQuery:@"DELETE FROM account WHERE account_id=?;" andArguments:@[accountID]];
+
+ // disable secure delete again
+ [self.db executeNonQuery:@"PRAGMA secure_delete=off;"];
+ return accountDeleted;
+ }];
+}
+
+-(BOOL) disableAccountForPasswordMigration:(NSNumber*) accountID
+{
+ return [self.db boolWriteTransaction:^{
+ [self persistState:[xmpp invalidateState:[self readStateForAccount:accountID]] forAccount:accountID];
+ return [self.db executeNonQuery:@"UPDATE account SET enabled=0, needs_password_migration=1, resource=? WHERE account_id=?;" andArguments:@[[HelperTools encodeRandomResource], accountID]];
+ }];
+}
+
+-(NSArray*) accountListNeedingPasswordMigration
+{
+ return [self.db idReadTransaction:^{
+ return [self.db executeReader:@"SELECT * FROM account WHERE NOT enabled AND needs_password_migration ORDER BY account_id ASC;"];
+ }];
+}
+
+-(BOOL) isPlainActivatedForAccount:(NSNumber*) accountID
+{
+ return [self.db boolReadTransaction:^{
+ NSNumber* plainActivated = (NSNumber*)[self.db executeScalar:@"SELECT plain_activated FROM account WHERE account_id=?;" andArguments:@[accountID]];
+ if(plainActivated == nil)
+ return NO;
+ else
+ return [plainActivated boolValue];
+ }];
+}
+
+-(BOOL) deactivatePlainForAccount:(NSNumber*) accountID
+{
+ return [self.db boolReadTransaction:^{
+ return [self.db executeNonQuery:@"UPDATE account SET plain_activated=0 WHERE account_id=?;" andArguments:@[accountID]];
+ }];
+}
+
+-(NSMutableDictionary*) readStateForAccount:(NSNumber*) accountID
+{
+ if(accountID == nil)
+ return nil;
+ NSString* query = @"SELECT state from account where account_id=?";
+ NSArray* params = @[accountID];
+ NSData* data = (NSData*)[self.db idReadTransaction:^{
+ return [self.db executeScalar:query andArguments:params];
+ }];
+ if(data)
+ return [HelperTools unserializeData:data];
+ return nil;
+}
+
+-(void) persistState:(NSDictionary*) state forAccount:(NSNumber*) accountID
+{
+ if(accountID == nil || !state)
+ return;
+ NSData* data = [HelperTools serializeObject:state];
+ [self.db voidWriteTransaction:^{
+ NSString* query = @"UPDATE account SET state=? WHERE account_id=?;";
+ NSArray* params = @[data, accountID];
+ [self.db executeNonQuery:query andArguments:params];
+ }];
+}
+
+#pragma mark contact Commands
+
+-(BOOL) addSelfChatForAccount:(NSNumber*) accountID
+{
+ BOOL encrypt = NO;
+#ifndef DISABLE_OMEMO
+ encrypt = [[HelperTools defaultsDB] boolForKey:@"OMEMODefaultOn"];
+#endif// DISABLE_OMEMO
+ NSDictionary* accountDetails = [self detailsForAccount:accountID];
+ return [self.db executeNonQuery:@"INSERT INTO buddylist ('account_id', 'buddy_name', 'full_name', 'nick_name', 'muc', 'muc_nick', 'encrypt') VALUES(?, ?, ?, ?, ?, ?, ?) ON CONFLICT(account_id, buddy_name) DO UPDATE SET subscription='both';" andArguments:@[accountID, [NSString stringWithFormat:@"%@@%@", accountDetails[kUsername], accountDetails[kDomain]], @"", @"", @0, @"", @(encrypt)]];
+}
+
+-(BOOL) addContact:(NSString*) contact forAccount:(NSNumber*) accountID nickname:(NSString*) nickName
+{
+ if(accountID == nil || !contact)
+ return NO;
+
+ return [self.db boolWriteTransaction:^{
+ //data length check
+ NSString* toPass;
+ NSString* cleanNickName;
+ if(!nickName)
+ {
+ //use already existing nickname, if none was given
+ cleanNickName = [self.db executeScalar:@"SELECT nick_name FROM buddylist WHERE account_id=? AND buddy_name=?;" andArguments:@[accountID, contact]];
+ //fall back to an empty one if this contact is not already in our db
+ if(!cleanNickName)
+ cleanNickName = @"";
+ }
+ else
+ cleanNickName = [nickName stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]];
+
+ if([cleanNickName length] > 50)
+ toPass = [cleanNickName substringToIndex:49];
+ else
+ toPass = cleanNickName;
+
+ BOOL encrypt = NO;
+#ifndef DISABLE_OMEMO
+ encrypt = [[HelperTools defaultsDB] boolForKey:@"OMEMODefaultOn"];
+#endif// DISABLE_OMEMO
+
+ return [self.db executeNonQuery:@"INSERT INTO buddylist ('account_id', 'buddy_name', 'full_name', 'nick_name', 'muc', 'muc_nick', 'encrypt') VALUES(?, ?, ?, ?, ?, ?, ?) ON CONFLICT(account_id, buddy_name) DO UPDATE SET nick_name=?;" andArguments:@[accountID, contact, @"", toPass, @0, @"", @(encrypt), toPass]];
+ }];
+}
+
+-(void) removeBuddy:(NSString*) buddy forAccount:(NSNumber*) accountID
+{
+ [self.db voidWriteTransaction:^{
+ //clean up logs...
+ [self clearMessagesWithBuddy:buddy onAccount:accountID];
+ //...and delete contact
+ [self.db executeNonQuery:@"DELETE FROM buddylist WHERE account_id=? AND buddy_name=?;" andArguments:@[accountID, buddy]];
+ }];
+}
+
+-(BOOL) clearBuddies:(NSNumber*) accountID
+{
+ return [self.db boolWriteTransaction:^{
+ return [self.db executeNonQuery:@"DELETE FROM buddylist WHERE account_id=?;" andArguments:@[accountID]];
+ }];
+}
+
+#pragma mark Buddy Property commands
+
+-(BOOL) resetContactsForAccount:(NSNumber*) accountID
+{
+ if(accountID == nil)
+ return NO;
+ return [self.db boolWriteTransaction:^{
+ NSString* query2 = @"DELETE FROM buddy_resources WHERE buddy_id IN (SELECT buddy_id FROM buddylist WHERE account_id=?);";
+ NSArray* params = @[accountID];
+ [self.db executeNonQuery:query2 andArguments:params];
+ NSString* query = @"UPDATE buddylist SET state='offline', status='' WHERE account_id=?;";
+ return [self.db executeNonQuery:query andArguments:params];
+ }];
+}
+
+-(NSDictionary* _Nullable) contactDictionaryForUsername:(NSString*) username forAccount:(NSNumber*) accountID
+{
+ if(!username || accountID == nil)
+ return nil;
+
+ return [self.db idReadTransaction:^{
+ NSArray* results = [self.db executeReader:@"SELECT b.buddy_id, b.buddy_name, state, status, b.full_name, b.nick_name, Muc, muc_subject, muc_type, muc_nick, mentionOnly, b.account_id, 0 AS 'count', subscription, ask, IFNULL(pinned, 0) AS 'pinned', encrypt, muted, \
+ CASE \
+ WHEN a.buddy_name IS NOT NULL THEN 1 \
+ ELSE 0 \
+ END AS 'isActiveChat' \
+ FROM buddylist AS b LEFT JOIN activechats AS a \
+ ON a.buddy_name = b.buddy_name AND a.account_id = b.account_id \
+ WHERE b.buddy_name=? AND b.account_id=?;" andArguments:@[username, accountID]];
+
+ MLAssert(results != nil && [results count] <= 1, @"Unexpected contact count", (@{
+ @"username": username,
+ @"accountID": accountID,
+ @"count": [NSNumber numberWithInteger:[results count]],
+ @"results": results ? results : @"(null)"
+ }));
+
+ if([results count] == 0)
+ return (NSMutableDictionary*)nil;
+
+ NSMutableDictionary* contact = [results[0] mutableCopy];
+ NSNumber* buddyId = contact[@"buddy_id"];
+ [contact removeObjectForKey:@"buddy_id"];
+
+ NSArray* groupArray = [self.db executeScalarReader:@"SELECT DISTINCT group_name FROM buddy_groups WHERE buddy_id=?;" andArguments:@[buddyId]];
+ NSSet* groups = [NSSet setWithArray:groupArray];
+ [contact setValue:groups forKey:@"rosterGroups"];
+
+ //correctly extract NSDate object or 1970, if last interaction is zero
+ contact[@"lastInteraction"] = nilWrapper([self lastInteractionOfJid:username forAccountID:accountID]);
+ //if we have this muc in our favorites table, this muc is "subscribed"
+ if([self.db executeScalar:@"SELECT room FROM muc_favorites WHERE room=? AND account_id=?;" andArguments:@[username, accountID]] != nil)
+ contact[@"subscription"] = @"both";
+ return contact;
+ }];
+}
+
+
+-(NSMutableArray*) searchContactsWithString:(NSString*) search
+{
+ return [self.db idReadTransaction:^{
+ NSString* likeString = [NSString stringWithFormat:@"%%%@%%", search];
+ NSString* query = @"SELECT B.buddy_name, B.account_id, IFNULL(IFNULL(NULLIF(B.nick_name, ''), NULLIF(B.full_name, '')), B.buddy_name) AS 'sortkey' FROM buddylist AS B INNER JOIN account AS A ON A.account_id=B.account_id WHERE A.enabled=1 AND (B.buddy_name LIKE ? OR B.full_name LIKE ? OR B.nick_name LIKE ?) ORDER BY sortkey COLLATE NOCASE ASC;";
+ NSArray* params = @[likeString, likeString, likeString];
+ NSMutableArray* toReturn = [NSMutableArray new];
+ for(NSDictionary* dic in [self.db executeReader:query andArguments:params])
+ [toReturn addObject:[MLContact createContactFromJid:dic[@"buddy_name"] andAccountID:dic[@"account_id"]]];
+ return toReturn;
+ }];
+}
+
+-(NSArray*) contactList
+{
+ return [self contactListWithJid:@""];
+}
+
+-(NSArray*) possibleGroupMembersForAccount:(NSNumber*) accountID
+{
+ return [self.db idReadTransaction:^{
+ //list all contacts without groupchats and self contact
+ NSString* query = @"SELECT B.buddy_name, B.account_id, IFNULL(IFNULL(NULLIF(B.nick_name, ''), NULLIF(B.full_name, '')), B.buddy_name) FROM buddylist as B INNER JOIN account AS A ON A.account_id=B.account_id WHERE B.account_id=? AND B.muc=0 AND B.buddy_name != (A.username || '@' || A.domain)";
+ NSMutableArray* toReturn = [NSMutableArray new];
+ for(NSDictionary* dic in [self.db executeReader:query andArguments:@[accountID]])
+ [toReturn addObject:[MLContact createContactFromJid:dic[@"buddy_name"] andAccountID:dic[@"account_id"]]];
+ return toReturn;
+ }];
+}
+
+-(NSArray*) contactListWithJid:(NSString*) jid
+{
+ return [self.db idReadTransaction:^{
+ //list all contacts and group chats
+ NSString* query = @"SELECT B.buddy_name, B.account_id, IFNULL(IFNULL(NULLIF(B.nick_name, ''), NULLIF(B.full_name, '')), B.buddy_name) AS 'sortkey' FROM buddylist AS B INNER JOIN account AS A ON A.account_id=B.account_id WHERE A.enabled=1 AND (B.buddy_name=? OR ?='') ORDER BY sortkey COLLATE NOCASE ASC;";
+ NSMutableArray* toReturn = [NSMutableArray new];
+ for(NSDictionary* dic in [self.db executeReader:query andArguments:@[jid, jid]])
+ [toReturn addObject:[MLContact createContactFromJid:dic[@"buddy_name"] andAccountID:dic[@"account_id"]]];
+ return toReturn;
+ }];
+}
+
+#pragma mark entity capabilities
+
+-(BOOL) checkCap:(NSString*) cap forUser:(NSString*) user onAccountID:(NSNumber*) accountID
+{
+ return [self.db boolReadTransaction:^{
+ NSString* query = @"SELECT COUNT(*) FROM buddylist AS a INNER JOIN buddy_resources AS b ON a.buddy_id=b.buddy_id INNER JOIN ver_info AS c ON b.ver=c.ver WHERE a.buddy_name=? AND a.account_id=? AND c.cap=? AND c.account_id=?;";
+ NSArray *params = @[user, accountID, cap, accountID];
+ NSNumber* count = (NSNumber*) [self.db executeScalar:query andArguments:params];
+ return (BOOL)([count integerValue]>0);
+ }];
+}
+
+-(BOOL) checkCap:(NSString*) cap forUser:(NSString*) user andResource:(NSString*) resource onAccountID:(NSNumber*) accountID
+{
+ return [self.db boolReadTransaction:^{
+ NSString* query = @"SELECT COUNT(*) FROM buddylist AS a INNER JOIN buddy_resources AS b ON a.buddy_id=b.buddy_id INNER JOIN ver_info AS c ON b.ver=c.ver WHERE a.buddy_name=? AND b.resource=? AND a.account_id=? AND c.cap=? AND c.account_id=?;";
+ NSNumber* count = (NSNumber*) [self.db executeScalar:query andArguments:@[user, resource, accountID, cap, accountID]];
+ return (BOOL)([count integerValue]>0);
+ }];
+}
+
+-(NSString*) getVerForUser:(NSString*) user andResource:(NSString*) resource onAccountID:(NSNumber*) accountID
+{
+ return [self.db idReadTransaction:^{
+ NSString* query = @"SELECT ver FROM buddy_resources AS A INNER JOIN buddylist AS B ON a.buddy_id=b.buddy_id WHERE resource=? AND buddy_name=? AND account_id=? LIMIT 1;";
+ NSArray * params = @[resource, user, accountID];
+ NSString* ver = (NSString*) [self.db executeScalar:query andArguments:params];
+ return ver;
+ }];
+}
+
+-(void) setVer:(NSString*) ver forUser:(NSString*) user andResource:(NSString*) resource onAccountID:(NSNumber*) accountID
+{
+ NSNumber* timestamp = [HelperTools currentTimestampInSeconds];
+ [self.db voidWriteTransaction:^{
+ //set ver for user and resource
+ NSString* query = @"UPDATE buddy_resources SET ver=? WHERE EXISTS(SELECT * FROM buddylist WHERE buddy_resources.buddy_id=buddylist.buddy_id AND resource=? AND buddy_name=? AND account_id=?)";
+ NSArray * params = @[ver, resource, user, accountID];
+ [self.db executeNonQuery:query andArguments:params];
+
+ //update timestamp for this ver string to make it not timeout (old ver strings and features are removed from feature cache after 28 days)
+ NSString* query2 = @"UPDATE ver_info SET timestamp=? WHERE ver=? AND account_id=?;";
+ NSArray * params2 = @[timestamp, ver, accountID];
+ [self.db executeNonQuery:query2 andArguments:params2];
+ }];
+}
+
+-(NSSet*) getCapsforVer:(NSString*) ver onAccountID:(NSNumber*) accountID
+{
+ return [self.db idReadTransaction:^{
+ NSSet* result = [NSSet setWithArray:[self.db executeScalarReader:@"SELECT cap FROM ver_info WHERE ver=? AND account_id=?;" andArguments:@[ver, accountID]]];
+
+ DDLogVerbose(@"caps count: %lu", (unsigned long)[result count]);
+ if([result count] == 0)
+ return (NSSet*)nil;
+ return result;
+ }];
+}
+
+-(void) setCaps:(NSSet*) caps forVer:(NSString*) ver onAccountID:(NSNumber*) accountID
+{
+ NSNumber* timestamp = [HelperTools currentTimestampInSeconds];
+ [self.db voidWriteTransaction:^{
+ //remove old caps for this ver
+ [self.db executeNonQuery:@"DELETE FROM ver_info WHERE ver=? AND account_id=?;" andArguments:@[ver, accountID]];
+
+ //insert new caps
+ for(NSString* feature in caps)
+ [self.db executeNonQuery:@"INSERT INTO ver_info (ver, cap, account_id, timestamp) VALUES (?, ?, ?, ?);" andArguments:@[ver, feature, accountID, timestamp]];
+
+ //cleanup old entries of *all* accounts
+ [self.db executeNonQuery:@"DELETE FROM ver_info WHERE timestamp;" andArguments:@[[NSNumber numberWithInteger:[timestamp integerValue] - (86400 * 28)]]]; //cache timeout is 28 days
+ }];
+}
+
+#pragma mark presence functions
+
+-(void) setResourceOnline:(XMPPPresence*) presenceObj forAccount:(NSNumber*) accountID
+{
+ if(!presenceObj.fromResource)
+ return;
+ [self.db voidWriteTransaction:^{
+ //get buddyid for name and account
+ NSString* query1 = @"select buddy_id from buddylist where account_id=? and buddy_name=?;";
+ NSObject* buddyid = [self.db executeScalar:query1 andArguments:@[accountID, presenceObj.fromUser]];
+ if(buddyid)
+ {
+ NSString* query = @"insert or ignore into buddy_resources ('buddy_id', 'resource', 'ver') values (?, ?, '')";
+ [self.db executeNonQuery:query andArguments:@[buddyid, presenceObj.fromResource]];
+ }
+ }];
+}
+
+
+-(NSArray*) resourcesForContact:(MLContact* _Nonnull) contact
+{
+ return [self.db idReadTransaction:^{
+ NSArray* resources = [self.db executeScalarReader:@"SELECT resource FROM buddy_resources AS A INNER JOIN buddylist AS B ON a.buddy_id=b.buddy_id WHERE buddy_name=?;" andArguments:@[contact.contactJid]];
+ return resources;
+ }];
+}
+
+-(MLContactSoftwareVersionInfo* _Nullable) getSoftwareVersionInfoForContact:(NSString*) contact resource:(NSString*) resource andAccount:(NSNumber*) accountID
+{
+ if(accountID == nil)
+ return nil;
+ NSArray* versionInfoArr = [self.db idReadTransaction:^{
+ NSArray* resources = [self.db executeReader:@"SELECT platform_App_Name, platform_App_Version, platform_OS FROM buddy_resources WHERE buddy_id IN (SELECT buddy_id FROM buddylist WHERE account_id=? AND buddy_name=?) AND resource=?" andArguments:@[accountID, contact, resource]];
+ return resources;
+ }];
+ if(versionInfoArr == nil || versionInfoArr.count == 0) {
+ return nil;
+ } else {
+ NSDictionary* versionInfo = versionInfoArr.firstObject;
+ NSDate* lastInteraction = [self lastInteractionOfJid:contact andResource:resource forAccountID:accountID];
+ return [[MLContactSoftwareVersionInfo alloc] initWithJid:contact andRessource:resource andAppName:versionInfo[@"platform_App_Name"] andAppVersion:versionInfo[@"platform_App_Version"] andPlatformOS:versionInfo[@"platform_OS"] andLastInteraction:lastInteraction];
+ }
+}
+
+-(void) setSoftwareVersionInfoForContact:(NSString*) contact
+ resource:(NSString*) resource
+ andAccount:(NSNumber*) account
+ withSoftwareInfo:(MLContactSoftwareVersionInfo*) newSoftwareInfo
+{
+ [self.db voidWriteTransaction:^{
+ NSString* query = @"update buddy_resources set platform_App_Name=?, platform_App_Version=?, platform_OS=? where buddy_id in (select buddy_id from buddylist where account_id=? and buddy_name=?) and resource=?";
+ NSArray* params = @[nilWrapper(newSoftwareInfo.appName), nilWrapper(newSoftwareInfo.appVersion), nilWrapper(newSoftwareInfo.platformOs), account, contact, resource];
+ [self.db executeNonQuery:query andArguments:params];
+ }];
+}
+
+-(void) setOnlineBuddy:(XMPPPresence*) presenceObj forAccount:(NSNumber*) accountID
+{
+ [self.db voidWriteTransaction:^{
+ [self setResourceOnline:presenceObj forAccount:accountID];
+ NSString* query = @"UPDATE buddylist SET state='' WHERE account_id=? AND buddy_name=? AND state='offline';";
+ NSArray* params = @[accountID, presenceObj.fromUser];
+ [self.db executeNonQuery:query andArguments:params];
+ }];
+}
+
+-(void) setOfflineBuddy:(XMPPPresence*) presenceObj forAccount:(NSNumber*) accountID
+{
+ return [self.db voidWriteTransaction:^{
+ [self.db executeNonQuery:@"DELETE FROM buddy_resources AS R WHERE resource=? AND EXISTS(SELECT * FROM buddylist AS B WHERE B.buddy_id=R.buddy_id AND B.account_id=? AND B.buddy_name=?);" andArguments:@[presenceObj.fromResource ? presenceObj.fromResource : @"", accountID, presenceObj.fromUser]];
+ [self.db executeNonQuery:@"UPDATE buddylist AS B SET state='offline' WHERE account_id=? AND buddy_name=? AND NOT EXISTS(SELECT * FROM buddy_resources AS R WHERE B.buddy_id=R.buddy_id);" andArguments:@[accountID, presenceObj.fromUser]];
+ }];
+}
+
+-(void) setBuddyState:(XMPPPresence*) presenceObj forAccount:(NSNumber*) accountID;
+{
+ NSString* toPass = @"";
+ if([presenceObj check:@"show#"])
+ {
+ //data length check
+ if([[presenceObj findFirst:@"show#"] length] > 20)
+ toPass = [[presenceObj findFirst:@"show#"] substringToIndex:19];
+ else
+ toPass = [presenceObj findFirst:@"show#"];
+ }
+
+ [self.db voidWriteTransaction:^{
+ NSString* query = @"UPDATE buddylist SET state=? WHERE account_id=? AND buddy_name=?;";
+ [self.db executeNonQuery:query andArguments:@[toPass, accountID, presenceObj.fromUser]];
+ }];
+}
+
+-(NSString*) buddyState:(NSString*) buddy forAccount:(NSNumber*) accountID
+{
+ return [self.db idReadTransaction:^{
+ NSString* query = @"SELECT state FROM buddylist WHERE account_id=? AND buddy_name=?;";
+ NSArray* params = @[accountID, buddy];
+ NSString* state = (NSString*)[self.db executeScalar:query andArguments:params];
+ return state;
+ }];
+}
+
+-(BOOL) hasContactRequestForContact:(MLContact*) contact
+{
+ return [self.db boolReadTransaction:^{
+ NSString* query = @"SELECT COUNT(*) FROM subscriptionRequests WHERE account_id=? AND buddy_name=?";
+ NSNumber* result = (NSNumber*)[self.db executeScalar:query andArguments:@[contact.accountID, contact.contactJid]];
+ return (BOOL)(result.intValue == 1);
+ }];
+}
+
+-(NSMutableArray*) allContactRequests
+{
+ return [self.db idReadTransaction:^{
+ NSString* query = @"SELECT subscriptionRequests.account_id, subscriptionRequests.buddy_name FROM subscriptionRequests, account WHERE subscriptionRequests.account_id = account.account_id AND account.enabled;";
+ NSMutableArray* toReturn = [NSMutableArray new];
+ for(NSDictionary* dic in [self.db executeReader:query])
+ [toReturn addObject:[MLContact createContactFromJid:dic[@"buddy_name"] andAccountID:dic[@"account_id"]]];
+ return toReturn;
+ }];
+}
+
+-(void) addContactRequest:(MLContact*) requestor;
+{
+ [self.db voidWriteTransaction:^{
+ NSString* query2 = @"INSERT OR IGNORE INTO subscriptionRequests (buddy_name, account_id) VALUES (?,?)";
+ [self.db executeNonQuery:query2 andArguments:@[requestor.contactJid, requestor.accountID]];
+ }];
+}
+
+-(void) deleteContactRequest:(MLContact*) requestor
+{
+ [self.db voidWriteTransaction:^{
+ NSString* query2 = @"delete from subscriptionRequests where buddy_name=? and account_id=? ";
+ [self.db executeNonQuery:query2 andArguments:@[requestor.contactJid, requestor.accountID]];
+ }];
+}
+
+-(void) setBuddyStatus:(XMPPPresence*) presenceObj forAccount:(NSNumber*) accountID
+{
+ NSString* toPass = @"";
+ if([presenceObj check:@"status#"])
+ {
+ //data length check
+ if([[presenceObj findFirst:@"status#"] length] > 200)
+ toPass = [[presenceObj findFirst:@"status#"] substringToIndex:199];
+ else
+ toPass = [presenceObj findFirst:@"status#"];
+ }
+
+ [self.db voidWriteTransaction:^{
+ NSString* query = @"UPDATE buddylist SET status=? WHERE account_id=? AND buddy_name=?;";
+ [self.db executeNonQuery:query andArguments:@[toPass, accountID, presenceObj.fromUser]];
+ }];
+}
+
+-(NSString*) buddyStatus:(NSString*) buddy forAccount:(NSNumber*) accountID
+{
+ return [self.db idReadTransaction:^{
+ NSString* query = @"SELECT status FROM buddylist WHERE account_id=? AND buddy_name=?;";
+ NSString* iconname = (NSString *)[self.db executeScalar:query andArguments:@[accountID, buddy]];
+ return iconname;
+ }];
+}
+
+-(NSString *) getRosterVersionForAccount:(NSNumber*) accountID
+{
+ return [self.db idReadTransaction:^{
+ NSString* query = @"SELECT rosterVersion FROM account WHERE account_id=?;";
+ NSArray* params = @[accountID];
+ NSString * version=(NSString*)[self.db executeScalar:query andArguments:params];
+ return version;
+ }];
+}
+
+-(void) setRosterVersion:(NSString*) version forAccount:(NSNumber*) accountID
+{
+ if(accountID == nil || !version)
+ return;
+ [self.db voidWriteTransaction:^{
+ NSString* query = @"update account set rosterVersion=? where account_id=?";
+ NSArray* params = @[version , accountID];
+ [self.db executeNonQuery:query andArguments:params];
+ }];
+}
+
+-(NSDictionary*) getSubscriptionForContact:(NSString*) contact andAccount:(NSNumber*) accountID
+{
+ if(!contact || accountID == nil)
+ return nil;
+ return [self.db idReadTransaction:^{
+ NSString* query = @"SELECT subscription, ask from buddylist where buddy_name=? and account_id=?";
+ NSArray* params = @[contact, accountID];
+ NSArray* version = [self.db executeReader:query andArguments:params];
+ return version.firstObject;
+ }];
+}
+
+-(void) setSubscription:(NSString*)sub andAsk:(NSString*) ask forContact:(NSString*) contact andAccount:(NSNumber*) accountID
+{
+ if(!contact || accountID == nil || !sub)
+ return;
+ [self.db voidWriteTransaction:^{
+ NSString* query = @"update buddylist set subscription=?, ask=? where account_id=? and buddy_name=?";
+ NSArray* params = @[sub, ask?ask:@"", accountID, contact];
+ [self.db executeNonQuery:query andArguments:params];
+ }];
+}
+
+/**
+ Ensures that the given groups are the only ones persisted for the given contact
+
+ This function updates the groups in the database to match the groups passed to the function. This means (for instance) that passing an empty groups set will delete all the groups for a user.
+ */
+-(void) setGroups:(NSSet*) groups forContact:(NSString*) contact inAccount:(NSNumber*) accountID
+{
+ if(groups == nil || contact == nil || accountID == nil)
+ return;
+
+ NSSet* validGroups = [groups filteredSetUsingPredicate:[NSPredicate predicateWithFormat:@"SELF.length > 0"]];
+
+ if([validGroups count] < [groups count]) {
+ DDLogWarn(@"Refusing to persist group(s) with empty name for contact %@", contact);
+ }
+
+ DDLogVerbose(@"For contact %@ and account %@, intend to persist these groups: %@", contact, accountID, validGroups);
+
+ NSString* deleteBuddyGroups = @"DELETE FROM buddy_groups \
+ WHERE buddy_id IN ( \
+ SELECT buddy_id FROM buddylist WHERE buddy_name=? AND account_id=? \
+ );";
+ NSString* saveBuddyGroups = @"INSERT OR IGNORE INTO buddy_groups ('buddy_id', 'group_name') \
+ SELECT buddy_id, ? FROM buddylist WHERE buddy_name=? AND account_id=?;";
+
+ [self.db voidWriteTransaction:^{
+ [self.db executeNonQuery:deleteBuddyGroups andArguments:@[contact, accountID]];
+ for(NSString* group in groups) {
+ [self.db executeNonQuery:saveBuddyGroups andArguments:@[group, contact, accountID]];
+ }
+ }];
+}
+
+
+#pragma mark Contact info
+
+-(void) setFullName:(NSString*) fullName forContact:(NSString*) contact andAccount:(NSNumber*) accountID
+{
+ //data length check
+ NSString* toPass;
+ NSString* cleanFullName = [fullName stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]];
+ if([cleanFullName length]>50)
+ toPass = [cleanFullName substringToIndex:49];
+ else
+ toPass = cleanFullName;
+
+ if(!toPass)
+ return;
+
+ [self.db voidWriteTransaction:^{
+ NSString* query = @"UPDATE buddylist SET full_name=? WHERE account_id=? AND buddy_name=?;";
+ NSArray* params = @[toPass , accountID, contact];
+ [self.db executeNonQuery:query andArguments:params];
+ }];
+}
+
+-(void) setAvatarHash:(NSString*) hash forContact:(NSString*) contact andAccount:(NSNumber*) accountID
+{
+ [self.db voidWriteTransaction:^{
+ [self.db executeNonQuery:@"UPDATE account SET iconhash=? WHERE account_id=? AND printf('%s@%s', username, domain)=?;" andArguments:@[hash, accountID, contact]];
+ [self.db executeNonQuery:@"UPDATE buddylist SET iconhash=? WHERE account_id=? AND buddy_name=?;" andArguments:@[hash, accountID, contact]];
+ }];
+}
+
+-(NSString*) getAvatarHashForContact:(NSString*) buddy andAccount:(NSNumber*) accountID
+{
+ return [self.db idReadTransaction:^{
+ NSString* hash = [self.db executeScalar:@"SELECT iconhash FROM buddylist WHERE account_id=? AND buddy_name=?;" andArguments:@[accountID, buddy]];
+ if(!hash) //try to get the hash of our own account
+ hash = [self.db executeScalar:@"SELECT iconhash FROM account WHERE account_id=? AND printf('%s@%s', username, domain)=?;" andArguments:@[accountID, buddy]];
+ if(!hash)
+ hash = @""; //hashes should never be nil
+ return hash;
+ }];
+}
+
+-(BOOL) isContactInList:(NSString*) buddy forAccount:(NSNumber*) accountID
+{
+ return [self.db boolReadTransaction:^{
+ NSString* query = @"select count(buddy_id) from buddylist where account_id=? and buddy_name=? ";
+ NSArray* params = @[accountID, buddy];
+
+ NSObject* value = [self.db executeScalar:query andArguments:params];
+
+ NSNumber* count=(NSNumber*)value;
+ BOOL toreturn = NO;
+ if(count != nil)
+ {
+ NSInteger val = [count integerValue];
+ if(val > 0) {
+ toreturn = YES;
+ }
+ }
+ return toreturn;
+ }];
+}
+
+-(BOOL) saveMessageDraft:(NSString*) buddy forAccount:(NSNumber*) accountID withComment:(NSString*) comment
+{
+ return [self.db boolWriteTransaction:^{
+ return [self.db executeNonQuery:@"UPDATE buddylist SET messageDraft=? WHERE account_id=? AND buddy_name=?;" andArguments:@[comment, accountID, buddy]];
+ }];
+}
+
+-(NSString*) loadMessageDraft:(NSString*) buddy forAccount:(NSNumber*) accountID
+{
+ return [self.db idReadTransaction:^{
+ NSString* query = @"SELECT messageDraft FROM buddylist WHERE account_id=? AND buddy_name=?;";
+ NSArray* params = @[accountID, buddy];
+ return [self.db executeScalar:query andArguments:params];
+ }];
+}
+
+#pragma mark MUC
+
+-(BOOL) initMuc:(NSString*) room forAccountID:(NSNumber*) accountID andMucNick:(NSString* _Nullable) mucNick
+{
+ return [self.db boolWriteTransaction:^{
+ BOOL isMuc = [self isBuddyMuc:room forAccount:accountID];
+ if(!isMuc)
+ {
+ // remove old buddy and add new one (this changes "normal" buddys to muc buddys if the aren't already tagged as mucs)
+ // this will clean up associated buddylist data, too (foreign keys etc.)
+ [self.db executeNonQuery:@"DELETE FROM buddylist WHERE account_id=? AND buddy_name=?;" andArguments:@[accountID, room]];
+ }
+
+ NSString* nick = mucNick;
+ if(!nick)
+ nick = [self ownNickNameforMuc:room forAccount:accountID];
+ MLAssert(nick != nil, @"Could not determine muc nick when adding muc");
+
+ //this cleanup is made *before* sending out the join presence to join a muc
+ //--> every entry should be removed and filled again by the incoming presence flood triggered by our join
+ [self.db executeNonQuery:@"DELETE FROM muc_participants WHERE account_id=? AND room=?;" andArguments:@[accountID, room]];
+
+ //this cleanup is made *before* sending out the join presence to join a muc and the list-fetching iqs
+ //--> every entry should be removed and filled again by the incoming presence flood triggered by our join or the list-fetching responses
+ //NOTE: initMuc will only be called on first join, not on rejoin --> these cleanups won't be called on rejoin so that
+ //NOTE: the members list will always be properly filled even while a rejoin is in progress
+ [self.db executeNonQuery:@"DELETE FROM muc_members WHERE account_id=? AND room=?;" andArguments:@[accountID, room]];
+
+ BOOL encrypt = NO;
+#ifndef DISABLE_OMEMO
+ // omemo for non group MUCs is disabled once the type of the muc is set
+ // (for channel type mucs this will be disabled while creating the muc shortly after this function is called)
+ encrypt = [[HelperTools defaultsDB] boolForKey:@"OMEMODefaultOn"];
+#endif// DISABLE_OMEMO
+
+ return [self.db executeNonQuery:@"INSERT INTO buddylist ('account_id', 'buddy_name', 'muc', 'muc_nick', 'encrypt') VALUES(?, ?, 1, ?, ?) ON CONFLICT(account_id, buddy_name) DO UPDATE SET muc=1, muc_nick=?;" andArguments:@[accountID, room, mucNick ? mucNick : @"", @(encrypt), mucNick ? mucNick : @""]];
+ }];
+}
+
+-(void) cleanupParticipantsListFor:(NSString*) room onAccountID:(NSNumber*) accountID
+{
+ //clean up old muc data (will be refilled by incoming presences and/or disco queries)
+ [self.db executeNonQuery:@"DELETE FROM muc_participants WHERE account_id=? AND room=?;" andArguments:@[accountID, room]];
+}
+
+-(void) cleanupMembersListFor:(NSString*) room andType:(NSString*) type onAccountID:(NSNumber*) accountID
+{
+ //clean up old muc data (will be refilled by incoming presences and/or disco queries)
+ [self.db executeNonQuery:@"DELETE FROM muc_members WHERE account_id=? AND room=? AND affiliation=?;" andArguments:@[accountID, room, type]];
+}
+
+-(void) addParticipant:(NSDictionary*) participant toMuc:(NSString*) room forAccountID:(NSNumber*) accountID
+{
+ if(!participant || !participant[@"nick"] || !room || accountID == nil)
+ return;
+
+ [self.db voidWriteTransaction:^{
+ //create entry if not already existing
+ //(update occupant_id if that nick is already existing --> occupant_id and nick should always be matching and up to date)
+ [self.db executeNonQuery:@"INSERT INTO muc_participants ('account_id', 'room', 'room_nick', 'occupant_id') VALUES(?, ?, ?, ?) ON CONFLICT DO UPDATE SET occupant_id=? WHERE account_id=? AND room=? AND room_nick=?;" andArguments:@[accountID, room, participant[@"nick"], nilWrapper(participant[@"occupant_id"]), nilWrapper(participant[@"occupant_id"]), accountID, room, participant[@"nick"]]];
+
+ //update entry with optional fields (the first two fields are for members that are not just participants)
+ [self.db executeNonQuery:@"UPDATE muc_participants SET participant_jid=? WHERE account_id=? AND room=? AND room_nick=?;" andArguments:@[nilWrapper(participant[@"jid"]), accountID, room, participant[@"nick"]]];
+ [self.db executeNonQuery:@"UPDATE muc_participants SET affiliation=? WHERE account_id=? AND room=? AND room_nick=?;" andArguments:@[nilWrapper(participant[@"affiliation"]), accountID, room, participant[@"nick"]]];
+ [self.db executeNonQuery:@"UPDATE muc_participants SET role=? WHERE account_id=? AND room=? AND room_nick=?;" andArguments:@[nilWrapper(participant[@"role"]), accountID, room, participant[@"nick"]]];
+ }];
+}
+
+-(void) removeParticipant:(NSDictionary*) participant fromMuc:(NSString*) room forAccountID:(NSNumber*) accountID
+{
+ if(!participant || !participant[@"nick"] || !room || accountID == nil)
+ return;
+
+ [self.db voidWriteTransaction:^{
+ [self.db executeNonQuery:@"DELETE FROM muc_participants WHERE account_id=? AND room=? AND room_nick=?;" andArguments:@[accountID, room, participant[@"nick"]]];
+ }];
+}
+
+-(void) addMember:(NSDictionary*) member toMuc:(NSString*) room forAccountID:(NSNumber*) accountID
+{
+ if(!member || !member[@"jid"] || !room || accountID == nil)
+ return;
+
+ [self.db voidWriteTransaction:^{
+ //create entry if not already existing
+ [self.db executeNonQuery:@"INSERT OR IGNORE INTO muc_members ('account_id', 'room', 'member_jid') VALUES(?, ?, ?);" andArguments:@[accountID, room, member[@"jid"]]];
+
+ //update entry with optional fields
+ [self.db executeNonQuery:@"UPDATE muc_members SET affiliation=? WHERE account_id=? AND room=? AND member_jid=?;" andArguments:@[nilWrapper(member[@"affiliation"]), accountID, room, member[@"jid"]]];
+ }];
+}
+
+-(void) removeMember:(NSDictionary*) member fromMuc:(NSString*) room forAccountID:(NSNumber*) accountID
+{
+ if(!member || !member[@"jid"] || !room || accountID == nil)
+ return;
+
+ DDLogDebug(@"Removing member '%@' from muc '%@'...", member[@"jid"], room);
+ [self.db voidWriteTransaction:^{
+ [self.db executeNonQuery:@"DELETE FROM muc_members WHERE account_id=? AND room=? AND member_jid=?;" andArguments:@[accountID, room, member[@"jid"]]];
+ }];
+}
+
+-(NSDictionary* _Nullable) getParticipantForNick:(NSString*) nick inRoom:(NSString*) room forAccountID:(NSNumber*) accountID
+{
+ if(!nick || !room || accountID == nil)
+ return nil;
+ return [self.db idReadTransaction:^{
+ NSArray* result = [self.db executeReader:@"SELECT * FROM muc_participants WHERE account_id=? AND room=? AND room_nick=?;" andArguments:@[accountID, room, nick]];
+ return result.count > 0 ? result[0] : nil;
+ }];
+}
+
+-(NSDictionary* _Nullable) getParticipantForOccupant:(NSString*) occupant inRoom:(NSString*) room forAccountID:(NSNumber*) accountID
+{
+ if(!occupant || !occupant || accountID == nil)
+ return nil;
+ return [self.db idReadTransaction:^{
+ NSArray* result = [self.db executeReader:@"SELECT * FROM muc_participants WHERE account_id=? AND room=? AND occupant_id=?;" andArguments:@[accountID, room, occupant]];
+ return result.count > 0 ? result[0] : nil;
+ }];
+}
+
+-(NSArray*>*) getMembersAndParticipantsOfMuc:(NSString*) room forAccountID:(NSNumber*) accountID
+{
+ if(!room || accountID == nil)
+ return [[NSMutableArray*> alloc] init];
+ return [self.db idReadTransaction:^{
+ NSMutableArray*>* toReturn = [[NSMutableArray*> alloc] init];
+
+ [toReturn addObjectsFromArray:[self.db executeReader:@"SELECT *, 1 as 'online' FROM muc_participants WHERE account_id=? AND room=? ORDER BY affiliation, room_nick;" andArguments:@[accountID, room]]];
+ [toReturn addObjectsFromArray:[self.db executeReader:@"SELECT *, 0 as 'online' FROM muc_members WHERE account_id=? AND room=? AND NOT EXISTS(SELECT * FROM muc_participants WHERE muc_members.account_id=muc_participants.account_id AND muc_members.room=muc_participants.room AND muc_members.member_jid=muc_participants.participant_jid) ORDER BY affiliation;" andArguments:@[accountID, room]]];
+
+ return toReturn;
+ }];
+}
+
+-(NSString* _Nullable) getOwnAffiliationInGroupOrChannel:(MLContact*) contact
+{
+ MLAssert(contact.isMuc, @"Function should only be called on a group contact");
+ return [self.db idReadTransaction:^{
+ NSString* retval = [self.db executeScalar:@"SELECT M.affiliation FROM muc_participants AS M INNER JOIN account AS A ON M.account_id=A.account_id WHERE M.room=? AND A.account_id=? AND (A.username || '@' || A.domain) == M.participant_jid" andArguments:@[contact.contactJid, contact.accountID]];
+ if(retval == nil)
+ retval = [self.db executeScalar:@"SELECT M.affiliation FROM muc_members AS M INNER JOIN account AS A ON M.account_id=A.account_id WHERE M.room=? AND A.account_id=? AND (A.username || '@' || A.domain) == M.member_jid" andArguments:@[contact.contactJid, contact.accountID]];
+ return retval;
+ }];
+}
+
+-(NSString* _Nullable) getOwnRoleInGroupOrChannel:(MLContact*) contact
+{
+ MLAssert(contact.isMuc, @"Function should only be called on a group contact");
+ return [self.db idReadTransaction:^{
+ return [self.db executeScalar:@"SELECT M.role FROM muc_participants AS M INNER JOIN account AS A ON M.account_id=A.account_id WHERE M.room=? AND A.account_id=? AND (A.username || '@' || A.domain) == M.participant_jid" andArguments:@[contact.contactJid, contact.accountID]];
+ }];
+}
+
+-(void) addMucFavorite:(NSString*) room forAccountID:(NSNumber*) accountID andMucNick:(NSString* _Nullable) mucNick
+{
+ [self.db voidWriteTransaction:^{
+ NSString* nick = mucNick;
+ if(!nick)
+ nick = [self ownNickNameforMuc:room forAccount:accountID];
+ MLAssert(nick != nil, @"Could not determine muc nick when adding muc");
+
+ [self.db executeNonQuery:@"INSERT INTO muc_favorites (room, nick, account_id) VALUES(?, ?, ?) ON CONFLICT(room, account_id) DO UPDATE SET nick=?;" andArguments:@[room, nick, accountID, nick]];
+ }];
+}
+
+-(NSString*) lastStanzaIdForMuc:(NSString* _Nonnull) room andAccount:(NSNumber* _Nonnull) accountID
+{
+ return [self.db idReadTransaction:^{
+ return [self.db executeScalar:@"SELECT lastMucStanzaId FROM buddylist WHERE muc=1 AND account_id=? AND buddy_name=?;" andArguments:@[accountID, room]];
+ }];
+}
+
+-(void) setLastStanzaId:(NSString*) lastStanzaId forMuc:(NSString* _Nonnull) room andAccount:(NSNumber* _Nonnull) accountID
+{
+ [self.db voidWriteTransaction:^{
+ if(lastStanzaId && [lastStanzaId length])
+ [self.db executeNonQuery:@"UPDATE buddylist SET lastMucStanzaId=? WHERE muc=1 AND account_id=? AND buddy_name=?;" andArguments:@[lastStanzaId, accountID, room]];
+ }];
+}
+
+
+-(BOOL) isBuddyMuc:(NSString*) buddy forAccount:(NSNumber*) accountID
+{
+ return [self.db boolReadTransaction:^{
+ NSNumber* status = (NSNumber*)[self.db executeScalar:@"SELECT Muc FROM buddylist WHERE account_id=? AND buddy_name=?;" andArguments:@[accountID, buddy]];
+ if(status == nil)
+ return NO;
+ else
+ return [status boolValue];
+ }];
+}
+
+-(NSString* _Nullable) ownNickNameforMuc:(NSString*) room forAccount:(NSNumber*) accountID
+{
+ return [self.db idReadTransaction:^{
+ NSString* nick = (NSString*)[self.db executeScalar:@"SELECT muc_nick FROM buddylist WHERE account_id=? AND buddy_name=? and muc=1;" andArguments:@[accountID, room]];
+ // fallback to nick in muc_favorites
+ if(!nick || nick.length == 0)
+ nick = (NSString*)[self.db executeScalar:@"SELECT nick FROM muc_favorites WHERE account_id=? AND room=?;" andArguments:@[accountID, room]];
+ if(!nick || nick.length == 0)
+ return (NSString*)nil;
+ return nick;
+ }];
+}
+
+-(BOOL) updateOwnNickName:(NSString*) nick forMuc:(NSString*) room forAccount:(NSNumber*) accountID
+{
+ return [self.db boolWriteTransaction:^{
+ NSString* query = @"UPDATE buddylist SET muc_nick=? WHERE account_id=? AND buddy_name=? AND muc=1;";
+ NSArray* params = @[nick, accountID, room];
+ DDLogVerbose(@"%@", query);
+
+ return [self.db executeNonQuery:query andArguments:params];
+ }];
+}
+
+-(BOOL) updateOwnOccupantID:(NSString* _Nullable) occupantID forMuc:(NSString*) room onAccountID:(NSNumber*) accountID
+{
+ return [self.db boolWriteTransaction:^{
+ NSString* query = @"UPDATE buddylist SET muc_occupant_id=? WHERE account_id=? AND buddy_name=? AND muc=1;";
+ NSArray* params = @[nilWrapper(occupantID), accountID, room];
+ return [self.db executeNonQuery:query andArguments:params];
+ }];
+}
+
+-(NSString* _Nullable) getOwnOccupantIdForMuc:(NSString*) room onAccountID:(NSNumber*) accountID
+{
+ return [self.db idReadTransaction:^{
+ return [self.db executeScalar:@"SELECT muc_occupant_id FROM buddylist WHERE buddy_name=? AND account_id=? AND muc=1;" andArguments:@[room, accountID]];
+ }];
+}
+
+-(BOOL) deleteMuc:(NSString*) room forAccountID:(NSNumber*) accountID
+{
+ return [self.db boolWriteTransaction:^{
+ NSString* query = @"DELETE FROM muc_favorites WHERE room=? AND account_id=?;";
+ NSArray* params = @[room, accountID];
+ DDLogVerbose(@"%@", query);
+
+ return [self.db executeNonQuery:query andArguments:params];
+ }];
+}
+
+-(NSSet*) listMucsForAccount:(NSNumber*) accountID
+{
+ return [self.db idReadTransaction:^{
+ NSMutableSet* retval = [NSMutableSet new];
+ for(NSDictionary* entry in [self.db executeReader:@"SELECT * FROM muc_favorites WHERE account_id=?;" andArguments:@[accountID]])
+ [retval addObject:[entry[@"room"] lowercaseString]];
+ return retval;
+ }];
+}
+
+-(BOOL) updateMucSubject:(NSString *) subject forAccount:(NSNumber*) accountID andRoom:(NSString *) room
+{
+ return [self.db boolWriteTransaction:^{
+ NSString* query = @"UPDATE buddylist SET muc_subject=? WHERE account_id=? AND buddy_name=?;";
+ NSArray* params = @[subject, accountID, room];
+ DDLogVerbose(@"%@", query);
+ return [self.db executeNonQuery:query andArguments:params];
+ }];
+}
+
+-(NSString*) mucSubjectforAccount:(NSNumber*) accountID andRoom:(NSString*) room
+{
+ return [self.db idReadTransaction:^{
+ NSString* query = @"SELECT muc_subject FROM buddylist WHERE account_id=? AND buddy_name=?;";
+
+ NSArray* params = @[accountID, room];
+ DDLogVerbose(@"%@", query);
+
+ return [self.db executeScalar:query andArguments:params];
+ }];
+}
+
+-(void) updateMucTypeTo:(NSString*) type forRoom:(NSString*) room andAccount:(NSNumber*) accountID
+{
+ [self.db voidWriteTransaction:^{
+ [self.db executeNonQuery:@"UPDATE buddylist SET muc_type=? WHERE account_id=? AND buddy_name=?;" andArguments:@[type, accountID, room]];
+ if([type isEqualToString:kMucTypeGroup] == NO)
+ {
+ // non group type MUCs do not support encryption
+ [self.db executeNonQuery:@"UPDATE buddylist SET encrypt=0 WHERE account_id=? AND buddy_name=?;" andArguments:@[accountID, room]];
+ }
+ }];
+}
+
+-(NSString*) getMucTypeOfRoom:(NSString*) room andAccount:(NSNumber*) accountID
+{
+ return [self.db idReadTransaction:^{
+ return [self.db executeScalar:@"SELECT muc_type FROM buddylist WHERE account_id=? AND buddy_name=?;" andArguments:@[accountID, room]];
+ }];
+}
+
+#pragma mark message Commands
+
+-(NSArray*) messagesForHistoryIDs:(NSArray*) historyIDs
+{
+ return [self.db idReadTransaction:^{
+ NSString* idList = [historyIDs componentsJoinedByString:@","];
+ NSString* query = [NSString stringWithFormat:@"SELECT \
+ B.Muc, B.muc_type, \
+ CASE \
+ WHEN M.actual_from NOT NULL THEN M.actual_from \
+ WHEN M.inbound=0 THEN (A.username || '@' || A.domain) \
+ ELSE M.buddy_name \
+ END AS af, \
+ timestamp AS thetime, M.* \
+ FROM message_history AS M INNER JOIN buddylist AS B \
+ ON M.account_id=B.account_id AND M.buddy_name=B.buddy_name \
+ INNER JOIN account AS A \
+ ON M.account_id=A.account_id \
+ WHERE M.message_history_id IN(%@);", idList];
+ NSMutableArray* retval = [[NSMutableArray alloc] init];
+ for(NSDictionary* dic in [self.db executeReader:query])
+ {
+ NSMutableDictionary* message = [dic mutableCopy];
+ if(message[@"thetime"])
+ message[@"thetime"] = [dbFormatter dateFromString:message[@"thetime"]];
+ [retval addObject:[MLMessage messageFromDictionary:message]];
+ }
+ return retval;
+ }];
+}
+
+-(MLMessage*) messageForHistoryID:(NSNumber*) historyID
+{
+ if(historyID == nil)
+ return nil;
+ NSArray* result = [self messagesForHistoryIDs:@[historyID]];
+ if(![result count])
+ return nil;
+ return result[0];
+}
+
+-(NSNumber*) getSmallestHistoryId
+{
+ return [self.db idReadTransaction:^{
+ return [self.db executeScalar:@"SELECT MIN(message_history_id) FROM message_history;"];
+ }];
+}
+
+-(NSNumber*) getBiggestHistoryId
+{
+ return [self.db idReadTransaction:^{
+ return [self.db executeScalar:@"SELECT MAX(message_history_id) FROM message_history;"];
+ }];
+}
+
+-(NSNumber*) addMessageToChatBuddy:(NSString*) buddyName withInboundDir:(BOOL) inbound forAccount:(NSNumber*) accountID withBody:(NSString*) message actuallyfrom:(NSString*) actualfrom occupantId:(NSString* _Nullable) occupantId participantJid:(NSString*_Nullable) participantJid sent:(BOOL) sent unread:(BOOL) unread messageId:(NSString*) messageid serverMessageId:(NSString*) stanzaid messageType:(NSString*) messageType andOverrideDate:(NSDate*) messageDate encrypted:(BOOL) encrypted displayMarkerWanted:(BOOL) displayMarkerWanted usingHistoryId:(NSNumber* _Nullable) historyId checkForDuplicates:(BOOL) checkForDuplicates;
+{
+ if(!buddyName || !message)
+ return nil;
+
+ return [self.db idWriteTransaction:^{
+ if(!checkForDuplicates || [self hasMessageForStanzaId:stanzaid orMessageID:messageid withInboundDir:inbound occupantId:occupantId andJid:buddyName onAccount:accountID] == nil)
+ {
+ //this is always from a contact
+ NSDateFormatter* formatter = [NSDateFormatter new];
+ [formatter setDateFormat:@"yyyy-MM-dd HH:mm:ss"];
+ NSDate* sourceDate = [NSDate date];
+ NSDate* destinationDate;
+ if(messageDate)
+ {
+ //already GMT no need for conversion
+ destinationDate = messageDate;
+ [formatter setTimeZone:[NSTimeZone timeZoneForSecondsFromGMT:0]];
+ }
+ else
+ {
+ NSTimeZone* sourceTimeZone = [NSTimeZone systemTimeZone];
+ NSTimeZone* destinationTimeZone = [NSTimeZone timeZoneWithAbbreviation:@"GMT"];
+
+ NSInteger sourceGMTOffset = [sourceTimeZone secondsFromGMTForDate:sourceDate];
+ NSInteger destinationGMTOffset = [destinationTimeZone secondsFromGMTForDate:sourceDate];
+ NSTimeInterval interval = destinationGMTOffset - sourceGMTOffset;
+
+ destinationDate = [[NSDate alloc] initWithTimeInterval:interval sinceDate:sourceDate];
+ }
+ // note: if it isnt the same day we want to show the full day
+ NSString* dateString = [formatter stringFromDate:destinationDate];
+
+ NSString* query;
+ NSArray* params;
+ if(historyId != nil)
+ {
+ DDLogVerbose(@"Inserting backwards with history id %@", historyId);
+ query = @"insert into message_history (message_history_id, account_id, buddy_name, inbound, timestamp, message, actual_from, unread, sent, displayMarkerWanted, messageid, messageType, encrypted, stanzaid, participant_jid, occupant_id) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);";
+ params = @[historyId, accountID, buddyName, [NSNumber numberWithBool:inbound], dateString, message, actualfrom, [NSNumber numberWithBool:unread], [NSNumber numberWithBool:sent], [NSNumber numberWithBool:displayMarkerWanted], messageid?messageid:@"", messageType, [NSNumber numberWithBool:encrypted], stanzaid?stanzaid:@"", nilWrapper(participantJid), nilWrapper(occupantId)];
+ }
+ else
+ {
+ //we use autoincrement here instead of MAX(message_history_id) + 1 to be a little bit faster (but at the cost of "duplicated code")
+ query = @"insert into message_history (account_id, buddy_name, inbound, timestamp, message, actual_from, unread, sent, displayMarkerWanted, messageid, messageType, encrypted, stanzaid, participant_jid, occupant_id) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);";
+ params = @[accountID, buddyName, [NSNumber numberWithBool:inbound], dateString, message, actualfrom, [NSNumber numberWithBool:unread], [NSNumber numberWithBool:sent], [NSNumber numberWithBool:displayMarkerWanted], messageid?messageid:@"", messageType, [NSNumber numberWithBool:encrypted], stanzaid?stanzaid:@"", nilWrapper(participantJid), nilWrapper(occupantId)];
+ }
+ DDLogVerbose(@"%@ params:%@", query, params);
+ BOOL success = [self.db executeNonQuery:query andArguments:params];
+ if(!success)
+ return (NSNumber*)nil;
+ NSNumber* historyId = [self.db lastInsertId];
+ [self updateActiveBuddy:actualfrom setTime:dateString forAccount:accountID];
+ return historyId;
+ }
+ else
+ {
+ DDLogWarn(@"Message(%@) %@ with stanzaid %@ already existing, ignoring history update: %@", accountID, messageid, stanzaid, message);
+ return (NSNumber*)nil;
+ }
+ }];
+}
+
+-(NSNumber* _Nullable) hasMessageForStanzaId:(NSString*) stanzaId orMessageID:(NSString*) messageId withInboundDir:(BOOL) inbound occupantId:(NSString* _Nullable) occupantId andJid:(NSString*) jid onAccount:(NSNumber*) accountID
+{
+ if(accountID == nil)
+ return (NSNumber*)nil;
+
+ return (NSNumber*)[self.db idWriteTransaction:^{
+ //if the stanzaid was given, this is conclusive for dedup, we don't need to check any other ids (EXCEPTION BELOW)
+ if(stanzaId)
+ {
+ DDLogVerbose(@"stanzaid provided");
+ NSArray* found = [self.db executeScalarReader:@"SELECT message_history_id FROM message_history WHERE account_id=? AND buddy_name=? AND stanzaid!='' AND stanzaid=?;" andArguments:@[accountID, jid, stanzaId]];
+ if([found count])
+ {
+ DDLogVerbose(@"stanzaid provided and could be found: %@", found);
+ return found[0];
+ }
+ }
+
+ //EXCEPT: outbound messages coming from this very client (we don't know their stanzaids)
+ //NOTE: the MAM XEP does not mandate full jids in from-attribute of the wrapped message stanza
+ // --> we can't use that to figure out if the message came from this very client or only from another client using this account
+ //=> if the stanzaid does not match and we process an outbound message, only dedup using origin-id (that should be unique and monal sets them)
+ // the check, if an origin-id was given, lives in MLMessageProcessor.m (it only triggers a dedup for messages either having a stanzaid or an origin-id)
+ if(inbound == NO)
+ {
+ NSNumber* historyId = (NSNumber*)[self.db executeScalar:@"SELECT message_history_id FROM message_history WHERE account_id=? AND buddy_name=? AND inbound=0 AND messageid=?;" andArguments:@[accountID, jid, messageId]];
+ if(historyId != nil)
+ {
+ DDLogVerbose(@"found by origin-id or messageid");
+ if(stanzaId!=nil)
+ {
+ DDLogDebug(@"Updating stanzaid of message_history_id %@ to %@ for (account=%@, messageid=%@, inbound=%d)...", historyId, stanzaId, accountID, messageId, inbound);
+ //this entry needs an update of its stanzaid
+ [self.db executeNonQuery:@"UPDATE message_history SET stanzaid=? WHERE message_history_id=?" andArguments:@[stanzaId, historyId]];
+ }
+ if(occupantId!=nil)
+ {
+ DDLogDebug(@"Updating occupant_id of message_history_id %@ to %@ for (account=%@, messageid=%@, inbound=%d)...", historyId, occupantId, accountID, messageId, inbound);
+ //only update occupant id if not set yet
+ [self.db executeNonQuery:@"UPDATE message_history SET occupant_id=? WHERE occupant_id IS NULL AND message_history_id=?" andArguments:@[nilWrapper(occupantId), historyId]];
+ }
+ return historyId;
+ }
+ }
+
+ DDLogVerbose(@"nothing worked --> message not found");
+ return (NSNumber*)nil;
+ }];
+}
+
+-(void) setMessageId:(NSString* _Nonnull) messageid andJid:(NSString*) jid sent:(BOOL) sent
+{
+ [self.db voidWriteTransaction:^{
+ BOOL _sent = sent;
+ //force sent YES if the message was already received
+ if(!_sent)
+ {
+ if([self.db executeScalar:@"SELECT messageid FROM message_history WHERE messageid=? AND buddy_name=? AND received;" andArguments:@[messageid, jid]])
+ _sent = YES;
+ }
+ NSString* query = @"UPDATE message_history SET sent=? WHERE messageid=? AND NOT sent;";
+ DDLogVerbose(@"setting sent %@, %@", messageid, jid);
+ [self.db executeNonQuery:query andArguments:@[[NSNumber numberWithBool:_sent], messageid]];
+ }];
+}
+
+-(void) setMessageId:(NSString* _Nonnull ) messageid andJid:(NSString*) jid received:(BOOL) received
+{
+ [self.db voidWriteTransaction:^{
+ NSString* query = @"UPDATE message_history SET received=?, sent=? WHERE messageid=? AND buddy_name=?;";
+ DDLogVerbose(@"setting received confrmed %@, %@", messageid, jid);
+ [self.db executeNonQuery:query andArguments:@[[NSNumber numberWithBool:received], [NSNumber numberWithBool:YES], messageid, jid]];
+ }];
+}
+
+-(void) setMessageId:(NSString* _Nonnull) messageid andJid:(NSString*) jid errorType:(NSString* _Nonnull) errorType errorReason:(NSString* _Nonnull) errorReason
+{
+ [self.db voidWriteTransaction:^{
+ //ignore error if the message was already received by *some* client
+ if([self.db executeScalar:@"SELECT messageid FROM message_history WHERE messageid=? AND buddy_name=? AND received;" andArguments:@[messageid, jid]])
+ {
+ DDLogVerbose(@"ignoring message error for %@, %@ [%@, %@]", messageid, jid, errorType, errorReason);
+ return;
+ }
+ NSString* query = @"UPDATE message_history SET errorType=?, errorReason=? WHERE messageid=? AND buddy_name=?;";
+ DDLogVerbose(@"setting message error %@, %@ [%@, %@]", messageid, jid, errorType, errorReason);
+ [self.db executeNonQuery:query andArguments:@[errorType, errorReason, messageid, jid]];
+ }];
+}
+
+-(void) clearErrorOfMessageId:(NSString* _Nonnull) messageid
+{
+ [self.db voidWriteTransaction:^{
+ [self.db executeNonQuery:@"UPDATE message_history SET errorType='', errorReason='' WHERE messageid=?;" andArguments:@[messageid]];
+ }];
+}
+
+-(void) setMessageHistoryId:(NSNumber*) historyId filetransferMimeType:(NSString*) mimeType filetransferSize:(NSNumber*) size
+{
+ if(historyId == nil)
+ return;
+ [self.db voidWriteTransaction:^{
+ NSString* query = @"UPDATE message_history SET messageType=?, filetransferMimeType=?, filetransferSize=? WHERE message_history_id=?;";
+ DDLogVerbose(@"setting message type 'kMessageTypeFiletransfer', mime type '%@' and size %@ for history id %@", mimeType, size, historyId);
+ [self.db executeNonQuery:query andArguments:@[kMessageTypeFiletransfer, mimeType, size, historyId]];
+ }];
+}
+
+-(void) setMessageHistoryId:(NSNumber*) historyId messageType:(NSString*) messageType
+{
+ if(historyId == nil)
+ return;
+ [self.db voidWriteTransaction:^{
+ NSString* query = @"UPDATE message_history SET messageType=? WHERE message_history_id=?;";
+ DDLogVerbose(@"setting message type '%@' for history id %@", messageType, historyId);
+ [self.db executeNonQuery:query andArguments:@[messageType, historyId]];
+ }];
+}
+
+-(void) setMessageId:(NSString*) messageid previewText:(NSString*) text andPreviewImage:(NSString*) image
+{
+ if(!messageid)
+ return;
+ [self.db voidWriteTransaction:^{
+ NSString* query = @"UPDATE message_history SET previewText=?, previewImage=? WHERE messageid=?;";
+ DDLogVerbose(@"setting previews type %@", messageid);
+ [self.db executeNonQuery:query andArguments:@[text?text:@"", image?image:@"", messageid]];
+ }];
+}
+
+-(void) setMessageId:(NSString*) messageid stanzaId:(NSString*) stanzaId
+{
+ [self.db voidWriteTransaction:^{
+ NSString* query = @"UPDATE message_history SET stanzaid=? WHERE messageid=?;";
+ DDLogVerbose(@"setting message stanzaid %@", query);
+ [self.db executeNonQuery:query andArguments:@[stanzaId, messageid]];
+ }];
+}
+
+-(void) clearMessages:(NSNumber*) accountID
+{
+ [self.db voidWriteTransaction:^{
+ [self.db executeNonQuery:@"PRAGMA secure_delete=on;"];
+ NSArray* messageHistoryIDs = [self.db executeScalarReader:@"SELECT message_history_id FROM message_history WHERE messageType=? AND account_id=?;" andArguments:@[kMessageTypeFiletransfer, accountID]];
+ for(NSNumber* historyId in messageHistoryIDs)
+ [MLFiletransfer deleteFileForMessage:[self messageForHistoryID:historyId]];
+ [self.db executeNonQuery:@"DELETE FROM message_history WHERE account_id=?;" andArguments:@[accountID]];
+
+ [self.db executeNonQuery:@"DELETE FROM activechats WHERE account_id=?;" andArguments:@[accountID]];
+ [self.db executeNonQuery:@"PRAGMA secure_delete=off;"];
+ }];
+}
+
+-(void) clearMessagesWithBuddy:(NSString*) buddy onAccount:(NSNumber*) accountID
+{
+ [self.db voidWriteTransaction:^{
+ [self.db executeNonQuery:@"PRAGMA secure_delete=on;"];
+ NSArray* messageHistoryIDs = [self.db executeScalarReader:@"SELECT message_history_id FROM message_history WHERE messageType=? AND account_id=? AND buddy_name=?;" andArguments:@[kMessageTypeFiletransfer, accountID, buddy]];
+ for(NSNumber* historyId in messageHistoryIDs)
+ [MLFiletransfer deleteFileForMessage:[self messageForHistoryID:historyId]];
+ [self.db executeNonQuery:@"DELETE FROM message_history WHERE account_id=? AND buddy_name=?;" andArguments:@[accountID, buddy]];
+
+ //better UX without deleting the active chat
+ //[self.db executeNonQuery:@"DELETE FROM activechats WHERE account_id=? AND buddy_name=?;" andArguments:@[accountID, buddy]];
+ [self.db executeNonQuery:@"PRAGMA secure_delete=off;"];
+ }];
+}
+
+-(NSNumber*) autoDeleteMessagesAfterInterval:(NSTimeInterval) interval
+{
+ return [self.db idWriteTransaction:^{
+ [self.db executeNonQuery:@"PRAGMA secure_delete=on;"];
+ //interval before now
+ NSDate* pastDate = [NSDate dateWithTimeIntervalSinceNow: -interval];
+ NSString* pastDateString = [dbFormatter stringFromDate:pastDate];
+
+ //select message history IDs of inbound read messages or outgoing messages being old enough
+ //if they are filetransfers and delete those files
+ NSArray* messageHistoryIDs = [self.db executeScalarReader:@"SELECT message_history_id FROM message_history WHERE (inbound=0 OR unread=0) AND timestamp AND messageType=?;" andArguments:@[pastDateString, kMessageTypeFiletransfer]];
+ for(NSNumber* historyId in messageHistoryIDs)
+ [MLFiletransfer deleteFileForMessage:[self messageForHistoryID:historyId]];
+
+ //delete inbound read messages or outgoing messages being old enough
+ NSNumber* deletionCount = [self.db executeScalar:@"SELECT COUNT(*) FROM message_history WHERE (inbound=0 OR unread=0) AND timestamp;" andArguments:@[pastDateString]];
+ [self.db executeNonQuery:@"DELETE FROM message_history WHERE (inbound=0 OR unread=0) AND timestamp;" andArguments:@[pastDateString]];
+
+ //delete all chats with empty history from active chats list
+ [self.db executeNonQuery:@"DELETE FROM activechats AS AC WHERE NOT EXISTS (SELECT 1 FROM message_history AS MH WHERE MH.account_id=AC.account_id AND MH.buddy_name=AC.buddy_name);"];
+
+ [self.db executeNonQuery:@"PRAGMA secure_delete=off;"];
+
+ return deletionCount;
+ }];
+}
+
+-(void) retractMessageHistory:(NSNumber*) messageNo
+{
+ [self.db voidWriteTransaction:^{
+ [self.db executeNonQuery:@"PRAGMA secure_delete=on;"];
+ MLMessage* msg = [self messageForHistoryID:messageNo];
+ if([msg.messageType isEqualToString:kMessageTypeFiletransfer])
+ [MLFiletransfer deleteFileForMessage:msg];
+ [self.db executeNonQuery:@"UPDATE message_history SET message='', messageType=?, filetransferMimeType='', filetransferSize=0, retracted=1 WHERE message_history_id=?;" andArguments:@[kMessageTypeText, messageNo]];
+ [self.db executeNonQuery:@"PRAGMA secure_delete=off;"];
+ }];
+}
+
+-(void) deleteMessageHistoryLocally:(NSNumber*) messageNo
+{
+ [self.db voidWriteTransaction:^{
+ [self.db executeNonQuery:@"PRAGMA secure_delete=on;"];
+ MLMessage* msg = [self messageForHistoryID:messageNo];
+ if([msg.messageType isEqualToString:kMessageTypeFiletransfer])
+ [MLFiletransfer deleteFileForMessage:msg];
+ [self.db executeNonQuery:@"DELETE FROM message_history WHERE message_history_id=?;" andArguments:@[messageNo]];
+ [self.db executeNonQuery:@"PRAGMA secure_delete=off;"];
+ }];
+}
+
+-(void) updateMessageHistory:(NSNumber*) messageNo withText:(NSString*) newText
+{
+ [self.db voidWriteTransaction:^{
+ [self.db executeNonQuery:@"UPDATE message_history SET message=? WHERE message_history_id=?;" andArguments:@[newText, messageNo]];
+ }];
+}
+
+-(NSNumber* _Nullable) getLMCHistoryIDForMessageId:(NSString*) messageid from:(NSString*) from occupantId:(NSString* _Nullable) occupantId participantJid:(NSString* _Nullable) participantJid andAccount:(NSNumber*) accountID
+{
+ return [self.db idReadTransaction:^{
+ return [self.db executeScalar:@"SELECT M.message_history_id FROM message_history AS M INNER JOIN account AS A ON M.account_id=A.account_id INNER JOIN buddylist AS B on M.buddy_name = B.buddy_name AND M.account_id = B.account_id WHERE messageid=? AND M.account_id=? AND (\
+ (B.Muc=0 AND ((M.buddy_name=? AND M.inbound=1) OR ((A.username || '@' || A.domain)=? AND M.inbound=0))) OR \
+ (B.Muc=1 AND M.buddy_name=? AND (\
+ (M.occupant_id=? AND M.occupant_id IS NOT NULL) OR \
+ (M.participant_jid=? AND M.participant_jid IS NOT NULL) \
+ ) \
+ ) \
+ );" andArguments:@[messageid, accountID, from, from, from, nilWrapper(occupantId), nilWrapper(participantJid)]];
+ }];
+}
+
+-(NSNumber* _Nullable) getRetractionHistoryIDForMessageId:(NSString*) messageid from:(NSString*) from participantJid:(NSString* _Nullable) participantJid occupantId:(NSString* _Nullable) occupantId andAccount:(NSNumber*) accountID
+{
+ return [self.db idReadTransaction:^{
+ return [self.db executeScalar:@"SELECT M.message_history_id FROM message_history AS M INNER JOIN account AS A ON M.account_id=A.account_id INNER JOIN buddylist AS B on M.buddy_name = B.buddy_name AND M.account_id = B.account_id WHERE M.account_id=? AND ( \
+ (B.Muc=0 AND M.messageid=? AND ((M.buddy_name=? AND M.inbound=1) OR ((A.username || '@' || A.domain)=? AND M.inbound=0))) OR \
+ (B.Muc=1 AND M.stanzaid=? AND M.buddy_name=? AND ( \
+ (M.participant_jid=? AND M.participant_jid IS NOT NULL) OR (M.occupant_id=? AND M.occupant_id IS NOT NULL)) \
+ ) \
+ );" andArguments:@[accountID, messageid, from, from, messageid, from, nilWrapper(participantJid), nilWrapper(occupantId)]];
+ }];
+}
+
+-(NSNumber* _Nullable) getRetractionHistoryIDForModeratedStanzaId:(NSString*) stanzaId from:(NSString*) from andAccount:(NSNumber*) accountID
+{
+ return [self.db idReadTransaction:^{
+ return [self.db executeScalar:@"SELECT M.message_history_id FROM message_history AS M INNER JOIN account AS A ON M.account_id=A.account_id INNER JOIN buddylist AS B on M.buddy_name = B.buddy_name AND M.account_id = B.account_id \
+ WHERE M.account_id=? AND B.Muc=1 AND M.stanzaid=? AND M.buddy_name=?;"
+ andArguments:@[accountID, stanzaId, from]];
+ }];
+}
+
+-(NSDate* _Nullable) returnTimestampForQuote:(NSNumber*) historyID
+{
+ return [self.db idReadTransaction:^{
+ MLMessage* msg = [self messageForHistoryID:historyID];
+
+ //timestamp not needed if we can't find the message we are quoting
+ if(msg == nil)
+ return (NSDate*)nil;
+
+ //check if message is among the newest 8 exchanged with this buddy
+ NSNumber* isRecentEnough = (NSNumber*)[self.db executeScalar:@"\
+ SELECT COUNT(message_history_id) \
+ FROM \
+ (SELECT message_history_id FROM message_history WHERE account_id=? AND buddy_name=? ORDER BY message_history_id DESC LIMIT 8) \
+ WHERE \
+ message_history_id=?; \
+ " andArguments:@[msg.accountID, msg.buddyName, historyID]];
+
+ if(isRecentEnough.intValue == 1)
+ return (NSDate*)nil;
+ //messages not among the newest 8, but received in the last 15 minutes don't need a timestamp either
+ if([[NSDate date] timeIntervalSinceDate:msg.timestamp] < 900)
+ return (NSDate*)nil;
+ return msg.timestamp;
+ }];
+}
+
+-(BOOL) checkLMCEligible:(NSNumber*) historyID encrypted:(BOOL) encrypted historyBaseID:(NSNumber* _Nullable) historyBaseID
+{
+ return [self.db boolReadTransaction:^{
+ MLMessage* msg = [self messageForHistoryID:historyID];
+ NSNumber* editAllowed;
+
+ //corretion not allowed if we can't find the message the correction was for
+ if(msg == nil)
+ return NO;
+
+ //only allow LMC if the correction message has the same encryption or better state as the original message
+ if(historyBaseID != nil)
+ {
+ //only allow LMC for the 3 newest messages of this contact (or of us)
+ editAllowed = (NSNumber*)[self.db executeScalar:@"\
+ SELECT \
+ CASE \
+ WHEN (encrypted=? OR 1=?) THEN 1 \
+ ELSE 0 \
+ END \
+ FROM \
+ (SELECT message_history_id, inbound, encrypted, messageType FROM message_history WHERE account_id=? AND buddy_name=? AND message_history_id ORDER BY message_history_id ASC) \
+ WHERE \
+ message_history_id=? LIMIT 1; \
+ " andArguments:@[@(encrypted), @(encrypted), msg.accountID, msg.buddyName, historyBaseID, historyID]];
+ }
+ else
+ {
+ //only allow LMC if the correction message has the same encryption or better state as the original message
+ editAllowed = (NSNumber*)[self.db executeScalar:@"\
+ SELECT \
+ CASE \
+ WHEN (encrypted=? OR 1=?) THEN 1 \
+ ELSE 0 \
+ END \
+ FROM \
+ (SELECT message_history_id, inbound, encrypted, messageType FROM message_history WHERE account_id=? AND buddy_name=? ORDER BY message_history_id DESC) \
+ WHERE \
+ message_history_id=? LIMIT 1; \
+ " andArguments:@[@(encrypted), @(encrypted), msg.accountID, msg.buddyName, historyID]];
+ }
+ BOOL eligible = YES;
+ eligible &= editAllowed.intValue == 1;
+ eligible &= [msg.messageType isEqualToString:kMessageTypeText];
+ return eligible;
+ }];
+}
+
+//message history
+-(NSNumber*) lastMessageHistoryIdForContact:(NSString*) buddy forAccount:(NSNumber*) accountID
+{
+ return [self.db idReadTransaction:^{
+ return [self.db executeScalar:@"SELECT message_history_id FROM message_history WHERE account_id=? AND buddy_name=? ORDER BY message_history_id DESC LIMIT 1" andArguments:@[accountID, buddy]];
+ }];
+}
+
+//message history
+-(NSMutableArray*) messagesForContact:(NSString*) buddy forAccount:(NSNumber*) accountID
+{
+ if(accountID == nil || !buddy)
+ return nil;
+ return [self.db idReadTransaction:^{
+ NSNumber* lastMsgHistID = [self lastMessageHistoryIdForContact:buddy forAccount:accountID];
+ // Increment msgHistId -> all messages <= msgHistId are feteched
+ lastMsgHistID = [NSNumber numberWithInt:[lastMsgHistID intValue] + 1];
+ return [self messagesForContact:buddy forAccount:accountID beforeMsgHistoryID:lastMsgHistID];
+ }];
+}
+
+//message history
+-(NSMutableArray*) messagesForContact:(NSString*) buddy forAccount:(NSNumber*) accountID beforeMsgHistoryID:(NSNumber* _Nullable) msgHistoryID
+{
+ if(accountID == nil || !buddy)
+ return nil;
+ return [self.db idReadTransaction:^{
+ NSNumber* historyIdToUse = msgHistoryID;
+ //fall back to newest message in history (including this message in this case)
+ if(historyIdToUse == nil)
+ {
+ //we are querying with < relation below, but want to include the newest message nontheless
+ historyIdToUse = @([[self lastMessageHistoryIdForContact:buddy forAccount:accountID] intValue] + 1);
+ }
+ NSString* query = @"SELECT message_history_id FROM (SELECT message_history_id FROM message_history WHERE account_id=? AND buddy_name=? AND message_history_id ORDER BY message_history_id DESC LIMIT ?) ORDER BY message_history_id ASC;";
+ NSNumber* msgLimit = @(kMonalBackscrollingMsgCount);
+ NSArray* params = @[accountID, buddy, historyIdToUse, msgLimit];
+ NSArray* results = [self.db executeScalarReader:query andArguments:params];
+ return [self messagesForHistoryIDs:results];
+ }];
+}
+
+-(MLMessage*) lastMessageForContact:(NSString*) contact forAccount:(NSNumber*) accountID
+{
+ if(accountID == nil || !contact)
+ return nil;
+
+ return [self.db idReadTransaction:^{
+ //return message draft (if any)
+ NSString* query = @"SELECT bl.messageDraft AS message, ac.lastMessageTime AS thetime, 'MessageDraft' AS messageType, '' AS af, '' AS filetransferMimeType, 0 AS filetransferSize, bl.Muc, bl.muc_type, bl.buddy_name FROM buddylist AS bl INNER JOIN activechats AS ac ON bl.account_id = ac.account_id AND bl.buddy_name = ac.buddy_name WHERE ac.account_id=? AND ac.buddy_name=? AND messageDraft IS NOT NULL AND messageDraft != '';";
+ NSArray* params = @[accountID, contact];
+ NSArray* results = [self.db executeReader:query andArguments:params];
+ if([results count])
+ {
+ NSMutableDictionary* message = [(NSDictionary*)results[0] mutableCopy];
+ if(message[@"thetime"])
+ message[@"thetime"] = [dbFormatter dateFromString:message[@"thetime"]];
+ return [MLMessage messageFromDictionary:message];
+ }
+
+ //return "real" last message
+ NSNumber* historyID = [self.db executeScalar:@"SELECT message_history_id FROM message_history WHERE account_id=? AND buddy_name=? ORDER BY message_history_id DESC LIMIT 1;" andArguments:@[accountID, contact]];
+ if(historyID == nil)
+ return (MLMessage*)nil;
+ return [self messageForHistoryID:historyID];
+ }];
+}
+
+-(NSArray*) markMessagesAsReadForBuddy:(NSString*) buddy andAccount:(NSNumber*) accountID tillStanzaId:(NSString*) stanzaid wasOutgoing:(BOOL) outgoing
+{
+ if(!buddy || accountID == nil)
+ {
+ DDLogError(@"No buddy or accountID specified!");
+ return @[];
+ }
+
+ return (NSArray*)[self.db idWriteTransaction:^{
+ NSNumber* historyId;
+
+ if(stanzaid) //stanzaid or messageid given --> return all unread / not displayed messages until (and including) this one
+ {
+ historyId = [self.db executeScalar:@"SELECT message_history_id FROM message_history WHERE account_id=? AND stanzaid!='' AND stanzaid=? ORDER BY message_history_id DESC LIMIT 1;" andArguments:@[accountID, stanzaid]];
+
+ //if stanzaid could not be found we've got a messageid instead
+ if(historyId == nil)
+ {
+ DDLogVerbose(@"Stanzaid not found, trying messageid");
+ historyId = [self.db executeScalar:@"SELECT message_history_id FROM message_history WHERE account_id=? AND messageid=? ORDER BY message_history_id DESC LIMIT 1;" andArguments:@[accountID, stanzaid]];
+ }
+ //messageid still not found?
+ if(historyId == nil)
+ {
+ DDLogWarn(@"Could not get message_history_id for stanzaid/messageid %@", stanzaid);
+ return @[]; //no messages with this stanzaid / messageid could be found
+ }
+ }
+ else //no stanzaid given --> return all unread / not displayed messages for this contact
+ {
+ DDLogDebug(@"Returning newest historyId (no stanzaid/messageid given)");
+ historyId = [self.db executeScalar:@"SELECT message_history_id FROM message_history WHERE account_id=? AND buddy_name=? ORDER BY message_history_id DESC LIMIT 1;" andArguments:@[accountID, buddy]];
+
+ if(historyId == nil)
+ {
+ DDLogWarn(@"Could not get newest message_history_id (history empty)");
+ return @[]; //no messages with this stanzaid / messageid could be found
+ }
+ }
+
+ //on outgoing messages we only allow displayed=true for markable messages that have been received properly by the other end
+ //marking messages as displayed that have not been received (or marking messages that are not markable) would create false UI
+ NSArray* messageArray;
+ if(outgoing)
+ messageArray = [self.db executeScalarReader:@"SELECT message_history_id FROM message_history WHERE displayed=0 AND displayMarkerWanted=1 AND received=1 AND account_id=? AND buddy_name=? AND inbound=0 AND message_history_id<=? ORDER BY message_history_id ASC;" andArguments:@[accountID, buddy, historyId]];
+ else
+ messageArray = [self.db executeScalarReader:@"SELECT message_history_id FROM message_history WHERE unread=1 AND account_id=? AND buddy_name=? AND inbound=1 AND message_history_id<=? ORDER BY message_history_id ASC;" andArguments:@[accountID, buddy, historyId]];
+
+ DDLogVerbose(@"[%@:%@] messageArray=%@", outgoing ? @"OUT" : @"IN", historyId, messageArray);
+
+ //mark messages as read/displayed
+ for(NSNumber* historyIDEntry in messageArray)
+ {
+ if(outgoing)
+ [self.db executeNonQuery:@"UPDATE message_history SET displayed=1 WHERE message_history_id=? AND received=1;" andArguments:@[historyIDEntry]];
+ else
+ {
+ [self.db executeNonQuery:@"UPDATE message_history SET unread=0 WHERE message_history_id=?;" andArguments:@[historyIDEntry]];
+ //make sure the latest_read_message_history_id field in our buddylist is updated
+ [self.db executeNonQuery:@"UPDATE buddylist SET latest_read_message_history_id=? WHERE account_id=? AND buddy_name=?;" andArguments:@[historyIDEntry, accountID, buddy]];
+ }
+ }
+
+ //return NSArray of all updated MLMessages
+ return (NSArray*)[self messagesForHistoryIDs:messageArray];
+ }];
+}
+
+-(NSNumber*) addMessageHistoryTo:(NSString*) to forAccount:(NSNumber*) accountID withMessage:(NSString*) message actuallyFrom:(NSString*) actualfrom withId:(NSString*) messageId encrypted:(BOOL) encrypted messageType:(NSString*) messageType mimeType:(NSString*) mimeType size:(NSNumber*) size
+{
+ //Message_history going out, from is always the local user. always read and not sent
+ NSArray* parts = [[[NSDate date] description] componentsSeparatedByString:@" "];
+ NSString* dateTime = [NSString stringWithFormat:@"%@ %@", [parts objectAtIndex:0], [parts objectAtIndex:1]];
+ if(mimeType && size != nil)
+ size = @(0);
+ NSString* query;
+ NSArray* params;
+ if(mimeType && size)
+ {
+ query = @"INSERT INTO message_history (account_id, buddy_name, inbound, timestamp, message, actual_from, unread, sent, messageid, messageType, encrypted, displayMarkerWanted, filetransferMimeType, filetransferSize) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?);";
+ params = @[accountID, to, [NSNumber numberWithBool:NO], dateTime, message, actualfrom, [NSNumber numberWithBool:NO], [NSNumber numberWithBool:NO], messageId, messageType, [NSNumber numberWithBool:encrypted], [NSNumber numberWithBool:YES], mimeType, size];
+ }
+ else
+ {
+ query = @"INSERT INTO message_history (account_id, buddy_name, inbound, timestamp, message, actual_from, unread, sent, messageid, messageType, encrypted, displayMarkerWanted) VALUES(?,?,?,?,?,?,?,?,?,?,?,?);";
+ params = @[accountID, to, [NSNumber numberWithBool:NO], dateTime, message, actualfrom, [NSNumber numberWithBool:NO], [NSNumber numberWithBool:NO], messageId, messageType, [NSNumber numberWithBool:encrypted], [NSNumber numberWithBool:YES]];
+ }
+
+ return [self.db idWriteTransaction:^{
+ DDLogVerbose(@"%@", query);
+ BOOL result = [self.db executeNonQuery:query andArguments:params];
+ if(!result)
+ return (NSNumber*)nil;
+ NSNumber* historyId = [self.db lastInsertId];
+ [self updateActiveBuddy:to setTime:dateTime forAccount:accountID];
+ return historyId;
+ }];
+}
+
+//count unread
+-(NSNumber*) countUnreadMessages
+{
+ return [self.db idReadTransaction:^{
+ // count # of unread msgs in message table and ignore muted buddies and mentionOnly buddies without mention
+ return [self.db executeScalar:@"SELECT Count(M.message_history_id) \
+ FROM message_history AS M \
+ LEFT JOIN buddylist AS B \
+ ON M.account_id = B.account_id \
+ AND M.buddy_name = B.buddy_name \
+ LEFT JOIN account AS A \
+ ON M.account_id = A.account_id \
+ WHERE M.message_history_id > (SELECT Min(latest_read_message_history_id) FROM buddylist) \
+ AND A.enabled \
+ AND B.muted = 0 \
+ AND M.inbound = 1 \
+ AND M.unread = 1 \
+ AND ( \
+ B.mentionOnly = 0 OR ( \
+ (B.muc_nick != '' AND M.message LIKE '%'||B.muc_nick||'%') \
+ OR (A.rosterName != '' AND M.message LIKE '%'||A.rosterName||'%') \
+ OR (A.username != '' AND M.message LIKE '%'||A.username||'%') \
+ OR (A.username != '' AND A.domain != '' AND M.message LIKE '%'||A.username||'@'||A.domain||'%') \
+ ) \
+ ) \
+ ;"];
+ }];
+}
+
+-(NSString*) lastStanzaIdForAccount:(NSNumber*) accountID
+{
+ return [self.db idReadTransaction:^{
+ return [self.db executeScalar:@"SELECT lastStanzaId FROM account WHERE account_id=?;" andArguments:@[accountID]];
+ }];
+}
+
+-(void) setLastStanzaId:(NSString*) lastStanzaId forAccount:(NSNumber*) accountID
+{
+ [self.db voidWriteTransaction:^{
+ [self.db executeNonQuery:@"UPDATE account SET lastStanzaId=? WHERE account_id=?;" andArguments:@[lastStanzaId, accountID]];
+ }];
+}
+
+#pragma mark active chats
+
+-(NSMutableArray*) activeContactsWithPinned:(BOOL) pinned
+{
+ return [self.db idReadTransaction:^{
+ NSString* query = @"SELECT a.buddy_name, a.account_id FROM activechats AS a JOIN buddylist AS b ON (a.buddy_name = b.buddy_name AND a.account_id = b.account_id) JOIN account ON a.account_id = account.account_id WHERE a.pinned=? AND account.enabled ORDER BY lastMessageTime DESC;";
+ NSMutableArray* toReturn = [[NSMutableArray alloc] init];
+ for(NSDictionary* dic in [self.db executeReader:query andArguments:@[[NSNumber numberWithBool:pinned]]])
+ [toReturn addObject:[MLContact createContactFromJid:dic[@"buddy_name"] andAccountID:dic[@"account_id"]]];
+ return toReturn;
+ }];
+}
+
+-(NSArray*) activeContactDict
+{
+ return [self.db idReadTransaction:^{
+ NSMutableArray* mergedContacts = [self activeContactsWithPinned:YES];
+ [mergedContacts addObjectsFromArray:[self activeContactsWithPinned:NO]];
+ return mergedContacts;
+ }];
+}
+
+-(void) removeActiveBuddy:(NSString*) buddyname forAccount:(NSNumber*) accountID
+{
+ [self.db voidWriteTransaction:^{
+ //mark all messages as read
+ [self.db executeNonQuery:@"UPDATE message_history SET unread=0 WHERE account_id=? AND buddy_name=?;" andArguments:@[accountID, buddyname]];
+ //make sure the latest_read_message_history_id field in our buddylist is updated
+ //(we use the newest history entry for this buddyname here)
+ [self.db executeNonQuery:@"UPDATE buddylist SET latest_read_message_history_id=COALESCE((\
+ SELECT message_history_id FROM message_history WHERE account_id=? AND buddy_name=? AND inbound=1 ORDER BY message_history_id DESC LIMIT 1\
+ ), (\
+ SELECT message_history_id FROM message_history ORDER BY message_history_id DESC LIMIT 1\
+ ), 0) WHERE account_id=? AND buddy_name=?;" andArguments:@[accountID, buddyname, accountID, buddyname]];
+ //remove contact from active chats list
+ [self.db executeNonQuery:@"DELETE FROM activechats WHERE account_id=? AND buddy_name=?;" andArguments:@[accountID, buddyname]];
+ }];
+}
+
+-(void) addActiveBuddies:(NSString*) buddyname forAccount:(NSNumber*) accountID
+{
+ if(!buddyname || accountID == nil)
+ return;
+
+ [self.db voidWriteTransaction:^{
+ //add contact if possible (ignore already existing contacts)
+ [self addContact:buddyname forAccount:accountID nickname:nil];
+
+ // insert or update active chat
+ NSString* query = @"INSERT INTO activechats (buddy_name, account_id, lastMessageTime) VALUES(?, ?, current_timestamp) ON CONFLICT(buddy_name, account_id) DO UPDATE SET lastMessageTime=current_timestamp;";
+ [self.db executeNonQuery:query andArguments:@[buddyname, accountID]];
+ }];
+ return;
+}
+
+
+-(BOOL) isActiveBuddy:(NSString*) buddyname forAccount:(NSNumber*) accountID
+{
+ return [self.db boolReadTransaction:^{
+ NSString* query = @"SELECT COUNT(buddy_name) FROM activechats WHERE account_id=? AND buddy_name=?;";
+ NSNumber* count = (NSNumber*)[self.db executeScalar:query andArguments:@[accountID, buddyname]];
+ if(count != nil)
+ {
+ NSInteger val = [((NSNumber*)count) integerValue];
+ return (BOOL)(val > 0);
+ }
+ else
+ return NO;
+ }];
+}
+
+-(BOOL) updateActiveBuddy:(NSString*) buddyname setTime:(NSString*) timestamp forAccount:(NSNumber*) accountID
+{
+ return [self.db boolWriteTransaction:^{
+ NSString* query = @"SELECT lastMessageTime FROM activechats WHERE account_id=? AND buddy_name=?;";
+ NSObject* result = [self.db executeScalar:query andArguments:@[accountID, buddyname]];
+ NSString* lastTime = (NSString *) result;
+
+ NSDate* lastDate = [dbFormatter dateFromString:lastTime];
+ NSDate* newDate = [dbFormatter dateFromString:timestamp];
+
+ if(lastDate.timeIntervalSince1970 < newDate.timeIntervalSince1970)
+ {
+ NSString* query = @"UPDATE activechats SET lastMessageTime=? WHERE account_id=? AND buddy_name=?;";
+ BOOL success = [self.db executeNonQuery:query andArguments:@[timestamp, accountID, buddyname]];
+ return success;
+ }
+ else
+ return NO;
+ }];
+}
+
+#pragma mark chat properties
+
+-(NSNumber*) countUserUnreadMessages:(NSString*) buddy forAccount:(NSNumber*) accountID
+{
+ if(!buddy || accountID == nil)
+ return @0;
+ return [self.db idReadTransaction:^{
+ // count # messages from a specific user in messages table
+ return [self.db executeScalar:@"SELECT COALESCE(COUNT(message_history_id),0) FROM message_history AS h WHERE h.message_history_id > (SELECT COALESCE(latest_read_message_history_id, 0) FROM buddylist WHERE account_id=? AND buddy_name=?) AND h.unread=1 AND h.account_id=? AND h.buddy_name=? AND h.inbound=1;" andArguments:@[accountID, buddy, accountID, buddy]];
+ }];
+}
+
+-(void) invalidateAllAccountStates
+{
+#ifndef IS_ALPHA
+ @try {
+#endif
+ DDLogWarn(@"Invalidating state of all accounts...");
+ [self.db voidWriteTransaction:^{
+ for(NSDictionary* entry in [self.db executeReader:@"SELECT account_id FROM account;"])
+ [self persistState:[xmpp invalidateState:[self readStateForAccount:entry[@"account_id"]]] forAccount:entry[@"account_id"]];
+ }];
+#ifndef IS_ALPHA
+ } @catch (NSException* exception) {
+ DDLogError(@"caught invalidate state exception: %@", exception);
+ }
+#endif
+}
+
+-(NSString*) lastUsedPushServerForAccount:(NSNumber*) accountID
+{
+ return [self.db idReadTransaction:^{
+ return [self.db executeScalar:@"SELECT registeredPushServer FROM account WHERE account_id=?;" andArguments:@[accountID]];
+ }];
+}
+
+-(void) updateUsedPushServer:(NSString*) pushServer forAccount:(NSNumber*) accountID
+{
+ [self.db voidWriteTransaction:^{
+ [self.db executeScalarReader:@"UPDATE account SET registeredPushServer=? WHERE account_id=?;" andArguments:@[pushServer, accountID]];
+ }];
+}
+
+-(void) deleteDelayedMessageStanzasForAccount:(NSNumber*) accountID
+{
+ [self.db voidWriteTransaction:^{
+ [self.db executeNonQuery:@"DELETE FROM delayed_message_stanzas WHERE account_id=?;" andArguments:@[accountID]];
+ }];
+}
+
+-(void) addDelayedMessageStanza:(MLXMLNode*) stanza forArchiveJid:(NSString*) archiveJid andAccountID:(NSNumber*) accountID
+{
+ if(accountID == nil || !archiveJid || !stanza)
+ return;
+ NSError* error;
+ NSData* data = [NSKeyedArchiver archivedDataWithRootObject:stanza requiringSecureCoding:YES error:&error];
+ if(error)
+ @throw [NSException exceptionWithName:@"NSError" reason:[NSString stringWithFormat:@"%@", error] userInfo:@{@"error": error}];
+ [self.db voidWriteTransaction:^{
+ [self.db executeNonQuery:@"INSERT INTO delayed_message_stanzas (account_id, archive_jid, stanza) VALUES(?, ?, ?);" andArguments:@[accountID, archiveJid, data]];
+ }];
+}
+
+-(MLXMLNode* _Nullable) getNextDelayedMessageStanzaForArchiveJid:(NSString*) archiveJid andAccountID:(NSNumber*) accountID
+{
+ if(accountID == nil|| !archiveJid)
+ return nil;
+ NSData* data = (NSData*)[self.db idWriteTransaction:^{
+ NSArray* entries = [self.db executeReader:@"SELECT id, stanza FROM delayed_message_stanzas WHERE account_id=? AND archive_jid=? ORDER BY id ASC LIMIT 1;" andArguments:@[accountID, archiveJid]];
+ if(![entries count])
+ return (NSData*)nil;
+ [self.db executeNonQuery:@"DELETE FROM delayed_message_stanzas WHERE id=?;" andArguments:@[entries[0][@"id"]]];
+ return (NSData*)entries[0][@"stanza"];
+ }];
+ if(data)
+ {
+ NSError* error;
+ MLXMLNode* stanza = (MLXMLNode*)[NSKeyedUnarchiver unarchivedObjectOfClasses:[[NSSet alloc] initWithArray:@[
+ [NSData class],
+ [NSMutableData class],
+ [NSMutableDictionary class],
+ [NSDictionary class],
+ [NSMutableSet class],
+ [NSSet class],
+ [NSMutableArray class],
+ [NSArray class],
+ [NSNumber class],
+ [NSString class],
+ [NSDate class],
+ [MLXMLNode class],
+ [XMPPIQ class],
+ [XMPPPresence class],
+ [XMPPMessage class],
+ [XMPPDataForm class],
+ ]] fromData:data error:&error];
+ if(error)
+ {
+#ifdef IS_ALPHA
+ @throw [NSException exceptionWithName:@"NSError" reason:[NSString stringWithFormat:@"%@", error] userInfo:@{@"error": error}];
+#else
+ DDLogError(@"Error: %@", error);
+ return nil;
+#endif
+ }
+ return stanza;
+ }
+ return nil;
+}
+
+-(void) addShareSheetPayload:(NSDictionary*) payload
+{
+ //make sure we don't insert empty data
+ if(payload[@"type"] == nil || payload[@"data"] == nil)
+ return;
+ [self.db voidWriteTransaction:^{
+ [self.db executeNonQuery:@"INSERT INTO sharesheet_outbox (account_id, recipient, type, data) VALUES(?, ?, ?, ?);" andArguments:@[
+ payload[@"account_id"],
+ payload[@"recipient"],
+ payload[@"type"],
+ [HelperTools serializeObject:payload[@"data"]],
+ ]];
+ }];
+}
+
+-(NSArray*) getShareSheetPayload
+{
+ return [self.db idWriteTransaction:^{
+ NSArray* payloadList = [self.db executeReader:@"SELECT * FROM sharesheet_outbox ORDER BY id ASC;"];
+ NSMutableArray* retval = [NSMutableArray new];
+ for(NSDictionary* entry_ in payloadList)
+ {
+ NSMutableDictionary* entry = [[NSMutableDictionary alloc] initWithDictionary:entry_];
+ if(entry[@"data"])
+ entry[@"data"] = [HelperTools unserializeData:entry[@"data"]];
+ [retval addObject:entry];
+ }
+ return (NSArray*)retval;
+ }];
+}
+
+-(void) deleteShareSheetPayloadWithId:(NSNumber*) payloadId
+{
+ [self.db voidWriteTransaction:^{
+ [self.db executeNonQuery:@"DELETE FROM sharesheet_outbox WHERE id=?;" andArguments:@[payloadId]];
+ }];
+}
+
+#pragma mark mute and block
+
+-(void) muteContact:(MLContact*) contact
+{
+ if(!contact)
+ {
+ unreachable();
+ return;
+ }
+ [self.db voidWriteTransaction:^{
+ [self.db executeNonQuery:@"UPDATE buddylist SET muted=1 WHERE account_id=? AND buddy_name=?;" andArguments:@[contact.accountID, contact.contactJid]];
+ }];
+}
+
+-(void) unMuteContact:(MLContact*) contact
+{
+ if(!contact)
+ {
+ unreachable();
+ return;
+ }
+ [self.db voidWriteTransaction:^{
+ [self.db executeNonQuery:@"UPDATE buddylist SET muted=0 WHERE account_id=? AND buddy_name=?;" andArguments:@[contact.accountID, contact.contactJid]];
+ }];
+}
+
+-(BOOL) isMutedJid:(NSString*) jid onAccount:(NSNumber*) accountID
+{
+ if(!jid || accountID == nil)
+ {
+ unreachable();
+ return NO;
+ }
+ return [self.db boolReadTransaction:^{
+ NSNumber* count = (NSNumber*)[self.db executeScalar:@"SELECT COUNT(buddy_name) FROM buddylist WHERE account_id=? AND buddy_name=? AND muted=1;" andArguments: @[accountID, jid]];
+ return count.boolValue;
+ }];
+}
+
+-(void) setMucAlertOnMentionOnly:(NSString*) jid onAccount:(NSNumber*) accountID
+{
+ if(!jid || accountID == nil)
+ {
+ unreachable();
+ return;
+ }
+ [self.db voidWriteTransaction:^{
+ [self.db executeNonQuery:@"UPDATE buddylist SET mentionOnly=1 WHERE account_id=? AND buddy_name=? AND muc=1;" andArguments:@[accountID, jid]];
+ }];
+}
+
+-(void) setMucAlertOnAll:(NSString*) jid onAccount:(NSNumber*) accountID
+{
+ if(!jid || accountID == nil)
+ {
+ unreachable();
+ return;
+ }
+ [self.db voidWriteTransaction:^{
+ [self.db executeNonQuery:@"UPDATE buddylist SET mentionOnly=0 WHERE account_id=? AND buddy_name=? AND muc=1;" andArguments:@[accountID, jid]];
+ }];
+}
+
+-(BOOL) isMucAlertOnMentionOnly:(NSString*) jid onAccount:(NSNumber*) accountID
+{
+ if(!jid || accountID == nil)
+ {
+ unreachable();
+ return NO;
+ }
+ return [self.db boolReadTransaction:^{
+ NSNumber* count = (NSNumber*)[self.db executeScalar:@"SELECT COUNT(buddy_name) FROM buddylist WHERE account_id=? AND buddy_name=? AND mentionOnly=1 AND muc=1;" andArguments: @[accountID, jid]];
+ return count.boolValue;
+ }];
+}
+
+-(void) blockJid:(NSString*) jid withAccountID:(NSNumber*) accountID
+{
+ if(!jid || accountID == nil)
+ return;
+
+ [self.db voidWriteTransaction:^{
+ [self.db executeNonQuery:@"INSERT OR IGNORE INTO blocklistCache(account_id, blocked_jid) VALUES(?, ?);" andArguments:@[accountID, jid]];
+ }];
+}
+
+-(void) updateLocalBlocklistCache:(NSSet*) blockedJids forAccountID:(NSNumber*) accountID
+{
+ [self.db voidWriteTransaction:^{
+ // remove blocked state for all buddies of account
+ [self.db executeNonQuery:@"DELETE FROM blocklistCache WHERE account_id=?;" andArguments:@[accountID]];
+ // set blocking
+ for(NSString* blockedJid in blockedJids)
+ [self blockJid:blockedJid withAccountID:accountID];
+ }];
+}
+
+-(void) unBlockJid:(NSString*) jid withAccountID:(NSNumber*) accountID
+{
+ [self.db voidWriteTransaction:^{
+ [self.db executeNonQuery:@"DELETE FROM blocklistCache WHERE account_id=? AND blocked_jid=?;" andArguments:@[accountID, jid]];
+ }];
+}
+
+-(BOOL) isBlockedContact:(MLContact*) contact
+{
+ if(!contact)
+ return NO;
+
+ return [self.db boolReadTransaction:^{
+ NSNumber* count = (NSNumber*) [self.db executeScalar:@"SELECT COUNT(*) FROM blocklistCache WHERE account_id=? AND blocked_jid=?;" andArguments:@[contact.accountID, contact.contactJid]];
+ return (BOOL) (count.intValue > 0);
+ }];
+}
+
+-(NSArray*) blockedJidsForAccount:(NSNumber*) accountID
+{
+ return [self.db idReadTransaction:^{
+ return (NSArray*) [self.db executeScalarReader:@"SELECT blocked_jid FROM blocklistCache WHERE account_id=?;" andArguments:@[accountID]];
+ }];
+}
+
+-(BOOL) isPinnedChat:(NSNumber*) accountID andBuddyJid:(NSString*) buddyJid
+{
+ if(accountID == nil || !buddyJid)
+ return NO;
+ return [self.db boolReadTransaction:^{
+ NSNumber* pinnedNum = [self.db executeScalar:@"SELECT pinned FROM activechats WHERE account_id=? AND buddy_name=?;" andArguments:@[accountID, buddyJid]];
+ if(pinnedNum != nil)
+ return [pinnedNum boolValue];
+ else
+ return NO;
+ }];
+}
+
+-(void) pinChat:(NSNumber*) accountID andBuddyJid:(NSString*) buddyJid
+{
+ if(accountID == nil || !buddyJid)
+ return;
+ [self.db voidWriteTransaction:^{
+ [self.db executeNonQuery:@"UPDATE activechats SET pinned=1 WHERE account_id=? AND buddy_name=?" andArguments:@[accountID, buddyJid]];
+ }];
+}
+-(void) unPinChat:(NSNumber*) accountID andBuddyJid:(NSString*) buddyJid
+{
+ if(accountID == nil || !buddyJid)
+ return;
+ [self.db voidWriteTransaction:^{
+ [self.db executeNonQuery:@"UPDATE activechats SET pinned=0 WHERE account_id=? AND buddy_name=?" andArguments:@[accountID, buddyJid]];
+ }];
+}
+
+#pragma mark - Filetransfers
+
+-(NSArray*) getAllMessagesForFiletransferUrl:(NSString*) url
+{
+ return [self.db idReadTransaction:^{
+ return [self messagesForHistoryIDs:[self.db executeScalarReader:@"SELECT message_history_id FROM message_history WHERE message=?;" andArguments:@[url]]];
+ }];
+}
+
+-(void) upgradeImageMessagesToFiletransferMessages
+{
+ [self.db voidWriteTransaction:^{
+ [self.db executeNonQuery:@"UPDATE message_history SET messageType=? WHERE messageType=?;" andArguments:@[kMessageTypeFiletransfer, @"Image"]];
+ }];
+}
+
+// (deprecated) should only be used to upgrade to new table format
+-(NSArray*) getAllCachedImages
+{
+ return [self.db idReadTransaction:^{
+ NSNumber* tableFound = [self.db executeScalar:@"SELECT count(*) FROM sqlite_master WHERE type='table' AND name='imageCache';"];
+ if(tableFound.boolValue == NO)
+ {
+ return [[NSArray alloc] init];
+ }
+ return (NSArray*)[self.db executeReader:@"SELECT DISTINCT * FROM imageCache;"];
+ }];
+}
+
+// (deprecated) should only be used to upgrade to new table format
+-(void) removeImageCacheTables
+{
+ [self.db voidWriteTransaction:^{
+ [self.db executeNonQuery:@"DROP TABLE IF EXISTS imageCache;"];
+ }];
+}
+
+-(NSMutableArray*) allAttachmentsFromContact:(NSString*) contact forAccount:(NSNumber*) accountID
+{
+ if(accountID == nil ||! contact)
+ return nil;
+
+ return [self.db idReadTransaction:^{
+ NSString* query = @"SELECT message_history_id FROM message_history WHERE messageType=? AND account_id=? AND buddy_name=? GROUP BY message ORDER BY message_history_id ASC;";
+ NSArray* params = @[kMessageTypeFiletransfer, accountID, contact];
+
+ NSMutableArray* retval = [NSMutableArray new];
+ for(MLMessage* msg in [self messagesForHistoryIDs:[self.db executeScalarReader:query andArguments:params]])
+ [retval addObject:[MLFiletransfer getFileInfoForMessage:msg]];
+ return retval;
+ }];
+}
+
+#pragma mark - last interaction
+
+-(NSDate* _Nullable) lastInteractionOfJid:(NSString* _Nonnull) jid forAccountID:(NSNumber* _Nonnull) accountID
+{
+ MLAssert(jid != nil, @"jid should not be null");
+ MLAssert(accountID != nil, @"accountID should not be null");
+ return [self.db idReadTransaction:^{
+ //this will only return resources supporting "urn:xmpp:idle:1" and being "online" (e.g. lastInteraction = 0)
+ NSNumber* online = [self.db executeScalar:@"SELECT R.lastInteraction FROM buddy_resources AS R INNER JOIN buddylist AS B ON R.buddy_id=B.buddy_id INNER JOIN ver_info AS V ON R.ver=V.ver WHERE B.account_id=? AND B.buddy_name=? AND V.account_id=? AND V.cap='urn:xmpp:idle:1' AND R.lastInteraction=0 ORDER BY R.lastInteraction ASC LIMIT 1;" andArguments:@[accountID, jid, accountID]];
+
+ //this will only return resources supporting "urn:xmpp:idle:1" and being "idle since <...>" (e.g. lastInteraction > 0)
+ NSNumber* idle = [self.db executeScalar:@"SELECT R.lastInteraction FROM buddy_resources AS R INNER JOIN buddylist AS B ON R.buddy_id=B.buddy_id INNER JOIN ver_info AS V ON R.ver=V.ver WHERE B.account_id=? AND B.buddy_name=? AND V.account_id=? AND V.cap='urn:xmpp:idle:1' AND R.lastInteraction!=0 ORDER BY R.lastInteraction DESC LIMIT 1;" andArguments:@[accountID, jid, accountID]];
+
+ //this will only return a value if the buddy has a last interaction not being NULL or 0
+ NSNumber* globalIdle = [self.db executeScalar:@"SELECT lastInteraction FROM buddylist WHERE account_id=? AND buddy_name=? AND NOT (lastInteraction IS NULL OR lastInteraction==0);" andArguments:@[accountID, jid]];
+
+ //at least one online resource means the buddy is online
+ //if no online resource can be found use the newest timestamp as "idle since <...>" timestamp
+ //if this can also not be found, use the global timestamp and if this is NULL then return nil
+ //(meaning last interaction is unsupported and was every since we saw presences from this jid)
+ DDLogDebug(@"LastInteraction of %@ online=%@, idle=%@, globalIdle=%@", jid, online, idle, globalIdle);
+ if(online != nil)
+ return [[NSDate date] initWithTimeIntervalSince1970:0] ;
+ if(idle == nil)
+ {
+ if(globalIdle == nil)
+ return (NSDate*)nil;
+ return [NSDate dateWithTimeIntervalSince1970:[globalIdle integerValue]];
+ }
+ return [NSDate dateWithTimeIntervalSince1970:[idle integerValue]];
+ }];
+}
+
+-(NSDate* _Nullable) lastInteractionOfJid:(NSString* _Nonnull) jid andResource:(NSString* _Nonnull) resource forAccountID:(NSNumber* _Nonnull) accountID
+{
+ MLAssert(jid != nil, @"jid should not be null");
+ MLAssert(accountID != nil, @"accountID should not be null");
+ return [self.db idReadTransaction:^{
+ //this will only return resources supporting "urn:xmpp:idle:1"
+ NSNumber* lastInteraction = [self.db executeScalar:@"SELECT R.lastInteraction FROM buddy_resources AS R INNER JOIN buddylist AS B ON R.buddy_id=B.buddy_id WHERE B.account_id=? AND B.buddy_name=? AND R.resource=? AND EXISTS(SELECT * FROM ver_info AS V WHERE V.ver=R.ver AND V.account_id=B.account_id AND V.cap='urn:xmpp:idle:1') LIMIT 1;" andArguments:@[accountID, jid, resource]];
+ DDLogDebug(@"LastInteraction of %@/%@ lastInteraction=%@", jid, resource, lastInteraction);
+ if(lastInteraction == nil)
+ return (NSDate*)nil;
+ return [NSDate dateWithTimeIntervalSince1970:[lastInteraction integerValue]];
+ }];
+}
+
+-(void) setLastInteraction:(NSDate*) lastInteractionTime forJid:(NSString* _Nonnull) jid andResource:(NSString*) resource onAccountID:(NSNumber* _Nonnull) accountID
+{
+ MLAssert(jid != nil, @"jid should not be null");
+ MLAssert(accountID != nil, @"accountID should not be null");
+
+ NSNumber* timestamp = @0; //default value for "online" or "unknown" (depending on caps)
+ if(lastInteractionTime != nil)
+ timestamp = [HelperTools dateToNSNumberSeconds:lastInteractionTime];
+
+ DDLogDebug(@"Setting lastInteraction of %@/%@ to %@...", jid, resource, timestamp);
+ [self.db voidWriteTransaction:^{
+ [self.db executeNonQuery:@"UPDATE buddy_resources AS R SET lastInteraction=? WHERE EXISTS(SELECT * FROM buddylist AS B WHERE B.buddy_id=R.buddy_id AND B.account_id=? AND B.buddy_name=?) AND R.resource=?;" andArguments:@[timestamp, accountID, jid, resource]];
+ [self.db executeNonQuery:@"UPDATE buddylist SET lastInteraction=? WHERE account_id=? AND buddy_name=? AND (lastInteraction IS NULL OR lastInteraction);" andArguments:@[timestamp, accountID, jid, timestamp]];
+ }];
+}
+
+#pragma mark - encryption
+
+-(BOOL) shouldEncryptForJid:(NSString*) jid andAccountID:(NSNumber*) accountID
+{
+ if(!jid || accountID == nil)
+ return NO;
+ return [self.db boolReadTransaction:^{
+ NSString* query = @"SELECT encrypt from buddylist where account_id=? and buddy_name=?";
+ NSArray* params = @[accountID, jid];
+ NSNumber* status=(NSNumber*)[self.db executeScalar:query andArguments:params];
+ return [status boolValue];
+ }];
+}
+
+
+-(void) encryptForJid:(NSString*) jid andAccountID:(NSNumber*) accountID
+{
+ if(!jid || accountID == nil)
+ return;
+ [self.db voidWriteTransaction:^{
+ [self.db executeNonQuery:@"UPDATE buddylist SET encrypt=1 WHERE account_id=? AND buddy_name=?;" andArguments:@[accountID, jid]];
+ }];
+ return;
+}
+
+-(void) disableEncryptForJid:(NSString*) jid andAccountID:(NSNumber*) accountID
+{
+ if(!jid || accountID == nil)
+ return;
+ [self.db voidWriteTransaction:^{
+ [self.db executeNonQuery:@"UPDATE buddylist SET encrypt=0 WHERE account_id=? AND buddy_name=?;" andArguments:@[accountID, jid]];
+ }];
+ return;
+}
+
+-(NSNumber*) addIdleTimerWithTimeout:(NSNumber*) timeout andHandler:(MLHandler*) handler onAccountID:(NSNumber*) accountID
+{
+ return [self.db idWriteTransaction:^{
+ [self.db executeNonQuery:@"INSERT INTO idle_timers (timeout, account_id, handler) VALUES (?, ?, ?);" andArguments:@[timeout, accountID, [HelperTools serializeObject:handler]]];
+ return [self.db lastInsertId];
+ }];
+}
+
+-(void) delIdleTimerWithId:(NSNumber* _Nullable) timerId
+{
+ DDLogVerbose(@"Trying to remove idle timer with id: %@", timerId);
+ if(timerId == nil)
+ return;
+ return [self.db voidWriteTransaction:^{
+ NSArray* timers = [self.db executeReader:@"SELECT * FROM idle_timers WHERE id=?;" andArguments:@[timerId]];
+ if(timers == nil || [timers count] != 1)
+ return; //we could not find this timerId, ignore this call
+ NSDictionary* timer = timers[0];
+ //call invalidation of this timer's handler (will do nothing if this handler does not have any invalidation method)
+ //and delete the timer afterwards
+ //thanks to foreign keys deleting an account will automatically delete it's idle timers, too.
+ //therefore the following assertion only handles deactivated accounts
+ xmpp* account = [[MLXMPPManager sharedInstance] getEnabledAccountForID:timer[@"account_id"]];
+ MLAssert(account != nil, @"Deleting an idle timer should not be done when an account is disabled!", (@{
+ @"timerId": timerId,
+ @"accountID": nilWrapper(timer[@"account_id"])
+ }));
+ $invalidate([HelperTools unserializeData:timer[@"handler"]], $ID(account));
+ [self.db executeNonQuery:@"DELETE FROM idle_timers WHERE id=?;" andArguments:@[timerId]];
+ }];
+}
+
+-(void) cleanupIdleTimerOnAccountID:(NSNumber*) accountID
+{
+ if(accountID == nil)
+ return;
+ return [self.db voidWriteTransaction:^{
+ xmpp* account = [[MLXMPPManager sharedInstance] getEnabledAccountForID:accountID];
+ MLAssert(account != nil, @"Cleaning up idle timers should not be done when an account is disabled!", (@{
+ @"accountID": nilWrapper(accountID)
+ }));
+ [self.db executeNonQuery:@"DELETE FROM idle_timers WHERE account_id=?;" andArguments:@[accountID]];
+ }];
+}
+
+//this method will only be called from our timer background thread also handling iq timeouts
+-(void) decrementIdleTimersForAccount:(xmpp*) account
+{
+ return [self.db voidWriteTransaction:^{
+ for(NSDictionary* timer in [self.db executeReader:@"SELECT * FROM idle_timers WHERE account_id=?;" andArguments:@[account.accountID]])
+ {
+ DDLogVerbose(@"Decrementing idle timer %@(%@): %@", timer[@"id"], timer[@"timeout"], [HelperTools unserializeData:timer[@"handler"]]);
+ if([timer[@"timeout"] unsignedIntegerValue] == 0)
+ {
+ //this timer expired --> call it's handler and delete the timer afterwards
+ $call([HelperTools unserializeData:timer[@"handler"]], $ID(account));
+ [self.db executeNonQuery:@"DELETE FROM idle_timers WHERE id=?;" andArguments:@[timer[@"id"]]];
+ continue;
+ }
+ //just decrease timeout of this timer (it will expire when reaching zero)
+ [self.db executeNonQuery:@"UPDATE idle_timers SET timeout=timeout-1 WHERE id=?;" andArguments:@[timer[@"id"]]];
+ }
+ }];
+}
+
+#pragma mark History Message Search (search keyword in message, buddy_name, messageType)
+
+-(NSArray*) searchResultOfHistoryMessageWithKeyWords:(NSString*) keyword accountID:(NSNumber*) accountID
+{
+ if(!keyword || accountID == nil)
+ return nil;
+ return [self.db idReadTransaction:^{
+ NSString *likeString = [NSString stringWithFormat:@"%%%@%%", keyword];
+ NSString* query = @"SELECT message_history_id FROM message_history WHERE account_id = ? AND (message like ? OR buddy_name LIKE ? OR messageType LIKE ?) ORDER BY timestamp ASC;";
+ NSArray* params = @[accountID, likeString, likeString, likeString];
+ NSArray* results = [self.db executeScalarReader:query andArguments:params];
+ return [self messagesForHistoryIDs:results];
+ }];
+}
+
+-(NSArray*) searchResultOfHistoryMessageWithKeyWords:(NSString*) keyword betweenContact:(MLContact* _Nonnull) contact
+{
+ if(!keyword)
+ return nil;
+ return [self.db idReadTransaction:^{
+ NSString* likeString = [NSString stringWithFormat:@"%%%@%%", keyword];
+ NSString* query = @"SELECT message_history_id FROM message_history WHERE account_id=? AND (message LIKE ? OR messageType LIKE ?) AND buddy_name=? ORDER BY timestamp ASC;";
+ NSArray* params = @[contact.accountID, likeString, contact.contactJid];
+ NSArray* results = [self.db executeScalarReader:query andArguments:params];
+ return [self messagesForHistoryIDs:results];
+ }];
+}
+
+@end
diff --git a/Monal/Classes/DataLayerMigrations.h b/Monal/Classes/DataLayerMigrations.h
new file mode 100644
index 0000000..614a925
--- /dev/null
+++ b/Monal/Classes/DataLayerMigrations.h
@@ -0,0 +1,21 @@
+//
+// DataLayerMigrations.h
+// monalxmpp
+//
+// Created by Friedrich Altheide on 15.01.22.
+// Copyright © 2022 Monal.im. All rights reserved.
+//
+
+#import
+#import "MLSQLite.h"
+#import "DataLayer.h"
+
+NS_ASSUME_NONNULL_BEGIN
+
+@interface DataLayerMigrations : NSObject
+
++(BOOL) migrateDB:(MLSQLite*) db withDataLayer:(DataLayer*) dataLayer;
+
+@end
+
+NS_ASSUME_NONNULL_END
diff --git a/Monal/Classes/DataLayerMigrations.m b/Monal/Classes/DataLayerMigrations.m
new file mode 100644
index 0000000..6681cc5
--- /dev/null
+++ b/Monal/Classes/DataLayerMigrations.m
@@ -0,0 +1,1197 @@
+//
+// DataLayerMigrations.m
+// monalxmpp
+//
+// Created by Friedrich Altheide on 15.01.22.
+// Copyright © 2022 Monal.im. All rights reserved.
+//
+
+#import "MLSQLite.h"
+#import "DataLayerMigrations.h"
+#import "DataLayer.h"
+#import "HelperTools.h"
+#import "MLImageManager.h"
+
+@implementation DataLayerMigrations
+
++(NSNumber*) readDBVersion:(MLSQLite*) db
+{
+ return [NSNumber numberWithDouble:[[db executeScalar:@"SELECT value FROM flags WHERE name='dbversion';"] doubleValue]];
+}
+
++(BOOL) updateDB:(MLSQLite*) db withDataLayer:(DataLayer*) dataLayer toVersion:(double) version withBlock:(monal_void_block_t) block
+{
+ if([(NSNumber*)[db executeScalar:@"SELECT value FROM flags WHERE name='dbversion';"] doubleValue] < version)
+ {
+ DDLogVerbose(@"Database version <%@ detected. Performing upgrade.", [NSNumber numberWithDouble:version]);
+ block();
+ [db executeNonQuery:@"UPDATE flags SET value=? WHERE name='dbversion';" andArguments:@[[NSNumber numberWithDouble:version]]];
+ DDLogDebug(@"Upgrade to %@ success", [NSNumber numberWithDouble:version]);
+ return YES;
+ }
+ return NO;
+}
+
++(BOOL) migrateDB:(MLSQLite*) db withDataLayer:(DataLayer*) dataLayer
+{
+ //migrate dbversion into flags table if necessary
+ [db voidWriteTransaction:^{
+ NSNumber* alreadyMigrated = [db executeScalar:@"SELECT count(*) FROM sqlite_master WHERE type='table' AND name='dbversion';"];
+ if([alreadyMigrated boolValue])
+ {
+ NSNumber* unmigratedDBVersion = [db executeScalar:@"SELECT dbversion FROM dbversion;"];
+ DDLogInfo(@"Migrating dbversion to flags table...");
+ [db executeNonQuery:@"DROP TABLE dbversion;"];
+ [db executeNonQuery:@"CREATE TABLE 'flags' ( \
+ 'name' VARCHAR(32) NOT NULL PRIMARY KEY, \
+ 'value' TEXT DEFAULT NULL \
+ );"];
+ [db executeNonQuery:@"INSERT INTO flags (name, value) VALUES('dbversion', ?);" andArguments:@[unmigratedDBVersion]];
+ }
+ else
+ DDLogVerbose(@"dbversion table already migrated");
+
+ //make sure we don't try to operate on a database we can't upgrade from
+ NSNumber* dbversion = [self readDBVersion:db];
+ if(dbversion.doubleValue < 4.78)
+ {
+ DDLogError(@"Got *TOO OLD* db version %@", dbversion);
+ NSFileManager* fileManager = [NSFileManager defaultManager];
+ NSString* writableDBPath = [[HelperTools getContainerURLForPathComponents:@[@"sworim.sqlite"]] path];
+ for(NSString* suffix in @[@"", @"-wal", @"-shm"])
+ [fileManager removeItemAtPath:[NSString stringWithFormat:@"%@%@", writableDBPath, suffix] error:nil];
+ @throw [NSException exceptionWithName:@"OLD_DB_DETECTED" reason:@"Detected too old DB version, deleted file and crashing now!" userInfo:nil];
+ }
+ }];
+
+ return [db boolWriteTransaction:^{
+ NSNumber* dbversion = [self readDBVersion:db];
+ DDLogInfo(@"Got db version %@", dbversion);
+
+ [self updateDB:db withDataLayer:dataLayer toVersion:4.80 withBlock:^{
+ [db executeNonQuery:@"CREATE TABLE ipc(id integer NOT NULL PRIMARY KEY AUTOINCREMENT, name VARCHAR(255), destination VARCHAR(255), data BLOB, timeout INTEGER NOT NULL DEFAULT 0);"];
+ }];
+
+ [self updateDB:db withDataLayer:dataLayer toVersion:4.81 withBlock:^{
+ // Remove silly chats
+ NSMutableArray* results = [db executeReader:@"select account_id, username, domain from account"];
+ for(NSDictionary* row in results) {
+ NSString* accountJid = [NSString stringWithFormat:@"%@@%@", [row objectForKey:kUsername], [row objectForKey:kDomain]];
+ NSString* accountID = [row objectForKey:kAccountID];
+
+ // delete chats with accountJid == buddy_name
+ [db executeNonQuery:@"delete from activechats where account_id=? and buddy_name=?" andArguments:@[accountID, accountJid]];
+ }
+ }];
+
+ [self updateDB:db withDataLayer:dataLayer toVersion:4.82 withBlock:^{
+ //use the more appropriate name "sent" for the "delivered" column of message_history
+ [db executeNonQuery:@"ALTER TABLE message_history RENAME TO _message_historyTMP;"];
+ [db executeNonQuery:@"CREATE TABLE 'message_history' (message_history_id integer not null primary key AUTOINCREMENT, account_id integer, message_from text collate nocase, message_to text collate nocase, timestamp datetime, message blob, actual_from text collate nocase, messageid text, messageType text, sent bool, received bool, unread bool, encrypted bool, previewText text, previewImage text, stanzaid text, errorType text, errorReason text);"];
+ [db executeNonQuery:@"INSERT INTO message_history (message_history_id, account_id, message_from, message_to, timestamp, message, actual_from, messageid, messageType, sent, received, unread, encrypted, previewText, previewImage, stanzaid, errorType, errorReason) SELECT message_history_id, account_id, message_from, message_to, timestamp, message, actual_from, messageid, messageType, delivered, received, unread, encrypted, previewText, previewImage, stanzaid, errorType, errorReason from _message_historyTMP;"];
+ [db executeNonQuery:@"DROP TABLE _message_historyTMP;"];
+ }];
+
+ [self updateDB:db withDataLayer:dataLayer toVersion:4.83 withBlock:^{
+ [db executeNonQuery:@"alter table activechats add column pinned bool DEFAULT FALSE;"];
+ }];
+
+ [self updateDB:db withDataLayer:dataLayer toVersion:4.84 withBlock:^{
+ [db executeNonQuery:@"DROP TABLE IF EXISTS ipc;"];
+ //remove synchPoint from db
+ [db executeNonQuery:@"ALTER TABLE buddylist RENAME TO _buddylistTMP;"];
+ [db executeNonQuery:@"CREATE TABLE buddylist(buddy_id integer not null primary key AUTOINCREMENT, account_id integer not null, buddy_name varchar(50) collate nocase, full_name varchar(50), nick_name varchar(50), group_name varchar(50), iconhash varchar(200), filename varchar(100), state varchar(20), status varchar(200), online bool, dirty bool, new bool, Muc bool, muc_subject varchar(255), muc_nick varchar(255), backgroundImage text, encrypt bool, subscription varchar(50), ask varchar(50), messageDraft text, lastInteraction INTEGER NOT NULL DEFAULT 0);"];
+ [db executeNonQuery:@"INSERT INTO buddylist (buddy_id, account_id, buddy_name, full_name, nick_name, group_name, iconhash, filename, state, status, online, dirty, new, Muc, muc_subject, muc_nick, backgroundImage, encrypt, subscription, ask, messageDraft, lastInteraction) SELECT buddy_id, account_id, buddy_name, full_name, nick_name, group_name, iconhash, filename, state, status, online, dirty, new, Muc, muc_subject, muc_nick, backgroundImage, encrypt, subscription, ask, messageDraft, lastInteraction FROM _buddylistTMP;"];
+ [db executeNonQuery:@"DROP TABLE _buddylistTMP;"];
+ [db executeNonQuery:@"CREATE UNIQUE INDEX IF NOT EXISTS uniqueContact on buddylist(buddy_name, account_id);"];
+ //make stanzaid, messageid and errorType caseinsensitive and create indixes for stanzaid and messageid
+ [db executeNonQuery:@"ALTER TABLE message_history RENAME TO _message_historyTMP;"];
+ [db executeNonQuery:@"CREATE TABLE message_history (message_history_id integer not null primary key AUTOINCREMENT, account_id integer, message_from text collate nocase, message_to text collate nocase, timestamp datetime, message blob, actual_from text collate nocase, messageid text collate nocase, messageType text, sent bool, received bool, unread bool, encrypted bool, previewText text, previewImage text, stanzaid text collate nocase, errorType text collate nocase, errorReason text);"];
+ [db executeNonQuery:@"INSERT INTO message_history (message_history_id, account_id, message_from, message_to, timestamp, message, actual_from, messageid, messageType, sent, received, unread, encrypted, previewText, previewImage, stanzaid, errorType, errorReason) SELECT message_history_id, account_id, message_from, message_to, timestamp, message, actual_from, messageid, messageType, sent, received, unread, encrypted, previewText, previewImage, stanzaid, errorType, errorReason FROM _message_historyTMP;"];
+ [db executeNonQuery:@"DROP TABLE _message_historyTMP;"];
+ [db executeNonQuery:@"CREATE INDEX stanzaidIndex on message_history(stanzaid collate nocase);"];
+ [db executeNonQuery:@"CREATE INDEX messageidIndex on message_history(messageid collate nocase);"];
+ }];
+
+ [self updateDB:db withDataLayer:dataLayer toVersion:4.85 withBlock:^{
+ //Performing upgrade on buddy_resources.
+ [db executeNonQuery:@"ALTER TABLE buddy_resources ADD platform_App_Name text;"];
+ [db executeNonQuery:@"ALTER TABLE buddy_resources ADD platform_App_Version text;"];
+ [db executeNonQuery:@"ALTER TABLE buddy_resources ADD platform_OS text;"];
+
+ //drop and recreate in 4.77 was faulty (wrong drop syntax), do it right this time
+ [db executeNonQuery:@"DROP TABLE IF EXISTS ver_info;"];
+ [db executeNonQuery:@"CREATE TABLE ver_info(ver VARCHAR(32), cap VARCHAR(255), PRIMARY KEY (ver,cap));"];
+ }];
+
+ [self updateDB:db withDataLayer:dataLayer toVersion:4.86 withBlock:^{
+ //add new stanzaid field to account table that always points to the last received stanzaid (even if that does not have a body)
+ [db executeNonQuery:@"ALTER TABLE account ADD lastStanzaId text;"];
+ }];
+
+ [self updateDB:db withDataLayer:dataLayer toVersion:4.87 withBlock:^{
+ //populate new stanzaid field in account table from message_history table
+ NSString* stanzaId = (NSString*)[db executeScalar:@"SELECT stanzaid FROM message_history WHERE stanzaid!='' ORDER BY message_history_id DESC LIMIT 1;"];
+ DDLogVerbose(@"Populating lastStanzaId with id %@ from history table", stanzaId);
+ if(stanzaId && [stanzaId length])
+ [db executeNonQuery:@"UPDATE account SET lastStanzaId=?;" andArguments:@[stanzaId]];
+ //remove all old and most probably *wrong* stanzaids from history table
+ [db executeNonQuery:@"UPDATE message_history SET stanzaid='';"];
+ }];
+
+ [self updateDB:db withDataLayer:dataLayer toVersion:4.9 withBlock:^{
+ // add timestamps to omemo prekeys
+ [db executeNonQuery:@"ALTER TABLE signalPreKey RENAME TO _signalPreKeyTMP;"];
+ [db executeNonQuery:@"CREATE TABLE 'signalPreKey' ('account_id' int NOT NULL, 'prekeyid' int NOT NULL, 'preKey' BLOB, 'creationTimestamp' INTEGER NOT NULL DEFAULT CURRENT_TIMESTAMP, 'pubSubRemovalTimestamp' INTEGER DEFAULT NULL, 'keyUsed' INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (account_id, prekeyid, preKey));"];
+ [db executeNonQuery:@"INSERT INTO signalPreKey (account_id, prekeyid, preKey) SELECT account_id, prekeyid, preKey FROM _signalPreKeyTMP;"];
+ [db executeNonQuery:@"DROP TABLE _signalPreKeyTMP;"];
+ }];
+
+ [self updateDB:db withDataLayer:dataLayer toVersion:4.91 withBlock:^{
+ //not needed anymore (better handled by 4.97)
+ }];
+
+ [self updateDB:db withDataLayer:dataLayer toVersion:4.92 withBlock:^{
+ //add displayed and displayMarkerWanted fields
+ [db executeNonQuery:@"ALTER TABLE message_history ADD COLUMN displayed BOOL DEFAULT FALSE;"];
+ [db executeNonQuery:@"ALTER TABLE message_history ADD COLUMN displayMarkerWanted BOOL DEFAULT FALSE;"];
+ }];
+
+ [self updateDB:db withDataLayer:dataLayer toVersion:4.93 withBlock:^{
+ //full_name should not be buddy_name anymore, but the user provided XEP-0172 nickname
+ //and nick_name will be the roster name, if given
+ //if none of these two are given, the local part of the jid (called node in prosody and in jidSplit:) will be used, like in other clients
+ //see also https://docs.modernxmpp.org/client/design/#contexts
+ [db executeNonQuery:@"UPDATE buddylist SET full_name='' WHERE full_name=buddy_name;"];
+ [db executeNonQuery:@"UPDATE account SET rosterVersion=?;" andArguments:@[@""]];
+ }];
+
+ [self updateDB:db withDataLayer:dataLayer toVersion:4.94 withBlock:^{
+ [db executeNonQuery:@"ALTER TABLE account ADD COLUMN rosterName TEXT;"];
+ }];
+
+ [self updateDB:db withDataLayer:dataLayer toVersion:4.95 withBlock:^{
+ [db executeNonQuery:@"ALTER TABLE account ADD COLUMN iconhash VARCHAR(200);"];
+ }];
+
+ [self updateDB:db withDataLayer:dataLayer toVersion:4.96 withBlock:^{
+ //not needed anymore (better handled by 4.97)
+ }];
+
+ [self updateDB:db withDataLayer:dataLayer toVersion:4.97 withBlock:^{
+ [dataLayer invalidateAllAccountStates];
+ }];
+
+ [self updateDB:db withDataLayer:dataLayer toVersion:4.98 withBlock:^{
+ [db executeNonQuery:@"ALTER TABLE message_history ADD COLUMN filetransferMimeType VARCHAR(32) DEFAULT 'application/octet-stream';"];
+ [db executeNonQuery:@"ALTER TABLE message_history ADD COLUMN filetransferSize INTEGER DEFAULT 0;"];
+ }];
+
+ [self updateDB:db withDataLayer:dataLayer toVersion:4.990 withBlock:^{
+ // remove dupl entries from activechats && budylist
+ [db executeNonQuery:@"DELETE FROM activechats \
+ WHERE ROWID NOT IN \
+ (SELECT tmpID FROM \
+ (SELECT ROWID as tmpID, account_id, buddy_name FROM activechats WHERE \
+ ROWID IN \
+ (SELECT ROWID FROM activechats ORDER BY lastMessageTime DESC) \
+ GROUP BY account_id, buddy_name) \
+ )"];
+ [db executeNonQuery:@"DELETE FROM buddylist WHERE ROWID NOT IN \
+ (SELECT tmpID FROM \
+ (SELECT ROWID as tmpID, account_id, buddy_name FROM buddylist GROUP BY account_id, buddy_name) \
+ )"];
+ }];
+
+ [self updateDB:db withDataLayer:dataLayer toVersion:4.991 withBlock:^{
+ //remove dirty, online, new from db
+ [db executeNonQuery:@"ALTER TABLE buddylist RENAME TO _buddylistTMP;"];
+ [db executeNonQuery:@"CREATE TABLE buddylist(buddy_id integer not null primary key AUTOINCREMENT, account_id integer not null, buddy_name varchar(50) collate nocase, full_name varchar(50), nick_name varchar(50), group_name varchar(50), iconhash varchar(200), filename varchar(100), state varchar(20), status varchar(200), Muc bool, muc_subject varchar(255), muc_nick varchar(255), backgroundImage text, encrypt bool, subscription varchar(50), ask varchar(50), messageDraft text, lastInteraction INTEGER NOT NULL DEFAULT 0);"];
+ [db executeNonQuery:@"INSERT INTO buddylist (buddy_id, account_id, buddy_name, full_name, nick_name, group_name, iconhash, filename, state, status, Muc, muc_subject, muc_nick, backgroundImage, encrypt, subscription, ask, messageDraft, lastInteraction) SELECT buddy_id, account_id, buddy_name, full_name, nick_name, group_name, iconhash, filename, state, status, Muc, muc_subject, muc_nick, backgroundImage, encrypt, subscription, ask, messageDraft, lastInteraction FROM _buddylistTMP;"];
+ [db executeNonQuery:@"DROP TABLE _buddylistTMP;"];
+ [db executeNonQuery:@"CREATE UNIQUE INDEX IF NOT EXISTS uniqueContact on buddylist(buddy_name, account_id);"];
+ }];
+
+ [self updateDB:db withDataLayer:dataLayer toVersion:4.992 withBlock:^{
+ [db executeNonQuery:@"ALTER TABLE account ADD COLUMN statusMessage TEXT;"];
+ }];
+
+ [self updateDB:db withDataLayer:dataLayer toVersion:4.993 withBlock:^{
+ //make filetransferMimeType and filetransferSize have NULL as default value
+ //(this makes it possible to distinguish unknown values from known ones)
+ [db executeNonQuery:@"ALTER TABLE message_history RENAME TO _message_historyTMP;"];
+ [db executeNonQuery:@"CREATE TABLE message_history (message_history_id integer not null primary key AUTOINCREMENT, account_id integer, message_from text collate nocase, message_to text collate nocase, timestamp datetime, message blob, actual_from text collate nocase, messageid text collate nocase, messageType text, sent bool, received bool, unread bool, encrypted bool, previewText text, previewImage text, stanzaid text collate nocase, errorType text collate nocase, errorReason text, displayed BOOL DEFAULT FALSE, displayMarkerWanted BOOL DEFAULT FALSE, filetransferMimeType VARCHAR(32) DEFAULT NULL, filetransferSize INTEGER DEFAULT NULL);"];
+ [db executeNonQuery:@"INSERT INTO message_history SELECT * FROM _message_historyTMP;"];
+ [db executeNonQuery:@"DROP TABLE _message_historyTMP;"];
+ [db executeNonQuery:@"CREATE INDEX stanzaidIndex on message_history(stanzaid collate nocase);"];
+ [db executeNonQuery:@"CREATE INDEX messageidIndex on message_history(messageid collate nocase);"];
+ }];
+
+ // skipping 4.994 due to invalid command
+
+ [self updateDB:db withDataLayer:dataLayer toVersion:4.995 withBlock:^{
+ [db executeNonQuery:@"CREATE UNIQUE INDEX IF NOT EXISTS uniqueActiveChat ON activechats(buddy_name, account_id);"];
+ }];
+
+ [self updateDB:db withDataLayer:dataLayer toVersion:4.996 withBlock:^{
+ //remove all icon hashes to reload all icons on next app/nse start
+ //(the db upgrade mechanism will make sure that no smacks resume will take place and pep pushes come in for all avatars)
+ [db executeNonQuery:@"UPDATE account SET iconhash='';"];
+ [db executeNonQuery:@"UPDATE buddylist SET iconhash='';"];
+ }];
+
+ [self updateDB:db withDataLayer:dataLayer toVersion:4.997 withBlock:^{
+ //create unique constraint for (account_id, buddy_name) on activechats table
+ [db executeNonQuery:@"ALTER TABLE activechats RENAME TO _activechatsTMP;"];
+ [db executeNonQuery:@"CREATE TABLE activechats (account_id integer not null, buddy_name varchar(50) collate nocase, lastMessageTime datetime, lastMesssage blob, pinned bool DEFAULT FALSE, UNIQUE(account_id, buddy_name));"];
+ [db executeNonQuery:@"INSERT INTO activechats SELECT * FROM _activechatsTMP;"];
+ [db executeNonQuery:@"DROP TABLE _activechatsTMP;"];
+ [db executeNonQuery:@"CREATE UNIQUE INDEX IF NOT EXISTS uniqueActiveChat ON activechats(buddy_name, account_id);"];
+
+ //create unique constraint for (buddy_name, account_id) on buddylist table
+ [db executeNonQuery:@"ALTER TABLE buddylist RENAME TO _buddylistTMP;"];
+ [db executeNonQuery:@"CREATE TABLE buddylist(buddy_id integer not null primary key AUTOINCREMENT, account_id integer not null, buddy_name varchar(50) collate nocase, full_name varchar(50), nick_name varchar(50), group_name varchar(50), iconhash varchar(200), filename varchar(100), state varchar(20), status varchar(200), Muc bool, muc_subject varchar(255), muc_nick varchar(255), backgroundImage text, encrypt bool, subscription varchar(50), ask varchar(50), messageDraft text, lastInteraction INTEGER NOT NULL DEFAULT 0, UNIQUE(account_id, buddy_name));"];
+ [db executeNonQuery:@"INSERT INTO buddylist SELECT * FROM _buddylistTMP;"];
+ [db executeNonQuery:@"DROP TABLE _buddylistTMP;"];
+ [db executeNonQuery:@"CREATE UNIQUE INDEX IF NOT EXISTS uniqueContact on buddylist(buddy_name, account_id);"];
+ }];
+
+ [self updateDB:db withDataLayer:dataLayer toVersion:5.000 withBlock:^{
+ // cleanup omemo tables
+ [db executeNonQuery:@"DELETE FROM signalContactIdentity WHERE account_id NOT IN (SELECT account_id FROM account);"];
+ [db executeNonQuery:@"DELETE FROM signalContactKey WHERE account_id NOT IN (SELECT account_id FROM account);"];
+ [db executeNonQuery:@"DELETE FROM signalIdentity WHERE account_id NOT IN (SELECT account_id FROM account);"];
+ [db executeNonQuery:@"DELETE FROM signalPreKey WHERE account_id NOT IN (SELECT account_id FROM account);"];
+ [db executeNonQuery:@"DELETE FROM signalSignedPreKey WHERE account_id NOT IN (SELECT account_id FROM account);"];
+ }];
+
+ [self updateDB:db withDataLayer:dataLayer toVersion:5.001 withBlock:^{
+ //do this in 5.0 branch as well
+
+ //create unique constraint for (account_id, buddy_name) on activechats table
+ [db executeNonQuery:@"ALTER TABLE activechats RENAME TO _activechatsTMP;"];
+ [db executeNonQuery:@"CREATE TABLE activechats (account_id integer not null, buddy_name varchar(50) collate nocase, lastMessageTime datetime, lastMesssage blob, pinned bool DEFAULT FALSE, UNIQUE(account_id, buddy_name));"];
+ [db executeNonQuery:@"INSERT INTO activechats SELECT * FROM _activechatsTMP;"];
+ [db executeNonQuery:@"DROP TABLE _activechatsTMP;"];
+ [db executeNonQuery:@"CREATE UNIQUE INDEX IF NOT EXISTS uniqueActiveChat ON activechats(buddy_name, account_id);"];
+
+ //create unique constraint for (buddy_name, account_id) on buddylist table
+ [db executeNonQuery:@"ALTER TABLE buddylist RENAME TO _buddylistTMP;"];
+ [db executeNonQuery:@"CREATE TABLE buddylist(buddy_id integer not null primary key AUTOINCREMENT, account_id integer not null, buddy_name varchar(50) collate nocase, full_name varchar(50), nick_name varchar(50), group_name varchar(50), iconhash varchar(200), filename varchar(100), state varchar(20), status varchar(200), Muc bool, muc_subject varchar(255), muc_nick varchar(255), backgroundImage text, encrypt bool, subscription varchar(50), ask varchar(50), messageDraft text, lastInteraction INTEGER NOT NULL DEFAULT 0, UNIQUE(account_id, buddy_name));"];
+ [db executeNonQuery:@"INSERT INTO buddylist SELECT * FROM _buddylistTMP;"];
+ [db executeNonQuery:@"DROP TABLE _buddylistTMP;"];
+ [db executeNonQuery:@"CREATE UNIQUE INDEX IF NOT EXISTS uniqueContact on buddylist(buddy_name, account_id);"];
+ }];
+
+ [self updateDB:db withDataLayer:dataLayer toVersion:5.002 withBlock:^{
+ [db executeNonQuery:@"ALTER TABLE buddylist ADD COLUMN blocked BOOL DEFAULT FALSE;"];
+ [db executeNonQuery:@"DROP TABLE blockList;"];
+ }];
+
+ [self updateDB:db withDataLayer:dataLayer toVersion:5.003 withBlock:^{
+ [db executeNonQuery:@"CREATE TABLE 'blocklistCache' (\
+ 'account_id' TEXT NOT NULL, \
+ 'node' TEXT, \
+ 'host' TEXT, \
+ 'resource' TEXT, \
+ UNIQUE('account_id','node','host','resource'), \
+ CHECK( \
+ (LENGTH('node') > 0 AND LENGTH('host') > 0 AND LENGTH('resource') > 0) \
+ OR \
+ (LENGTH('node') > 0 AND LENGTH('host') > 0) \
+ OR \
+ (LENGTH('host') > 0 AND LENGTH('resource') > 0) \
+ OR \
+ (LENGTH('host') > 0) \
+ ), \
+ FOREIGN KEY('account_id') REFERENCES 'account'('account_id') \
+ );"];
+ }];
+
+ /*
+ * OMEMO trust levels:
+ * 0: no trust
+ * 1: ToFU
+ * 2: trust
+ */
+ [self updateDB:db withDataLayer:dataLayer toVersion:5.004 withBlock:^{
+ [db executeNonQuery:@"ALTER TABLE signalContactIdentity RENAME TO _signalContactIdentityTMP;"];
+ [db executeNonQuery:@"CREATE TABLE 'signalContactIdentity' ( \
+ 'account_id' INTEGER NOT NULL, \
+ 'contactName' TEXT NOT NULL, \
+ 'contactDeviceId' INTEGER NOT NULL, \
+ 'identity' BLOB, \
+ 'lastReceivedMsg' INTEGER DEFAULT NULL, \
+ 'removedFromDeviceList' INTEGER DEFAULT NULL, \
+ 'trustLevel' INTEGER NOT NULL DEFAULT 1, \
+ FOREIGN KEY('contactName') REFERENCES 'buddylist'('buddy_name'), \
+ PRIMARY KEY('account_id', 'contactName', 'contactDeviceId'), \
+ FOREIGN KEY('account_id') REFERENCES 'account'('account_id') \
+ );"];
+ [db executeNonQuery:@"INSERT INTO signalContactIdentity \
+ ( \
+ account_id, contactName, contactDeviceId, identity, trustLevel \
+ ) \
+ SELECT \
+ account_id, contactName, contactDeviceId, identity, \
+ CASE \
+ WHEN trusted=1 THEN 1 \
+ ELSE 0 \
+ END \
+ FROM _signalContactIdentityTMP;"];
+ [db executeNonQuery:@"DROP TABLE _signalContactIdentityTMP;"];
+ }];
+
+ [self updateDB:db withDataLayer:dataLayer toVersion:5.005 withBlock:^{
+ //remove group_name and filename columns from buddylist, resize buddy_name, full_name, nick_name and muc_subject columns and add lastStanzaId column (only used for mucs)
+ [db executeNonQuery:@"ALTER TABLE buddylist RENAME TO _buddylistTMP;"];
+ [db executeNonQuery:@"CREATE TABLE buddylist(buddy_id integer not null primary key AUTOINCREMENT, account_id integer not null, buddy_name varchar(255) collate nocase, full_name varchar(255), nick_name varchar(255), iconhash varchar(200), state varchar(20), status varchar(200), Muc bool, muc_subject varchar(1024), muc_nick varchar(255), backgroundImage text, encrypt bool, subscription varchar(50), ask varchar(50), messageDraft text, lastInteraction INTEGER NOT NULL DEFAULT 0, blocked BOOL DEFAULT FALSE, muc_type VARCHAR(10) DEFAULT 'channel', lastMucStanzaId text DEFAULT NULL, UNIQUE(account_id, buddy_name));"];
+ [db executeNonQuery:@"INSERT INTO buddylist SELECT buddy_id, account_id, buddy_name, full_name, nick_name, iconhash, state, status, Muc, muc_subject, muc_nick, backgroundImage, encrypt, subscription, ask, messageDraft, lastInteraction, blocked, 'channel', NULL FROM _buddylistTMP;"];
+ [db executeNonQuery:@"DROP TABLE _buddylistTMP;"];
+ [db executeNonQuery:@"CREATE UNIQUE INDEX IF NOT EXISTS uniqueContact ON buddylist(buddy_name, account_id);"];
+ [db executeNonQuery:@"UPDATE buddylist SET muc_type='channel' WHERE Muc = true;"]; //muc default type
+
+ //create new muc favorites table
+ [db executeNonQuery:@"DROP TABLE muc_favorites;"];
+ [db executeNonQuery:@"CREATE TABLE muc_favorites (room VARCHAR(255) PRIMARY KEY, nick varchar(255), account_id INTEGER, UNIQUE(room, account_id));"];
+ }];
+
+ [self updateDB:db withDataLayer:dataLayer toVersion:5.006 withBlock:^{
+ // recreate blocklistCache - fixes foreign key
+ [db executeNonQuery:@"ALTER TABLE blocklistCache RENAME TO _blocklistCacheTMP;"];
+ [db executeNonQuery:@"CREATE TABLE 'blocklistCache' (\
+ 'account_id' TEXT NOT NULL, \
+ 'node' TEXT, \
+ 'host' TEXT, \
+ 'resource' TEXT, \
+ UNIQUE('account_id','node','host','resource'), \
+ CHECK( \
+ (LENGTH('node') > 0 AND LENGTH('host') > 0 AND LENGTH('resource') > 0) \
+ OR \
+ (LENGTH('node') > 0 AND LENGTH('host') > 0) \
+ OR \
+ (LENGTH('host') > 0 AND LENGTH('resource') > 0) \
+ OR \
+ (LENGTH('host') > 0) \
+ ), \
+ FOREIGN KEY('account_id') REFERENCES 'account'('account_id') ON DELETE CASCADE \
+ );"];
+ [db executeNonQuery:@"DELETE FROM _blocklistCacheTMP WHERE account_id NOT IN (SELECT account_id FROM account)"];
+ [db executeNonQuery:@"INSERT INTO blocklistCache SELECT * FROM _blocklistCacheTMP;"];
+ [db executeNonQuery:@"DROP TABLE _blocklistCacheTMP;"];
+
+ // recreate signalContactIdentity - fixes foreign key
+ [db executeNonQuery:@"ALTER TABLE signalContactIdentity RENAME TO _signalContactIdentityTMP;"];
+ [db executeNonQuery:@"CREATE TABLE 'signalContactIdentity' ( \
+ 'account_id' INTEGER NOT NULL, \
+ 'contactName' TEXT NOT NULL, \
+ 'contactDeviceId' INTEGER NOT NULL, \
+ 'identity' BLOB, \
+ 'lastReceivedMsg' INTEGER DEFAULT NULL, \
+ 'removedFromDeviceList' INTEGER DEFAULT NULL, \
+ 'trustLevel' INTEGER NOT NULL DEFAULT 1, \
+ FOREIGN KEY('account_id','contactName') REFERENCES 'buddylist'('account_id', 'buddy_name') ON DELETE CASCADE, \
+ PRIMARY KEY('account_id', 'contactName', 'contactDeviceId'), \
+ FOREIGN KEY('account_id') REFERENCES 'account'('account_id') ON DELETE CASCADE \
+ );"];
+ [db executeNonQuery:@"DELETE FROM _signalContactIdentityTMP WHERE account_id NOT IN (SELECT account_id FROM account) OR (account_id, contactName) NOT IN (SELECT account_id, buddy_name FROM buddylist);"];
+ [db executeNonQuery:@"INSERT INTO signalContactIdentity SELECT * FROM _signalContactIdentityTMP;"];
+ [db executeNonQuery:@"DROP TABLE _signalContactIdentityTMP;"];
+ // add self chats for omemo
+ [db executeNonQuery:@"INSERT OR IGNORE INTO buddylist ('account_id', 'buddy_name', 'muc') SELECT account_id, (username || '@' || domain), 0 FROM account;"];
+ }];
+
+ [self updateDB:db withDataLayer:dataLayer toVersion:5.007 withBlock:^{
+ // remove broken omemo sessions
+ [db executeNonQuery:@"DELETE FROM signalContactIdentity WHERE (account_id, contactName) NOT IN (SELECT account_id, contactName FROM signalContactSession);"];
+ [db executeNonQuery:@"DELETE FROM signalContactSession WHERE (account_id, contactName) NOT IN (SELECT account_id, contactName FROM signalContactIdentity);"];
+ }];
+
+ [self updateDB:db withDataLayer:dataLayer toVersion:5.008 withBlock:^{
+ [db executeNonQuery:@"DROP TABLE muc_favorites;"];
+ [db executeNonQuery:@"CREATE TABLE 'muc_favorites' ( \
+ 'account_id' INTEGER NOT NULL, \
+ 'room' VARCHAR(255) NOT NULL, \
+ 'nick' varchar(255), \
+ 'autojoin' BOOL NOT NULL DEFAULT 0, \
+ FOREIGN KEY('account_id') REFERENCES 'account'('account_id') ON DELETE CASCADE, \
+ UNIQUE('room', 'account_id'), \
+ PRIMARY KEY('account_id', 'room') \
+ );"];
+ }];
+
+ [self updateDB:db withDataLayer:dataLayer toVersion:5.009 withBlock:^{
+ // add foreign key to signalContactSession
+ [db executeNonQuery:@"ALTER TABLE signalContactSession RENAME TO _signalContactSessionTMP;"];
+ [db executeNonQuery:@"CREATE TABLE 'signalContactSession' ( \
+ 'account_id' int NOT NULL, \
+ 'contactName' text, \
+ 'contactDeviceId' int NOT NULL, \
+ 'recordData' BLOB, \
+ PRIMARY KEY('account_id','contactName','contactDeviceId'), \
+ FOREIGN KEY('account_id') REFERENCES 'account'('account_id') ON DELETE CASCADE, \
+ FOREIGN KEY('account_id', 'contactName') REFERENCES 'buddylist'('account_id', 'buddy_name') ON DELETE CASCADE \
+ );"];
+ [db executeNonQuery:@"DELETE FROM _signalContactSessionTMP WHERE account_id NOT IN (SELECT account_id FROM account)"];
+ [db executeNonQuery:@"DELETE FROM _signalContactSessionTMP WHERE (account_id, contactName) NOT IN (SELECT account_id, buddy_name FROM buddylist)"];
+ [db executeNonQuery:@"INSERT INTO signalContactSession SELECT * FROM _signalContactSessionTMP;"];
+ [db executeNonQuery:@"DROP TABLE _signalContactSessionTMP;"];
+
+ // add foreign key to signalIdentity
+ [db executeNonQuery:@"ALTER TABLE signalIdentity RENAME TO _signalIdentityTMP;"];
+ [db executeNonQuery:@"CREATE TABLE 'signalIdentity' ( \
+ 'account_id' int NOT NULL, \
+ 'deviceid' int NOT NULL, \
+ 'identityPublicKey' BLOB NOT NULL, \
+ 'identityPrivateKey' BLOB NOT NULL, \
+ PRIMARY KEY('account_id', 'deviceid'), \
+ FOREIGN KEY('account_id') REFERENCES 'account'('account_id') ON DELETE CASCADE \
+ );"];
+ [db executeNonQuery:@"DELETE FROM _signalIdentityTMP WHERE account_id NOT IN (SELECT account_id FROM account)"];
+ [db executeNonQuery:@"INSERT INTO signalIdentity (account_id, deviceid, identityPublicKey, identityPrivateKey) SELECT account_id, deviceid, identityPublicKey, identityPrivateKey FROM _signalIdentityTMP;"];
+ [db executeNonQuery:@"DROP TABLE _signalIdentityTMP;"];
+
+ // add foreign key to signalPreKey
+ [db executeNonQuery:@"ALTER TABLE signalPreKey RENAME TO _signalPreKeyTMP;"];
+ [db executeNonQuery:@"CREATE TABLE 'signalPreKey' ( \
+ 'account_id' int NOT NULL, \
+ 'prekeyid' int NOT NULL, \
+ 'preKey' BLOB, \
+ 'creationTimestamp' INTEGER NOT NULL DEFAULT CURRENT_TIMESTAMP, \
+ 'pubSubRemovalTimestamp' INTEGER DEFAULT NULL, \
+ 'keyUsed' INTEGER NOT NULL DEFAULT 0, \
+ PRIMARY KEY('account_id', 'prekeyid'), \
+ FOREIGN KEY('account_id') REFERENCES 'account'('account_id') ON DELETE CASCADE \
+ );"];
+ [db executeNonQuery:@"DELETE FROM _signalPreKeyTMP WHERE account_id NOT IN (SELECT account_id FROM account)"];
+ [db executeNonQuery:@"INSERT INTO signalPreKey SELECT * FROM _signalPreKeyTMP;"];
+ [db executeNonQuery:@"DROP TABLE _signalPreKeyTMP;"];
+
+ // add foreign key to signalSignedPreKey
+ [db executeNonQuery:@"ALTER TABLE signalSignedPreKey RENAME TO _signalSignedPreKeyTMP;"];
+ [db executeNonQuery:@"CREATE TABLE 'signalSignedPreKey' ( \
+ 'account_id' int NOT NULL, \
+ 'signedPreKeyId' int NOT NULL, \
+ 'signedPreKey' BLOB, \
+ PRIMARY KEY('account_id', 'signedPreKeyId'), \
+ FOREIGN KEY('account_id') REFERENCES 'account'('account_id') ON DELETE CASCADE \
+ );"];
+ [db executeNonQuery:@"DELETE FROM _signalSignedPreKeyTMP WHERE account_id NOT IN (SELECT account_id FROM account)"];
+ [db executeNonQuery:@"DELETE FROM _signalSignedPreKeyTMP WHERE (ROWID, account_id, signedPreKeyId, signedPreKey) NOT IN (SELECT ROWID, account_id, signedPreKeyId, signedPreKey FROM _signalSignedPreKeyTMP GROUP BY account_id);"];
+ [db executeNonQuery:@"INSERT INTO signalSignedPreKey SELECT * FROM _signalSignedPreKeyTMP;"];
+ [db executeNonQuery:@"DROP TABLE _signalSignedPreKeyTMP;"];
+ }];
+
+ [self updateDB:db withDataLayer:dataLayer toVersion:5.010 withBlock:^{
+ // add foreign key to activechats
+ [db executeNonQuery:@"ALTER TABLE activechats RENAME TO _activechatsTMP;"];
+ [db executeNonQuery:@"CREATE TABLE 'activechats' ( \
+ 'account_id' integer NOT NULL, \
+ 'buddy_name' varchar(50) NOT NULL COLLATE nocase, \
+ 'lastMessageTime' datetime, \
+ 'lastMesssage' blob, \
+ 'pinned' bool NOT NULL DEFAULT FALSE, \
+ PRIMARY KEY('account_id', 'buddy_name'), \
+ FOREIGN KEY('account_id') REFERENCES 'account'('account_id') ON DELETE CASCADE, \
+ FOREIGN KEY('account_id', 'buddy_name') REFERENCES 'buddylist'('account_id', 'buddy_name') ON DELETE CASCADE \
+ );"];
+ [db executeNonQuery:@"DELETE FROM _activechatsTMP WHERE account_id NOT IN (SELECT account_id FROM account)"];
+ [db executeNonQuery:@"DELETE FROM _activechatsTMP WHERE (account_id, buddy_name) NOT IN (SELECT account_id, buddy_name FROM buddylist)"];
+ [db executeNonQuery:@"INSERT INTO activechats SELECT * FROM _activechatsTMP;"];
+ [db executeNonQuery:@"DROP TABLE _activechatsTMP;"];
+
+ // add foreign key to activechats
+ [db executeNonQuery:@"ALTER TABLE buddylist RENAME TO _buddylistTMP;"];
+ [db executeNonQuery:@"CREATE TABLE 'buddylist' ( \
+ 'buddy_id' integer NOT NULL, \
+ 'account_id' integer NOT NULL, \
+ 'buddy_name' varchar(255) COLLATE nocase, \
+ 'full_name' varchar(255), \
+ 'nick_name' varchar(255), \
+ 'iconhash' varchar(200), \
+ 'state' varchar(20), \
+ 'status' varchar(200), \
+ 'Muc' bool, \
+ 'muc_subject' varchar(1024), \
+ 'muc_nick' varchar(255), \
+ 'backgroundImage' text, \
+ 'encrypt' bool, \
+ 'subscription' varchar(50), \
+ 'ask' varchar(50), \
+ 'messageDraft' text, \
+ 'lastInteraction' INTEGER NOT NULL DEFAULT 0, \
+ 'blocked' BOOL DEFAULT FALSE, \
+ 'muc_type' VARCHAR(10) DEFAULT 'channel', \
+ 'lastMucStanzaId' text DEFAULT NULL, \
+ UNIQUE('account_id', 'buddy_name'), \
+ PRIMARY KEY('buddy_id' AUTOINCREMENT), \
+ FOREIGN KEY('account_id') REFERENCES 'account'('account_id') ON DELETE CASCADE \
+ );"];
+ [db executeNonQuery:@"DELETE FROM _buddylistTMP WHERE account_id NOT IN (SELECT account_id FROM account)"];
+ [db executeNonQuery:@"INSERT INTO buddylist SELECT * FROM _buddylistTMP;"];
+ [db executeNonQuery:@"DROP TABLE _buddylistTMP;"];
+
+ // add foreign key to buddy_resources
+ [db executeNonQuery:@"ALTER TABLE buddy_resources RENAME TO _buddy_resourcesTMP;"];
+ [db executeNonQuery:@"CREATE TABLE 'buddy_resources' ( \
+ 'buddy_id' integer NOT NULL, \
+ 'resource' varchar(255, 0) NOT NULL, \
+ 'ver' varchar(20, 0), \
+ 'platform_App_Name' text, \
+ 'platform_App_Version' text, \
+ 'platform_OS' text, \
+ PRIMARY KEY('buddy_id','resource'), \
+ FOREIGN KEY('buddy_id') REFERENCES 'buddylist'('buddy_id') ON DELETE CASCADE \
+ );"];
+ [db executeNonQuery:@"DELETE FROM _buddy_resourcesTMP WHERE buddy_id NOT IN (SELECT buddy_id FROM buddylist)"];
+ [db executeNonQuery:@"DELETE FROM _buddy_resourcesTMP WHERE (ROWID, buddy_id, resource) NOT IN (SELECT ROWID, buddy_id, resource FROM _buddy_resourcesTMP GROUP BY buddy_id, resource);"];
+ [db executeNonQuery:@"INSERT INTO buddy_resources (buddy_id, resource, ver, platform_App_Name, platform_App_Version, platform_OS) SELECT buddy_id, resource, ver, platform_App_Name, platform_App_Version, platform_OS FROM _buddy_resourcesTMP;"];
+ [db executeNonQuery:@"DROP TABLE _buddy_resourcesTMP;"];
+ }];
+
+ [self updateDB:db withDataLayer:dataLayer toVersion:5.011 withBlock:^{
+ [db executeNonQuery:@"CREATE TABLE 'muc_participants' ( \
+ 'account_id' INTEGER NOT NULL, \
+ 'room' VARCHAR(255) NOT NULL, \
+ 'room_nick' VARCHAR(255) NOT NULL, \
+ 'participant_jid' VARCHAR(255), \
+ 'affiliation' VARCHAR(255), \
+ 'role' VARCHAR(255), \
+ PRIMARY KEY('account_id','room','room_nick'), \
+ FOREIGN KEY('account_id') REFERENCES 'account'('account_id') ON DELETE CASCADE, \
+ FOREIGN KEY('account_id', 'room') REFERENCES 'buddylist'('account_id', 'buddy_name') ON DELETE CASCADE \
+ );"];
+ }];
+
+ [self updateDB:db withDataLayer:dataLayer toVersion:5.012 withBlock:^{
+ [db executeNonQuery:@"ALTER TABLE buddylist ADD COLUMN muted BOOL DEFAULT FALSE"];
+ }];
+
+ [self updateDB:db withDataLayer:dataLayer toVersion:5.013 withBlock:^{
+ [db executeNonQuery:@"ALTER TABLE signalContactIdentity ADD COLUMN brokenSession BOOL DEFAULT FALSE"];
+ }];
+
+ [self updateDB:db withDataLayer:dataLayer toVersion:5.014 withBlock:^{
+ [db executeNonQuery:@"ALTER TABLE message_history RENAME TO _message_historyTMP;"];
+ // Create a backup before changing a lot of the table style
+ [db executeNonQuery:@"CREATE TABLE message_history_backup AS SELECT * FROM _message_historyTMP WHERE 0"];
+ [db executeNonQuery:@"INSERT INTO message_history_backup SELECT * FROM _message_historyTMP;"];
+ [db executeNonQuery:@"CREATE TABLE 'message_history' ( \
+ 'message_history_id' integer NOT NULL, \
+ 'account_id' integer NOT NULL, \
+ 'buddy_name' TEXT NOT NULL, \
+ 'inbound' BOOL NOT NULL DEFAULT 0, \
+ 'timestamp' datetime NOT NULL, \
+ 'message' blob NOT NULL, \
+ 'actual_from' text COLLATE nocase, \
+ 'messageid' text COLLATE nocase, \
+ 'messageType' text, \
+ 'sent' bool, \
+ 'received' bool, \
+ 'unread' bool, \
+ 'encrypted' bool DEFAULT FALSE, \
+ 'previewText' text, \
+ 'previewImage' text, \
+ 'stanzaid' text COLLATE nocase, \
+ 'errorType' text COLLATE nocase, \
+ 'errorReason' text, \
+ 'displayed' BOOL DEFAULT FALSE, \
+ 'displayMarkerWanted' BOOL DEFAULT FALSE, \
+ 'filetransferMimeType' VARCHAR(32) DEFAULT NULL, \
+ 'filetransferSize' INTEGER DEFAULT NULL, \
+ PRIMARY KEY('message_history_id' AUTOINCREMENT), \
+ FOREIGN KEY('account_id') REFERENCES 'account'('account_id') ON DELETE CASCADE, \
+ FOREIGN KEY('account_id', 'buddy_name') REFERENCES 'buddylist'('account_id', 'buddy_name') ON DELETE CASCADE \
+ );"];
+ [db executeNonQuery:@"DELETE FROM _message_historyTMP WHERE account_id NOT IN (SELECT account_id FROM account)"];
+ // delete all group chats and all chats that don't have a valid buddy
+ [db executeNonQuery:@"DELETE FROM _message_historyTMP WHERE message_history_id IN (\
+ SELECT message_history_id \
+ FROM _message_historyTMP AS M INNER JOIN account AS A \
+ ON M.account_id=A.account_id \
+ WHERE (M.message_from!=(A.username || '@' || A.domain) AND M.message_to!=(A.username || '@' || A.domain))\
+ )\
+ "];
+ [db executeNonQuery:@"INSERT INTO message_history \
+ (message_history_id, account_id, buddy_name, inbound, timestamp, message, actual_from, messageid, messageType, sent, received, unread, encrypted, previewText, previewImage, stanzaid, errorType, errorReason, displayed, displayMarkerWanted, filetransferMimeType, filetransferSize) \
+ SELECT \
+ M.message_history_id, M.account_id, \
+ CASE \
+ WHEN M.message_from=(A.username || '@' || A.domain) THEN M.message_to \
+ ELSE M.message_from \
+ END AS buddy_name, \
+ CASE \
+ WHEN M.message_from=(A.username || '@' || A.domain) THEN 0 \
+ ELSE 1 \
+ END AS inbound, \
+ M.timestamp, M.message, M.actual_from, M.messageid, M.messageType, M.sent, M.received, M.unread, M.encrypted, M.previewText, M.previewImage, M.stanzaid, M.errorType, M.errorReason, M.displayed, M.displayMarkerWanted, M.filetransferMimeType, M.filetransferSize \
+ FROM _message_historyTMP AS M INNER JOIN account AS A ON M.account_id=A.account_id;\
+ "];
+ // delete muc messages
+ [db executeNonQuery:@"DELETE FROM message_history WHERE message_history_id IN (\
+ SELECT message_history_id \
+ FROM message_history AS M INNER JOIN buddylist AS B\
+ ON M.account_id=B.account_id AND M.buddy_name=B.buddy_name \
+ WHERE B.Muc=1) \
+ "];
+ [db executeNonQuery:@"DROP TABLE _message_historyTMP;"];
+ }];
+
+ [self updateDB:db withDataLayer:dataLayer toVersion:5.015 withBlock:^{
+ [db executeNonQuery:@"CREATE TABLE 'muc_members' ( \
+ 'account_id' INTEGER NOT NULL, \
+ 'room' VARCHAR(255) NOT NULL, \
+ 'member_jid' VARCHAR(255), \
+ 'affiliation' VARCHAR(255), \
+ PRIMARY KEY('account_id','room','member_jid'), \
+ FOREIGN KEY('account_id') REFERENCES 'account'('account_id') ON DELETE CASCADE, \
+ FOREIGN KEY('account_id', 'room') REFERENCES 'buddylist'('account_id', 'buddy_name') ON DELETE CASCADE \
+ );"];
+ }];
+
+ // Migrate muteList to new format and delete old table
+ [self updateDB:db withDataLayer:dataLayer toVersion:5.016 withBlock:^{
+ [db executeNonQuery:@"UPDATE buddylist SET muted=1 \
+ WHERE buddy_name IN ( \
+ SELECT DISTINCT jid FROM muteList \
+ );"];
+ [db executeNonQuery:@"DROP TABLE muteList;"];
+ }];
+
+ // Delete all muc's
+ [self updateDB:db withDataLayer:dataLayer toVersion:5.017 withBlock:^{
+ [db executeNonQuery:@"DELETE FROM buddylist WHERE Muc=1;"];
+ [db executeNonQuery:@"DELETE FROM muc_participants;"];
+ [db executeNonQuery:@"DELETE FROM muc_members;"];
+ [db executeNonQuery:@"DELETE FROM muc_favorites;"];
+ }];
+
+ [self updateDB:db withDataLayer:dataLayer toVersion:5.018 withBlock:^{
+ [db executeNonQuery:@"ALTER TABLE message_history ADD COLUMN participant_jid TEXT DEFAULT NULL"];
+ }];
+
+ // delete message_history backup table
+ [self updateDB:db withDataLayer:dataLayer toVersion:5.019 withBlock:^{
+ [db executeNonQuery:@"DROP TABLE message_history_backup;"];
+ }];
+
+ //update muc favorites to have the autojoin flag set
+ [self updateDB:db withDataLayer:dataLayer toVersion:5.020 withBlock:^{
+ [db executeNonQuery:@"UPDATE muc_favorites SET autojoin=1;"];
+ }];
+
+ // jid's should be lower only
+ [self updateDB:db withDataLayer:dataLayer toVersion:5.021 withBlock:^{
+ [db executeNonQuery:@"UPDATE account SET username=LOWER(username), domain=LOWER(domain);"];
+ [db executeNonQuery:@"UPDATE activechats SET buddy_name=lower(buddy_name);"];
+ [db executeNonQuery:@"UPDATE buddylist SET buddy_name=LOWER(buddy_name);"];
+ [db executeNonQuery:@"UPDATE message_history SET buddy_name=LOWER(buddy_name), actual_from=LOWER(actual_from), participant_jid=LOWER(participant_jid);"];
+ [db executeNonQuery:@"UPDATE muc_members SET room=LOWER(room);"];
+ [db executeNonQuery:@"UPDATE muc_participants SET room=LOWER(room);"];
+ [db executeNonQuery:@"UPDATE muc_participants SET room=LOWER(room);"];
+ [db executeNonQuery:@"UPDATE signalContactIdentity SET contactName=LOWER(contactName);"];
+ [db executeNonQuery:@"UPDATE signalContactSession SET contactName=LOWER(contactName);"];
+ [db executeNonQuery:@"UPDATE subscriptionRequests SET buddy_name=LOWER(buddy_name);"];
+ }];
+
+ [self updateDB:db withDataLayer:dataLayer toVersion:5.022 withBlock:^{
+ [db executeNonQuery:@"ALTER TABLE subscriptionRequests RENAME TO _subscriptionRequestsTMP;"];
+ [db executeNonQuery:@"CREATE TABLE 'subscriptionRequests' ( \
+ 'account_id' integer NOT NULL, \
+ 'buddy_name' varchar(255) NOT NULL, \
+ FOREIGN KEY('account_id') REFERENCES 'account'('account_id') ON DELETE CASCADE, \
+ PRIMARY KEY('account_id','buddy_name') \
+ );"];
+ [db executeNonQuery:@"DELETE FROM _subscriptionRequestsTMP WHERE account_id NOT IN (SELECT account_id FROM account)"];
+ [db executeNonQuery:@"INSERT INTO subscriptionRequests (account_id, buddy_name) SELECT account_id, buddy_name FROM _subscriptionRequestsTMP;"];
+ [db executeNonQuery:@"DROP TABLE _subscriptionRequestsTMP;"];
+ }];
+
+ [self updateDB:db withDataLayer:dataLayer toVersion:5.023 withBlock:^{
+ [db executeNonQuery:@"ALTER TABLE muc_favorites RENAME TO _muc_favoritesTMP;"];
+ [db executeNonQuery:@"CREATE TABLE 'muc_favorites' ( \
+ 'account_id' INTEGER NOT NULL, \
+ 'room' VARCHAR(255) NOT NULL, \
+ 'nick' varchar(255), \
+ FOREIGN KEY('account_id') REFERENCES 'account'('account_id') ON DELETE CASCADE, \
+ UNIQUE('room', 'account_id'), \
+ PRIMARY KEY('account_id', 'room') \
+ );"];
+ [db executeNonQuery:@"INSERT INTO muc_favorites (account_id, room, nick) SELECT account_id, room, nick FROM _muc_favoritesTMP;"];
+ [db executeNonQuery:@"DROP TABLE _muc_favoritesTMP;"];
+ }];
+
+ [self updateDB:db withDataLayer:dataLayer toVersion:5.024 withBlock:^{
+ //nicknames should be compared case sensitive --> change collation
+ //we don't need to migrate our table data because the db upgrade triggers a xmpp reconnect and this in turn triggers
+ //a new muc join which does clear this table anyways
+ [db executeNonQuery:@"DROP TABLE muc_participants;"];
+ [db executeNonQuery:@"CREATE TABLE 'muc_participants' ( \
+ 'account_id' INTEGER NOT NULL, \
+ 'room' VARCHAR(255) NOT NULL, \
+ 'room_nick' VARCHAR(255) NOT NULL COLLATE binary, \
+ 'participant_jid' VARCHAR(255), \
+ 'affiliation' VARCHAR(255), \
+ 'role' VARCHAR(255), \
+ PRIMARY KEY('account_id','room','room_nick'), \
+ FOREIGN KEY('account_id') REFERENCES 'account'('account_id') ON DELETE CASCADE, \
+ FOREIGN KEY('account_id', 'room') REFERENCES 'buddylist'('account_id', 'buddy_name') ON DELETE CASCADE \
+ );"];
+ }];
+
+ [self updateDB:db withDataLayer:dataLayer toVersion:5.026 withBlock:^{
+ //new outbox table for sharesheet
+ [db executeNonQuery:@"CREATE TABLE 'sharesheet_outbox' ( \
+ 'id' INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, \
+ 'account_id' INTEGER NOT NULL, \
+ 'recipient' VARCHAR(255) NOT NULL, \
+ 'type' VARCHAR(32), \
+ 'data' VARCHAR(1023), \
+ 'comment' VARCHAR(255), \
+ FOREIGN KEY('account_id') REFERENCES 'account'('account_id') ON DELETE CASCADE, \
+ FOREIGN KEY('account_id', 'recipient') REFERENCES 'buddylist'('account_id', 'buddy_name') ON DELETE CASCADE \
+ );"];
+ [[HelperTools defaultsDB] removeObjectForKey:@"outbox"];
+ [[HelperTools defaultsDB] synchronize];
+ }];
+
+ [self updateDB:db withDataLayer:dataLayer toVersion:5.101 withBlock:^{
+ //save the smallest unread id for faster retrieval of unread message count per contact
+ //we use -1because all queries using this test for message_history_id > this field, not >=
+ [db executeNonQuery:@"ALTER TABLE 'buddylist' ADD COLUMN 'latest_read_message_history_id' INTEGER NOT NULL DEFAULT -1;"];
+ }];
+
+ [self updateDB:db withDataLayer:dataLayer toVersion:5.103 withBlock:^{
+ //make sure the latest_read_message_history_id is filled with correct initial values
+ [db executeNonQuery:@"UPDATE buddylist AS b SET latest_read_message_history_id=COALESCE((\
+ SELECT message_history_id FROM message_history AS h\
+ WHERE h.account_id=b.account_id AND h.buddy_name=b.buddy_name AND unread=1 AND inbound=1\
+ ORDER BY h.message_history_id ASC LIMIT 1\
+ )-1, (\
+ SELECT message_history_id FROM message_history ORDER BY message_history_id DESC LIMIT 1\
+ ), 0);"];
+ }];
+
+ [self updateDB:db withDataLayer:dataLayer toVersion:5.104 withBlock:^{
+ //database table for storage of delayed message stanzas during catchup phase (we store this into a database to make sure we don't consume too much memory)
+ [db executeNonQuery:@"CREATE TABLE 'delayed_message_stanzas' ( \
+ 'id' INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, \
+ 'account_id' INTEGER NOT NULL, \
+ 'archive_jid' BLOB NOT NULL, \
+ 'stanza' VARCHAR(32), \
+ FOREIGN KEY('account_id') REFERENCES 'account'('account_id') ON DELETE CASCADE, \
+ FOREIGN KEY('account_id', 'archive_jid') REFERENCES 'buddylist'('account_id', 'buddy_name') ON DELETE CASCADE \
+ );"];
+ }];
+
+ [self updateDB:db withDataLayer:dataLayer toVersion:5.105 withBlock:^{
+ [db executeNonQuery:@"ALTER TABLE buddylist ADD COLUMN mentionOnly BOOL DEFAULT FALSE"];
+ }];
+
+ [self updateDB:db withDataLayer:dataLayer toVersion:5.106 withBlock:^{
+ [db executeNonQuery:@"DROP TABLE signalContactKey;"];
+ }];
+
+ /* this gap between 5.106 and 5.112 is intentional and should not be filled */
+
+ //this flag remains on for unclean appex shutdowns and can be used to warn (alpha) users about this
+ [self updateDB:db withDataLayer:dataLayer toVersion:5.112 withBlock:^{
+ [db executeNonQuery:@"INSERT INTO flags (name, value) VALUES('clean_appex_shutdown', '1');"];
+ }];
+
+ //remove all cached hashes and saved avatar images
+ //--> avatar images will be loaded on next non-smacks connect (because of the incoming metadata +notify on full reconnect)
+ //and replace the already saved avatar files
+ //NOTE: next reconnect is now(!) due to the upgraded db version
+ [self updateDB:db withDataLayer:dataLayer toVersion:5.113 withBlock:^{
+ [db executeNonQuery:@"UPDATE buddylist SET iconhash='';"];
+ [[MLImageManager sharedInstance] removeAllIcons];
+ }];
+
+ // migrate account_id column in blocklistCache to integer
+ [self updateDB:db withDataLayer:dataLayer toVersion:5.114 withBlock:^{
+ [db executeNonQuery:@"ALTER TABLE blocklistCache RENAME TO _blocklistCacheTMP;"];
+ [db executeNonQuery:@"CREATE TABLE 'blocklistCache' (\
+ 'account_id' INTEGER NOT NULL, \
+ 'node' TEXT, \
+ 'host' TEXT, \
+ 'resource' TEXT, \
+ UNIQUE('account_id','node','host','resource'), \
+ CHECK( \
+ (LENGTH('node') > 0 AND LENGTH('host') > 0 AND LENGTH('resource') > 0) \
+ OR \
+ (LENGTH('node') > 0 AND LENGTH('host') > 0) \
+ OR \
+ (LENGTH('host') > 0 AND LENGTH('resource') > 0) \
+ OR \
+ (LENGTH('host') > 0) \
+ ), \
+ FOREIGN KEY('account_id') REFERENCES 'account'('account_id') ON DELETE CASCADE \
+ );"];
+ [db executeNonQuery:@"DELETE FROM _blocklistCacheTMP WHERE account_id NOT IN (SELECT account_id FROM account)"];
+ [db executeNonQuery:@"INSERT INTO blocklistCache SELECT * FROM _blocklistCacheTMP;"];
+ [db executeNonQuery:@"DROP TABLE _blocklistCacheTMP;"];
+ }];
+
+ // relax foreign key constraints for omemo tables
+ // muc participants might not be a buddy
+ [self updateDB:db withDataLayer:dataLayer toVersion:5.115 withBlock:^{
+ // migrate signalContactIdentity
+ [db executeNonQuery:@"ALTER TABLE signalContactIdentity RENAME TO _signalContactIdentityTMP;"];
+ [db executeNonQuery:@"CREATE TABLE 'signalContactIdentity' (\
+ 'account_id' INTEGER NOT NULL,\
+ 'contactName' TEXT NOT NULL,\
+ 'contactDeviceId' INTEGER NOT NULL,\
+ 'identity' BLOB,\
+ 'lastReceivedMsg' INTEGER DEFAULT NULL,\
+ 'removedFromDeviceList' INTEGER DEFAULT NULL,\
+ 'trustLevel' INTEGER NOT NULL DEFAULT 1, brokenSession BOOL DEFAULT FALSE,\
+ PRIMARY KEY('account_id', 'contactName', 'contactDeviceId'),\
+ FOREIGN KEY('account_id') REFERENCES 'account'('account_id') ON DELETE CASCADE\
+ )"];
+ [db executeNonQuery:@"INSERT INTO signalContactIdentity SELECT * FROM _signalContactIdentityTMP;"];
+ [db executeNonQuery:@"DROP TABLE _signalContactIdentityTMP;"];
+
+ // migrate signalContactSession
+ [db executeNonQuery:@"ALTER TABLE signalContactSession RENAME TO _signalContactSessionTMP;"];
+ [db executeNonQuery:@"CREATE TABLE 'signalContactSession' ( \
+ 'account_id' INTEGER NOT NULL, \
+ 'contactName' text NOT NULL, \
+ 'contactDeviceId' INTEGER NOT NULL, \
+ 'recordData' BLOB, \
+ PRIMARY KEY('account_id','contactName','contactDeviceId'), \
+ FOREIGN KEY('account_id') REFERENCES 'account'('account_id') ON DELETE CASCADE \
+ );"];
+ [db executeNonQuery:@"INSERT INTO signalContactSession SELECT * FROM _signalContactSessionTMP;"];
+ [db executeNonQuery:@"DROP TABLE _signalContactSessionTMP;"];
+ }];
+
+ [self updateDB:db withDataLayer:dataLayer toVersion:5.116 withBlock:^{
+ [db executeNonQuery:@"ALTER TABLE delayed_message_stanzas RENAME TO _delayed_message_stanzasTMP;"];
+ [db executeNonQuery:@"CREATE TABLE 'delayed_message_stanzas' ( \
+ 'id' INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, \
+ 'account_id' INTEGER NOT NULL, \
+ 'archive_jid' BLOB NOT NULL, \
+ 'stanza' VARCHAR(32), \
+ FOREIGN KEY('account_id') REFERENCES 'account'('account_id') ON DELETE CASCADE\
+ );"];
+ [db executeNonQuery:@"INSERT INTO delayed_message_stanzas SELECT * FROM _delayed_message_stanzasTMP;"];
+ [db executeNonQuery:@"DROP TABLE _delayed_message_stanzasTMP;"];
+ }];
+
+ // remove old self chat buddies needed for omemo
+ [self updateDB:db withDataLayer:dataLayer toVersion:5.117 withBlock:^{
+ [db executeNonQuery:@"DELETE \
+ FROM buddylist \
+ WHERE \
+ ROWID IN ( \
+ SELECT b.ROWID \
+ FROM buddylist AS b \
+ INNER JOIN account AS a \
+ ON a.account_id=b.account_id \
+ WHERE b.buddy_name==(a.username || '@' || a.domain) \
+ ) \
+ "];
+ }];
+
+ //clear roster version to remove all non-muc roster entries pointing to a muc jid
+ [self updateDB:db withDataLayer:dataLayer toVersion:5.118 withBlock:^{
+ [db executeNonQuery:@"UPDATE account SET rosterVersion=NULL;"];
+ }];
+
+ //change data column in sharesheet outbox table to TEXT instead of length-bound VARCHAR and truncate table to make sure we don't have NULL data entries
+ [self updateDB:db withDataLayer:dataLayer toVersion:5.119 withBlock:^{
+ [db executeNonQuery:@"ALTER TABLE sharesheet_outbox DROP COLUMN data;"];
+ [db executeNonQuery:@"ALTER TABLE sharesheet_outbox ADD COLUMN data TEXT DEFAULT NULL;"];
+ [db executeNonQuery:@"DELETE FROM sharesheet_outbox;"];
+ }];
+
+ [self updateDB:db withDataLayer:dataLayer toVersion:5.120 withBlock:^{
+ //dummy upgrade to make sure all state gets invalidated because of new MLHandler behaviour (mandatory arguments)
+ }];
+
+ // add push server column to accounts
+ [self updateDB:db withDataLayer:dataLayer toVersion:5.201 withBlock:^{
+ [db executeNonQuery:@"ALTER TABLE account ADD COLUMN registeredPushServer TEXT DEFAULT NULL;"];
+ #ifdef IS_ALPHA
+ NSString* currentPushserver = @"push.molitor-dietzel.de";
+ #else
+ NSString* currentPushserver = @"ios13push.monal.im";
+ #endif
+ [db executeNonQuery:@"UPDATE account SET registeredPushServer=?;" andArguments:@[currentPushserver]];
+ }];
+
+ [self updateDB:db withDataLayer:dataLayer toVersion:5.202 withBlock:^{
+ //dummy upgrade to make sure all state gets invalidated because of new mandatory {MLFiletransfer, handleHardlinking} arguments
+ }];
+
+ [self updateDB:db withDataLayer:dataLayer toVersion:5.203 withBlock:^{
+ // ensure that we TOFU trust our own device ids
+ [db executeNonQuery:@"UPDATE signalContactIdentity \
+ SET trustLevel=1 \
+ WHERE \
+ ROWID IN ( \
+ SELECT sci.ROWID \
+ FROM account as a \
+ INNER JOIN signalIdentity as si \
+ ON a.account_id = si.account_id \
+ INNER JOIN signalContactIdentity as sci \
+ ON sci.account_id = a.account_id \
+ AND si.deviceid = sci.contactDeviceId \
+ WHERE \
+ sci.trustLevel = 0 \
+ ) \
+ ;"];
+ }];
+
+ //add needs_password_migration field to accounts db
+ [self updateDB:db withDataLayer:dataLayer toVersion:5.301 withBlock:^{
+ [db executeNonQuery:@"ALTER TABLE account ADD COLUMN needs_password_migration BOOL DEFAULT false;"];
+ }];
+
+ [self updateDB:db withDataLayer:dataLayer toVersion:5.302 withBlock:^{
+ //dummy upgrade to make sure all state gets invalidated, we want to be sure push gets correctly enabled
+ }];
+
+ //remove unused sharesheet outbox column "comment"
+ [self updateDB:db withDataLayer:dataLayer toVersion:5.303 withBlock:^{
+ [db executeNonQuery:@"ALTER TABLE sharesheet_outbox DROP COLUMN comment;"];
+ }];
+
+ //add new column for SASL2 pinning
+ [self updateDB:db withDataLayer:dataLayer toVersion:5.304 withBlock:^{
+ [db executeNonQuery:@"ALTER TABLE account ADD COLUMN supports_sasl2 BOOL DEFAULT false;"];
+ }];
+
+ //add device id to flags table
+ [self updateDB:db withDataLayer:dataLayer toVersion:5.305 withBlock:^{
+ [db executeNonQuery:@"INSERT INTO flags (name, value) VALUES('device_id', ?);" andArguments:@[UIDevice.currentDevice.identifierForVendor.UUIDString]];
+ }];
+
+ //add retracted flag to message history table
+ [self updateDB:db withDataLayer:dataLayer toVersion:6.001 withBlock:^{
+ [db executeNonQuery:@"ALTER TABLE message_history ADD COLUMN retracted BOOL DEFAULT false;"];
+ }];
+
+ //create idle timer table
+ [self updateDB:db withDataLayer:dataLayer toVersion:6.002 withBlock:^{
+ [db executeNonQuery:@"CREATE TABLE 'idle_timers' ( \
+ 'id' INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, \
+ 'timeout' INTEGER NOT NULL, \
+ 'handler' BLOB NOT NULL, \
+ 'account_id' INTEGER NOT NULL, \
+ FOREIGN KEY('account_id') REFERENCES 'account'('account_id') ON DELETE CASCADE\
+ );"];
+ }];
+
+ //create self-chats
+ [self updateDB:db withDataLayer:dataLayer toVersion:6.003 withBlock:^{
+ for(NSDictionary* dictionary in [dataLayer accountList])
+ [dataLayer addContact:[NSString stringWithFormat:@"%@@%@", dictionary[kUsername], dictionary[kDomain]] forAccount:dictionary[kAccountID] nickname:nil];
+ }];
+
+ [self updateDB:db withDataLayer:dataLayer toVersion:6.004 withBlock:^{
+ [db executeNonQuery:@"ALTER TABLE buddy_resources ADD COLUMN lastInteraction INTEGER NOT NULL DEFAULT 0;"];
+ [db executeNonQuery:@"ALTER TABLE buddylist DROP COLUMN lastInteraction;"];
+ }];
+
+ [self updateDB:db withDataLayer:dataLayer toVersion:6.005 withBlock:^{
+ [db executeNonQuery:@"ALTER TABLE signalContactIdentity ADD COLUMN lastFailedBundleFetch INTEGER DEFAULT NULL;"];
+ }];
+
+ [self updateDB:db withDataLayer:dataLayer toVersion:6.006 withBlock:^{
+ // remove session records without a corresponding trust info
+ [db executeNonQuery:@"DELETE FROM signalContactSession WHERE (account_id, contactName, contactDeviceId) NOT IN (SELECT account_id, contactName, contactDeviceId FROM signalContactIdentity);"];
+ // mark identities as broken if no session exists
+ [db executeNonQuery:@"UPDATE signalContactIdentity SET brokenSession=true WHERE (account_id, contactName, contactDeviceId) NOT IN (SELECT account_id, contactName, contactDeviceId FROM signalContactSession);"];
+ }];
+
+ //reintroduce lastInteraction column to buddylist to record the latest interaction independently of online resources
+ [self updateDB:db withDataLayer:dataLayer toVersion:6.007 withBlock:^{
+ [db executeNonQuery:@"ALTER TABLE buddylist ADD COLUMN lastInteraction INTEGER DEFAULT NULL;"];
+ }];
+
+ //friedrich said: do this to make sure we reregister push with the right type and token
+ [self updateDB:db withDataLayer:dataLayer toVersion:6.008 withBlock:^{
+ }];
+
+ [self updateDB:db withDataLayer:dataLayer toVersion:6.009 withBlock:^{
+ [db executeNonQuery:@"UPDATE account SET server=TRIM(server);"];
+ [db executeNonQuery:@"UPDATE account SET username=TRIM(username);"];
+ [db executeNonQuery:@"UPDATE account SET domain=TRIM(domain);"];
+ }];
+
+ [self updateDB:db withDataLayer:dataLayer toVersion:6.201 withBlock:^{
+ [db executeNonQuery:@"DROP TABLE IF EXISTS ver_info;"];
+ [db executeNonQuery:@"CREATE TABLE ver_info( \
+ ver VARCHAR(32), \
+ cap VARCHAR(255), \
+ timestamp INTEGER NOT NULL DEFAULT 0, \
+ account_id INTEGER NOT NULL, \
+ PRIMARY KEY (ver, cap, account_id) \
+ FOREIGN KEY('account_id') REFERENCES 'account'('account_id') ON DELETE CASCADE \
+ );"];
+ [db executeNonQuery:@"DROP TABLE IF EXISTS ver_timestamp;"];
+ }];
+
+ [self updateDB:db withDataLayer:dataLayer toVersion:6.202 withBlock:^{
+ //intentionally left blank
+ }];
+
+ //fix empty domain in db for weird setups
+ [self updateDB:db withDataLayer:dataLayer toVersion:6.203 withBlock:^{
+ [db executeNonQuery:@"UPDATE account SET domain=TRIM(server) WHERE domain='';"];
+ }];
+
+ //add new setting to force deactivate sasl2 and fallback to sals1 and plain
+ [self updateDB:db withDataLayer:dataLayer toVersion:6.204 withBlock:^{
+ [db executeNonQuery:@"ALTER TABLE account ADD COLUMN plain_activated BOOL DEFAULT false;"];
+
+ //make sure that all users are still able to connect if the server supports SASL2 and the account is disabled
+ //--> possibly disabled because it only supports PLAIN
+ //==> the next connect will (re)set the plain_activated and supports_sasl2 flags to the correct values
+ [db executeNonQuery:@"UPDATE account SET plain_activated=true, supports_sasl2=false WHERE NOT enabled AND supports_sasl2;"];
+ }];
+
+ //add occupant-id (XEP-0421) support
+ [self updateDB:db withDataLayer:dataLayer toVersion:6.401 withBlock:^{
+ [db executeNonQuery:@"ALTER TABLE muc_participants ADD COLUMN occupant_id VARCHAR(128) NULL DEFAULT NULL;"];
+ [db executeNonQuery:@"ALTER TABLE muc_members ADD COLUMN occupant_id VARCHAR(128) NULL DEFAULT NULL;"];
+ [db executeNonQuery:@"ALTER TABLE message_history ADD COLUMN occupant_id VARCHAR(128) NULL DEFAULT NULL;"];
+ }];
+
+ //fix last update
+ [self updateDB:db withDataLayer:dataLayer toVersion:6.402 withBlock:^{
+ [db executeNonQuery:@"CREATE UNIQUE INDEX unique_occupant ON muc_participants('room', 'account_id', 'occupant_id');"];
+ [db executeNonQuery:@"ALTER TABLE muc_members DROP COLUMN occupant_id;"];
+ }];
+
+ //make new mucs not have a default type instead of 'channel'
+ //(that means that the default encryption gets turned off when entering a channel and kept on when entering a group)
+ [self updateDB:db withDataLayer:dataLayer toVersion:6.403 withBlock:^{
+ [db executeNonQuery:@"ALTER TABLE buddylist RENAME COLUMN 'muc_type' to 'muc_type_old';"];
+ [db executeNonQuery:@"ALTER TABLE buddylist ADD COLUMN 'muc_type' VARCHAR(10) DEFAULT NULL;"];
+ [db executeNonQuery:@"UPDATE buddylist SET muc_type=muc_type_old;"];
+ [db executeNonQuery:@"ALTER TABLE buddylist DROP COLUMN 'muc_type_old';"];
+ }];
+
+ //make sure all existing mucs get their type and encryption state correctly updated, too
+ [self updateDB:db withDataLayer:dataLayer toVersion:6.404 withBlock:^{
+ [db executeNonQuery:@"UPDATE buddylist SET muc_type=NULL;"];
+ }];
+
+ //reactivate PLAIN auth on all accounts to allow proper upgrades to servers only supporting PLAIN even with SASL2
+ //this should fix issue #1186
+ [self updateDB:db withDataLayer:dataLayer toVersion:6.405 withBlock:^{
+ [db executeNonQuery:@"UPDATE account SET plain_activated=true WHERE supports_sasl2=false;"];
+ }];
+
+ //streamlined code with only plain_activated column
+ [self updateDB:db withDataLayer:dataLayer toVersion:6.406 withBlock:^{
+ [db executeNonQuery:@"ALTER TABLE account DROP COLUMN 'supports_sasl2';"];
+ }];
+
+ //allow for storage of roster groups
+ [self updateDB:db withDataLayer:dataLayer toVersion:6.407 withBlock:^{
+ [db executeNonQuery:@"CREATE TABLE 'buddy_groups' ( \
+ 'buddy_id' INTEGER NOT NULL, \
+ 'group_name' VARCHAR(50) NOT NULL, \
+ FOREIGN KEY('buddy_id') REFERENCES 'buddylist'('buddy_id') ON DELETE CASCADE, \
+ PRIMARY KEY('buddy_id', 'group_name') \
+ );"];
+ [db executeNonQuery:@"CREATE INDEX buddyIdIndex ON 'buddy_groups'('buddy_id');"];
+ }];
+
+ //add own occupant-id to database
+ [self updateDB:db withDataLayer:dataLayer toVersion:6.408 withBlock:^{
+ [db executeNonQuery:@"ALTER TABLE buddylist ADD COLUMN muc_occupant_id VARCHAR(128) NULL DEFAULT NULL;"];
+ }];
+
+ //allow NULL values for optional fields and make this explicit
+ //we don't need to migrate data because of our non-smacks reconnect on db upgrade
+ [self updateDB:db withDataLayer:dataLayer toVersion:6.409 withBlock:^{
+ [db executeNonQuery:@"ALTER TABLE muc_participants DROP COLUMN participant_jid;"];
+ [db executeNonQuery:@"ALTER TABLE muc_participants DROP COLUMN affiliation;"];
+ [db executeNonQuery:@"ALTER TABLE muc_participants DROP COLUMN role;"];
+ [db executeNonQuery:@"ALTER TABLE muc_participants ADD COLUMN participant_jid VARCHAR(255) NULL DEFAULT NULL;"];
+ [db executeNonQuery:@"ALTER TABLE muc_participants ADD COLUMN affiliation VARCHAR(255) NULL DEFAULT NULL;"];
+ [db executeNonQuery:@"ALTER TABLE muc_participants ADD COLUMN role VARCHAR(255) NULL DEFAULT NULL;"];
+
+ [db executeNonQuery:@"DROP TABLE muc_members;"];
+ [db executeNonQuery:@"CREATE TABLE 'muc_members' ( \
+ 'account_id' INTEGER NOT NULL, \
+ 'room' VARCHAR(255) NOT NULL, \
+ 'member_jid' VARCHAR(255) NULL DEFAULT NULL, \
+ 'affiliation' VARCHAR(255) NULL DEFAULT NULL, \
+ PRIMARY KEY('account_id','room','member_jid'), \
+ FOREIGN KEY('account_id') REFERENCES 'account'('account_id') ON DELETE CASCADE, \
+ FOREIGN KEY('account_id', 'room') REFERENCES 'buddylist'('account_id', 'buddy_name') ON DELETE CASCADE \
+ );"];
+ }];
+
+ //simplify the blocklistCache table
+ [self updateDB:db withDataLayer:dataLayer toVersion:6.410 withBlock:^{
+ //the cache is regenerated on log-in, thus there is no need to migrate the data
+ [db executeNonQuery:@"DROP TABLE blocklistCache;"];
+ [db executeNonQuery:@"CREATE TABLE 'blocklistCache' (\
+ 'account_id' INTEGER NOT NULL, \
+ 'blocked_jid' TEXT NOT_NULL CHECK(LENGTH(blocked_jid) > 0), \
+ UNIQUE('account_id','blocked_jid'), \
+ FOREIGN KEY('account_id') REFERENCES 'account'('account_id') ON DELETE CASCADE \
+ );"];
+ }];
+
+ //a contact's blocked state is deduced directly from the blocklistCache table.
+ //as such, this column is redundant.
+ [self updateDB:db withDataLayer:dataLayer toVersion:6.411 withBlock:^{
+ [db executeNonQuery:@"ALTER TABLE buddylist DROP COLUMN 'blocked';"];
+ }];
+
+
+ //check if device id changed and invalidate state, if so
+ //but do so only for non-sandbox (e.g. non-development) installs
+ if(![[HelperTools defaultsDB] boolForKey:@"isSandboxAPNS"])
+ {
+ NSString* stored_id = (NSString*)[db executeScalar:@"SELECT value FROM flags WHERE name='device_id';"];
+ NSString* current_id = UIDevice.currentDevice.identifierForVendor.UUIDString;
+ if(current_id == nil)
+ DDLogWarn(@"Deviceid is nil, not checking for deviceid change");
+ else if(![current_id isEqualToString:stored_id])
+ {
+ DDLogWarn(@"Device id has changed (%@ --> %@), invalidating state AND omemo identity keys!", stored_id, current_id);
+ //invalidate account state because the app was migrated to a new device
+ [dataLayer invalidateAllAccountStates];
+ //change resource because of app migration
+ for(NSMutableDictionary* accountDict in [[dataLayer accountList] mutableCopy])
+ {
+ accountDict[kResource] = [HelperTools encodeRandomResource];
+ [dataLayer updateAccounWithDictionary:accountDict];
+ }
+ //clean up signal store and generate new omemo keys (but don't change trust settings!)
+ [db executeNonQuery:@"DELETE FROM signalContactSession;"];
+ [db executeNonQuery:@"DELETE FROM signalIdentity;"];
+ [db executeNonQuery:@"DELETE FROM signalPreKey;"];
+ [db executeNonQuery:@"DELETE FROM signalSignedPreKey;"];
+ //update device id in db
+ [db executeNonQuery:@"UPDATE flags SET value=? WHERE name='device_id';" andArguments:@[UIDevice.currentDevice.identifierForVendor.UUIDString]];
+ }
+ }
+
+ //check if db version changed and invalidate state, if so
+ NSNumber* newdbversion = [self readDBVersion:db];
+ if([dbversion isEqualToNumber:newdbversion] == NO)
+ {
+ //invalidate account state if the database has changed
+ [dataLayer invalidateAllAccountStates];
+ DDLogInfo(@"Database migrated from old version %@ to version %@", dbversion, newdbversion);
+ return YES;
+ }
+ else
+ {
+ DDLogInfo(@"Database: no migration needed, version: %@", newdbversion);
+ return NO;
+ }
+ }];
+}
+
+@end
diff --git a/Monal/Classes/DebugView.swift b/Monal/Classes/DebugView.swift
new file mode 100644
index 0000000..d002f48
--- /dev/null
+++ b/Monal/Classes/DebugView.swift
@@ -0,0 +1,202 @@
+//
+// LogView.swift
+// Monal
+//
+// Created by Zain Ashraf on 3/23/24.
+// Copyright © 2024 monal-im.org. All rights reserved.
+//
+
+class DebugDefaultDB: ObservableObject {
+ @defaultsDB("udpLoggerEnabled")
+ var udpLoggerEnabled: Bool
+
+ @defaultsDB("udpLoggerPort")
+ var udpLoggerPort: String
+
+ @defaultsDB("udpLoggerHostname")
+ var udpLoggerHostname: String
+
+ @defaultsDB("udpLoggerKey")
+ var udpLoggerKey: String
+
+ @defaultsDB("hasCompletedOnboarding")
+ var hasCompletedOnboarding: Bool
+}
+
+struct LogFilesView: View {
+ @State private var sortedLogFileInfos: [DDLogFileInfo] = []
+ @State private var showShareSheet:Bool = false
+ @State private var fileURL: URL?
+ @State private var showingDBExportFailedAlert = false
+
+ func refreshSortedLogfiles() {
+ if let sortedLogFileInfos = HelperTools.fileLogger?.logFileManager.sortedLogFileInfos {
+ self.sortedLogFileInfos = sortedLogFileInfos
+ }
+ DispatchQueue.main.asyncAfter(deadline: .now() + 4.0) {
+ refreshSortedLogfiles()
+ }
+ }
+
+ var body: some View {
+ VStack(alignment: .leading) {
+ Text("This can be used to export logfiles.\n[Learn how to read them](https://github.com/monal-im/Monal/wiki/Introduction-to-Monal-Logging#view-the-log).")
+ List {
+ Section(header: Text("Logfiles")) {
+ ForEach(sortedLogFileInfos, id: \.self) { logFileInfo in
+ Button(logFileInfo.fileName) {
+ fileURL = URL(fileURLWithPath: logFileInfo.filePath)
+ }
+ }
+ }
+ Section(header: Text("Database Files")) {
+ Button("Main Database") {
+ if let dbFile = DataLayer.sharedInstance().exportDB() {
+ self.fileURL = URL(fileURLWithPath: dbFile)
+ } else {
+ showingDBExportFailedAlert = true
+ }
+ }
+ Button("IPC Database") {
+ if let dbFile = HelperTools.exportIPCDatabase() {
+ self.fileURL = URL(fileURLWithPath: dbFile)
+ } else {
+ showingDBExportFailedAlert = true
+ }
+ }
+ }
+ }
+ .listStyle(.grouped)
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .background(.interpolatedWindowBackground)
+ .alert(isPresented: $showingDBExportFailedAlert) {
+ Alert(title: Text("Database Export Failed"), message: Text("Failed to export the database, please check the logfile for errors and try again."), dismissButton: .default(Text("Close")))
+ }
+ .sheet(isPresented:$fileURL.optionalMappedToBool()) {
+ if let fileURL = fileURL {
+ ActivityViewController(activityItems: [fileURL])
+ }
+ }
+ .onAppear {
+ refreshSortedLogfiles()
+ }
+ }
+}
+
+struct UDPConfigView: View {
+ @ObservedObject var defaultDB = DebugDefaultDB()
+
+ var body: some View {
+ VStack(alignment: .leading) {
+ Text("The UDP logger allows you to livestream the log to the configured IP. Please use a secure key when streaming over the internet!\n[Learn how to receive the log stream](https://github.com/monal-im/Monal/wiki/Introduction-to-Monal-Logging#stream-the-log).")
+ Form {
+ Section(header: Text("UDP Logger Configuration")) {
+ Toggle(isOn: $defaultDB.udpLoggerEnabled) {
+ Text("Enable")
+ }
+ LabeledContent("Logserver IP:") {
+ TextField("Logserver IP", text: $defaultDB.udpLoggerHostname, prompt: Text("Required"))
+ }
+ LabeledContent("Logserver Port:") {
+ TextField("Logserver Port", text: $defaultDB.udpLoggerPort, prompt: Text("Required"))
+ }.keyboardType(.numberPad)
+ LabeledContent("AES Encryption Key:") {
+ TextField("AES Encryption Key", text: $defaultDB.udpLoggerKey, prompt: Text("Required"))
+ }
+ }
+ }
+ .padding(0)
+ .textFieldStyle(.roundedBorder)
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .background(.interpolatedWindowBackground)
+ }
+}
+
+struct CrashTestingView: View {
+ @ObservedObject var defaultDB = DebugDefaultDB()
+
+ var body: some View {
+ VStack(alignment:.leading, spacing: 25) {
+ Section(header: Text("Some debug settings.")) {
+ Toggle(isOn: $defaultDB.hasCompletedOnboarding) {
+ Text("Don't show onboarding")
+ }
+ }
+
+ Text("The following buttons allow you to forcefully crash the app using several different methods to test the crash handling.")
+
+ Group {
+ Button("Try to call unknown handler method") {
+ DispatchQueue.global(qos: .default).async(execute: {
+ HelperTools.flushLogs(withTimeout: 0.100)
+ let handler = MLHandler(delegate: self, handlerName: "IDontKnowThis", andBoundArguments: [:])
+ handler.call(withArguments: nil)
+ })
+ }
+ Button("Bad Access Crash") {
+ HelperTools.flushLogs(withTimeout: 0.100)
+ let delegate: AnyClass? = NSClassFromString("MonalAppDelegate")
+ print(delegate.unsafelyUnwrapped.audiovisualTypes())
+
+ }
+ Button("Assertion Crash") {
+ HelperTools.flushLogs(withTimeout: 0.100)
+ assert(false)
+ }
+ Button("Fatal Error Crash") {
+ HelperTools.flushLogs(withTimeout: 0.100)
+ fatalError("fatalError_example")
+ }
+ Button("Nil Crash") {
+ HelperTools.flushLogs(withTimeout: 0.100)
+ let crasher:Int? = nil
+ print(crasher!)
+ }
+ }.foregroundColor(.red)
+ Spacer()
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .background(.interpolatedWindowBackground)
+ }
+}
+
+struct DebugView: View {
+ @StateObject private var overlay = LoadingOverlayState()
+
+ var body: some View {
+ TabView {
+ LogFilesView()
+ .tabItem {
+ Image(systemName: "list.bullet")
+ Text("Logs")
+ }
+ UDPConfigView()
+ .tabItem {
+ Image(systemName: "gear")
+ Text("UDP Logger")
+ }
+ CrashTestingView()
+ .tabItem {
+ Image(systemName: "bolt.fill")
+ Text("Crash Testing")
+ }
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .padding()
+ .addLoadingOverlay(overlay)
+ .navigationBarItems(trailing:Button("Reconnect All") {
+ showLoadingOverlay(overlay, headline: "Reconnecting", description: "Will log out and reconnect all (connected) accounts.") {
+ MLXMPPManager.sharedInstance().reconnectAll()
+ return after(seconds:3.0)
+ }
+ })
+ }
+}
+
+#Preview {
+ NavigationStack {
+ DebugView()
+ }
+}
diff --git a/Monal/Classes/EditGroupSubject.swift b/Monal/Classes/EditGroupSubject.swift
new file mode 100644
index 0000000..c2f2ea1
--- /dev/null
+++ b/Monal/Classes/EditGroupSubject.swift
@@ -0,0 +1,51 @@
+//
+// EditGroupSubject.swift
+// Monal
+//
+// Created by Friedrich Altheide on 27.02.24.
+// Copyright © 2024 monal-im.org. All rights reserved.
+//
+
+struct EditGroupSubject: View {
+ @StateObject var contact: ObservableKVOWrapper
+ private let account: xmpp?
+ @State private var subject: String
+
+ @Environment(\.presentationMode) var presentationMode
+
+ init(contact: ObservableKVOWrapper) {
+ MLAssert(contact.isMuc, "contact must be a muc")
+
+ _subject = State(wrappedValue: contact.obj.groupSubject)
+ _contact = StateObject(wrappedValue: contact)
+ self.account = contact.obj.account! as xmpp
+ }
+
+ var body: some View {
+ NavigationStack {
+ VStack {
+ Form {
+ Section(header: Text("Group Description (optional)")) {
+ TextEditor(text: $subject)
+ .multilineTextAlignment(.leading)
+ .lineLimit(10...50)
+ }
+ }
+ }
+ .navigationTitle(Text("Group/Channel description"))
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Abort") {
+ self.presentationMode.wrappedValue.dismiss()
+ }
+ }
+ ToolbarItem(placement: .confirmationAction) {
+ Button("Done") {
+ self.account!.mucProcessor.changeSubject(ofMuc: contact.contactJid, to: self.subject)
+ self.presentationMode.wrappedValue.dismiss()
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/Monal/Classes/EncryptedPayload.swift b/Monal/Classes/EncryptedPayload.swift
new file mode 100644
index 0000000..bebae64
--- /dev/null
+++ b/Monal/Classes/EncryptedPayload.swift
@@ -0,0 +1,27 @@
+//
+// EncryptedPayload.swift
+// MLCrypto
+//
+// Created by Anurodh Pokharel on 1/7/20.
+// Copyright © 2020 Anurodh Pokharel. All rights reserved.
+//
+
+import UIKit
+
+@objcMembers
+public class EncryptedPayload: NSObject {
+ public var body: Data?
+ public var iv : Data?
+ public var key: Data?
+ public var tag: Data?
+ public var combined: Data?
+
+ @objc
+ public func updateValues(body:Data, iv: Data, key: Data, tag: Data)
+ {
+ self.body=body
+ self.iv=iv
+ self.key=key
+ self.tag=tag
+ }
+}
diff --git a/Monal/Classes/GeneralSettings.swift b/Monal/Classes/GeneralSettings.swift
new file mode 100644
index 0000000..d489fda
--- /dev/null
+++ b/Monal/Classes/GeneralSettings.swift
@@ -0,0 +1,536 @@
+//
+// GeneralSettings.swift
+// Monal
+//
+// Created by Vaidik Dubey on 22/03/24.
+// Copyright © 2024 monal-im.org. All rights reserved.
+//
+
+import ViewExtractor
+struct SettingsToggle: View where T: View {
+ let value: Binding
+ let contents: T
+
+ init(isOn value: Binding, @ViewBuilder contents: @escaping () -> T) {
+ self.value = value
+ self.contents = contents()
+ }
+
+ var body:some View {
+ VStack(alignment: .leading, spacing: 0) {
+ Extract(contents) { views in
+ if views.count == 0 {
+ Text("")
+ } else {
+ Toggle(isOn: value) {
+ views[0]
+ .font(.body)
+ }
+ if views.count > 1 {
+ Group {
+ ForEach(views[1...]) { view in
+ view
+ .foregroundColor(Color(UIColor.secondaryLabel))
+ .font(.footnote)
+ }
+ }.fixedSize(horizontal: false, vertical: true).frame(maxWidth: .infinity, alignment: .leading)
+ }
+ }
+ }
+ }
+ }
+}
+
+func getNotificationPrivacyOption(_ option: NotificationPrivacySettingOption) -> String {
+ switch option{
+ case .DisplayNameAndMessage:
+ return NSLocalizedString("Display Name And Message", comment: "")
+ case .DisplayOnlyName:
+ return NSLocalizedString("Display Only Name", comment: "")
+ case .DisplayOnlyPlaceholder:
+ return NSLocalizedString("Display Only Placeholder", comment: "")
+ }
+}
+
+class GeneralSettingsDefaultsDB: ObservableObject {
+ @defaultsDB("NotificationPrivacySetting")
+ var notificationPrivacySetting: Int
+
+ @defaultsDB("OMEMODefaultOn")
+ var omemoDefaultOn:Bool
+
+ @defaultsDB("AutodeleteInterval")
+ var AutodeleteInterval: Int
+
+ @defaultsDB("SendLastUserInteraction")
+ var sendLastUserInteraction: Bool
+
+ @defaultsDB("SendLastChatState")
+ var sendLastChatState: Bool
+
+ @defaultsDB("SendReceivedMarkers")
+ var sendReceivedMarkers: Bool
+
+ @defaultsDB("SendDisplayedMarkers")
+ var sendDisplayedMarkers: Bool
+
+ @defaultsDB("ShowGeoLocation")
+ var showGeoLocation: Bool
+
+ @defaultsDB("ShowURLPreview")
+ var showURLPreview: Bool
+
+ @defaultsDB("useInlineSafari")
+ var useInlineSafari: Bool
+
+ @defaultsDB("webrtcAllowP2P")
+ var webrtcAllowP2P: Bool
+
+ @defaultsDB("webrtcUseFallbackTurn")
+ var webrtcUseFallbackTurn: Bool
+
+ @defaultsDB("allowVersionIQ")
+ var allowVersionIQ: Bool
+
+ @defaultsDB("allowNonRosterContacts")
+ var allowNonRosterContacts: Bool
+
+ @defaultsDB("allowCallsFromNonRosterContacts")
+ var allowCallsFromNonRosterContacts: Bool
+
+ @defaultsDB("AutodownloadFiletransfers")
+ var autodownloadFiletransfers : Bool
+
+ @defaultsDB("AutodownloadFiletransfersWifiMaxSize")
+ var autodownloadFiletransfersWifiMaxSize : UInt
+
+ @defaultsDB("AutodownloadFiletransfersMobileMaxSize")
+ var autodownloadFiletransfersMobileMaxSize : UInt
+
+ @defaultsDB("ImageUploadQuality")
+ var imageUploadQuality : Float
+
+ @defaultsDB("showKeyboardOnChatOpen")
+ var showKeyboardOnChatOpen: Bool
+
+ @defaultsDB("useDnssecForAllConnections")
+ var useDnssecForAllConnections: Bool
+
+ @defaultsDB("uploadImagesOriginal")
+ var uploadImagesOriginal: Bool
+
+ @defaultsDB("hardlinkFiletransfersIntoDocuments")
+ var hardlinkFiletransfersIntoDocuments: Bool
+
+ @defaultsDB("showAdvancedUI")
+ var showAdvancedUI: Bool
+}
+
+
+struct GeneralSettings: View {
+ @ObservedObject var generalSettingsDefaultsDB = GeneralSettingsDefaultsDB()
+
+ var body: some View {
+ Form {
+ Section(header:Text("General Settings")) {
+ NavigationLink(destination: LazyClosureView(UserInterfaceSettings())) {
+ HStack{
+ Image(systemName: "hand.tap.fill")
+ .resizable()
+ .aspectRatio(contentMode: .fit)
+ .frame(width: 20, height: 20)
+ Text("User Interface")
+ }
+ }
+ NavigationLink(destination: LazyClosureView(SecuritySettings())) {
+ HStack{
+ Image(systemName: "shield.checkerboard")
+ .resizable()
+ .aspectRatio(contentMode: .fit)
+ .frame(width: 20, height: 20)
+ Text("Security")
+ }
+ }
+ NavigationLink(destination: LazyClosureView(PrivacySettings())) {
+ HStack{
+ Image(systemName: "eye")
+ .resizable()
+ .aspectRatio(contentMode: .fit)
+ .frame(width: 20, height: 20)
+ Text("Privacy")
+ }
+ }
+ NavigationLink(destination: LazyClosureView(NotificationSettings())) {
+ HStack{
+ Image(systemName: "text.bubble")
+ .resizable()
+ .aspectRatio(contentMode: .fit)
+ .frame(width: 20, height: 20)
+ Text("Notifications")
+ }
+ }
+ NavigationLink(destination: LazyClosureView(AttachmentSettings())) {
+ HStack {
+ Image(systemName: "paperclip")
+ .resizable()
+ .aspectRatio(contentMode: .fit)
+ .frame(width: 20, height: 20)
+ Text("Attachments")
+ }
+ }
+
+ Button(action: {
+ UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!)
+ }, label: {
+ HStack {
+ Image(systemName: "gear")
+ .resizable()
+ .aspectRatio(contentMode: .fit)
+ .frame(width: 20, height: 20)
+ #if targetEnvironment(macCatalyst)
+ Text("Open macOS settings")
+ #else
+ Text("Open iOS settings")
+ #endif
+ }.foregroundColor(Color(UIColor.label))
+ })
+ .buttonStyle(.borderless)
+ }
+ }
+ .navigationBarTitle(Text("General Settings"))
+ }
+}
+
+struct UserInterfaceSettings: View {
+ @ObservedObject var generalSettingsDefaultsDB = GeneralSettingsDefaultsDB()
+
+ var body: some View {
+ Form {
+ Section(header: Text("Previews")) {
+ SettingsToggle(isOn: $generalSettingsDefaultsDB.showGeoLocation) {
+ Text("Show inline geo location")
+ Text("Received geo locations are shared with Apple's Maps App.")
+ }
+ SettingsToggle(isOn: $generalSettingsDefaultsDB.showURLPreview) {
+ Text("Show URL previews")
+ Text("The operator of the webserver providing that URL may see your IP address.")
+ }
+ SettingsToggle(isOn: $generalSettingsDefaultsDB.useInlineSafari) {
+ Text("Open URLs inline in Safari")
+ Text("When disabled, URLs will opened in your default browser (that might not be Safari).")
+ }
+ }
+
+ Section(header: Text("Input")) {
+ SettingsToggle(isOn: $generalSettingsDefaultsDB.showKeyboardOnChatOpen) {
+ Text("Autofocus text input on chat open")
+ Text("Will focus the textfield on macOS or iOS with hardware keyboard attached, will open the software keyboard otherwise.")
+ }
+ }
+
+ Section(header: Text("Appearance")) {
+ VStack(alignment: .leading, spacing: 0) {
+ NavigationLink(destination: LazyClosureView(BackgroundSettings(contact:nil))) {
+ Text("Chat background image").font(.body)
+ }
+ Text("Configure the background image displayed in open chats.")
+ .foregroundColor(Color(UIColor.secondaryLabel))
+ .font(.footnote)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+ SettingsToggle(isOn: $generalSettingsDefaultsDB.showAdvancedUI) {
+ Text("Show advanced options in UI")
+ Text("Show power-user options in settings and other parts of the user interface.")
+ }
+ }
+ }
+ .navigationBarTitle(Text("User Interface"), displayMode: .inline)
+ }
+}
+
+struct SecuritySettings: View {
+ @ObservedObject var generalSettingsDefaultsDB = GeneralSettingsDefaultsDB()
+ @State var autodeleteInterval: Int = 0
+ @State var autodeleteIntervalSelection: Int = 0
+ var autodeleteOptions = [
+ 0: NSLocalizedString("Off", comment:"Message autdelete time"),
+ 30: NSLocalizedString("30 seconds", comment:"Message autdelete time"),
+ 60: NSLocalizedString("1 minute", comment:"Message autdelete time"),
+ 300: NSLocalizedString("5 minutes", comment:"Message autdelete time"),
+ 900: NSLocalizedString("15 minutes", comment:"Message autdelete time"),
+ 1800: NSLocalizedString("30 minutes", comment:"Message autdelete time"),
+ 3600: NSLocalizedString("1 hour", comment:"Message autdelete time"),
+ 43200: NSLocalizedString("12 hours", comment:"Message autdelete time"),
+ 86400: NSLocalizedString("1 day", comment:"Message autdelete time"),
+ 259200: NSLocalizedString("3 days", comment:"Message autdelete time"),
+ 604800: NSLocalizedString("1 week", comment:"Message autdelete time"),
+ 2419200: NSLocalizedString("4 weeks", comment:"Message autdelete time"),
+ 5184000: NSLocalizedString("2 month", comment:"Message autdelete time"), //based on 30 days per month
+ 7776000: NSLocalizedString("3 month", comment:"Message autdelete time"), //based on 30 days per month
+ ]
+
+ init() {
+ _autodeleteInterval = State(wrappedValue:generalSettingsDefaultsDB.AutodeleteInterval)
+ _autodeleteIntervalSelection = State(wrappedValue:generalSettingsDefaultsDB.AutodeleteInterval)
+ autodeleteOptions[-1] = NSLocalizedString("Custom", comment:"Message autdelete time")
+ //check if we have a custom value and change picker value accordingly
+ if autodeleteOptions[autodeleteInterval] == nil {
+ _autodeleteIntervalSelection = State(wrappedValue:-1)
+ }
+ }
+
+ var body: some View {
+ Form {
+ Section(header: Text("Encryption")) {
+ SettingsToggle(isOn: $generalSettingsDefaultsDB.omemoDefaultOn) {
+ Text("Enable encryption by default for new chats")
+ Text("Every new contact will have encryption enabled, but already known contacts will preserve their encryption settings.")
+ }
+
+ if generalSettingsDefaultsDB.showAdvancedUI {
+ SettingsToggle(isOn: $generalSettingsDefaultsDB.useDnssecForAllConnections) {
+ Text("Use DNSSEC validation for all connections")
+ Text(
+"""
+Use DNSSEC to validate all DNS query responses before connecting to the IP address designated \
+in the DNS response.\n\
+While being more secure, this can lead to connection problems in certain networks \
+like hotel wifi, ugly mobile carriers etc.
+"""
+ )
+ }
+ }
+
+ SettingsToggle(isOn: $generalSettingsDefaultsDB.webrtcAllowP2P) {
+ Text("Calls: Allow P2P sessions")
+ Text("Allow your device to establish a direct network connection to the remote party. This might leak your IP address to the caller/callee.")
+ }
+ }
+
+ Section(header: Text("On this device")) {
+ VStack(alignment: .leading, spacing: 0) {
+ Picker(selection: $autodeleteIntervalSelection, label: Text("Autodelete all messages older than")) {
+ ForEach(autodeleteOptions.keys.sorted(), id: \.self) { key in
+ Text(autodeleteOptions[key]!).tag(key)
+ }
+ }
+ //custom interval requested explicitly
+ if autodeleteIntervalSelection == -1 {
+ HStack {
+ Text("Custom Time: ")
+ Stepper(String(format:NSLocalizedString("%@ hours", comment:""), String(describing:(max(1, autodeleteInterval / 3600)).formatted())), value: Binding(
+ get: { max(1, autodeleteInterval / 3600) /*clamp to 1 ... .max*/ },
+ set: { autodeleteInterval = $0 * 3600 }
+ ), in: 1 ... .max)
+ }
+ }
+ Text("Be warned: Message will only be deleted on incoming pushes or if you open the app! This is especially true for shorter time intervals!").foregroundColor(Color(UIColor.secondaryLabel)).font(.footnote)
+ Text("Also beware: You won't be able to load older history from your server, Monal will immediately delete it after fetching it!").foregroundColor(Color(UIColor.secondaryLabel)).font(.footnote)
+ }
+ }
+ }
+ .navigationBarTitle(Text("Security"), displayMode: .inline)
+ //save only when closing view to not delete messages while the user is selecting a (custom) value
+ .onDisappear {
+ if autodeleteIntervalSelection == -1 {
+ //make sure our custom value is stored clamped, too
+ autodeleteInterval = max(1, autodeleteInterval / 3600)
+ } else {
+ //copy over picker value if not set to custom
+ autodeleteInterval = autodeleteIntervalSelection
+ }
+ generalSettingsDefaultsDB.AutodeleteInterval = autodeleteInterval
+ }
+ }
+}
+
+struct PrivacySettings: View {
+ @ObservedObject var generalSettingsDefaultsDB = GeneralSettingsDefaultsDB()
+
+ var body: some View {
+ Form {
+ PrivacySettingsSubview(onboardingPart:-1)
+ }
+ .navigationBarTitle(Text("Privacy"), displayMode: .inline)
+ }
+}
+struct PrivacySettingsSubview: View {
+ @ObservedObject var generalSettingsDefaultsDB = GeneralSettingsDefaultsDB()
+ var onboardingPart: Int
+
+ var body: some View {
+ if onboardingPart == -1 || onboardingPart == 0 {
+ Section(header: Text("Activity indications")) {
+ SettingsToggle(isOn: $generalSettingsDefaultsDB.sendReceivedMarkers) {
+ Text("Send message receipts")
+ Text("Let your contacts know if you received a message.")
+ }
+ SettingsToggle(isOn: $generalSettingsDefaultsDB.sendDisplayedMarkers) {
+ Text("Send read receipts")
+ Text("Let your contacts know if you read a message.")
+ }
+ SettingsToggle(isOn: $generalSettingsDefaultsDB.sendLastChatState) {
+ Text("Send typing notifications")
+ Text("Let your contacts know if you are typing a message.")
+ }
+ SettingsToggle(isOn: $generalSettingsDefaultsDB.sendLastUserInteraction) {
+ Text("Send last interaction time")
+ Text("Let your contacts know when you last opened the app.")
+ }
+ }
+ }
+ if onboardingPart == -1 || onboardingPart == 1 {
+ Section(header: Text("Interactions")) {
+ SettingsToggle(isOn: $generalSettingsDefaultsDB.allowNonRosterContacts) {
+ Text("Accept incoming messages from strangers")
+ Text("Allow contacts not in your contact list to contact you.")
+ }
+ SettingsToggle(isOn: Binding(
+ get: { generalSettingsDefaultsDB.allowCallsFromNonRosterContacts && generalSettingsDefaultsDB.allowNonRosterContacts },
+ set: { generalSettingsDefaultsDB.allowCallsFromNonRosterContacts = $0 }
+ )) {
+ Text("Accept incoming calls from strangers")
+ Text("Allow contacts not in your contact list to call you.")
+ }.disabled(!generalSettingsDefaultsDB.allowNonRosterContacts)
+ }
+ }
+ if onboardingPart == -1 || onboardingPart == 2 {
+ Section(header: Text("Misc")) {
+ SettingsToggle(isOn: $generalSettingsDefaultsDB.allowVersionIQ) {
+ Text("Publish version")
+#if IS_QUICKSY
+ Text("Allow contacts in your contact list to query your Quicksy and iOS versions.")
+#else
+ Text("Allow contacts in your contact list to query your Monal and iOS versions.")
+#endif
+ }
+//the quicksy.im server always has a proper TURN server, no need for this setting there
+#if !IS_QUICKSY
+ SettingsToggle(isOn: $generalSettingsDefaultsDB.webrtcUseFallbackTurn) {
+ Text("Calls: Allow TURN fallback to Monal-Servers")
+ Text("This will make calls possible even if your XMPP server does not provide a TURN server, but leaks your IP to Monal's servers if your XMPP server does not provide a TURN server.")
+ }
+#endif
+ }
+ }
+ }
+}
+
+struct NotificationSettings: View {
+ @ObservedObject var generalSettingsDefaultsDB = GeneralSettingsDefaultsDB()
+ @State private var pushPermissionEnabled = false
+
+ private var pushNotEnabled: Bool {
+ let xmppAccountInfo = MLXMPPManager.sharedInstance().connectedXMPP as! [xmpp]
+ var pushNotEnabled = false
+ for account in xmppAccountInfo {
+ pushNotEnabled = pushNotEnabled || !account.connectionProperties.pushEnabled
+ }
+ return pushNotEnabled
+ }
+
+ var body: some View {
+ Form {
+ Section(header: Text("Settings")) {
+ Picker(selection: $generalSettingsDefaultsDB.notificationPrivacySetting, label: Text("Privacy")) {
+ ForEach(NotificationPrivacySettingOption.allCases, id: \.self) { option in
+ Text(getNotificationPrivacyOption(option)).tag(option.rawValue)
+ }
+ }
+ .frame(height: 56, alignment: .trailing)
+ }
+
+ Section(header: Text("Debugging")) {
+ NavigationLink(destination: LazyClosureView(NotificationDebugging())) {
+ buildNotificationStateLabel(Text("Debug Notification Problems"), isWorking: !self.pushNotEnabled && self.pushPermissionEnabled)
+ }
+ }
+ }
+ .onAppear {
+ UNUserNotificationCenter.current().getNotificationSettings { (settings) -> Void in
+ self.pushPermissionEnabled = (settings.authorizationStatus == .authorized || settings.authorizationStatus == .provisional);
+ }
+ }
+ .navigationBarTitle(Text("Notifications"), displayMode: .inline)
+ }
+}
+
+struct AttachmentSettings: View {
+ @ObservedObject var generalSettingsDefaultsDB = GeneralSettingsDefaultsDB()
+
+ var body: some View {
+ Form {
+ Section(header: Text("General File Transfer Settings")) {
+ SettingsToggle(isOn: $generalSettingsDefaultsDB.autodownloadFiletransfers) {
+ Text("Auto-Download Media and Files")
+ }
+ SettingsToggle(isOn: $generalSettingsDefaultsDB.hardlinkFiletransfersIntoDocuments) {
+ Text("Make transfered Media and Files accessible in Files App")
+ }
+ }
+
+ Section(header: Text("Download Settings")) {
+ Text("Adjust the maximum file size for auto-downloads over WiFi")
+ .foregroundColor(.secondary)
+ .font(.footnote)
+ Slider(
+ value: $generalSettingsDefaultsDB.autodownloadFiletransfersWifiMaxSize.bytecount(mappedTo: 1024*1024),
+ in: 1.0...100.0,
+ step: 1.0,
+ minimumValueLabel: Text("1 MiB"),
+ maximumValueLabel: Text("100 MiB"),
+ label: {
+ Text("Load over wifi")
+ }
+ )
+ Text("Load over WiFi up to: \(String(describing:UInt(generalSettingsDefaultsDB.autodownloadFiletransfersWifiMaxSize/(1024*1024)))) MiB")
+ }
+
+ Section {
+ Text("Adjust the maximum file size for auto-downloads over cellular network")
+ .foregroundColor(.secondary)
+ .font(.footnote)
+ Slider(
+ value: $generalSettingsDefaultsDB.autodownloadFiletransfersMobileMaxSize.bytecount(mappedTo: 1024*1024),
+ in: 0.0...100.0,
+ step: 1.0,
+ minimumValueLabel: Text("1 MiB"),
+ maximumValueLabel: Text("100 MiB"),
+ label: {
+ Text("Load over Cellular")
+ }
+ )
+ Text("Load over cellular up to: \(String(describing:UInt(generalSettingsDefaultsDB.autodownloadFiletransfersMobileMaxSize/(1024*1024)))) MiB")
+ }
+
+ Section(header: Text("Upload Settings")) {
+ SettingsToggle(isOn: $generalSettingsDefaultsDB.uploadImagesOriginal) {
+ Text("Upload Original Images")
+ }
+ if !generalSettingsDefaultsDB.uploadImagesOriginal {
+ Text("Adjust the quality of images uploaded")
+ .foregroundColor(.secondary)
+ .font(.footnote)
+ Slider(
+ value: $generalSettingsDefaultsDB.imageUploadQuality,
+ in: 0.33...1.0,
+ step: 0.01,
+ minimumValueLabel: Text("33%"),
+ maximumValueLabel: Text("100%"),
+ label: {
+ Text("Upload Settings")
+ }
+ )
+ Text("Image Upload JPEG-Quality: \(String(format: "%.0f%%", generalSettingsDefaultsDB.imageUploadQuality*100))")
+ }
+ }
+ }
+ }
+}
+
+
+struct ContentView_Previews: PreviewProvider {
+ static var previews: some View {
+ GeneralSettings()
+ }
+}
diff --git a/Monal/Classes/HelperTools+Quicksy_CountryCodes.h b/Monal/Classes/HelperTools+Quicksy_CountryCodes.h
new file mode 100644
index 0000000..626763c
--- /dev/null
+++ b/Monal/Classes/HelperTools+Quicksy_CountryCodes.h
@@ -0,0 +1,15 @@
+//
+// HelperTools+Quicksy_CountryCodes.h
+// Monal
+//
+// Created by Thilo Molitor on 28.08.24.
+// Copyright © 2024 Monal.im. All rights reserved.
+//
+
+#import
+#import "HelperTools.h"
+
+FOUNDATION_EXPORT NSArray* _Nonnull COUNTRY_CODES;
+
+@implementation HelperTools (CountryCodes)
+@end
diff --git a/Monal/Classes/HelperTools+Quicksy_CountryCodes.m b/Monal/Classes/HelperTools+Quicksy_CountryCodes.m
new file mode 100644
index 0000000..0f304a2
--- /dev/null
+++ b/Monal/Classes/HelperTools+Quicksy_CountryCodes.m
@@ -0,0 +1,255 @@
+// This file was automatically generated by scripts/itu_pdf_to_objc.py
+// Please run this python script again to update this file
+// Example ../scripts/itu_pdf_to_objc.py >Classes/HelperTools+Quicksy_CountryCodes.m
+
+#import "Quicksy_Country.h"
+#import "HelperTools.h"
+
+NSArray* _Nonnull COUNTRY_CODES = @[]; //will be replaced by actual values in +load below
+
+@implementation HelperTools (CountryCodes)
+
+//see https://stackoverflow.com/a/13326633 and https://fek.io/blog/method-swizzling-in-obj-c-and-swift/
++(void) load
+{
+ if(self == HelperTools.self)
+ {
+ static dispatch_once_t onceToken;
+ dispatch_once(&onceToken, ^{
+ COUNTRY_CODES = @[
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"AF" code:@"+93" pattern:@"^([0-9]{9})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"AL" code:@"+355" pattern:@"^([0-9]{3,9})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"DZ" code:@"+213" pattern:@"^([0-9]{8}|[0-9]{9})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"AS" code:@"+1" pattern:@"^(684)([0-9]{7})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"AD" code:@"+376" pattern:@"^([0-9]{6}|[0-9]{8}|[0-9]{9})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"AO" code:@"+244" pattern:@"^([0-9]{9})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"AI" code:@"+1" pattern:@"^(264)([0-9]{7})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"AG" code:@"+1" pattern:@"^(268)([0-9]{7})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"AR" code:@"+54" pattern:@"^([0-9]{10})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"AM" code:@"+374" pattern:@"^([0-9]{8})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"AW" code:@"+297" pattern:@"^([0-9]{7})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"AU" code:@"+61" pattern:@"^([0-9]{5,15})$"],
+ [[Quicksy_Country alloc] initWithName:NSLocalizedString(@"Australian External Territories", @"quicksy country") alpha2:nil code:@"+672" pattern:@"^([0-9]{6})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"AT" code:@"+43" pattern:@"^([0-9]{4,13})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"AZ" code:@"+994" pattern:@"^([0-9]{8,9})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"BS" code:@"+1" pattern:@"^(242)([0-9]{7})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"BH" code:@"+973" pattern:@"^([0-9]{8})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"BD" code:@"+880" pattern:@"^([0-9]{6,10})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"BB" code:@"+1" pattern:@"^(246)([0-9]{7})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"BY" code:@"+375" pattern:@"^([0-9]{9,10})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"BE" code:@"+32" pattern:@"^([0-9]{8,9})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"BZ" code:@"+501" pattern:@"^([0-9]{7})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"BJ" code:@"+229" pattern:@"^([0-9]{8})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"BM" code:@"+1" pattern:@"^(441)([0-9]{7})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"BT" code:@"+975" pattern:@"^([0-9]{7,8})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"BO" code:@"+591" pattern:@"^([0-9]{8})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"BQ" code:@"+599" pattern:@"^([0-9]{7})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"BA" code:@"+387" pattern:@"^([0-9]{8})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"BW" code:@"+267" pattern:@"^([0-9]{7,8})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"BR" code:@"+55" pattern:@"^([0-9]{10})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"VG" code:@"+1" pattern:@"^(284)([0-9]{7})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"BN" code:@"+673" pattern:@"^([0-9]{7})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"BG" code:@"+359" pattern:@"^([0-9]{7,9})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"BF" code:@"+226" pattern:@"^([0-9]{8})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"BI" code:@"+257" pattern:@"^([0-9]{8})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"KH" code:@"+855" pattern:@"^([0-9]{8})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"CM" code:@"+237" pattern:@"^([0-9]{8})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"CA" code:@"+1" pattern:@"^([0-9]{10})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"CV" code:@"+238" pattern:@"^([0-9]{7})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"KY" code:@"+1" pattern:@"^(345)([0-9]{7})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"CF" code:@"+236" pattern:@"^([0-9]{8})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"TD" code:@"+235" pattern:@"^([0-9]{8})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"CL" code:@"+56" pattern:@"^([0-9]{8,9})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"CN" code:@"+86" pattern:@"^([0-9]{5,12})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"CO" code:@"+57" pattern:@"^([0-9]{8}|[0-9]{10})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"KM" code:@"+269" pattern:@"^([0-9]{7})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"CG" code:@"+242" pattern:@"^([0-9]{9})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"CK" code:@"+682" pattern:@"^([0-9]{5})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"CR" code:@"+506" pattern:@"^([0-9]{8})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"CI" code:@"+225" pattern:@"^([0-9]{8})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"HR" code:@"+385" pattern:@"^([0-9]{8,12})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"CU" code:@"+53" pattern:@"^([0-9]{6,8})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"CW" code:@"+599" pattern:@"^([0-9]{7,8})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"CY" code:@"+357" pattern:@"^([0-9]{8}|[0-9]{11})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"CZ" code:@"+420" pattern:@"^([0-9]{4,12})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"KP" code:@"+850" pattern:@"^([0-9]{6,17})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"CD" code:@"+243" pattern:@"^([0-9]{5,9})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"DK" code:@"+45" pattern:@"^([0-9]{8})$"],
+ [[Quicksy_Country alloc] initWithName:NSLocalizedString(@"Diego Garcia", @"quicksy country") alpha2:nil code:@"+246" pattern:@"^([0-9]{7})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"DJ" code:@"+253" pattern:@"^([0-9]{6})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"DM" code:@"+1" pattern:@"^(767)([0-9]{7})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"DO" code:@"+1" pattern:@"^(809|829)([0-9]{7})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"EC" code:@"+593" pattern:@"^([0-9]{8})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"EG" code:@"+20" pattern:@"^([0-9]{7,9})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"SV" code:@"+503" pattern:@"^([0-9]{7}|[0-9]{8}|[0-9]{11})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"GQ" code:@"+240" pattern:@"^([0-9]{9})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"ER" code:@"+291" pattern:@"^([0-9]{7})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"EE" code:@"+372" pattern:@"^([0-9]{7,10})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"ET" code:@"+251" pattern:@"^([0-9]{9})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"FK" code:@"+500" pattern:@"^([0-9]{5})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"FO" code:@"+298" pattern:@"^([0-9]{6})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"FJ" code:@"+679" pattern:@"^([0-9]{7})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"FI" code:@"+358" pattern:@"^([0-9]{5,12})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"FR" code:@"+33" pattern:@"^([0-9]{9})$"],
+ [[Quicksy_Country alloc] initWithName:NSLocalizedString(@"French Departments and Territories in the Indian Ocean", @"quicksy country") alpha2:nil code:@"+262" pattern:@"^([0-9]{9})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"GF" code:@"+594" pattern:@"^([0-9]{9})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"PF" code:@"+689" pattern:@"^([0-9]{6})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"GA" code:@"+241" pattern:@"^([0-9]{6}|[0-9]{7})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"GM" code:@"+220" pattern:@"^([0-9]{7})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"GE" code:@"+995" pattern:@"^([0-9]{9})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"DE" code:@"+49" pattern:@"^([0-9]{6,13})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"GH" code:@"+233" pattern:@"^([0-9]{5,9})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"GI" code:@"+350" pattern:@"^([0-9]{8})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"GR" code:@"+30" pattern:@"^([0-9]{10})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"GL" code:@"+299" pattern:@"^([0-9]{6})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"GD" code:@"+1" pattern:@"^(473)([0-9]{7})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"GP" code:@"+590" pattern:@"^([0-9]{9})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"GU" code:@"+1" pattern:@"^(671)([0-9]{7})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"GT" code:@"+502" pattern:@"^([0-9]{8})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"GN" code:@"+224" pattern:@"^([0-9]{8})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"GW" code:@"+245" pattern:@"^([0-9]{7})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"GY" code:@"+592" pattern:@"^([0-9]{7})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"HT" code:@"+509" pattern:@"^([0-9]{8})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"HN" code:@"+504" pattern:@"^([0-9]{8})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"HK" code:@"+852" pattern:@"^([0-9]{4}|[0-9]{8,9})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"HU" code:@"+36" pattern:@"^([0-9]{8,9})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"IS" code:@"+354" pattern:@"^([0-9]{7}|[0-9]{9})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"IN" code:@"+91" pattern:@"^([0-9]{7,10})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"ID" code:@"+62" pattern:@"^([0-9]{5,10})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"IR" code:@"+98" pattern:@"^([0-9]{6,10})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"IQ" code:@"+964" pattern:@"^([0-9]{8,10})$"],
+ [[Quicksy_Country alloc] initWithName:NSLocalizedString(@"Ireland", @"quicksy country") alpha2:nil code:@"+353" pattern:@"^([0-9]{7,11})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"IL" code:@"+972" pattern:@"^([0-9]{8,9})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"IT" code:@"+39" pattern:@"^([0-9]{1,11})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"JM" code:@"+1" pattern:@"^(876)([0-9]{7})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"JP" code:@"+81" pattern:@"^([0-9]{5,13})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"JO" code:@"+962" pattern:@"^([0-9]{5,9})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"KZ" code:@"+7" pattern:@"^([0-9]{10})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"KE" code:@"+254" pattern:@"^([0-9]{6,10})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"KI" code:@"+686" pattern:@"^([0-9]{5})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"KR" code:@"+82" pattern:@"^([0-9]{8,11})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"KW" code:@"+965" pattern:@"^([0-9]{7}|[0-9]{8})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"KG" code:@"+996" pattern:@"^([0-9]{9})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"LA" code:@"+856" pattern:@"^([0-9]{8,10})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"LV" code:@"+371" pattern:@"^([0-9]{7}|[0-9]{8})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"LB" code:@"+961" pattern:@"^([0-9]{7,8})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"LS" code:@"+266" pattern:@"^([0-9]{8})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"LR" code:@"+231" pattern:@"^([0-9]{7,8})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"LY" code:@"+218" pattern:@"^([0-9]{8,9})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"LI" code:@"+423" pattern:@"^([0-9]{7,9})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"LT" code:@"+370" pattern:@"^([0-9]{8})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"LU" code:@"+352" pattern:@"^([0-9]{4,11})$"],
+ [[Quicksy_Country alloc] initWithName:NSLocalizedString(@"Macao, China", @"quicksy country") alpha2:nil code:@"+853" pattern:@"^([0-9]{7,8})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"MG" code:@"+261" pattern:@"^([0-9]{9,10})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"MW" code:@"+265" pattern:@"^([0-9]{7}|[0-9]{8})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"MY" code:@"+60" pattern:@"^([0-9]{7,9})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"MV" code:@"+960" pattern:@"^([0-9]{7})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"ML" code:@"+223" pattern:@"^([0-9]{8})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"MT" code:@"+356" pattern:@"^([0-9]{8})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"MH" code:@"+692" pattern:@"^([0-9]{7})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"MQ" code:@"+596" pattern:@"^([0-9]{9})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"MR" code:@"+222" pattern:@"^([0-9]{7})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"MU" code:@"+230" pattern:@"^([0-9]{7})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"MX" code:@"+52" pattern:@"^([0-9]{10})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"FM" code:@"+691" pattern:@"^([0-9]{7})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"MD" code:@"+373" pattern:@"^([0-9]{8})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"MC" code:@"+377" pattern:@"^([0-9]{5,9})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"MN" code:@"+976" pattern:@"^([0-9]{7,8})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"ME" code:@"+382" pattern:@"^([0-9]{4,12})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"MS" code:@"+1" pattern:@"^(664)([0-9]{7})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"MA" code:@"+212" pattern:@"^([0-9]{9})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"MZ" code:@"+258" pattern:@"^([0-9]{8,9})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"MM" code:@"+95" pattern:@"^([0-9]{7,9})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"NA" code:@"+264" pattern:@"^([0-9]{6,10})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"NR" code:@"+674" pattern:@"^([0-9]{4}|[0-9]{7})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"NP" code:@"+977" pattern:@"^([0-9]{8,9})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"NL" code:@"+31" pattern:@"^([0-9]{9})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"NC" code:@"+687" pattern:@"^([0-9]{6})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"NZ" code:@"+64" pattern:@"^([0-9]{3,10})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"NI" code:@"+505" pattern:@"^([0-9]{8})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"NE" code:@"+227" pattern:@"^([0-9]{8})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"NG" code:@"+234" pattern:@"^([0-9]{7,10})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"NU" code:@"+683" pattern:@"^([0-9]{4})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"MP" code:@"+1" pattern:@"^(670)([0-9]{7})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"NO" code:@"+47" pattern:@"^([0-9]{5}|[0-9]{8})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"OM" code:@"+968" pattern:@"^([0-9]{7,8})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"PK" code:@"+92" pattern:@"^([0-9]{8,11})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"PW" code:@"+680" pattern:@"^([0-9]{7})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"PA" code:@"+507" pattern:@"^([0-9]{7}|[0-9]{8})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"PG" code:@"+675" pattern:@"^([0-9]{4,11})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"PY" code:@"+595" pattern:@"^([0-9]{5,9})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"PE" code:@"+51" pattern:@"^([0-9]{8,11})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"PH" code:@"+63" pattern:@"^([0-9]{8,10})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"PL" code:@"+48" pattern:@"^([0-9]{6,9})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"PT" code:@"+351" pattern:@"^([0-9]{9}|[0-9]{11})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"PR" code:@"+1" pattern:@"^(787|939)([0-9]{7})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"QA" code:@"+974" pattern:@"^([0-9]{3,8})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"RO" code:@"+40" pattern:@"^([0-9]{9})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"RU" code:@"+7" pattern:@"^([0-9]{10})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"RW" code:@"+250" pattern:@"^([0-9]{9})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"SH" code:@"+247" pattern:@"^([0-9]{4})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"SH" code:@"+290" pattern:@"^([0-9]{4})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"KN" code:@"+1" pattern:@"^(869)([0-9]{7})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"LC" code:@"+1" pattern:@"^(758)([0-9]{7})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"PM" code:@"+508" pattern:@"^([0-9]{6})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"VC" code:@"+1" pattern:@"^(784)([0-9]{7})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"WS" code:@"+685" pattern:@"^([0-9]{3,7})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"SM" code:@"+378" pattern:@"^([0-9]{6,10})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"ST" code:@"+239" pattern:@"^([0-9]{7})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"SA" code:@"+966" pattern:@"^([0-9]{8,9})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"SN" code:@"+221" pattern:@"^([0-9]{9})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"RS" code:@"+381" pattern:@"^([0-9]{4,12})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"SC" code:@"+248" pattern:@"^([0-9]{7})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"SL" code:@"+232" pattern:@"^([0-9]{8})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"SG" code:@"+65" pattern:@"^([0-9]{8,12})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"SX" code:@"+1" pattern:@"^(721)([0-9]{7})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"SK" code:@"+421" pattern:@"^([0-9]{4,9})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"SI" code:@"+386" pattern:@"^([0-9]{8})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"SB" code:@"+677" pattern:@"^([0-9]{5})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"SO" code:@"+252" pattern:@"^([0-9]{5,8})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"ZA" code:@"+27" pattern:@"^([0-9]{9})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"ES" code:@"+34" pattern:@"^([0-9]{9})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"LK" code:@"+94" pattern:@"^([0-9]{9})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"SD" code:@"+249" pattern:@"^([0-9]{9})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"SR" code:@"+597" pattern:@"^([0-9]{6,7})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"SZ" code:@"+268" pattern:@"^([0-9]{7,8})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"SE" code:@"+46" pattern:@"^([0-9]{7,13})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"CH" code:@"+41" pattern:@"^([0-9]{4,12})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"SY" code:@"+963" pattern:@"^([0-9]{8,10})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"TW" code:@"+886" pattern:@"^([0-9]{8,9})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"TJ" code:@"+992" pattern:@"^([0-9]{9})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"TZ" code:@"+255" pattern:@"^([0-9]{9})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"TH" code:@"+66" pattern:@"^([0-9]{8}|[0-9]{9})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"MK" code:@"+389" pattern:@"^([0-9]{8})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"TL" code:@"+670" pattern:@"^([0-9]{7})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"TG" code:@"+228" pattern:@"^([0-9]{8})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"TK" code:@"+690" pattern:@"^([0-9]{4})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"TO" code:@"+676" pattern:@"^([0-9]{5}|[0-9]{7})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"TT" code:@"+1" pattern:@"^(868)([0-9]{7})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"TN" code:@"+216" pattern:@"^([0-9]{8})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"TR" code:@"+90" pattern:@"^([0-9]{10})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"TM" code:@"+993" pattern:@"^([0-9]{8})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"TC" code:@"+1" pattern:@"^(649)([0-9]{7})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"TV" code:@"+688" pattern:@"^([0-9]{5}|[0-9]{6})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"UG" code:@"+256" pattern:@"^([0-9]{9})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"UA" code:@"+380" pattern:@"^([0-9]{9})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"AE" code:@"+971" pattern:@"^([0-9]{8,9})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"GB" code:@"+44" pattern:@"^([0-9]{7,10})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"US" code:@"+1" pattern:@"^([0-9]{10})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"VI" code:@"+1" pattern:@"^(340)([0-9]{7})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"UY" code:@"+598" pattern:@"^([0-9]{4,11})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"UZ" code:@"+998" pattern:@"^([0-9]{9})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"VU" code:@"+678" pattern:@"^([0-9]{5}|[0-9]{7})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"VA" code:@"+39" pattern:@"^([0-9]{1,11})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"VE" code:@"+58" pattern:@"^([0-9]{10})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"VN" code:@"+84" pattern:@"^([0-9]{7,10})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"WF" code:@"+681" pattern:@"^([0-9]{6})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"YE" code:@"+967" pattern:@"^([0-9]{6,9})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"ZM" code:@"+260" pattern:@"^([0-9]{9})$"],
+ [[Quicksy_Country alloc] initWithName:nil alpha2:@"ZW" code:@"+263" pattern:@"^([0-9]{5,10})$"],
+ ];
+ });
+ }
+}
+
+@end
diff --git a/Monal/Classes/HelperTools.h b/Monal/Classes/HelperTools.h
new file mode 100644
index 0000000..6cc4cc0
--- /dev/null
+++ b/Monal/Classes/HelperTools.h
@@ -0,0 +1,224 @@
+//
+// HelperTools.h
+// Monal
+//
+// Created by Friedrich Altheide on 08.07.20.
+// Copyright © 2020 Monal.im. All rights reserved.
+//
+
+#import
+#import "MLConstants.h"
+#import "MLDelayableTimer.h"
+
+#include "metamacros.h"
+
+#define createDelayableTimer(timeout, handler, ...) createDelayableQueuedTimer(timeout, nil, handler, __VA_ARGS__)
+#define createDelayableQueuedTimer(timeout, queue, handler, ...) metamacro_if_eq(0, metamacro_argcount(__VA_ARGS__))([HelperTools startDelayableQueuedTimer:timeout withHandler:handler andCancelHandler:nil andFile:(char*)__FILE__ andLine:__LINE__ andFunc:(char*)__func__ onQueue:queue])(_createDelayableQueuedTimer(timeout, queue, handler, __VA_ARGS__))
+#define _createDelayableQueuedTimer(timeout, queue, handler, cancelHandler, ...) [HelperTools startDelayableQueuedTimer:timeout withHandler:handler andCancelHandler:cancelHandler andFile:(char*)__FILE__ andLine:__LINE__ andFunc:(char*)__func__ onQueue:queue]
+
+#define createTimer(timeout, handler, ...) createQueuedTimer(timeout, nil, handler, __VA_ARGS__)
+#define createQueuedTimer(timeout, queue, handler, ...) metamacro_if_eq(0, metamacro_argcount(__VA_ARGS__))([HelperTools startQueuedTimer:timeout withHandler:handler andCancelHandler:nil andFile:(char*)__FILE__ andLine:__LINE__ andFunc:(char*)__func__ onQueue:queue])(_createQueuedTimer(timeout, queue, handler, __VA_ARGS__))
+#define _createQueuedTimer(timeout, queue, handler, cancelHandler, ...) [HelperTools startQueuedTimer:timeout withHandler:handler andCancelHandler:cancelHandler andFile:(char*)__FILE__ andLine:__LINE__ andFunc:(char*)__func__ onQueue:queue]
+
+#define MLAssert(check, text, ...) do { if(!(check)) { metamacro_if_eq(0, metamacro_argcount(__VA_ARGS__))([HelperTools MLAssertWithText:text andUserData:nil andFile:(char*)__FILE__ andLine:__LINE__ andFunc:(char*)__func__];)([HelperTools MLAssertWithText:text andUserData:metamacro_head(__VA_ARGS__) andFile:(char*)__FILE__ andLine:__LINE__ andFunc:(char*)__func__];) while(YES); } } while(0)
+#define unreachable(...) do { metamacro_if_eq(0, metamacro_argcount(__VA_ARGS__))(MLAssert(NO, @"unreachable", __VA_ARGS__);)(MLAssert(NO, __VA_ARGS__);); } while(0)
+
+#define showErrorOnAlpha(account, description, ...) do { [HelperTools showErrorOnAlpha:[NSString stringWithFormat:description, ##__VA_ARGS__] withNode:nil andAccount:account andFile:(char*)__FILE__ andLine:__LINE__ andFunc:(char*)__func__]; } while(0)
+#define showXMLErrorOnAlpha(account, node, description, ...) do { [HelperTools showErrorOnAlpha:[NSString stringWithFormat:description, ##__VA_ARGS__] withNode:node andAccount:account andFile:(char*)__FILE__ andLine:__LINE__ andFunc:(char*)__func__]; } while(0)
+
+NS_ASSUME_NONNULL_BEGIN
+
+@class AnyPromise;
+@class MLXMLNode;
+@class xmpp;
+@class XMPPStanza;
+@class UNNotificationRequest;
+@class DDLogMessage;
+@class DDFileLogger;
+@class UIView;
+@class UITapGestureRecognizer;
+@class AVURLAsset;
+
+typedef NS_ENUM(NSUInteger, MLVersionType) {
+ MLVersionTypeIQ,
+ MLVersionTypeLog,
+};
+
+typedef NS_ENUM(NSUInteger, MLDefinedIdentifier) {
+ MLDefinedIdentifier_kAppGroup,
+ MLDefinedIdentifier_kMonalOpenURL,
+ MLDefinedIdentifier_kBackgroundProcessingTask,
+ MLDefinedIdentifier_kBackgroundRefreshingTask,
+ MLDefinedIdentifier_kMonalKeychainName,
+ MLDefinedIdentifier_kMucTypeGroup,
+ MLDefinedIdentifier_kMucTypeChannel,
+ MLDefinedIdentifier_kMucRoleModerator,
+ MLDefinedIdentifier_kMucRoleNone,
+ MLDefinedIdentifier_kMucRoleParticipant,
+ MLDefinedIdentifier_kMucRoleVisitor,
+ MLDefinedIdentifier_kMucAffiliationOwner,
+ MLDefinedIdentifier_kMucAffiliationAdmin,
+ MLDefinedIdentifier_kMucAffiliationMember,
+ MLDefinedIdentifier_kMucAffiliationOutcast,
+ MLDefinedIdentifier_kMucAffiliationNone,
+ MLDefinedIdentifier_kMucActionShowProfile,
+ MLDefinedIdentifier_kMucActionReinvite,
+ MLDefinedIdentifier_SHORT_PING,
+ MLDefinedIdentifier_LONG_PING,
+ MLDefinedIdentifier_MUC_PING,
+ MLDefinedIdentifier_BGFETCH_DEFAULT_INTERVAL,
+};
+
+typedef NS_ENUM(NSUInteger, MLRunLoopIdentifier) {
+ MLRunLoopIdentifierNetwork,
+ MLRunLoopIdentifierTimer,
+};
+
+void logException(NSException* exception);
+void swizzle(Class c, SEL orig, SEL new);
+
+//weak container holding an object as weak pointer (needed to not create retain circles in NSCache
+@interface WeakContainer : NSObject
+@property (atomic, weak) id obj;
+-(id) initWithObj:(id) obj;
+@end
+
+@interface HelperTools : NSObject
+
+@property (class, nonatomic, strong, nullable) DDFileLogger* fileLogger;
+
++(NSData* _Nullable) convertLogmessageToJsonData:(DDLogMessage*) logMessage counter:(uint64_t*) counter andError:(NSError** _Nullable) error;
++(void) initSystem;
++(void) installExceptionHandler;
++(int) pendingCrashreportCount;
++(void) flushLogsWithTimeout:(double) timeout;
++(void) __attribute__((noreturn)) MLAssertWithText:(NSString*) text andUserData:(id _Nullable) additionalData andFile:(const char* const) file andLine:(int) line andFunc:(const char* const) func;
++(void) __attribute__((noreturn)) handleRustPanicWithText:(NSString*) text andBacktrace:(NSString*) backtrace;
++(void) __attribute__((noreturn)) throwExceptionWithName:(NSString*) name reason:(NSString*) reason userInfo:(NSDictionary* _Nullable) userInfo;
++(void) postError:(NSString*) description withNode:(XMPPStanza* _Nullable) node andAccount:(xmpp*) account andIsSevere:(BOOL) isSevere andDisableAccount:(BOOL) disableAccount;
++(void) postError:(NSString*) description withNode:(XMPPStanza* _Nullable) node andAccount:(xmpp*) account andIsSevere:(BOOL) isSevere;
++(NSString*) extractXMPPError:(XMPPStanza*) stanza withDescription:(NSString* _Nullable) description;
++(void) showErrorOnAlpha:(NSString*) description withNode:(XMPPStanza* _Nullable) node andAccount:(xmpp* _Nullable) account andFile:(char*) file andLine:(int) line andFunc:(char*) func;
+
++(NSDictionary*) getInvalidPushServers;
++(NSString*) getSelectedPushServerBasedOnLocale;
++(NSDictionary*) getAvailablePushServers;
+
++(void) configureDefaultAudioSession;
+
++(NSArray*) getFailoverStunServers;
++(NSURL*) getFailoverTurnApiServer;
++(NSArray* _Nullable) sdp2xml:(NSString*) sdp withInitiator:(BOOL) initiator;
++(NSString* _Nullable) xml2sdp:(MLXMLNode*) xml withInitiator:(BOOL) initiator;
++(MLXMLNode* _Nullable) candidate2xml:(NSString*) candidate withMid:(NSString*) mid pwd:(NSString* _Nullable) pwd ufrag:(NSString* _Nullable) ufrag andInitiator:(BOOL) initiator;
++(NSString* _Nullable) xml2candidate:(MLXMLNode*) xml withInitiator:(BOOL) initiator;
+
++(AnyPromise*) renderUIImageFromSVGURL:(NSURL* _Nullable) url;
++(AnyPromise*) renderUIImageFromSVGData:(NSData* _Nullable) data;
++(void) busyWaitForOperationQueue:(NSOperationQueue*) queue;
++(id) getObjcDefinedValue:(MLDefinedIdentifier) identifier;
++(NSRunLoop*) getExtraRunloopWithIdentifier:(MLRunLoopIdentifier) identifier;
++(NSError* _Nullable) hardLinkOrCopyFile:(NSString*) from to:(NSString*) to;
++(NSString*) getQueueThreadLabelFor:(DDLogMessage*) logMessage;
++(BOOL) shouldProvideVoip;
++(BOOL) isSandboxAPNS;
++(int) compareIOcted:(NSData*) data1 with:(NSData*) data2;
++(NSURL*) getContainerURLForPathComponents:(NSArray*) components;
++(NSURL*) getSharedDocumentsURLForPathComponents:(NSArray*) components;
++(NSData*) serializeObject:(id) obj;
++(id) unserializeData:(NSData*) data;
++(NSError* _Nullable) postUserNotificationRequest:(UNNotificationRequest*) request;
++(void) createAVURLAssetFromFile:(NSString*) file havingMimeType:(NSString*) mimeType andFileExtension:(NSString* _Nullable) fileExtension withCompletionHandler:(void(^)(AVURLAsset* _Nullable)) completion;
++(AnyPromise*) generateVideoThumbnailFromFile:(NSString*) file havingMimeType:(NSString*) mimeType andFileExtension:(NSString* _Nullable) fileExtension;
++(void) addUploadItemPreviewForItem:(NSURL* _Nullable) url provider:(NSItemProvider* _Nullable) provider andPayload:(NSMutableDictionary*) payload withCompletionHandler:(void(^)(NSMutableDictionary* _Nullable)) completion;
++(void) handleUploadItemProvider:(NSItemProvider*) provider withCompletionHandler:(void (^)(NSMutableDictionary* _Nullable)) completion;
++(UIImage* _Nullable) rotateImage:(UIImage* _Nullable) image byRadians:(CGFloat) rotation;
++(UIImage* _Nullable) mirrorImageOnXAxis:(UIImage* _Nullable) image;
++(UIImage* _Nullable) mirrorImageOnYAxis:(UIImage* _Nullable) image;
++(UIImage*) imageWithNotificationBadgeForImage:(UIImage*) image;
++(UIView*) buttonWithNotificationBadgeForImage:(UIImage*) image hasNotification:(bool) hasNotification withTapHandler: (UITapGestureRecognizer*) handler;
++(NSData*) resizeAvatarImage:(UIImage* _Nullable) image withCircularMask:(BOOL) circularMask toMaxBase64Size:(unsigned long) length;
++(double) report_memory;
++(UIColor*) generateColorFromJid:(NSString*) jid;
++(NSString*) bytesToHuman:(int64_t) bytes;
++(NSString*) stringFromToken:(NSData*) tokenIn;
++(NSString* _Nullable) exportIPCDatabase;
++(void) configureFileProtection:(NSString*) protectionLevel forFile:(NSString*) file;
++(void) configureFileProtectionFor:(NSString*) file;
++(BOOL) isContactBlacklistedForEncryption:(MLContact*) contact NS_SWIFT_NAME(isContactBlacklistedForEncryption(_:));
++(void) removeAllShareInteractionsForAccountID:(NSNumber*) accountID;
++(NSDictionary*) splitJid:(NSString*) jid;
+
++(void) scheduleBackgroundTask:(BOOL) force;
++(void) clearSyncErrorsOnAppForeground;
++(void) removePendingSyncErrorNotifications;
++(void) updateSyncErrorsWithDeleteOnly:(BOOL) removeOnly andWaitForCompletion:(BOOL) waitForCompletion;
+
++(BOOL) isInBackground;
++(BOOL) isNotInFocus;
+
++(void) dispatchAsync:(BOOL) async reentrantOnQueue:(dispatch_queue_t _Nullable) queue withBlock:(monal_void_block_t) block;
++(NSUserDefaults*) defaultsDB;
++(BOOL) isAppExtension;
++(NSString*) generateStringOfFeatureSet:(NSSet*) features;
++(NSSet*) getOwnFeatureSet;
++(NSString*) getEntityCapsHashForIdentities:(NSArray*) identities andFeatures:(NSSet*) features andForms:(NSArray*) forms;
++(NSString*) formatLastInteraction:(NSDate*) lastInteraction;
++(NSString*) stringFromTimeInterval:(NSUInteger) interval;
++(NSDate*) parseDateTimeString:(NSString*) datetime;
++(NSString*) generateDateTimeString:(NSDate*) datetime;
++(NSString*) generateRandomPassword;
++(NSString*) encodeRandomResource;
+
++(NSData* _Nullable) sha1:(NSData* _Nullable) data;
++(NSString* _Nullable) stringSha1:(NSString* _Nullable) data;
++(NSData* _Nullable) sha1HmacForKey:(NSData* _Nullable) key andData:(NSData* _Nullable) data;
++(NSString* _Nullable) stringSha1HmacForKey:(NSString* _Nullable) key andData:(NSString* _Nullable) data;
++(NSData* _Nullable) sha256:(NSData* _Nullable) data;
++(NSString* _Nullable) stringSha256:(NSString* _Nullable) data;
++(NSData* _Nullable) sha256HmacForKey:(NSData* _Nullable) key andData:(NSData* _Nullable) data;
++(NSString* _Nullable) stringSha256HmacForKey:(NSString* _Nullable) key andData:(NSString* _Nullable) data;
++(NSData* _Nullable) sha512:(NSData* _Nullable) data;
++(NSString* _Nullable) stringSha512:(NSString* _Nullable) data;
++(NSData* _Nullable) sha512HmacForKey:(NSData* _Nullable) key andData:(NSData* _Nullable) data;
++(NSString* _Nullable) stringSha512HmacForKey:(NSString* _Nullable) key andData:(NSString* _Nullable) data;
+
++(NSUUID*) dataToUUID:(NSData*) data;
++(NSUUID*) stringToUUID:(NSString*) data;
+
++(NSString*) encodeBase64WithString:(NSString*) strData;
++(NSString*) encodeBase64WithData:(NSData*) objData;
++(NSData*) dataWithBase64EncodedString:(NSString*) string;
++(NSString*) hexadecimalString:(NSData*) data;
++(NSData*) dataWithHexString:(NSString*) hex;
++(NSData*) XORData:(NSData*) data1 withData:(NSData*) data2;
+
++(NSString*) signalHexKeyWithData:(NSData*) data;
++(NSData*) signalIdentityWithHexKey:(NSString*) hexKey;
++(NSString*) signalHexKeyWithSpacesWithData:(NSData*) data;
+
++(UIView*) MLCustomViewHeaderWithTitle:(NSString*) title;
++(CIImage*) createQRCodeFromString:(NSString*) input;
++(AnyPromise*) waitAtLeastSeconds:(NSTimeInterval) seconds forPromise:(AnyPromise*) promise;
++(NSString*) sanitizeFilePath:(const char* const) file;
+
+//don't use these four directly, but via createTimer() makro
++(MLDelayableTimer*) startDelayableQueuedTimer:(double) timeout withHandler:(monal_void_block_t) handler andCancelHandler:(monal_void_block_t _Nullable) cancelHandler andFile:(char*) file andLine:(int) line andFunc:(char*) func onQueue:(dispatch_queue_t _Nullable) queue;
++(monal_void_block_t) startQueuedTimer:(double) timeout withHandler:(monal_void_block_t) handler andCancelHandler:(monal_void_block_t _Nullable) cancelHandler andFile:(char*) file andLine:(int) line andFunc:(char*) func onQueue:(dispatch_queue_t _Nullable) queue;
+
++(NSString*) appBuildVersionInfoFor:(MLVersionType) type;
+
++(NSNumber*) currentTimestampInSeconds;
++(NSNumber*) dateToNSNumberSeconds:(NSDate*) date;
+
++(BOOL) constantTimeCompareAttackerString:(NSString* _Nonnull) str1 withKnownString:(NSString* _Nonnull) str2;
+
++(BOOL) isIP:(NSString*) host;
+
++(NSURLSession*) createEphemeralURLSession;
+
++(void) updateCurrentLogfilePath:(NSString*) logfilePath;
+
+@end
+
+NS_ASSUME_NONNULL_END
diff --git a/Monal/Classes/HelperTools.m b/Monal/Classes/HelperTools.m
new file mode 100644
index 0000000..cb60da9
--- /dev/null
+++ b/Monal/Classes/HelperTools.m
@@ -0,0 +1,3064 @@
+//
+// HelperTools.m
+// Monal
+//
+// Created by Friedrich Altheide on 08.07.20.
+// Copyright © 2020 Monal.im. All rights reserved.
+//
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#import
+#import
+#import
+#import
+#import
+#import
+#import
+#import
+//can not be imported, use extern declaration instead
+//#import
+extern int64_t kscrs_getNextCrashReport(char* crashReportPathBuffer);
+#import
+#import "hsluv.h"
+#import "HelperTools.h"
+#import "MLXMPPManager.h"
+#import "MLPubSub.h"
+#import "MLUDPLogger.h"
+#import "MLHandler.h"
+#import "MLBasePaser.h"
+#import "MLXMLNode.h"
+#import "XMPPStanza.h"
+#import "XMPPIQ.h"
+#import "XMPPPresence.h"
+#import "XMPPMessage.h"
+#import "XMPPDataForm.h"
+#import "xmpp.h"
+#import "MLNotificationQueue.h"
+#import "MLContact.h"
+#import "MLMessage.h"
+#import "MLFiletransfer.h"
+#import "DataLayer.h"
+#import "OmemoState.h"
+#import "MLUDPLogger.h"
+#import "MLStreamRedirect.h"
+#import "commithash.h"
+#import "MLContactSoftwareVersionInfo.h"
+#import "IPC.h"
+#import "MLDelayableTimer.h"
+#import "Quicksy_Country.h"
+
+@import UserNotifications;
+@import CoreImage;
+@import CoreImage.CIFilterBuiltins;
+@import UIKit;
+@import AVFoundation;
+@import UniformTypeIdentifiers;
+@import QuickLookThumbnailing;
+
+@interface KSCrash()
+@property(nonatomic,readwrite,retain) NSString* basePath;
+@end
+
+@interface MLDelayableTimer()
+-(void) invalidate;
+@end
+
+@interface NSUserDefaults (SerializeNSObject)
+-(id) swizzled_objectForKey:(NSString*) defaultName;
+-(void) swizzled_setObject:(id) value forKey:(NSString*) defaultName;
+@end
+
+static char* _crashBundleName = "UnifiedReport";
+static NSString* _processID;
+static DDFileLogger* _fileLogger = nil;
+static char _origLogfilePath[1024] = "";
+static char _logfilePath[1024] = "";
+static char _origProfilePath[1024] = "";
+static char _profilePath[1024] = "";
+static NSObject* _isAppExtensionLock = nil;
+static NSMutableDictionary* _versionInfoCache;
+static MLStreamRedirect* _stdoutRedirector = nil;
+static MLStreamRedirect* _stderrRedirector = nil;
+static volatile void (*_oldExceptionHandler)(NSException*) = NULL;
+#if TARGET_OS_MACCATALYST
+static objc_exception_preprocessor _oldExceptionPreprocessor = NULL;
+#endif
+
+//shamelessly stolen from utils.ip in conversations source
+static NSRegularExpression* IPV4;
+static NSRegularExpression* IPV6_HEX4DECCOMPRESSED;
+static NSRegularExpression* IPV6_6HEX4DEC;
+static NSRegularExpression* IPV6_HEXCOMPRESSED;
+static NSRegularExpression* IPV6;
+
+//add own crash info (used by rust panic handler)
+//see https://alastairs-place.net/blog/2013/01/10/interesting-os-x-crash-report-tidbits/
+//and kscrash sources (KSDynamicLinker.c)
+#pragma pack(8)
+static struct {
+ unsigned version;
+ const char* message;
+ const char* signature;
+ const char* backtrace;
+ const char* message2;
+ void* reserved;
+ void* reserved2;
+ void* reserved3; // First introduced in version 5
+} _crash_info __attribute__((section("__DATA, __crash_info"))) = { 5, 0, 0, 0, 0, 0, 0, 0 };
+#pragma pack()
+
+
+void exitLogging(void)
+{
+ DDLogInfo(@"exit() was called...");
+ [HelperTools flushLogsWithTimeout:0.250];
+ return;
+}
+
+// see: https://developer.apple.com/library/archive/qa/qa1361/_index.html
+// Returns true if the current process is being debugged (either
+// running under the debugger or has a debugger attached post facto).
+bool isDebugerActive(void)
+{
+ int junk;
+ int mib[4];
+ struct kinfo_proc info;
+ size_t size;
+
+ // Initialize the flags so that, if sysctl fails for some bizarre
+ // reason, we get a predictable result.
+ info.kp_proc.p_flag = 0;
+
+ // Initialize mib, which tells sysctl the info we want, in this case
+ // we're looking for information about a specific process ID.
+ mib[0] = CTL_KERN;
+ mib[1] = KERN_PROC;
+ mib[2] = KERN_PROC_PID;
+ mib[3] = getpid();
+
+ // Call sysctl
+ size = sizeof(info);
+ junk = sysctl(mib, sizeof(mib) / sizeof(*mib), &info, &size, NULL, 0);
+ assert(junk == 0);
+
+ // We're being debugged if the P_TRACED flag is set.
+ return ( (info.kp_proc.p_flag & P_TRACED) != 0 );
+}
+
+//see https://stackoverflow.com/a/2180788
+int asyncSafeCopyFile(const char* from, const char* to)
+{
+ int fd_to, fd_from;
+ char buf[1024];
+ ssize_t nread;
+ int saved_errno;
+
+ fd_from = open(from, O_RDONLY);
+ if (fd_from < 0)
+ return -1;
+
+ fd_to = open(to, O_WRONLY | O_CREAT | O_EXCL, 0660);
+ if (fd_to < 0)
+ goto out_error;
+
+ while((nread = read(fd_from, buf, sizeof buf)) > 0)
+ {
+ char *out_ptr = buf;
+ ssize_t nwritten;
+
+ do {
+ nwritten = write(fd_to, out_ptr, nread);
+
+ if (nwritten >= 0)
+ {
+ nread -= nwritten;
+ out_ptr += nwritten;
+ }
+ else if (errno != EINTR)
+ {
+ goto out_error;
+ }
+ } while (nread > 0);
+ }
+
+ if (nread == 0)
+ {
+ if (close(fd_to) < 0)
+ {
+ fd_to = -1;
+ goto out_error;
+ }
+ close(fd_from);
+
+ /* Success! */
+ return 0;
+ }
+
+out_error:
+ saved_errno = errno;
+
+ close(fd_from);
+ if (fd_to >= 0)
+ close(fd_to);
+
+ errno = saved_errno;
+ return -1;
+}
+
+static void addFilePathWithSize(const KSCrashReportWriter* writer, char* name, char* filePath)
+{
+ struct stat st;
+ char name_size[64];
+ strncpy(name_size, name, 64);
+ name_size[63] = '\0';
+ strncat(name_size, "_size", 64);
+ name_size[63] = '\0';
+
+ writer->addStringElement(writer, name, filePath);
+ stat(filePath, &st);
+ writer->addIntegerElement(writer, name_size, st.st_size);
+}
+
+static void crash_callback(const KSCrashReportWriter* writer)
+{
+ //copy current logfile
+ int logfileCopyRetval = asyncSafeCopyFile(_origLogfilePath, _logfilePath);
+ int errnoLogfileCopy = errno;
+ writer->addStringElement(writer, "logfileCopied", "YES");
+ writer->addIntegerElement(writer, "logfileCopyResult", logfileCopyRetval);
+ writer->addIntegerElement(writer, "logfileCopyErrno", errnoLogfileCopy);
+ addFilePathWithSize(writer, "logfileCopy", _logfilePath);
+ //this comes last to make sure we see size differences if the logfile got written during crash data collection (could be other processes)
+ addFilePathWithSize(writer, "currentLogfile", _origLogfilePath);
+
+ //copy current profiling file (see https://leodido.dev/demystifying-profraw/)
+ int profileCopyRetval = asyncSafeCopyFile(_origProfilePath, _profilePath);
+ int errnoProfileCopy = errno;
+ writer->addStringElement(writer, "profileCopied", "YES");
+ writer->addIntegerElement(writer, "profileCopyResult", profileCopyRetval);
+ writer->addIntegerElement(writer, "profileCopyErrno", errnoProfileCopy);
+ addFilePathWithSize(writer, "profileCopy", _profilePath);
+ //this comes last to make sure we see size differences if the logfile got written during crash data collection (could be other processes)
+ addFilePathWithSize(writer, "currentProfile", _origProfilePath);
+}
+
+void logException(NSException* exception)
+{
+#if TARGET_OS_MACCATALYST
+ NSString* prefix = @"POSSIBLE_CRASH";
+#else
+ NSString* prefix = @"CRASH";
+#endif
+ //log error and flush all logs
+ [DDLog flushLog];
+ DDLogError(@"*****************\n%@(%@): %@\nUserInfo: %@\nStack Trace: %@", prefix, [exception name], [exception reason], [exception userInfo], [exception callStackSymbols]);
+ [DDLog flushLog];
+ [HelperTools flushLogsWithTimeout:0.250];
+}
+
+void uncaughtExceptionHandler(NSException* exception)
+{
+ logException(exception);
+
+ //don't report that crash through KSCrash if the debugger is active
+ if(isDebugerActive())
+ {
+ DDLogError(@"Not reporting crash through KSCrash: debugger is active!");
+ return;
+ }
+
+ //make sure this crash will be recorded by kscrash using the NSException rather than the c++ exception thrown by the objc runtime
+ //this will make sure that the stacktrace matches the objc exception rather than being a top level c++ stacktrace
+ KSCrash.sharedInstance.uncaughtExceptionHandler(exception);
+}
+
+//this function will only be in use under macos alpha builds to log every exception (even when catched with @try-@catch constructs)
+#if TARGET_OS_MACCATALYST
+static id preprocess(id exception)
+{
+ id preprocessed = exception;
+ if(_oldExceptionPreprocessor != NULL)
+ preprocessed = _oldExceptionPreprocessor(exception);
+ logException(preprocessed);
+ return preprocessed;
+}
+#endif
+
+void swizzle(Class c, SEL orig, SEL new)
+{
+ Method origMethod = class_getInstanceMethod(c, orig);
+ Method newMethod = class_getInstanceMethod(c, new);
+ if(class_addMethod(c, orig, method_getImplementation(newMethod), method_getTypeEncoding(newMethod)))
+ class_replaceMethod(c, new, method_getImplementation(origMethod), method_getTypeEncoding(origMethod));
+ else
+ method_exchangeImplementations(origMethod, newMethod);
+}
+
+static void notification_center_logging(CFNotificationCenterRef center, void* observer, CFStringRef name, const void* object, CFDictionaryRef userInfo)
+{
+ DDLogDebug(@"NSNotification %@ with %@: %@", name, object, userInfo);
+}
+
+@implementation WeakContainer
+-(id) initWithObj:(id) obj
+{
+ self = [super init];
+ self.obj = obj;
+ return self;
+}
+@end
+
+@implementation NSUserDefaults (SerializeNSObject)
+-(id) swizzled_objectForKey:(NSString*) defaultName
+{
+ //this will call the original not this one, because of swizzling!
+ id data = [self swizzled_objectForKey:defaultName];
+ //always unserialize this: every real NSData should be serialized to NSData (e.g. an NSData containing a serialized NSData)
+ //and therefore any exception thrown by unserialize of not serialized data should never happen as it is an implementation error in Monal
+ if([data isKindOfClass:[NSData class]])
+ {
+ @try {
+ return [HelperTools unserializeData:data];
+ } @catch (NSException* exception) {
+ NSMutableDictionary* userInfo = [NSMutableDictionary dictionaryWithDictionary:nilDefault(exception.userInfo, @{})];
+ [userInfo addEntriesFromDictionary:@{@"userDefaultsName":defaultName}];
+ @throw [NSException exceptionWithName:exception.name reason:exception.reason userInfo:userInfo];
+ }
+ }
+ return data;
+}
+
+-(void) swizzled_setObject:(id) value forKey:(NSString*) defaultName
+{
+ id toSave = value;
+ //these are the default datatypes/class clusters already handled by NSUserDefaults
+ //(NSData gets a special handling by us and is therefore not listed here)
+ if(
+ [value isKindOfClass:[NSString class]] ||
+ [value isKindOfClass:[NSNumber class]] ||
+ [value isKindOfClass:[NSDate class]] ||
+ [value isKindOfClass:[NSURL class]] ||
+ [value isKindOfClass:[NSDictionary class]] ||
+ [value isKindOfClass:[NSMutableDictionary class]] ||
+ [value isKindOfClass:[NSArray class]] ||
+ [value isKindOfClass:[NSMutableArray class]] ||
+ value == nil
+ )
+ ; //do nothing, already handled by original NSUserDefaults method
+ //every NSData should be double serialized (see swizzled_objectForKey: above for a detailed explanation)
+ //everything else will just be (single) serialized to NSData
+ else
+ toSave = [HelperTools serializeObject:value];
+ return [self swizzled_setObject:toSave forKey:defaultName];
+}
+
+//see https://stackoverflow.com/a/13326633 and https://fek.io/blog/method-swizzling-in-obj-c-and-swift/
++(void) load
+{
+ if(self == NSUserDefaults.self)
+ {
+ static dispatch_once_t onceToken;
+ dispatch_once(&onceToken, ^{
+ swizzle([self class], @selector(objectForKey:), @selector(swizzled_objectForKey:));
+ swizzle([self class], @selector(setObject:forKey:), @selector(swizzled_setObject:forKey:));
+ });
+ }
+}
+@end
+
+@implementation HelperTools
+
++(void) initialize
+{
+ _isAppExtensionLock = [NSObject new];
+ _versionInfoCache = [NSMutableDictionary new];
+
+ u_int32_t i = arc4random();
+ _processID = [self hexadecimalString:[NSData dataWithBytes:&i length:sizeof(i)]];
+
+ //shamelessly stolen from utils.ip in conversations source
+ IPV4 = [NSRegularExpression regularExpressionWithPattern:@"\\A(25[0-5]|2[0-4]\\d|[0-1]?\\d?\\d)(\\.(25[0-5]|2[0-4]\\d|[0-1]?\\d?\\d)){3}\\z" options:0 error:nil];
+ IPV6_HEX4DECCOMPRESSED = [NSRegularExpression regularExpressionWithPattern:@"\\A((?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4})*)?) ::((?:[0-9A-Fa-f]{1,4}:)*)(25[0-5]|2[0-4]\\d|[0-1]?\\d?\\d)(\\.(25[0-5]|2[0-4]\\d|[0-1]?\\d?\\d)){3}\\z" options:0 error:nil];
+ IPV6_6HEX4DEC = [NSRegularExpression regularExpressionWithPattern:@"\\A((?:[0-9A-Fa-f]{1,4}:){6,6})(25[0-5]|2[0-4]\\d|[0-1]?\\d?\\d)(\\.(25[0-5]|2[0-4]\\d|[0-1]?\\d?\\d)){3}\\z" options:0 error:nil];
+ IPV6_HEXCOMPRESSED = [NSRegularExpression regularExpressionWithPattern:@"\\A((?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4})*)?)::((?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4})*)?)\\z" options:0 error:nil];
+ IPV6 = [NSRegularExpression regularExpressionWithPattern:@"\\A(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}\\z" options:0 error:nil];
+}
+
++(void) installExceptionHandler
+{
+ //only install our exception handler if not yet installed
+ _oldExceptionHandler = (volatile void (*)(NSException*))NSGetUncaughtExceptionHandler();
+ if((void*)_oldExceptionHandler != (void*)uncaughtExceptionHandler)
+ {
+ DDLogVerbose(@"Replaced unhandled exception handler, old handler: %p, new handler: %p", NSGetUncaughtExceptionHandler(), &uncaughtExceptionHandler);
+ NSSetUncaughtExceptionHandler(uncaughtExceptionHandler);
+ }
+
+#if TARGET_OS_MACCATALYST
+ //this is needed for catalyst because catalyst apps are based on NSApplication which will swallow exceptions on the main thread and just continue
+ //see: https://stackoverflow.com/questions/3336278/why-is-raising-an-nsexception-not-bringing-down-my-application
+ //obj exception handling explanation: https://stackoverflow.com/a/28391007/3528174
+ //objc exception implementation: https://opensource.apple.com/source/objc4/objc4-818.2/runtime/objc-exception.mm.auto.html
+ //objc exception header: https://opensource.apple.com/source/objc4/objc4-818.2/runtime/objc-exception.h.auto.html
+ //example C++ exception ABI: https://github.com/nicolasbrailo/cpp_exception_handling_abi/tree/master/abi_v12
+
+ //this will log the exception
+ if(_oldExceptionPreprocessor == NULL)
+ _oldExceptionPreprocessor = objc_setExceptionPreprocessor(preprocess);
+
+ //this will stop the swallowing
+ [[NSUserDefaults standardUserDefaults] registerDefaults:@{@"NSApplicationCrashOnExceptions": @YES}];
+#endif
+}
+
++(void) __attribute__((noreturn)) MLAssertWithText:(NSString*) text andUserData:(id) userInfo andFile:(const char* const) file andLine:(int) line andFunc:(const char* const) func
+{
+ NSString* fileStr = [self sanitizeFilePath:file];
+ DDLogError(@"Assertion triggered at %@:%d in %s", fileStr, line, func);
+ @throw [NSException exceptionWithName:[NSString stringWithFormat:@"MLAssert triggered at %@:%d in %s with reason '%@' and userInfo: %@", fileStr, line, func, text, userInfo] reason:text userInfo:userInfo];
+}
+
++(void) __attribute__((noreturn)) handleRustPanicWithText:(NSString*) text andBacktrace:(NSString*) backtrace
+{
+ NSString* abort_msg = [NSString stringWithFormat:@"RUST_PANIC: %@", text];
+
+ //set crash_info_message in DATA section of our binary image
+ //see https://alastairs-place.net/blog/2013/01/10/interesting-os-x-crash-report-tidbits/
+ _crash_info.message = abort_msg.UTF8String;
+ _crash_info.signature = abort_msg.UTF8String; //use signature for apple crash reporter which does not handle message field
+ _crash_info.backtrace = backtrace.UTF8String;
+
+ //log error and flush all logs
+ [DDLog flushLog];
+ DDLogError(@"*****************\n%@\n%@", abort_msg, backtrace);
+ [DDLog flushLog];
+ [HelperTools flushLogsWithTimeout:0.250];
+
+ //now abort everything
+ abort();
+}
+
++(void) __attribute__((noreturn)) throwExceptionWithName:(NSString*) name reason:(NSString*) reason userInfo:(NSDictionary* _Nullable) userInfo
+{
+ @throw [NSException exceptionWithName:name reason:reason userInfo:userInfo];
+}
+
++(void) postError:(NSString*) description withNode:(XMPPStanza* _Nullable) node andAccount:(xmpp*) account andIsSevere:(BOOL) isSevere andDisableAccount:(BOOL) disableAccount
+{
+ [self postError:description withNode:node andAccount:account andIsSevere:isSevere];
+
+ //disconnect and reset state (including pipelined auth etc.)
+ //this has to be done before disabling the account to not trigger an assertion
+ [[MLXMPPManager sharedInstance] disconnectAccount:account.accountID withExplicitLogout:YES];
+
+ //make sure we don't try this again even when the mainapp/appex gets restarted
+ NSMutableDictionary* accountDic = [[NSMutableDictionary alloc] initWithDictionary:[[DataLayer sharedInstance] detailsForAccount:account.accountID] copyItems:YES];
+ accountDic[kEnabled] = @NO;
+ [[DataLayer sharedInstance] updateAccounWithDictionary:accountDic];
+}
+
++(void) postError:(NSString*) description withNode:(XMPPStanza* _Nullable) node andAccount:(xmpp*) account andIsSevere:(BOOL) isSevere
+{
+ NSString* message = description;
+ if(node)
+ message = [HelperTools extractXMPPError:node withDescription:description];
+ DDLogError(@"Notifying user about %@ error: %@", isSevere ? @"SEVERE" : @"non-severe", message);
+ [[MLNotificationQueue currentQueue] postNotificationName:kXMPPError object:account userInfo:@{@"message": message, @"isSevere":@(isSevere)}];
+}
+
++(void) showErrorOnAlpha:(NSString*) description withNode:(XMPPStanza* _Nullable) node andAccount:(xmpp* _Nullable) account andFile:(char*) file andLine:(int) line andFunc:(char*) func
+{
+ NSString* fileStr = [self sanitizeFilePath:file];
+ NSString* message = description;
+ if(node)
+ message = [self extractXMPPError:node withDescription:description];
+#ifdef IS_ALPHA
+ DDLogError(@"Notifying alpha user about error on account %@ at %@:%d in %s: %@", account, fileStr, line, func, message);
+ if(account != nil)
+ [[MLNotificationQueue currentQueue] postNotificationName:kXMPPError object:account userInfo:@{@"message": message, @"isSevere":@YES}];
+ else
+ {
+ UNMutableNotificationContent* content = [UNMutableNotificationContent new];
+ content.title = @"Global Error";
+ content.body = message;
+ content.sound = [UNNotificationSound defaultSound];
+ UNNotificationRequest* request = [UNNotificationRequest requestWithIdentifier:[[NSUUID UUID] UUIDString] content:content trigger:nil];
+ NSError* error = [self postUserNotificationRequest:request];
+ if(error)
+ DDLogError(@"Error posting global alpha xmppError notification: %@", error);
+ }
+#else
+ DDLogWarn(@"Ignoring alpha-only error at %@:%d in %s: %@", fileStr, line, func, message);
+#endif
+}
+
++(NSString*) extractXMPPError:(XMPPStanza*) stanza withDescription:(NSString*) description
+{
+ if(description == nil || [description isEqualToString:@""])
+ description = @"XMPP Error";
+ NSMutableString* message = [description mutableCopy];
+ NSString* errorReason = [stanza findFirst:@"error/{urn:ietf:params:xml:ns:xmpp-stanzas}!text$"];
+ if(errorReason && ![errorReason isEqualToString:@""])
+ [message appendString:[NSString stringWithFormat:@": %@", errorReason]];
+ NSString* errorText = [stanza findFirst:@"error/{urn:ietf:params:xml:ns:xmpp-stanzas}text#"];
+ if(errorText && ![errorText isEqualToString:@""])
+ [message appendString:[NSString stringWithFormat:@" (%@)", errorText]];
+ return message;
+}
+
++(void) initSystem
+{
+ BOOL enableDefaultLogAndCrashFramework = YES;
+#if TARGET_OS_SIMULATOR
+ // Automatically switch between the debug technique of TMolitor and FAltheide
+ enableDefaultLogAndCrashFramework = [[HelperTools defaultsDB] boolForKey:@"udpLoggerEnabled"];
+#endif
+ if(enableDefaultLogAndCrashFramework)
+ {
+ [self configureLogging];
+ //don't install KSCrash if the debugger is active
+ if(!isDebugerActive())
+ [self installCrashHandler];
+ else
+ DDLogWarn(@"Not installing crash handler: debugger is active!");
+ [self installExceptionHandler];
+ }
+ else
+ [self configureXcodeLogging];
+
+ //see https://stackoverflow.com/a/3738387
+ CFNotificationCenterAddObserver(CFNotificationCenterGetLocalCenter(),
+ NULL,
+ notification_center_logging,
+ NULL,
+ NULL,
+ CFNotificationSuspensionBehaviorDeliverImmediately);
+
+ atexit(exitLogging);
+
+ //set right path for llvm default.profraw file
+ NSString* profrawFilePath = [[HelperTools getContainerURLForPathComponents:@[@"default.profraw"]] path];
+ setenv("LLVM_PROFILE_FILE", profrawFilePath.UTF8String, 1);
+
+ [SwiftHelpers initSwiftHelpers];
+ [self activityLog];
+}
+
++(void) configureDefaultAudioSession
+{
+ AVAudioSession* audioSession = [AVAudioSession sharedInstance];
+ NSError* error;
+ DDLogDebug(@"configuring default audio session...");
+ AVAudioSessionCategoryOptions options = 0;
+ options |= AVAudioSessionCategoryOptionMixWithOthers;
+ //options |= AVAudioSessionCategoryOptionInterruptSpokenAudioAndMixWithOthers;
+ //options |= AVAudioSessionCategoryOptionAllowBluetooth;
+ //options |= AVAudioSessionCategoryOptionAllowBluetoothA2DP;
+ //options |= AVAudioSessionCategoryOptionAllowAirPlay;
+ [audioSession setCategory:AVAudioSessionCategoryPlayback mode:AVAudioSessionModeDefault options:options error:&error];
+ if(error != nil)
+ DDLogError(@"failed to configure audio session: %@", error);
+ [audioSession setActive:YES withOptions:0 error:&error];
+ if(error != nil)
+ DDLogError(@"error activating audio session: %@", error);
+ DDLogVerbose(@"current audio route: %@", audioSession.currentRoute);
+}
+
++(NSDictionary*) getInvalidPushServers
+{
+ return @{
+ @"ios13push.monal.im": nilWrapper([[[UIDevice currentDevice] identifierForVendor] UUIDString]),
+ @"push.monal.im": nilWrapper([[[UIDevice currentDevice] identifierForVendor] UUIDString]),
+ @"us.prod.push.monal-im.org": nilWrapper(nil),
+ };
+}
+
++(NSString*) getSelectedPushServerBasedOnLocale
+{
+#ifdef IS_ALPHA
+ return @"alpha.push.monal-im.org";
+#else
+ return @"eu.prod.push.monal-im.org";
+ /*
+ if([[[NSLocale currentLocale] countryCode] isEqualToString:@"US"])
+ {
+ return @"us.prod.push.monal-im.org";
+ }
+ else
+ {
+ return @"eu.prod.push.monal-im.org";
+ }
+ */
+#endif
+}
+
++(NSDictionary*) getAvailablePushServers
+{
+ return @{
+ //@"us.prod.push.monal-im.org": @"US",
+ @"eu.prod.push.monal-im.org": @"Europe",
+ @"alpha.push.monal-im.org": @"Alpha/Debug (more Logging)",
+#ifdef IS_ALPHA
+ @"disabled.push.monal-im.org": @"Disabled - Alpha Test",
+#endif
+ };
+}
+
++(NSArray*) getFailoverStunServers
+{
+ return @[
+#ifdef IS_ALPHA
+ @"stuns:alpha.turn.monal-im.org:443",
+ @"stuns:alpha.turn.monal-im.org:3478",
+#else
+ @"stuns:eu.prod.turn.monal-im.org:443",
+ @"stuns:eu.prod.turn.monal-im.org:3478",
+#endif
+ ];
+}
+
+//this wrapper is needed, because MLChatImageCell can't import our monalxmpp-Swift bridging header, but importing HelperTools is okay
++(AnyPromise*) renderUIImageFromSVGURL:(NSURL* _Nullable) url
+{
+ return [SwiftHelpers _renderUIImageFromSVGURL:url];
+}
+
+//this wrapper is needed, because MLChatImageCell can't import our monalxmpp-Swift bridging header, but importing HelperTools is okay
++(AnyPromise*) renderUIImageFromSVGData:(NSData* _Nullable) data
+{
+ return [SwiftHelpers _renderUIImageFromSVGData:data];
+}
+
++(void) busyWaitForOperationQueue:(NSOperationQueue*) queue
+{
+ //apparently setting someQueue.suspended = YES does return before the queue is actually suspended
+ //--> busy wait for someQueue.suspended == YES
+ int busyWaitCounter = 0;
+ NSTimeInterval waitTime = 0.0;
+ NSDate* startTime = [NSDate date];
+ while([queue isSuspended] != YES)
+ {
+ busyWaitCounter++;
+ waitTime = [[NSDate date] timeIntervalSinceDate:startTime];
+ MLAssert(waitTime <= 4.0, @"Busy wait for queue freeze took longer than 4.0 seconds!", (@{@"queue": queue, @"name": queue.name}));
+
+ }
+ if(busyWaitCounter > 0)
+ DDLogWarn(@"busyWaitFor:%@ --> busyWaitCounter=%d, waitTime=%f", queue.name, busyWaitCounter, waitTime);
+}
+
++(id) getObjcDefinedValue:(MLDefinedIdentifier) identifier
+{
+ switch(identifier)
+ {
+ case MLDefinedIdentifier_kAppGroup: return kAppGroup; break;
+ case MLDefinedIdentifier_kMonalOpenURL: return kMonalOpenURL; break;
+ case MLDefinedIdentifier_kBackgroundProcessingTask: return kBackgroundProcessingTask; break;
+ case MLDefinedIdentifier_kBackgroundRefreshingTask: return kBackgroundRefreshingTask; break;
+ case MLDefinedIdentifier_kMonalKeychainName: return kMonalKeychainName; break;
+ case MLDefinedIdentifier_kMucTypeGroup: return kMucTypeGroup; break;
+ case MLDefinedIdentifier_kMucRoleModerator: return kMucRoleModerator; break;
+ case MLDefinedIdentifier_kMucRoleNone: return kMucRoleNone; break;
+ case MLDefinedIdentifier_kMucRoleParticipant: return kMucRoleParticipant; break;
+ case MLDefinedIdentifier_kMucRoleVisitor: return kMucRoleVisitor; break;
+ case MLDefinedIdentifier_kMucAffiliationOwner: return kMucAffiliationOwner; break;
+ case MLDefinedIdentifier_kMucAffiliationAdmin: return kMucAffiliationAdmin; break;
+ case MLDefinedIdentifier_kMucAffiliationMember: return kMucAffiliationMember; break;
+ case MLDefinedIdentifier_kMucAffiliationOutcast: return kMucAffiliationOutcast; break;
+ case MLDefinedIdentifier_kMucAffiliationNone: return kMucAffiliationNone; break;
+ case MLDefinedIdentifier_kMucActionShowProfile: return kMucActionShowProfile; break;
+ case MLDefinedIdentifier_kMucActionReinvite: return kMucActionReinvite; break;
+ case MLDefinedIdentifier_kMucTypeChannel: return kMucTypeChannel; break;
+ case MLDefinedIdentifier_SHORT_PING: return @(SHORT_PING); break;
+ case MLDefinedIdentifier_LONG_PING: return @(LONG_PING); break;
+ case MLDefinedIdentifier_MUC_PING: return @(MUC_PING); break;
+ case MLDefinedIdentifier_BGFETCH_DEFAULT_INTERVAL: return @(BGFETCH_DEFAULT_INTERVAL); break;
+ default:
+ unreachable(@"unknown MLDefinedIdentifier!");
+ }
+}
+
++(NSRunLoop*) getExtraRunloopWithIdentifier:(MLRunLoopIdentifier) identifier
+{
+ static NSMutableDictionary* runloops = nil;
+ static dispatch_once_t onceToken;
+ dispatch_once(&onceToken, ^{
+ runloops = [NSMutableDictionary new];
+ });
+
+ //every identifier has its own thread priority/qos class
+ __block dispatch_queue_priority_t priority;
+ __block char* name;
+ switch(identifier)
+ {
+ case MLRunLoopIdentifierNetwork: priority = DISPATCH_QUEUE_PRIORITY_BACKGROUND; name = "im.monal.runloop.networking"; break;
+ case MLRunLoopIdentifierTimer: priority = DISPATCH_QUEUE_PRIORITY_BACKGROUND; name = "im.monal.runloop.timer"; break;
+ default: unreachable(@"unknown runloop identifier!");
+ }
+
+ @synchronized(runloops) {
+ if(runloops[@(identifier)] == nil)
+ {
+ NSCondition* condition = [NSCondition new];
+ [condition lock];
+ dispatch_async(dispatch_queue_create_with_target(name, DISPATCH_QUEUE_SERIAL, dispatch_get_global_queue(priority, 0)), ^{
+ //set thread name, too (not only runloop name)
+ [NSThread.currentThread setName:[NSString stringWithFormat:@"%s", name]];
+ //we don't need an @synchronized block around this because the @synchronized block of the outer thread
+ //waits until we signal our condition (e.g. no other thread can race with us)
+ NSRunLoop* localLoop = runloops[@(identifier)] = [NSRunLoop currentRunLoop];
+ [condition lock];
+ [condition signal];
+ [condition unlock];
+ while(YES)
+ {
+ [localLoop run];
+ usleep(10000); //sleep 10ms if we ever return from our runloop to not consume too much cpu
+ }
+ });
+ [condition wait];
+ [condition unlock];
+ }
+ return runloops[@(identifier)];
+ }
+}
+
++(NSError* _Nullable) hardLinkOrCopyFile:(NSString*) from to:(NSString*) to
+{
+ NSError* error = nil;
+ NSFileManager* fileManager = [NSFileManager defaultManager];
+ DDLogVerbose(@"Trying to hardlink file '%@' to '%@'...", from, to);
+ [fileManager linkItemAtPath:from toPath:to error:&error];
+ if(error)
+ {
+ DDLogWarn(@"Hardlinking failed, trying normal copy operation: %@", error);
+ error = nil;
+ [fileManager copyItemAtPath:from toPath:to error:&error];
+ if(error)
+ {
+ DDLogWarn(@"File copy failed, too: %@", error);
+ return error;
+ }
+ }
+ return nil;
+}
+
++(NSString*) getQueueThreadLabelFor:(DDLogMessage*) logMessage
+{
+ NSString* queueThreadLabel = logMessage.threadName;
+ if(![queueThreadLabel length])
+ queueThreadLabel = logMessage.queueLabel;
+ if([@"com.apple.main-thread" isEqualToString:queueThreadLabel])
+ queueThreadLabel = @"main";
+ if(![queueThreadLabel length])
+ queueThreadLabel = logMessage.threadID;
+
+ //remove already appended " (QOS: XXX)" because we want to append the QOS part ourselves
+ NSRange range = [queueThreadLabel rangeOfString:@" (QOS: "];
+ if(range.length > 0)
+ queueThreadLabel = [queueThreadLabel substringWithRange:NSMakeRange(0, range.location)];
+
+ return queueThreadLabel;
+}
+
++(NSURL*) getFailoverTurnApiServer
+{
+ NSString* turnApiServer;
+#ifdef IS_ALPHA
+ turnApiServer = @"https://alpha.turn.monal-im.org";
+#else
+ turnApiServer = @"https://eu.prod.turn.monal-im.org";
+#endif
+ return [NSURL URLWithString:turnApiServer];
+}
+
++(BOOL) shouldProvideVoip
+{
+ BOOL shouldProvideVoip = NO;
+#if TARGET_OS_MACCATALYST
+#ifdef IS_ALPHA
+ shouldProvideVoip = YES;
+#endif
+#else
+#ifdef IS_QUICKSY
+ NSLocale* userLocale = [NSLocale currentLocale];
+ shouldProvideVoip = !([userLocale.countryCode containsString: @"CN"] || [userLocale.countryCode containsString: @"CHN"]);
+#else
+ shouldProvideVoip = YES;
+#endif
+#endif
+ return shouldProvideVoip;
+}
+
++(BOOL) isSandboxAPNS
+{
+#if TARGET_OS_SIMULATOR
+ DDLogVerbose(@"APNS environment is: sandbox");
+ return YES;
+#else
+ // check if were are sandbox or production
+ NSString* embeddedProvPath;
+#if TARGET_OS_MACCATALYST
+ NSString* bundleURL = [[NSBundle mainBundle] bundleURL].absoluteString;
+ embeddedProvPath = [[[bundleURL componentsSeparatedByString:@"file://"] objectAtIndex:1] stringByAppendingString:@"Contents/embedded.provisionprofile"];
+#else
+ embeddedProvPath = [[NSBundle mainBundle] pathForResource:@"embedded" ofType:@"mobileprovision"];
+#endif
+ DDLogVerbose(@"Loading embedded provision plist at: %@", embeddedProvPath);
+ NSError* loadingError;
+ NSString* embeddedProvStr = [NSString stringWithContentsOfFile:embeddedProvPath encoding:NSISOLatin1StringEncoding error:&loadingError];
+ if(embeddedProvStr == nil)
+ {
+ // fallback to production
+ DDLogWarn(@"Could not read embedded provision (should be production install): %@", loadingError);
+ DDLogVerbose(@"APNS environment is: production");
+ return NO;
+ }
+ NSScanner* plistScanner = [NSScanner scannerWithString:embeddedProvStr];
+ [plistScanner scanUpToString:@"" intoString:&plistStr];
+ plistStr = [NSString stringWithFormat:@"%@", plistStr];
+ DDLogVerbose(@"Extracted bundle plist string: %@", plistStr);
+
+ NSError* plistError;
+ NSPropertyListFormat format;
+ NSDictionary* plist = [NSPropertyListSerialization propertyListWithData:[plistStr dataUsingEncoding:NSISOLatin1StringEncoding] options:NSPropertyListImmutable format:&format error:&plistError];
+ DDLogVerbose(@"Parsed plist: %@", plist);
+ if(plistError != nil)
+ {
+ // fallback to production
+ DDLogWarn(@"Could not parse embedded provision as plist: %@", plistError);
+ DDLogVerbose(@"APNS environment is: production");
+ return NO;
+ }
+ if(plist[@"com.apple.developer.aps-environment"] && [@"production" isEqualToString:plist[@"com.apple.developer.aps-environment"]] == NO)
+ {
+ // sandbox
+ DDLogWarn(@"aps-environmnet is set to: %@", plist[@"com.apple.developer.aps-environment"]);
+ DDLogVerbose(@"APNS environment is: sandbox");
+ return YES;
+ }
+ if(plist[@"Entitlements"] && [@"production" isEqualToString:plist[@"Entitlements"][@"aps-environment"]] == NO)
+ {
+ // sandbox
+ DDLogWarn(@"aps-environmnet is set to: %@", plist[@"Entitlements"][@"aps-environment"]);
+ DDLogVerbose(@"APNS environment is: sandbox");
+ return YES;
+ }
+ // production
+ DDLogVerbose(@"APNS environment is: production");
+ return NO;
+#endif
+}
+
++(int) compareIOcted:(NSData*) data1 with:(NSData*) data2
+{
+ int result = memcmp(data1.bytes, data2.bytes, min(data1.length, data2.length));
+ if(result == 0 && data1.length < data2.length)
+ return -1;
+ else if(result == 0 && data1.length > data2.length)
+ return 1;
+ return result;
+}
+
++(NSURL*) getContainerURLForPathComponents:(NSArray*) components
+{
+ static NSURL* containerUrl;
+ static dispatch_once_t onceToken;
+ dispatch_once(&onceToken, ^{
+ containerUrl = [[NSFileManager defaultManager] containerURLForSecurityApplicationGroupIdentifier:kAppGroup];
+ });
+ MLAssert(containerUrl != nil, @"Container URL should never be nil!");
+ NSURL* retval = containerUrl;
+ for(NSString* component in components)
+ retval = [retval URLByAppendingPathComponent:component];
+ return retval;
+}
+
++(NSURL*) getSharedDocumentsURLForPathComponents:(NSArray*) components
+{
+ NSURL* sharedUrl = [[[NSFileManager defaultManager] URLsForDirectory:NSDocumentDirectory inDomains:NSUserDomainMask] lastObject];
+ for(NSString* component in components)
+ sharedUrl = [sharedUrl URLByAppendingPathComponent:component];
+ NSURLComponents* urlComponents = [NSURLComponents componentsWithURL:sharedUrl resolvingAgainstBaseURL:NO];
+ urlComponents.scheme = @"shareddocuments";
+ return urlComponents.URL;
+}
+
++(NSData*) serializeObject:(id) obj
+{
+ NSError* error;
+ NSData* data = [NSKeyedArchiver archivedDataWithRootObject:obj requiringSecureCoding:YES error:&error];
+ if(error)
+ @throw [NSException exceptionWithName:@"NSError" reason:[NSString stringWithFormat:@"%@", error] userInfo:@{@"error": error}];
+ return data;
+}
+
++(id) unserializeData:(NSData*) data
+{
+ NSError* error;
+ id obj = [NSKeyedUnarchiver unarchivedObjectOfClasses:[[NSSet alloc] initWithArray:@[
+ [NSData class],
+ [NSMutableData class],
+ [NSMutableDictionary class],
+ [NSDictionary class],
+ [NSMutableSet class],
+ [NSSet class],
+ [NSMutableArray class],
+ [NSArray class],
+ [NSNumber class],
+ [NSString class],
+ [NSDate class],
+ [MLHandler class],
+ [MLXMLNode class],
+ [XMPPIQ class],
+ [XMPPPresence class],
+ [XMPPMessage class],
+ [XMPPDataForm class],
+ [MLContact class],
+ [MLMessage class],
+ [NSURL class],
+ [OmemoState class],
+ [MLContactSoftwareVersionInfo class],
+ [Quicksy_Country class],
+ ]] fromData:data error:&error];
+ if(error)
+ @throw [NSException exceptionWithName:@"NSError" reason:[NSString stringWithFormat:@"%@", error] userInfo:@{@"error": error}];
+ return obj;
+}
+
++(NSError* _Nullable) postUserNotificationRequest:(UNNotificationRequest*) request
+{
+ __block NSError* retval = nil;
+ NSCondition* condition = [NSCondition new];
+ [condition lock];
+ monal_void_block_t cancelTimeout = createTimer(1.0, (^{
+ DDLogError(@"Waiting for notification center took more than 1.0 second, continuing anyways");
+ [condition lock];
+ [condition signal];
+ [condition unlock];
+ }));
+ [[UNUserNotificationCenter currentNotificationCenter] addNotificationRequest:request withCompletionHandler:^(NSError* _Nullable error) {
+ if(error)
+ DDLogError(@"Error posting notification: %@", error);
+ retval = error;
+ [condition lock];
+ [condition signal];
+ [condition unlock];
+ }];
+ [condition wait];
+ [condition unlock];
+ cancelTimeout();
+ return retval;
+}
+
++(void) createAVURLAssetFromFile:(NSString*) file havingMimeType:(NSString*) mimeType andFileExtension:(NSString* _Nullable) fileExtension withCompletionHandler:(void(^)(AVURLAsset* _Nullable)) completion
+{
+ NSURL* fileUrl = [NSURL fileURLWithPath:file];
+ if(@available(iOS 17.0, macCatalyst 17.0, *))
+ {
+ //generate an AVURLAsset using the modern ios 17 method to attach a mime type to an AVURLAsset
+ return completion([AVURLAsset URLAssetWithURL:fileUrl options:@{AVURLAssetOverrideMIMETypeKey: mimeType}]);
+ }
+
+ //TODO: instead of this symlink method hack, we *maybe* could use the AVURLAssetOutOfBandMIMETypeKey in place of
+ //TODO: AVURLAssetOverrideMIMETypeKey on ios 16, BUT: that symbol isn't public and may be catched by apple review
+ //TODO: (but it makes our code way cleaner than using this symlink stuff)
+ DDLogDebug(@"Generating thumbnail with symlink method...");
+ if(fileExtension == nil)
+ {
+ //this will return nil if the mime type isn't known by apple
+ fileExtension = [[UTType typeWithMIMEType:mimeType] preferredFilenameExtension];
+ //--> bail out if this is still nil
+ if(fileExtension == nil)
+ {
+ DDLogWarn(@"Could not get file extension for file, not creating AVURLAsset...");
+ return completion(nil);
+ }
+ }
+
+ NSURL* symlinkUrl = [self getContainerURLForPathComponents:@[
+ @"documentCache",
+ [NSString stringWithFormat:@"tmp.avurlasset_symlink.%@.%@", fileUrl.lastPathComponent, fileExtension]
+ ]];
+ NSError* error = nil;
+ if([[NSFileManager defaultManager] fileExistsAtPath:symlinkUrl.path])
+ [[NSFileManager defaultManager] removeItemAtURL:symlinkUrl error:&error];
+ if(error != nil)
+ {
+ DDLogError(@"Could not delete old leftover symlink file at '%@': %@", symlinkUrl, error);
+ return completion(nil);
+ }
+ [[NSFileManager defaultManager] createSymbolicLinkAtURL:symlinkUrl withDestinationURL:fileUrl error:&error];
+ if(error != nil)
+ {
+ DDLogError(@"Could not create symlink file '%@' pointing to '%@': %@", symlinkUrl, fileUrl, error);
+ return completion(nil);
+ }
+
+ //create the AVURLAsset and invoke the callback using it
+ completion([AVURLAsset URLAssetWithURL:fileUrl options:@{}]);
+
+ //remove file afterwards and just log errors if removal of symlink fails
+ [[NSFileManager defaultManager] removeItemAtURL:symlinkUrl error:&error];
+ if(error != nil)
+ DDLogError(@"Could not clean up symlink file '%@' pointing to '%@': %@", symlinkUrl, fileUrl, error);
+}
+
++(AnyPromise*) generateVideoThumbnailFromFile:(NSString*) file havingMimeType:(NSString*) mimeType andFileExtension:(NSString* _Nullable) fileExtension
+{
+ return [AnyPromise promiseWithResolverBlock:^(PMKResolver resolve) {
+ [self createAVURLAssetFromFile:file havingMimeType:mimeType andFileExtension:fileExtension withCompletionHandler:^(AVURLAsset* asset) {
+ if(asset == nil)
+ return resolve([NSError errorWithDomain:@"Monal" code:0 userInfo:@{NSLocalizedDescriptionKey: @"Could not create AVURLAsset"}]);
+
+ AVAssetImageGenerator* imageGenerator = [[AVAssetImageGenerator alloc] initWithAsset:asset];
+ imageGenerator.appliesPreferredTrackTransform=TRUE;
+ CMTime time = CMTimeMakeWithSeconds(1, 600);
+
+ [imageGenerator generateCGImageAsynchronouslyForTime:time completionHandler:^(CGImageRef image, CMTime actualTime, NSError* error) {
+ if(error != nil)
+ {
+ DDLogError(@"Error generating thumbnail: %@", error);
+ return resolve(error);
+ }
+ return resolve([UIImage imageWithCGImage:image]);
+ }];
+ }];
+ }];
+}
+
+#pragma clang diagnostic push
+#pragma clang diagnostic ignored "-Wcompletion-handler"
++(void) addUploadItemPreviewForItem:(NSURL* _Nullable) url provider:(NSItemProvider* _Nullable) provider andPayload:(NSMutableDictionary*) payload withCompletionHandler:(void(^)(NSMutableDictionary* _Nullable)) completion
+{
+ void (^useProvider)() = ^() {
+ if(provider == nil)
+ {
+ DDLogWarn(@"Can not creating preview image via item provider, no provider present: using generic doc image instead");
+ payload[@"preview"] = [UIImage systemImageNamed:@"doc"];
+ [url stopAccessingSecurityScopedResource];
+ return completion(payload);
+ }
+ else
+ [provider loadPreviewImageWithOptions:nil completionHandler:^(UIImage* _Nullable previewImage, NSError* _Null_unspecified error) {
+ if(error != nil || previewImage == nil)
+ {
+ if(url == nil)
+ {
+ DDLogWarn(@"Error creating preview image via item provider, using generic doc image instead: %@", error);
+ payload[@"preview"] = [UIImage systemImageNamed:@"doc"];
+ }
+ }
+ else
+ {
+ DDLogVerbose(@"Managed to generate thumbnail for url=%@ using loadPreviewImageWithOptions: %@", url, previewImage);
+ payload[@"preview"] = previewImage;
+ }
+ [url stopAccessingSecurityScopedResource];
+ return completion(payload);
+ }];
+ };
+ if(url != nil)
+ {
+ DDLogVerbose(@"Generating thumbnail for url=%@", url);
+ QLThumbnailGenerationRequest* request = [[QLThumbnailGenerationRequest alloc] initWithFileAtURL:url size:CGSizeMake(64, 64) scale:1.0 representationTypes:QLThumbnailGenerationRequestRepresentationTypeThumbnail];
+ NSURL* tmpURL = [NSURL fileURLWithPath:NSTemporaryDirectory() isDirectory: YES];
+ tmpURL = [tmpURL URLByAppendingPathComponent:[[NSUUID UUID] UUIDString]];
+ [QLThumbnailGenerator.sharedGenerator saveBestRepresentationForRequest:request toFileAtURL:tmpURL withContentType:UTTypePNG.identifier completionHandler:^(NSError *error) {
+ if(error == nil)
+ {
+ UIImage* result = [UIImage imageWithContentsOfFile:[url path]];
+ [[NSFileManager defaultManager] removeItemAtURL:tmpURL error:nil]; //remove temporary file, we don't need it anymore
+ if(result != nil)
+ {
+ payload[@"preview"] = result;
+ DDLogVerbose(@"Managed to generate thumbnail for url=%@ using QLThumbnailGenerator: %@", url, result);
+ [url stopAccessingSecurityScopedResource];
+ return completion(payload); //don't fall through on success
+ }
+ }
+ //if we fall through to this point, either the thumbnail generation or the imageWithContentsOfFile above failed
+ //--> try something else
+ DDLogVerbose(@"Extracting thumbnail using imageWithContentsOfFile failed, retrying with imageWithContentsOfFile: %@", error);
+ UIImage* result = [UIImage imageWithContentsOfFile:[url path]];
+ if(result != nil)
+ {
+ payload[@"preview"] = result;
+ DDLogVerbose(@"Managed to generate thumbnail for url=%@ using imageWithContentsOfFile: %@", url, result);
+ [url stopAccessingSecurityScopedResource];
+ return completion(payload);
+ }
+ else
+ {
+ DDLogVerbose(@"Thumbnail generation not successful - reverting to generic image for file: %@", error);
+ UIDocumentInteractionController* imgCtrl = [UIDocumentInteractionController interactionControllerWithURL:url];
+ if(imgCtrl != nil && imgCtrl.icons.count > 0)
+ {
+ payload[@"preview"] = imgCtrl.icons.firstObject;
+ DDLogVerbose(@"Managed to generate thumbnail for url=%@ using generic image for file: %@", url, imgCtrl.icons.firstObject);
+ [url stopAccessingSecurityScopedResource];
+ return completion(payload);
+ }
+ }
+
+ //try to generate video thumbnail
+ [self generateVideoThumbnailFromFile:url.path havingMimeType:[UTType typeWithFilenameExtension:url.pathExtension].preferredMIMEType andFileExtension:url.pathExtension].then(^(UIImage* image) {
+ payload[@"preview"] = image;
+ DDLogVerbose(@"Managed to generate thumbnail for url=%@ using generateVideoThumbnailFromFile: %@", url, image);
+ [url stopAccessingSecurityScopedResource];
+ return completion(payload);
+ }).catch(^(NSError* error) {
+ DDLogError(@"Could not create video thumbnail, using provider as last resort: %@", error);
+
+ //last resort
+ useProvider();
+ });
+ }];
+ }
+ else
+ useProvider();
+}
+#pragma clang diagnostic pop
+
+#pragma clang diagnostic push
+#pragma clang diagnostic ignored "-Wcompletion-handler"
++(void) handleUploadItemProvider:(NSItemProvider*) provider withCompletionHandler:(void(^)(NSMutableDictionary* _Nullable)) completion
+{
+ NSMutableDictionary* payload = [NSMutableDictionary new];
+ //for a list of types, see UTCoreTypes.h in MobileCoreServices framework
+ DDLogInfo(@"ShareProvider: %@", provider.registeredTypeIdentifiers);
+ if(provider.suggestedName != nil)
+ payload[@"filename"] = provider.suggestedName;
+
+ void (^prepareFile)(NSURL*) = ^(NSURL* item) {
+ NSError* error;
+ [item startAccessingSecurityScopedResource];
+ [[NSFileCoordinator new] coordinateReadingItemAtURL:item options:NSFileCoordinatorReadingForUploading error:&error byAccessor:^(NSURL* _Nonnull newURL) {
+ DDLogDebug(@"NSFileCoordinator called accessor: %@", newURL);
+ payload[@"data"] = [MLFiletransfer prepareFileUpload:newURL];
+ //we can not use newURL here, because it will fall out of scope while the preview is rendered in another thread
+ return [HelperTools addUploadItemPreviewForItem:item provider:provider andPayload:payload withCompletionHandler:completion];
+ }];
+ if(error != nil)
+ {
+ DDLogError(@"Error preparing file coordinator: %@", error);
+ payload[@"error"] = error;
+ [item stopAccessingSecurityScopedResource];
+ return completion(payload);
+ }
+ };
+
+ if([provider hasItemConformingToTypeIdentifier:@"com.apple.mapkit.map-item"])
+ {
+ // convert map item to geo:
+ [provider loadItemForTypeIdentifier:@"com.apple.mapkit.map-item" options:nil completionHandler:^(NSData* _Nullable item, NSError* _Null_unspecified error) {
+ if(error != nil || item == nil)
+ {
+ DDLogError(@"Error extracting item from NSItemProvider: %@", error);
+ payload[@"error"] = error;
+ return completion(payload);
+ }
+ NSError* err;
+ MKMapItem* mapItem = [NSKeyedUnarchiver unarchivedObjectOfClass:[MKMapItem class] fromData:item error:&err];
+ if(err != nil || mapItem == nil)
+ {
+ DDLogError(@"Error extracting mapkit item: %@", err);
+ payload[@"error"] = err;
+ return completion(payload);
+ }
+ else
+ {
+ DDLogInfo(@"Got mapkit item: %@", item);
+ payload[@"type"] = @"geo";
+ payload[@"data"] = [NSString stringWithFormat:@"geo:%f,%f", mapItem.placemark.coordinate.latitude, mapItem.placemark.coordinate.longitude];
+ return [HelperTools addUploadItemPreviewForItem:nil provider:provider andPayload:payload withCompletionHandler:completion];
+ }
+ }];
+ }
+ //the apple-private autoloop gif type has a bug that does not allow to load this as normal gif --> try audiovisual content below
+ else if([provider hasItemConformingToTypeIdentifier:UTTypeGIF.identifier] && ![provider hasItemConformingToTypeIdentifier:@"com.apple.private.auto-loop-gif"])
+ {
+ /*
+ [provider loadDataRepresentationForTypeIdentifier:UTTypeGIF.identifier completionHandler:^(NSData* data, NSError* error) {
+ if(error != nil || data == nil)
+ {
+ DDLogError(@"Error extracting gif image from NSItemProvider: %@", error);
+ payload[@"error"] = error;
+ return completion(payload);
+ }
+ DDLogInfo(@"Got gif image data: %@", data);
+ payload[@"type"] = @"file";
+ payload[@"data"] = [MLFiletransfer prepareDataUpload:data withFileExtension:@"gif"];
+ return [HelperTools addUploadItemPreviewForItem:nil provider:provider andPayload:payload withCompletionHandler:completion];
+ }];
+ */
+ [provider loadInPlaceFileRepresentationForTypeIdentifier:UTTypeGIF.identifier completionHandler:^(NSURL* _Nullable item, BOOL isInPlace, NSError* _Null_unspecified error) {
+ if(error != nil || item == nil)
+ {
+ DDLogError(@"Error extracting gif image from NSItemProvider: %@", error);
+ payload[@"error"] = error;
+ return completion(payload);
+ }
+ DDLogInfo(@"Got %@ gif image item: %@", isInPlace ? @"(in place)" : @"(copied)", item);
+ payload[@"type"] = @"file";
+ return prepareFile(item);
+ }];
+ }
+ else if([provider hasItemConformingToTypeIdentifier:UTTypeAudiovisualContent.identifier])
+ {
+ [provider loadItemForTypeIdentifier:UTTypeAudiovisualContent.identifier options:nil completionHandler:^(NSURL* _Nullable item, NSError* _Null_unspecified error) {
+ if(error != nil || item == nil)
+ {
+ DDLogError(@"Error extracting item from NSItemProvider: %@", error);
+ payload[@"error"] = error;
+ return completion(payload);
+ }
+ DDLogInfo(@"Got audiovisual item: %@", item);
+ payload[@"type"] = @"audiovisual";
+ return prepareFile(item);
+ }];
+ }
+ else if([provider hasItemConformingToTypeIdentifier:UTTypeImage.identifier])
+ {
+ [provider loadItemForTypeIdentifier:UTTypeImage.identifier options:nil completionHandler:^(NSURL* _Nullable item, NSError* _Null_unspecified error) {
+ if(error != nil || item == nil)
+ {
+ //for example: image shared directly from screenshots
+ DDLogWarn(@"Got error, retrying with UIImage: %@", error);
+ [provider loadItemForTypeIdentifier:UTTypeImage.identifier options:nil completionHandler:^(UIImage* _Nullable item, NSError* _Null_unspecified error) {
+ if(error != nil || item == nil)
+ {
+ DDLogError(@"Error extracting item from NSItemProvider: %@", error);
+ payload[@"error"] = error;
+ return completion(payload);
+ }
+ DDLogInfo(@"Got memory image item: %@", item);
+ payload[@"type"] = @"image";
+ if(![[HelperTools defaultsDB] boolForKey:@"uploadImagesOriginal"])
+ {
+ //use prepareUIImageUpload to resize the image to the configured quality
+ payload[@"data"] = [MLFiletransfer prepareUIImageUpload:item];
+ }
+ else
+ payload[@"data"] = [MLFiletransfer prepareDataUpload:UIImagePNGRepresentation(item) withFileExtension:@"png"];
+ payload[@"preview"] = item;
+ return completion(payload);
+ }];
+ }
+ else
+ {
+ DDLogInfo(@"Got image item: %@", item);
+ payload[@"type"] = @"image";
+ if(![[HelperTools defaultsDB] boolForKey:@"uploadImagesOriginal"])
+ {
+ [item startAccessingSecurityScopedResource];
+ [[NSFileCoordinator new] coordinateReadingItemAtURL:item options:NSFileCoordinatorReadingForUploading error:&error byAccessor:^(NSURL* _Nonnull newURL) {
+ DDLogDebug(@"NSFileCoordinator called accessor for image: %@", newURL);
+ UIImage* image = [UIImage imageWithContentsOfFile:[newURL path]];
+ DDLogDebug(@"Created UIImage: %@", image);
+ //use prepareUIImageUpload to resize the image to the configured quality (instead of just uploading the raw image file)
+ payload[@"data"] = [MLFiletransfer prepareUIImageUpload:image];
+ //we can not use newURL here, because it will fall out of scope while the preview is rendered in another thread
+ return [HelperTools addUploadItemPreviewForItem:item provider:provider andPayload:payload withCompletionHandler:completion];
+ }];
+ }
+ else
+ return prepareFile(item);
+ if(error != nil)
+ {
+ DDLogError(@"Error preparing file coordinator: %@", error);
+ payload[@"error"] = error;
+ [item stopAccessingSecurityScopedResource];
+ return completion(payload);
+ }
+ }
+ }];
+ }
+ /*else if([provider hasItemConformingToTypeIdentifier:(NSString*)])
+ {
+ }
+ else if([provider hasItemConformingToTypeIdentifier:(NSString*)])
+ {
+ }*/
+ else if([provider hasItemConformingToTypeIdentifier:UTTypeContact.identifier])
+ {
+ [provider loadItemForTypeIdentifier:UTTypeContact.identifier options:nil completionHandler:^(NSURL* _Nullable item, NSError* _Null_unspecified error) {
+ if(error != nil || item == nil)
+ {
+ DDLogError(@"Error extracting item from NSItemProvider: %@", error);
+ payload[@"error"] = error;
+ return completion(payload);
+ }
+ DDLogInfo(@"Got contact item: %@", item);
+ payload[@"type"] = @"contact";
+ return prepareFile(item);
+ }];
+ }
+ else if([provider hasItemConformingToTypeIdentifier:UTTypeFileURL.identifier])
+ {
+ [provider loadItemForTypeIdentifier:UTTypeFileURL.identifier options:nil completionHandler:^(NSURL* _Nullable item, NSError* _Null_unspecified error) {
+ if(error != nil || item == nil)
+ {
+ DDLogError(@"Error extracting item from NSItemProvider: %@", error);
+ payload[@"error"] = error;
+ return completion(payload);
+ }
+ DDLogInfo(@"Got file url item: %@", item);
+ payload[@"type"] = @"file";
+ return prepareFile(item);
+ }];
+ }
+ else if([provider hasItemConformingToTypeIdentifier:(NSString*)@"com.apple.finder.node"])
+ {
+ [provider loadItemForTypeIdentifier:UTTypeItem.identifier options:nil completionHandler:^(id item, NSError* _Null_unspecified error) {
+ if(error != nil || item == nil)
+ {
+ DDLogError(@"Error extracting item from NSItemProvider: %@", error);
+ payload[@"error"] = error;
+ return completion(payload);
+ }
+ if([(NSObject*)item isKindOfClass:[NSURL class]])
+ {
+ DDLogInfo(@"Got finder file url item: %@", item);
+ payload[@"type"] = @"file";
+ return prepareFile((NSURL*)item);
+ }
+ else
+ {
+ DDLogError(@"Could not extract finder item");
+ payload[@"error"] = NSLocalizedString(@"Could not access Finder item!", @"");
+ return completion(payload);
+ }
+ }];
+ }
+ else if([provider hasItemConformingToTypeIdentifier:UTTypeURL.identifier])
+ {
+ [provider loadItemForTypeIdentifier:UTTypeURL.identifier options:nil completionHandler:^(NSURL* _Nullable item, NSError* _Null_unspecified error) {
+ if(error != nil || item == nil)
+ {
+ DDLogError(@"Error extracting item from NSItemProvider: %@", error);
+ payload[@"error"] = error;
+ return completion(payload);
+ }
+ DDLogInfo(@"Got internet url item: %@", item);
+ payload[@"type"] = @"url";
+ payload[@"data"] = item.absoluteString;
+ return [HelperTools addUploadItemPreviewForItem:nil provider:provider andPayload:payload withCompletionHandler:completion];
+ }];
+ }
+ else if([provider hasItemConformingToTypeIdentifier:UTTypePlainText.identifier])
+ {
+ [provider loadItemForTypeIdentifier:UTTypePlainText.identifier options:nil completionHandler:^(NSString* _Nullable item, NSError* _Null_unspecified error) {
+ if(error != nil || item == nil)
+ {
+ DDLogError(@"Error extracting item from NSItemProvider: %@", error);
+ payload[@"error"] = error;
+ return completion(payload);
+ }
+ DDLogInfo(@"Got direct text item: %@", item);
+ payload[@"type"] = @"text";
+ payload[@"data"] = item;
+ return [HelperTools addUploadItemPreviewForItem:nil provider:provider andPayload:payload withCompletionHandler:completion];
+ }];
+ }
+ else
+ return completion(nil);
+}
+#pragma clang diagnostic pop
+
+//see https://gist.github.com/giaesp/7704753
++(UIImage* _Nullable) rotateImage:(UIImage* _Nullable) image byRadians:(CGFloat) rotation
+{
+ if(image == nil)
+ return nil;
+
+ //Calculate Destination Size
+ CGAffineTransform t = CGAffineTransformMakeRotation(rotation);
+ CGRect sizeRect = (CGRect) {.size = image.size};
+ CGRect destRect = CGRectApplyAffineTransform(sizeRect, t);
+
+ return [[[UIGraphicsImageRenderer alloc] initWithSize:destRect.size] imageWithActions:^(UIGraphicsImageRendererContext* _Nonnull rendererContext) {
+ CGContextRef context = rendererContext.CGContext;
+
+ //Move the origin to the middle of the image to apply the transformation
+ CGContextTranslateCTM(context, destRect.size.width / 2.0f, destRect.size.height / 2.0f);
+ CGContextRotateCTM(context, rotation);
+
+ //Draw the original image into the transformed context
+ [image drawInRect:CGRectMake(-image.size.width / 2.0f, -image.size.height / 2.0f, image.size.width, image.size.height)];
+ }];
+}
+
++(UIImage* _Nullable) mirrorImageOnXAxis:(UIImage* _Nullable) image
+{
+ if(image == nil)
+ return nil;
+
+ return [[[UIGraphicsImageRenderer alloc] initWithSize:image.size] imageWithActions:^(UIGraphicsImageRendererContext* _Nonnull rendererContext) {
+ CGContextRef context = rendererContext.CGContext;
+
+ //Move the origin to the middle of the image to apply the transformation
+ CGContextTranslateCTM(context, image.size.width / 2, image.size.height / 2);
+
+ //Apply the y-axis mirroring transform
+ CGContextScaleCTM(context, 1.0, -1.0);
+
+ //Move the origin back to the bottom left corner
+ CGContextTranslateCTM(context, -image.size.width / 2, -image.size.height / 2);
+
+ //Draw the original image into the transformed context
+ [image drawInRect:CGRectMake(0, 0, image.size.width, image.size.height)];
+ }];
+}
+
++(UIImage* _Nullable) mirrorImageOnYAxis:(UIImage* _Nullable) image
+{
+ if(image == nil)
+ return nil;
+
+ return [[[UIGraphicsImageRenderer alloc] initWithSize:image.size] imageWithActions:^(UIGraphicsImageRendererContext* _Nonnull rendererContext) {
+ CGContextRef context = rendererContext.CGContext;
+
+ //Move the origin to the middle of the image to apply the transformation
+ CGContextTranslateCTM(context, image.size.width / 2, image.size.height / 2);
+
+ //Apply the y-axis mirroring transform
+ CGContextScaleCTM(context, -1.0, 1.0);
+
+ //Move the origin back to the bottom left corner
+ CGContextTranslateCTM(context, -image.size.width / 2, -image.size.height / 2);
+
+ //Draw the original image into the transformed context
+ [image drawInRect:CGRectMake(0, 0, image.size.width, image.size.height)];
+ }];
+}
+
++(UIImage*) imageWithNotificationBadgeForImage:(UIImage*) image
+{
+ UIImage* badge = [[UIImage systemImageNamed:@"circle.fill"] imageWithTintColor:UIColor.redColor];
+
+ CGRect imgSize = CGRectMake(0, 0, image.size.width, image.size.height);
+ CGRect dotSize = CGRectMake(image.size.width - 7, 0, 7, 7);
+
+ return [[[UIGraphicsImageRenderer alloc] initWithSize:image.size] imageWithActions:^(UIGraphicsImageRendererContext* _Nonnull rendererContext) {
+ [image drawInRect:imgSize];
+ [badge drawInRect:dotSize blendMode:kCGBlendModeNormal alpha:1.0];
+ }];
+}
+
++(UIImageView*) buttonWithNotificationBadgeForImage:(UIImage*) image hasNotification:(bool) hasNotification withTapHandler: (UITapGestureRecognizer*) handler
+{
+ UIImageView* result;
+ if(hasNotification)
+ result = [[UIImageView alloc] initWithImage:[self imageWithNotificationBadgeForImage:image]];
+ else
+ result = [[UIImageView alloc] initWithImage: image];
+
+ [result addGestureRecognizer:handler];
+ return result;
+}
+
++(NSData*) resizeAvatarImage:(UIImage* _Nullable) image withCircularMask:(BOOL) circularMask toMaxBase64Size:(unsigned long) length
+{
+ if(!image)
+ return [NSData new];
+
+ int destinationSize = 480;
+ int epsilon = 8;
+ UIImage* clippedImage = image;
+ UIGraphicsImageRendererFormat* format = [UIGraphicsImageRendererFormat new];
+ format.opaque = NO;
+ format.preferredRange = UIGraphicsImageRendererFormatRangeStandard;
+ format.scale = 1.0;
+ if(ABS(image.size.width - image.size.height) > epsilon)
+ {
+ //see this for different resizing techniques, memory consumption and other caveats:
+ // - https://nshipster.com/image-resizing/
+ // - https://www.advancedswift.com/crop-image/
+ // - https://www.swiftjectivec.com/optimizing-images/
+ CGFloat minSize = MIN(image.size.width, image.size.height);
+ CGRect drawImageRect = CGRectMake(
+ (image.size.width - minSize) / -2.0,
+ (image.size.height - minSize) / -2.0,
+ image.size.width,
+ image.size.height
+ );
+ CGRect drawRect = CGRectMake(
+ 0,
+ 0,
+ minSize,
+ minSize
+ );
+ DDLogInfo(@"Clipping avatar image %@ to %lux%lu pixels", image, (unsigned long)drawImageRect.size.width, (unsigned long)drawImageRect.size.height);
+ DDLogDebug(@"minSize: %.2f, drawImageRect: (%.2f, %.2f, %.2f, %.2f)", minSize,
+ drawImageRect.origin.x,
+ drawImageRect.origin.y,
+ drawImageRect.size.width,
+ drawImageRect.size.height
+ );
+ clippedImage = [[[UIGraphicsImageRenderer alloc] initWithSize:drawRect.size format:format] imageWithActions:^(UIGraphicsImageRendererContext* _Nonnull context __unused) {
+ //not needed here, already done below
+ //if(circularMask)
+ // [[UIBezierPath bezierPathWithOvalInRect:drawRect] addClip];
+ [image drawInRect:drawImageRect];
+ }];
+ image = nil; //make sure we free our memory as soon as possible
+ DDLogInfo(@"Clipped image is now: %@", clippedImage);
+ }
+
+ //shrink image to a maximum of 480x480 pixel (AVMakeRectWithAspectRatioInsideRect() keeps the aspect ratio)
+ //CGRect dimensions = AVMakeRectWithAspectRatioInsideRect(image.size, CGRectMake(0, 0, 480, 480));
+ CGRect dimensions;
+ if(clippedImage.size.width > destinationSize + epsilon)
+ {
+ dimensions = CGRectMake(0, 0, destinationSize, destinationSize);
+ DDLogInfo(@"Now shrinking image to %lux%lu pixels", (unsigned long)dimensions.size.width, (unsigned long)dimensions.size.height);
+ }
+ else if(circularMask)
+ {
+ dimensions = CGRectMake(0, 0, clippedImage.size.width, clippedImage.size.height);
+ DDLogInfo(@"Only masking image to a %lux%lu pixels circle", (unsigned long)dimensions.size.width, (unsigned long)dimensions.size.height);
+ }
+ else
+ {
+ dimensions = CGRectMake(0, 0, 0, 0);
+ DDLogInfo(@"Not doing anything to image, everything is already perfect: %@", clippedImage);
+ }
+
+ //only shink/mask image if needed and requested (indicated by a dimension size > 0
+ UIImage* resizedImage = clippedImage;
+ if(dimensions.size.width > 0)
+ {
+ resizedImage = [[[UIGraphicsImageRenderer alloc] initWithSize:dimensions.size format:format] imageWithActions:^(UIGraphicsImageRendererContext* _Nonnull context __unused) {
+ if(circularMask)
+ [[UIBezierPath bezierPathWithOvalInRect:dimensions] addClip];
+ [clippedImage drawInRect:dimensions];
+ }];
+ DDLogInfo(@"Shrinked/masked image is now: %@", resizedImage);
+ }
+ clippedImage = nil; //make sure we free our memory as soon as possible
+
+ //masked images MUST be of type png because jpeg does no carry any transparency information
+ NSData* data = nil;
+ if(circularMask)
+ {
+ data = UIImagePNGRepresentation(resizedImage);
+ DDLogInfo(@"Returning new avatar png data with size %lu for image: %@", (unsigned long)data.length, resizedImage);
+ }
+ else
+ {
+ //now reduce quality until image data is smaller than provided size
+ unsigned int i = 0;
+ double qualityList[] = {0.96, 0.80, 0.64, 0.48, 0.32, 0.24, 0.16, 0.10, 0.09, 0.08, 0.07, 0.06, 0.05, 0.04, 0.03, 0.02, 0.01};
+ for(i = 0; (data == nil || (data.length * 1.5) > length) && i < sizeof(qualityList) / sizeof(qualityList[0]); i++)
+ {
+ DDLogDebug(@"Resizing new avatar to quality %f", qualityList[i]);
+ data = UIImageJPEGRepresentation(resizedImage, qualityList[i]);
+ DDLogDebug(@"New avatar size after changing quality: %lu", (unsigned long)data.length);
+ }
+ DDLogInfo(@"Returning new avatar jpeg data with size %lu and quality %f for image: %@", (unsigned long)data.length, qualityList[i-1], resizedImage);
+ }
+ return data;
+}
+
++(double) report_memory
+{
+ struct task_basic_info info;
+ mach_msg_type_number_t size = TASK_BASIC_INFO_COUNT;
+ kern_return_t kerr = task_info(mach_task_self(),
+ TASK_BASIC_INFO,
+ (task_info_t)&info,
+ &size);
+ if(kerr == KERN_SUCCESS)
+ return ((CGFloat)info.resident_size / 1048576);
+ else
+ DDLogDebug(@"Error with task_info(): %s", mach_error_string(kerr));
+ return 1.0; //dummy value
+}
+
++(UIColor*) generateColorFromJid:(NSString*) jid
+{
+ //cache generated colors
+ static NSMutableDictionary* cache;
+ static dispatch_once_t onceToken;
+ dispatch_once(&onceToken, ^{
+ cache = [NSMutableDictionary new];
+ });
+ if(cache[jid] != nil)
+ return cache[jid];
+
+ //XEP-0392 implementation
+ NSData* hash = [self sha1:[jid dataUsingEncoding:NSUTF8StringEncoding]];
+ uint16_t rawHue = CFSwapInt16LittleToHost(*(uint16_t*)[hash bytes]);
+ double hue = (rawHue / 65536.0) * 360.0;
+ double saturation = 100.0;
+ double lightness = 50.0;
+
+ double r, g, b;
+ hsluv2rgb(hue, saturation, lightness, &r, &g, &b);
+ return cache[jid] = [UIColor colorWithRed:r green:g blue:b alpha:1];
+}
+
++(NSString*) bytesToHuman:(int64_t) bytes
+{
+ NSArray* suffixes = @[@"B", @"KiB", @"MiB", @"GiB", @"TiB", @"PiB", @"EiB"];
+ NSString* prefix = @"";
+ double size = bytes;
+ if(size < 0)
+ {
+ prefix = @"-";
+ size *= -1;
+ }
+ for(NSString* suffix in suffixes)
+ if(size < 1024)
+ return [NSString stringWithFormat:@"%@%.1F %@", prefix, size, suffix];
+ else
+ size /= 1024.0;
+ return [NSString stringWithFormat:@"%lld B", bytes];
+}
+
++(NSString*) stringFromToken:(NSData*) tokenIn
+{
+ unsigned char* tokenBytes = (unsigned char*)[tokenIn bytes];
+ NSMutableString* token = [NSMutableString new];
+ NSUInteger counter = 0;
+ while(counter < tokenIn.length)
+ {
+ [token appendString:[NSString stringWithFormat:@"%02x", (unsigned char)tokenBytes[counter]]];
+ counter++;
+ }
+ return token;
+}
+
+//proxy to not have full IPC class accessible from UI
++(NSString* _Nullable) exportIPCDatabase
+{
+ return [[IPC sharedInstance] exportDB];
+}
+
++(void) configureFileProtection:(NSString*) protectionLevel forFile:(NSString*) file
+{
+#if TARGET_OS_IPHONE
+ NSFileManager* fileManager = [NSFileManager defaultManager];
+ if([fileManager fileExistsAtPath:file])
+ {
+ //DDLogVerbose(@"protecting file '%@'...", file);
+ NSError* error;
+ [fileManager setAttributes:@{NSFileProtectionKey: protectionLevel} ofItemAtPath:file error:&error];
+ if(error)
+ {
+ DDLogError(@"Error configuring file protection level for: %@", file);
+ @throw [NSException exceptionWithName:@"NSError" reason:[NSString stringWithFormat:@"%@", error] userInfo:@{@"error": error}];
+ }
+ else
+ ;//DDLogVerbose(@"file '%@' now protected", file);
+ }
+ else
+ ;//DDLogVerbose(@"file '%@' does not exist!", file);
+#endif
+}
+
++(void) configureFileProtectionFor:(NSString*) file
+{
+ [self configureFileProtection:NSFileProtectionCompleteUntilFirstUserAuthentication forFile:file];
+}
+
++(NSDictionary*) splitJid:(NSString*) jid
+{
+ //cache results
+ static NSCache* cache;
+ static dispatch_once_t onceToken;
+ dispatch_once(&onceToken, ^{
+ cache = [NSCache new];
+ });
+ @synchronized(cache) {
+ if([cache objectForKey:jid] != nil)
+ return [cache objectForKey:jid];
+ }
+
+ NSMutableDictionary* retval = [NSMutableDictionary new];
+ NSArray* parts = [self splitString:jid withSeparator:@"/" andMaxSize:2];
+
+ retval[@"user"] = [[parts objectAtIndex:0] lowercaseString]; //intended to not break code that expects lowercase
+ if([parts count] > 1 && [[parts objectAtIndex:1] isEqualToString:@""] == NO)
+ retval[@"resource"] = [parts objectAtIndex:1]; //resources are case sensitive
+ //there should never be more than one @ char, but just in case: split only at the first one
+ parts = [self splitString:retval[@"user"] withSeparator:@"@" andMaxSize:2];
+ if([parts count] > 1)
+ {
+ retval[@"node"] = [[parts objectAtIndex:0] lowercaseString]; //intended to not break code that expects lowercase
+ retval[@"host"] = [[parts objectAtIndex:1] lowercaseString]; //intended to not break code that expects lowercase
+ }
+ else
+ retval[@"host"] = [[parts objectAtIndex:0] lowercaseString]; //intended to not break code that expects lowercase
+
+ //don't assert to not have a dos vector here, but still log the error
+ if([retval[@"host"] isEqualToString:@""])
+ DDLogError(@"jid has no host part: %@", jid);
+ //assert on sanity check errors (this checks 'host' and 'user' at once because without node host==user)
+ //MLAssert(![retval[@"host"] isEqualToString:@""], @"jid has no host part!", @{@"jid": jid});
+
+ //sanitize retval
+ if([retval[@"node"] isEqualToString:@""])
+ {
+ [retval removeObjectForKey:@"node"];
+ retval[@"user"] = retval[@"host"]; //empty node means user==host
+ }
+ if([retval[@"resource"] isEqualToString:@""])
+ [retval removeObjectForKey:@"resource"];
+
+ //cache and return immutable copy
+ @synchronized(cache) {
+ [cache setObject:[retval copy] forKey:jid];
+ }
+ return [retval copy];
+}
+
++(BOOL) isContactBlacklistedForEncryption:(MLContact*) contact
+{
+ BOOL blacklisted = NO;
+ //cheogram.com does not support OMEMO encryption as it is a PSTN gateway
+ blacklisted = [@"cheogram.com" isEqualToString:[self splitJid:contact.contactJid][@"host"]];
+ if(blacklisted)
+ DDLogWarn(@"Jid blacklisted for encryption: %@", contact);
+ return blacklisted;
+}
+
++(void) removeAllShareInteractionsForAccountID:(NSNumber*) accountID
+{
+ DDLogInfo(@"Removing share interaction for all contacts on account id %@", accountID);
+ for(MLContact* contact in [[DataLayer sharedInstance] contactList])
+ if(contact.accountID.intValue == accountID.intValue)
+ [contact removeShareInteractions];
+}
+
++(void) scheduleBackgroundTask:(BOOL) force
+{
+ DDLogInfo(@"Scheduling new BackgroundTask with force=%s...", force ? "yes" : "no");
+ [HelperTools dispatchAsync:NO reentrantOnQueue:dispatch_get_main_queue() withBlock:^{
+ NSError* error;
+ if(force)
+ {
+ //don't cancel existing task because that could delay our next execution
+// //cancel existing task (if any)
+// [BGTaskScheduler.sharedScheduler cancelTaskRequestWithIdentifier:kBackgroundProcessingTask];
+ //new task
+ BGProcessingTaskRequest* processingRequest = [[BGProcessingTaskRequest alloc] initWithIdentifier:kBackgroundProcessingTask];
+ //do the same like the corona warn app from germany which leads to this hint: https://developer.apple.com/forums/thread/134031
+ processingRequest.earliestBeginDate = nil;
+ processingRequest.requiresNetworkConnectivity = YES;
+ processingRequest.requiresExternalPower = NO;
+ if(![[BGTaskScheduler sharedScheduler] submitTaskRequest:processingRequest error:&error])
+ {
+ // Errorcodes https://stackoverflow.com/a/58224050/872051
+ DDLogError(@"Failed to submit BGTask request %@: %@", processingRequest, error);
+ }
+ else
+ DDLogVerbose(@"Success submitting BGTask request %@", processingRequest);
+ }
+ else
+ {
+ //cancel existing task (if any)
+ [BGTaskScheduler.sharedScheduler cancelTaskRequestWithIdentifier:kBackgroundRefreshingTask];
+ //new task
+ BGAppRefreshTaskRequest* refreshingRequest = [[BGAppRefreshTaskRequest alloc] initWithIdentifier:kBackgroundRefreshingTask];
+ //on ios<17 do the same like the corona warn app from germany which leads to this hint: https://developer.apple.com/forums/thread/134031
+// if(@available(iOS 17.0, macCatalyst 17.0, *))
+// refreshingRequest.earliestBeginDate = [NSDate dateWithTimeIntervalSinceNow:BGFETCH_DEFAULT_INTERVAL];
+// else
+ refreshingRequest.earliestBeginDate = nil;
+ if(![[BGTaskScheduler sharedScheduler] submitTaskRequest:refreshingRequest error:&error])
+ {
+ // Errorcodes https://stackoverflow.com/a/58224050/872051
+ DDLogError(@"Failed to submit BGTask request %@: %@", refreshingRequest, error);
+ }
+ else
+ DDLogVerbose(@"Success submitting BGTask request %@", refreshingRequest);
+ }
+ }];
+}
+
++(void) clearSyncErrorsOnAppForeground
+{
+ NSMutableDictionary* syncErrorsDisplayed = [NSMutableDictionary dictionaryWithDictionary:[[HelperTools defaultsDB] objectForKey:@"syncErrorsDisplayed"]];
+ DDLogInfo(@"Clearing syncError notification states: %@", syncErrorsDisplayed);
+ for(xmpp* account in [MLXMPPManager sharedInstance].connectedXMPP)
+ {
+ syncErrorsDisplayed[account.connectionProperties.identity.jid] = @NO;
+ //also remove pending or delivered sync error notifications
+ //this will delay the delivery of such notifications until 60 seconds after the app moved into the background
+ //rather than being delivered 60 seconds after our first sync attempt failed (wether it was in the appex or mainapp)
+ NSString* syncErrorIdentifier = [NSString stringWithFormat:@"syncError::%@", account.connectionProperties.identity.jid];
+ [[UNUserNotificationCenter currentNotificationCenter] removePendingNotificationRequestsWithIdentifiers:@[syncErrorIdentifier]];
+ [[UNUserNotificationCenter currentNotificationCenter] removeDeliveredNotificationsWithIdentifiers:@[syncErrorIdentifier]];
+ }
+ [[HelperTools defaultsDB] setObject:syncErrorsDisplayed forKey:@"syncErrorsDisplayed"];
+}
+
++(void) removePendingSyncErrorNotifications
+{
+ NSMutableDictionary* syncErrorsDisplayed = [NSMutableDictionary dictionaryWithDictionary:[[HelperTools defaultsDB] objectForKey:@"syncErrorsDisplayed"]];
+ DDLogInfo(@"Removing pending syncError notifications, current state: %@", syncErrorsDisplayed);
+ for(xmpp* account in [MLXMPPManager sharedInstance].connectedXMPP)
+ {
+ NSString* syncErrorIdentifier = [NSString stringWithFormat:@"syncError::%@", account.connectionProperties.identity.jid];
+ [[UNUserNotificationCenter currentNotificationCenter] getPendingNotificationRequestsWithCompletionHandler:^(NSArray* requests) {
+ for(UNNotificationRequest* request in requests)
+ if([request.identifier isEqualToString:syncErrorIdentifier])
+ {
+ //remove pending but not yet delivered sync error notifications and reset state to "not displayed yet"
+ //this will delay the delivery of such notifications until 60 seconds after our last sync attempt failed
+ //rather than being delivered 60 seconds after our first sync attempt failed
+ //--> better UX
+ syncErrorsDisplayed[account.connectionProperties.identity.jid] = @NO;
+ [[UNUserNotificationCenter currentNotificationCenter] removePendingNotificationRequestsWithIdentifiers:@[syncErrorIdentifier]];
+ }
+ }];
+ }
+ [[HelperTools defaultsDB] setObject:syncErrorsDisplayed forKey:@"syncErrorsDisplayed"];
+}
+
++(void) updateSyncErrorsWithDeleteOnly:(BOOL) removeOnly andWaitForCompletion:(BOOL) waitForCompletion
+{
+ monal_void_block_t updateSyncErrors = ^{
+ @synchronized(self) {
+ NSMutableDictionary* syncErrorsDisplayed = [NSMutableDictionary dictionaryWithDictionary:[[HelperTools defaultsDB] objectForKey:@"syncErrorsDisplayed"]];
+ DDLogInfo(@"Updating syncError notifications: %@", syncErrorsDisplayed);
+ for(xmpp* account in [MLXMPPManager sharedInstance].connectedXMPP)
+ {
+ NSString* syncErrorIdentifier = [NSString stringWithFormat:@"syncError::%@", account.connectionProperties.identity.jid];
+ //dispatching this to the receive queue isn't neccessary anymore, see comments in account.idle
+ if(account.idle)
+ {
+ //but only do so, if we have connectivity, otherwise just ignore it (the old sync error should still be displayed)
+ if([[MLXMPPManager sharedInstance] hasConnectivity])
+ {
+ DDLogInfo(@"Removing syncError notification for %@ (now synced)...", account.connectionProperties.identity.jid);
+ [[UNUserNotificationCenter currentNotificationCenter] removePendingNotificationRequestsWithIdentifiers:@[syncErrorIdentifier]];
+ [[UNUserNotificationCenter currentNotificationCenter] removeDeliveredNotificationsWithIdentifiers:@[syncErrorIdentifier]];
+ syncErrorsDisplayed[account.connectionProperties.identity.jid] = @NO;
+ [[HelperTools defaultsDB] setObject:syncErrorsDisplayed forKey:@"syncErrorsDisplayed"];
+ }
+ }
+ else if(!removeOnly && [self isNotInFocus])
+ {
+ if([syncErrorsDisplayed[account.connectionProperties.identity.jid] boolValue])
+ {
+ DDLogWarn(@"NOT posting syncError notification for %@ (already did so since last app foreground)...", account.connectionProperties.identity.jid);
+ continue;
+ }
+ //we always want to post sync errors if we are in the appex (because an incoming push means the server has
+ //*possibly* queued some messages for us)
+ //if we are in the main app we only want to post sync errors if we are in one of these states:
+ //1. we are NOT doing a full reconnect and the smacks queue does not contain some unacked message stanzas having a body
+ //--> (briefly) opening the app while not having an internet connection does not generate sync errors (if no
+ //outgoing message is pending)
+ //2. we are doing a full reconnect --> we always want to post sync erros because we have to rejoin mucs,
+ //set up push etc. and we *really* want to be sure all of these get a chance to complete
+ //NOTE: this conditions are all swapped and ANDed because we want to continue the loop here instead of posting a sync error
+ if(![self isAppExtension] && !account.isDoingFullReconnect && ![account shouldTriggerSyncErrorForImportantUnackedOutgoingStanzas])
+ {
+ DDLogWarn(@"NOT posting syncError notification for %@ (we are not in the appex, no important stanzas are unacked and we are not doing a full reconnect)...", account.connectionProperties.identity.jid);
+ DDLogDebug(@"[self isAppExtension] == %@, account.isDoingFullReconnect == %@, [account shouldTriggerSyncErrorForImportantUnackedOutgoingStanzas] == %@", bool2str([self isAppExtension]), bool2str(account.isDoingFullReconnect), bool2str([account shouldTriggerSyncErrorForImportantUnackedOutgoingStanzas]));
+ continue;
+ }
+ DDLogWarn(@"Posting syncError notification for %@...", account.connectionProperties.identity.jid);
+ UNMutableNotificationContent* content = [UNMutableNotificationContent new];
+ content.title = NSLocalizedString(@"Could not synchronize", @"");
+ content.subtitle = account.connectionProperties.identity.jid;
+ content.body = NSLocalizedString(@"Some messages might wait to be retrieved or sent. Please open the app to retry.", @"");
+ content.sound = [UNNotificationSound defaultSound];
+ content.categoryIdentifier = @"simple";
+ //we don't know if and when apple will start the background process or when the next push will come in
+ //--> we need a sync error notification to make the user aware of possible issues
+ //BUT: we can delay it for some time and hope a background process/push that removes the notification before it
+ //is displayed at all is started in the meantime (we use 60 seconds here)
+ UNNotificationRequest* request = [UNNotificationRequest requestWithIdentifier:syncErrorIdentifier content:content trigger:[UNTimeIntervalNotificationTrigger triggerWithTimeInterval:60 repeats: NO]];
+ NSError* error = [self postUserNotificationRequest:request];
+ if(error)
+ DDLogError(@"Error posting syncError notification: %@", error);
+ else
+ {
+ syncErrorsDisplayed[account.connectionProperties.identity.jid] = @YES;
+ [[HelperTools defaultsDB] setObject:syncErrorsDisplayed forKey:@"syncErrorsDisplayed"];
+ }
+ }
+ }
+ }
+ };
+
+ //dispatch async because we don't want to block the receive/parse/send queue invoking this check
+ if(waitForCompletion)
+ updateSyncErrors();
+ else
+ dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_BACKGROUND, 0), updateSyncErrors);
+}
+
++(BOOL) isInBackground
+{
+ __block BOOL inBackground = NO;
+ if([HelperTools isAppExtension])
+ inBackground = YES;
+ else
+ inBackground = [[MLXMPPManager sharedInstance] isBackgrounded];
+ /*
+ {
+ [HelperTools dispatchAsync:NO reentrantOnQueue:dispatch_get_main_queue() withBlock:^{
+ if([UIApplication sharedApplication].applicationState==UIApplicationStateBackground)
+ inBackground = YES;
+ }];
+ }
+ */
+ return inBackground;
+}
+
++(BOOL) isNotInFocus
+{
+ __block BOOL isNotInFocus = NO;
+ isNotInFocus |= [HelperTools isAppExtension];
+ isNotInFocus |= [[MLXMPPManager sharedInstance] isBackgrounded];
+ isNotInFocus |= [[MLXMPPManager sharedInstance] isNotInFocus];
+
+ return isNotInFocus;
+}
+
++(void) dispatchAsync:(BOOL) async reentrantOnQueue:(dispatch_queue_t _Nullable) queue withBlock:(monal_void_block_t) block
+{
+ dispatch_queue_t main_queue = dispatch_get_main_queue();
+ if(!queue)
+ queue = main_queue;
+
+ //apple docs say that enqueueing blocks for synchronous execution will execute this blocks in the thread the enqueueing came from
+ //(e.g. the tread we are already in).
+ //so when dispatching synchronously from main queue/thread to some "other queue" and from that queue back to the main queue this means:
+ //the block queued for execution in the "other queue" will be executed in the main thread
+ //this holds true even if multiple synchronous queues sit in between the main thread and this dispatchSyncReentrant:onQueue:(main_queue) call
+
+ //directly call block:
+ //IF: the destination queue is equal to our current queue
+ //OR IF: the destination queue is the main queue and we are already in the main thread (but not the main queue)
+#pragma clang diagnostic push
+#pragma clang diagnostic ignored "-Wdeprecated-declarations"
+ dispatch_queue_t current_queue = dispatch_get_current_queue();
+#pragma clang diagnostic pop
+ if(queue == main_queue && [NSThread isMainThread])
+ block();
+ else if(current_queue == queue)
+ block();
+ else
+ {
+ if(async)
+ dispatch_async(queue, block);
+ else
+ dispatch_sync(queue, block);
+ }
+}
+
++(void) activityLog
+{
+ BOOL log_activity = NO;
+#ifdef DEBUG
+ log_activity = YES;
+#else
+ log_activity = [[HelperTools defaultsDB] boolForKey:@"showLogInSettings"];
+#endif
+ if(log_activity)
+ {
+ dispatch_async(dispatch_queue_create_with_target("im.monal.activityLog", DISPATCH_QUEUE_SERIAL, dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_BACKGROUND, 0)), ^{
+ unsigned long counter = 1;
+ while(counter++)
+ {
+ DDLogInfo(@"activity: %lu, memory used / available: %.3fMiB / %.3fMiB", counter, [self report_memory], (CGFloat)os_proc_available_memory() / 1048576);
+ [NSThread sleepForTimeInterval:1];
+ }
+ });
+ }
+}
+
++(NSUserDefaults*) defaultsDB
+{
+ static NSUserDefaults* db;
+ static dispatch_once_t onceToken;
+ dispatch_once(&onceToken, ^{
+ db = [[NSUserDefaults alloc] initWithSuiteName:kAppGroup];
+ });
+ return db;
+}
+
++(DDFileLogger*) fileLogger
+{
+ return _fileLogger;
+}
+
++(void) setFileLogger:(DDFileLogger*) fileLogger
+{
+ _fileLogger = fileLogger;
+}
+
++(NSData* _Nullable) convertLogmessageToJsonData:(DDLogMessage*) logMessage counter:(uint64_t*) counter andError:(NSError** _Nullable) error
+{
+ static NSDateFormatter* dateFormatter = nil;
+ static NSString* (^qos2name)(NSUInteger) = nil;
+ static dispatch_once_t onceToken;
+ dispatch_once(&onceToken, ^{
+ dateFormatter = [[NSDateFormatter alloc] init];
+ [dateFormatter setFormatterBehavior:NSDateFormatterBehavior10_4];
+ [dateFormatter setDateFormat:@"yyyy-MM-dd HH:mm:ss:SSS"];
+ [dateFormatter setLocale:[NSLocale localeWithLocaleIdentifier:@"en_US_POSIX"]];
+ [dateFormatter setCalendar:[[NSCalendar alloc] initWithCalendarIdentifier:NSCalendarIdentifierGregorian]];
+
+ qos2name = ^(NSUInteger qos) {
+ switch ((qos_class_t) qos) {
+ case QOS_CLASS_USER_INTERACTIVE: return @"QOS_CLASS_USER_INTERACTIVE";
+ case QOS_CLASS_USER_INITIATED: return @"QOS_CLASS_USER_INITIATED";
+ case QOS_CLASS_DEFAULT: return @"QOS_CLASS_DEFAULT";
+ case QOS_CLASS_UTILITY: return @"QOS_CLASS_UTILITY";
+ case QOS_CLASS_BACKGROUND: return @"QOS_CLASS_BACKGROUND";
+ default: return [NSString stringWithFormat:@"QOS_UNKNOWN(%lu)", (unsigned long)qos];
+ }
+ };
+ });
+
+ //construct json dictionary
+ (*counter)++;
+ NSDictionary* representedObject = @{
+ @"queueThreadLabel": [self getQueueThreadLabelFor:logMessage],
+ @"processType": [self isAppExtension] ? @"appex" : @"mainapp",
+ @"processName": [[[NSBundle mainBundle] executablePath] lastPathComponent],
+ @"counter": [NSNumber numberWithUnsignedLongLong:*counter],
+ @"processID": _processID,
+ @"qosName": qos2name(logMessage.qos),
+ @"representedObject": logMessage.representedObject ? logMessage.representedObject : [NSNull null],
+ };
+ NSDictionary* msgDict = @{
+ @"messageFormat": logMessage.messageFormat,
+ @"message": logMessage.message,
+ @"level": [NSNumber numberWithInteger:logMessage.level],
+ @"flag": [NSNumber numberWithInteger:logMessage.flag],
+ @"context": [NSNumber numberWithInteger:logMessage.context],
+ @"file": logMessage.file,
+ @"fileName": logMessage.fileName,
+ @"function": logMessage.function,
+ @"line": [NSNumber numberWithInteger:logMessage.line],
+ @"tag": representedObject,
+ @"options": [NSNumber numberWithInteger:logMessage.options],
+ @"timestamp": [dateFormatter stringFromDate:logMessage.timestamp],
+ @"threadID": logMessage.threadID,
+ @"threadName": logMessage.threadName,
+ @"queueLabel": logMessage.queueLabel,
+ @"qos": [NSNumber numberWithInteger:logMessage.qos],
+ };
+
+ //encode json into NSData
+ NSError* writeError = nil;
+ NSData* rawData = [NSJSONSerialization dataWithJSONObject:msgDict options:NSJSONWritingSortedKeys error:&writeError];
+ if(writeError)
+ {
+ if(error != nil)
+ *error = writeError;
+ return nil;
+ }
+ return rawData;
+}
+
++(void) flushLogsWithTimeout:(double) timeout
+{
+ [_stderrRedirector flushWithTimeout:timeout];
+ [_stdoutRedirector flushWithTimeout:timeout];
+ [DDLog flushLog];
+ [MLUDPLogger flushWithTimeout:timeout];
+}
+
++(void) configureXcodeLogging
+{
+ //only start console logger
+ [DDLog addLogger:[DDOSLogger sharedInstance]];
+}
+
++(void) configureLogging
+{
+ //network logger (start as early as possible)
+ MLUDPLogger* udpLogger = [MLUDPLogger new];
+ [DDLog addLogger:udpLogger];
+
+ //redirect stderr containing NSLog() messages
+ _stderrRedirector = [[MLStreamRedirect alloc] initWithStream:stderr];
+ NSLog(@"stderr redirection complete...");
+
+ //redirect stdout for good measure
+ _stdoutRedirector = [[MLStreamRedirect alloc] initWithStream:stdout];
+ printf("stdout redirection complete...");
+
+ NSString* containerUrl = [[HelperTools getContainerURLForPathComponents:@[]] path];
+ DDLogInfo(@"Logfile dir: %@", containerUrl);
+
+ //file logger
+ id logFileManager = [[MLLogFileManager alloc] initWithLogsDirectory:containerUrl defaultFileProtectionLevel:NSFileProtectionCompleteUntilFirstUserAuthentication];
+ logFileManager.maximumNumberOfLogFiles = 4;
+ logFileManager.logFilesDiskQuota = 512 * 1024 * 1024;
+ self.fileLogger = [[DDFileLogger alloc] initWithLogFileManager:logFileManager];
+ self.fileLogger.doNotReuseLogFiles = NO;
+ self.fileLogger.rollingFrequency = 60 * 60 * 48; // 48 hour rolling
+ self.fileLogger.maximumFileSize = 128 * 1024 * 1024;
+ [DDLog addLogger:self.fileLogger];
+
+ DDLogDebug(@"Sorted logfiles: %@", [logFileManager sortedLogFileInfos]);
+ DDLogDebug(@"Current logfile: %@", self.fileLogger.currentLogFileInfo.filePath);
+ NSError* error;
+ NSDictionary* attrs = [[NSFileManager defaultManager] attributesOfItemAtPath:self.fileLogger.currentLogFileInfo.filePath error:&error];
+ if(error)
+ DDLogError(@"File attributes error: %@", error);
+ else
+ DDLogDebug(@"File attributes: %@", attrs);
+
+ //log version info as early as possible
+ DDLogInfo(@"Starting: %@", [self appBuildVersionInfoFor:MLVersionTypeLog]);
+ [DDLog flushLog];
+
+ DDLogVerbose(@"QOS level: %@ = %d", @"QOS_CLASS_USER_INTERACTIVE", QOS_CLASS_USER_INTERACTIVE);
+ DDLogVerbose(@"QOS level: %@ = %d", @"QOS_CLASS_USER_INITIATED", QOS_CLASS_USER_INITIATED);
+ DDLogVerbose(@"QOS level: %@ = %d", @"QOS_CLASS_DEFAULT", QOS_CLASS_DEFAULT);
+ DDLogVerbose(@"QOS level: %@ = %d", @"QOS_CLASS_UTILITY", QOS_CLASS_UTILITY);
+ DDLogVerbose(@"QOS level: %@ = %d", @"QOS_CLASS_BACKGROUND", QOS_CLASS_BACKGROUND);
+
+ //remove old ascii based logfiles
+ for(NSString* file in [[[NSFileManager defaultManager] contentsOfDirectoryAtPath:containerUrl error:nil] filteredArrayUsingPredicate:[NSPredicate predicateWithFormat:@"self LIKE %@", @"Monal *.log"]])
+ {
+ DDLogWarn(@"Removing old ascii logfile: %@/%@", containerUrl, file);
+ [[NSFileManager defaultManager] removeItemAtPath:[containerUrl stringByAppendingPathComponent:file] error:nil];
+ }
+
+ //for debugging when upgrading the app
+ NSArray* directoryContents = [[NSFileManager defaultManager] contentsOfDirectoryAtPath:containerUrl error:nil];
+ for(NSString* file in directoryContents)
+ DDLogVerbose(@"File %@/%@", containerUrl, file);
+}
+
++(int) pendingCrashreportCount
+{
+ KSCrash* handler = [KSCrash sharedInstance];
+ return handler.reportCount;
+}
+
++(void) cleanupRawlogCrashcopies
+{
+ NSError* error;
+ KSCrash* handler = [KSCrash sharedInstance];
+ NSSet* reportIds = [NSSet setWithArray:[handler reportIDs]];
+ NSString* reportpath = [[HelperTools getContainerURLForPathComponents:@[@"CrashReports", @"Reports"]] path];
+ NSArray* directoryContentsReports = [[NSFileManager defaultManager] contentsOfDirectoryAtPath:reportpath error:&error];
+ if(error != nil)
+ {
+ DDLogError(@"Failed to get directory contents while cleaning up rawlog crashcopies...");
+ return;
+ }
+
+ //parts taken from https://github.com/kstenerud/KSCrash/blob/9e72c018a0ba455a89cf5770dea6e1d5258744b6/Source/KSCrash/Recording/KSCrashReportStore.c#L75
+ char scanFormat[100];
+ snprintf(scanFormat, sizeof(scanFormat), "%s-log-%%" PRIx64 ".rawlog", _crashBundleName);
+ for(NSString* filename in [directoryContentsReports filteredArrayUsingPredicate:[NSPredicate predicateWithFormat:@"SELF LIKE %@", [NSString stringWithFormat:@"%s-log-*.rawlog", _crashBundleName]]])
+ {
+ NSString* file = [NSString stringWithFormat:@"%@/%@", reportpath, filename];
+ int64_t reportID = 0;
+ sscanf(filename.UTF8String, scanFormat, &reportID);
+ if(reportID == 0)
+ {
+ DDLogError(@"Could not extract crash report id from '%@', ignoring file!", file);
+ continue;
+ }
+ if(![reportIds containsObject:[NSNumber numberWithLongLong:reportID]])
+ {
+ DDLogInfo(@"Deleting orphan rawlog copy at '%@'...", file);
+ [[NSFileManager defaultManager] removeItemAtPath:file error:&error];
+ if(error != nil)
+ DDLogError(@"Error cleaning up orphan rawlog copy at '%@', ignoring file!", file);
+ }
+ }
+}
+
++(void) installCrashHandler
+{
+
+ DDLogVerbose(@"KSCrash installing handler with callback: %p", crash_callback);
+ KSCrash* handler = [KSCrash sharedInstance];
+ handler.basePath = [[HelperTools getContainerURLForPathComponents:@[@"CrashReports"]] path];
+ handler.monitoring = KSCrashMonitorTypeProductionSafe; //KSCrashMonitorTypeAll
+ handler.onCrash = crash_callback;
+ //this can trigger crashes on macos < 13 (e.g. mac catalyst < 16) (and possibly ios < 16)
+#if !TARGET_OS_MACCATALYST
+ [handler enableSwapOfCxaThrow];
+#endif
+ handler.searchQueueNames = NO; //this is not async safe and can crash :(
+ handler.introspectMemory = YES;
+ handler.addConsoleLogToReport = YES;
+ handler.printPreviousLog = NO; //debug kscrash itself?
+ handler.demangleLanguages = KSCrashDemangleLanguageAll;
+ handler.maxReportCount = 4;
+ handler.deadlockWatchdogInterval = 0; // no main thread watchdog
+ handler.userInfo = @{
+ @"isAppex": @([self isAppExtension]),
+ @"processName": [[[NSBundle mainBundle] executablePath] lastPathComponent],
+ @"bundleName": nilWrapper([[[NSBundle mainBundle] infoDictionary] objectForKey:@"CFBundleName"]),
+ @"appVersion": [self appBuildVersionInfoFor:MLVersionTypeLog],
+ };
+ //we can not use [KSCrash install] because this uses the bundle names to store our crash reports which are different
+ //in appex and mainapp use the lowlevel C api with dummy bundle name "UnifiedReport" instead
+ handler.monitoring = kscrash_install(_crashBundleName, handler.basePath.UTF8String);
+ if(handler.monitoring == KSCrashMonitorTypeNone)
+ DDLogError(@"Failed to install KSCrash monitors, crash reporting is disabled now!");
+ else
+ DDLogInfo(@"Crash monitoring active now: %d", handler.monitoring);
+
+ [HelperTools updateCurrentLogfilePath:self.fileLogger.currentLogFileInfo.filePath];
+
+ //store data globally for later retrieval by our crash_callback() (_origProfilePath and _profilePath)
+ NSString* profrawFilePath = [[HelperTools getContainerURLForPathComponents:@[@"default.profraw"]] path];
+ strncpy(_origProfilePath, profrawFilePath.UTF8String, sizeof(_profilePath)-1);
+ _origProfilePath[sizeof(_origProfilePath)-1] = '\0';
+ //use the same id for our logfile copy as for the main report (allows to delete all logfile copies for which no crash report exists)
+ //KSCrash increments the id by one every new crash --> the next id used by kscrash will be this one
+ uint64_t nextCrashId = kscrs_getNextCrashReport(NULL) + 1;
+ snprintf(_profilePath, sizeof(_profilePath)-1, "%s/Reports/%s-profile-%016llx.profraw", handler.basePath.UTF8String, _crashBundleName, nextCrashId);
+ _profilePath[sizeof(_profilePath)-1] = '\0';
+ DDLogVerbose(@"KSCrash: _origProfilePath=%s, _profilePath=%s", _origProfilePath, _profilePath);
+
+ //clean up orphan rawlog copies
+ [self cleanupRawlogCrashcopies];
+
+ NSArray* directoryContentsData = [[NSFileManager defaultManager] contentsOfDirectoryAtPath:[[HelperTools getContainerURLForPathComponents:@[@"CrashReports", @"Data"]] path] error:nil];
+ DDLogDebug(@"KSCrash data files: %@", directoryContentsData);
+ NSArray* directoryContentsReports = [[NSFileManager defaultManager] contentsOfDirectoryAtPath:[[HelperTools getContainerURLForPathComponents:@[@"CrashReports", @"Reports"]] path] error:nil];
+ DDLogDebug(@"KSCrash report files: %@", directoryContentsReports);
+
+ //[[KSCrash sharedInstance] reportUserException:@"test" reason:@"dummy test" language:@"dylang" lineOfCode:nil stackTrace:nil logAllThreads:NO terminateProgram:YES];
+}
+
++(void) updateCurrentLogfilePath:(NSString*) logfilePath
+{
+ KSCrash* handler = [KSCrash sharedInstance];
+
+ //store data globally for later retrieval by our crash_callback() (_origLogfilePath and _logfilePath)
+ strncpy(_origLogfilePath, logfilePath.UTF8String, sizeof(_logfilePath)-1);
+ _origLogfilePath[sizeof(_origLogfilePath)-1] = '\0';
+ //use the same id for our logfile copy as for the main report (allows to delete all logfile copies for which no crash report exists)
+ //KSCrash increments the id by one every new crash --> the next id used by kscrash will be this one
+ uint64_t nextCrashId = kscrs_getNextCrashReport(NULL) + 1;
+ snprintf(_logfilePath, sizeof(_logfilePath)-1, "%s/Reports/%s-log-%016llx.rawlog", handler.basePath.UTF8String, _crashBundleName, nextCrashId);
+ _logfilePath[sizeof(_logfilePath)-1] = '\0';
+ DDLogVerbose(@"KSCrash: _origLogfilePath=%s, _logfilePath=%s", _origLogfilePath, _logfilePath);
+}
+
++(BOOL) isAppExtension
+{
+ //dispatch once seems to corrupt this check (nearly always return mainapp even if in appex) --> don't use dispatch once
+ static BOOL result = NO;
+ static BOOL calculated = NO;
+ @synchronized(_isAppExtensionLock) {
+ if(calculated)
+ return result;
+ result = [[[NSBundle mainBundle] executablePath] containsString:@".appex/"];
+ calculated = YES;
+ return result;
+ }
+}
+
++(NSString*) getEntityCapsHashForIdentities:(NSArray*) identities andFeatures:(NSSet*) features andForms:(NSArray*) forms
+{
+ // see https://xmpp.org/extensions/xep-0115.html#ver
+ NSMutableString* unhashed = [NSMutableString new];
+
+ //generate identities string (must be sorted according to XEP-0115)
+ identities = [identities sortedArrayUsingSelector:@selector(compare:)];
+ for(NSString* identity in identities)
+ [unhashed appendString:[NSString stringWithFormat:@"%@<", [self _replaceLowerThanInString:identity]]];
+
+ //append features string
+ [unhashed appendString:[self generateStringOfFeatureSet:features]];
+
+ //append forms string
+ [unhashed appendString:[self generateStringOfCapsForms:forms]];
+
+ NSString* hashedBase64 = [self encodeBase64WithData:[self sha1:[unhashed dataUsingEncoding:NSUTF8StringEncoding]]];
+ DDLogVerbose(@"ver string: unhashed %@, hashed-64 %@", unhashed, hashedBase64);
+ return hashedBase64;
+}
+
++(NSString*) _replaceLowerThanInString:(NSString*) str
+{
+ NSMutableString* retval = [str mutableCopy];
+ [retval replaceOccurrencesOfString:@"<" withString:@"<" options:NSLiteralSearch range:NSMakeRange(0, retval.length)];
+ return [retval copy]; //make immutable
+}
+
++(NSSet*) getOwnFeatureSet
+{
+ static NSSet* featuresSet;
+ static dispatch_once_t onceToken;
+ dispatch_once(&onceToken, ^{
+ NSMutableArray* featuresArray = [@[
+ @"http://jabber.org/protocol/caps",
+ @"http://jabber.org/protocol/disco#info",
+ @"jabber:x:conference",
+ @"jabber:x:oob",
+ @"urn:xmpp:ping",
+ @"urn:xmpp:eme:0",
+ @"urn:xmpp:message-retract:1",
+ @"urn:xmpp:message-correct:0",
+
+
+ ] mutableCopy];
+ if([[HelperTools defaultsDB] boolForKey: @"SendLastUserInteraction"])
+ [featuresArray addObject:@"urn:xmpp:idle:1"];
+ if([[HelperTools defaultsDB] boolForKey: @"SendLastChatState"])
+ [featuresArray addObject:@"http://jabber.org/protocol/chatstates"];
+ if([[HelperTools defaultsDB] boolForKey: @"SendReceivedMarkers"])
+ [featuresArray addObject:@"urn:xmpp:receipts"];
+ if([[HelperTools defaultsDB] boolForKey: @"SendDisplayedMarkers"])
+ [featuresArray addObject:@"urn:xmpp:chat-markers:0"];
+ if([[HelperTools defaultsDB] boolForKey: @"allowVersionIQ"])
+ [featuresArray addObject:@"jabber:iq:version"];
+ //voip stuff
+ if([HelperTools shouldProvideVoip])
+ {
+ [featuresArray addObject:@"urn:xmpp:jingle-message:0"];
+ [featuresArray addObject:@"urn:xmpp:jingle:1"];
+ [featuresArray addObject:@"urn:xmpp:jingle:apps:rtp:1"];
+ [featuresArray addObject:@"urn:xmpp:jingle:apps:rtp:audio"];
+ [featuresArray addObject:@"urn:xmpp:jingle:apps:rtp:video"];
+ [featuresArray addObject:@"urn:xmpp:jingle:transports:ice-udp:1"];
+ [featuresArray addObject:@"urn:ietf:rfc:5888"];
+ [featuresArray addObject:@"urn:xmpp:jingle:apps:dtls:0"];
+ [featuresArray addObject:@"urn:ietf:rfc:5576"];
+ [featuresArray addObject:@"urn:xmpp:jingle:apps:rtp:rtp-hdrext:0"];
+ [featuresArray addObject:@"urn:xmpp:jingle:apps:rtp:rtcp-fb:0"];
+ }
+
+ featuresSet = [[NSSet alloc] initWithArray:featuresArray];
+ });
+ return featuresSet;
+}
+
++(NSString*) generateStringOfFeatureSet:(NSSet*) features
+{
+ // this has to be sorted for the features hash to be correct, see https://xmpp.org/extensions/xep-0115.html#ver
+ NSArray* featuresArray = [[features allObjects] sortedArrayUsingSelector:@selector(compare:)];
+ NSMutableString* toreturn = [NSMutableString new];
+ for(NSString* feature in featuresArray)
+ {
+ [toreturn appendString:[self _replaceLowerThanInString:feature]];
+ [toreturn appendString:@"<"];
+ }
+ return toreturn;
+}
+
++(NSString*) generateStringOfCapsForms:(NSArray*) forms
+{
+ // this has to be sorted for the features hash to be correct, see https://xmpp.org/extensions/xep-0115.html#ver
+ NSMutableString* toreturn = [NSMutableString new];
+ for(XMPPDataForm* form in [forms sortedArrayUsingDescriptors:@[[NSSortDescriptor sortDescriptorWithKey:@"formType" ascending:YES selector:@selector(compare:)]]])
+ {
+ [toreturn appendString:[self _replaceLowerThanInString:form.formType]];
+ [toreturn appendString:@"<"];
+ for(NSString* field in [[form allKeys] sortedArrayUsingSelector:@selector(compare:)])
+ {
+ if([@"FORM_TYPE" isEqualToString:field])
+ continue;
+ [toreturn appendString:[self _replaceLowerThanInString:field]];
+ [toreturn appendString:@"<"];
+ for(NSString* value in [[form getField:field][@"allValues"] sortedArrayUsingSelector:@selector(compare:)])
+ {
+ [toreturn appendString:[self _replaceLowerThanInString:value]];
+ [toreturn appendString:@"<"];
+ }
+ }
+ }
+ return toreturn;
+}
+
+/*
+ * create string containing the info when a user was seen the last time
+ */
++(NSString*) formatLastInteraction:(NSDate*) lastInteraction
+{
+ // get current timestamp
+ unsigned long currentTimestamp = [HelperTools currentTimestampInSeconds].unsignedLongValue;
+
+ unsigned long lastInteractionTime = 0; //default is zero which corresponds to "online"
+
+ // calculate timestamp and clamp it to be not in the future (but only if given)
+ if(lastInteraction && [lastInteraction timeIntervalSince1970] != 0)
+ {
+ //NSDictionary does not support nil, so we're using timeSince1970 + 0 sometimes
+ lastInteractionTime = MIN([HelperTools dateToNSNumberSeconds:lastInteraction].unsignedLongValue, currentTimestamp);
+ }
+
+ if(lastInteractionTime > 0) {
+ NSString* timeString;
+
+ long long diff = currentTimestamp - lastInteractionTime;
+ if(diff < 60)
+ {
+ // less than one minute
+ timeString = NSLocalizedString(@"Just seen", @"");
+ }
+ else if(diff < 120)
+ {
+ // less than two minutes
+ timeString = NSLocalizedString(@"Last seen: 1 minute ago", @"");
+ }
+ else if(diff < 3600)
+ {
+ // less than one hour
+ timeString = NSLocalizedString(@"Last seen: %d minutes ago", @"");
+ diff /= 60.0;
+ }
+ else if(diff < 7200)
+ {
+ // less than 2 hours
+ timeString = NSLocalizedString(@"Last seen: 1 hour ago", @"");
+ }
+ else if(diff < 86400)
+ {
+ // less than 24 hours
+ timeString = NSLocalizedString(@"Last seen: %d hours ago", @"");
+ diff /= 3600;
+ }
+ else if(diff < 86400 * 2)
+ {
+ // less than 2 days
+ timeString = NSLocalizedString(@"Last seen: 1 day ago", @"");
+ }
+ else
+ {
+ // more than 2 days
+ timeString = NSLocalizedString(@"Last seen: %d days ago", @"");
+ diff /= 86400;
+ }
+
+ NSString* lastSeen = [NSString stringWithFormat:timeString, diff];
+ return [NSString stringWithFormat:@"%@", lastSeen];
+ } else {
+ return NSLocalizedString(@"Online", @"");
+ }
+}
+
++(NSString*) stringFromTimeInterval:(NSUInteger) interval
+{
+ NSUInteger hours = interval / 3600;
+ NSUInteger minutes = (interval % 3600) / 60;
+ NSUInteger seconds = interval % 60;
+
+ return [NSString stringWithFormat:@"%luh %lumin and %lusec", hours, minutes, seconds];
+}
+
++(NSDate*) parseDateTimeString:(NSString*) datetime
+{
+ static NSDateFormatter* rfc3339DateFormatter;
+ static NSDateFormatter* rfc3339DateFormatter2;
+ static dispatch_once_t onceToken;
+ dispatch_once(&onceToken, ^{
+ NSLocale* enUSPOSIXLocale = [[NSLocale alloc] initWithLocaleIdentifier:@"en_US_POSIX"];
+ rfc3339DateFormatter = [NSDateFormatter new];
+ rfc3339DateFormatter2 = [NSDateFormatter new];
+
+ [rfc3339DateFormatter setLocale:enUSPOSIXLocale];
+ [rfc3339DateFormatter setDateFormat:@"yyyy'-'MM'-'dd'T'HH':'mm':'ss.SSSSSSXXXXX"];
+ [rfc3339DateFormatter setTimeZone:[NSTimeZone timeZoneForSecondsFromGMT:0]];
+
+ [rfc3339DateFormatter2 setLocale:enUSPOSIXLocale];
+ [rfc3339DateFormatter2 setTimeZone:[NSTimeZone timeZoneForSecondsFromGMT:0]];
+ [rfc3339DateFormatter2 setDateFormat:@"yyyy'-'MM'-'dd'T'HH':'mm':'ssZ"];
+ });
+
+ NSDate* retval = [rfc3339DateFormatter dateFromString:datetime];
+ if(!retval)
+ retval = [rfc3339DateFormatter2 dateFromString:datetime];
+ return retval;
+}
+
++(NSString*) generateDateTimeString:(NSDate*) datetime
+{
+ static NSDateFormatter* rfc3339DateFormatter;
+ static dispatch_once_t onceToken;
+ dispatch_once(&onceToken, ^{
+ NSLocale* enUSPOSIXLocale = [[NSLocale alloc] initWithLocaleIdentifier:@"en_US_POSIX"];
+ rfc3339DateFormatter = [NSDateFormatter new];
+
+ [rfc3339DateFormatter setLocale:enUSPOSIXLocale];
+ [rfc3339DateFormatter setDateFormat:@"yyyy'-'MM'-'dd'T'HH':'mm':'ss'Z"];
+ [rfc3339DateFormatter setTimeZone:[NSTimeZone timeZoneForSecondsFromGMT:0]];
+ });
+
+ return [rfc3339DateFormatter stringFromDate:datetime];
+}
+
++(NSString*) sanitizeFilePath:(const char* const) file
+{
+ NSString* fileStr = [NSString stringWithFormat:@"%s", file];
+ NSArray* filePathComponents = [fileStr pathComponents];
+ if([filePathComponents count]>1)
+ fileStr = [NSString stringWithFormat:@"%@/%@", filePathComponents[[filePathComponents count]-2], filePathComponents[[filePathComponents count]-1]];
+ return fileStr;
+}
+
+//don't use this directly, but via createDelayableTimer() makros
++(MLDelayableTimer*) startDelayableQueuedTimer:(double) timeout withHandler:(monal_void_block_t) handler andCancelHandler:(monal_void_block_t _Nullable) cancelHandler andFile:(char*) file andLine:(int) line andFunc:(char*) func onQueue:(dispatch_queue_t _Nullable) queue
+{
+ if(queue == nil)
+ queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
+
+ MLDelayableTimer* timer = [[MLDelayableTimer alloc] initWithHandler:^(MLDelayableTimer* timer){
+ if(handler)
+ dispatch_async(queue, ^{
+ DDLogDebug(@"calling handler for timer: %@", timer);
+ handler();
+ });
+ } andCancelHandler:^(MLDelayableTimer* timer){
+ if(cancelHandler)
+ dispatch_async(queue, ^{
+ DDLogDebug(@"calling cancel block for timer: %@", timer);
+ cancelHandler();
+ });
+ } timeout:timeout tolerance:0.1 andDescription:[NSString stringWithFormat:@"created at %@:%d in %s", [self sanitizeFilePath:file], line, func]];
+
+ if(timeout < 0.001)
+ {
+ //DDLogVerbose(@"Timer timeout is smaller than 0.001, dispatching handler directly: %@", timer);
+ [timer invalidate];
+ if(handler)
+ dispatch_async(queue, ^{
+ handler();
+ });
+ return timer; //this timer is not added to a runloop and invalid because the handler already got called
+ }
+
+ [timer start];
+ return timer;
+}
+
+//don't use this directly, but via createTimer() makros
++(monal_void_block_t) startQueuedTimer:(double) timeout withHandler:(monal_void_block_t) handler andCancelHandler:(monal_void_block_t _Nullable) cancelHandler andFile:(char*) file andLine:(int) line andFunc:(char*) func onQueue:(dispatch_queue_t _Nullable) queue
+{
+ MLDelayableTimer* timer = [self startDelayableQueuedTimer:timeout withHandler:handler andCancelHandler:cancelHandler andFile:file andLine:line andFunc:func onQueue:queue];
+ return ^{
+ [timer cancel];
+ };
+}
+
++(AnyPromise*) waitAtLeastSeconds:(NSTimeInterval) seconds forPromise:(AnyPromise*) promise
+{
+ return PMKWhen(@[promise, PMKAfter(seconds)]).then(^{
+ return promise;
+ });
+}
+
++(NSString*) generateRandomPassword
+{
+ u_int32_t i=arc4random();
+ return [self hexadecimalString:[NSData dataWithBytes: &i length: sizeof(i)]];
+}
+
++(NSString*) encodeRandomResource
+{
+ u_int32_t i=arc4random();
+#if TARGET_OS_MACCATALYST
+ NSString* resource = [NSString stringWithFormat:@"Monal-macOS.%@", [self hexadecimalString:[NSData dataWithBytes: &i length: sizeof(i)]]];
+#else
+#if IS_QUICKSY
+ NSString* resource = [NSString stringWithFormat:@"Quicksy-iOS.%@", [self hexadecimalString:[NSData dataWithBytes: &i length: sizeof(i)]]];
+#else
+ NSString* resource = [NSString stringWithFormat:@"Monal-iOS.%@", [self hexadecimalString:[NSData dataWithBytes: &i length: sizeof(i)]]];
+#endif
+#endif
+ return resource;
+}
+
++(NSString*) appBuildVersionInfoFor:(MLVersionType) type
+{
+ @synchronized(_versionInfoCache) {
+ if(_versionInfoCache[@(type)] != nil)
+ return _versionInfoCache[@(type)];
+
+#ifdef IS_ALPHA
+ NSString* rawVersionString = [NSString stringWithFormat:@"Alpha %s (%s %s UTC)", ALPHA_COMMIT_HASH, __DATE__, __TIME__];
+#else// IS_ALPHA
+ NSDictionary* infoDict = [[NSBundle mainBundle] infoDictionary];
+ NSString* rawVersionString = [NSString stringWithFormat:@"%@ %@ (%@)",
+#ifdef DEBUG
+ @"Beta",
+#else// DEBUG
+ @"Stable",
+#endif// DEBUG
+ [infoDict objectForKey:@"CFBundleShortVersionString"],
+ [infoDict objectForKey:@"CFBundleVersion"]
+ ];
+#endif// IS_ALPHA
+
+ if(type == MLVersionTypeIQ)
+ return _versionInfoCache[@(type)] = rawVersionString;
+ else if(type == MLVersionTypeLog)
+ return _versionInfoCache[@(type)] = [NSString stringWithFormat:@"Version %@, %@ on iOS/macOS %@", rawVersionString, [[NSBundle mainBundle] objectForInfoDictionaryKey:@"CFBundleIdentifier"], [UIDevice currentDevice].systemVersion];
+ unreachable(@"unknown version type!");
+ }
+}
+
++(NSNumber*) currentTimestampInSeconds
+{
+ return [HelperTools dateToNSNumberSeconds:[NSDate date]];
+}
+
++(NSNumber*) dateToNSNumberSeconds:(NSDate*) date
+{
+ return [NSNumber numberWithUnsignedLong:(unsigned long)date.timeIntervalSince1970];
+}
+
++(NSArray* _Nullable) sdp2xml:(NSString*) sdp withInitiator:(BOOL) initiator
+{
+ DDLogVerbose(@"Parsing SDP string using rust(withInitiator=%@): %@", bool2str(initiator), sdp);
+ __block NSMutableArray* retval = [NSMutableArray new];
+ MLBasePaser* delegate = [[MLBasePaser alloc] initWithCompletion:^(MLXMLNode* _Nullable parsedElement) {
+ DDLogVerbose(@"Parsed jingle sdp element: %@", parsedElement);
+ [retval addObject:parsedElement];
+ }];
+ NSString* xmlString = [JingleSDPBridge getJingleStringForSDPString:sdp withInitiator:initiator];
+ if(xmlString == nil)
+ return nil;
+ DDLogVerbose(@"Parsing XML string produced by rust sdp parser(withInitiator=%@): %@", bool2str(initiator), xmlString);
+ NSXMLParser* xmlParser = [[NSXMLParser alloc] initWithData:[xmlString dataUsingEncoding:NSUTF8StringEncoding]];
+ [xmlParser setShouldProcessNamespaces:YES];
+ [xmlParser setShouldReportNamespacePrefixes:YES]; //for debugging only
+ [xmlParser setShouldResolveExternalEntities:NO];
+ [xmlParser setDelegate:delegate];
+ [xmlParser parse]; //blocking operation
+ return retval;
+}
+
++(NSString* _Nullable) xml2sdp:(MLXMLNode*) xml withInitiator:(BOOL) initiator
+{
+ NSString* xmlstr = [[[MLXMLNode alloc] initWithElement:@"root" withAttributes:@{} andChildren:xml.children andData:nil] XMLString];
+ NSString* retval = [JingleSDPBridge getSDPStringForJingleString:xmlstr withInitiator:initiator];
+ DDLogVerbose(@"Got sdp string from rust(withInitiator=%@): %@", bool2str(initiator), retval);
+ return retval;
+}
+
++(MLXMLNode* _Nullable) candidate2xml:(NSString*) candidate withMid:(NSString*) mid pwd:(NSString* _Nullable) pwd ufrag:(NSString* _Nullable) ufrag andInitiator:(BOOL) initiator
+{
+ //use some dummy sdp string to make our rust sdp parser happy
+ //always use "audio" for our dummy media
+ NSMutableString* sdp = [NSMutableString stringWithFormat:@"v=0\r\n\
+o=- 2005859539484728435 2 IN IP4 127.0.0.1\r\n\
+s=-\r\n\
+t=0 0\r\n\
+m=audio 9 UDP/TLS/RTP/SAVPF 0\r\n\
+c=IN IP4 0.0.0.0\r\n\
+a=mid:%@\r\n\
+a=%@\r\n", mid, candidate];
+ if(pwd != nil)
+ [sdp appendString:[NSString stringWithFormat:@"a=ice-pwd:%@\r\n", pwd]];
+ if(ufrag != nil)
+ [sdp appendString:[NSString stringWithFormat:@"a=ice-ufrag:%@\r\n", ufrag]];
+ DDLogVerbose(@"Dummy sdp candidate string for rust parser: %@", sdp);
+
+ //this result array should only contain one single content node or be nil on parser errors
+ NSArray* xml = [self sdp2xml:sdp withInitiator:initiator];
+ if(xml == nil)
+ return nil;
+ MLAssert([xml count] == 1, @"Only one single content node expected!", (@{@"xml": xml}));
+ MLXMLNode* contentNode = xml[0];
+ MLAssert([contentNode check:@"/{urn:xmpp:jingle:1}content"], @"Content node not present!", (@{@"xml": xml}));
+
+ //remove unwanted description node resulting from our dummy sdp media line above (which is needed for the sdp parser)
+ for(MLXMLNode* node in [contentNode find:@"{urn:xmpp:jingle:apps:rtp:1}description"])
+ [contentNode removeChildNode:node];
+ return contentNode;
+}
+
++(NSString* _Nullable) xml2candidate:(MLXMLNode*) xml withInitiator:(BOOL) initiator
+{
+ //add dummy description childs to each content element, but don't change the original xml node
+ MLXMLNode* node = [xml copy];
+ for(MLXMLNode* contentNode in [node find:@"{urn:xmpp:jingle:1}content"])
+ [contentNode addChildNode:[[MLXMLNode alloc] initWithElement:@"description" andNamespace:@"urn:xmpp:jingle:apps:rtp:1" withAttributes:@{@"media": @"audio"} andChildren:@[] andData:nil]];
+ NSString* xmlString = [self xml2sdp:node withInitiator:initiator];
+ //the candidate attribute line should always be the last one (given our current rust parser code), but we try to be more robust here
+ NSArray* lines = [xmlString componentsSeparatedByString:@"\r\n"];
+ NSString* prefix = @"a=candidate";
+ for(NSString* line in lines)
+ if(line.length >= prefix.length && [prefix isEqualToString:[line substringWithRange:NSMakeRange(0, prefix.length)]])
+ return [line substringWithRange:NSMakeRange(2, line.length - 2)];
+ return nil;
+}
+
+#pragma mark Hashes
+
++(NSData*) sha1:(NSData*) data
+{
+ if(!data)
+ return nil;
+ NSData* hashed;
+ unsigned char digest[CC_SHA1_DIGEST_LENGTH];
+ if(CC_SHA1([data bytes], (UInt32)[data length], digest))
+ hashed = [NSData dataWithBytes:digest length:CC_SHA1_DIGEST_LENGTH];
+ return hashed;
+}
+
++(NSString*) stringSha1:(NSString*) data
+{
+ return [self hexadecimalString:[self sha1:[data dataUsingEncoding:NSUTF8StringEncoding]]];
+}
+
++(NSData*) sha1HmacForKey:(NSData*) key andData:(NSData*) data
+{
+ if(!key || !data)
+ return nil;
+ unsigned char digest[CC_SHA1_DIGEST_LENGTH];
+ CCHmac(kCCHmacAlgSHA1, [key bytes], (UInt32)[key length], [data bytes], (UInt32)[data length], digest);
+ return [NSData dataWithBytes:digest length:CC_SHA1_DIGEST_LENGTH];
+}
+
++(NSString*) stringSha1HmacForKey:(NSString*) key andData:(NSString*) data
+{
+ if(!key || !data)
+ return nil;
+ return [self hexadecimalString:[self sha1HmacForKey:[key dataUsingEncoding:NSUTF8StringEncoding] andData:[data dataUsingEncoding:NSUTF8StringEncoding]]];
+}
+
++(NSData*) sha256:(NSData*) data
+{
+ if(!data)
+ return nil;
+ NSData* hashed;
+ unsigned char digest[CC_SHA256_DIGEST_LENGTH];
+ if(CC_SHA256([data bytes], (UInt32)[data length], digest))
+ hashed = [NSData dataWithBytes:digest length:CC_SHA256_DIGEST_LENGTH];
+ return hashed;
+}
+
++(NSString*) stringSha256:(NSString*) data
+{
+ return [self hexadecimalString:[self sha256:[data dataUsingEncoding:NSUTF8StringEncoding]]];
+}
+
++(NSData*) sha256HmacForKey:(NSData*) key andData:(NSData*) data
+{
+ if(!key || !data)
+ return nil;
+ unsigned char digest[CC_SHA256_DIGEST_LENGTH];
+ CCHmac(kCCHmacAlgSHA256, [key bytes], (UInt32)[key length], [data bytes], (UInt32)[data length], digest);
+ return [NSData dataWithBytes:digest length:CC_SHA256_DIGEST_LENGTH];
+}
+
++(NSString*) stringSha256HmacForKey:(NSString*) key andData:(NSString*) data
+{
+ if(!key || !data)
+ return nil;
+ return [self hexadecimalString:[self sha256HmacForKey:[key dataUsingEncoding:NSUTF8StringEncoding] andData:[data dataUsingEncoding:NSUTF8StringEncoding]]];
+}
+
++(NSData*) sha512:(NSData*) data
+{
+ if(!data)
+ return nil;
+ NSData* hashed;
+ unsigned char digest[CC_SHA512_DIGEST_LENGTH];
+ if(CC_SHA512([data bytes], (UInt32)[data length], digest))
+ hashed = [NSData dataWithBytes:digest length:CC_SHA512_DIGEST_LENGTH];
+ return hashed;
+}
+
++(NSString*) stringSha512:(NSString*) data
+{
+ return [self hexadecimalString:[self sha512:[data dataUsingEncoding:NSUTF8StringEncoding]]];
+}
+
++(NSData*) sha512HmacForKey:(NSData*) key andData:(NSData*) data
+{
+ if(!key || !data)
+ return nil;
+ unsigned char digest[CC_SHA512_DIGEST_LENGTH];
+ CCHmac(kCCHmacAlgSHA512, [key bytes], (UInt32)[key length], [data bytes], (UInt32)[data length], digest);
+ return [NSData dataWithBytes:digest length:CC_SHA512_DIGEST_LENGTH];
+}
+
++(NSString*) stringSha512HmacForKey:(NSString*) key andData:(NSString*) data
+{
+ if(!key || !data)
+ return nil;
+ return [self hexadecimalString:[self sha512HmacForKey:[key dataUsingEncoding:NSUTF8StringEncoding] andData:[data dataUsingEncoding:NSUTF8StringEncoding]]];
+}
+
++(NSUUID*) dataToUUID:(NSData*) data
+{
+ NSData* hash = [self sha256:data];
+ uint8_t* bytes = (uint8_t*)hash.bytes;
+ uint16_t* version = (uint16_t*)(bytes + 6);
+ *version = (*version & 0x0fff) | 0x4000;
+ return [[NSUUID alloc] initWithUUIDBytes:bytes];
+}
+
++(NSUUID*) stringToUUID:(NSString*) data
+{
+ return [self dataToUUID:[data dataUsingEncoding:NSUTF8StringEncoding]];
+}
+
+#pragma mark base64, hex and other data formats
+
++(NSString*) encodeBase64WithString:(NSString*) strData
+{
+ NSData* data = [strData dataUsingEncoding:NSUTF8StringEncoding];
+ return [self encodeBase64WithData:data];
+}
+
++(NSString*) encodeBase64WithData:(NSData*) objData
+{
+ return [objData base64EncodedStringWithOptions:NSDataBase64EncodingEndLineWithLineFeed];
+}
+
++(NSData*) dataWithBase64EncodedString:(NSString*) string
+{
+ return [[NSData alloc] initWithBase64EncodedString:string options:NSDataBase64DecodingIgnoreUnknownCharacters];
+}
+
+//very fast, taken from https://stackoverflow.com/a/33501154
++(NSString*) hexadecimalString:(NSData*) data
+{
+ static char _NSData_BytesConversionString_[512] = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9fa0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebfc0c1c2c3c4c5c6c7c8c9cacbcccdcecfd0d1d2d3d4d5d6d7d8d9dadbdcdddedfe0e1e2e3e4e5e6e7e8e9eaebecedeeeff0f1f2f3f4f5f6f7f8f9fafbfcfdfeff";
+ UInt16* mapping = (UInt16*)_NSData_BytesConversionString_;
+ register NSUInteger len = data.length;
+ char* hexChars = (char*)malloc( sizeof(char) * (len*2) );
+
+ // --- Coeur's contribution - a safe way to check the allocation
+ if (hexChars == NULL) {
+ // we directly raise an exception instead of using NSAssert to make sure assertion is not disabled as this is irrecoverable
+ [NSException raise:@"NSInternalInconsistencyException" format:@"failed malloc" arguments:nil];
+ return nil;
+ }
+ // ---
+
+ register UInt16* dst = ((UInt16*)hexChars) + len-1;
+ register unsigned char* src = (unsigned char*)data.bytes + len-1;
+
+ while (len--) *dst-- = mapping[*src--];
+
+ NSString* retVal = [[NSString alloc] initWithBytesNoCopy:hexChars length:data.length*2 encoding:NSASCIIStringEncoding freeWhenDone:YES];
+ return retVal;
+}
+
++(NSData*) dataWithHexString:(NSString*) hex
+{
+ char buf[3];
+ buf[2] = '\0';
+
+ if([hex length] % 2 != 00) {
+ DDLogError(@"Hex strings should have an even number of digits");
+ return [NSData new];
+ }
+ unsigned char* bytes = malloc([hex length] / 2);
+ if(bytes == NULL)
+ {
+ [NSException raise:@"NSInternalInconsistencyException" format:@"failed malloc" arguments:nil];
+ return nil;
+ }
+ unsigned char* bp = bytes;
+ for (unsigned int i = 0; i < [hex length]; i += 2) {
+ buf[0] = (unsigned char) [hex characterAtIndex:i];
+ buf[1] = (unsigned char) [hex characterAtIndex:i+1];
+ char* b2 = NULL;
+ *bp++ = (unsigned char) strtol(buf, &b2, 16);
+ if(b2 != buf + 2) {
+ DDLogError(@"String should be all hex digits");
+ free(bytes);
+ return [NSData new];
+ }
+ }
+ return [NSData dataWithBytesNoCopy:bytes length:[hex length]/2 freeWhenDone:YES];
+}
+
+//see https://stackoverflow.com/a/29911397/3528174
++(NSData*) XORData:(NSData*) data1 withData:(NSData*) data2
+{
+ const char* data1Bytes = [data1 bytes];
+ const char* data2Bytes = [data2 bytes];
+ // Mutable data that individual xor'd bytes will be added to
+ NSMutableData* xorData = [NSMutableData new];
+ for(NSUInteger i = 0; i < data1.length; i++)
+ {
+ const char xorByte = data1Bytes[i] ^ data2Bytes[i];
+ [xorData appendBytes:&xorByte length:1];
+ }
+ return xorData;
+}
+
+#pragma mark omemo stuff
+
++(NSString*) signalHexKeyWithData:(NSData*) data
+{
+ NSString* hex = [self hexadecimalString:data];
+
+ //remove 05 cipher info
+ hex = [hex substringWithRange:NSMakeRange(2, hex.length - 2)];
+
+ return hex;
+}
+
++(NSData*) signalIdentityWithHexKey:(NSString*) hexKey
+{
+ //add 05 cipher info
+ NSString* hexKeyWithCipherInfo = [NSString stringWithFormat:@"05%@", hexKey];
+ NSData* identity = [self dataWithHexString:hexKeyWithCipherInfo];
+
+ return identity;
+}
+
++(NSString*) signalHexKeyWithSpacesWithData:(NSData*) data
+{
+ NSMutableString* hex = [[self signalHexKeyWithData:data] mutableCopy];
+
+ unsigned int counter = 0;
+ while(counter <= (hex.length - 2))
+ {
+ counter+=8;
+ [hex insertString:@" " atIndex:counter];
+ counter++;
+ }
+ return hex.uppercaseString;
+}
+
+#pragma mark ui stuff
+
++(UIView*) MLCustomViewHeaderWithTitle:(NSString*) title
+{
+ UIView* tempView = [[UIView alloc]initWithFrame:CGRectMake(0, 200, 300, 244)];
+ tempView.backgroundColor = [UIColor clearColor];
+
+ UILabel* tempLabel = [[UILabel alloc]initWithFrame:CGRectMake(15, 0, 300, 44)];
+ tempLabel.backgroundColor = [UIColor clearColor];
+ tempLabel.shadowColor = [UIColor blackColor];
+ tempLabel.shadowOffset = CGSizeMake(0, 2);
+ tempLabel.textColor = [UIColor whiteColor]; //here you can change the text color of header.
+ tempLabel.font = [UIFont boldSystemFontOfSize:17.0f];
+ tempLabel.text = title;
+
+ [tempView addSubview:tempLabel];
+
+ tempLabel.textColor = [UIColor darkGrayColor];
+ tempLabel.text = tempLabel.text.uppercaseString;
+ tempLabel.shadowColor = [UIColor clearColor];
+ tempLabel.font = [UIFont systemFontOfSize:[UIFont systemFontSize]];
+
+ return tempView;
+}
+
++(CIImage*) createQRCodeFromString:(NSString*) input
+{
+ NSData* inputAsUTF8 = [input dataUsingEncoding:NSUTF8StringEncoding];
+
+ CIFilter* qrCode = [CIFilter QRCodeGenerator];
+ [qrCode setValue:inputAsUTF8 forKey:@"message"];
+ [qrCode setValue:@"L" forKey:@"correctionLevel"];
+
+ return qrCode.outputImage;
+}
+
+//taken from: https://stackoverflow.com/a/30932216/3528174
++(NSArray*) splitString:(NSString*) string withSeparator:(NSString*) separator andMaxSize:(NSUInteger)size
+{
+ NSMutableArray* result = [[NSMutableArray alloc]initWithCapacity:size];
+ NSArray* components = [string componentsSeparatedByString:separator];
+
+ if(components.count < size)
+ return components;
+
+ NSUInteger i = 0;
+ while(i < size-1)
+ {
+ [result addObject:components[i]];
+ i++;
+ }
+
+ NSMutableString* lastItem = [[NSMutableString alloc] init];
+ while(i < components.count)
+ {
+ [lastItem appendString:components[i]];
+ [lastItem appendString:separator];
+ i++;
+ }
+
+ //remove the last separator
+ [result addObject:[lastItem substringToIndex:lastItem.length - 1]];
+
+ return result;
+}
+
+//see https://nachtimwald.com/2017/04/02/constant-time-string-comparison-in-c/
++(BOOL) constantTimeCompareAttackerString:(NSString* _Nonnull) str1 withKnownString:(NSString* _Nonnull) str2
+{
+ if(str1 == nil || str2 == nil)
+ return NO;
+
+ const char* s1 = str1.UTF8String;
+ const char* s2 = str2.UTF8String;
+ volatile int m = 0;
+ volatile size_t i = 0;
+ volatile size_t j = 0;
+ volatile size_t k = 0;
+
+ while(1)
+ {
+ //this will only turn on bits in m, but never turn them off
+ m |= s1[i] ^ s2[j];
+
+ //
+ if(s1[i] == '\0')
+ break;
+ i++;
+
+ //always balance increments even if s2 is shorter than s1
+ if(s2[j] != '\0')
+ j++;
+ if(s2[j] == '\0')
+ k++;
+ }
+
+ return m == 0; //check if we never turned on any bit in m
+}
+
++(BOOL) isIP:(NSString*) host
+{
+ if([[IPV4 matchesInString:host options:0 range:NSMakeRange(0, [host length])] count] > 0)
+ return YES;
+ if([[IPV6_HEX4DECCOMPRESSED matchesInString:host options:0 range:NSMakeRange(0, [host length])] count] > 0)
+ return YES;
+ if([[IPV6_6HEX4DEC matchesInString:host options:0 range:NSMakeRange(0, [host length])] count] > 0)
+ return YES;
+ if([[IPV6_HEXCOMPRESSED matchesInString:host options:0 range:NSMakeRange(0, [host length])] count] > 0)
+ return YES;
+ if([[IPV6 matchesInString:host options:0 range:NSMakeRange(0, [host length])] count] > 0)
+ return YES;
+ return NO;
+}
+
++(NSURLSession*) createEphemeralURLSession
+{
+ NSURLSessionConfiguration* sessionConfig = [NSURLSessionConfiguration ephemeralSessionConfiguration];
+ if([[HelperTools defaultsDB] boolForKey: @"useDnssecForAllConnections"])
+ sessionConfig.requiresDNSSECValidation = YES;
+ return [NSURLSession sessionWithConfiguration:sessionConfig];
+}
+
+@end
diff --git a/Monal/Classes/IPC.h b/Monal/Classes/IPC.h
new file mode 100644
index 0000000..a408806
--- /dev/null
+++ b/Monal/Classes/IPC.h
@@ -0,0 +1,33 @@
+//
+// IPC.h
+// Monal
+//
+// Created by Thilo Molitor on 31.07.20.
+// Copyright © 2020 Monal.im. All rights reserved.
+//
+
+#import
+#import "MLConstants.h"
+
+#define kMonalIncomingIPC @"kMonalIncomingIPC"
+
+NS_ASSUME_NONNULL_BEGIN
+
+typedef void (^IPC_response_handler_t)(NSDictionary*);
+
+@interface IPC : NSObject
+
++(void) initializeForProcess:(NSString*) processName;
++(id) sharedInstance;
++(void) terminate;
+-(void) sendMessage:(NSString*) name withData:(NSData* _Nullable) data to:(NSString*) destination;
+-(void) sendMessage:(NSString*) name withData:(NSData* _Nullable) data to:(NSString*) destination withResponseHandler:(IPC_response_handler_t _Nullable) responseHandler;
+-(void) sendBroadcastMessage:(NSString*) name withData:(NSData* _Nullable) data;
+-(void) sendBroadcastMessage:(NSString*) name withData:(NSData* _Nullable) data withResponseHandler:(IPC_response_handler_t _Nullable) responseHandler;
+-(NSString* _Nullable) exportDB;
+
+-(void) respondToMessage:(NSDictionary*) message withData:(NSData* _Nullable) data;
+
+@end
+
+NS_ASSUME_NONNULL_END
diff --git a/Monal/Classes/IPC.m b/Monal/Classes/IPC.m
new file mode 100755
index 0000000..5cebf8b
--- /dev/null
+++ b/Monal/Classes/IPC.m
@@ -0,0 +1,331 @@
+//
+// IPC.m
+// Monal
+//
+// Created by Thilo Molitor on 31.07.20.
+// Copyright © 2020 Monal.im. All rights reserved.
+//
+
+#import
+#import
+#import
+#import "IPC.h"
+#import "MLSQLite.h"
+#import "HelperTools.h"
+
+#define MSG_TIMEOUT 2.0
+
+@interface IPC()
+{
+ NSString* _processName;
+ NSString* _dbFile;
+ NSMutableDictionary* _ipcQueues;
+ NSCondition* _serverThreadCondition;
+}
+@property (readonly, strong) MLSQLite* db;
+@property (readonly, strong) NSThread* serverThread;
+
+-(void) incomingDarwinNotification:(NSString*) name;
+@end
+
+static volatile NSMutableDictionary* _responseHandlers;
+static volatile IPC* _sharedInstance;
+static volatile CFNotificationCenterRef _darwinNotificationCenterRef;
+
+//forward notifications to the IPC instance that is waiting (the instance running the server thread)
+void darwinNotificationCenterCallback(CFNotificationCenterRef center __unused, void* observer, CFNotificationName name, const void* object __unused, CFDictionaryRef userInfo __unused)
+{
+ [(__bridge IPC*)observer incomingDarwinNotification:(__bridge NSString*)name];
+}
+
+@implementation IPC
+
++(void) initializeForProcess:(NSString*) processName
+{
+ @synchronized(self) {
+ MLAssert(_sharedInstance == nil, @"Please don't call [IPC initialize:@\"processName\" twice!");
+ _responseHandlers = [NSMutableDictionary new];
+ _darwinNotificationCenterRef = CFNotificationCenterGetDarwinNotifyCenter();
+ _sharedInstance = [[self alloc] initWithProcessName:processName]; //has to be last because it starts the thread which needs those global vars
+ }
+}
+
++(id) sharedInstance
+{
+ @synchronized(self) {
+ MLAssert(_sharedInstance!=nil, @"Please call [IPC initialize:@\"processName\"] first!");
+ return _sharedInstance;
+ }
+}
+
++(void) terminate
+{
+ @synchronized(self) {
+ //cancel server thread and wake it up to let it terminate properly
+ if(_sharedInstance.serverThread)
+ [_sharedInstance.serverThread cancel];
+ [_sharedInstance->_serverThreadCondition signal];
+ //deallocate everything
+ _responseHandlers = nil;
+ _sharedInstance = nil;
+ }
+}
+
+-(void) sendMessage:(NSString*) name withData:(NSData* _Nullable) data to:(NSString*) destination
+{
+ [self sendMessage:name withData:data to:destination withResponseHandler:nil];
+}
+
+-(void) sendMessage:(NSString*) name withData:(NSData* _Nullable) data to:(NSString*) destination withResponseHandler:(IPC_response_handler_t _Nullable) responseHandler
+{
+ NSNumber* id = [self writeIpcMessage:name withData:data andResponseId:[NSNumber numberWithInt:0] to:destination];
+ //save response handler for later execution (if one is specified)
+ if(responseHandler)
+ _responseHandlers[id] = responseHandler;
+}
+
+-(void) sendBroadcastMessage:(NSString*) name withData:(NSData* _Nullable) data
+{
+ [self sendMessage:name withData:data to:@"*" withResponseHandler:nil];
+}
+
+-(void) sendBroadcastMessage:(NSString*) name withData:(NSData* _Nullable) data withResponseHandler:(IPC_response_handler_t _Nullable) responseHandler
+{
+ [self sendMessage:name withData:data to:@"*" withResponseHandler:responseHandler];
+}
+
+-(void) respondToMessage:(NSDictionary*) message withData:(NSData* _Nullable) data
+{
+ [self writeIpcMessage:message[@"name"] withData:data andResponseId:message[@"id"] to:message[@"source"]];
+}
+
+-(NSString* _Nullable) exportDB
+{
+ NSFileManager* fileManager = [NSFileManager defaultManager];
+ NSString* temporaryFilename = [NSString stringWithFormat:@"ipc_%@.db", [[NSProcessInfo processInfo] globallyUniqueString]];
+ NSString* temporaryFilePath = [NSTemporaryDirectory() stringByAppendingPathComponent:temporaryFilename];
+
+ //checkpoint db before copying db file
+ [self.db checkpointWal];
+
+ //this transaction creates a new wal log and makes sure the file copy is atomic/consistent
+ BOOL success = [self.db boolWriteTransaction:^{
+ //copy db file to temp file
+ NSError* error;
+ [fileManager copyItemAtPath:self->_dbFile toPath:temporaryFilePath error:&error];
+ if(error)
+ {
+ DDLogError(@"Could not copy database to export location!");
+ return NO;
+ }
+ return YES;
+ }];
+
+ if(success)
+ return temporaryFilePath;
+ return nil;
+}
+
+-(id) initWithProcessName:(NSString*) processName
+{
+ self = [super init];
+
+ _dbFile = [[HelperTools getContainerURLForPathComponents:@[@"ipc.sqlite"]] path];
+ _processName = processName;
+ _ipcQueues = [NSMutableDictionary new];
+ _serverThreadCondition = [NSCondition new];
+
+ static dispatch_once_t once;
+ static const int VERSION = 3;
+ dispatch_once(&once, ^{
+ BOOL fileExists = [[NSFileManager defaultManager] fileExistsAtPath:_dbFile];
+ //first command creates initial database if file does not exist
+ //this can not be used inside a transaction --> turn on WAL mode before executing any other db operations
+ //this will create the database file and open the database because it is the first MLSQlite call done for this file
+ //turning on WAL mode has to be done *outside* of any transactions
+ [self.db enableWAL];
+ [self.db executeNonQuery:@"PRAGMA secure_delete=on;"];
+
+ //needed for sqlite >= 3.26.0 (see https://sqlite.org/lang_altertable.html point 2)
+ [self.db executeNonQuery:@"PRAGMA legacy_alter_table=on;"];
+ [self.db executeNonQuery:@"PRAGMA foreign_keys=off;"];
+
+ NSNumber* version = [self.db idWriteTransaction:^{
+ if(!fileExists)
+ {
+ [self.db executeNonQuery:@"CREATE TABLE ipc(id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, name VARCHAR(255), source VARCHAR(255), destination VARCHAR(255), data BLOB, timeout INTEGER NOT NULL DEFAULT 0);"];
+ [self.db executeNonQuery:@"CREATE TABLE versions(name VARCHAR(255) NOT NULL PRIMARY KEY, version INTEGER NOT NULL);"];
+ [self.db executeNonQuery:@"INSERT INTO versions (name, version) VALUES('db', '1');"];
+ }
+
+ //upgrade database version if needed
+ NSNumber* version = (NSNumber*)[self.db executeScalar:@"SELECT version FROM versions WHERE name='db';"];
+ DDLogInfo(@"IPC db version: %@", version);
+ if([version integerValue] < 2)
+ [self.db executeNonQuery:@"ALTER TABLE ipc ADD COLUMN response_to INTEGER NOT NULL DEFAULT 0;"];
+
+ //do a vacuum and from now on do it on every db upgrade
+ if([version integerValue] < 3)
+ ;
+
+ //any upgrade done --> update version table and delete all old ipc messages
+ if([version integerValue] < VERSION)
+ {
+ //always truncate ipc table on version upgrade
+ [self.db executeNonQuery:@"DELETE FROM ipc;"];
+ [self.db executeNonQuery:@"UPDATE versions SET version=? WHERE name='db';" andArguments:@[[NSNumber numberWithInt:VERSION]]];
+ DDLogInfo(@"IPC db upgraded to version: %d", VERSION);
+ }
+ return version;
+ }];
+ if([version integerValue] < VERSION)
+ [self.db vacuum];
+
+ //turn foreign keys on again
+ //needed for sqlite >= 3.26.0 (see https://sqlite.org/lang_altertable.html point 2)
+ [self.db executeNonQuery:@"PRAGMA legacy_alter_table=off;"];
+ [self.db executeNonQuery:@"PRAGMA foreign_keys=on;"];
+ });
+
+ //use a dedicated and very high priority thread to make sure this always runs
+ _serverThread = [[NSThread alloc] initWithTarget:self selector:@selector(serverThreadMain) object:nil];
+ //_serverThread.threadPriority = 1.0;
+ _serverThread.qualityOfService = NSQualityOfServiceUserInteractive;
+ [_serverThread setName:@"IPCServerThread"];
+ [_serverThread start];
+
+ return self;
+}
+
+-(void) serverThreadMain
+{
+ DDLogInfo(@"Now running IPC server for '%@' with thread priority %f...", _processName, [NSThread threadPriority]);
+ //register darwin notification handler for "im.monal.ipc.wakeup:" which is used to wake up readNextMessage using the NSCondition
+ CFNotificationCenterAddObserver(_darwinNotificationCenterRef, (__bridge void*) self, &darwinNotificationCenterCallback, (__bridge CFNotificationName)[NSString stringWithFormat:@"im.monal.ipc.wakeup:%@", _processName], NULL, 0);
+ CFNotificationCenterAddObserver(_darwinNotificationCenterRef, (__bridge void*) self, &darwinNotificationCenterCallback, (__bridge CFNotificationName)@"im.monal.ipc.wakeup:*", NULL, 0);
+ while(![[NSThread currentThread] isCancelled])
+ {
+ NSDictionary* message = [self readNextMessage]; //this will be blocking
+ if(!message)
+ continue;
+ DDLogDebug(@"Got IPC message: %@", message);
+
+ //use a dedicated serial queue for every IPC receiver to maintain IPC message ordering while not blocking other receivers or this serverThread)
+ NSArray* parts = [message[@"name"] componentsSeparatedByString:@"."];
+ NSString* queueName = [parts objectAtIndex:0];
+ if(!queueName || [parts count]<2)
+ queueName = @"_default";
+ queueName = [NSString stringWithFormat:@"ipc.queue:%@", queueName];
+ if(!_ipcQueues[queueName])
+ _ipcQueues[queueName] = dispatch_queue_create([queueName cStringUsingEncoding:NSUTF8StringEncoding], dispatch_queue_attr_make_with_qos_class(DISPATCH_QUEUE_SERIAL, QOS_CLASS_USER_INTERACTIVE, 0));
+
+ //handle all responses (don't trigger a kMonalIncomingIPC for responses)
+ if(message[@"response_to"] && [message[@"response_to"] intValue] > 0)
+ {
+ //call response handler if one is present (ignore the spurious response otherwise)
+ if(_responseHandlers[message[@"response_to"]])
+ {
+ IPC_response_handler_t responseHandler = (IPC_response_handler_t)_responseHandlers[message[@"response_to"]];
+ if(responseHandler)
+ {
+ //responses handlers are only valid for the maximum RTT of messages (+ some safety margin)
+ createTimer(MSG_TIMEOUT*2 + 1, (^{
+ [_responseHandlers removeObjectForKey:message[@"response_to"]];
+ }));
+ dispatch_async(_ipcQueues[queueName], ^{
+ responseHandler(message);
+ });
+ }
+ }
+ }
+ else //publish all non-responses (using the message name as object allows for filtering by ipc message name)
+ dispatch_async(_ipcQueues[queueName], ^{
+ [[NSNotificationCenter defaultCenter] postNotificationName:kMonalIncomingIPC object:message[@"name"] userInfo:message];
+ });
+
+ DDLogDebug(@"Handled IPC message: %@", message);
+ }
+ //unregister darwin notification handler
+ CFNotificationCenterRemoveObserver(_darwinNotificationCenterRef, (__bridge void*) self, (__bridge CFNotificationName)[NSString stringWithFormat:@"im.monal.ipc.wakeup:%@", _processName], NULL);
+ CFNotificationCenterRemoveObserver(_darwinNotificationCenterRef, (__bridge void*) self, (__bridge CFNotificationName)@"im.monal.ipc.wakeup:*", NULL);
+ DDLogInfo(@"IPC server for '%@' now terminated", _processName);
+}
+
+-(void) incomingDarwinNotification:(NSString*) name
+{
+ DDLogDebug(@"Got incoming darwin notification: %@", name);
+ [_serverThreadCondition signal]; //wake up server thread to process new messages
+}
+
+-(NSDictionary*) readNextMessage
+{
+ while(![[NSThread currentThread] isCancelled])
+ {
+ NSDictionary* data = [self readIpcMessageFor:_processName];
+ if(data)
+ return data;
+ //wait for wakeup (incoming darwin notification or thread termination)
+ DDLogVerbose(@"IPC readNextMessage waiting for wakeup via darwin notification");
+ [_serverThreadCondition wait];
+ }
+ return nil; //thread cancelled
+}
+
+//this is the getter of our readonly "db" property always returning the thread-local instance of the MLSQLite class
+-(MLSQLite*) db
+{
+ //always return thread-local instance of sqlite class (this is important for performance!)
+ return [MLSQLite sharedInstanceForFile:_dbFile];
+}
+
+-(NSDictionary*) readIpcMessageFor:(NSString*) destination
+{
+ return [self.db idWriteTransaction:^{
+ NSDictionary* retval = nil;
+
+ //delete old entries that timed out
+ NSNumber* timestamp = [HelperTools currentTimestampInSeconds];
+ [self.db executeNonQuery:@"DELETE FROM ipc WHERE timeout;" andArguments:@[timestamp]];
+
+ //load a *single* message from table and delete it afterwards
+ NSArray* rows = [self.db executeReader:@"SELECT * FROM ipc WHERE destination=? OR destination='*' ORDER BY id ASC LIMIT 1;" andArguments:@[destination]];
+ if([rows count])
+ {
+ retval = rows[0];
+ if(![retval[@"destination"] isEqualToString:@"*"]) //broadcast will be deleted by their timeout value only
+ [self.db executeNonQuery:@"DELETE FROM ipc WHERE id=?;" andArguments:@[retval[@"id"]]];
+ }
+ return retval;
+ }];
+}
+
+-(NSNumber*) writeIpcMessage:(NSString*) name withData:(NSData* _Nullable) data andResponseId:(NSNumber*) responseId to:(NSString*) destination
+{
+ //empty data is default if not specified
+ if(!data)
+ data = [NSData new];
+
+ DDLogDebug(@"writeIpcMessage:%@ withData:%@ andResponseId:%@ to:%@", name, data, responseId, destination);
+
+ NSNumber* id = [self.db idWriteTransaction:^{
+ //delete old entries that timed out
+ NSNumber* timestamp = [HelperTools currentTimestampInSeconds];
+ [self.db executeNonQuery:@"DELETE FROM ipc WHERE timeout;" andArguments:@[timestamp]];
+
+ //save message to table
+ NSNumber* timeout = @([timestamp intValue] + MSG_TIMEOUT); //timeout for every message
+ [self.db executeNonQuery:@"INSERT INTO ipc (name, source, destination, data, timeout, response_to) VALUES(?, ?, ?, ?, ?, ?);" andArguments:@[name, self->_processName, destination, data, timeout, responseId]];
+ return [self.db lastInsertId];
+ }];
+
+ //send out darwin notification to wake up other processes waiting for IPC
+ if(![destination isEqualToString:@"*"])
+ CFNotificationCenterPostNotification(CFNotificationCenterGetDarwinNotifyCenter(), (__bridge CFNotificationName)[NSString stringWithFormat:@"im.monal.ipc.wakeup:%@", destination], NULL, NULL, NO);
+ else
+ CFNotificationCenterPostNotification(CFNotificationCenterGetDarwinNotifyCenter(), (__bridge CFNotificationName)@"im.monal.ipc.wakeup:*", NULL, NULL, NO);
+
+ DDLogDebug(@"Wrote IPC message %@ to database", id);
+ return id;
+}
+
+@end
diff --git a/Monal/Classes/LoadingOverlay.swift b/Monal/Classes/LoadingOverlay.swift
new file mode 100644
index 0000000..16a9116
--- /dev/null
+++ b/Monal/Classes/LoadingOverlay.swift
@@ -0,0 +1,165 @@
+//
+// LoadingOverlay.swift
+// Monal
+//
+// Created by Jan on 21.06.22.
+// Copyright © 2022 Monal.im. All rights reserved.
+//
+
+//data class for overlay state
+class LoadingOverlayState : ObservableObject {
+ @Published var enabled: Bool
+ @Published var headline: AnyView
+ @Published var description: AnyView
+ init(enabled:Bool = false, headline:AnyView = AnyView(Text("")), description:AnyView = AnyView(Text(""))) {
+ self.enabled = enabled
+ self.headline = headline
+ self.description = description
+ }
+}
+
+//view modifier for overlay
+struct LoadingOverlay: ViewModifier {
+ @ObservedObject var state : LoadingOverlayState
+ public func body(content: Content) -> some View {
+ ZStack(alignment: .center) {
+ Color(UIColor.systemGroupedBackground).ignoresSafeArea()
+
+ content
+ .disabled(state.enabled == true)
+ .blur(radius:(state.enabled == true ? 3 : 0))
+
+ if(state.enabled == true) {
+ VStack {
+ state.headline.font(.headline)
+ state.description.font(.footnote)
+ ProgressView()
+ }
+ .padding(20)
+ .frame(minWidth: 250, minHeight: 100)
+ .background(Color.secondary.colorInvert())
+ .cornerRadius(20)
+ .transaction { transaction in transaction.animation = nil}
+ }
+ }
+ }
+}
+
+//this extension contains the easy-access view modifier
+extension View {
+ func addLoadingOverlay(_ overlay: LoadingOverlayState) -> some View {
+ modifier(LoadingOverlay(state:overlay))
+ }
+}
+
+func showPromisingLoadingOverlay(_ overlay: LoadingOverlayState, headlineView headline: T1, descriptionView description: T2) -> Guarantee {
+ return HelperTools.wait(atLeastSeconds:1.0, for:AnyPromise(DispatchQueue.main.async(.promise) {
+ overlay.headline = AnyView(headline)
+ overlay.description = AnyView(description)
+ overlay.enabled = true
+ //only rerender ui once (not sure if this optimization is really needed, if this is missing, use @Published for member vars of state class)
+ overlay.objectWillChange.send()
+ //make sure to really draw the overlay on race conditions
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.250) {
+ overlay.objectWillChange.send()
+ }
+ })).toGuarantee()
+}
+
+func showPromisingLoadingOverlay(_ overlay: LoadingOverlayState, headline: T, description: T = "") -> Guarantee {
+ return showPromisingLoadingOverlay(overlay, headlineView:Text(headline), descriptionView:Text(description))
+}
+
+func showPromisingLoadingOverlay(_ overlay: LoadingOverlayState, headlineView headline: T1, descriptionView description: T2, firstlyClosure: @escaping () -> U) -> Promise {
+ return Promise { seal in
+ showPromisingLoadingOverlay(overlay, headlineView: headline, descriptionView: description).done {
+ let _ = firstlyClosure().done { value in
+ hideLoadingOverlay(overlay)
+ seal.fulfill(value)
+ }.catch { error in
+ hideLoadingOverlay(overlay)
+ seal.reject(error)
+ }
+ }
+ }
+}
+
+func showPromisingLoadingOverlay(_ overlay: LoadingOverlayState, headlineView headline: T1, descriptionView description: T2, firstlyClosure: @escaping () -> U) -> Guarantee {
+ return Guarantee { seal in
+ showPromisingLoadingOverlay(overlay, headlineView: headline, descriptionView: description).done {
+ let _ = firstlyClosure().finally {
+ hideLoadingOverlay(overlay)
+ seal(())
+ }
+ }
+ }
+}
+
+func showPromisingLoadingOverlay(_ overlay: LoadingOverlayState, headline: T, description: T = "", firstlyClosure: @escaping () -> U) -> Promise {
+ return showPromisingLoadingOverlay(overlay, headlineView:Text(headline), descriptionView:Text(description), firstlyClosure:firstlyClosure)
+}
+
+func showPromisingLoadingOverlay(_ overlay: LoadingOverlayState, headline: T, description: T = "", firstlyClosure: @escaping () -> U) -> Guarantee {
+ return showPromisingLoadingOverlay(overlay, headlineView:Text(headline), descriptionView:Text(description), firstlyClosure:firstlyClosure)
+}
+
+func showLoadingOverlay(_ overlay: LoadingOverlayState, headlineView headline: T1, descriptionView description: T2) {
+ let _ = showPromisingLoadingOverlay(overlay, headlineView:headline, descriptionView:description)
+}
+
+func showLoadingOverlay(_ overlay: LoadingOverlayState, headline: T, description: T = "") {
+ let _ = showPromisingLoadingOverlay(overlay, headline:headline, description:description)
+}
+
+func showLoadingOverlay(_ overlay: LoadingOverlayState, headlineView headline: T1, descriptionView description: T2, firstlyClosure: @escaping () -> U) {
+ let _ = showPromisingLoadingOverlay(overlay, headlineView:headline, descriptionView:description, firstlyClosure:firstlyClosure)
+}
+
+func showLoadingOverlay(_ overlay: LoadingOverlayState, headlineView headline: T1, descriptionView description: T2, firstlyClosure: @escaping () -> U) {
+ let _ = showPromisingLoadingOverlay(overlay, headlineView:headline, descriptionView:description, firstlyClosure:firstlyClosure)
+}
+
+func showLoadingOverlay(_ overlay: LoadingOverlayState, headline: T, description: T = "", firstlyClosure: @escaping () -> U) {
+ let _ = showPromisingLoadingOverlay(overlay, headline:headline, description:description, firstlyClosure:firstlyClosure)
+}
+
+func showLoadingOverlay(_ overlay: LoadingOverlayState, headline: T, description: T = "", firstlyClosure: @escaping () -> U) {
+ let _ = showPromisingLoadingOverlay(overlay, headline:headline, description:description, firstlyClosure:firstlyClosure)
+}
+
+func hideLoadingOverlay(_ overlay: LoadingOverlayState) {
+ DispatchQueue.main.async {
+ overlay.headline = AnyView(Text(""))
+ overlay.description = AnyView(Text(""))
+ overlay.enabled = false
+ //only rerender ui once (not sure if this optimization is really needed, if this is missing, use @Published for member vars of state class)
+ overlay.objectWillChange.send()
+ //make sure to really draw the overlay on race conditions
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.250) {
+ overlay.objectWillChange.send()
+ }
+ }
+}
+
+struct LoadingOverlay_Previews: PreviewProvider {
+ @StateObject static var overlay1 = LoadingOverlayState(enabled:true, headline:AnyView(Text("Loading")), description:AnyView(Text("More info?")))
+ @StateObject static var overlay2 = LoadingOverlayState(enabled:true, headline:AnyView(Text("Loading")), description:AnyView(HStack {
+ Image(systemName: "checkmark")
+ Text("Doing a lot of work...")
+ }))
+ static var previews: some View {
+ Form {
+ Text("Entry 1")
+ Text("Entry 2")
+ Text("Entry 3")
+ }
+ .addLoadingOverlay(overlay1)
+
+ Form {
+ Text("Entry 1")
+ Text("Entry 2")
+ Text("Entry 3")
+ }
+ .addLoadingOverlay(overlay2)
+ }
+}
diff --git a/Monal/Classes/MLAccountCell.xib b/Monal/Classes/MLAccountCell.xib
new file mode 100644
index 0000000..b32fca5
--- /dev/null
+++ b/Monal/Classes/MLAccountCell.xib
@@ -0,0 +1,55 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Monal/Classes/MLAccountPickerViewController.h b/Monal/Classes/MLAccountPickerViewController.h
new file mode 100644
index 0000000..378f66b
--- /dev/null
+++ b/Monal/Classes/MLAccountPickerViewController.h
@@ -0,0 +1,20 @@
+//
+// MLAccountPickerViewController.h
+// Monal
+//
+// Created by Anurodh Pokharel on 2/10/20.
+// Copyright © 2020 Monal.im. All rights reserved.
+//
+
+#import
+#import "MLConstants.h"
+
+NS_ASSUME_NONNULL_BEGIN
+
+@interface MLAccountPickerViewController : UITableViewController
+
+@property (nonatomic, strong) accountCompletion completion;
+
+@end
+
+NS_ASSUME_NONNULL_END
diff --git a/Monal/Classes/MLAccountPickerViewController.m b/Monal/Classes/MLAccountPickerViewController.m
new file mode 100644
index 0000000..5e024a9
--- /dev/null
+++ b/Monal/Classes/MLAccountPickerViewController.m
@@ -0,0 +1,49 @@
+//
+// MLAccountPickerViewController.m
+// Monal
+//
+// Created by Anurodh Pokharel on 2/10/20.
+// Copyright © 2020 Monal.im. All rights reserved.
+//
+
+#import "MLAccountPickerViewController.h"
+#import "MLXMPPManager.h"
+#import "xmpp.h"
+
+@interface MLAccountPickerViewController ()
+
+@end
+
+@implementation MLAccountPickerViewController
+
+- (void)viewDidLoad {
+ [super viewDidLoad];
+}
+
+#pragma mark - Table view data source
+
+- (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView {
+ return 1;
+}
+
+- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section {
+ return [[MLXMPPManager sharedInstance].connectedXMPP count];
+}
+
+
+- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
+ UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:@"AccountCell" forIndexPath:indexPath];
+ xmpp* xmppAccount = [MLXMPPManager sharedInstance].connectedXMPP[indexPath.row];
+ cell.textLabel.text=xmppAccount.connectionProperties.identity.jid;
+ return cell;
+}
+
+
+-(void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath
+{
+ if(self.completion) self.completion(indexPath.row);
+ [self.navigationController popViewControllerAnimated:YES];
+}
+
+
+@end
diff --git a/Monal/Classes/MLAttributedLabel.h b/Monal/Classes/MLAttributedLabel.h
new file mode 100644
index 0000000..416815c
--- /dev/null
+++ b/Monal/Classes/MLAttributedLabel.h
@@ -0,0 +1,19 @@
+//
+// MLAttributedLabel.h
+// Monal
+//
+// Created by Friedrich Altheide on 01.04.20.
+// Copyright © 2020 Monal.im. All rights reserved.
+//
+
+#import
+
+@interface MLAttributedLabel : UILabel
+
+@property (nonatomic, strong) NSAttributedString* localAttributedText;
+
+-(void) setText:(NSString*) text;
+-(void) setAttributedText: (NSAttributedString*) attributedText;
+-(NSAttributedString*) attributedText;
+-(NSAttributedString*) originalAttributedText;
+@end
diff --git a/Monal/Classes/MLAttributedLabel.m b/Monal/Classes/MLAttributedLabel.m
new file mode 100644
index 0000000..01ac351
--- /dev/null
+++ b/Monal/Classes/MLAttributedLabel.m
@@ -0,0 +1,31 @@
+//
+// MLAttributedLabel.m
+// Monal
+//
+// Created by Friedrich Altheide on 01.04.20.
+// Copyright © 2020 Monal.im. All rights reserved.
+//
+
+#import "MLAttributedLabel.h"
+
+@implementation MLAttributedLabel
+
+-(void) setText:(NSString*) text {
+ self.localAttributedText = nil;
+ [super setText:text];
+}
+
+-(void) setAttributedText:(NSAttributedString*) attributedText {
+ [super setAttributedText:attributedText];
+ self.localAttributedText = attributedText;
+}
+
+-(NSAttributedString *) attributedText {
+ return [super attributedText];
+}
+
+-(NSAttributedString *) originalAttributedText {
+ return self.localAttributedText;
+}
+
+@end
diff --git a/Monal/Classes/MLAudioRecoderManager.h b/Monal/Classes/MLAudioRecoderManager.h
new file mode 100644
index 0000000..904af62
--- /dev/null
+++ b/Monal/Classes/MLAudioRecoderManager.h
@@ -0,0 +1,37 @@
+//
+// MLAudioRecoderManager.h
+// Monal
+//
+// Created by jimtsai (poormusic2001@gmail.com) on 2021/2/26.
+// Copyright © 2021 Monal.im. All rights reserved.
+//
+
+#import
+#import
+#import "HelperTools.h"
+
+NS_ASSUME_NONNULL_BEGIN
+
+@protocol AudioRecoderManagerDelegate
+
+-(void) notifyResult:(BOOL) isSuccess error:(NSString* _Nullable) errorMsg;
+-(void) notifyStart;
+-(void) notifyStop:(NSURL* _Nullable) fileURL;
+-(void) updateCurrentTime:(NSTimeInterval) audioDuration;
+@end
+
+@interface MLAudioRecoderManager : NSObject
+
+@property (strong, nonatomic) AVAudioRecorder* audioRecorder;
+@property (weak, nonatomic) id recoderManagerDelegate;
+
++ (MLAudioRecoderManager* _Nonnull)sharedInstance;
+
+-(void) start;
+-(void) stop:(BOOL) shouldSend;
+
+@property (nonatomic) NSString* currentPlayFilePath;
+
+@end
+
+NS_ASSUME_NONNULL_END
diff --git a/Monal/Classes/MLAudioRecoderManager.m b/Monal/Classes/MLAudioRecoderManager.m
new file mode 100644
index 0000000..111f2f3
--- /dev/null
+++ b/Monal/Classes/MLAudioRecoderManager.m
@@ -0,0 +1,138 @@
+//
+// MLAudioRecoderManager.m
+// Monal
+//
+// Created by jimtsai (poormusic2001@gmail.com) on 2021/2/26.
+// Copyright © 2021 Monal.im. All rights reserved.
+//
+
+#import "MLAudioRecoderManager.h"
+
+NSTimer *updateTimer = nil;
+NSURL *audioFileURL = nil;
+
+@implementation MLAudioRecoderManager
+
++(MLAudioRecoderManager*)sharedInstance
+{
+ static dispatch_once_t once;
+ static MLAudioRecoderManager* sharedInstance;
+ dispatch_once(&once, ^{
+ sharedInstance = [MLAudioRecoderManager new] ;
+ });
+ return sharedInstance;
+}
+
+-(void) start
+{
+ id recoderManagerDelegate = self.recoderManagerDelegate;
+ NSError *audioSessionCategoryError = nil;
+ NSError *audioRecodSetActiveError = nil;
+ AVAudioSession *audioSession = [AVAudioSession sharedInstance];
+ [audioSession setCategory:AVAudioSessionCategoryRecord error:&audioSessionCategoryError];
+ [audioSession setActive:YES error:&audioRecodSetActiveError];
+ if (audioSessionCategoryError) {
+ DDLogError(@"Audio Recorder set category error: %@", audioSessionCategoryError);
+ [recoderManagerDelegate notifyResult:NO error:NSLocalizedString(@"Audio Recorder set category error: %@", audioSessionCategoryError)];
+ return;
+ }
+
+ if (audioRecodSetActiveError) {
+ DDLogError(@"Audio Recorder set active error: %@", audioRecodSetActiveError);
+ [recoderManagerDelegate notifyResult:NO error:NSLocalizedString(@"Audio Recorder set active error: %@", audioRecodSetActiveError)];
+ return;
+ }
+
+ NSError* recoderError = nil;
+ NSDictionary* recodSettings = [NSDictionary dictionaryWithObjectsAndKeys:
+ [NSNumber numberWithInt:kAudioFormatMPEG4AAC] , AVFormatIDKey,
+ [NSNumber numberWithInt:AVAudioQualityMin],AVEncoderAudioQualityKey,
+ [NSNumber numberWithInt: 1], AVNumberOfChannelsKey,
+ [NSNumber numberWithFloat:32000.0], AVSampleRateKey, nil];
+
+ audioFileURL = [NSURL fileURLWithPath:[self getAudioPath]];
+
+ self.audioRecorder = [[AVAudioRecorder alloc] initWithURL:audioFileURL settings:recodSettings error:&recoderError];
+
+ if(recoderError)
+ {
+ DDLogError(@"recorderError: %@", recoderError);
+ [recoderManagerDelegate notifyResult:NO error:NSLocalizedString(@"Audio recorder init fail.", @"")];
+ return;
+ }
+ self.audioRecorder.delegate = self;
+ BOOL isPrepare = [self.audioRecorder prepareToRecord];
+
+ if(!isPrepare)
+ {
+ [recoderManagerDelegate notifyResult:NO error:NSLocalizedString(@"Audio recorder prepareToRecord fail.", @"")];
+ return;
+ }
+ BOOL isRecord = [self.audioRecorder record];
+
+ if(!isRecord)
+ {
+ [recoderManagerDelegate notifyResult:NO error:NSLocalizedString(@"Audio recorder record fail.", @"")];
+ return;
+ }
+ [recoderManagerDelegate notifyStart];
+ updateTimer = [NSTimer scheduledTimerWithTimeInterval:1.0 target:self selector:@selector(updateTimeInfo) userInfo:nil repeats:YES];
+}
+
+-(void) stop:(BOOL) shouldSend
+{
+ self.audioRecorder.delegate = nil;
+ if(shouldSend)
+ self.audioRecorder.delegate = self;
+ [self.audioRecorder stop];
+ [updateTimer invalidate];
+ updateTimer = nil;
+ [self.recoderManagerDelegate notifyStop:shouldSend ? audioFileURL : nil];
+ if(!shouldSend)
+ {
+ NSFileManager* fileManager = [NSFileManager defaultManager];
+ [fileManager removeItemAtURL:audioFileURL error:nil];
+ [self.recoderManagerDelegate notifyResult:NO error:NSLocalizedString(@"Aborted recording audio", @"")];
+ }
+}
+
+-(void) updateTimeInfo
+{
+ [self.recoderManagerDelegate updateCurrentTime:self.audioRecorder.currentTime];
+}
+
+- (void) audioRecorderDidFinishRecording:(AVAudioRecorder*) recorder successfully:(BOOL) flag
+{
+ id recoderManagerDelegate = self.recoderManagerDelegate;
+ if(flag)
+ {
+ [recoderManagerDelegate notifyResult:YES error:nil];
+ }
+ else
+ {
+ [recoderManagerDelegate notifyResult:NO error:NSLocalizedString(@"Audio Recorder: failed to record", @"")];
+ DDLogError(@"Audio Recorder record fail");
+ }
+}
+
+-(void) audioRecorderEncodeErrorDidOccur:(AVAudioRecorder*) recorder error:(NSError*) error
+{
+ DDLogError(@"Audio Recorder EncodeError: %@", [error description]);
+ [self.recoderManagerDelegate notifyResult:NO error:[error description]];
+}
+
+-(NSString*) getAudioPath
+{
+ NSFileManager* fileManager = [NSFileManager defaultManager];
+ NSString* writablePath = [[HelperTools getContainerURLForPathComponents:@[@"AudioRecordCache"]] path];
+ NSError* error = nil;
+ [fileManager createDirectoryAtPath:writablePath withIntermediateDirectories:YES attributes:nil error:&error];
+ if(error)
+ DDLogError(@"Audio Recorder create directory fail: %@", [error description]);
+ [HelperTools configureFileProtectionFor:writablePath];
+ NSString* audioFilePath = [writablePath stringByAppendingPathComponent:[NSString stringWithFormat:@"%@.m4a",[[NSUUID UUID] UUIDString]]];
+ return audioFilePath;
+}
+
+
+@end
diff --git a/Monal/Classes/MLBaseCell.h b/Monal/Classes/MLBaseCell.h
new file mode 100644
index 0000000..1fa82e0
--- /dev/null
+++ b/Monal/Classes/MLBaseCell.h
@@ -0,0 +1,60 @@
+//
+// MLBaseCell.h
+// Monal
+//
+// Created by Anurodh Pokharel on 12/24/17.
+// Copyright © 2017 Monal.im. All rights reserved.
+//
+
+#import
+#import "MLConstants.h"
+#import "MLMessage.h"
+
+#define kDefaultTextHeight 20
+#define kDefaultTextOffset 5
+
+#define kSending NSLocalizedString(@"Sending...", @"")
+#define kSent LocalizationNotNeeded(@"")
+#define kReceived LocalizationNotNeeded(@"✓")
+#define kDisplayed LocalizationNotNeeded(@"✓✓")
+
+
+@interface MLBaseCell : UITableViewCell
+
+-(id) init;
+
+@property (nonatomic, assign) BOOL outBound;
+@property (nonatomic, assign) BOOL MUC;
+
+@property (nonatomic, strong) IBOutlet UILabel* name;
+@property (nonatomic, strong) IBOutlet NSLayoutConstraint *nameHeight;
+
+@property (nonatomic, strong) IBOutlet UILabel* date;
+@property (nonatomic, strong) IBOutlet UILabel* messageBody;
+@property (nonatomic, strong) IBOutlet UILabel* messageStatus;
+@property (nonatomic, strong) IBOutlet UILabel* dividerDate;
+@property (nonatomic, strong) IBOutlet NSLayoutConstraint *dividerHeight;
+@property (nonatomic, strong) IBOutlet NSLayoutConstraint *bubbleTop;
+@property (nonatomic, strong) IBOutlet NSLayoutConstraint *dayTop;
+
+@property (nonatomic, strong) NSString* link;
+@property (nonatomic, strong) IBOutlet UIView* bubbleView;
+
+@property (nonatomic, weak) IBOutlet UIImageView *bubbleImage;
+@property (nonatomic, weak) IBOutlet UIImageView *lockImage;
+
+@property (nonatomic, assign) BOOL deliveryFailed;
+@property (nonatomic, strong) IBOutlet UIButton* retry;
+@property (nonatomic, strong) NSNumber* messageHistoryId;
+@property (nonatomic, weak) UIViewController *parent;
+
+/**
+ Updates ths cells spacing and display
+ @param newSender determines if the sender of this cell
+ is the same as the prior cell's sender
+ **/
+-(void) updateCellWithNewSender:(BOOL) newSender;
+
+-(void) initCell:(MLMessage*) message;
+
+@end
diff --git a/Monal/Classes/MLBaseCell.m b/Monal/Classes/MLBaseCell.m
new file mode 100644
index 0000000..774b2e0
--- /dev/null
+++ b/Monal/Classes/MLBaseCell.m
@@ -0,0 +1,110 @@
+//
+// MLBaseCell.m
+// Monal
+//
+// Created by Anurodh Pokharel on 12/24/17.
+// Copyright © 2017 Monal.im. All rights reserved.
+//
+
+#import "HelperTools.h"
+#import "MLBaseCell.h"
+#import "MLMessage.h"
+
+@implementation MLBaseCell
+
+-(id) init
+{
+ self = [super init];
+ [self setRetryButtonImage];
+ return self;
+}
+
+-(void) initCell:(MLMessage*) message
+{
+ [self setRetryButtonImage];
+
+ self.messageHistoryId = message.messageDBId;
+ self.messageBody.text = message.messageText;
+ self.outBound = !message.inbound;
+}
+
+-(void) setRetryButtonImage
+{
+ [self.retry setImage:[UIImage systemImageNamed:@"info.circle"] forState:UIControlStateNormal];
+}
+
+- (void)awakeFromNib {
+ [super awakeFromNib];
+
+ BOOL backgrounds = [[HelperTools defaultsDB] boolForKey:@"ChatBackgrounds"];
+ if(backgrounds) {
+ self.name.textColor=[UIColor whiteColor];
+ self.date.textColor=[UIColor whiteColor];
+ self.messageStatus.textColor=[UIColor whiteColor];
+ self.dividerDate.textColor=[UIColor whiteColor];
+ }
+}
+
+- (void)setSelected:(BOOL)selected animated:(BOOL)animated {
+ [super setSelected:selected animated:animated];
+
+ // Configure the view for the selected state
+}
+
+
+-(void) updateCellWithNewSender:(BOOL) newSender
+{
+#pragma clang diagnostic push
+#pragma clang diagnostic ignored "-Wundeclared-selector"
+ if([self.parent respondsToSelector:@selector(retry:)]) {
+ [self.retry addTarget:self.parent action:@selector(retry:) forControlEvents:UIControlEventTouchUpInside];
+ }
+#pragma clang diagnostic pop
+
+ self.retry.tag= [self.messageHistoryId integerValue];
+
+ if(self.deliveryFailed) {
+ self.retry.hidden=NO;
+ }
+ else{
+ self.retry.hidden=YES;
+ }
+
+ if(self.name) {
+ if(self.name.text.length==0) {
+ self.nameHeight.constant=0;
+ self.bubbleTop.constant=0;
+ self.dayTop.constant=0;
+ } else {
+ self.nameHeight.constant= kDefaultTextHeight;
+ self.bubbleTop.constant=kDefaultTextOffset;
+ self.dayTop.constant=kDefaultTextOffset;
+ }
+ }
+
+ if(self.dividerDate.text.length==0) {
+ self.dividerHeight.constant=0;
+ if(!self.name) {
+ self.bubbleTop.constant=0;
+ self.dayTop.constant=0;
+ }
+ } else {
+ if(!self.name) {
+ self.bubbleTop.constant=kDefaultTextOffset;
+ self.dayTop.constant=kDefaultTextOffset;
+ }
+ self.dividerHeight.constant=kDefaultTextHeight;
+ }
+
+ if(newSender && self.dividerHeight.constant==0) {
+ self.dividerHeight.constant= kDefaultTextHeight/2;
+ }
+}
+
+-(void)prepareForReuse{
+ [super prepareForReuse];
+ self.deliveryFailed=NO;
+ self.outBound=NO;
+}
+
+@end
diff --git a/Monal/Classes/MLBasePaser.h b/Monal/Classes/MLBasePaser.h
new file mode 100644
index 0000000..367c44e
--- /dev/null
+++ b/Monal/Classes/MLBasePaser.h
@@ -0,0 +1,30 @@
+//
+// MLBasePaser.h
+// monalxmpp
+//
+// Created by Anurodh Pokharel on 4/11/20.
+// Copyright © 2020 Monal.im. All rights reserved.
+//
+
+#import
+#import "MLXMLNode.h"
+
+//stanzas
+#import "XMPPIQ.h"
+#import "XMPPPresence.h"
+#import "XMPPMessage.h"
+#import "XMPPDataForm.h"
+
+
+NS_ASSUME_NONNULL_BEGIN
+
+typedef void (^stanza_completion_t)(MLXMLNode* _Nullable parsedStanza);
+
+@interface MLBasePaser : NSObject
+
+-(id) initWithCompletion:(stanza_completion_t) completion;
+-(void) reset;
+
+@end
+
+NS_ASSUME_NONNULL_END
diff --git a/Monal/Classes/MLBasePaser.m b/Monal/Classes/MLBasePaser.m
new file mode 100644
index 0000000..4160d29
--- /dev/null
+++ b/Monal/Classes/MLBasePaser.m
@@ -0,0 +1,156 @@
+//
+// MLBasePaser.m
+// monalxmpp
+//
+// Created by Anurodh Pokharel on 4/11/20.
+// Copyright © 2020 Monal.im. All rights reserved.
+//
+
+#import "MLConstants.h"
+#import "MLBasePaser.h"
+
+//#define DebugParser(...) DDLogDebug(__VA_ARGS__)
+#define DebugParser(...)
+
+@interface MLXMLNode()
+@property (atomic, readwrite) MLXMLNode* parent;
+-(MLXMLNode*) addChildNodeWithoutCopy:(MLXMLNode*) child;
+@end
+
+@interface MLBasePaser ()
+{
+ //this stak is needed to hold strong references to all nodes until they are dispatched to our _completion callback
+ //(the parent references of the MLXMLNodes are weak and don't hold the parents alive)
+ NSMutableArray* _currentStack;
+ stanza_completion_t _completion;
+ NSMutableArray* _namespacePrefixes;
+}
+@end
+
+@implementation MLBasePaser
+
+-(id) initWithCompletion:(stanza_completion_t) completion
+{
+ self = [super init];
+ _completion = completion;
+ return self;
+}
+
+-(void) reset
+{
+ _currentStack = [NSMutableArray new];
+}
+
+-(void) parserDidStartDocument:(NSXMLParser*) parser
+{
+ DDLogInfo(@"Document start");
+ [self reset];
+}
+
+-(void) parser:(NSXMLParser*) parser didStartMappingPrefix:(NSString*) prefix toURI:(NSString*) namespaceURI
+{
+ DebugParser(@"Got new namespace prefix mapping for '%@' to '%@'...", prefix, namespaceURI);
+}
+
+-(void) parser:(NSXMLParser*) parser didEndMappingPrefix:(NSString*) prefix
+{
+ DebugParser(@"Namespace prefix '%@' now out of scope again...", prefix);
+}
+
+-(void) parser:(NSXMLParser*) parser didStartElement:(NSString*) elementName namespaceURI:(NSString*) namespaceURI qualifiedName:(NSString*) qName attributes:(NSDictionary*) attributeDict
+{
+ NSInteger depth = [_currentStack count] + 1; //this makes the depth in here equal to the depth in didEndElement:
+ DebugParser(@"Started element: %@ :: %@ (%@) depth %ld", elementName, namespaceURI, qName, depth);
+
+ //use appropriate MLXMLNode child classes for iq, message and presence stanzas
+ MLXMLNode* newNode;
+ if(depth == 2 && [elementName isEqualToString:@"iq"] && [namespaceURI isEqualToString:@"jabber:client"])
+ newNode = [XMPPIQ alloc];
+ else if(depth == 2 && [elementName isEqualToString:@"message"] && [namespaceURI isEqualToString:@"jabber:client"])
+ newNode = [XMPPMessage alloc];
+ else if(depth == 2 && [elementName isEqualToString:@"presence"] && [namespaceURI isEqualToString:@"jabber:client"])
+ newNode = [XMPPPresence alloc];
+ else if(depth >= 3 && [elementName isEqualToString:@"x"] && [namespaceURI isEqualToString:@"jabber:x:data"])
+ newNode = [XMPPDataForm alloc];
+ else
+ newNode = [MLXMLNode alloc];
+ newNode = [newNode initWithElement:elementName andNamespace:namespaceURI withAttributes:attributeDict andChildren:@[] andData:nil];
+
+ DebugParser(@"Current stack: %@", _currentStack);
+ //add new node to tree (each node needs a prototype MLXMLNode element and a mutable string to hold its future
+ //char data added to the MLXMLNode when the xml element is closed
+ newNode.parent = [_currentStack lastObject][@"node"];
+ [_currentStack addObject:@{@"node": newNode, @"charData": [NSMutableString new]}];
+}
+
+-(void) parser:(NSXMLParser*) parser foundCharacters:(NSString*) string
+{
+ DebugParser(@"Got new xml character data: '%@'", string);
+ NSInteger depth = [_currentStack count];
+ if(depth == 0)
+ {
+ DDLogError(@"Got xml character data outside of any element!");
+ [self fakeStreamError];
+ return;
+ }
+
+ [[_currentStack lastObject][@"charData"] appendString:string];
+ DebugParser(@"_currentCharData is now: '%@'", [_currentStack lastObject][@"charData"]);
+}
+
+-(void) parser:(NSXMLParser*) parser didEndElement:(NSString*) elementName namespaceURI:(NSString*) namespaceURI qualifiedName:(NSString*) qName
+{
+ NSInteger depth = [_currentStack count];
+ NSDictionary* topmostStackElement = [_currentStack lastObject];
+ MLXMLNode* currentNode = ((MLXMLNode*)topmostStackElement[@"node"]);
+
+ if([topmostStackElement[@"charData"] length])
+ currentNode.data = [topmostStackElement[@"charData"] copy];
+
+ DebugParser(@"Ended element: %@ :: %@ (%@) depth %ld", elementName, namespaceURI, qName, depth);
+
+ MLXMLNode* parent = currentNode.parent;
+ if(parent)
+ {
+ DebugParser(@"Ascending from child %@ to parent %@", currentNode.element, parent.element);
+ if(depth > 2) //don't add all received stanzas/nonzas as childs to our stream header (that would create a memory leak!)
+ {
+ DebugParser(@"Adding %@ to parent %@", currentNode.element, parent.element);
+ [parent addChildNodeWithoutCopy:currentNode];
+ }
+ }
+ [_currentStack removeLastObject];
+
+ //only call completion for stanzas, not for inner elements inside stanzas and not for our outermost stream start element
+ if(depth == 2)
+ _completion(currentNode);
+}
+
+-(void) parserDidEndDocument:(NSXMLParser*) parser
+{
+ DDLogInfo(@"Document end");
+}
+
+-(void) parser:(NSXMLParser*) parser foundIgnorableWhitespace:(NSString*) whitespaceString
+{
+ DebugParser(@"Found ignorable whitespace: '%@'", whitespaceString);
+}
+
+-(void) parser:(NSXMLParser*) parser parseErrorOccurred:(NSError*) parseError
+{
+ DDLogError(@"XML parse error occurred: line: %ld , col: %ld desc: %@ ",(long)[parser lineNumber],
+ (long)[parser columnNumber], [parseError localizedDescription]);
+ [self fakeStreamError];
+}
+
+-(void) fakeStreamError
+{
+ //fake stream error and let xmpp.m handle it
+ _completion([[MLXMLNode alloc] initWithElement:@"error" andNamespace:@"http://etherx.jabber.org/streams" withAttributes:@{} andChildren:@[
+ [[MLXMLNode alloc] initWithElement:@"bad-format" andNamespace:@"urn:ietf:params:xml:ns:xmpp-streams" withAttributes:@{} andChildren:@[
+ [[MLXMLNode alloc] initWithElement:@"text" andNamespace:@"urn:ietf:params:xml:ns:xmpp-streams" withAttributes:@{} andChildren:@[] andData:@"Could not parse XML coming from server"]
+ ] andData:nil]
+ ] andData:nil]);
+}
+
+@end
diff --git a/Monal/Classes/MLButtonCell.h b/Monal/Classes/MLButtonCell.h
new file mode 100644
index 0000000..438eae2
--- /dev/null
+++ b/Monal/Classes/MLButtonCell.h
@@ -0,0 +1,14 @@
+//
+// MLButtonCell.h
+// Monal
+//
+// Created by Anurodh Pokharel on 4/10/15.
+// Copyright (c) 2015 Monal.im. All rights reserved.
+//
+
+#import
+
+@interface MLButtonCell : UITableViewCell
+@property (nonatomic, weak) IBOutlet UILabel *buttonText;
+
+@end
diff --git a/Monal/Classes/MLButtonCell.m b/Monal/Classes/MLButtonCell.m
new file mode 100644
index 0000000..3e7bad1
--- /dev/null
+++ b/Monal/Classes/MLButtonCell.m
@@ -0,0 +1,24 @@
+//
+// MLButtonCell.m
+// Monal
+//
+// Created by Anurodh Pokharel on 4/10/15.
+// Copyright (c) 2015 Monal.im. All rights reserved.
+//
+
+#import "MLButtonCell.h"
+
+@implementation MLButtonCell
+
+- (void)awakeFromNib {
+ [super awakeFromNib];
+ // Initialization code
+}
+
+- (void)setSelected:(BOOL)selected animated:(BOOL)animated {
+ [super setSelected:selected animated:animated];
+
+ // Configure the view for the selected state
+}
+
+@end
diff --git a/Monal/Classes/MLButtonCell.xib b/Monal/Classes/MLButtonCell.xib
new file mode 100644
index 0000000..bd261d2
--- /dev/null
+++ b/Monal/Classes/MLButtonCell.xib
@@ -0,0 +1,44 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Monal/Classes/MLCall.h b/Monal/Classes/MLCall.h
new file mode 100644
index 0000000..24cad34
--- /dev/null
+++ b/Monal/Classes/MLCall.h
@@ -0,0 +1,100 @@
+//
+// MLCall.h
+// monalxmpp
+//
+// Created by Thilo Molitor on 30.12.22.
+// Copyright © 2022 monal-im.org. All rights reserved.
+//
+
+#ifndef MLCall_h
+#define MLCall_h
+
+#import
+
+NS_ASSUME_NONNULL_BEGIN
+
+@class WebRTCClient;
+@protocol RTCVideoRenderer;
+@class CXAnswerCallAction;
+@class CXEndCallAction;
+@class xmpp;
+@class MLVoIPProcessor;
+@class MLContact;
+
+
+typedef NS_ENUM(NSUInteger, MLCallType) {
+ MLCallTypeAudio,
+ MLCallTypeVideo,
+};
+
+typedef NS_ENUM(NSUInteger, MLCallDirection) {
+ MLCallDirectionIncoming,
+ MLCallDirectionOutgoing,
+};
+
+typedef NS_ENUM(NSUInteger, MLCallState) {
+ MLCallStateUnknown,
+ MLCallStateDiscovering,
+ MLCallStateRinging,
+ MLCallStateConnecting,
+ MLCallStateReconnecting,
+ MLCallStateConnected,
+ MLCallStateFinished,
+};
+
+typedef NS_ENUM(NSUInteger, MLCallFinishReason) {
+ MLCallFinishReasonUnknown, //dummy default value
+ MLCallFinishReasonNormal, //used for a call answered and finished locally (call direction etc. don't matter here)
+ MLCallFinishReasonConnectivityError, //used for a call accepted but not connected (call direction etc. don't matter here)
+ MLCallFinishReasonSecurityError, //used for a call that could not be encrypted using OMEMO
+ MLCallFinishReasonUnanswered, //used for a call retracted remotely (always remote party)
+ MLCallFinishReasonAnsweredElsewhere, //used for a call answered and finished remotely (own account OR remote party)
+ MLCallFinishReasonRetracted, //used for a call retracted locally (always own acount)
+ MLCallFinishReasonRejected, //used for a call rejected remotely (own account OR remote party)
+ MLCallFinishReasonDeclined, //used for a call rejected locally (always own account)
+ MLCallFinishReasonError, //used for a call error
+};
+
+typedef NS_ENUM(NSUInteger, MLCallEncryptionState) {
+ MLCallEncryptionStateUnknown,
+ MLCallEncryptionStateClear,
+ MLCallEncryptionStateToFU,
+ MLCallEncryptionStateTrusted,
+};
+
+@interface MLCall : NSObject
+@property (strong, readonly) NSString* description;
+
+@property (nonatomic, strong, readonly) NSUUID* uuid;
+@property (nonatomic, strong, readonly) NSString* jmiid;
+@property (nonatomic, strong, readonly) MLContact* contact;
+@property (nonatomic, readonly) MLCallType callType;
+@property (nonatomic, readonly) MLCallDirection direction;
+@property (nonatomic, readonly) MLCallEncryptionState encryptionState;
+@property (nonatomic, readonly) MLCallState state;
+@property (nonatomic, readonly) MLCallFinishReason finishReason;
+@property (nonatomic, readonly) uint32_t durationTime;
+@property (nonatomic, readonly) BOOL wasConnectedOnce;
+@property (nonatomic, assign) BOOL muted;
+@property (nonatomic, assign) BOOL speaker;
+
++(instancetype) makeDummyCall:(int) type;
+-(void) end;
+
+//these will not use the correct RTCVideoRenderer protocol like in the implementation because the forward declaration of
+//RTCVideoRenderer will not be visible to swift until we have swift 5.9 (feature flag ImportObjcForwardDeclarations) or swift 6.0 support
+//see https://github.com/apple/swift-evolution/blob/main/proposals/0384-importing-forward-declared-objc-interfaces-and-protocols.md
+-(void) startCaptureLocalVideoWithRenderer:(id) renderer andCameraPosition:(AVCaptureDevicePosition) position;
+-(void) stopCaptureLocalVideo;
+-(void) renderRemoteVideoWithRenderer:(id) renderer;
+-(void) hideVideo;
+-(void) showVideo;
+
+-(BOOL) isEqualToContact:(MLContact*) contact;
+-(BOOL) isEqualToCall:(MLCall*) call;
+-(BOOL) isEqual:(id _Nullable) object;
+-(NSUInteger) hash;
+@end
+
+NS_ASSUME_NONNULL_END
+#endif /* MLCall_h */
diff --git a/Monal/Classes/MLCall.m b/Monal/Classes/MLCall.m
new file mode 100644
index 0000000..c129dd6
--- /dev/null
+++ b/Monal/Classes/MLCall.m
@@ -0,0 +1,1797 @@
+//
+// MLCall.m
+// monalxmpp
+//
+// Created by admin on 30.12.22.
+// Copyright © 2022 monal-im.org. All rights reserved.
+//
+
+#import
+#import "MLConstants.h"
+#import "Monal-Swift.h"
+#import "HelperTools.h"
+#import "XMPPIQ.h"
+#import "XMPPMessage.h"
+#import "xmpp.h"
+#import "MLXMPPManager.h"
+#import "MLVoIPProcessor.h"
+#import "MLCall.h"
+#import "MonalAppDelegate.h"
+#import "MLOMEMO.h"
+
+@import CallKit;
+@import WebRTC;
+
+//this is our private interface only shared with MLVoIPProcessor
+@interface MLCall()
+{
+ //these are not synthesized automatically because we have getters and setters
+ MLXMLNode* _jmiProceed;
+ CXAnswerCallAction* _providerAnswerAction;
+ WebRTCClient* _webRTCClient;
+ BOOL _muted;
+ BOOL _speaker;
+ BOOL _isConnected;
+ AVAudioSession* _audioSession;
+}
+@property (nonatomic, strong) NSUUID* uuid;
+@property (nonatomic, strong) NSString* jmiid;
+@property (nonatomic, strong) MLContact* contact;
+@property (nonatomic) MLCallType callType;
+@property (nonatomic) MLCallDirection direction;
+@property (nonatomic) MLCallEncryptionState encryptionState;
+
+@property (nonatomic, strong) MLXMLNode* _Nullable jmiPropose;
+@property (nonatomic, strong) MLXMLNode* _Nullable jmiProceed;
+@property (nonatomic, strong) NSString* _Nullable fullRemoteJid;
+@property (nonatomic, strong) WebRTCClient* _Nullable webRTCClient;
+@property (nonatomic, strong) CXAnswerCallAction* _Nullable providerAnswerAction;
+@property (nonatomic, assign) BOOL isConnected;
+@property (nonatomic, assign) BOOL wasConnectedOnce;
+@property (nonatomic, assign) BOOL isReconnecting;
+@property (nonatomic, assign) BOOL isFinished;
+@property (nonatomic, assign) BOOL tieBreak;
+@property (nonatomic, strong) AVAudioSession* _Nullable audioSession;
+@property (nonatomic, assign) MLCallFinishReason finishReason;
+@property (nonatomic, assign) uint32_t durationTime;
+@property (nonatomic, strong) NSTimer* _Nullable callDurationTimer;
+@property (nonatomic, strong) monal_void_block_t _Nullable cancelDiscoveringTimeout;
+@property (nonatomic, strong) monal_void_block_t _Nullable cancelRingingTimeout;
+@property (nonatomic, strong) monal_void_block_t _Nullable cancelConnectingTimeout;
+@property (nonatomic, strong) monal_void_block_t _Nullable cancelWaitUntilIceRestart;
+@property (nonatomic, strong) MLXMLNode* localSDP;
+@property (nonatomic, strong) MLXMLNode* remoteSDP;
+@property (nonatomic, strong) NSNumber* remoteOmemoDeviceId;
+@property (nonatomic, strong) NSObject* candidateQueueLock;
+@property (nonatomic, strong) NSMutableArray* incomingCandidateQueue;
+@property (nonatomic, strong) NSMutableArray* outgoingCandidateQueue;
+
+@property (nonatomic, readonly) xmpp* account;
+@property (nonatomic, strong) MLVoIPProcessor* voipProcessor;
+@end
+
+//this is private and only shared to this class
+@interface MLVoIPProcessor()
+@property (nonatomic, strong) CXCallController* _Nullable callController;
+@property (nonatomic, strong) CXProvider* _Nullable cxProvider;
+-(void) removeCall:(MLCall*) call;
+-(void) initWebRTCForPendingCall:(MLCall*) call;
+-(void) handleIncomingJMIStanza:(MLXMLNode*) messageNode onAccount:(xmpp*) account;
+@end
+
+@implementation MLCall
+
++(instancetype) makeDummyCall:(int) type
+{
+ NSUUID* uuid = [NSUUID UUID];
+ return [[self alloc] initWithUUID:uuid jmiid:uuid.UUIDString contact:[MLContact makeDummyContact:type] callType:MLCallTypeAudio andDirection:MLCallDirectionOutgoing];
+}
+
+-(instancetype) initWithUUID:(NSUUID*) uuid jmiid:(NSString*) jmiid contact:(MLContact*) contact callType:(MLCallType) callType andDirection:(MLCallDirection) direction
+{
+ self = [super init];
+ MLAssert(uuid != nil, @"Call UUIDs must not be nil!");
+ MLAssert(jmiid != nil, @"Call jmiids must not be nil!");
+ self.uuid = uuid;
+ self.jmiid = jmiid;
+ self.contact = contact;
+ self.callType = callType;
+ self.direction = direction;
+ self.encryptionState = MLCallEncryptionStateUnknown;
+ self.isConnected = NO;
+ self.wasConnectedOnce = NO;
+ self.isReconnecting = NO;
+ self.durationTime = 0;
+ self.isFinished = NO;
+ self.finishReason = MLCallFinishReasonUnknown;
+ self.cancelDiscoveringTimeout = nil;
+ self.cancelRingingTimeout = nil;
+ self.cancelConnectingTimeout = nil;
+ self.localSDP = nil;
+ self.remoteSDP = nil;
+ self.remoteOmemoDeviceId = nil;
+ self.candidateQueueLock = [NSObject new];
+ self.incomingCandidateQueue = [NSMutableArray new];
+ self.outgoingCandidateQueue = [NSMutableArray new];
+
+ [HelperTools dispatchAsync:NO reentrantOnQueue:dispatch_get_main_queue() withBlock:^{
+ MonalAppDelegate* appDelegate = (MonalAppDelegate*)[[UIApplication sharedApplication] delegate];
+ MLAssert(appDelegate.voipProcessor != nil, @"appDelegate.voipProcessor should never be nil!");
+ self.voipProcessor = appDelegate.voipProcessor;
+ }];
+
+ [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(processIncomingSDP:) name:kMonalIncomingSDP object:nil];
+ [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(processIncomingICECandidate:) name:kMonalIncomingICECandidate object:nil];
+ [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(handleAudioRouteChangeNotification:) name:AVAudioSessionRouteChangeNotification object:nil];
+ //[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(handleConnectivityChange:) name:kMonalConnectivityChange object:nil];
+
+ return self;
+}
+
+-(void) dealloc
+{
+ DDLogInfo(@"Called dealloc: %@", self);
+ [self.callDurationTimer invalidate];
+ [[NSNotificationCenter defaultCenter] removeObserver:self];
+}
+
+#pragma mark - public interface
+
+-(void) startCaptureLocalVideoWithRenderer:(id) renderer andCameraPosition:(AVCaptureDevicePosition) position
+{
+ MLAssert(self.callType == MLCallTypeVideo, @"startCaptureLocalVideoWithRenderer:andCameraPosition: can only be called for video calls!");
+ [self.webRTCClient startCaptureLocalVideoWithRenderer:renderer andCameraPosition:position];
+}
+
+-(void) stopCaptureLocalVideo
+{
+ MLAssert(self.callType == MLCallTypeVideo, @"stopCaptureLocalVideo: can only be called for video calls!");
+ [self.webRTCClient stopCaptureLocalVideo];
+}
+
+-(void) renderRemoteVideoWithRenderer:(id) renderer
+{
+ MLAssert(self.callType == MLCallTypeVideo, @"renderRemoteVideoWithRenderer: can only be called for video calls!");
+ [self.webRTCClient renderRemoteVideoTo:renderer];
+}
+
+-(void) hideVideo
+{
+ MLAssert(self.callType == MLCallTypeVideo, @"hideVideo: can only be called for video calls!");
+ [self.webRTCClient hideVideo];
+}
+
+-(void) showVideo
+{
+ MLAssert(self.callType == MLCallTypeVideo, @"showVideo: can only be called for video calls!");
+ [self.webRTCClient showVideo];
+}
+
+-(void) end
+{
+ if(self.isFinished)
+ {
+ DDLogInfo(@"Not requesting end call action: call already in finished state...");
+ return;
+ }
+ DDLogVerbose(@"Requesting end call transaction for %@", [self short]);
+ CXEndCallAction* endCallAction = [[CXEndCallAction alloc] initWithCallUUID:self.uuid];
+ CXTransaction* transaction = [[CXTransaction alloc] initWithAction:endCallAction];
+ [self.voipProcessor.callController requestTransaction:transaction completion:^(NSError* error) {
+ if(error != nil)
+ {
+ //try to do this "manually" without looping through callkit
+ DDLogError(@"Error requesting end call transaction: %@", error);
+ [self internalHandleEndCallActionWithReason:MLCallFinishReasonUnknown];
+ return;
+ }
+ else
+ DDLogInfo(@"Successfully created end call transaction for CallKit..");
+ }];
+}
+
+-(void) delayedEnd:(double) delay withDisconnectedState:(BOOL) disconnected
+{
+ createTimer(delay, (^{
+ //isConnected = NO will result in MLCallFinishReasonConnectivityError if wasConnectedOnce == YES
+ if(disconnected)
+ self.isConnected = NO;
+ [self end];
+ }));
+}
+
+-(void) setMuted:(BOOL) muted
+{
+ @synchronized(self) {
+ if(self.webRTCClient == nil || self.audioSession == nil)
+ return;
+ _muted = muted;
+ if(_muted)
+ [self.webRTCClient muteAudio];
+ else
+ [self.webRTCClient unmuteAudio];
+ }
+}
+
+-(BOOL) muted
+{
+ @synchronized(self) {
+ return _muted;
+ }
+}
+
+-(void) setSpeaker:(BOOL) speaker
+{
+ @synchronized(self) {
+ if(self.webRTCClient == nil || self.audioSession == nil)
+ return;
+ if(_speaker == speaker)
+ return;
+ _speaker = speaker;
+ if(_speaker)
+ [self.webRTCClient speakerOn];
+ else
+ [self.webRTCClient speakerOff];
+ }
+}
+
+-(BOOL) speaker
+{
+ @synchronized(self) {
+ return _speaker;
+ }
+}
+
+-(MLCallState) state
+{
+ @synchronized(self) {
+ if(self.direction == MLCallDirectionOutgoing)
+ {
+ if(self.isFinished)
+ return MLCallStateFinished;
+ if(self.isConnected && self.webRTCClient != nil && self.audioSession != nil)
+ return MLCallStateConnected;
+ if(self.jmiProceed != nil && self.isReconnecting)
+ return MLCallStateReconnecting;
+ if(self.jmiProceed != nil)
+ return MLCallStateConnecting;
+ if(self.jmiProceed == nil && self.cancelRingingTimeout != nil)
+ return MLCallStateRinging;
+ if(self.jmiProceed == nil && self.cancelDiscoveringTimeout != nil)
+ return MLCallStateDiscovering;
+ return MLCallStateUnknown;
+ }
+ else
+ {
+ if(self.isFinished)
+ return MLCallStateFinished;
+ if(self.isConnected && self.webRTCClient != nil && self.audioSession != nil)
+ return MLCallStateConnected;
+ if(self.providerAnswerAction != nil && self.isReconnecting)
+ return MLCallStateReconnecting;
+ if(self.providerAnswerAction != nil)
+ return MLCallStateConnecting;
+ if(self.providerAnswerAction == nil)
+ return MLCallStateRinging;
+ return MLCallStateUnknown;
+ }
+ }
+}
+
++(NSSet*) keyPathsForValuesAffectingState
+{
+ return [NSSet setWithObjects:@"direction", @"isConnected", @"jmiProceed", @"webRTCClient", @"providerAnswerAction", @"audioSession", @"isFinished", @"cancelDiscoveringTimeout", @"cancelRingingTimeout", @"cancelConnectingTimeout", @"isReconnecting", nil];
+}
+
+#pragma mark - internals
+
+-(xmpp*) account
+{
+ @synchronized(self) {
+ xmpp* account = self.contact.account;
+ MLAssert(account != nil, @"Account of call must be listed in MLXMPPManager connected accounts!", (@{
+ @"contact": nilWrapper(self.contact),
+ @"call": nilWrapper(self),
+ }));
+ return account;
+ }
+}
+-(void) startCallDuartionTimer
+{
+ //the timer needs a thread with runloop, see https://stackoverflow.com/a/18098396/3528174
+ dispatch_async(dispatch_get_main_queue(), ^{
+ if(self.cancelDiscoveringTimeout != nil)
+ self.cancelDiscoveringTimeout();
+ self.cancelDiscoveringTimeout = nil;
+ if(self.cancelRingingTimeout != nil)
+ self.cancelRingingTimeout();
+ self.cancelRingingTimeout = nil;
+ if(self.cancelConnectingTimeout != nil)
+ self.cancelConnectingTimeout();
+ self.cancelConnectingTimeout = nil;
+
+ //don't restart our timer if we just reconnected
+ if(self.isReconnecting)
+ return;
+ if(self.callDurationTimer != nil)
+ [self.callDurationTimer invalidate];
+ DDLogInfo(@"%@: Starting call duration timer...", [self short]);
+ self.callDurationTimer = [NSTimer scheduledTimerWithTimeInterval:1.0 repeats:YES block:^(NSTimer* timer) {
+ DDLogVerbose(@"%@:Call duration timer triggered: %d", [self short], self.durationTime);
+ if(self.state == MLCallStateFinished)
+ {
+ DDLogInfo(@"%@: Stopping call duration timer...", [self short]);
+ [timer invalidate];
+ self.callDurationTimer = nil;
+ }
+ else
+ self.durationTime++;
+ }];
+ });
+}
+
+-(void) setJmiProceed:(MLXMLNode*) jmiProceed
+{
+ @synchronized(self) {
+ _jmiProceed = jmiProceed;
+ if(self.direction == MLCallDirectionOutgoing)
+ {
+ //see https://gist.github.com/iNPUTmice/aa4fc0aeea6ce5fb0e0fe04baca842cd
+ self.remoteOmemoDeviceId = [jmiProceed findFirst:@"{urn:xmpp:jingle-message:0}proceed/{http://gultsch.de/xmpp/drafts/omemo/dlts-srtp-verification}device@id|uint"];
+ DDLogInfo(@"Proceed set remote omemo deviceid to: %@", self.remoteOmemoDeviceId);
+ }
+ if(self.direction == MLCallDirectionOutgoing && self.webRTCClient != nil)
+ [self establishOutgoingConnection];
+ }
+}
+-(MLXMLNode*) jmiProceed
+{
+ @synchronized(self) {
+ return _jmiProceed;
+ }
+}
+
+-(void) setProviderAnswerAction:(CXAnswerCallAction*) action
+{
+ @synchronized(self) {
+ _providerAnswerAction = action;
+ if(self.direction == MLCallDirectionIncoming && self.webRTCClient != nil)
+ [self establishIncomingConnection];
+ }
+}
+-(CXAnswerCallAction*) providerAnswerAction
+{
+ @synchronized(self) {
+ return _providerAnswerAction;
+ }
+}
+
+-(void) setWebRTCClient:(WebRTCClient*) webRTCClient
+{
+ @synchronized(self) {
+ _webRTCClient = webRTCClient;
+ if(self.webRTCClient != nil && self.direction == MLCallDirectionIncoming && self.providerAnswerAction != nil)
+ [self establishIncomingConnection];
+ if(self.webRTCClient != nil && self.direction == MLCallDirectionOutgoing && self.jmiProceed != nil)
+ [self establishOutgoingConnection];
+ }
+}
+-(WebRTCClient*) webRTCClient
+{
+ @synchronized(self) {
+ return _webRTCClient;
+ }
+}
+
+-(void) setIsConnected:(BOOL) isConnected
+{
+ @synchronized(self) {
+ BOOL oldValue = _isConnected;
+ _isConnected = isConnected;
+ if(isConnected)
+ self.wasConnectedOnce = YES;
+
+ //if switching to connected state: check if we need to activate the already reported audio session now
+ if(oldValue == NO && self.isConnected == YES && self.audioSession != nil)
+ [self didActivateAudioSession:self.audioSession];
+
+ //start timer once we are fully connected
+ if(self.isConnected && self.audioSession != nil)
+ [self startCallDuartionTimer];
+ }
+
+#ifdef IS_ALPHA
+#if TARGET_OS_MACCATALYST
+ //set audio session to default one
+ self.audioSession = [AVAudioSession sharedInstance];
+#endif
+#endif
+}
+-(BOOL) isConnected
+{
+ @synchronized(self) {
+ return _isConnected;
+ }
+}
+
+-(void) setAudioSession:(AVAudioSession*) audioSession
+{
+ @synchronized(self) {
+ if(audioSession == _audioSession)
+ {
+ DDLogWarn(@"Trying to activate same audio session a second time, ignoring...");
+ return;
+ }
+ BOOL assertActivated = YES;
+#ifdef IS_ALPHA
+#if TARGET_OS_MACCATALYST
+ assertActivated = NO;
+#endif
+#endif
+ if(assertActivated && audioSession != nil)
+ MLAssert(_audioSession == nil, @"Audio session should never be activated without deactivating old audio session first!", (@{
+ @"oldAudioSession": nilWrapper(_audioSession),
+ @"newAudioSession": nilWrapper(audioSession),
+ @"call": self,
+ }));
+ AVAudioSession* oldSession = _audioSession;
+ _audioSession = audioSession;
+
+ //do nothing if not yet connected
+ if(self.isConnected == YES && oldSession == nil && self.audioSession != nil)
+ [self didActivateAudioSession:self.audioSession];
+
+ if(self.audioSession == nil && oldSession != nil)
+ [self didDeactivateAudioSession:oldSession];
+
+ //start timer once we are fully connected
+ if(self.isConnected && self.audioSession != nil)
+ [self startCallDuartionTimer];
+ }
+}
+-(AVAudioSession*) audioSession
+{
+ @synchronized(self) {
+ return _audioSession;
+ }
+}
+
+-(void) didActivateAudioSession:(AVAudioSession*) audioSession
+{
+ NSError* error = nil;
+ DDLogInfo(@"Activating audio session now: %@", audioSession);
+ [[RTCAudioSession sharedInstance] lockForConfiguration];
+ NSUInteger options = 0;
+ options |= AVAudioSessionCategoryOptionAllowBluetooth;
+ options |= AVAudioSessionCategoryOptionAllowBluetoothA2DP;
+ options |= AVAudioSessionCategoryOptionInterruptSpokenAudioAndMixWithOthers;
+ options |= AVAudioSessionCategoryOptionAllowAirPlay;
+ [[RTCAudioSession sharedInstance] setCategory:AVAudioSessionCategoryPlayAndRecord withOptions:options error:&error];
+ if(error != nil)
+ DDLogError(@"Failed to configure AVAudioSession category: %@", error);
+ [[RTCAudioSession sharedInstance] setMode:AVAudioSessionModeVoiceChat error:&error];
+ if(error != nil)
+ DDLogError(@"Failed to configure AVAudioSession mode: %@", error);
+ [[RTCAudioSession sharedInstance] audioSessionDidActivate:audioSession];
+ [[RTCAudioSession sharedInstance] setIsAudioEnabled:YES];
+ [[RTCAudioSession sharedInstance] unlockForConfiguration];
+}
+
+-(void) didDeactivateAudioSession:(AVAudioSession*) audioSession
+{
+ DDLogInfo(@"Deactivating audio session now: %@", audioSession);
+ [[RTCAudioSession sharedInstance] lockForConfiguration];
+ [[RTCAudioSession sharedInstance] audioSessionDidDeactivate:audioSession];
+ [[RTCAudioSession sharedInstance] setIsAudioEnabled:NO];
+ [[RTCAudioSession sharedInstance] unlockForConfiguration];
+}
+
+-(void) reportRinging
+{
+ DDLogDebug(@"%@ was reported as ringing...", [self short]);
+ [self createRingingTimeoutTimer];
+}
+
+-(void) migrateTo:(MLCall*) otherCall
+{
+ //send jmi finish with migration before chaning all ids etc.
+ DDLogDebug(@"Migrating call using JMI finish: %@", self);
+ XMPPMessage* jmiNode = [[XMPPMessage alloc] initWithType:kMessageChatType to:self.fullRemoteJid];
+ [jmiNode addChildNode:[[MLXMLNode alloc] initWithElement:@"finish" andNamespace:@"urn:xmpp:jingle-message:0" withAttributes:@{
+ @"id": self.jmiid,
+ } andChildren:@[
+ [[MLXMLNode alloc] initWithElement:@"reason" andNamespace:@"urn:xmpp:jingle:1" withAttributes:@{} andChildren:@[
+ [[MLXMLNode alloc] initWithElement:@"expired"]
+ ] andData:nil],
+ [[MLXMLNode alloc] initWithElement:@"migrated" andNamespace:@"urn:xmpp:jingle:1" withAttributes:@{
+ @"to": otherCall.jmiid,
+ } andChildren:@[] andData:nil]
+ ] andData:nil]];
+ [jmiNode setStoreHint];
+ [self.account send:jmiNode];
+
+ @synchronized(self) {
+ DDLogDebug(@"%@: Preparing this call for new webrtc connection...", [self short]);
+ self.jmiid = otherCall.jmiid;
+ self.fullRemoteJid = otherCall.fullRemoteJid;
+ self.callType = otherCall.callType;
+ self.isConnected = NO;
+ self.isReconnecting = YES;
+ self.finishReason = MLCallFinishReasonUnknown;
+ self.direction = otherCall.direction;
+ self.jmiPropose = otherCall.jmiPropose;
+ self.jmiProceed = nil;
+ [self.callDurationTimer invalidate];
+ self.callDurationTimer = nil;
+ self.localSDP = otherCall.localSDP; //should be nil
+ self.remoteSDP = otherCall.remoteSDP; //should be nil
+ self.incomingCandidateQueue = otherCall.incomingCandidateQueue; //should be empty
+ self.outgoingCandidateQueue = otherCall.outgoingCandidateQueue; //should be empty
+ self.remoteOmemoDeviceId = otherCall.remoteOmemoDeviceId; //depends on jmiProceed and should be empty
+ self.encryptionState = MLCallEncryptionStateUnknown; //depends on callstate >= connecting
+ otherCall = nil;
+
+ DDLogDebug(@"%@: Stopping all running timers...", [self short]);
+ if(self.cancelDiscoveringTimeout != nil)
+ self.cancelDiscoveringTimeout();
+ self.cancelDiscoveringTimeout = nil;
+ if(self.cancelRingingTimeout != nil)
+ self.cancelRingingTimeout();
+ self.cancelRingingTimeout = nil;
+ if(self.cancelConnectingTimeout != nil)
+ self.cancelConnectingTimeout();
+ self.cancelConnectingTimeout = nil;
+
+ if(self.webRTCClient != nil)
+ {
+ DDLogDebug(@"%@: Closing old webrtc connection...", [self short]);
+ __block WebRTCClient* client = self.webRTCClient;
+ self.webRTCClient = nil;
+ //do this async to not run into a deadlock with the signalling thread
+ dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
+ [client.peerConnection close];
+ client = nil;
+
+ //report this migrated call as ringing
+ [self sendJmiRinging];
+
+ //now fake a cxprovider answer action (we do auto-answer this call, but ios does not even know we switched the underlying webrtc connection)
+ DDLogVerbose(@"%@: Faking CXAnswerCallAction...", [self short]);
+ self.providerAnswerAction = [[CXAnswerCallAction alloc] initWithCallUUID:self.uuid];
+
+ DDLogVerbose(@"%@: Initializing webrtc for our migrated call...", [self short]);
+ [self.voipProcessor initWebRTCForPendingCall:self];
+
+ DDLogDebug(@"%@: Migration done, waiting for new webrtc connection...", [self short]);
+ });
+ }
+ else
+ DDLogDebug(@"%@: No old webrtc connection to close...", [self short]);
+ }
+}
+
+-(void) handleEndCallActionWithReason:(MLCallFinishReason) reason
+{
+ @synchronized(self) {
+ [self internalHandleEndCallActionWithReason:reason];
+ [self internalUpdateCallKitState];
+ }
+}
+
+-(void) internalHandleEndCallActionWithReason:(MLCallFinishReason) reason
+{
+ @synchronized(self) {
+ //stop all running timers
+ if(self.cancelDiscoveringTimeout != nil)
+ self.cancelDiscoveringTimeout();
+ self.cancelDiscoveringTimeout = nil;
+ if(self.cancelRingingTimeout != nil)
+ self.cancelRingingTimeout();
+ self.cancelRingingTimeout = nil;
+ if(self.cancelConnectingTimeout != nil)
+ self.cancelConnectingTimeout();
+ self.cancelConnectingTimeout = nil;
+
+ //end webrtc call if already established or in the process of establishing
+ if(self.webRTCClient != nil)
+ {
+ WebRTCClient* client = self.webRTCClient;
+ self.webRTCClient = nil; //this will prevent the new webrtc state from being handled
+ //do this async to not run into a deadlock with the signalling thread
+ dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
+ [client.peerConnection close];
+ });
+ }
+
+ //update state (this will automatically stop the call duration timer)
+ self.finishReason = reason;
+ self.isConnected = NO;
+ self.isFinished = YES;
+
+ //remove this call from pending calls
+ [self.voipProcessor removeCall:self];
+ }
+}
+
+-(void) internalUpdateCallKitState
+{
+ @synchronized(self) {
+ //the CXEndCallAction means either the call was rejected (if not yet answered) or it was terminated normally (if the call was accepted)
+ //see https://developer.apple.com/documentation/callkit/cxcallendedreason?language=objc for end reasons
+ if(self.direction == MLCallDirectionIncoming)
+ {
+ [self.providerAnswerAction fail]; //fail will do nothing if already fulfilled or nil
+ if(self.jmiProceed == nil)
+ {
+ if(self.finishReason == MLCallFinishReasonAnsweredElsewhere)
+ [self.voipProcessor.cxProvider reportCallWithUUID:self.uuid endedAtDate:nil reason:CXCallEndedReasonAnsweredElsewhere];
+ else if(self.finishReason == MLCallFinishReasonUnanswered)
+ [self.voipProcessor.cxProvider reportCallWithUUID:self.uuid endedAtDate:nil reason:CXCallEndedReasonRemoteEnded];
+ else if(self.finishReason == MLCallFinishReasonRejected)
+ [self.voipProcessor.cxProvider reportCallWithUUID:self.uuid endedAtDate:nil reason:CXCallEndedReasonDeclinedElsewhere];
+ else if(self.finishReason == MLCallFinishReasonDeclined)
+ {
+ if(self.tieBreak)
+ [self sendJmiRejectWithTieBreak];
+ else
+ [self sendJmiReject];
+ }
+ else if(self.finishReason == MLCallFinishReasonError)
+ {
+ [self sendJmiFinishWithReason:@"application-error"];
+ [self.voipProcessor.cxProvider reportCallWithUUID:self.uuid endedAtDate:nil reason:CXCallEndedReasonFailed];
+ }
+ else
+ {
+ DDLogError(@"Unexpected finish reason: %@", (@{@"call": self, @"finishReason": @(self.finishReason)}));
+ [self.voipProcessor.cxProvider reportCallWithUUID:self.uuid endedAtDate:nil reason:CXCallEndedReasonFailed];
+ }
+ }
+ else
+ {
+ if(self.finishReason == MLCallFinishReasonNormal)
+ {
+ [self sendJmiFinishWithReason:@"success"];
+ //this is not needed because this case is always looped through cxprovider endCallAction
+ //[self.voipProcessor.cxProvider reportCallWithUUID:self.uuid endedAtDate:nil reason:CXCallEndedReasonRemoteEnded];
+ }
+ else if(self.finishReason == MLCallFinishReasonConnectivityError)
+ {
+ [self sendJmiFinishWithReason:@"connectivity-error"];
+ [self.voipProcessor.cxProvider reportCallWithUUID:self.uuid endedAtDate:nil reason:CXCallEndedReasonFailed];
+ }
+ else if(self.finishReason == MLCallFinishReasonSecurityError)
+ {
+ [self sendJmiFinishWithReason:@"security-error"];
+ [self.voipProcessor.cxProvider reportCallWithUUID:self.uuid endedAtDate:nil reason:CXCallEndedReasonFailed];
+ }
+ else if(self.finishReason == MLCallFinishReasonError)
+ {
+ [self sendJmiFinishWithReason:@"application-error"];
+ [self.voipProcessor.cxProvider reportCallWithUUID:self.uuid endedAtDate:nil reason:CXCallEndedReasonFailed];
+ }
+ else
+ {
+ DDLogError(@"Unexpected finish reason: %@", (@{@"call": self, @"finishReason": @(self.finishReason)}));
+ [self.voipProcessor.cxProvider reportCallWithUUID:self.uuid endedAtDate:nil reason:CXCallEndedReasonFailed];
+ }
+ }
+ }
+ else
+ {
+ if(self.jmiPropose != nil)
+ {
+ if(self.jmiProceed == nil)
+ {
+ if(self.finishReason == MLCallFinishReasonRejected)
+ [self.voipProcessor.cxProvider reportCallWithUUID:self.uuid endedAtDate:nil reason:CXCallEndedReasonRemoteEnded];
+ else if(self.finishReason == MLCallFinishReasonAnsweredElsewhere)
+ [self.voipProcessor.cxProvider reportCallWithUUID:self.uuid endedAtDate:nil reason:CXCallEndedReasonAnsweredElsewhere];
+ else if(self.finishReason == MLCallFinishReasonRetracted)
+ {
+ if(self.tieBreak)
+ [self sendJmiRetractWithTieBreak];
+ else
+ [self sendJmiRetract];
+ }
+ else if(self.finishReason == MLCallFinishReasonError)
+ {
+ [self sendJmiFinishWithReason:@"application-error"];
+ [self.voipProcessor.cxProvider reportCallWithUUID:self.uuid endedAtDate:nil reason:CXCallEndedReasonFailed];
+ }
+ else
+ {
+ DDLogError(@"Unexpected finish reason: %@", (@{@"call": self, @"finishReason": @(self.finishReason)}));
+ [self.voipProcessor.cxProvider reportCallWithUUID:self.uuid endedAtDate:nil reason:CXCallEndedReasonFailed];
+ }
+ }
+ else
+ {
+ if(self.finishReason == MLCallFinishReasonNormal)
+ {
+ [self sendJmiFinishWithReason:@"success"];
+ //this is not needed because this case is always looped through cxprovider endCallAction
+ //[self.voipProcessor.cxProvider reportCallWithUUID:self.uuid endedAtDate:nil reason:CXCallEndedReasonRemoteEnded];
+ }
+ else if(self.finishReason == MLCallFinishReasonConnectivityError)
+ {
+ [self sendJmiFinishWithReason:@"connectivity-error"];
+ [self.voipProcessor.cxProvider reportCallWithUUID:self.uuid endedAtDate:nil reason:CXCallEndedReasonFailed];
+ }
+ else if(self.finishReason == MLCallFinishReasonSecurityError)
+ {
+ [self sendJmiFinishWithReason:@"security-error"];
+ [self.voipProcessor.cxProvider reportCallWithUUID:self.uuid endedAtDate:nil reason:CXCallEndedReasonFailed];
+ }
+ else if(self.finishReason == MLCallFinishReasonError)
+ {
+ [self sendJmiFinishWithReason:@"application-error"];
+ [self.voipProcessor.cxProvider reportCallWithUUID:self.uuid endedAtDate:nil reason:CXCallEndedReasonFailed];
+ }
+ else
+ {
+ DDLogError(@"Unexpected finish reason: %@", (@{@"call": self, @"finishReason": @(self.finishReason)}));
+ [self.voipProcessor.cxProvider reportCallWithUUID:self.uuid endedAtDate:nil reason:CXCallEndedReasonFailed];
+ }
+ }
+ }
+ else
+ {
+ //this case probably does never happen
+ //(the outgoing call transaction was started, but start call action not yet executed, and then the end call action arrives)
+ [self sendJmiFinishWithReason:@"connectivity-error"];
+ [self.voipProcessor.cxProvider reportCallWithUUID:self.uuid endedAtDate:nil reason:CXCallEndedReasonUnanswered];
+ self.finishReason = MLCallFinishReasonConnectivityError;
+ }
+ }
+ }
+}
+
+-(void) createConnectingTimeoutTimer
+{
+ if(self.cancelDiscoveringTimeout != nil)
+ self.cancelDiscoveringTimeout();
+ self.cancelDiscoveringTimeout = nil;
+ if(self.cancelRingingTimeout != nil)
+ self.cancelRingingTimeout();
+ self.cancelRingingTimeout = nil;
+ if(self.cancelConnectingTimeout != nil)
+ self.cancelConnectingTimeout();
+ self.cancelConnectingTimeout = nil;
+ self.cancelConnectingTimeout = createTimer(15.0, (^{
+ DDLogError(@"Failed to connect call, aborting!");
+ [self end];
+ }));
+}
+
+-(void) createReconnectingTimeoutTimer
+{
+ if(self.cancelDiscoveringTimeout != nil)
+ self.cancelDiscoveringTimeout();
+ self.cancelDiscoveringTimeout = nil;
+ if(self.cancelRingingTimeout != nil)
+ self.cancelRingingTimeout();
+ self.cancelRingingTimeout = nil;
+ if(self.cancelConnectingTimeout != nil)
+ self.cancelConnectingTimeout();
+ self.cancelConnectingTimeout = nil;
+ self.cancelConnectingTimeout = createTimer(45.0, (^{
+ DDLogError(@"Failed to connect call, aborting!");
+ [self end];
+ }));
+}
+
+-(void) createRingingTimeoutTimer
+{
+ if(self.cancelDiscoveringTimeout != nil)
+ self.cancelDiscoveringTimeout();
+ self.cancelDiscoveringTimeout = nil;
+ if(self.cancelRingingTimeout != nil)
+ self.cancelRingingTimeout();
+ self.cancelRingingTimeout = nil;
+ self.cancelRingingTimeout = createTimer(45.0, (^{
+ DDLogError(@"Call not answered in time, aborting!");
+ [self end];
+ }));
+}
+
+-(void) createDiscoveringTimeoutTimer
+{
+ if(self.cancelDiscoveringTimeout != nil)
+ self.cancelDiscoveringTimeout();
+ self.cancelDiscoveringTimeout = nil;
+ self.cancelDiscoveringTimeout = createTimer(30.0, (^{
+ DDLogError(@"Discovery not answered in time, aborting!");
+ [self end];
+ }));
+}
+
+-(void) establishIncomingConnection
+{
+ DDLogInfo(@"Now connecting incoming VoIP call: %@", self);
+ [self.webRTCClient configureAudioSession];
+ [self createConnectingTimeoutTimer];
+ //the remote (e.g. "initiator") will send a jingle "session-initiate" as soon as it receives our jmi proceed
+ [self sendJmiProceed];
+}
+
+-(void) establishOutgoingConnection
+{
+ DDLogInfo(@"Now connecting outgoing VoIP call: %@", self);
+ [self.webRTCClient configureAudioSession];
+ [self.voipProcessor.cxProvider reportOutgoingCallWithUUID:self.uuid startedConnectingAtDate:nil];
+ [self createConnectingTimeoutTimer];
+ [self offerSDP];
+}
+
+/*
+-(void) restartIce
+{
+ if(self.isReconnecting)
+ {
+ DDLogWarn(@"Not restarting ICE, already reconnecting!");
+ return;
+ }
+ DDLogInfo(@"Restarting ICE...");
+ @synchronized(self) {
+ self.isConnected = NO;
+ self.isReconnecting = YES;
+ [self.webRTCClient.peerConnection restartIce];
+
+ //we have to decide for a prefered direction because otherwise we'd get a webrtc error on incoming sdp:
+ //Failed to set remote offer sdp: Called in wrong state: have-local-offer
+ if(self.direction == MLCallDirectionOutgoing)
+ [self offerSDP];
+
+ //start connecting timeout if not already running (but leave it running if so, because we don't want to create endless reconnect loops
+ if(self.cancelConnectingTimeout == nil)
+ [self createReconnectingTimeoutTimer];
+ }
+}
+
+-(void) handleConnectivityChange:(NSNotification*) notification
+{
+ //only handle connectivity change if we switched to unreachable
+ if(self.wasConnectedOnce && self.isConnected && !self.isReconnecting && [notification.userInfo[@"reachable"] boolValue] == NO)
+ {
+ DDLogDebug(@"Connectivity changed, restarting ICE...");
+ //this will reconnect and use the (possibly still working) old connection until
+ //the new connection is usable, then transparently switch over to the new one
+ [self restartIce];
+ }
+ else
+ DDLogDebug(@"Not restarting ICE because of connectivity change: was never connected");
+}
+*/
+
+-(void) offerSDP
+{
+ //see https://webrtc.googlesource.com/src/+/refs/heads/main/sdk/objc/api/peerconnection/RTCSessionDescription.h
+ [self.webRTCClient offerWithCompletion:^(RTCSessionDescription* sdp) {
+ DDLogDebug(@"WebRTC reported local SDP '%@', sending to '%@': %@", [RTCSessionDescription stringForType:sdp.type], self.fullRemoteJid, sdp.sdp);
+
+ NSArray* children = [HelperTools sdp2xml:sdp.sdp withInitiator:YES];
+ if(children.count == 0)
+ {
+ DDLogError(@"Could not serialize local SDP to XML!");
+ [self handleEndCallActionWithReason:MLCallFinishReasonError];
+ return;
+ }
+
+ //we don't encrypt anything if encryption is not enabled for this contact or if the remote did not send us their deviceid
+ if(self.contact.isEncrypted && self.remoteOmemoDeviceId != nil && [self encryptFingerprintsInChildren:children])
+ {
+ //we are encrypted now (if the remote can't decrypt this or answers with a cleartext fingerprint, we throw a security error later on)
+ self.encryptionState = [self encryptionTypeForDeviceid:self.remoteOmemoDeviceId];
+ }
+ else
+ self.encryptionState = MLCallEncryptionStateClear;
+
+ XMPPIQ* sdpIQ = [[XMPPIQ alloc] initWithType:kiqSetType to:self.fullRemoteJid];
+ [sdpIQ addChildNode:[[MLXMLNode alloc] initWithElement:@"jingle" andNamespace:@"urn:xmpp:jingle:1" withAttributes:@{
+ @"action": @"session-initiate",
+ @"sid": self.jmiid,
+ } andChildren:children andData:nil]];
+ @synchronized(self.candidateQueueLock) {
+ self.localSDP = sdpIQ;
+ }
+ [self.account sendIq:sdpIQ withResponseHandler:^(XMPPIQ* result) {
+ DDLogDebug(@"Received SDP response for offer: %@", result);
+ } andErrorHandler:^(XMPPIQ* error) {
+ DDLogError(@"Got error for SDP offer: %@", error);
+ }];
+ }];
+}
+
+-(void) sendJmiPropose
+{
+ DDLogDebug(@"Proposing new call via JMI: %@", self);
+ NSMutableArray* descriptions = [NSMutableArray new];
+ [descriptions addObject:[[MLXMLNode alloc] initWithElement:@"description" andNamespace:@"urn:xmpp:jingle:apps:rtp:1" withAttributes:@{@"media": @"audio"} andChildren:@[] andData:nil]];
+ if(self.callType == MLCallTypeVideo)
+ [descriptions addObject:[[MLXMLNode alloc] initWithElement:@"description" andNamespace:@"urn:xmpp:jingle:apps:rtp:1" withAttributes:@{@"media": @"video"} andChildren:@[] andData:nil]];
+ XMPPMessage* jmiNode = [[XMPPMessage alloc] initToContact:self.contact];
+ [jmiNode addChildNode:[[MLXMLNode alloc] initWithElement:@"propose" andNamespace:@"urn:xmpp:jingle-message:0" withAttributes:@{
+ @"id": self.jmiid,
+ } andChildren:descriptions andData:nil]];
+ [jmiNode setStoreHint];
+ self.jmiPropose = jmiNode;
+ [self.account send:jmiNode];
+
+ //abort if no device responds with "ringing" in time
+ [self createDiscoveringTimeoutTimer];
+}
+
+-(void) sendJmiReject
+{
+ DDLogDebug(@"Rejecting via JMI: %@", self);
+ XMPPMessage* jmiNode = [[XMPPMessage alloc] initWithType:kMessageChatType to:self.fullRemoteJid];
+ [jmiNode addChildNode:[[MLXMLNode alloc] initWithElement:@"reject" andNamespace:@"urn:xmpp:jingle-message:0" withAttributes:@{
+ @"id": self.jmiid,
+ } andChildren:@[
+ [[MLXMLNode alloc] initWithElement:@"reason" andNamespace:@"urn:xmpp:jingle:1" withAttributes:@{} andChildren:@[
+ [[MLXMLNode alloc] initWithElement:@"busy"]
+ ] andData:nil]
+ ] andData:nil]];
+ [jmiNode setStoreHint];
+ [self.account send:jmiNode];
+}
+
+-(void) sendJmiRejectWithTieBreak
+{
+ DDLogDebug(@"Rejecting with tie-break via JMI: %@", self);
+ XMPPMessage* jmiNode = [[XMPPMessage alloc] initWithType:kMessageChatType to:self.fullRemoteJid];
+ [jmiNode addChildNode:[[MLXMLNode alloc] initWithElement:@"reject" andNamespace:@"urn:xmpp:jingle-message:0" withAttributes:@{
+ @"id": self.jmiid,
+ } andChildren:@[
+ [[MLXMLNode alloc] initWithElement:@"reason" andNamespace:@"urn:xmpp:jingle:1" withAttributes:@{} andChildren:@[
+ [[MLXMLNode alloc] initWithElement:@"expired"]
+ ] andData:nil],
+ [[MLXMLNode alloc] initWithElement:@"tie-break"]
+ ] andData:nil]];
+ [jmiNode setStoreHint];
+ [self.account send:jmiNode];
+}
+
+-(void) sendJmiRinging
+{
+ DDLogDebug(@"Ringing via JMI: %@", self);
+ XMPPMessage* jmiNode = [[XMPPMessage alloc] initWithType:kMessageChatType to:self.fullRemoteJid];
+ [jmiNode addChildNode:[[MLXMLNode alloc] initWithElement:@"ringing" andNamespace:@"urn:xmpp:jingle-message:0" withAttributes:@{
+ @"id": self.jmiid,
+ } andChildren:@[] andData:nil]];
+ [jmiNode setStoreHint];
+ [self.account send:jmiNode];
+}
+
+-(void) sendJmiProceed
+{
+ DDLogDebug(@"Accepting via JMI: %@", self);
+ //xep 0353 mandates bare jid, but daniel will update it to mandate full jid
+ XMPPMessage* jmiNode = [[XMPPMessage alloc] initWithType:kMessageChatType to:self.fullRemoteJid];
+ MLXMLNode* proceedElement = [[MLXMLNode alloc] initWithElement:@"proceed" andNamespace:@"urn:xmpp:jingle-message:0" withAttributes:@{
+ @"id": self.jmiid,
+ } andChildren:@[] andData:nil];
+ //only offer omemo deviceid for encryption if encryption is enabled for this contact
+ if(self.contact.isEncrypted)
+ {
+ //see https://gist.github.com/iNPUTmice/aa4fc0aeea6ce5fb0e0fe04baca842cd
+ [proceedElement addChildNode:[[MLXMLNode alloc] initWithElement:@"device" andNamespace:@"http://gultsch.de/xmpp/drafts/omemo/dlts-srtp-verification" withAttributes:@{
+ @"id": [self.account.omemo getDeviceId],
+ } andChildren:@[] andData:nil]];
+ }
+ [jmiNode addChildNode:proceedElement];
+ [jmiNode setStoreHint];
+ self.jmiProceed = jmiNode;
+ [self.account send:jmiNode];
+}
+
+-(void) sendJmiFinishWithReason:(NSString*) reason
+{
+ DDLogVerbose(@"Finishing via jingle: %@", self);
+ XMPPIQ* jingleIQ = [[XMPPIQ alloc] initWithType:kiqSetType to:self.fullRemoteJid];
+ [jingleIQ addChildNode:[[MLXMLNode alloc] initWithElement:@"jingle" andNamespace:@"urn:xmpp:jingle:1" withAttributes:@{
+ @"action": @"session-terminate",
+ @"sid": self.jmiid,
+ } andChildren:@[
+ [[MLXMLNode alloc] initWithElement:@"reason" andNamespace:@"urn:xmpp:jingle:1" withAttributes:@{} andChildren:@[
+ [[MLXMLNode alloc] initWithElement:reason]
+ ] andData:nil]
+ ] andData:nil]];
+ [self.account send:jingleIQ];
+
+ DDLogDebug(@"Finishing via JMI: %@", self);
+ XMPPMessage* jmiNode = [[XMPPMessage alloc] initWithType:kMessageChatType to:self.fullRemoteJid];
+ [jmiNode addChildNode:[[MLXMLNode alloc] initWithElement:@"finish" andNamespace:@"urn:xmpp:jingle-message:0" withAttributes:@{
+ @"id": self.jmiid,
+ } andChildren:@[
+ [[MLXMLNode alloc] initWithElement:@"reason" andNamespace:@"urn:xmpp:jingle:1" withAttributes:@{} andChildren:@[
+ [[MLXMLNode alloc] initWithElement:reason]
+ ] andData:nil]
+ ] andData:nil]];
+ [jmiNode setStoreHint];
+ [self.account send:jmiNode];
+}
+
+-(void) sendJmiRetract
+{
+ DDLogDebug(@"Retracting via JMI: %@", self);
+ XMPPMessage* jmiNode = [[XMPPMessage alloc] initToContact:self.contact];
+ [jmiNode addChildNode:[[MLXMLNode alloc] initWithElement:@"retract" andNamespace:@"urn:xmpp:jingle-message:0" withAttributes:@{
+ @"id": self.jmiid,
+ } andChildren:@[
+ [[MLXMLNode alloc] initWithElement:@"reason" andNamespace:@"urn:xmpp:jingle:1" withAttributes:@{} andChildren:@[
+ [[MLXMLNode alloc] initWithElement:@"cancel"]
+ ] andData:nil]
+ ] andData:nil]];
+ [jmiNode setStoreHint];
+ [self.account send:jmiNode];
+}
+
+-(void) sendJmiRetractWithTieBreak
+{
+ DDLogDebug(@"Retracting via JMI: %@", self);
+ XMPPMessage* jmiNode = [[XMPPMessage alloc] initToContact:self.contact];
+ [jmiNode addChildNode:[[MLXMLNode alloc] initWithElement:@"retract" andNamespace:@"urn:xmpp:jingle-message:0" withAttributes:@{
+ @"id": self.jmiid,
+ } andChildren:@[
+ [[MLXMLNode alloc] initWithElement:@"reason" andNamespace:@"urn:xmpp:jingle:1" withAttributes:@{} andChildren:@[
+ [[MLXMLNode alloc] initWithElement:@"cancel"]
+ ] andData:nil]
+ ] andData:nil]];
+ [jmiNode setStoreHint];
+ [self.account send:jmiNode];
+}
+
+-(NSString*) description
+{
+ NSString* state;
+ switch(self.state)
+ {
+ case MLCallStateDiscovering: state = @"discovering"; break;
+ case MLCallStateRinging: state = @"ringing"; break;
+ case MLCallStateConnecting: state = @"connecting"; break;
+ case MLCallStateReconnecting: state = @"reconnecting"; break;
+ case MLCallStateConnected: state = @"connected"; break;
+ case MLCallStateFinished: state = @"finished"; break;
+ case MLCallStateUnknown: state = @"unknown"; break;
+ default: state = @"undefined"; break;
+ }
+ return [NSString stringWithFormat:@"%@Call:%@",
+ self.direction == MLCallDirectionIncoming ? @"Incoming" : @"Outgoing",
+ @{
+ @"uuid": self.uuid,
+ @"jmiid": self.jmiid,
+ @"state": state,
+ @"finishReason": @(self.finishReason),
+ @"durationTime": @(self.durationTime),
+ @"contact": nilWrapper(self.contact),
+ @"fullRemoteJid": nilWrapper(self.fullRemoteJid),
+ @"jmiPropose": nilWrapper(self.jmiPropose),
+ @"jmiProceed": nilWrapper(self.jmiProceed),
+ @"webRTCClient": nilWrapper(self.webRTCClient),
+ @"providerAnswerAction": nilWrapper(self.providerAnswerAction),
+ @"wasConnectedOnce": bool2str(self.wasConnectedOnce),
+ @"isConnected": bool2str(self.isConnected),
+ @"isReconnecting": bool2str(self.isReconnecting),
+ @"hasLocalSDP": bool2str(self.localSDP != nil),
+ @"hasRemoteSDP": bool2str(self.remoteSDP != nil),
+ @"remoteOmemoDeviceId": nilWrapper(self.remoteOmemoDeviceId),
+ @"encryptionState": @(self.encryptionState),
+ }
+ ];
+}
+
+-(NSString*) short
+{
+ return [NSString stringWithFormat:@"%@Call:%@{%@}", self.direction == MLCallDirectionIncoming ? @"Incoming" : @"Outgoing", self.uuid, self.jmiid];
+}
+
+-(BOOL) isEqualToContact:(MLContact*) contact
+{
+ return [self.contact isEqualToContact:contact];
+}
+
+-(BOOL) isEqualToCall:(MLCall*) call
+{
+ return [self.uuid isEqual:call.uuid];
+}
+
+-(BOOL) isEqual:(id _Nullable) object
+{
+ if(object == nil || self == object)
+ return YES;
+ else if([object isKindOfClass:[MLContact class]])
+ return [self isEqualToContact:(MLContact*)object];
+ else if([object isKindOfClass:[MLCall class]])
+ return [self isEqualToCall:(MLCall*)object];
+ else
+ return NO;
+}
+
+-(NSUInteger) hash
+{
+ return [self.uuid hash];
+}
+
+#pragma mark - WebRTCClientDelegate
+
+-(void) webRTCClient:(WebRTCClient*) webRTCClient didDiscoverLocalCandidate:(RTCIceCandidate*) candidate
+{
+ @synchronized(self) {
+ if(webRTCClient != self.webRTCClient)
+ {
+ DDLogDebug(@"%@: Ignoring discovered local ICE candidate: %@ (call migrated)", [self short], candidate);
+ return;
+ }
+ DDLogDebug(@"%@: Discovered local ICE candidate destined for '%@': %@", [self short], self.fullRemoteJid, candidate);
+
+ //set ufrag to nil, it will be automatically filled via candidate.sdp
+ //extract the pwd from our outgoing offer using the sdpMid to identify the correct element
+ NSString* localPwd = [self.localSDP findFirst:@"{urn:xmpp:jingle:1}jingle/content/{urn:xmpp:jingle:transports:ice-udp:1}transport@pwd", candidate.sdpMid];
+ MLXMLNode* contentNode = [HelperTools candidate2xml:candidate.sdp withMid:candidate.sdpMid pwd:localPwd ufrag:nil andInitiator:self.direction==MLCallDirectionOutgoing];
+ if(contentNode == nil)
+ {
+ DDLogError(@"Failed to convert raw sdp candidate to jingle, ignoring this candidate: %@", candidate);
+ return;
+ }
+#ifdef IS_ALPHA
+ if([contentNode check:@"{urn:xmpp:jingle:transports:ice-udp:1}transport/candidate"])
+ {
+ //add tcptype because that attribute is apparently not supported by our mozilla sdp lib
+ MLXMLNode* candidateNode = [contentNode findFirst:@"{urn:xmpp:jingle:transports:ice-udp:1}transport/candidate"];
+ if([candidate.sdp containsString:@"typ host tcptype active"])
+ candidateNode.attributes[@"tcptype"] = @"active";
+ else if([candidate.sdp containsString:@"typ host tcptype passive"])
+ candidateNode.attributes[@"tcptype"] = @"passive";
+ else
+ DDLogWarn(@"Unknown type-tcptype combination!");
+ }
+#else
+ if([contentNode check:@"{urn:xmpp:jingle:transports:ice-udp:1}transport/candidate"])
+ {
+ DDLogError(@"Ignoring raw sdp candidate, because it's using tcp instead of udp: %@", candidate);
+ return;
+ }
+#endif
+ //see https://webrtc.googlesource.com/src/+/refs/heads/main/sdk/objc/api/peerconnection/RTCIceCandidate.h
+ XMPPIQ* candidateIq = [[XMPPIQ alloc] initWithType:kiqSetType to:self.fullRemoteJid];
+ [candidateIq addChildNode:[[MLXMLNode alloc] initWithElement:@"jingle" andNamespace:@"urn:xmpp:jingle:1" withAttributes:@{
+ @"action": @"transport-info",
+ @"sid": self.jmiid,
+ } andChildren:@[contentNode] andData:nil]];
+ @synchronized(self.candidateQueueLock) {
+ //queue candidate if sdp offer or answer have not been processed yet
+ if(self.remoteSDP == nil || self.localSDP == nil)
+ {
+ DDLogDebug(@"Adding outgoing ICE candidate iq to candidate queue: %@", candidateIq);
+ [self.outgoingCandidateQueue addObject:candidateIq];
+ return;
+ }
+ }
+ [self.account sendIq:candidateIq withResponseHandler:^(XMPPIQ* result) {
+ DDLogDebug(@"%@: Received outgoing ICE candidate result: %@", [self short], result);
+ } andErrorHandler:^(XMPPIQ* error) {
+ DDLogError(@"%@: Got error for outgoing ICE candidate: %@", [self short], error);
+ }];
+ }
+}
+
+-(void) webRTCClient:(WebRTCClient*) webRTCClient didChangeConnectionState:(RTCIceConnectionState) state
+{
+ @synchronized(self) {
+ if(webRTCClient != self.webRTCClient)
+ {
+ DDLogInfo(@"Ignoring new RTCIceConnectionState %ld for webRTCClient: %@ (call migrated)", (long)state, webRTCClient);
+ return;
+ }
+ if(self.isFinished)
+ {
+ DDLogInfo(@"Ignoring new RTCIceConnectionState %ld for webRTCClient: %@ (call already finished)", (long)state, webRTCClient);
+ return;
+ }
+ //state enums can be found over here: https://chromium.googlesource.com/external/webrtc/+/9eeb6240c93efe2219d4d6f4cf706030e00f64d7/webrtc/sdk/objc/Framework/Headers/WebRTC/RTCPeerConnection.h
+ DDLogDebug(@"New RTCIceConnectionState %ld for webRTCClient: %@", (long)state, webRTCClient);
+ //we *always* want to cancel the running iceRestart timer once the state changes
+ if(self.cancelWaitUntilIceRestart != nil)
+ {
+ self.cancelWaitUntilIceRestart();
+ self.cancelWaitUntilIceRestart = nil;
+ }
+ switch(state)
+ {
+ case RTCIceConnectionStateConnected:
+ DDLogInfo(@"New WebRTC ICE state: connected, falling through to completed...");
+ case RTCIceConnectionStateCompleted:
+ DDLogInfo(@"New WebRTC ICE state: completed: %@", self);
+ self.isConnected = YES;
+ self.isReconnecting = NO;
+ //at this stage this means the call is incoming (--> fulfill callkit answer action to update ui to reflect connected call)
+ if(self.direction == MLCallDirectionIncoming)
+ {
+ DDLogInfo(@"Informing CallKit of successful connection of incoming call...");
+ [self.providerAnswerAction fulfill];
+ }
+ //otherwise the call was outgoing (--> initialize callkit ui for outgoing call, we are connected now)
+ else
+ {
+ DDLogInfo(@"Informing CallKit of successful connection of outgoing call...");
+ [self.voipProcessor.cxProvider reportOutgoingCallWithUUID:self.uuid connectedAtDate:nil];
+ }
+ break;
+ case RTCIceConnectionStateDisconnected:
+ DDLogInfo(@"New WebRTC ICE state: disconnected: %@", self);
+ /*if(self.wasConnectedOnce)
+ {
+ //wait some time before restarting ice (maybe the connection can be reestablished without a new candidate exchange)
+ //see: https://groups.google.com/g/discuss-webrtc/c/I4K8NwN4Huw
+ //see: https://webrtccourse.com/course/webrtc-codelab/module/fiddle-of-the-month/lesson/ice-restarts/
+ //see: https://medium.com/@fippo/ice-restarts-5d759caceda6
+ self.cancelWaitUntilIceRestart = createTimer(2.0, (^{
+ [self restartIce];
+ }));
+ }
+ else
+ [self end];*/
+ //wait some time for other jmi and jingle stanzas to arrive (these may contain call end reasons we want to process)
+ [self delayedEnd:2.0 withDisconnectedState:YES];
+ break;
+ case RTCIceConnectionStateFailed:
+ DDLogInfo(@"New WebRTC ICE state: failed: %@", self);
+ /*if(self.wasConnectedOnce)
+ [self restartIce];
+ else
+ [self end];*/
+ self.isConnected = NO; //will result in MLCallFinishReasonConnectivityError if wasConnectedOnce == YES
+ [self end];
+ break;
+ //all following states can be ignored
+ case RTCIceConnectionStateClosed:
+ DDLogInfo(@"New WebRTC ICE state: closed: %@", self);
+ break;
+ case RTCIceConnectionStateNew:
+ DDLogInfo(@"New WebRTC ICE state: new: %@", self);
+ break;
+ case RTCIceConnectionStateChecking:
+ DDLogInfo(@"New WebRTC ICE state: checking: %@", self);
+ break;
+ case RTCIceConnectionStateCount:
+ DDLogInfo(@"New WebRTC ICE state: count: %@", self);
+ break;
+ default:
+ DDLogInfo(@"New WebRTC ICE state: UNKNOWN: %@", self);
+ break;
+ }
+ }
+}
+
+-(void) webRTCClient:(WebRTCClient*) webRTCClient didReceiveData:(NSData*) data
+{
+ if(webRTCClient != self.webRTCClient)
+ {
+ DDLogDebug(@"Ignoring received WebRTC data: %@ (call migrated)", data);
+ return;
+ }
+ DDLogDebug(@"Received WebRTC data: %@", data);
+}
+
+#pragma mark - ICE handling
+
+-(void) processIncomingICECandidate:(NSNotification*) notification
+{
+ DDLogInfo(@"Got new incoming ICE candidate...");
+ xmpp* account = notification.object;
+ NSDictionary* userInfo = notification.userInfo;
+ //ignore sdp for disabled accounts
+ if(account != [[MLXMPPManager sharedInstance] getEnabledAccountForID:account.accountID])
+ return;
+ //don't use self.account because that asserts on nil
+ if(self.contact.account == nil)
+ return;
+
+ XMPPIQ* iqNode = userInfo[@"iqNode"];
+ NSString* jmiid = [iqNode findFirst:@"{urn:xmpp:jingle:1}jingle@sid"];
+ if(![account.accountID isEqualToNumber:self.account.accountID] || ![self.jmiid isEqual:jmiid])
+ {
+ DDLogInfo(@"Incoming ICE candidate not matching %@, ignoring...", [self short]);
+ return;
+ }
+
+ @synchronized(self.candidateQueueLock) {
+ //queue candidate if sdp offer or answer have not been processed yet
+ if(self.remoteSDP == nil || self.localSDP == nil)
+ {
+ DDLogDebug(@"Adding incoming ICE candidate iq to candidate queue: %@", iqNode);
+ [self.incomingCandidateQueue addObject:iqNode];
+ return;
+ }
+ }
+ [self processRemoteICECandidate:iqNode];
+}
+
+-(void) processRemoteICECandidate:(XMPPIQ*) iqNode
+{
+ RTCIceCandidate* incomingCandidate = nil;
+ NSString* rawSdp = [HelperTools xml2candidate:[iqNode findFirst:@"{urn:xmpp:jingle:1}jingle"] withInitiator:self.direction==MLCallDirectionIncoming];
+#ifdef IS_ALPHA
+ if([iqNode check:@"{urn:xmpp:jingle:1}jingle/content/{urn:xmpp:jingle:transports:ice-udp:1}transport/candidate"])
+ {
+ NSString* type = [iqNode findFirst:@"{urn:xmpp:jingle:1}jingle/content/{urn:xmpp:jingle:transports:ice-udp:1}transport/candidate@type"];
+ NSString* tcptype = [iqNode findFirst:@"{urn:xmpp:jingle:1}jingle/content/{urn:xmpp:jingle:transports:ice-udp:1}transport/candidate@tcptype"];
+ DDLogDebug(@"Patching raw sdp type=%@ to contain tcptype: %@", type, tcptype);
+ rawSdp = [rawSdp stringByReplacingOccurrencesOfString:[NSString stringWithFormat:@"typ %@", type] withString:[NSString stringWithFormat:@"typ %@ tcptype %@", type, tcptype]];
+ }
+#else
+ if([iqNode check:@"{urn:xmpp:jingle:1}jingle/content/{urn:xmpp:jingle:transports:ice-udp:1}transport/candidate"])
+ {
+ DDLogWarn(@"Got tcp candidate, ignoring: %@", [iqNode findFirst:@"{urn:xmpp:jingle:1}jingle/content/{urn:xmpp:jingle:transports:ice-udp:1}transport/candidate"]);
+ rawSdp = nil;
+ }
+#endif
+ DDLogVerbose(@"Got raw remote sdp: %@", rawSdp);
+ if(rawSdp == nil)
+ {
+ DDLogError(@"Failed to convert jingle candidate to raw sdp!");
+ XMPPIQ* errorIq = [[XMPPIQ alloc] initAsErrorTo:iqNode];
+ [errorIq addChildNode:[[MLXMLNode alloc] initWithElement:@"error" withAttributes:@{@"type": @"modify"} andChildren:@[
+ [[MLXMLNode alloc] initWithElement:@"bad-request" andNamespace:@"urn:ietf:params:xml:ns:xmpp-stanzas"],
+ ] andData:nil]];
+ [self.account send:errorIq];
+
+ //don't be too harsh and not end the call here
+ //[self handleEndCallActionWithReason:MLCallFinishReasonError];
+ return;
+ }
+
+ //calculate correct mLineIndex by searching for the corresponding mid (e.g. content@name) in the list of contents advertised in our offer
+ NSString* sdpMid = [iqNode findFirst:@"{urn:xmpp:jingle:1}jingle/content@name"];
+ NSArray* offeredMedia = [self.remoteSDP find:@"{urn:xmpp:jingle:1}jingle/content"];
+ NSUInteger mLineIndex = 0;
+ for(; mLineIndex < [offeredMedia count]; mLineIndex++)
+ if([sdpMid isEqualToString:offeredMedia[mLineIndex].attributes[@"name"]])
+ {
+ incomingCandidate = [[RTCIceCandidate alloc] initWithSdp:rawSdp sdpMLineIndex:(int)mLineIndex sdpMid:sdpMid];
+ break;
+ }
+ if(mLineIndex == [offeredMedia count])
+ DDLogError(@"Could not find content element with mid='%@' in remoteSDP!", sdpMid);
+
+ if(incomingCandidate == nil)
+ {
+ DDLogError(@"incomingCandidate is unexpectedly nil, ignoring!");
+ [self.account send:[[XMPPIQ alloc] initAsResponseTo:iqNode]];
+ return;
+
+// XMPPIQ* errorIq = [[XMPPIQ alloc] initAsErrorTo:iqNode];
+// [errorIq addChildNode:[[MLXMLNode alloc] initWithElement:@"error" withAttributes:@{@"type": @"modify"} andChildren:@[
+// [[MLXMLNode alloc] initWithElement:@"bad-request" andNamespace:@"urn:ietf:params:xml:ns:xmpp-stanzas"],
+// ] andData:nil]];
+// [self.account send:errorIq];
+//
+// //don't be too harsh and not end the call here
+// //[self handleEndCallActionWithReason:MLCallFinishReasonError];
+// return;
+ }
+ DDLogInfo(@"%@: Got remote ICE candidate for call: %@", self, incomingCandidate);
+ NSString* remoteUfrag = [self.remoteSDP findFirst:@"{urn:xmpp:jingle:1}jingle/content/{urn:xmpp:jingle:transports:ice-udp:1}transport@ufrag", incomingCandidate.sdpMid];
+ NSString* remotePwd = [self.remoteSDP findFirst:@"{urn:xmpp:jingle:1}jingle/content/{urn:xmpp:jingle:transports:ice-udp:1}transport@pwd", incomingCandidate.sdpMid];
+ NSString* candidateUfrag = [iqNode findFirst:@"{urn:xmpp:jingle:1}jingle/content/{urn:xmpp:jingle:transports:ice-udp:1}transport@ufrag", incomingCandidate.sdpMid];
+ NSString* candidatePwd = [iqNode findFirst:@"{urn:xmpp:jingle:1}jingle/content/{urn:xmpp:jingle:transports:ice-udp:1}transport@pwd", incomingCandidate.sdpMid];
+ if(remotePwd == nil || remoteUfrag == nil || ![remoteUfrag isEqualToString:candidateUfrag] || ![remotePwd isEqualToString:candidatePwd])
+ {
+ DDLogError(@"Jingle incoming candidate has wrong pwd or ufrag: incomingCandidate.ufrag='%@', incomingCandidate.pwd='%@', remoteSDP.ufrag='%@', remoteSDP.pwd='%@'", candidateUfrag, candidatePwd, remoteUfrag, remotePwd);
+ XMPPIQ* errorIq = [[XMPPIQ alloc] initAsErrorTo:iqNode];
+ [errorIq addChildNode:[[MLXMLNode alloc] initWithElement:@"error" withAttributes:@{@"type": @"auth"} andChildren:@[
+ [[MLXMLNode alloc] initWithElement:@"not-authorized" andNamespace:@"urn:ietf:params:xml:ns:xmpp-stanzas"],
+ ] andData:nil]];
+ [self.account send:errorIq];
+
+ //don't be too harsh and not end the call here
+ //[self handleEndCallActionWithReason:MLCallFinishReasonError];
+ return;
+ }
+
+ weakify(self);
+ [self.webRTCClient setRemoteCandidate:incomingCandidate completion:^(id error) {
+ strongify(self);
+ DDLogDebug(@"Got setRemoteCandidate callback...");
+ if(error)
+ {
+ DDLogError(@"Got error while passing new remote ICE candidate to webRTCClient: %@", error);
+ XMPPIQ* errorIq = [[XMPPIQ alloc] initAsErrorTo:iqNode];
+ [errorIq addChildNode:[[MLXMLNode alloc] initWithElement:@"error" withAttributes:@{@"type": @"modify"} andChildren:@[
+ [[MLXMLNode alloc] initWithElement:@"not-acceptable" andNamespace:@"urn:ietf:params:xml:ns:xmpp-stanzas"],
+ ] andData:nil]];
+ [self.account send:errorIq];
+
+ //don't be too harsh and not end the call here
+ //[self handleEndCallActionWithReason:MLCallFinishReasonError];
+ }
+ else
+ {
+ DDLogDebug(@"Successfully passed new remote ICE candidate to webRTCClient...");
+ [self.account send:[[XMPPIQ alloc] initAsResponseTo:iqNode]];
+ }
+ }];
+ DDLogDebug(@"Leaving method...");
+}
+
+-(void) processIncomingSDP:(NSNotification*) notification
+{
+ DDLogInfo(@"Got new incoming SDP...");
+ xmpp* account = notification.object;
+ NSDictionary* userInfo = notification.userInfo;
+ //ignore sdp for disabled accounts
+ if(account != [[MLXMPPManager sharedInstance] getEnabledAccountForID:account.accountID])
+ return;
+ //don't use self.account because that asserts on nil
+ if(self.contact.account == nil)
+ return;
+ XMPPIQ* iqNode = userInfo[@"iqNode"];
+
+ NSString* jmiid = [iqNode findFirst:@"{urn:xmpp:jingle:1}jingle@sid"];
+ if(![account.accountID isEqualToNumber:self.account.accountID] || ![self.jmiid isEqual:jmiid])
+ {
+ DDLogInfo(@"Ignoring incoming SDP not matching: %@", self);
+ return;
+ }
+
+ //make sure we don't handle incoming sdp twice
+ if(self.remoteSDP != nil && [iqNode findFirst:@"{urn:xmpp:jingle:1}jingle"])
+ {
+ DDLogWarn(@"Got new remote sdp but we already got one, ignoring! MITM/DDOS??");
+ XMPPIQ* errorIq = [[XMPPIQ alloc] initAsErrorTo:iqNode];
+ [errorIq addChildNode:[[MLXMLNode alloc] initWithElement:@"error" withAttributes:@{@"type": @"cancel"} andChildren:@[
+ [[MLXMLNode alloc] initWithElement:@"conflict" andNamespace:@"urn:ietf:params:xml:ns:xmpp-stanzas"],
+ ] andData:nil]];
+ [self.account send:errorIq];
+ return;
+ }
+
+ NSString* rawSDP;
+ NSString* type;
+ if([iqNode check:@"{urn:xmpp:jingle:1}jingle"])
+ {
+ if(
+ ([iqNode findFirst:@"{urn:xmpp:jingle:1}jingle"] && self.direction != MLCallDirectionOutgoing) ||
+ ([iqNode findFirst:@"{urn:xmpp:jingle:1}jingle"] && self.direction != MLCallDirectionIncoming)
+ ) {
+ DDLogWarn(@"Unexpected incoming jingle data direction, ignoring: %@", iqNode);
+ return;
+ }
+
+ //don't change iqNode directly to not influence code outside of this method
+ iqNode = [iqNode copy];
+ //handle candidates in initial sdp (our webrtc lib does not like them --> fake transport-info iqs for these)
+ //(candidates in initial jingle are allowed by xep!)
+ @synchronized(self.candidateQueueLock) {
+ for(MLXMLNode* content in [iqNode find:@"{urn:xmpp:jingle:1}jingle/content"])
+ {
+ MLXMLNode* transport = [content findFirst:@"{urn:xmpp:jingle:transports:ice-udp:1}transport"];
+ for(MLXMLNode* candidate in [transport find:@"{urn:xmpp:jingle:transports:ice-udp:1}candidate"])
+ {
+ XMPPIQ* fakeCandidateIQ = [[XMPPIQ alloc] initWithType:kiqSetType];
+ fakeCandidateIQ.from = self.fullRemoteJid;
+ fakeCandidateIQ.to = self.account.connectionProperties.identity.fullJid;
+ MLXMLNode* shallowTransport = [transport shallowCopyWithData:YES];
+ [shallowTransport addChildNode:[transport removeChildNode:candidate]];
+ MLXMLNode* shallowContent = [content shallowCopyWithData:YES];
+ [shallowContent addChildNode:shallowTransport];
+ [fakeCandidateIQ addChildNode:[[MLXMLNode alloc] initWithElement:@"jingle" andNamespace:@"urn:xmpp:jingle:1" withAttributes:@{
+ @"action": @"transport-info",
+ @"sid": self.jmiid,
+ } andChildren:@[shallowContent] andData:nil]];
+ DDLogDebug(@"Adding fake candidate iq to candidate queue: %@", fakeCandidateIQ);
+ [self.incomingCandidateQueue addObject:fakeCandidateIQ];
+ }
+ }
+ }
+ //decrypt fingerprint, if needed (use iqNode copy created above to not influence code outside of this method)
+ //only decrypt if encryption is enabled for this contact
+ if(self.contact.isEncrypted)
+ {
+ //if this is a session-initiate and we can decrypt the fingerprint using the given deviceid, this call is encrypted now
+ //if we can NOT decrypt anything, but have a remote deviceid (e.g. the iq contains an omemo envelope), this is a security error
+ if([iqNode check:@"{urn:xmpp:jingle:1}jingle"])
+ {
+ //save omemo deviceid if we got a session-initiate for this (incoming) call
+ self.remoteOmemoDeviceId = [iqNode findFirst:@"{urn:xmpp:jingle:1}jingle/{urn:xmpp:jingle:1}content/{urn:xmpp:jingle:transports:ice-udp:1}transport/{http://gultsch.de/xmpp/drafts/omemo/dlts-srtp-verification}fingerprint/{eu.siacs.conversations.axolotl}encrypted/header@sid|uint"];
+ if(self.remoteOmemoDeviceId != nil)
+ {
+ if([self decryptFingerprintsInIqNode:iqNode])
+ self.encryptionState = [self encryptionTypeForDeviceid:self.remoteOmemoDeviceId];
+ else
+ {
+ DDLogError(@"Could not decrypt remote SDP session-initiate fingerprint with OMEMO!");
+ XMPPIQ* errorIq = [[XMPPIQ alloc] initAsErrorTo:iqNode];
+ [errorIq addChildNode:[[MLXMLNode alloc] initWithElement:@"error" withAttributes:@{@"type": @"modify"} andChildren:@[
+ [[MLXMLNode alloc] initWithElement:@"not-acceptable" andNamespace:@"urn:ietf:params:xml:ns:xmpp-stanzas"],
+ [[MLXMLNode alloc] initWithElement:@"text" andNamespace:@"urn:ietf:params:xml:ns:xmpp-stanzas" andData:@"Could not decrypt call with OMEMO!"],
+ ] andData:nil]];
+ [self.account send:errorIq];
+
+ [self handleEndCallActionWithReason:MLCallFinishReasonSecurityError];
+ return;
+ }
+ }
+ else
+ self.encryptionState = MLCallEncryptionStateClear;
+ }
+
+ //if this is a session-accept after sending an encrypted session-initiate and we can NOT decrypt the fingerprint,
+ //this call is a security error (if we can decrypt it, everything is fine and the call is secured)
+ if([iqNode check:@"{urn:xmpp:jingle:1}jingle"])
+ {
+ //we don't need to check self.remoteOmemoDeviceId, because self.encryptionState will only be different to
+ //MLCallEncryptionStateClear if the deviceid is not nil
+ if(self.encryptionState != MLCallEncryptionStateClear && ![self decryptFingerprintsInIqNode:iqNode])
+ {
+ DDLogError(@"Could not decrypt remote SDP session-accept fingerprint with OMEMO!");
+ XMPPIQ* errorIq = [[XMPPIQ alloc] initAsErrorTo:iqNode];
+ [errorIq addChildNode:[[MLXMLNode alloc] initWithElement:@"error" withAttributes:@{@"type": @"modify"} andChildren:@[
+ [[MLXMLNode alloc] initWithElement:@"not-acceptable" andNamespace:@"urn:ietf:params:xml:ns:xmpp-stanzas"],
+ [[MLXMLNode alloc] initWithElement:@"text" andNamespace:@"urn:ietf:params:xml:ns:xmpp-stanzas" andData:@"Could not decrypt call with OMEMO!"],
+ ] andData:nil]];
+ [self.account send:errorIq];
+
+ [self handleEndCallActionWithReason:MLCallFinishReasonSecurityError];
+ return;
+ }
+ }
+ }
+ else
+ self.encryptionState = MLCallEncryptionStateClear;
+
+ //check if the jingle offer/response contains only the media that got advertised in jmi and throw a security error otherwise
+ if(self.callType == MLCallTypeAudio && [iqNode check:@"{urn:xmpp:jingle:1}jingle/content/{urn:xmpp:jingle:apps:rtp:1}description"])
+ {
+ DDLogError(@"Security: jingle advertises video while jmi only contained audio, aborting call!");
+ XMPPIQ* errorIq = [[XMPPIQ alloc] initAsErrorTo:iqNode];
+ [errorIq addChildNode:[[MLXMLNode alloc] initWithElement:@"error" withAttributes:@{@"type": @"modify"} andChildren:@[
+ [[MLXMLNode alloc] initWithElement:@"not-acceptable" andNamespace:@"urn:ietf:params:xml:ns:xmpp-stanzas"],
+ [[MLXMLNode alloc] initWithElement:@"text" andNamespace:@"urn:ietf:params:xml:ns:xmpp-stanzas" andData:@"Sent video in jingle, but only advertised audio in jmi!"],
+ ] andData:nil]];
+ [self.account send:errorIq];
+
+ [self handleEndCallActionWithReason:MLCallFinishReasonSecurityError];
+ return;
+ }
+
+ //now handle the jingle offer/response nodes and convert jingle xml to sdp
+ if([iqNode findFirst:@"{urn:xmpp:jingle:1}jingle"])
+ {
+ type = @"answer";
+ rawSDP = [HelperTools xml2sdp:[iqNode findFirst:@"{urn:xmpp:jingle:1}jingle"] withInitiator:NO];
+ }
+ else if([iqNode findFirst:@"{urn:xmpp:jingle:1}jingle"])
+ {
+ type = @"offer";
+ rawSDP = [HelperTools xml2sdp:[iqNode findFirst:@"{urn:xmpp:jingle:1}jingle"] withInitiator:YES];
+ }
+ }
+ //handle session-terminate: fake jmi finish message and handle it
+ else if([iqNode findFirst:@"{urn:xmpp:jingle:1}jingle"])
+ {
+ DDLogDebug(@"Got jingle session-terminate, faking incoming jmi:finish for Conversations compatibility...");
+ XMPPMessage* jmiNode = [[XMPPMessage alloc] initWithType:kMessageChatType to:self.account.connectionProperties.identity.jid];
+ [jmiNode addChildNode:[[MLXMLNode alloc] initWithElement:@"finish" andNamespace:@"urn:xmpp:jingle-message:0" withAttributes:@{
+ @"id": self.jmiid,
+ } andChildren:[iqNode find:@"{urn:xmpp:jingle:1}jingle/reason"] andData:nil]];
+ [jmiNode setStoreHint];
+ [self.voipProcessor handleIncomingJMIStanza:jmiNode onAccount:self.account];
+ return;
+ }
+ else
+ {
+ DDLogWarn(@"Unexpected incoming jingle type, ignoring: %@", iqNode);
+ return;
+ }
+
+ DDLogVerbose(@"rawSDP(%@)=%@", type, rawSDP);
+ if(rawSDP == nil)
+ {
+ DDLogError(@"Failed to convert jingle to raw sdp!");
+ XMPPIQ* errorIq = [[XMPPIQ alloc] initAsErrorTo:iqNode];
+ [errorIq addChildNode:[[MLXMLNode alloc] initWithElement:@"error" withAttributes:@{@"type": @"modify"} andChildren:@[
+ [[MLXMLNode alloc] initWithElement:@"bad-request" andNamespace:@"urn:ietf:params:xml:ns:xmpp-stanzas"],
+ ] andData:nil]];
+ [self.account send:errorIq];
+
+ [self handleEndCallActionWithReason:MLCallFinishReasonError];
+ return;
+ }
+
+ //convert raw sdp string to RTCSessionDescription object
+ RTCSessionDescription* resultSDP = [[RTCSessionDescription alloc] initWithType:[RTCSessionDescription typeForString:type] sdp:rawSDP];
+ if(resultSDP == nil)
+ {
+ DDLogError(@"resultSDP is unexpectedly nil!");
+ XMPPIQ* errorIq = [[XMPPIQ alloc] initAsErrorTo:iqNode];
+ [errorIq addChildNode:[[MLXMLNode alloc] initWithElement:@"error" withAttributes:@{@"type": @"modify"} andChildren:@[
+ [[MLXMLNode alloc] initWithElement:@"bad-request" andNamespace:@"urn:ietf:params:xml:ns:xmpp-stanzas"],
+ ] andData:nil]];
+ [self.account send:errorIq];
+
+ [self handleEndCallActionWithReason:MLCallFinishReasonError];
+ return;
+ }
+ DDLogInfo(@"%@: Got remote SDP for call: %@", self, resultSDP);
+ @synchronized(self.candidateQueueLock) {
+ self.remoteSDP = iqNode;
+ }
+
+ //this is blocking (e.g. no need for an inner @synchronized)
+ weakify(self);
+ [self.webRTCClient setRemoteSdp:resultSDP completion:^(id error) {
+ strongify(self);
+ if(error)
+ {
+ DDLogError(@"Got error while passing remote SDP to webRTCClient: %@", error);
+ XMPPIQ* errorIq = [[XMPPIQ alloc] initAsErrorTo:iqNode];
+ [errorIq addChildNode:[[MLXMLNode alloc] initWithElement:@"error" withAttributes:@{@"type": @"modify"} andChildren:@[
+ [[MLXMLNode alloc] initWithElement:@"not-acceptable" andNamespace:@"urn:ietf:params:xml:ns:xmpp-stanzas"],
+ ] andData:nil]];
+ [self.account send:errorIq];
+
+ [self handleEndCallActionWithReason:MLCallFinishReasonError];
+ return;
+ }
+ else
+ {
+ DDLogDebug(@"Successfully passed SDP to webRTCClient...");
+ //only send a "session-accept" if the remote is the initiator (e.g. this is an incoming call)
+ if(self.direction == MLCallDirectionIncoming)
+ {
+ [self.webRTCClient answerWithCompletion:^(RTCSessionDescription* localSdp) {
+ DDLogDebug(@"Sending SDP answer back...");
+ NSArray* children = [HelperTools sdp2xml:localSdp.sdp withInitiator:NO];
+ //we got a session-initiate jingle iq
+ //--> self.encryptionState will NOT be MLCallEncryptionStateClear, if that iq contained an encrypted fingerprint,
+ //--> self.encryptionState WILL be MLCallEncryptionStateClear, if it did not contain such an encrypted fingerprint
+ //(in this case we just don't try to decrypt anything, the call will simply be unencrypted but continue)
+ //we don't need to check self.remoteOmemoDeviceId, because self.encryptionState will only be different to
+ //MLCallEncryptionStateClear if the deviceid is not nil
+ if(self.encryptionState != MLCallEncryptionStateClear && ![self encryptFingerprintsInChildren:children])
+ {
+ DDLogError(@"Could not encrypt local SDP response fingerprint with OMEMO!");
+ XMPPIQ* errorIq = [[XMPPIQ alloc] initAsErrorTo:iqNode];
+ [errorIq addChildNode:[[MLXMLNode alloc] initWithElement:@"error" withAttributes:@{@"type": @"modify"} andChildren:@[
+ [[MLXMLNode alloc] initWithElement:@"not-acceptable" andNamespace:@"urn:ietf:params:xml:ns:xmpp-stanzas"],
+ [[MLXMLNode alloc] initWithElement:@"text" andNamespace:@"urn:ietf:params:xml:ns:xmpp-stanzas" andData:@"Could not encrypt call with OMEMO!"],
+ ] andData:nil]];
+ [self.account send:errorIq];
+
+ [self handleEndCallActionWithReason:MLCallFinishReasonSecurityError];
+ return;
+ }
+ [self.account send:[[XMPPIQ alloc] initAsResponseTo:iqNode]];
+
+ XMPPIQ* sdpIQ = [[XMPPIQ alloc] initWithType:kiqSetType to:self.fullRemoteJid];
+ [sdpIQ addChildNode:[[MLXMLNode alloc] initWithElement:@"jingle" andNamespace:@"urn:xmpp:jingle:1" withAttributes:@{
+ @"action": @"session-accept",
+ @"sid": self.jmiid,
+ } andChildren:children andData:nil]];
+ [self.account send:sdpIQ];
+
+ @synchronized(self.candidateQueueLock) {
+ self.localSDP = sdpIQ;
+
+ DDLogDebug(@"Now handling queued incoming candidate iqs: %lu", (unsigned long)self.incomingCandidateQueue.count);
+ for(XMPPIQ* candidateIq in self.incomingCandidateQueue)
+ [self processRemoteICECandidate:candidateIq];
+ }
+ }];
+ }
+ else
+ {
+ [self.account send:[[XMPPIQ alloc] initAsResponseTo:iqNode]];
+ @synchronized(self.candidateQueueLock) {
+ DDLogDebug(@"Now handling queued incoming candidate iqs: %lu", (unsigned long)self.incomingCandidateQueue.count);
+ for(XMPPIQ* candidateIq in self.incomingCandidateQueue)
+ [self processRemoteICECandidate:candidateIq];
+ }
+ }
+ @synchronized(self.candidateQueueLock) {
+ DDLogDebug(@"Now sending queued outgoing candidate iqs: %lu", (unsigned long)self.outgoingCandidateQueue.count);
+ for(XMPPIQ* candidateIq in self.outgoingCandidateQueue)
+ [self.account sendIq:candidateIq withResponseHandler:^(XMPPIQ* result) {
+ DDLogDebug(@"%@: Received outgoing ICE candidate result: %@", [self short], result);
+ } andErrorHandler:^(XMPPIQ* error) {
+ DDLogError(@"%@: Got error for outgoing ICE candidate: %@", [self short], error);
+ }];
+ }
+ }
+ }];
+ DDLogDebug(@"Leaving method...");
+}
+
+-(void) handleAudioRouteChangeNotification:(NSNotification*) notification
+{
+ DDLogVerbose(@"Audio route changed: %@", notification);
+ DDLogVerbose(@"Current audio route: %@", self.audioSession.currentRoute);
+ BOOL speaker = NO;
+ for(AVAudioSessionPortDescription* port in self.audioSession.currentRoute.outputs)
+ if(port.portType == AVAudioSessionPortBuiltInSpeaker)
+ speaker = YES;
+
+ if(speaker)
+ self.speaker = YES;
+ else
+ self.speaker = NO;
+}
+
+-(MLCallEncryptionState) encryptionTypeForDeviceid:(NSNumber* _Nonnull) deviceid
+{
+ NSNumber* trustLevel = [self.account.omemo getTrustLevelForJid:self.contact.contactJid andDeviceId:deviceid];
+ if(trustLevel == nil)
+ return MLCallEncryptionStateClear;
+ switch(trustLevel.intValue)
+ {
+ case MLOmemoTrusted: return MLCallEncryptionStateTrusted;
+ case MLOmemoToFU: return MLCallEncryptionStateToFU;
+ default: return MLCallEncryptionStateClear;
+ }
+}
+
+-(BOOL) encryptFingerprintsInChildren:(NSArray*) children
+{
+ //don't try to encrypt if the remote deviceid is not trusted
+ if([self encryptionTypeForDeviceid:self.remoteOmemoDeviceId] == MLCallEncryptionStateClear)
+ return NO;
+
+ //see https://gist.github.com/iNPUTmice/aa4fc0aeea6ce5fb0e0fe04baca842cd
+ BOOL retval = NO;
+ for(MLXMLNode* child in children)
+ for(MLXMLNode* fingerprint in [child find:@"/{urn:xmpp:jingle:1}content/{urn:xmpp:jingle:transports:ice-udp:1}transport/{urn:xmpp:jingle:apps:dtls:0}fingerprint"])
+ {
+ MLXMLNode* envelope = [self.account.omemo encryptString:fingerprint.data toDeviceids:@{
+ self.contact.contactJid: [NSSet setWithArray:@[self.remoteOmemoDeviceId]],
+ }];
+ if(envelope == nil)
+ {
+ DDLogWarn(@"Could not encrypt fingerprint with OMEMO!");
+ return NO;
+ }
+ [fingerprint addChildNode:envelope];
+ [fingerprint setXMLNS:@"http://gultsch.de/xmpp/drafts/omemo/dlts-srtp-verification"];
+ fingerprint.data = nil;
+ retval = YES;
+ }
+ //this is only true if at least one fingerprint could be found and encrypted (this is normally true)
+ return retval;
+}
+
+-(BOOL) decryptFingerprintsInIqNode:(XMPPIQ*) iqNode
+{
+ //don't try to decrypt if the remote deviceid is not trusted
+ if([self encryptionTypeForDeviceid:self.remoteOmemoDeviceId] == MLCallEncryptionStateClear)
+ return NO;
+
+ //see https://gist.github.com/iNPUTmice/aa4fc0aeea6ce5fb0e0fe04baca842cd
+ BOOL retval = NO;
+ for(MLXMLNode* fingerprintNode in [iqNode find:@"{urn:xmpp:jingle:1}jingle/content/{urn:xmpp:jingle:transports:ice-udp:1}transport/{http://gultsch.de/xmpp/drafts/omemo/dlts-srtp-verification}fingerprint"])
+ {
+ //more than one omemo envelope means we are under attack
+ if([[fingerprintNode find:@"{eu.siacs.conversations.axolotl}encrypted"] count] > 1)
+ {
+ DDLogWarn(@"More than one OMEMO envelope found!");
+ return NO;
+ }
+ NSString* decryptedFingerprint = [self.account.omemo decryptOmemoEnvelope:[fingerprintNode findFirst:@"{eu.siacs.conversations.axolotl}encrypted"] forSenderJid:self.contact.contactJid andReturnErrorString:NO];
+ if(decryptedFingerprint == nil)
+ {
+ DDLogWarn(@"Could not decrypt OMEMO encrypted fingerprint!");
+ return NO;
+ }
+ //remove omemo envelope, correct xmlns and add our decrypted fingerprint back in as text content
+ [fingerprintNode removeChildNode:[fingerprintNode findFirst:@"{eu.siacs.conversations.axolotl}encrypted"]];
+ [fingerprintNode setXMLNS:@"urn:xmpp:jingle:apps:dtls:0"];
+ fingerprintNode.data = decryptedFingerprint;
+ retval = YES;
+ }
+ //this is only true if at least one fingerprint could be found and decrypted
+ //(that could be false, if the remote did something weird or a MITM changed something)
+ return retval;
+}
+
+@end
diff --git a/Monal/Classes/MLChatCell.h b/Monal/Classes/MLChatCell.h
new file mode 100644
index 0000000..6c7b6fa
--- /dev/null
+++ b/Monal/Classes/MLChatCell.h
@@ -0,0 +1,17 @@
+//
+// MLChatCell.h
+// Monal
+//
+// Created by Anurodh Pokharel on 8/20/13.
+//
+//
+
+#import
+#import "MLBaseCell.h"
+
+@interface MLChatCell : MLBaseCell
+
+
+-(void) openlink: (id) sender;
+
+@end
diff --git a/Monal/Classes/MLChatCell.m b/Monal/Classes/MLChatCell.m
new file mode 100644
index 0000000..ba14e10
--- /dev/null
+++ b/Monal/Classes/MLChatCell.m
@@ -0,0 +1,82 @@
+//
+// MLChatCell.m
+// Monal
+//
+// Created by Anurodh Pokharel on 8/20/13.
+//
+//
+
+#import "MLChatCell.h"
+#import "MLImageManager.h"
+#import "MLConstants.h"
+#import "HelperTools.h"
+
+@import SafariServices;
+
+
+@implementation MLChatCell
+
+-(void) updateCellWithNewSender:(BOOL) newSender
+{
+ [super updateCellWithNewSender:newSender];
+
+ if(self.outBound)
+ {
+ self.textLabel.textColor = [UIColor whiteColor];
+ self.bubbleImage.image = [[MLImageManager sharedInstance] outboundImage];
+ }
+ else
+ {
+ self.textLabel.textColor = [UIColor blackColor];
+ self.bubbleImage.image = [[MLImageManager sharedInstance] inboundImage];
+ }
+}
+
+
+-(BOOL) canPerformAction:(SEL) action withSender:(id) sender
+{
+ if(action == @selector(openlink:))
+ {
+ if(self.link)
+ return YES;
+ }
+ return (action == @selector(copy:));
+}
+
+
+-(void) openlink:(id) sender {
+
+ if(self.link)
+ {
+ NSURL* url = [NSURL URLWithString:self.link];
+ DDLogInfo(@"Opening link (inline=%@): %@", bool2str([[HelperTools defaultsDB] boolForKey: @"useInlineSafari"]), url);
+ if([[HelperTools defaultsDB] boolForKey: @"useInlineSafari"] && ([url.scheme.lowercaseString isEqualToString:@"http"] || [url.scheme.lowercaseString isEqualToString:@"https"]))
+ {
+ SFSafariViewController* safariView = [[SFSafariViewController alloc] initWithURL:url];
+ [self.parent presentViewController:safariView animated:YES completion:nil];
+ }
+ else
+ [[UIApplication sharedApplication] performSelector:@selector(openURL:) withObject:url];
+ }
+}
+
+-(void) copy:(id) sender {
+ UIPasteboard* pboard = [UIPasteboard generalPasteboard];
+ pboard.string = self.messageBody.text;
+}
+
+-(void) prepareForReuse
+{
+ [super prepareForReuse];
+ self.messageBody.attributedText = nil;
+ self.messageBody.text = @"";
+}
+
+- (void)setSelected:(BOOL) selected animated:(BOOL) animated
+{
+ [super setSelected:selected animated:animated];
+
+ // Configure the view for the selected state
+}
+
+@end
diff --git a/Monal/Classes/MLChatImageCell.h b/Monal/Classes/MLChatImageCell.h
new file mode 100644
index 0000000..41d0cfc
--- /dev/null
+++ b/Monal/Classes/MLChatImageCell.h
@@ -0,0 +1,20 @@
+//
+// MLChatImageCell.h
+// Monal
+//
+// Created by Anurodh Pokharel on 12/24/17.
+// Copyright © 2017 Monal.im. All rights reserved.
+//
+
+#import "MLBaseCell.h"
+
+@class MLMessage;
+
+@interface MLChatImageCell : MLBaseCell
+
+-(void) initCellWithMLMessage:(MLMessage*) message;
+
+-(UIImage*) getDisplayedImage;
+
+@end
+
diff --git a/Monal/Classes/MLChatImageCell.m b/Monal/Classes/MLChatImageCell.m
new file mode 100644
index 0000000..0dccf51
--- /dev/null
+++ b/Monal/Classes/MLChatImageCell.m
@@ -0,0 +1,157 @@
+//
+// MLChatImageCell.m
+// Monal
+//
+// Created by Anurodh Pokharel on 12/24/17.
+// Copyright © 2017 Monal.im. All rights reserved.
+//
+
+#import "FLAnimatedImage.h"
+#import "MLChatImageCell.h"
+#import "MLImageManager.h"
+#import "MLFiletransfer.h"
+#import "MLMessage.h"
+#import "HelperTools.h"
+
+@import QuartzCore;
+@import UIKit;
+
+@interface MLChatImageCell() {
+ FLAnimatedImageView* _animatedImageView;
+}
+
+@property (nonatomic, weak) IBOutlet UIImageView* thumbnailImage;
+@property (nonatomic, weak) IBOutlet UIActivityIndicatorView* spinner;
+@property (nonatomic, weak) IBOutlet NSLayoutConstraint* imageWidth;
+@property (nonatomic, weak) IBOutlet NSLayoutConstraint* imageHeight;
+
+@end
+
+@implementation MLChatImageCell
+
+-(void) awakeFromNib
+{
+ [super awakeFromNib];
+
+ // Initialization code
+ self.thumbnailImage.layer.cornerRadius = 15.0f;
+ self.thumbnailImage.layer.masksToBounds = YES;
+}
+
+// init a image cell if needed
+-(void) initCellWithMLMessage:(MLMessage*) message
+{
+ if(_animatedImageView != nil)
+ [_animatedImageView removeFromSuperview];
+ // reset image view if we open a new message
+ if(self.messageHistoryId != message.messageDBId)
+ self.thumbnailImage.image = nil;
+ // init base cell
+ [super initCell:message];
+ // load image and display it in the UI if needed
+ [self loadImage:message];
+}
+
+/// Load the image from messageText (link) and display it in the UI
+-(void) loadImage:(MLMessage*) msg
+{
+ if(_animatedImageView != nil)
+ [_animatedImageView removeFromSuperview];
+ if(msg.messageText && self.thumbnailImage.image == nil)
+ {
+ [self.spinner startAnimating];
+ NSDictionary* info = [MLFiletransfer getFileInfoForMessage:msg];
+ if(info && [info[@"mimeType"] hasPrefix:@"image/gif"])
+ {
+ self.link = msg.messageText;
+ // uses cached file if the file was already downloaded
+ FLAnimatedImage* image = [FLAnimatedImage animatedImageWithGIFData:[NSData dataWithContentsOfFile:info[@"cacheFile"]]];
+ if(!image)
+ return;
+ _animatedImageView = [FLAnimatedImageView new];
+ DDLogVerbose(@"image %@\n--> %fx%f", info, image.size.height, image.size.width);
+ CGFloat wi = image.size.width;
+ CGFloat hi = image.size.height;
+ CGFloat ws = 225.0;
+ CGFloat hs = 200.0;
+ CGFloat ri = wi / hi;
+ CGFloat rs = ws / hs;
+ if(rs > ri)
+ _animatedImageView.frame = CGRectMake(0.0, 0.0, wi * hs/hi, hs);
+ else
+ _animatedImageView.frame = CGRectMake(0.0, 0.0, ws, hi * ws/wi);
+ self.imageWidth.constant = _animatedImageView.frame.size.width;
+ self.imageHeight.constant = _animatedImageView.frame.size.height;
+ _animatedImageView.animatedImage = image;
+ [self.thumbnailImage addSubview:_animatedImageView];
+ self.thumbnailImage.contentMode = UIViewContentModeScaleAspectFit;
+ }
+ else if(info && [info[@"mimeType"] hasPrefix:@"image/"])
+ {
+ self.link = msg.messageText;
+ AnyPromise* imagePromise = nil;
+ // this code already runs in the main queue --> we can't use PMKHang
+ if([info[@"mimeType"] hasPrefix:@"image/svg"])
+ imagePromise = [HelperTools renderUIImageFromSVGURL:[NSURL fileURLWithPath:info[@"cacheFile"]]];
+ else
+ imagePromise = [AnyPromise promiseWithValue:[[UIImage alloc] initWithContentsOfFile:info[@"cacheFile"]]];
+ imagePromise.then(^(UIImage* image) {
+ if(!nilExtractor(image))
+ return;
+ DDLogVerbose(@"image %@\n--> %fx%f", info, image.size.height, image.size.width);
+ CGFloat wi = image.size.width;
+ CGFloat hi = image.size.height;
+ CGFloat ws = 225.0;
+ CGFloat hs = 200.0;
+ CGFloat ri = wi / hi;
+ CGFloat rs = ws / hs;
+ if(rs > ri)
+ self.thumbnailImage.frame = CGRectMake(0.0, 0.0, wi * hs/hi, hs);
+ else
+ self.thumbnailImage.frame = CGRectMake(0.0, 0.0, ws, hi * ws/wi);
+ self.imageWidth.constant = self.thumbnailImage.frame.size.width;
+ self.imageHeight.constant = self.thumbnailImage.frame.size.height;
+ [self.thumbnailImage setImage:image];
+ }).catch(^(NSError* error) {
+ DDLogWarn(@"Image promise returned an error: %@", error);
+ });
+ }
+ else
+ unreachable();
+ [self.spinner stopAnimating];
+ }
+}
+
+-(UIImage*) getDisplayedImage
+{
+ return self.thumbnailImage.image;
+}
+
+-(void) setSelected:(BOOL) selected animated:(BOOL) animated
+{
+ [super setSelected:selected animated:animated];
+ // Configure the view for the selected state
+}
+
+-(BOOL) canPerformAction:(SEL) action withSender:(id) sender
+{
+ return (action == @selector(copy:));
+}
+
+-(void) copy:(id) sender
+{
+ UIPasteboard* pboard = [UIPasteboard generalPasteboard];
+ pboard.image = [self getDisplayedImage];
+}
+
+-(void) prepareForReuse
+{
+ [super prepareForReuse];
+ self.imageHeight.constant = 200;
+ [self.spinner stopAnimating];
+ if(_animatedImageView != nil)
+ [_animatedImageView removeFromSuperview];
+}
+
+
+@end
diff --git a/Monal/Classes/MLChatInputContainer.h b/Monal/Classes/MLChatInputContainer.h
new file mode 100644
index 0000000..c0481f4
--- /dev/null
+++ b/Monal/Classes/MLChatInputContainer.h
@@ -0,0 +1,27 @@
+//
+// MLChatInputContainer.h
+// Monal
+//
+// Created by Anurodh Pokharel on 1/20/20.
+// Copyright © 2020 Monal.im. All rights reserved.
+//
+
+#import
+#import "MLResizingTextView.h"
+
+@protocol ChatInputActionDelegage
+
+@optional
+- (void) doScrollDownAction;
+
+@end
+
+NS_ASSUME_NONNULL_BEGIN
+
+@interface MLChatInputContainer : UIView
+
+@property (nonatomic, weak) IBOutlet MLResizingTextView* chatInput;
+@property (nonatomic, weak) id chatInputActionDelegate;
+@end
+
+NS_ASSUME_NONNULL_END
diff --git a/Monal/Classes/MLChatInputContainer.m b/Monal/Classes/MLChatInputContainer.m
new file mode 100644
index 0000000..cb57367
--- /dev/null
+++ b/Monal/Classes/MLChatInputContainer.m
@@ -0,0 +1,49 @@
+//
+// MLChatInputContainer.m
+// Monal
+//
+// Created by Anurodh Pokharel on 1/20/20.
+// Copyright © 2020 Monal.im. All rights reserved.
+//
+
+#import "MLChatInputContainer.h"
+
+@implementation MLChatInputContainer
+@synthesize chatInputActionDelegate;
+
+- (instancetype)initWithCoder:(NSCoder *)coder
+{
+ self = [super initWithCoder:coder];
+ if (self) {
+ self.autoresizingMask = UIViewAutoresizingFlexibleHeight;
+ self.chatInput.scrollEnabled = NO;
+ self.chatInput.contentInset = UIEdgeInsetsMake(5, 0, 5, 0);
+ }
+ return self;
+}
+
+- (CGSize)intrinsicContentSize {
+ CGSize size = CGSizeMake(self.bounds.size.width, self.chatInput.intrinsicContentSize.height);
+ return size;
+}
+
+- (UIView*)hitTest:(CGPoint)point withEvent:(UIEvent *)event
+{
+ return [super hitTest:point withEvent:event];
+}
+
+-(BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event
+{
+ NSArray *subViews = self.subviews;
+ for(UIView *subView in subViews) {
+ if (CGRectContainsPoint(subView.frame, point) && subView.frame.origin.y < 0) {
+ DDLogDebug(@"ScrollDown button tapped...");
+ //without async dispatch this would do nothing
+ dispatch_async(dispatch_get_main_queue(), ^{
+ [self.chatInputActionDelegate doScrollDownAction];
+ });
+ }
+ }
+ return [super pointInside:point withEvent:event];
+}
+@end
diff --git a/Monal/Classes/MLChatMapsCell.h b/Monal/Classes/MLChatMapsCell.h
new file mode 100644
index 0000000..2c334fd
--- /dev/null
+++ b/Monal/Classes/MLChatMapsCell.h
@@ -0,0 +1,23 @@
+//
+// MLChatMapsCell.h
+// Monal
+//
+// Created by Friedrich Altheide on 29.03.20.
+// Copyright © 2020 Monal.im. All rights reserved.
+//
+#import
+
+#import "MLBaseCell.h"
+
+
+@interface MLChatMapsCell : MLBaseCell
+
+@property (nonatomic, weak) IBOutlet MKMapView *map;
+
+@property (nonatomic) CLLocationDegrees longitude;
+@property (nonatomic) CLLocationDegrees latitude;
+
+-(void) loadCoordinatesWithCompletion:(void (^)(void))completion;
+
+@end
+
diff --git a/Monal/Classes/MLChatMapsCell.m b/Monal/Classes/MLChatMapsCell.m
new file mode 100644
index 0000000..6f5b8bc
--- /dev/null
+++ b/Monal/Classes/MLChatMapsCell.m
@@ -0,0 +1,68 @@
+//
+// MLChatMapsCell.m
+// Monal
+//
+// Created by Friedrich Altheide on 29.03.20.
+// Copyright © 2020 Monal.im. All rights reserved.
+//
+
+#import "MLChatMapsCell.h"
+#import "MLImageManager.h"
+@import QuartzCore;
+
+@implementation MLChatMapsCell
+
+CLLocationCoordinate2D geoLocation;
+
+- (void)awakeFromNib {
+ [super awakeFromNib];
+ // Initialization code
+ self.map.layer.cornerRadius=15.0f;
+ self.map.layer.masksToBounds=YES;
+}
+
+-(void) loadCoordinatesWithCompletion:(void (^)(void))completion {
+ // Remove old annotations
+ [self.map removeAnnotations:self.map.annotations];
+
+ geoLocation = CLLocationCoordinate2DMake(self.latitude, self.longitude);
+
+ MKPointAnnotation* geoPin = [[MKPointAnnotation alloc]init];
+ geoPin.coordinate = geoLocation;
+ [self.map addAnnotation:geoPin];
+
+ MKCoordinateRegion viewRegion = MKCoordinateRegionMakeWithDistance(geoLocation, 1500, 1500);
+
+ [self.map setRegion:viewRegion animated:FALSE];
+
+ // Init tap handling
+ UITapGestureRecognizer *tapGestureRec = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(handleMapViewGesture)];
+ [self.map addGestureRecognizer:tapGestureRec];
+
+ completion();
+}
+
+-(void) handleMapViewGesture {
+ NSMutableArray* mapItems = [NSMutableArray new];
+
+ MKPlacemark* placemark = [[MKPlacemark alloc]initWithCoordinate:geoLocation];
+ MKMapItem* location = [[MKMapItem alloc]initWithPlacemark:placemark];
+ [location setName:@"📍 A Location"];
+ [mapItems addObject:location];
+
+ NSMutableDictionary* launchOptions = [NSMutableDictionary new];
+ // Open apple maps
+ [MKMapItem openMapsWithItems:mapItems launchOptions:launchOptions];
+}
+
+-(BOOL) canPerformAction:(SEL)action withSender:(id)sender
+{
+ return FALSE;
+}
+
+-(void)prepareForReuse{
+ [super prepareForReuse];
+}
+
+
+@end
diff --git a/Monal/Classes/MLChatViewHelper.h b/Monal/Classes/MLChatViewHelper.h
new file mode 100644
index 0000000..e57fa2e
--- /dev/null
+++ b/Monal/Classes/MLChatViewHelper.h
@@ -0,0 +1,20 @@
+//
+// MLChatViewHelper.h
+// Monal
+//
+// Created by Friedrich Altheide on 04.08.20.
+// Copyright © 2020 Monal.im. All rights reserved.
+//
+
+#import